Merge branch 'master' of github.com:spotify/backstage into blam/msw

* 'master' of github.com:spotify/backstage: (63 commits)
  packages/cli: add lint rule to avoid cross-package src imports
  fix(catalog-backend): make location delete work again
  Add Other component category (#1292)
  packages/core-api: add initial definition of IdentityApi
  additional sqli protection
  build(deps): bump eslint from 7.1.0 to 7.2.0 (#1285)
  return 204 when there's no response data
  build(deps-dev): bump @types/codemirror from 0.0.95 to 0.0.96 (#1284)
  build(deps-dev): bump @types/morgan from 1.9.0 to 1.9.1 (#1287)
  build(deps): bump chalk from 4.0.0 to 4.1.0 (#1286)
  catalog: suffix entiy kind typeswith Entity
  feat(catalog): new kind LocationRef
  packages/cli: assume browser build if esm output is included
  packages/backend-common: import winston as namespace
  plugins/catalog-backend: typecheck migration files
  plugins/catalog-backend: move migrations to JS with type annotations
  packages/cli: fix jest module mapper to only match entire module name
  github/workflows: use env app config to set e2e test port
  packages/cli: use app.baseUrl to configure dev server
  packages/config-loader: more docs!
  ...
This commit is contained in:
blam
2020-06-15 14:53:24 +02:00
131 changed files with 2380 additions and 977 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
@@ -39,7 +39,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/body-parser": "^1.19.0",
"@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3",
@@ -0,0 +1,62 @@
/*
* 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 express from 'express';
import { AuthProviderRouteHandlers } from '../providers/types';
import { NotFoundError } from '@backstage/backend-common';
export type EnvironmentHandlers = {
[key: string]: AuthProviderRouteHandlers;
};
export class EnvironmentHandler implements AuthProviderRouteHandlers {
constructor(private readonly providers: EnvironmentHandlers) {}
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
const env = req.query.env?.toString();
if (!this.providers.hasOwnProperty(env)) {
throw new NotFoundError(
`No environment for ${env} found in this provider`,
);
}
return this.providers[env];
}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.start(req, res);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
if (provider.refresh) {
provider.refresh(req, res);
}
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.logout(req, res);
}
}
@@ -18,15 +18,12 @@ import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
removeRefreshTokenCookie,
setRefreshTokenCookie,
THOUSAND_DAYS_MS,
setNonceCookie,
TEN_MINUTES_MS,
verifyNonce,
OAuthProvider,
} from './OAuthProvider';
import { AuthResponse, OAuthProviderHandlers } from './types';
import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
@@ -80,52 +77,8 @@ describe('OAuthProvider Utils', () => {
});
});
describe('setNonceCookie', () => {
it('should set nonce cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setNonceCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-nonce',
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
});
});
describe('setRefreshTokenCookie', () => {
it('should set refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'REFRESH_TOKEN',
expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }),
);
});
});
describe('removeRefreshTokenCookie', () => {
it('should remove refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
removeRefreshTokenCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'',
expect.objectContaining({ maxAge: 0 }),
);
});
});
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
@@ -144,7 +97,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -165,7 +118,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -221,10 +174,19 @@ describe('OAuthProvider', () => {
}
}
const providerInstance = new MyAuthProvider();
const providerId = 'test-provider';
const oAuthProviderOptions = {
providerId: 'test-provider',
secure: false,
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(
providerInstance,
oAuthProviderOptions,
);
const mockRequest = ({
query: {
scope: 'user',
@@ -239,6 +201,14 @@ describe('OAuthProvider', () => {
} as unknown) as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
// nonce cookie checks
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
// redirect checks
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
@@ -247,7 +217,10 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
cookies: {
@@ -269,12 +242,18 @@ describe('OAuthProvider', () => {
expect(mockResponse.cookie).toHaveBeenCalledWith(
expect.stringContaining('test-provider-refresh-token'),
expect.stringContaining('token'),
expect.objectContaining({ path: '/auth/test-provider' }),
expect.objectContaining({
path: '/auth/test-provider',
maxAge: THOUSAND_DAYS_MS,
}),
);
});
it('does no set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
cookies: {
@@ -296,7 +275,10 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -317,7 +299,11 @@ describe('OAuthProvider', () => {
});
it('gets new access-token when refreshing', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -341,7 +327,10 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -14,20 +14,29 @@
* limitations under the License.
*/
import express, { CookieOptions } from 'express';
import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
AuthResponse,
AuthProviderRouteHandlers,
OAuthProviderHandlers,
} from './types';
} from '../providers/types';
import { InputError } from '@backstage/backend-common';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
export const verifyNonce = (req: express.Request, provider: string) => {
const cookieNonce = req.cookies[`${provider}-nonce`];
export type Options = {
providerId: string;
secure: boolean;
disableRefresh?: boolean;
baseUrl: string;
appOrigin: string;
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const stateNonce = req.query.state;
if (!cookieNonce || !stateNonce) {
@@ -39,58 +48,9 @@ export const verifyNonce = (req: express.Request, provider: string) => {
}
};
export const setNonceCookie = (res: express.Response, provider: string) => {
const nonce = crypto.randomBytes(16).toString('base64');
const options: CookieOptions = {
maxAge: TEN_MINUTES_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}/handler`,
httpOnly: true,
};
res.cookie(`${provider}-nonce`, nonce, options);
return nonce;
};
export const setRefreshTokenCookie = (
res: express.Response,
provider: string,
refreshToken: string,
) => {
const options: CookieOptions = {
maxAge: THOUSAND_DAYS_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, refreshToken, options);
};
export const removeRefreshTokenCookie = (
res: express.Response,
provider: string,
) => {
const options: CookieOptions = {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, '', options);
};
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
data: AuthResponse,
) => {
const jsonData = JSON.stringify(data);
@@ -104,7 +64,7 @@ export const postMessageResponse = (
<html>
<body>
<script>
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), 'http://localhost:3000')
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
</script>
</body>
@@ -122,17 +82,16 @@ export const ensuresXRequestedWith = (req: express.Request) => {
};
export class OAuthProvider implements AuthProviderRouteHandlers {
private readonly provider: string;
private readonly providerHandlers: OAuthProviderHandlers;
private readonly disableRefresh: boolean;
private readonly domain: string;
private readonly basePath: string;
constructor(
providerHandlers: OAuthProviderHandlers,
provider: string,
disableRefresh?: boolean,
private readonly providerHandlers: OAuthProviderHandlers,
private readonly options: Options,
) {
this.provider = provider;
this.providerHandlers = providerHandlers;
this.disableRefresh = disableRefresh ?? false;
const url = new URL(options.baseUrl);
this.domain = url.hostname;
this.basePath = url.pathname;
}
async start(req: express.Request, res: express.Response): Promise<any> {
@@ -143,8 +102,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
throw new InputError('missing scope parameter');
}
const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
const nonce = setNonceCookie(res, this.provider);
this.setNonceCookie(res, nonce);
const options = {
scope,
@@ -152,6 +112,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
prompt: 'consent',
state: nonce,
};
const { url, status } = await this.providerHandlers.start(req, options);
res.statusCode = status || 302;
@@ -166,11 +127,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
): Promise<any> {
try {
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.provider);
verifyNonce(req, this.options.providerId);
const { user, info } = await this.providerHandlers.handler(req);
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// throw error if missing refresh token
const { refreshToken } = info;
if (!refreshToken) {
@@ -178,17 +139,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
// set new refresh token
setRefreshTokenCookie(res, this.provider, refreshToken);
this.setRefreshTokenCookie(res, refreshToken);
}
// post message back to popup if successful
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
payload: user,
});
} catch (error) {
// post error message back to popup if failure
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
error: {
name: error.name,
@@ -203,9 +164,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
removeRefreshTokenCookie(res, this.provider);
this.removeRefreshTokenCookie(res);
}
return res.send('logout!');
}
@@ -215,14 +176,15 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.providerHandlers.refresh || this.disableRefresh) {
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
return res.send(
`Refresh token not supported for provider: ${this.provider}`,
`Refresh token not supported for provider: ${this.options.providerId}`,
);
}
try {
const refreshToken = req.cookies[`${this.provider}-refresh-token`];
const refreshToken =
req.cookies[`${this.options.providerId}-refresh-token`];
// throw error if refresh token is missing in the request
if (!refreshToken) {
@@ -241,4 +203,40 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send(`${error.message}`);
}
}
private setNonceCookie = (res: express.Response, nonce: string) => {
res.cookie(`${this.options.providerId}-nonce`, nonce, {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
httpOnly: true,
});
};
private setRefreshTokenCookie = (
res: express.Response,
refreshToken: string,
) => {
res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, {
maxAge: THOUSAND_DAYS_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
private removeRefreshTokenCookie = (res: express.Response) => {
res.cookie(`${this.options.providerId}-refresh-token`, '', {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: `${this.domain}`,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
}
@@ -17,7 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
@@ -1,43 +0,0 @@
/*
* 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 const providers = [
{
provider: 'google',
options: {
clientID: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/google/handler/frame',
},
},
{
provider: 'github',
options: {
clientID: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/github/handler/frame',
},
disableRefresh: true,
},
{
provider: 'saml',
options: {
path: '/auth/saml/handler/frame',
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
];
@@ -19,6 +19,7 @@ import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -26,17 +27,18 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
saml: createSamlProvider,
};
export function createAuthProvider(providerId: string, config: any) {
export const createAuthProviderRouter = (
providerId: string,
globalConfig: AuthProviderConfig,
providerConfig: any, // TODO: make this a config reader object of sorts
logger: Logger,
) => {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
return factory(config);
}
export const createAuthProviderRouter = (config: AuthProviderConfig) => {
const providerId = config.provider;
const provider = createAuthProvider(providerId, config);
const provider = factory(globalConfig, providerConfig, logger);
const router = Router();
router.get('/start', provider.start.bind(provider));
@@ -46,5 +48,6 @@ export const createAuthProviderRouter = (config: AuthProviderConfig) => {
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};
@@ -19,24 +19,30 @@ import { Strategy as GithubStrategy } from 'passport-github2';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../PassportStrategyHelper';
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
AuthInfoBase,
AuthInfoPrivate,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class GithubAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GithubStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: OAuthProviderOptions) {
this._strategy = new GithubStrategy(
{ ...this.providerConfig.options },
{ ...options },
(accessToken: any, _: any, params: any, profile: any, done: any) => {
done(undefined, {
profile,
@@ -59,8 +65,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
}
}
export function createGithubProvider(config: AuthProviderConfig) {
const provider = new GithubAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider, true);
return oauthProvider;
export function createGithubProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as OAuthProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/github/handler/frame${callbackURLParam}`,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars',
);
}
logger.warn(
'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
providerId: 'github',
secure,
baseUrl,
appOrigin,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -22,7 +22,7 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../PassportStrategyHelper';
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthInfoBase,
@@ -30,19 +30,27 @@ import {
RedirectInfo,
AuthProviderConfig,
AuthInfoWithProfile,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
import { OAuthProvider } from '../../lib/OAuthProvider';
import passport from 'passport';
import {
EnvironmentHandler,
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GoogleStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: OAuthProviderOptions) {
// TODO: throw error if env variables not set?
this._strategy = new GoogleStrategy(
{ ...this.providerConfig.options },
// We need passReqToCallback set to false to get params, but there's
// no matching type signature for that, so instead behold this beauty
{ ...options, passReqToCallback: false as true },
(
accessToken: any,
refreshToken: any,
@@ -104,8 +112,42 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
}
}
export function createGoogleProvider(config: AuthProviderConfig) {
const provider = new GoogleAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider);
return oauthProvider;
export function createGoogleProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as OAuthProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/google/handler/frame${callbackURLParam}`,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars',
);
}
logger.warn(
'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), {
providerId: 'google',
secure,
baseUrl,
appOrigin,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -19,16 +19,26 @@ import { Strategy as SamlStrategy } from 'passport-saml';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../PassportStrategyHelper';
import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types';
import { postMessageResponse } from '../OAuthProvider';
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
AuthProviderRouteHandlers,
EnvironmentProviderConfig,
SAMLProviderConfig,
} from '../types';
import { postMessageResponse } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class SamlAuthProvider implements AuthProviderRouteHandlers {
private readonly strategy: SamlStrategy;
constructor(providerConfig: AuthProviderConfig) {
constructor(options: SAMLProviderOptions) {
this.strategy = new SamlStrategy(
{ ...providerConfig.options },
{ ...options },
(profile: any, done: any) => {
// TODO: There's plenty more validation and profile handling to do here,
// this provider is currently only intended to validate the provider pattern
@@ -57,12 +67,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
try {
const { user } = await executeFrameHandlerStrategy(req, this.strategy);
return postMessageResponse(res, {
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
payload: user,
});
} catch (error) {
return postMessageResponse(res, {
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
error: {
name: error.name,
@@ -77,6 +87,36 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
}
}
export function createSamlProvider(config: AuthProviderConfig) {
return new SamlAuthProvider(config);
type SAMLProviderOptions = {
entryPoint: string;
issuer: string;
path: string;
};
export function createSamlProvider(
_authProviderConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as SAMLProviderConfig;
const opts = {
entryPoint: config.entryPoint,
issuer: config.issuer,
path: '/auth/saml/handler/frame',
};
if (!opts.entryPoint || !opts.issuer) {
logger.warn(
'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable',
);
continue;
}
envProviders[env] = new SamlAuthProvider(opts);
}
return new EnvironmentHandler(envProviders);
}
+31 -4
View File
@@ -15,11 +15,32 @@
*/
import express from 'express';
import { Logger } from 'winston';
export type OAuthProviderOptions = {
clientID: string;
clientSecret: string;
callbackURL: string;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type EnvironmentProviderConfig = {
[key: string]: OAuthProviderConfig | SAMLProviderConfig;
};
export type AuthProviderConfig = {
provider: string;
options: any;
disableRefresh?: boolean;
baseUrl: string;
};
export type OAuthProviderConfig = {
secure: boolean;
appOrigin: string; // http://localhost:3000
clientId: string;
clientSecret: string;
};
export interface OAuthProviderHandlers {
@@ -36,8 +57,14 @@ export interface AuthProviderRouteHandlers {
logout(req: express.Request, res: express.Response): Promise<any>;
}
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
export type AuthProviderFactory = (
config: AuthProviderConfig,
globalConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
+55 -8
View File
@@ -19,7 +19,6 @@ import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import { Logger } from 'winston';
import { providers } from './../providers/config';
import { createAuthProviderRouter } from '../providers';
export interface RouterOptions {
@@ -36,13 +35,61 @@ export async function createRouter(
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
// configure all the providers
for (const providerConfig of providers) {
const { provider } = providerConfig;
const providerRouter = createAuthProviderRouter(providerConfig);
logger.info(`Configuring provider, ${provider}`);
router.use(`/${provider}`, providerRouter);
}
// TODO: read from app config
const config = {
backend: {
baseUrl: 'http://localhost:7000',
},
auth: {
providers: {
google: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
},
production: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: '',
clientSecret: '',
},
},
github: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
},
},
saml: {
development: {
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
},
},
};
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${config.backend.baseUrl}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
providerId,
{ baseUrl },
providerConfig,
logger,
);
router.use(`/${providerId}`, providerRouter);
} catch (e) {
logger.error(e.message);
}
}
return router;
}
+22 -6
View File
@@ -1,16 +1,32 @@
# Catalog Backend
WORK IN PROGRESS
This is the backend part of the default catalog plugin.
It responds to requests from the frontend part, and fulfills them by delegating
to your existing catalog related services.
It comes with a builtin database backed implementation of the catalog, that can store
and serve your catalog for you.
It can also act as a bridge to your existing catalog solutions, either ingesting their
data to store in the database, or by effectively proxying calls to an external catalog
service.
## Getting Started
After starting the backend, you can issue the `yarn mock-catalog-data` command
in this directory to populate the catalog with some mock entities.
This backend plugin can be started in a standalone mode from directly in this package
with `yarn start`. However, it will have limited functionality and that process is
most convenient when developing the catalog backend plugin itself.
To evaluate the catalog and have a greater amount of functionality available, instead do
```bash
# in one terminal window, run this from from the very root of the Backstage project
cd packages/backend
yarn start
# open another terminal window, and run the following from the very root of the Backstage project
yarn lerna run mock-catalog-data
```
This will launch the full example backend and populate its catalog with some mock entities.
## Links
@@ -0,0 +1,8 @@
---
apiVersion: backstage.io/v1beta1
kind: Location
metadata:
name: location-1
spec:
type: github
target: https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml
@@ -14,9 +14,12 @@
* limitations under the License.
*/
import * as Knex from 'knex';
// @ts-check
export async function up(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return (
knex.schema
//
@@ -114,9 +117,12 @@ export async function up(knex: Knex): Promise<any> {
.comment('The corresponding value to match on');
})
);
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema
.dropTable('entities_search')
.alterTable('entities', table => {
@@ -124,4 +130,4 @@ export async function down(knex: Knex): Promise<any> {
})
.dropTable('entities')
.dropTable('locations');
}
};
@@ -13,16 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Knex from 'knex';
export async function up(knex: Knex): Promise<any> {
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('location_update_log', table => {
table.uuid('id').primary();
table.enum('status', ['success', 'fail']).notNullable();
table
.dateTime('created_at')
.defaultTo(knex.fn.now())
.notNullable();
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable();
table.string('message');
table
.uuid('location_id')
@@ -32,8 +33,11 @@ export async function up(knex: Knex): Promise<any> {
.onDelete('CASCADE');
table.string('entity_name').nullable();
});
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTableIfExists('location_update_log');
}
};
@@ -13,9 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Knex from 'knex';
export async function up(knex: Knex): Promise<any> {
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
// Need to first order by date of creation
const query = knex
.select()
@@ -28,8 +32,11 @@ export async function up(knex: Knex): Promise<any> {
await knex.schema.raw(
`CREATE VIEW location_update_log_latest AS ${groupedQuery.toString()};`,
);
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.raw(`DROP VIEW location_update_log_latest;`);
}
};
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -16,8 +16,8 @@
"mock-catalog-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -34,7 +34,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
@@ -45,7 +45,8 @@
"tsc-watch": "^4.2.3"
},
"files": [
"dist"
"dist",
"migrations"
],
"nodemonConfig": {
"watch": "./dist"
@@ -14,29 +14,14 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import Knex from 'knex';
import path from 'path';
import { CommonDatabase } from '../database';
import { DatabaseManager } from '../database';
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
describe('DatabaseLocationsCatalog', () => {
let catalog: DatabaseLocationsCatalog;
beforeEach(async () => {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
await knex.migrate.latest({
directory: path.resolve(__dirname, '../database/migrations'),
loadExtensions: ['.ts'],
});
const db = new CommonDatabase(knex, getVoidLogger());
const db = await DatabaseManager.createTestDatabase();
catalog = new DatabaseLocationsCatalog(db);
});
@@ -14,16 +14,10 @@
* limitations under the License.
*/
import {
ConflictError,
getVoidLogger,
NotFoundError,
} from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { CommonDatabase } from './CommonDatabase';
import { DatabaseLocationUpdateLogStatus } from './types';
import { DatabaseManager } from './DatabaseManager';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
import type {
DbEntityRequest,
DbEntityResponse,
@@ -31,22 +25,12 @@ import type {
} from './types';
describe('CommonDatabase', () => {
let knex: Knex;
let db: Database;
let entityRequest: DbEntityRequest;
let entityResponse: DbEntityResponse;
beforeEach(async () => {
knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
await knex.raw('PRAGMA foreign_keys = ON');
await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.ts'],
});
db = await DatabaseManager.createTestDatabase();
entityRequest = {
entity: {
@@ -84,7 +68,6 @@ describe('CommonDatabase', () => {
});
it('manages locations', async () => {
const db = new CommonDatabase(knex, getVoidLogger());
const input: Location = {
id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
@@ -115,55 +98,76 @@ describe('CommonDatabase', () => {
describe('addEntity', () => {
it('happy path: adds entity to empty database', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
expect(added).toStrictEqual(entityResponse);
expect(added.entity.metadata.generation).toBe(1);
});
it('rejects adding the same-named entity twice', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
await db.transaction(tx => db.addEntity(tx, entityRequest));
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-kind entity twice', async () => {
entityRequest.entity.kind = 'some-kind';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.kind = 'SomeKind';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-named entity twice', async () => {
entityRequest.entity.metadata.name = 'some-name';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.name = 'SomeName';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-namespace entity twice', async () => {
entityRequest.entity.metadata.namespace = undefined;
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = '';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
entityRequest.entity.metadata.namespace = 'namespace1';
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = 'namespace2';
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntity(tx, entityRequest)),
).resolves.toBeDefined();
});
});
describe('locationHistory', () => {
it('outputs the history correctly', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const location: Location = {
id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
target: 'b',
};
await catalog.addLocation(location);
await db.addLocation(location);
await catalog.addLocationUpdateLogEvent(
await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.SUCCESS,
);
await catalog.addLocationUpdateLogEvent(
await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.FAIL,
undefined,
'Something went wrong',
);
const result = await catalog.locationHistory(
const result = await db.locationHistory(
'dd12620d-0436-422f-93bd-929aa0788123',
);
expect(result).toEqual([
@@ -189,12 +193,9 @@ describe('CommonDatabase', () => {
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
expect(updated.entity.kind).toEqual(added.entity.kind);
@@ -211,77 +212,55 @@ describe('CommonDatabase', () => {
});
it('can update name if uid matches', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.name! = 'new!';
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.metadata.name).toEqual('new!');
});
it('can update fields if kind, name, and namespace match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual('something.new');
});
it('rejects if kind, name, but not namespace match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
added.entity.metadata.namespace = 'something.wrong';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(NotFoundError);
});
it('fails to update an entity if etag does not match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.etag = 'garbage';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
it('fails to update an entity if generation does not match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.generation! += 100;
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
});
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const e1: Entity = {
apiVersion: 'a',
kind: 'k1',
@@ -293,13 +272,11 @@ describe('CommonDatabase', () => {
metadata: { name: 'n' },
spec: { c: null },
};
await catalog.transaction(async tx => {
await catalog.addEntity(tx, { entity: e1 });
await catalog.addEntity(tx, { entity: e2 });
await db.transaction(async tx => {
await db.addEntity(tx, { entity: e1 });
await db.addEntity(tx, { entity: e2 });
});
const result = await catalog.transaction(async tx =>
catalog.entities(tx, []),
);
const result = await db.transaction(async tx => db.entities(tx, []));
expect(result.length).toEqual(2);
expect(result).toEqual(
expect.arrayContaining([
@@ -316,7 +293,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters (naive case)', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -333,15 +309,15 @@ describe('CommonDatabase', () => {
},
];
await catalog.transaction(async tx => {
await db.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
await db.addEntity(tx, { entity });
}
});
await expect(
catalog.transaction(async tx =>
catalog.entities(tx, [
db.transaction(async tx =>
db.entities(tx, [
{ key: 'kind', values: ['k2'] },
{ key: 'spec.c', values: ['some'] },
]),
@@ -355,7 +331,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -372,14 +347,14 @@ describe('CommonDatabase', () => {
},
];
await catalog.transaction(async tx => {
await db.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
await db.addEntity(tx, { entity });
}
});
const rows = await catalog.transaction(async tx =>
catalog.entities(tx, [
const rows = await db.transaction(async tx =>
db.entities(tx, [
{ key: 'apiVersion', values: ['a'] },
{ key: 'spec.c', values: [null, 'some'] },
]),
@@ -43,7 +43,6 @@ function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
delete output.uid;
delete output.etag;
delete output.generation;
return output;
}
@@ -70,7 +69,7 @@ function toEntityRow(
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name || null,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: serializeMetadata(entity.metadata),
spec: serializeSpec(entity.spec),
@@ -124,6 +123,7 @@ function generateEtag(): string {
export class CommonDatabase implements Database {
constructor(
private readonly database: Knex,
private readonly normalize: (value: string) => string,
private readonly logger: Logger,
) {}
@@ -158,6 +158,8 @@ export class CommonDatabase implements Database {
throw new InputError('May not specify generation for new entities');
}
await this.ensureNoSimilarNames(tx, request.entity);
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
@@ -255,6 +257,8 @@ export class CommonDatabase implements Database {
}
}
await this.ensureNoSimilarNames(tx, newEntity);
// Store the updated entity; select on the old etag to ensure that we do
// not lose to another writer
const newRow = toEntityRow(request.locationId, newEntity);
@@ -278,23 +282,50 @@ export class CommonDatabase implements Database {
const tx = txOpaque as Knex.Transaction<any, any>;
let builder = tx<DbEntitiesRow>('entities');
for (const [index, filter] of (filters ?? []).entries()) {
for (const [indexU, filter] of (filters ?? []).entries()) {
const index = Number(indexU);
const key = filter.key.replace('*', '%');
const keyOp = filter.key.includes('*') ? 'like' : '=';
let matchNulls = false;
const matchIn: string[] = [];
const matchLike: string[] = [];
for (const value of filter.values) {
if (!value) {
matchNulls = true;
} else if (value.includes('*')) {
matchLike.push(value.replace('*', '%'));
} else {
matchIn.push(value);
}
}
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`);
}
.leftOuterJoin(`entities_search as t${index}`, function joins() {
this.on('entities.id', '=', `t${index}.entity_id`);
this.andOn(`t${index}.key`, keyOp, tx.raw('?', [key]));
})
.where(`t${index}.key`, '=', filter.key);
.where(function rules() {
if (matchIn.length) {
this.orWhereIn(`t${index}.value`, matchIn);
}
if (matchLike.length) {
for (const x of matchLike) {
this.orWhere(`t${index}.value`, 'like', tx.raw('?', [x]));
}
}
if (matchNulls) {
this.orWhereNull(`t${index}.value`);
}
});
}
const rows = await builder
.orderBy('namespace', 'name')
.select('entities.*')
.orderBy('kind', 'asc')
.orderBy('namespace', 'asc')
.orderBy('name', 'asc')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
@@ -359,6 +390,10 @@ export class CommonDatabase implements Database {
async removeLocation(txOpaque: unknown, id: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
await tx<DbEntitiesRow>('entities')
.where({ location_id: id })
.update({ location_id: null });
const result = await tx<DbLocationsRow>('locations').where({ id }).del();
if (!result) {
@@ -447,4 +482,46 @@ export class CommonDatabase implements Database {
// we got around to writing the entries
}
}
private async ensureNoSimilarNames(
tx: Knex.Transaction<any, any>,
data: Entity,
): Promise<void> {
const newKind = data.kind;
const newName = data.metadata.name;
const newNamespace = data.metadata.namespace;
const newKindNorm = this.normalize(newKind);
const newNameNorm = this.normalize(newName);
const newNamespaceNorm = this.normalize(newNamespace || '');
for (const item of await this.entities(tx)) {
if (data.metadata.uid === item.entity.metadata.uid) {
continue;
}
const oldKind = item.entity.kind;
const oldName = item.entity.metadata.name;
const oldNamespace = item.entity.metadata.namespace;
const oldKindNorm = this.normalize(oldKind);
const oldNameNorm = this.normalize(oldName);
const oldNamespaceNorm = this.normalize(oldNamespace || '');
if (
oldKindNorm === newKindNorm &&
oldNameNorm === newNameNorm &&
oldNamespaceNorm === newNamespaceNorm
) {
// Only throw if things were actually different - for completely equal
// things, we let the database handle the conflict
if (
oldKind !== newKind ||
oldName !== newName ||
oldNamespace !== newNamespace
) {
const message = `Kind, namespace, name are too similar to an existing entity`;
throw new ConflictError(message);
}
}
}
}
}
@@ -14,26 +14,43 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { makeValidator } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-catalog-backend/package.json'),
'../migrations',
);
export type CreateDatabaseOptions = {
logger: Logger;
fieldNormalizer: (value: string) => string;
};
const defaultOptions: CreateDatabaseOptions = {
logger: getVoidLogger(),
fieldNormalizer: makeValidator().normalizeEntityName,
};
export class DatabaseManager {
public static async createDatabase(
knex: Knex,
logger: Logger,
options: Partial<CreateDatabaseOptions> = {},
): Promise<Database> {
await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.js'],
directory: migrationsDir,
});
return new CommonDatabase(knex, logger);
const { logger, fieldNormalizer } = { ...defaultOptions, ...options };
return new CommonDatabase(knex, fieldNormalizer, logger);
}
public static async createInMemoryDatabase(
logger: Logger,
options: Partial<CreateDatabaseOptions> = {},
): Promise<Database> {
const knex = Knex({
client: 'sqlite3',
@@ -43,6 +60,22 @@ export class DatabaseManager {
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return DatabaseManager.createDatabase(knex, logger);
return DatabaseManager.createDatabase(knex, options);
}
public static async createTestDatabase(): Promise<Database> {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
await knex.migrate.latest({
directory: migrationsDir,
});
const { logger, fieldNormalizer } = defaultOptions;
return new CommonDatabase(knex, fieldNormalizer, logger);
}
}
@@ -26,6 +26,7 @@ import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEn
import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor';
import { FileReaderProcessor } from './processors/FileReaderProcessor';
import { GithubReaderProcessor } from './processors/GithubReaderProcessor';
import { LocationRefProcessor } from './processors/LocationEntityProcessor';
import * as result from './processors/results';
import {
LocationProcessor,
@@ -57,6 +58,7 @@ export class LocationReaders implements LocationReader {
new GithubReaderProcessor(),
new YamlProcessor(),
new EntityPolicyProcessor(entityPolicy),
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
];
}
@@ -0,0 +1,46 @@
/*
* 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 { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
export class LocationRefProcessor implements LocationProcessor {
async processEntity(
entity: Entity,
_location: LocationSpec,
emit: LocationProcessorEmit,
): Promise<Entity> {
if (entity.kind === 'Location') {
const location = entity as LocationEntity;
if (location.spec.target) {
emit(
result.location(
{ type: location.spec.type, target: location.spec.target },
false,
),
);
}
if (location.spec.targets) {
for (const target of location.spec.targets) {
emit(result.location({ type: location.spec.type, target }, false));
}
}
}
return entity;
}
}
@@ -110,7 +110,7 @@ export async function createRouter(
.delete('/locations/:id', async (req, res) => {
const { id } = req.params;
await locationsCatalog.removeLocation(id);
res.status(200).send();
res.status(204).send();
});
}
@@ -36,7 +36,7 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'catalog-backend' });
logger.debug('Creating application...');
const db = await DatabaseManager.createInMemoryDatabase(logger);
const db = await DatabaseManager.createInMemoryDatabase({ logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders();
+1
View File
@@ -9,6 +9,7 @@
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"allowJs": true,
"lib": ["es2019"],
"types": ["node", "jest"]
}
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,25 +22,25 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
"@backstage/plugin-sentry": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
"@backstage/plugin-sentry": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"node-cache": "^5.1.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-use": "^14.2.0"
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-use": "^14.2.0",
"swr": "^0.2.2"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/react-hooks": "^3.3.0",
-11
View File
@@ -19,14 +19,9 @@ import {
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Cache from 'node-cache';
import { CatalogApi, EntityCompoundName } from './types';
export class CatalogClient implements CatalogApi {
// TODO(blam): This cache is just temporary until we have GraphQL.
// And client side caching using things like React Apollo or Relay.
// There's a lot of loading states that cause flickering around the app which aren't needed.
private cache: Cache;
private apiOrigin: string;
private basePath: string;
@@ -39,7 +34,6 @@ export class CatalogClient implements CatalogApi {
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
this.cache = new Cache({ stdTTL: 10 });
}
private async getRequired(path: string): Promise<any> {
@@ -79,11 +73,6 @@ export class CatalogClient implements CatalogApi {
async getEntities(
filter?: Record<string, string | string[]>,
): Promise<Entity[]> {
const cachedValue = this.cache.get<Entity[]>(
`get:${JSON.stringify(filter)}`,
);
if (cachedValue) return cachedValue;
let path = `/entities`;
if (filter) {
const params = new URLSearchParams();
@@ -35,7 +35,6 @@ import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { defaultFilter, entityFilters, filterGroups } from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
@@ -45,6 +44,7 @@ import {
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { CatalogTable } from '../CatalogTable/CatalogTable';
import useStaleWhileRevalidate from 'swr';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -61,20 +61,21 @@ const useStyles = makeStyles(theme => ({
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const {
starredEntities,
toggleStarredEntity,
isStarredEntity,
} = useStarredEntities();
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
const { value, error, loading } = useAsync(async () => {
const filter = entityFilters[selectedFilter.id];
const all = await catalogApi.getEntities();
return all.filter(e => filter(e, { isStarred: isStarredEntity(e) }));
}, [selectedFilter.id, starredEntities.size]);
const { data: entities, error } = useStaleWhileRevalidate(
['catalog/all', entityFilters[selectedFilter.id]],
async () => catalogApi.getEntities(),
);
const data =
entities?.filter(e =>
entityFilters[selectedFilter.id](e, { isStarred: isStarredEntity(e) }),
) ?? [];
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
@@ -147,6 +148,10 @@ export const CatalogPage: FC<{}> = () => {
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
return (
@@ -195,8 +200,8 @@ export const CatalogPage: FC<{}> = () => {
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
entities={value || []}
loading={loading}
entities={data || []}
loading={!data && !error}
error={error}
actions={actions}
/>
@@ -14,32 +14,38 @@
* limitations under the License.
*/
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
return {
...actual,
useNavigate: jest.fn(() => mockNavigate),
useParams: jest.fn(),
};
});
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, wait } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage } from './EntityPage';
const getTestProps = (name: string) => {
return {
match: {
params: {
optionalNamespaceAndName: name,
kind: 'Component',
},
},
history: {
push: jest.fn(),
},
};
};
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('EntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
const props = getTestProps('');
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
@@ -53,13 +59,11 @@ describe('EntityPage', () => {
],
])}
>
<EntityPage {...props} />
<EntityPage />
</ApiProvider>,
),
);
await wait(() =>
expect(props.history.push).toHaveBeenCalledWith('/catalog'),
);
await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
@@ -34,21 +34,9 @@ import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { useParams, useNavigate } from 'react-router-dom';
const REDIRECT_DELAY = 1000;
type Props = {
match: {
params: {
optionalNamespaceAndName: string;
kind: string;
};
};
history: {
push: (url: string) => void;
};
};
function headerProps(
kind: string,
namespace: string | undefined,
@@ -68,8 +56,12 @@ function headerProps(
};
}
export const EntityPage: FC<Props> = ({ match, history }) => {
const { optionalNamespaceAndName, kind } = match.params;
export const EntityPage: FC<{}> = () => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
kind: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
@@ -85,19 +77,19 @@ export const EntityPage: FC<Props> = ({ match, history }) => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
history.push('/');
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, history, error, loading, entity]);
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
history.push('/catalog');
navigate('/catalog');
return null;
}
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
history.push('/');
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -31,8 +31,8 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -42,13 +42,13 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+9 -14
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import { Switch, Route, MemoryRouter } from 'react-router';
import { Route, MemoryRouter, Routes } from 'react-router';
import { BuildsPage, Builds } from '../pages/BuildsPage';
import { DetailedViewPage, BuildWithSteps } from '../pages/BuildWithStepsPage';
import { AppStateProvider } from '../state';
@@ -24,14 +24,13 @@ export const App = () => {
return (
<AppStateProvider>
<>
<Switch>
<Route path="/circleci" exact component={BuildsPage} />
<Routes>
<Route path="/circleci" element={<BuildsPage />} />
<Route
path="/circleci/build/:buildId"
exact
component={DetailedViewPage}
element={<DetailedViewPage />}
/>
</Switch>
</Routes>
<Settings />
</>
</AppStateProvider>
@@ -45,14 +44,10 @@ export const CircleCIWidget = () => (
<MemoryRouter initialEntries={['/circleci']}>
<AppStateProvider>
<>
<Switch>
<Route path="/circleci" exact component={Builds} />
<Route
path="/circleci/build/:buildId"
exact
component={BuildWithSteps}
/>
</Switch>
<Routes>
<Route path="/circleci" element={<Builds />} />
<Route path="/circleci/build/:buildId" element={<BuildWithSteps />} />
</Routes>
<Settings />
</>
</AppStateProvider>
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-explore",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,8 +22,8 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -33,9 +33,9 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,19 +22,19 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "^5.2.0"
"react-router-dom": "^5.2.0",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -33,8 +33,7 @@ import { gitOpsApiRef, Status } from '../../api';
import { transformRunStatus } from '../ProfileCatalog';
const ClusterPage: FC<{}> = () => {
const params = useParams<{ owner: string; repo: string }>();
const params = useParams() as { owner: string; repo: string };
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
+8 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-graphiql",
"description": "Backstage plugin for browsing GraphQL APIs",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -32,8 +32,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -44,18 +44,18 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/codemirror": "^0.0.95",
"@types/codemirror": "^0.0.96",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3",
"react-router-dom": "^5.2.0"
"react-router-dom": "6.0.0-alpha.5"
},
"files": [
"dist/**/*.{js,d.ts}"
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-home-page",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,8 +22,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -32,8 +32,8 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -41,7 +41,7 @@
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3",
"react-router-dom": "^5.2.0"
"react-router-dom": "6.0.0-alpha.5"
},
"files": [
"dist/**/*.{js,d.ts}"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-identity-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -27,7 +27,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"jest-fetch-mock": "^3.0.3",
"tsc-watch": "^4.2.3"
},
+8 -8
View File
@@ -1,11 +1,11 @@
{
"name": "@backstage/plugin-lighthouse",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
@@ -22,21 +22,21 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-markdown": "^4.3.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -16,13 +16,10 @@
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mocks = {
replace: jest.fn(),
push: jest.fn(),
};
const mockNavigation = jest.fn();
return {
...actual,
useHistory: jest.fn(() => mocks),
useNavigate: jest.fn(() => mockNavigation),
};
});
@@ -41,7 +38,7 @@ import AuditList from '.';
import * as data from '../../__fixtures__/website-list-response.json';
const { useHistory } = jest.requireMock('react-router-dom');
const { useNavigate } = jest.requireMock('react-router-dom');
const websiteListResponse = data as WebsiteListResponse;
describe('AuditList', () => {
@@ -145,7 +142,8 @@ describe('AuditList', () => {
);
const element = await rendered.findByLabelText(/Go to page 1/);
fireEvent.click(element);
expect(useHistory().replace).toHaveBeenCalledWith(`/lighthouse?page=1`);
expect(useNavigate()).toHaveBeenCalledWith(`/lighthouse?page=1`);
});
});
});
@@ -16,7 +16,7 @@
import React, { useState, useMemo, FC, ReactNode } from 'react';
import { useLocalStorage, useAsync } from 'react-use';
import { useHistory } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { Grid, Button } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import Pagination from '@material-ui/lab/Pagination';
@@ -65,7 +65,7 @@ const AuditList: FC<{}> = () => {
return 0;
}, [value?.total, value?.limit]);
const history = useHistory();
const navigate = useNavigate();
let content: ReactNode = null;
if (value) {
@@ -77,7 +77,7 @@ const AuditList: FC<{}> = () => {
page={page}
count={pageCount}
onChange={(_event: Event, newPage: number) => {
history.replace(`/lighthouse?page=${newPage}`);
navigate(`/lighthouse?page=${newPage}`);
}}
/>
)}
@@ -18,10 +18,10 @@ import { Link, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import {
makeStyles,
Button,
Grid,
List,
ListItem,
Button,
ListItemIcon,
ListItemText,
} from '@material-ui/core';
@@ -68,7 +68,7 @@ const AuditLinkList: FC<AuditLinkListProps> = ({
component="nav"
aria-label="lighthouse audit history"
>
{audits.map((audit) => (
{audits.map(audit => (
<ListItem
key={audit.id}
selected={audit.id === selectedId}
@@ -88,7 +88,7 @@ const AuditLinkList: FC<AuditLinkListProps> = ({
const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
const classes = useStyles();
const params = useParams<{ id: string }>();
const params = useParams() as { id: string };
const { url: lighthouseUrl } = useApi(lighthouseApiRef);
if (audit?.status === 'RUNNING') return <Progress />;
@@ -114,7 +114,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => {
const ConnectedAuditView: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
const params = useParams<{ id: string }>();
const params = useParams() as { id: string };
const classes = useStyles();
const { loading, error, value: nextValue } = useAsync<Website>(
@@ -136,7 +136,7 @@ const ConnectedAuditView: FC<{}> = () => {
<AuditLinkList audits={value?.audits} selectedId={params.id} />
</Grid>
<Grid item xs={9}>
<AuditView audit={value?.audits.find((a) => a.id === params.id)} />
<AuditView audit={value?.audits.find(a => a.id === params.id)} />
</Grid>
</Grid>
);
@@ -16,13 +16,10 @@
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mocks = {
replace: jest.fn(),
push: jest.fn(),
};
const mockNavigate = jest.fn();
return {
...actual,
useHistory: jest.fn(() => mocks),
useNavigate: jest.fn(() => mockNavigate),
};
});
@@ -41,7 +38,7 @@ import { lighthouseApiRef, LighthouseRestApi, Audit } from '../../api';
import CreateAudit from '.';
import * as data from '../../__fixtures__/create-audit-response.json';
const { useHistory }: { useHistory: jest.Mock } = jest.requireMock(
const { useNavigate }: { useNavigate: jest.Mock } = jest.requireMock(
'react-router-dom',
);
const createAuditResponse = data as Audit;
@@ -115,7 +112,7 @@ describe('CreateAudit', () => {
describe('when the audit is successfully created', () => {
it('triggers a location change to the table', async () => {
useHistory().push.mockClear();
useNavigate.mockClear();
mockFetch.mockResponseOnce(JSON.stringify(createAuditResponse));
const rendered = render(
@@ -140,7 +137,7 @@ describe('CreateAudit', () => {
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
expect(useHistory().push).toHaveBeenCalledWith('/lighthouse');
expect(useNavigate()).toHaveBeenCalledWith('/lighthouse');
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useState, useCallback, FC } from 'react';
import { useHistory } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import {
makeStyles,
Grid,
@@ -40,7 +40,7 @@ import { lighthouseApiRef } from '../../api';
import { useQuery } from '../../utils';
import LighthouseSupportButton from '../SupportButton';
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
input: {
minWidth: 300,
},
@@ -58,7 +58,7 @@ const CreateAudit: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
const classes = useStyles();
const query = useQuery();
const history = useHistory();
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [url, setUrl] = useState<string>(query.get('url') || '');
const [emulatedFormFactor, setEmulatedFormFactor] = useState('mobile');
@@ -78,7 +78,7 @@ const CreateAudit: FC<{}> = () => {
},
},
});
history.push('/lighthouse');
navigate('/lighthouse');
} catch (err) {
errorApi.post(err);
} finally {
@@ -90,7 +90,7 @@ const CreateAudit: FC<{}> = () => {
lighthouseApi,
setSubmitting,
errorApi,
history,
navigate,
]);
return (
@@ -113,7 +113,7 @@ const CreateAudit: FC<{}> = () => {
<Grid item xs={12} sm={6}>
<InfoCard>
<form
onSubmit={(ev) => {
onSubmit={ev => {
ev.preventDefault();
triggerAudit();
}}
@@ -128,7 +128,7 @@ const CreateAudit: FC<{}> = () => {
helperText="The target URL for Lighthouse to use."
required
disabled={submitting}
onChange={(ev) => setUrl(ev.target.value)}
onChange={ev => setUrl(ev.target.value)}
value={url}
inputProps={{ 'aria-label': 'URL' }}
/>
@@ -142,7 +142,7 @@ const CreateAudit: FC<{}> = () => {
select
required
disabled={submitting}
onChange={(ev) => setEmulatedFormFactor(ev.target.value)}
onChange={ev => setEmulatedFormFactor(ev.target.value)}
value={emulatedFormFactor}
inputProps={{ 'aria-label': 'Emulated form factor' }}
>
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-register-component",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,23 +22,23 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/plugin-catalog": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-catalog": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^5.7.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -14,7 +14,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dockerode": "^3.2.0",
@@ -27,7 +27,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/fs-extra": "^9.0.1",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2"
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { StorageBase, createStorage } from '.';
import winston from 'winston';
import * as winston from 'winston';
describe('Storage Interface Test', () => {
const mockStore = new (class MockStorage implements StorageBase {
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-scaffolder",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,19 +22,19 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-sentry-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"axios": "^0.19.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -28,7 +28,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"jest-fetch-mock": "^3.0.3",
"tsc-watch": "^4.2.3"
},
+1
View File
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './service/router';
+11 -5
View File
@@ -22,13 +22,19 @@ export async function createRouter(logger: Logger): Promise<express.Router> {
const router = Router();
const SENTRY_TOKEN = process.env.SENTRY_TOKEN;
if (!SENTRY_TOKEN) {
throw new Error(
'Sentry token must be provided in SENTRY_TOKEN environment variable to start the API.',
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Sentry token must be provided in SENTRY_TOKEN environment variable to start the API.',
);
}
logger.warn(
'Failed to initialize Sentry backend, set SENTRY_TOKEN environment variable to start the API.',
);
}
const sentryForwarder = getSentryApiForwarder(SENTRY_TOKEN, logger);
} else {
const sentryForwarder = getSentryApiForwarder(SENTRY_TOKEN, logger);
router.use(sentryForwarder);
router.use(sentryForwarder);
}
return router;
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-sentry",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,8 +22,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -34,8 +34,8 @@
"timeago.js": "^4.0.2"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-tech-radar",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,9 +22,9 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -37,8 +37,8 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-welcome",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
@@ -22,19 +22,19 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/dev-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",