Merge branch 'master' of github.com:spotify/backstage into shmidt-i/location-update-results

This commit is contained in:
Ivan Shmidt
2020-05-27 10:02:56 +02:00
53 changed files with 1371 additions and 206 deletions
+10 -4
View File
@@ -25,10 +25,16 @@
"morgan": "^1.10.0",
"winston": "^3.2.1",
"yn": "^4.0.0",
"passport": "0.4.1",
"passport-google-oauth20": "2.0.0",
"@types/passport": "1.0.3",
"@types/passport-google-oauth20": "2.0.3"
"passport": "^0.4.1",
"passport-google-oauth20": "^2.0.0",
"passport-oauth2-refresh": "^2.0.0",
"passport-oauth2": "^1.5.0",
"cookie-parser": "^1.4.5",
"@types/passport-oauth2-refresh": "^1.1.1",
"@types/passport": "^1.0.3",
"@types/passport-google-oauth20": "^2.0.3",
"@types/cookie-parser": "^1.4.2",
"@types/passport-oauth2": "^1.4.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
@@ -14,10 +14,11 @@
* limitations under the License.
*/
import { GoogleAuthProvider } from './provider';
import { GoogleAuthProvider, THOUSAND_DAYS_MS } from './provider';
import passport from 'passport';
import express from 'express';
import * as utils from './../utils';
import refresh from 'passport-oauth2-refresh';
const googleAuthProviderConfig = {
provider: 'google',
@@ -51,7 +52,7 @@ describe('GoogleAuthProvider', () => {
});
describe('start authentication handler', () => {
const mockResponse: any = ({} as unknown) as express.Response;
const mockResponse = ({} as unknown) as express.Response;
const mockNext: express.NextFunction = jest.fn();
it('should initiate authenticate request with provided scopes', () => {
@@ -97,6 +98,7 @@ describe('GoogleAuthProvider', () => {
it('should perform logout and respond with 200', () => {
const mockResponse: any = ({
send: jest.fn(),
cookie: jest.fn(),
} as unknown) as express.Response;
const googleAuthProvider = new GoogleAuthProvider(
@@ -110,23 +112,68 @@ describe('GoogleAuthProvider', () => {
googleAuthProvider.logout(mockRequest, mockResponse);
expect(spyResponse).toBeCalledTimes(1);
expect(spyResponse).toBeCalledWith('logout!');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'google-refresh-token',
'',
expect.objectContaining({ maxAge: 0 }),
);
});
});
describe('redirect frame handler', () => {
const mockRequest = ({} as unknown) as express.Request;
const mockResponse: any = ({
send: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const mockNext: express.NextFunction = jest.fn();
it('should call authenticate and post a response ', () => {
it('should call authenticate and post a response', () => {
const spyPostMessage = jest
.spyOn(utils, 'postMessageResponse')
.mockImplementation(() => jest.fn());
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
const cb = callbackFunc as Function;
cb(null, { refreshToken: 'REFRESH_TOKEN' });
return jest.fn();
});
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(spyPassport).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'google-refresh-token',
'REFRESH_TOKEN',
expect.objectContaining({
path: '/auth/google',
sameSite: 'none',
httpOnly: true,
maxAge: THOUSAND_DAYS_MS,
}),
);
});
it('should respond with a error message if no refresh token returned', () => {
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
const cb = callbackFunc as Function;
cb(null, {});
return jest.fn();
});
const spyPostMessage = jest
.spyOn(utils, 'postMessageResponse')
.mockImplementation(() => jest.fn());
const googleAuthProvider = new GoogleAuthProvider(
@@ -134,10 +181,38 @@ describe('GoogleAuthProvider', () => {
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
const callbackFunc = spyPassport.mock.calls[0][1] as Function;
callbackFunc();
expect(spyPassport).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledWith(mockResponse, {
type: 'auth-result',
error: new Error('Missing refresh token'),
});
});
it('should respond with a error message if auth failed', () => {
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
const cb = callbackFunc as Function;
cb(new Error('TokenError'), null);
return jest.fn();
});
const spyPostMessage = jest
.spyOn(utils, 'postMessageResponse')
.mockImplementation(() => jest.fn());
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(spyPassport).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledTimes(1);
expect(spyPostMessage).toBeCalledWith(mockResponse, {
type: 'auth-result',
error: new Error('Google auth failed, Error: TokenError'),
});
});
});
@@ -159,4 +234,159 @@ describe('GoogleAuthProvider', () => {
}).toThrow();
});
});
describe('refresh token handler', () => {
const mockResponse = ({
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
describe('no refresh token cookie', () => {
it('should respond with a 401', () => {
const mockRequest = ({
cookies: jest.fn(),
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Missing session cookie');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
});
describe('refresh token cookie, no scope', () => {
const mockRequest = ({
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
query: {},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
it('should request for a new access token and fail if no access token returned', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb(undefined, undefined, undefined, {});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{},
expect.any(Function),
);
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith(
'Failed to refresh access token',
);
});
it('should request for a new access token and return 401 if any error', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb({ error: 'ERROR' }, undefined, undefined, {});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{},
expect.any(Function),
);
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith(
'Failed to refresh access token',
);
});
it('should fetch and return a new access token', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb(undefined, 'ACCESS_TOKEN', undefined, {
expires_in: 'EXPIRES_IN',
id_token: 'ID_TOKEN',
});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{},
expect.any(Function),
);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith({
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 'EXPIRES_IN',
scope: undefined,
});
});
});
describe('refresh token cookie and scope', () => {
const mockRequest = ({
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
query: {
scope: 'a,b',
},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
it('should fetch and return a new access token with scopes', () => {
const spyRefresh = jest
.spyOn(refresh, 'requestNewAccessToken')
.mockImplementation((_x, _y, _z, callbackFunc) => {
const cb = callbackFunc as Function;
cb(undefined, 'ACCESS_TOKEN', undefined, {
expires_in: 'EXPIRES_IN',
id_token: 'ID_TOKEN',
scope: 'a,b',
});
});
googleAuthProvider.refresh(mockRequest, mockResponse);
expect(spyRefresh).toBeCalledTimes(1);
expect(spyRefresh).toBeCalledWith(
'google',
'REFRESH_TOKEN',
{ scope: 'a,b' },
expect.any(Function),
);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith({
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 'EXPIRES_IN',
scope: 'a,b',
});
});
});
});
});
@@ -15,8 +15,9 @@
*/
import passport from 'passport';
import express from 'express';
import express, { CookieOptions } from 'express';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import refresh from 'passport-oauth2-refresh';
import {
AuthProvider,
AuthProviderRouteHandlers,
@@ -25,6 +26,7 @@ import {
import { postMessageResponse } from './../utils';
import { InputError } from '@backstage/backend-common';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export class GoogleAuthProvider
implements AuthProvider, AuthProviderRouteHandlers {
private readonly providerConfig: AuthProviderConfig;
@@ -53,8 +55,40 @@ export class GoogleAuthProvider
res: express.Response,
next: express.NextFunction,
) {
return passport.authenticate('google', (_, user) => {
postMessageResponse(res, {
return passport.authenticate('google', (err, user) => {
if (err) {
return postMessageResponse(res, {
type: 'auth-result',
error: new Error(`Google auth failed, ${err}`),
});
}
const { refreshToken } = user;
if (!refreshToken) {
return postMessageResponse(res, {
type: 'auth-result',
error: new Error('Missing refresh token'),
});
}
delete user.refreshToken;
const options: CookieOptions = {
maxAge: THOUSAND_DAYS_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${this.providerConfig.provider}`,
httpOnly: true,
};
res.cookie(
`${this.providerConfig.provider}-refresh-token`,
refreshToken,
options,
);
return postMessageResponse(res, {
type: 'auth-result',
payload: user,
});
@@ -62,7 +96,46 @@ export class GoogleAuthProvider
}
async logout(_req: express.Request, res: express.Response) {
res.send('logout!');
const options: CookieOptions = {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${this.providerConfig.provider}`,
httpOnly: true,
};
res.cookie(`${this.providerConfig.provider}-refresh-token`, '', options);
return res.send('logout!');
}
async refresh(req: express.Request, res: express.Response) {
const refreshToken =
req.cookies[`${this.providerConfig.provider}-refresh-token`];
if (!refreshToken) {
return res.status(401).send('Missing session cookie');
}
const scope = req.query.scope?.toString() ?? '';
const refreshTokenRequestParams = scope ? { scope } : {};
return refresh.requestNewAccessToken(
this.providerConfig.provider,
refreshToken,
refreshTokenRequestParams,
(err, accessToken, _refreshToken, params) => {
if (err || !accessToken) {
return res.status(401).send('Failed to refresh access token');
}
return res.send({
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
});
},
);
}
strategy(): passport.Strategy {
@@ -81,6 +154,8 @@ export class GoogleAuthProvider
idToken: params.id_token,
accessToken,
refreshToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
});
},
);
+1 -1
View File
@@ -24,7 +24,7 @@ export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
router.get('/handler/frame', provider.frameHandler);
router.get('/logout', provider.logout);
if (provider.refresh) {
router.get('/refreshToken', provider.refresh);
router.get('/refresh', provider.refresh);
}
return router;
};
+12 -3
View File
@@ -58,16 +58,25 @@ export type AuthProviderFactory = {
new (providerConfig: any): AuthProvider & AuthProviderRouteHandlers;
};
export type AuthInfo = {
profile: passport.Profile;
export type AuthInfoBase = {
accessToken: string;
idToken?: string;
expiresInSeconds?: number;
scope: string;
};
export type AuthInfoWithProfile = AuthInfoBase & {
profile: passport.Profile;
};
export type AuthInfoPrivate = AuthInfoWithProfile & {
refreshToken: string;
};
export type AuthResponse =
| {
type: 'auth-result';
payload: AuthInfo;
payload: AuthInfoBase | AuthInfoWithProfile;
}
| {
type: 'auth-result';
@@ -17,6 +17,9 @@
import express from 'express';
import Router from 'express-promise-router';
import passport from 'passport';
import cookieParser from 'cookie-parser';
import refresh from 'passport-oauth2-refresh';
import OAuth2Strategy from 'passport-oauth2';
import { Logger } from 'winston';
import { providers } from './../providers/config';
import { makeProvider } from '../providers';
@@ -39,6 +42,9 @@ export async function createRouter(
);
logger.info(`Configuring provider: ${providerId}`);
passport.use(strategy);
if (strategy instanceof OAuth2Strategy) {
refresh.use(strategy);
}
providerRouters[providerId] = providerRouter;
}
@@ -52,6 +58,7 @@ export async function createRouter(
router.use(passport.initialize());
router.use(passport.session());
router.use(cookieParser());
for (const providerId in providerRouters) {
if (providerRouters.hasOwnProperty(providerId)) {
+3 -1
View File
@@ -17,6 +17,7 @@
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.6",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -37,9 +38,10 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/lodash": "^4.14.151",
"@types/uuid": "^7.0.3",
"@types/uuid": "^8.0.0",
"@types/yup": "^0.28.2",
"jest-fetch-mock": "^3.0.3",
"supertest": "^4.0.2",
"tsc-watch": "^4.2.3"
},
"files": [
@@ -14,32 +14,47 @@
* limitations under the License.
*/
import { NotFoundError } from '@backstage/backend-common';
import { Database } from '../database';
import { DescriptorEnvelope } from '../ingestion/types';
import { EntitiesCatalog } from './types';
import { EntitiesCatalog, EntityFilters } from './types';
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
constructor(private readonly database: Database) {}
async entities(): Promise<DescriptorEnvelope[]> {
async entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]> {
const items = await this.database.transaction(tx =>
this.database.entities(tx),
this.database.entities(tx, filters),
);
return items.map(i => i.entity);
}
async entity(
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
);
return matches.length ? matches[0].entity : undefined;
}
async entityByName(
kind: string,
name: string,
namespace: string | undefined,
): Promise<DescriptorEnvelope | undefined> {
const item = await this.database.transaction(tx =>
this.database.entity(tx, kind, name, namespace),
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [
{ key: 'kind', values: [kind] },
{ key: 'name', values: [name] },
{
key: 'namespace',
values:
!namespace || namespace === 'default'
? [null, 'default']
: [namespace],
},
]),
);
if (!item) {
throw new NotFoundError('Entity cannot be found');
}
return item.entity;
return matches.length ? matches[0].entity : undefined;
}
}
@@ -30,7 +30,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
return lodash.cloneDeep(this._entities);
}
async entity(
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
const item = this._entities.find(e => uid === e.metadata?.uid);
if (!item) {
throw new NotFoundError('Entity cannot be found');
}
return lodash.cloneDeep(item);
}
async entityByName(
kind: string,
name: string,
namespace: string | undefined,
+11 -4
View File
@@ -19,15 +19,22 @@ import { DescriptorEnvelope } from '../ingestion';
import { DatabaseLocationUpdateLogEvent } from '../database';
//
// Items
// Entities
//
export type EntityFilter = {
key: string;
values: (string | null)[];
};
export type EntityFilters = EntityFilter[];
export type EntitiesCatalog = {
entities(): Promise<DescriptorEnvelope[]>;
entity(
entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]>;
entityByUid(uid: string): Promise<DescriptorEnvelope | undefined>;
entityByName(
kind: string,
name: string,
namespace: string | undefined,
name: string,
): Promise<DescriptorEnvelope | undefined>;
};
@@ -21,6 +21,7 @@ import {
} from '@backstage/backend-common';
import Knex from 'knex';
import path from 'path';
import { DescriptorEnvelope } from '../ingestion';
import { Database } from './Database';
import {
AddDatabaseLocation,
@@ -289,4 +290,112 @@ describe('Database', () => {
).rejects.toThrow(ConflictError);
});
});
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
const catalog = new Database(database, getVoidLogger());
const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' };
const e2: DescriptorEnvelope = {
apiVersion: 'a',
kind: 'b',
spec: { c: null },
};
await catalog.transaction(async tx => {
await catalog.addEntity(tx, { entity: e1 });
await catalog.addEntity(tx, { entity: e2 });
});
const result = await catalog.transaction(async tx =>
catalog.entities(tx, []),
);
expect(result.length).toEqual(2);
expect(result).toEqual(
expect.arrayContaining([
{ locationId: undefined, entity: expect.objectContaining(e1) },
{ locationId: undefined, entity: expect.objectContaining(e2) },
]),
);
});
it('can get all specific entities for matching filters (naive case)', async () => {
const catalog = new Database(database, getVoidLogger());
const entities: DescriptorEnvelope[] = [
{ apiVersion: 'a', kind: 'b' },
{
apiVersion: 'a',
kind: 'b',
spec: { c: 'some' },
},
{
apiVersion: 'a',
kind: 'b',
spec: { c: null },
},
];
await catalog.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
}
});
await expect(
catalog.transaction(async tx =>
catalog.entities(tx, [
{ key: 'kind', values: ['b'] },
{ key: 'spec.c', values: ['some'] },
]),
),
).resolves.toEqual([
{ locationId: undefined, entity: expect.objectContaining(entities[1]) },
]);
});
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
const catalog = new Database(database, getVoidLogger());
const entities: DescriptorEnvelope[] = [
{ apiVersion: 'a', kind: 'b' },
{
apiVersion: 'a',
kind: 'b',
spec: { c: 'some' },
},
{
apiVersion: 'a',
kind: 'b',
spec: { c: null },
},
];
await catalog.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
}
});
const rows = await catalog.transaction(async tx =>
catalog.entities(tx, [
{ key: 'kind', values: ['b'] },
{ key: 'spec.c', values: [null, 'some'] },
]),
);
expect(rows.length).toEqual(3);
expect(rows).toEqual(
expect.arrayContaining([
{
locationId: undefined,
entity: expect.objectContaining(entities[0]),
},
{
locationId: undefined,
entity: expect.objectContaining(entities[1]),
},
{
locationId: undefined,
entity: expect.objectContaining(entities[2]),
},
]),
);
});
});
});
@@ -23,6 +23,7 @@ import Knex from 'knex';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { EntityFilters } from '../catalog';
import { DescriptorEnvelope, EntityMeta } from '../ingestion';
import { buildEntitySearch } from './search';
import {
@@ -187,7 +188,8 @@ export class Database {
}
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = Object.assign({}, newEntity.metadata, {
newEntity.metadata = {
...newEntity.metadata,
uid: generateUid(),
etag: generateEtag(),
generation: 1,
@@ -195,7 +197,7 @@ export class Database {
...(newEntity.metadata?.annotations ?? {}),
'backstage.io/managed-by-location': request.locationId,
},
});
};
const newRow = toEntityRow(request.locationId, newEntity);
await tx<DbEntitiesRow>('entities').insert(newRow);
@@ -280,11 +282,12 @@ export class Database {
? oldRow.generation
: oldRow.generation + 1;
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = Object.assign({}, request.entity.metadata, {
newEntity.metadata = {
...newEntity.metadata,
uid: oldRow.id,
etag: newEtag,
generation: newGeneration,
});
};
// Preserve annotations that were set on the old version of the entity,
// unless the new version overwrites them
@@ -314,10 +317,30 @@ export class Database {
return { locationId: request.locationId, entity: newEntity };
}
async entities(tx: Knex.Transaction<any, any>): Promise<DbEntityResponse[]> {
const rows = await tx<DbEntitiesRow>('entities')
async entities(
tx: Knex.Transaction<any, any>,
filters?: EntityFilters,
): Promise<DbEntityResponse[]> {
let builder = tx<DbEntitiesRow>('entities');
for (const [index, filter] of (filters ?? []).entries()) {
builder = builder
.leftOuterJoin(`entities_search as t${index}`, function join() {
this.on('entities.id', '=', `t${index}.entity_id`).onIn(
`t${index}.value`,
filter.values.filter(x => x),
);
if (filter.values.some(x => !x)) {
this.orOnNull(`t${index}.value`);
}
})
.where(`t${index}.key`, '=', filter.key);
}
const rows = await builder
.orderBy('namespace', 'name')
.select();
.select('entities.*')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
}
@@ -341,9 +364,7 @@ export class Database {
async addLocation(location: AddDatabaseLocation): Promise<DbLocationsRow> {
return await this.database.transaction<DbLocationsRow>(async tx => {
const existingLocation = await tx<DbLocationsRow>('locations')
.where({
target: location.target,
})
.where({ target: location.target })
.select();
if (existingLocation?.[0]) {
@@ -15,6 +15,7 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import Knex from 'knex';
import {
ComponentDescriptor,
DescriptorParser,
@@ -28,7 +29,6 @@ import {
DbLocationsRow,
DbLocationsRowWithStatus,
} from './types';
import Knex from 'knex';
describe('DatabaseManager', () => {
describe('refreshLocations', () => {
@@ -0,0 +1,198 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
import { DescriptorEnvelope } from '../ingestion';
import { createRouter } from './router';
class MockEntitiesCatalog implements EntitiesCatalog {
entities = jest.fn();
entityByUid = jest.fn();
entityByName = jest.fn();
}
class MockLocationsCatalog implements LocationsCatalog {
addLocation = jest.fn();
removeLocation = jest.fn();
locations = jest.fn();
location = jest.fn();
}
describe('createRouter', () => {
describe('entities', () => {
it('happy path: lists entities', async () => {
const entities: DescriptorEnvelope[] = [{ apiVersion: 'a', kind: 'b' }];
const catalog = new MockEntitiesCatalog();
catalog.entities.mockResolvedValueOnce(entities);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities');
expect(response.status).toEqual(200);
expect(response.body).toEqual(entities);
});
it('parses single and multiple request parameters and passes them down', async () => {
const catalog = new MockEntitiesCatalog();
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
expect(response.status).toEqual(200);
expect(catalog.entities).toHaveBeenCalledWith([
{ key: 'a', values: ['1', null, '3'] },
{ key: 'b', values: ['4'] },
{ key: 'c', values: [null] },
]);
});
});
describe('entityByUid', () => {
it('can fetch entity by uid', async () => {
const entity: DescriptorEnvelope = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
},
};
const catalog = new MockEntitiesCatalog();
catalog.entityByUid.mockResolvedValue(entity);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-uid/zzz');
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
const catalog = new MockEntitiesCatalog();
catalog.entityByUid.mockResolvedValue(undefined);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-uid/zzz');
expect(response.status).toEqual(404);
expect(response.text).toMatch(/uid/);
});
});
describe('entityByName', () => {
it('can fetch entity by name', async () => {
const entity: DescriptorEnvelope = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
};
const catalog = new MockEntitiesCatalog();
catalog.entityByName.mockResolvedValue(entity);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-name/b/d/c');
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
const catalog = new MockEntitiesCatalog();
catalog.entityByName.mockResolvedValue(undefined);
const router = await createRouter({
entitiesCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/entities/by-name//b/d/c');
expect(response.status).toEqual(404);
expect(response.text).toMatch(/name/);
});
});
describe('locations', () => {
it('happy path: lists locations', async () => {
const locations: Location[] = [{ id: 'a', type: 'b', target: 'c' }];
const catalog = new MockLocationsCatalog();
catalog.locations.mockResolvedValueOnce(locations);
const router = await createRouter({
locationsCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).get('/locations');
expect(response.status).toEqual(200);
expect(response.body).toEqual(locations);
});
it('rejects malformed locations', async () => {
const location = ({
id: 'a',
typez: 'b',
target: 'c',
} as unknown) as Location;
const catalog = new MockLocationsCatalog();
const router = await createRouter({
locationsCatalog: catalog,
logger: getVoidLogger(),
});
const app = express().use(router);
const response = await request(app).post('/locations').send(location);
expect(response.status).toEqual(400);
});
});
});
+58 -6
View File
@@ -14,12 +14,14 @@
* limitations under the License.
*/
import { errorHandler, InputError } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
addLocationSchema,
EntitiesCatalog,
EntityFilters,
LocationsCatalog,
} from '../catalog';
import { validateRequestBody } from './util';
@@ -34,17 +36,43 @@ export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { entitiesCatalog, locationsCatalog } = options;
const router = Router();
router.use(express.json());
if (entitiesCatalog) {
// Entities
router.get('/entities', async (_req, res) => {
const entities = await entitiesCatalog.entities();
res.status(200).send(entities);
});
router
.get('/entities', async (req, res) => {
const filters = translateQueryToEntityFilters(req);
const entities = await entitiesCatalog.entities(filters);
res.status(200).send(entities);
})
.get('/entities/by-uid/:uid', async (req, res) => {
const { uid } = req.params;
const entity = await entitiesCatalog.entityByUid(uid);
if (!entity) {
res.status(404).send(`No entity with uid ${uid}`);
}
res.status(200).send(entity);
})
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
const { kind, namespace, name } = req.params;
const entity = await entitiesCatalog.entityByName(
kind,
name,
namespace,
);
if (!entity) {
res
.status(404)
.send(
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
);
}
res.status(200).send(entity);
});
}
// Locations
if (locationsCatalog) {
router
.post('/locations', async (req, res) => {
@@ -73,5 +101,29 @@ export async function createRouter(
});
}
router.use(errorHandler());
return router;
}
function translateQueryToEntityFilters(
request: express.Request,
): EntityFilters {
const filters: EntityFilters = [];
for (const [key, valueOrValues] of Object.entries(request.query)) {
const values = Array.isArray(valueOrValues)
? valueOrValues
: [valueOrValues];
if (values.some(v => typeof v !== 'string')) {
throw new InputError('Complex query parameters are not supported');
}
filters.push({
key,
values: values.map(v => v || null) as string[],
});
}
return filters;
}
@@ -0,0 +1,131 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
describe('Catalog Filter', () => {
it('should render the different groups', async () => {
const mockGroups: CatalogFilterGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = render(
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
}
});
it('should render the different items and their names', async () => {
const mockGroups: CatalogFilterGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'first',
label: 'First Label',
},
{
id: 'second',
label: 'Second Label',
},
],
},
];
const { findByText } = render(
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
);
const [group] = mockGroups;
for (const item of group.items) {
expect(await findByText(item.label)).toBeInTheDocument();
}
});
it('should render the count in each item', async () => {
const mockGroups: CatalogFilterGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'first',
label: 'First Label',
count: 100,
},
{
id: 'second',
label: 'Second Label',
count: 400,
},
],
},
];
const { findByText } = render(
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
);
const [group] = mockGroups;
for (const item of group.items) {
expect(await findByText(item.count!.toString())).toBeInTheDocument();
}
});
it('should fire the callback when an item is clicked', async () => {
const mockGroups: CatalogFilterGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'first',
label: 'First Label',
count: 100,
},
{
id: 'second',
label: 'Second Label',
count: 400,
},
],
},
];
const onSelectedChangeHandler = jest.fn();
const { findByText } = render(
wrapInThemedTestApp(
<CatalogFilter
groups={mockGroups}
onSelectedChange={onSelectedChangeHandler}
/>,
),
);
const item = mockGroups[0].items[0];
const element = await findByText(item.label);
fireEvent.click(element);
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item);
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Card,
List,
ListItemIcon,
ListItemText,
MenuItem,
Typography,
Theme,
makeStyles,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
export type CatalogFilterItem = {
id: string;
label: string;
icon?: IconComponent;
count?: number;
loading?: boolean;
};
export type CatalogFilterGroup = {
name: string;
items: CatalogFilterItem[];
};
export type CatalogFilterProps = {
groups: CatalogFilterGroup[];
selectedId?: string;
onSelectedChange?: (item: CatalogFilterItem) => void;
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
backgroundColor: 'rgba(0, 0, 0, .11)',
boxShadow: 'none',
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
listIcon: {
minWidth: 30,
color: theme.palette.text.primary,
},
menuItem: {
minHeight: theme.spacing(6),
},
groupWrapper: {
margin: theme.spacing(1, 1, 2, 1),
},
menuTitle: {
fontWeight: 500,
},
}));
export const CatalogFilter: React.FC<CatalogFilterProps> = ({
groups,
selectedId,
onSelectedChange,
}) => {
const classes = useStyles();
return (
<Card className={classes.root}>
{groups.map(group => (
<React.Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense>
{group.items.map(item => (
<MenuItem
key={item.id}
button
divider
onClick={() => onSelectedChange?.(item)}
selected={item.id === selectedId}
className={classes.menuItem}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography variant="body1" className={classes.menuTitle}>
{item.label}
</Typography>
</ListItemText>
{item.count}
</MenuItem>
))}
</List>
</Card>
</React.Fragment>
))}
</Card>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CatalogFilter } from './CatalogFilter';
@@ -17,8 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import CatalogPage from './CatalogPage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { wrapInThemedTestApp } from '@backstage/test-utils';
import { ComponentFactory } from '../../data/component';
const testComponentFactory: ComponentFactory = {
@@ -28,11 +27,14 @@ const testComponentFactory: ComponentFactory = {
};
describe('CatalogPage', () => {
// this test right now causes some red lines in the log output when running tests
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<CatalogPage componentFactory={testComponentFactory} />
</ThemeProvider>,
wrapInThemedTestApp(
<CatalogPage componentFactory={testComponentFactory} />,
),
);
expect(
await rendered.findByText('Keep track of your software'),
@@ -27,13 +27,38 @@ import {
import { useAsync } from 'react-use';
import { ComponentFactory } from '../../data/component';
import CatalogTable from '../CatalogTable/CatalogTable';
import { Button } from '@material-ui/core';
import {
CatalogFilter,
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { Button, makeStyles } from '@material-ui/core';
import { filterGroups, defaultFilter } from '../../data/filters';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
}));
type CatalogPageProps = {
componentFactory: ComponentFactory;
};
const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
defaultFilter,
);
const onFilterSelected = React.useCallback(
selected => setSelectedFilter(selected),
[],
);
const styles = useStyles();
return (
<Page theme={pageTheme.home}>
<Header title="Service Catalog" subtitle="Keep track of your software">
@@ -46,11 +71,21 @@ const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
</Button>
<SupportButton>All your components</SupportButton>
</ContentHeader>
<CatalogTable
components={value || []}
loading={loading}
error={error}
/>
<div className={styles.contentWrapper}>
<div>
<CatalogFilter
groups={filterGroups}
selectedId={selectedFilter.id}
onSelectedChange={onFilterSelected}
/>
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={value || []}
loading={loading}
error={error}
/>
</div>
</Content>
</Page>
);
@@ -28,7 +28,9 @@ const components: Component[] = [
describe('CatalogTable component', () => {
it('should render loading when loading prop it set to true', async () => {
const rendered = render(
wrapInThemedTestApp(<CatalogTable components={[]} loading />),
wrapInThemedTestApp(
<CatalogTable titlePreamble="Owned" components={[]} loading />,
),
);
const progress = await rendered.findByTestId('progress');
expect(progress).toBeInTheDocument();
@@ -38,6 +40,7 @@ describe('CatalogTable component', () => {
const rendered = render(
wrapInThemedTestApp(
<CatalogTable
titlePreamble="Owned"
components={[]}
loading={false}
error={{ code: 'error' }}
@@ -53,9 +56,16 @@ describe('CatalogTable component', () => {
it('should display component names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInThemedTestApp(
<CatalogTable components={components} loading={false} />,
<CatalogTable
titlePreamble="Owned"
components={components}
loading={false}
/>,
),
);
expect(
await rendered.findByText(`Owned (${components.length})`),
).toBeInTheDocument();
expect(await rendered.findByText('component1')).toBeInTheDocument();
expect(await rendered.findByText('component2')).toBeInTheDocument();
expect(await rendered.findByText('component3')).toBeInTheDocument();
@@ -63,6 +63,7 @@ const columns: TableColumn[] = [
type CatalogTableProps = {
components: Component[];
titlePreamble: string;
loading: boolean;
error?: any;
};
@@ -70,6 +71,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
components,
loading,
error,
titlePreamble,
}) => {
if (loading) {
return <Progress />;
@@ -87,7 +89,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
<Table
columns={columns}
options={{ paging: false }}
title={`Owned (${(components && components.length) || 0})`}
title={`${titlePreamble} (${(components && components.length) || 0})`}
data={components}
/>
);
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: 'owned',
label: 'Owned',
count: 123,
icon: SettingsIcon,
},
{
id: 'starred',
label: 'Starred',
count: 10,
icon: StarIcon,
},
],
},
{
name: 'Spotify',
items: [
{
id: 'all',
label: 'All Services',
count: 123,
},
],
},
];
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];