diff --git a/.changeset/eleven-hounds-invent.md b/.changeset/eleven-hounds-invent.md new file mode 100644 index 0000000000..ba863ebc28 --- /dev/null +++ b/.changeset/eleven-hounds-invent.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +In frontend builds and tests `process.env.HAS_REACT_DOM_CLIENT` will now be defined if `react-dom/client` is present, i.e. if using React 18. This allows for conditional imports of `react-dom/client`. diff --git a/.changeset/healthy-shirts-fold.md b/.changeset/healthy-shirts-fold.md new file mode 100644 index 0000000000..f2ac0a9e83 --- /dev/null +++ b/.changeset/healthy-shirts-fold.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-techdocs': patch +--- + +The `spec.lifecycle' field in entities will now always be rendered as a string. diff --git a/.changeset/popular-bikes-do.md b/.changeset/popular-bikes-do.md new file mode 100644 index 0000000000..b1cc5c2cdd --- /dev/null +++ b/.changeset/popular-bikes-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-coverage': patch +--- + +The warning for missing code coverage will now render the entity as a reference. diff --git a/.changeset/pretty-swans-worry.md b/.changeset/pretty-swans-worry.md new file mode 100644 index 0000000000..621ae98fe5 --- /dev/null +++ b/.changeset/pretty-swans-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fixed the type declaration of `DependencyGraphProps`, the `defs` prop now expects `JSX.Element`s. diff --git a/.changeset/serious-ravens-serve.md b/.changeset/serious-ravens-serve.md new file mode 100644 index 0000000000..66cbd795c0 --- /dev/null +++ b/.changeset/serious-ravens-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +The scaffolder-module template now recommends usage of `createMockDirectory` instead of `mock-fs`. diff --git a/.changeset/shaggy-beers-collect.md b/.changeset/shaggy-beers-collect.md new file mode 100644 index 0000000000..471368d6f2 --- /dev/null +++ b/.changeset/shaggy-beers-collect.md @@ -0,0 +1,6 @@ +--- +'@backstage/dev-utils': patch +'@backstage/plugin-techdocs': patch +--- + +Added support for React 18. The new `createRoot` API from `react-dom/client` will now be used if present. diff --git a/.changeset/short-ears-rescue.md b/.changeset/short-ears-rescue.md new file mode 100644 index 0000000000..41968f3da4 --- /dev/null +++ b/.changeset/short-ears-rescue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-pinniped-provider': minor +--- + +Add new Pinniped auth module and authenticator to be used alongside the new Pinniped auth provider. diff --git a/.changeset/sweet-buckets-fry.md b/.changeset/sweet-buckets-fry.md new file mode 100644 index 0000000000..8f17617645 --- /dev/null +++ b/.changeset/sweet-buckets-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +The `spec.type` field in entities will now always be rendered as a string. diff --git a/.changeset/thick-dolphins-boil.md b/.changeset/thick-dolphins-boil.md new file mode 100644 index 0000000000..dd562e620c --- /dev/null +++ b/.changeset/thick-dolphins-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +The `app.title` configuration is now properly required to be a string. diff --git a/.changeset/wet-cows-brake.md b/.changeset/wet-cows-brake.md new file mode 100644 index 0000000000..9e694d8452 --- /dev/null +++ b/.changeset/wet-cows-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +The filter options passed to `SearchResultGroupLayout` are now always explicitly rendered as strings by default. diff --git a/.changeset/wild-geese-occur.md b/.changeset/wild-geese-occur.md new file mode 100644 index 0000000000..b97620e7f5 --- /dev/null +++ b/.changeset/wild-geese-occur.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Minor internal code cleanup. diff --git a/.changeset/young-ducks-heal.md b/.changeset/young-ducks-heal.md new file mode 100644 index 0000000000..28614e47bc --- /dev/null +++ b/.changeset/young-ducks-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Adding optional audience parameter to OAuthState type declaration diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e788a6fe95..25073c9556 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -70,6 +70,8 @@ yarn.lock @backstage/maintainers @backst /plugins/playlist-* @backstage/maintainers @backstage/reviewers @kuangp /plugins/rollbar @backstage/maintainers @backstage/reviewers @andrewthauer /plugins/rollbar-backend @backstage/maintainers @backstage/reviewers @andrewthauer +/plugins/scaffolder @backstage/maintainers @backstage/reviewers @backstage/scaffolder-maintainers +/plugins/scaffolder-* @backstage/maintainers @backstage/reviewers @backstage/scaffolder-maintainers /plugins/search @backstage/discoverability-maintainers /plugins/search-* @backstage/discoverability-maintainers /plugins/sonarqube @backstage/maintainers @backstage/reviewers @backstage/sda-se-reviewers diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 1756b37db2..4a8bf9694f 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -24,6 +24,13 @@ const envOptions = { oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS), }; +try { + require.resolve('react-dom/client'); + process.env.HAS_REACT_DOM_CLIENT = true; +} catch { + /* ignored */ +} + const transformIgnorePattern = [ '@material-ui', 'ajv', diff --git a/packages/cli/package.json b/packages/cli/package.json index 44ad5047e9..1a5af11de3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -158,7 +158,6 @@ "@types/http-proxy": "^1.17.4", "@types/inquirer": "^8.1.3", "@types/minimatch": "^5.0.0", - "@types/mock-fs": "^4.13.0", "@types/node": "^18.17.8", "@types/npm-packlist": "^3.0.0", "@types/recursive-readdir": "^2.2.0", @@ -169,7 +168,6 @@ "@types/terser-webpack-plugin": "^5.0.4", "@types/yarnpkg__lockfile": "^1.1.4", "del": "^7.0.0", - "mock-fs": "^5.2.0", "msw": "^1.0.0", "nodemon": "^3.0.1", "ts-node": "^10.0.0", diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts index aa59f94fb4..84af6ebf69 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts @@ -16,23 +16,21 @@ import fs from 'fs-extra'; import path from 'path'; -import mockFs from 'mock-fs'; import { movePlugin } from './createPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const id = 'testPluginMock'; describe('createPlugin', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); describe('movePlugin', () => { it('should move the temporary plugin directory to its final place', async () => { - mockFs({ + mockDir.setContent({ [id]: {}, }); - const tempDir = id; - const pluginDir = path.join('test-temp', 'plugins', id); + const tempDir = mockDir.resolve(id); + const pluginDir = mockDir.resolve('test-temp/plugins', id); await movePlugin(tempDir, pluginDir, id); await expect(fs.pathExists(pluginDir)).resolves.toBe(true); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 7af6c6160b..d00b4ce8da 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -15,10 +15,7 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { Command } from 'commander'; -import { resolve as resolvePath } from 'path'; -import { paths } from '../../lib/paths'; import * as runObj from '../../lib/run'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; import { @@ -30,6 +27,10 @@ import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; import { Lockfile } from '../../lib/versioning/Lockfile'; +import { + MockDirectory, + createMockDirectory, +} from '@backstage/backend-test-utils'; // Avoid mutating the global http(s) agent used in other tests jest.mock('global-agent/bootstrap', () => {}); @@ -56,6 +57,19 @@ jest.mock('ora', () => ({ }, })); +let mockDir: MockDirectory; + +jest.mock('../../lib/paths', () => ({ + paths: { + resolveTargetRoot(filename: string) { + return mockDir.resolve(filename); + }, + get targetDir() { + return mockDir.path; + }, + }, +})); + jest.mock('../../lib/run', () => { return { run: jest.fn(), @@ -117,7 +131,17 @@ const lockfileMockResult = `${HEADER} version "1.0.0" `; +// Avoid flakes by comparing sorted log lines. File system access is async, which leads to the log line order being indeterministic +const expectLogsToMatch = ( + recievedLogs: String[], + expected: String[], +): void => { + expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort()); +}; + describe('bump', () => { + mockDir = createMockDirectory(); + beforeEach(() => { mockFetchPackageInfo.mockImplementation(async name => ({ name: name, @@ -128,7 +152,6 @@ describe('bump', () => { }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); @@ -136,31 +159,34 @@ describe('bump', () => { setupRequestMockHandlers(worker); it('should bump backstage dependencies', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -177,7 +203,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -208,17 +234,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -229,31 +262,34 @@ describe('bump', () => { }); it('should bump backstage dependencies but not install them', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -274,7 +310,7 @@ describe('bump', () => { skipInstall: true, } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -304,17 +340,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -325,31 +368,34 @@ describe('bump', () => { }); it('should prefer dependency versions from release manifest', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -376,7 +422,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -408,17 +454,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -429,30 +482,33 @@ describe('bump', () => { }); it('should only bump packages in the manifest when a specific release is specified', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( @@ -472,14 +528,18 @@ describe('bump', () => { expect(runObj.run).toHaveBeenCalledTimes(0); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.5', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -489,32 +549,36 @@ describe('bump', () => { }); }); + // eslint-disable-next-line jest/expect-expect it('should prefer versions from the highest manifest version when main is not specified', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -561,7 +625,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'next' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -609,35 +673,38 @@ describe('bump', () => { "@backstage/theme@^1.0.0": version "1.0.0" `; - mockFs({ - '/yarn.lock': customLockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': customLockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', - '@backstage-extra/custom': '^1.0.1', - '@backstage-extra/custom-two': '^1.0.0', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + '@backstage-extra/custom': '^1.0.1', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', - '@backstage-extra/custom': '^1.1.0', - '@backstage-extra/custom-two': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + '@backstage-extra/custom': '^1.1.0', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -657,7 +724,7 @@ describe('bump', () => { release: 'main', } as any); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using custom pattern glob @{backstage,backstage-extra}/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage-extra/custom', @@ -696,10 +763,15 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toEqual(customLockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { @@ -708,7 +780,9 @@ describe('bump', () => { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -721,31 +795,34 @@ describe('bump', () => { }); it('should ignore not found packages', async () => { - mockFs({ - '/yarn.lock': lockfileMockResult, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMockResult, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^2.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^2.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); mockFetchPackageInfo.mockRejectedValue(new NotFoundError('Nope')); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( @@ -763,7 +840,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -778,17 +855,24 @@ describe('bump', () => { expect(runObj.run).toHaveBeenCalledTimes(0); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.5', // not bumped }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -798,6 +882,7 @@ describe('bump', () => { }); }); + // eslint-disable-next-line jest/expect-expect it('should log duplicates', async () => { jest.spyOn(Lockfile.prototype, 'analyze').mockReturnValue({ invalidRanges: [], @@ -826,31 +911,34 @@ describe('bump', () => { }, ], }); - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -867,7 +955,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -891,24 +979,23 @@ describe('bump', () => { }); describe('bumpBackstageJsonVersion', () => { + mockDir = createMockDirectory(); + afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should bump version in backstage.json', async () => { - mockFs({ - '/backstage.json': JSON.stringify({ version: '0.0.1' }), + mockDir.setContent({ + 'backstage.json': JSON.stringify({ version: '0.0.1' }), }); - paths.targetDir = '/'; - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); const { log } = await withLogCollector(async () => { await bumpBackstageJsonVersion('1.4.1'); }); - expect(await fs.readJson('/backstage.json')).toEqual({ version: '1.4.1' }); + expect(await fs.readJson(mockDir.resolve('backstage.json'))).toEqual({ + version: '1.4.1', + }); expect(log).toEqual([ 'Upgraded from release 0.0.1 to 1.4.1, please review these template changes:', undefined, @@ -918,17 +1005,15 @@ describe('bumpBackstageJsonVersion', () => { }); it("should create backstage.json if doesn't exist", async () => { - mockFs({}); - paths.targetDir = '/'; + mockDir.clear(); // empty temp test folder const latest = '1.4.1'; - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); const { log } = await withLogCollector(async () => { await bumpBackstageJsonVersion(latest); }); - expect(await fs.readJson('/backstage.json')).toEqual({ version: latest }); + expect(await fs.readJson(mockDir.resolve('backstage.json'))).toEqual({ + version: latest, + }); expect(log).toEqual([ 'Your project is now at version 1.4.1, which has been written to backstage.json', ]); diff --git a/packages/cli/src/lib/builder/plugins.test.ts b/packages/cli/src/lib/builder/plugins.test.ts index dcbdc55cc0..f6984dd67e 100644 --- a/packages/cli/src/lib/builder/plugins.test.ts +++ b/packages/cli/src/lib/builder/plugins.test.ts @@ -15,7 +15,6 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { NormalizedOutputOptions, OutputAsset, @@ -24,6 +23,7 @@ import { } from 'rollup'; import { forwardFileImports } from './plugins'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const context = { meta: { @@ -91,16 +91,18 @@ describe('forwardFileImports', () => { ); }); - describe('with mock fs', () => { - beforeEach(() => { - mockFs({ - '/dev/src/my-module.ts': '', - '/dev/src/dir/my-image.png': 'my-image', - }); - }); + describe('with createMockDirectory', () => { + const mockDir = createMockDirectory(); - afterEach(() => { - mockFs.restore(); + beforeEach(() => { + mockDir.setContent({ + dev: { + src: { + 'my-module.ts': '', + dir: { 'my-image.png': 'my-image' }, + }, + }, + }); }); it('should extract files', async () => { @@ -111,23 +113,33 @@ describe('forwardFileImports', () => { throw new Error('options.external is not a function'); } - expect(options.external('./my-module', '/dev/src/index.ts', false)).toBe( - false, - ); expect( - options.external('./my-image.png', '/dev/src/dir/index.ts', false), + options.external( + './my-module', + mockDir.resolve('dev/src/index.ts'), + false, + ), + ).toBe(false); + expect( + options.external( + './my-image.png', + mockDir.resolve('dev', 'src', 'dir', 'index.ts'), + false, + ), ).toBe(true); - const outPath = '/dev/dist/dir/my-image.png'; + const outPath = mockDir.resolve('dev', 'dist', 'dir', 'my-image.png'); await expect(fs.pathExists(outPath)).resolves.toBe(false); await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, false, // isWrite = false -> no write @@ -136,7 +148,9 @@ describe('forwardFileImports', () => { await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { // output assets should not cause a write ['index.js']: { type: 'asset' } as OutputAsset, @@ -150,11 +164,13 @@ describe('forwardFileImports', () => { // output chunk + isWrite -> generate files await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, true, @@ -164,11 +180,13 @@ describe('forwardFileImports', () => { // should not break when triggering another write await plugin.generateBundle?.call( context, - { file: '/dev/dist/my-output.js' } as NormalizedOutputOptions, + { + file: mockDir.resolve('dev/dist/my-output.js'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, true, diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index ed20fe385a..534ffe8c58 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -81,6 +81,15 @@ async function readBuildInfo() { }; } +function hasReactDomClient() { + try { + require.resolve('react-dom/client'); + return true; + } catch { + return false; + } +} + export async function createConfig( paths: BundlingPaths, options: BundlingOptions, @@ -136,6 +145,9 @@ export async function createConfig( () => JSON.stringify(options.getFrontendAppConfigs()), true, ), + // This allows for conditional imports of react-dom/client, since there's no way + // to check for presence of it in source code without module resolution errors. + 'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(hasReactDomClient()), }), ); diff --git a/packages/cli/src/lib/new/factories/backendModule.test.ts b/packages/cli/src/lib/new/factories/backendModule.test.ts index c633b1c1b2..78ef5f96bb 100644 --- a/packages/cli/src/lib/new/factories/backendModule.test.ts +++ b/packages/cli/src/lib/new/factories/backendModule.test.ts @@ -15,39 +15,38 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { backendModule } from './backendModule'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('backendModule factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a backend plugin', async () => { - mockFs({ - '/root': { - packages: { - backend: { - 'package.json': JSON.stringify({}), - }, + mockDir.setContent({ + packages: { + backend: { + 'package.json': JSON.stringify({}), }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(backendModule, { @@ -73,8 +72,7 @@ describe('backendModule factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend module backstage-plugin-test-backend-module-tester-two', 'Checking Prerequisites:', `availability plugins${sep}test-backend-module-tester-two`, @@ -91,14 +89,14 @@ describe('backendModule factory', () => { ]); await expect( - fs.readJson('/root/packages/backend/package.json'), + fs.readJson(mockDir.resolve('packages/backend/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test-backend-module-tester-two': '^1.0.0', }, }); const moduleFile = await fs.readFile( - '/root/plugins/test-backend-module-tester-two/src/module.ts', + mockDir.resolve('plugins/test-backend-module-tester-two/src/module.ts'), 'utf-8', ); @@ -110,11 +108,11 @@ describe('backendModule factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + cwd: mockDir.resolve('plugins/test-backend-module-tester-two'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + cwd: mockDir.resolve('plugins/test-backend-module-tester-two'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/backendPlugin.test.ts b/packages/cli/src/lib/new/factories/backendPlugin.test.ts index f326de88b8..5c1d496e49 100644 --- a/packages/cli/src/lib/new/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/backendPlugin.test.ts @@ -15,39 +15,38 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { backendPlugin } from './backendPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('backendPlugin factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a backend plugin', async () => { - mockFs({ - '/root': { - packages: { - backend: { - 'package.json': JSON.stringify({}), - }, + mockDir.setContent({ + packages: { + backend: { + 'package.json': JSON.stringify({}), }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(backendPlugin, { @@ -72,8 +71,7 @@ describe('backendPlugin factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend plugin backstage-plugin-test-backend', 'Checking Prerequisites:', `availability plugins${sep}test-backend`, @@ -94,14 +92,14 @@ describe('backendPlugin factory', () => { ]); await expect( - fs.readJson('/root/packages/backend/package.json'), + fs.readJson(mockDir.resolve('packages/backend/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test-backend': '^1.0.0', }, }); const standaloneServerFile = await fs.readFile( - '/root/plugins/test-backend/src/service/standaloneServer.ts', + mockDir.resolve('plugins/test-backend/src/service/standaloneServer.ts'), 'utf-8', ); @@ -112,11 +110,11 @@ describe('backendPlugin factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-backend'), + cwd: mockDir.resolve('plugins/test-backend'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-backend'), + cwd: mockDir.resolve('plugins/test-backend'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/common/tasks.test.ts b/packages/cli/src/lib/new/factories/common/tasks.test.ts index 49381676e7..60783344fc 100644 --- a/packages/cli/src/lib/new/factories/common/tasks.test.ts +++ b/packages/cli/src/lib/new/factories/common/tasks.test.ts @@ -15,32 +15,37 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { sep } from 'path'; -import { createMockOutputStream, mockPaths } from './testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './testUtils'; import { CreateContext } from '../../types'; import { executePluginPackageTemplate } from './tasks'; +import { createMockDirectory } from '@backstage/backend-test-utils'; + +const mockDir = createMockDirectory(); mockPaths({ - ownDir: '/own', - targetRoot: '/root', + ownDir: mockDir.resolve('own'), + targetRoot: mockDir.resolve('root'), }); describe('executePluginPackageTemplate', () => { afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should execute template', async () => { - mockFs({ - '/root': { + mockDir.setContent({ + root: { 'yarn.lock': ` some-package@^1.1.0: version "1.5.0" `, }, - '/own': { + own: { templates: { 'test-template': { 'package.json.hbs': ` @@ -78,7 +83,7 @@ some-package@^1.1.0: } as CreateContext, { templateName: 'test-template', - targetDir: '/target', + targetDir: mockDir.resolve('target'), values: { id: 'testing', makePrivate: true, @@ -87,7 +92,7 @@ some-package@^1.1.0: ); expect(modified).toBe(true); - expect(output).toEqual([ + expectLogsToMatch(output, [ 'Checking Prerequisites:', `availability ..${sep}target`, 'creating temp dir', @@ -98,7 +103,8 @@ some-package@^1.1.0: 'Installing:', `moving ..${sep}target`, ]); - await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ + await expect(fs.readFile(mockDir.resolve('target/package.json'), 'utf8')) + .resolves.toBe(`{ "name": "my-testing-plugin", "private": true, "description": "testing", @@ -109,10 +115,10 @@ some-package@^1.1.0: } `); await expect( - fs.readFile('/target/subdir/templated.txt', 'utf8'), + fs.readFile(mockDir.resolve('target/subdir/templated.txt'), 'utf8'), ).resolves.toBe('Hello testing!'); await expect( - fs.readFile('/target/subdir/not-templated.txt', 'utf8'), + fs.readFile(mockDir.resolve('target/subdir/not-templated.txt'), 'utf8'), ).resolves.toBe('Hello {{id}}!'); }); }); diff --git a/packages/cli/src/lib/new/factories/common/testUtils.ts b/packages/cli/src/lib/new/factories/common/testUtils.ts index 01081a7486..1ade13a996 100644 --- a/packages/cli/src/lib/new/factories/common/testUtils.ts +++ b/packages/cli/src/lib/new/factories/common/testUtils.ts @@ -73,3 +73,11 @@ export function createMockOutputStream() { } as unknown as WriteStream & { fd: any }, ] as const; } + +// Avoid flakes by comparing sorted log lines. File system access is async, which leads to the log line order being indeterministic +export function expectLogsToMatch( + recievedLogs: String[], + expected: String[], +): void { + expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort()); +} diff --git a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts index 8390eb192e..94d4a7eb4c 100644 --- a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts @@ -15,13 +15,16 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { frontendPlugin } from './frontendPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const appTsxContent = ` import { createApp } from '@backstage/app-defaults'; @@ -34,33 +37,29 @@ const router = ( `; describe('frontendPlugin factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a frontend plugin', async () => { - mockFs({ - '/root': { - packages: { - app: { - 'package.json': JSON.stringify({}), - src: { - 'App.tsx': appTsxContent, - }, + mockDir.setContent({ + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, }, }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(frontendPlugin, { @@ -85,8 +84,7 @@ describe('frontendPlugin factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating frontend plugin backstage-plugin-test', 'Checking Prerequisites:', `availability plugins${sep}test`, @@ -114,15 +112,16 @@ describe('frontendPlugin factory', () => { ]); await expect( - fs.readJson('/root/packages/app/package.json'), + fs.readJson(mockDir.resolve('packages/app/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test': '^1.0.0', }, }); - await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves - .toBe(` + await expect( + fs.readFile(mockDir.resolve('packages/app/src/App.tsx'), 'utf8'), + ).resolves.toBe(` import { createApp } from '@backstage/app-defaults'; import { TestPage } from 'backstage-plugin-test'; @@ -136,32 +135,27 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); }); it('should create a frontend plugin with more options and codeowners', async () => { - mockFs({ - '/root': { - CODEOWNERS: '', - packages: { - app: { - 'package.json': JSON.stringify({}), - src: { - 'App.tsx': appTsxContent, - }, + mockDir.setContent({ + CODEOWNERS: '', + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, }, }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(frontendPlugin, { @@ -183,15 +177,16 @@ const router = ( }); await expect( - fs.readJson('/root/packages/app/package.json'), + fs.readJson(mockDir.resolve('packages/app/package.json')), ).resolves.toEqual({ dependencies: { '@internal/plugin-test': '^1.0.0', }, }); - await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves - .toBe(` + await expect( + fs.readFile(mockDir.resolve('packages/app/src/App.tsx'), 'utf8'), + ).resolves.toBe(` import { createApp } from '@backstage/app-defaults'; import { TestPage } from '@internal/plugin-test'; @@ -205,11 +200,11 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts index d0b08f4f06..e3673d21ba 100644 --- a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts @@ -15,36 +15,35 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath, join as joinPath } from 'path'; -import { paths } from '../../paths'; +import { join as joinPath } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { nodeLibraryPackage } from './nodeLibraryPackage'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('nodeLibraryPackage factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a node library package', async () => { const expectedNodeLibraryPackageName = 'test'; - mockFs({ - '/root': { - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + packages: {}, }); const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, { @@ -69,8 +68,7 @@ describe('nodeLibraryPackage factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ `Creating node-library package ${expectedNodeLibraryPackageName}`, 'Checking Prerequisites:', `availability ${joinPath('packages', expectedNodeLibraryPackageName)}`, @@ -87,7 +85,11 @@ describe('nodeLibraryPackage factory', () => { await expect( fs.readJson( - `/root/packages/${expectedNodeLibraryPackageName}/package.json`, + mockDir.resolve( + 'packages', + expectedNodeLibraryPackageName, + 'package.json', + ), ), ).resolves.toEqual( expect.objectContaining({ @@ -99,11 +101,11 @@ describe('nodeLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/packages/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/packages/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName), optional: true, }); }); @@ -111,14 +113,9 @@ describe('nodeLibraryPackage factory', () => { it('should create a node library plugin with options and codeowners', async () => { const expectedNodeLibraryPackageName = 'test'; - mockFs({ - '/root': { - CODEOWNERS: '', - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + CODEOWNERS: '', + packages: {}, }); const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, { @@ -141,11 +138,11 @@ describe('nodeLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve(expectedNodeLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve(expectedNodeLibraryPackageName), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginCommon.test.ts b/packages/cli/src/lib/new/factories/pluginCommon.test.ts index 25b6b51905..d85e490510 100644 --- a/packages/cli/src/lib/new/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/new/factories/pluginCommon.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginCommon } from './pluginCommon'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginCommon factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a common plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginCommon, { @@ -67,8 +66,7 @@ describe('pluginCommon factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend plugin backstage-plugin-test-common', 'Checking Prerequisites:', `availability plugins${sep}test-common`, @@ -84,7 +82,7 @@ describe('pluginCommon factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-common/package.json'), + fs.readJson(mockDir.resolve('plugins/test-common/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-common', @@ -96,11 +94,11 @@ describe('pluginCommon factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-common'), + cwd: mockDir.resolve('plugins/test-common'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-common'), + cwd: mockDir.resolve('plugins/test-common'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginNode.test.ts b/packages/cli/src/lib/new/factories/pluginNode.test.ts index 50971ff191..d8d9ea33ab 100644 --- a/packages/cli/src/lib/new/factories/pluginNode.test.ts +++ b/packages/cli/src/lib/new/factories/pluginNode.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginNode } from './pluginNode'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginNode factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a node plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginNode, { @@ -67,8 +66,7 @@ describe('pluginNode factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating Node.js plugin library backstage-plugin-test-node', 'Checking Prerequisites:', `availability plugins${sep}test-node`, @@ -84,7 +82,7 @@ describe('pluginNode factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-node/package.json'), + fs.readJson(mockDir.resolve('plugins/test-node/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-node', @@ -96,11 +94,11 @@ describe('pluginNode factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-node'), + cwd: mockDir.resolve('plugins/test-node'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-node'), + cwd: mockDir.resolve('plugins/test-node'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginWeb.test.ts b/packages/cli/src/lib/new/factories/pluginWeb.test.ts index bcd62f3bcd..ff4211d8fa 100644 --- a/packages/cli/src/lib/new/factories/pluginWeb.test.ts +++ b/packages/cli/src/lib/new/factories/pluginWeb.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginWeb } from './pluginWeb'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginWeb factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a react plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginWeb, { @@ -67,8 +66,7 @@ describe('pluginWeb factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating web plugin library backstage-plugin-test-react', 'Checking Prerequisites:', `availability plugins${sep}test-react`, @@ -91,7 +89,7 @@ describe('pluginWeb factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-react/package.json'), + fs.readJson(mockDir.resolve('plugins/test-react/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-react', @@ -103,11 +101,11 @@ describe('pluginWeb factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-react'), + cwd: mockDir.resolve('plugins/test-react'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-react'), + cwd: mockDir.resolve('plugins/test-react'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts index 38d1207a3f..b43946228b 100644 --- a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts +++ b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { scaffolderModule } from './scaffolderModule'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('scaffolderModule factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a scaffolder backend module package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(scaffolderModule, { @@ -67,8 +66,7 @@ describe('scaffolderModule factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating module backstage-plugin-scaffolder-backend-module-test', 'Checking Prerequisites:', `availability plugins${sep}scaffolder-backend-module-test`, @@ -87,7 +85,9 @@ describe('scaffolderModule factory', () => { ]); await expect( - fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'), + fs.readJson( + mockDir.resolve('plugins/scaffolder-backend-module-test/package.json'), + ), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-scaffolder-backend-module-test', @@ -99,11 +99,11 @@ describe('scaffolderModule factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + cwd: mockDir.resolve('plugins/scaffolder-backend-module-test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + cwd: mockDir.resolve('plugins/scaffolder-backend-module-test'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts index 50fef44a67..b0c8461189 100644 --- a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts @@ -15,36 +15,35 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath, join as joinPath } from 'path'; -import { paths } from '../../paths'; +import { join as joinPath } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { webLibraryPackage } from './webLibraryPackage'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('webLibraryPackage factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a web library package', async () => { const expectedwebLibraryPackageName = 'test'; - mockFs({ - '/root': { - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + packages: {}, }); const options = await FactoryRegistry.populateOptions(webLibraryPackage, { @@ -69,8 +68,7 @@ describe('webLibraryPackage factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ `Creating web-library package ${expectedwebLibraryPackageName}`, 'Checking Prerequisites:', `availability ${joinPath('packages', expectedwebLibraryPackageName)}`, @@ -87,7 +85,11 @@ describe('webLibraryPackage factory', () => { await expect( fs.readJson( - `/root/packages/${expectedwebLibraryPackageName}/package.json`, + mockDir.resolve( + 'packages', + expectedwebLibraryPackageName, + 'package.json', + ), ), ).resolves.toEqual( expect.objectContaining({ @@ -99,11 +101,11 @@ describe('webLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/packages/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedwebLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/packages/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedwebLibraryPackageName), optional: true, }); }); @@ -111,14 +113,9 @@ describe('webLibraryPackage factory', () => { it('should create a web library plugin with options and codeowners', async () => { const expectedwebLibraryPackageName = 'test'; - mockFs({ - '/root': { - CODEOWNERS: '', - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + CODEOWNERS: '', + packages: {}, }); const options = await FactoryRegistry.populateOptions(webLibraryPackage, { @@ -141,11 +138,11 @@ describe('webLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve(expectedwebLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve(expectedwebLibraryPackageName), optional: true, }); }); diff --git a/packages/cli/src/lib/role.test.ts b/packages/cli/src/lib/role.test.ts index adca2d70b8..8163512b76 100644 --- a/packages/cli/src/lib/role.test.ts +++ b/packages/cli/src/lib/role.test.ts @@ -14,10 +14,20 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; +import { createMockDirectory } from '@backstage/backend-test-utils'; import { Command } from 'commander'; import { findRoleFromCommand } from './role'; +const mockDir = createMockDirectory(); + +jest.mock('./paths', () => ({ + paths: { + resolveTarget(filename: string) { + return mockDir.resolve(filename); + }, + }, +})); + describe('findRoleFromCommand', () => { function mkCommand(args: string) { const parsed = new Command() @@ -27,7 +37,7 @@ describe('findRoleFromCommand', () => { } beforeEach(() => { - mockFs({ + mockDir.setContent({ 'package.json': JSON.stringify({ name: 'test', backstage: { @@ -37,10 +47,6 @@ describe('findRoleFromCommand', () => { }); }); - afterEach(() => { - mockFs.restore(); - }); - it('provides role info by role', async () => { await expect(findRoleFromCommand(mkCommand(''))).resolves.toEqual( 'web-library', diff --git a/packages/cli/src/lib/tasks.test.ts b/packages/cli/src/lib/tasks.test.ts index fb1f38536f..1119adb7f7 100644 --- a/packages/cli/src/lib/tasks.test.ts +++ b/packages/cli/src/lib/tasks.test.ts @@ -15,14 +15,11 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath } from 'path'; import { templatingTask } from './tasks'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('templatingTask', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should template a directory with mix of regular files and templates', async () => { // Testing template directory @@ -36,7 +33,7 @@ describe('templatingTask', () => { const testVersionFileContent = "version: {{pluginVersion}} {{versionQuery 'mock-pkg'}}"; - mockFs({ + mockDir.setContent({ [tmplDir]: { sub: { 'version.txt.hbs': testVersionFileContent, @@ -47,8 +44,8 @@ describe('templatingTask', () => { }); await templatingTask( - tmplDir, - destDir, + mockDir.resolve(tmplDir), + mockDir.resolve(destDir), { pluginVersion: '0.0.0', }, @@ -57,10 +54,10 @@ describe('templatingTask', () => { ); await expect( - fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'), + fs.readFile(mockDir.resolve(destDir, 'test.txt'), 'utf8'), ).resolves.toBe(testFileContent); await expect( - fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'), + fs.readFile(mockDir.resolve(destDir, 'sub/version.txt'), 'utf8'), ).resolves.toBe('version: 0.0.0 ^0.1.2'); }); }); diff --git a/packages/cli/src/lib/version.test.ts b/packages/cli/src/lib/version.test.ts index 604129679b..6b1aa391d5 100644 --- a/packages/cli/src/lib/version.test.ts +++ b/packages/cli/src/lib/version.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; import { packageVersions, createPackageVersionProvider } from './version'; import { Lockfile } from './versioning'; import corePluginApiPkg from '@backstage/core-plugin-api/package.json'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('createPackageVersionProvider', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -30,7 +28,7 @@ describe('createPackageVersionProvider', () => { `; it('should provide package versions', async () => { - mockFs({ + mockDir.setContent({ 'yarn.lock': `${HEADER} "a@^0.1.0": version "0.1.5" @@ -55,7 +53,8 @@ describe('createPackageVersionProvider', () => { `, }); - const lockfile = await Lockfile.load('yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const provider = createPackageVersionProvider(lockfile); expect(provider('a', '0.1.5')).toBe('^0.1.0'); diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts index 909f8be9aa..a4267acdb7 100644 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -15,9 +15,9 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { BackstagePackage } from '@backstage/cli-node'; import { Lockfile } from './Lockfile'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -76,16 +76,14 @@ const mockBDedup = `${LEGACY_HEADER} `; describe('Lockfile', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should load and serialize mockA', async () => { - mockFs({ - '/yarn.lock': mockA, + mockDir.setContent({ + 'yarn.lock': mockA, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); expect(lockfile.get('a')).toEqual([ { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, ]); @@ -97,11 +95,12 @@ describe('Lockfile', () => { }); it('should deduplicate and save mockA', async () => { - mockFs({ - '/yarn.lock': mockA, + mockDir.setContent({ + 'yarn.lock': mockA, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -120,17 +119,17 @@ describe('Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockADedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockA); + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockADedup); }); it('should deduplicate mockB', async () => { - mockFs({ - '/yarn.lock': mockB, + mockDir.setContent({ + 'yarn.lock': mockB, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -226,16 +225,14 @@ b@^2: `; describe('New Lockfile', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should load and serialize mockANew', async () => { - mockFs({ - '/yarn.lock': mockANew, + mockDir.setContent({ + 'yarn.lock': mockANew, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); expect(lockfile.get('a')).toEqual([ { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, ]); @@ -248,11 +245,12 @@ describe('New Lockfile', () => { }); it('should deduplicate and save mockANew', async () => { - mockFs({ - '/yarn.lock': mockANew, + mockDir.setContent({ + 'yarn.lock': mockANew, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -271,19 +269,20 @@ describe('New Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockANewDedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockANew); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockANew); + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewDedup, ); }); it('should deduplicate and save mockANewLocal', async () => { - mockFs({ - '/yarn.lock': mockANewLocal, + mockDir.setContent({ + 'yarn.lock': mockANewLocal, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map([ [ @@ -311,11 +310,11 @@ describe('New Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockANewLocalDedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewLocal, ); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewLocalDedup, ); }); diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index 5a255532f9..23ff927c48 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; -import path from 'path'; import * as runObj from '../run'; import * as yarn from '../yarn'; import { fetchPackageInfo, mapDependencies } from './packages'; import { NotFoundError } from '../errors'; +import { createMockDirectory } from '@backstage/backend-test-utils'; jest.mock('../run', () => { return { @@ -96,34 +95,41 @@ describe('fetchPackageInfo', () => { }); describe('mapDependencies', () => { + const mockDir = createMockDirectory(); + afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should read dependencies', async () => { - mockFs({ - '/root/package.json': JSON.stringify({ + mockDir.setContent({ + 'package.json': JSON.stringify({ workspaces: { packages: ['pkgs/*'], }, }), - '/root/pkgs/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '1 || 2', + pkgs: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '1 || 2', + }, + }), }, - }), - '/root/pkgs/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '3', - '@backstage/cli': '^0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '3', + '@backstage/cli': '^0', + }, + }), }, - }), + }, }); - const dependencyMap = await mapDependencies('/root', '@backstage/*'); + const dependencyMap = await mapDependencies(mockDir.path, '@backstage/*'); expect(Array.from(dependencyMap)).toEqual([ [ '@backstage/core', @@ -131,12 +137,12 @@ describe('mapDependencies', () => { { name: 'a', range: '1 || 2', - location: path.resolve('/root/pkgs/a'), + location: mockDir.resolve('pkgs/a'), }, { name: 'b', range: '3', - location: path.resolve('/root/pkgs/b'), + location: mockDir.resolve('pkgs/b'), }, ], ], @@ -146,7 +152,7 @@ describe('mapDependencies', () => { { name: 'b', range: '^0', - location: path.resolve('/root/pkgs/b'), + location: mockDir.resolve('pkgs/b'), }, ], ], diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts index 3f91a8f49d..242f93c2f3 100644 --- a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts @@ -22,7 +22,7 @@ describe('acme:example', () => { logStream: new PassThrough(), output: jest.fn(), createTemporaryDirectory() { - // Usage of mock-fs is recommended for testing of filesystem operations + // Usage of createMockDirectory is recommended for testing of filesystem operations throw new Error('Not implemented'); }, }); diff --git a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts index c0ee452d28..09e6389e7f 100644 --- a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts @@ -31,9 +31,8 @@ jest.mock('react-router-dom', () => jest.requireActual('react-router-dom-beta'), ); -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set(), diff --git a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts index 0b4bf588a2..39949b4cd6 100644 --- a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts @@ -25,9 +25,8 @@ import { } from '@backstage/core-plugin-api'; import { MATCH_ALL_ROUTE } from './collectors'; -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set(), diff --git a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts index 05f7678078..f1f9ec7137 100644 --- a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts @@ -31,9 +31,8 @@ jest.mock('react-router-dom', () => jest.requireActual('react-router-dom-stable'), ); -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set(), diff --git a/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx index c3a5bab89e..0f7a5cd3ad 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx @@ -353,7 +353,9 @@ describe('v1 consumer', () => { initialProps: { routeRef: routeRef1 as AnyRouteRef, }, - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + wrapper: ({ + children, + }: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => ( , string>([ diff --git a/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx index 84a33842c9..495fc20b15 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx @@ -385,7 +385,9 @@ describe('v1 consumer', () => { initialProps: { routeRef: routeRef1 as AnyRouteRef, }, - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + wrapper: ({ + children, + }: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => ( , string>([ diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 44a8b33e04..d40179dc62 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -249,7 +249,7 @@ export interface DependencyGraphProps acyclicer?: 'greedy'; align?: DependencyGraphTypes.Alignment; curve?: 'curveStepBefore' | 'curveMonotoneX'; - defs?: SVGDefsElement | SVGDefsElement[]; + defs?: JSX.Element | JSX.Element[]; direction?: DependencyGraphTypes.Direction; edgeMargin?: number; edgeRanks?: number; diff --git a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx index 4928292424..3dd1025b8f 100644 --- a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx +++ b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx @@ -144,7 +144,7 @@ export interface DependencyGraphProps * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs | Defs} shared by rendered SVG to be used by * {@link DependencyGraphProps.renderNode} and/or {@link DependencyGraphProps.renderLabel} */ - defs?: SVGDefsElement | SVGDefsElement[]; + defs?: JSX.Element | JSX.Element[]; /** * Controls zoom behavior of graph * diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index 9f2b04c214..e4ee9b5995 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -455,7 +455,7 @@ export function Table(props: TableProps) { const hasFilters = !!filters?.length; const Toolbar = useCallback( - toolbarProps => { + (toolbarProps: any /* no type for this in material-table */) => { return ( (props: TableProps) { const hasNoRows = typeof data !== 'function' && data.length === 0; const columnCount = columns.length; const Body = useCallback( - bodyProps => { + (bodyProps: any /* no type for this in material-table */) => { if (isLoading) { return ( diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index c40d45f8b4..aba4c79181 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -312,7 +312,11 @@ const sidebarSubmenuType = React.createElement(SidebarSubmenu).type; // properly yet, matching for example /foobar with /foo. export const WorkaroundNavLink = React.forwardRef< HTMLAnchorElement, - NavLinkProps & { activeStyle?: CSSProperties; activeClassName?: string } + NavLinkProps & { + children?: ReactNode; + activeStyle?: CSSProperties; + activeClassName?: string; + } >(function WorkaroundNavLinkWithRef( { to, @@ -361,7 +365,10 @@ export const WorkaroundNavLink = React.forwardRef< /** * Common component used by SidebarItem & SidebarItemWithSubmenu */ -const SidebarItemBase = forwardRef((props, ref) => { +const SidebarItemBase = forwardRef< + any, + SidebarItemProps & { children: ReactNode } +>((props, ref) => { const { icon: Icon, text, @@ -553,7 +560,10 @@ const SidebarItemWithSubmenu = ({ * @remarks * If children contain a `SidebarSubmenu` component the `SidebarItem` will have a expandable submenu */ -export const SidebarItem = forwardRef((props, ref) => { +export const SidebarItem = forwardRef< + any, + SidebarItemProps & { children: ReactNode } +>((props, ref) => { // Filter children for SidebarSubmenu components const [submenu] = useElementFilter(props.children, elements => // Directly comparing child.type with SidebarSubmenu will not work with in diff --git a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx index e124036648..88d653cea5 100644 --- a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx +++ b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx @@ -25,7 +25,7 @@ import Typography from '@material-ui/core/Typography'; import CloseIcon from '@material-ui/icons/Close'; import MenuIcon from '@material-ui/icons/Menu'; import { orderBy } from 'lodash'; -import React, { useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState, useContext, ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import { SidebarOpenStateProvider } from './SidebarOpenStateContext'; import { SidebarGroup } from './SidebarGroup'; @@ -206,8 +206,7 @@ export const MobileSidebar = (props: MobileSidebarProps) => { onClose={() => setSelectedMenuItemIndex(-1)} > {sidebarGroups[selectedMenuItemIndex] && - (sidebarGroups[selectedMenuItemIndex].props - .children as React.ReactChildren)} + (sidebarGroups[selectedMenuItemIndex].props.children as ReactNode)} { export const SampleSidebar = () => ( - + }> diff --git a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx index a09ea11b3d..4e75878a7d 100644 --- a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx +++ b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx @@ -96,7 +96,9 @@ export function TabbedCard(props: PropsWithChildren) { } else { React.Children.map(children, child => { if ( - React.isValidElement<{ children?: unknown; value?: unknown }>(child) && + React.isValidElement<{ children?: ReactNode; value?: unknown }>( + child, + ) && child?.props.value === value ) { selectedTabContent = child?.props.children; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index 8795ecdded..9a3760d019 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -41,7 +41,12 @@ const FeatureFlagComponent = (_props: { }) => null; attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); -const Wrapper = ({ children }: { children?: React.ReactNode }) => ( +const Wrapper = ({ + children, +}: { + children?: React.ReactNode; + tree?: ReactNode; +}) => ( {children} diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx index a8dbdc0402..19f0516906 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -303,7 +303,12 @@ describe('useTranslationRef', () => { const translationApi = I18nextTranslationApi.create({ languageApi }); const { result, rerender } = renderHook( - ({ translationRef }) => useTranslationRef(translationRef), + ({ + translationRef, + }: { + translationRef: TranslationRef; + children?: ReactNode; + }) => useTranslationRef(translationRef), { wrapper: ({ children }) => ( } />); @@ -235,7 +245,11 @@ export class DevAppBuilder { window.location.pathname = this.defaultPage; } - ReactDOM.render(, document.getElementById('root')); + if ('createRoot' in ReactDOM) { + ReactDOM.createRoot(document.getElementById('root')!).render(); + } else { + ReactDOM.render(, document.getElementById('root')); + } } } diff --git a/plugins/auth-backend-module-pinniped-provider/.eslintrc.js b/plugins/auth-backend-module-pinniped-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-pinniped-provider/README.md b/plugins/auth-backend-module-pinniped-provider/README.md new file mode 100644 index 0000000000..bdb340d426 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/README.md @@ -0,0 +1,7 @@ +# Auth Module: Pinniped Provider + +This module provides an Pinniped auth provider implementation for `@backstage/plugin-auth-backend`. + +## Links + +- [Backstage](https://backstage.io) diff --git a/plugins/auth-backend-module-pinniped-provider/api-report.md b/plugins/auth-backend-module-pinniped-provider/api-report.md new file mode 100644 index 0000000000..b9b993bd1e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/api-report.md @@ -0,0 +1,28 @@ +## API Report File for "@backstage/plugin-auth-backend-module-pinniped-provider" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; +import { BaseClient } from 'openid-client'; +import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; +import { Strategy } from 'openid-client'; +import { TokenSet } from 'openid-client'; + +// @public (undocumented) +export const authModulePinnipedProvider: () => BackendFeature; + +// @public (undocumented) +export const pinnipedAuthenticator: OAuthAuthenticator< + Promise<{ + providerStrategy: Strategy< + { + tokenset: TokenSet; + }, + BaseClient + >; + client: BaseClient; + }>, + unknown +>; +``` diff --git a/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml b/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml new file mode 100644 index 0000000000..9d1ef1c299 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-pinniped-provider + title: '@backstage/plugin-auth-backend-module-pinniped-provider' + description: The pinniped-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/auth-backend-module-pinniped-provider/dev/index.ts b/plugins/auth-backend-module-pinniped-provider/dev/index.ts new file mode 100644 index 0000000000..bd09f77a1f --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { createBackend } from '@backstage/backend-defaults'; +import authPlugin from '@backstage/plugin-auth-backend'; +import { authModulePinnipedProvider } from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModulePinnipedProvider); + +backend.start(); diff --git a/plugins/auth-backend-module-pinniped-provider/package.json b/plugins/auth-backend-module-pinniped-provider/package.json new file mode 100644 index 0000000000..3d7dcf97d9 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/plugin-auth-backend-module-pinniped-provider", + "description": "The pinniped-provider backend module for the auth plugin.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "openid-client": "^5.4.3" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "cookie-parser": "^1.4.6", + "express": "^4.18.2", + "express-promise-router": "^4.1.1", + "express-session": "^1.17.3", + "jose": "^4.14.6", + "msw": "^1.3.0", + "passport": "^0.6.0", + "supertest": "^6.3.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..f908c1ea13 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -0,0 +1,503 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, + OAuthAuthenticatorStartInput, + OAuthState, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { pinnipedAuthenticator } from './authenticator'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { rest } from 'msw'; +import express from 'express'; + +describe('pinnipedAuthenticator', () => { + let implementation: any; + let oauthState: OAuthState; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', + userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', + introspection_endpoint: 'https://pinniped.test/introspect.oauth2', + jwks_uri: 'https://pinniped.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + const clusterScopedIdToken = 'dummy-token'; + + beforeAll(async () => { + const keyPair = await generateKeyPair('ES256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'ES256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mswServer.use( + rest.get( + 'https://federationDomain.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); + + implementation = pinnipedAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + federationDomain: 'https://federationDomain.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + }); + + oauthState = { + nonce: 'nonce', + env: 'env', + }; + }); + + describe('#start', () => { + let fakeSession: Record; + let startRequest: OAuthAuthenticatorStartInput; + + beforeEach(() => { + fakeSession = {}; + startRequest = { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + session: fakeSession, + }, + } as unknown as OAuthAuthenticatorStartInput; + }); + + it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const url = new URL(startResponse.url); + + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('pinniped.test'); + expect(url.pathname).toBe('/oauth2/authorize'); + }); + + it('initiates authorization code grant', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('response_type')).toBe('code'); + }); + + it('persists audience parameter in oauth state', async () => { + startRequest.req.query = { audience: 'test-cluster' }; + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject({ + nonce: 'nonce', + env: 'env', + audience: 'test-cluster', + }); + }); + + it('passes client ID from config', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('client_id')).toBe('clientId'); + }); + + it('passes callback URL from config', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('redirect_uri')).toBe( + 'https://backstage.test/callback', + ); + }); + + it('generates PKCE challenge', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('code_challenge_method')).toBe('S256'); + expect(searchParams.get('code_challenge')).not.toBeNull(); + }); + + it('stores PKCE verifier in session', async () => { + await pinnipedAuthenticator.start(startRequest, implementation); + expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); + }); + + it('requests sufficient scopes for token exchange by default', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const scopes = searchParams.get('scope')?.split(' ') ?? []; + + expect(scopes).toEqual( + expect.arrayContaining([ + 'openid', + 'pinniped:request-audience', + 'username', + 'offline_access', + ]), + ); + }); + + it('encodes OAuth state in query param', async () => { + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject(oauthState); + }); + + it('fails when request has no session', async () => { + return expect( + pinnipedAuthenticator.start( + { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + }, + } as unknown as OAuthAuthenticatorStartInput, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#authenticate', () => { + let handlerRequest: OAuthAuthenticatorAuthenticateInput; + + beforeEach(() => { + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + }); + + it('exchanges authorization code for access token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const accessToken = handlerResponse.session.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('exchanges authorization code for refresh token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const refreshToken = handlerResponse.session.refreshToken; + + expect(refreshToken).toEqual('refreshToken'); + }); + + it('returns granted scope', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const responseScope = handlerResponse.session.scope; + + expect(responseScope).toEqual('testScope'); + }); + + it('returns cluster-scoped ID token when audience is specified', async () => { + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + + expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken); + }); + + it('fails on network error during token exchange', async () => { + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (_req, response, _ctx) => + response.networkError('Connection timed out'), + ), + ); + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }, + ), + ); + + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + await expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow( + `Failed to get cluster specific ID token for "test_cluster": Error: RFC8693 token exchange failed with error: NetworkError: Connection timed out`, + ); + }); + + it('fails without authorization code', async () => { + handlerRequest.req.url = 'https://test.com'; + return expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow('Unexpected redirect'); + }); + + it('fails without oauth state', async () => { + return expect( + pinnipedAuthenticator.authenticate( + { + req: { + method: 'GET', + url: `https://test?code=authorization_code}`, + session: { + ['oidc:pinniped.test']: { + state: { handle: 'sessionid', code_verifier: 'foo' }, + }, + }, + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow( + 'Authentication rejected, state missing from the response', + ); + }); + + it('fails when request has no session', async () => { + return expect( + pinnipedAuthenticator.authenticate( + { + req: { + method: 'GET', + url: 'https://test.com', + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#refresh', () => { + let refreshRequest: OAuthAuthenticatorRefreshInput; + + beforeEach(() => { + refreshRequest = { + scope: '', + refreshToken: 'otherRefreshToken', + req: {} as express.Request, + }; + }); + + it('gets new refresh token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('refreshToken'); + }); + + it('gets access token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.accessToken).toBe('accessToken'); + }); + + it('gets id token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.idToken).toBe(idToken); + }); + }); +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts new file mode 100644 index 0000000000..83c4204ad0 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -0,0 +1,195 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { PassportDoneCallback } from '@backstage/plugin-auth-node'; +import { + createOAuthAuthenticator, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { + Client, + Issuer, + TokenSet, + Strategy as OidcStrategy, +} from 'openid-client'; + +const rfc8693TokenExchange = async ({ + subject_token, + target_audience, + ctx, +}: { + subject_token: string; + target_audience: string; + ctx: Promise<{ + providerStrategy: OidcStrategy<{}>; + client: Client; + }>; +}): Promise => { + const { client } = await ctx; + return client + .grant({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token, + audience: target_audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }) + .then(tokenset => tokenset.access_token) + .catch(err => { + throw new Error(`RFC8693 token exchange failed with error: ${err}`); + }); +}; + +/** @public */ +export const pinnipedAuthenticator = createOAuthAuthenticator({ + defaultProfileTransform: async (_r, _c) => ({ profile: {} }), + async initialize({ callbackUrl, config }) { + const issuer = await Issuer.discover( + `${config.getString( + 'federationDomain', + )}/.well-known/openid-configuration`, + ); + const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token + client_id: config.getString('clientId'), + client_secret: config.getString('clientSecret'), + redirect_uris: [callbackUrl], + response_types: ['code'], + scope: config.getOptionalString('scope') || '', + id_token_signed_response_alg: 'ES256', + }); + const providerStrategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + done: PassportDoneCallback< + { tokenset: TokenSet }, + { + refreshToken?: string; + } + >, + ) => { + done(undefined, { tokenset }, {}); + }, + ); + + return { providerStrategy, client }; + }, + + async start(input, ctx) { + const { providerStrategy } = await ctx; + const stringifiedAudience = input.req.query?.audience as string; + const decodedState = decodeOAuthState(input.state); + const state = { ...decodedState, audience: stringifiedAudience }; + const options: Record = { + scope: + input.scope || + 'openid pinniped:request-audience username offline_access', + state: encodeOAuthState(state), + }; + + return new Promise((resolve, reject) => { + const strategy = Object.create(providerStrategy); + strategy.redirect = (url: string) => { + resolve({ url }); + }; + strategy.error = (error: Error) => { + reject(error); + }; + strategy.authenticate(input.req, { ...options }); + }); + }, + + async authenticate(input, ctx) { + const { providerStrategy } = await ctx; + const { req } = input; + const { searchParams } = new URL(req.url, 'https://pinniped.com'); + const stateParam = searchParams.get('state'); + const audience = stateParam + ? decodeOAuthState(stateParam).audience + : undefined; + + return new Promise((resolve, reject) => { + const strategy = Object.create(providerStrategy); + strategy.success = (user: any) => { + (audience + ? rfc8693TokenExchange({ + subject_token: user.tokenset.access_token, + target_audience: audience, + ctx, + }).catch(err => + reject( + new Error( + `Failed to get cluster specific ID token for "${audience}": ${err}`, + ), + ), + ) + : Promise.resolve(user.tokenset.id_token) + ).then(idToken => { + resolve({ + fullProfile: { provider: '', id: '', displayName: '' }, + session: { + accessToken: user.tokenset.access_token!, + tokenType: user.tokenset.token_type ?? 'bearer', + scope: user.tokenset.scope!, + idToken, + refreshToken: user.tokenset.refresh_token, + }, + }); + }); + }; + + strategy.fail = (info: any) => { + reject(new Error(`Authentication rejected, ${info.message || ''}`)); + }; + + strategy.error = (error: Error) => { + reject(error); + }; + + strategy.redirect = () => { + reject(new Error('Unexpected redirect')); + }; + + strategy.authenticate(req); + }); + }, + + async refresh(input, ctx) { + const { client } = await ctx; + const tokenset = await client.refresh(input.refreshToken); + + return new Promise((resolve, reject) => { + if (!tokenset.access_token) { + reject(new Error('Refresh Failed')); + } + + resolve({ + fullProfile: { provider: '', id: '', displayName: '' }, + session: { + accessToken: tokenset.access_token!, + tokenType: tokenset.token_type ?? 'bearer', + scope: tokenset.scope!, + idToken: tokenset.id_token, + refreshToken: tokenset.refresh_token, + }, + }); + }); + }, +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/config.d.ts b/plugins/auth-backend-module-pinniped-provider/src/config.d.ts new file mode 100644 index 0000000000..50685abfb0 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/config.d.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 interface Config { + /** Configuration options for the auth plugin */ + auth?: { + providers?: { + pinniped?: { + [authEnv: string]: { + clientId: string; + federationDomain: string; + /** + * @visibility secret + */ + clientSecret: string; + scope?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-pinniped-provider/src/index.ts b/plugins/auth-backend-module-pinniped-provider/src/index.ts new file mode 100644 index 0000000000..9a2ca6727e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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. + */ + +/** + * The pinniped-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { pinnipedAuthenticator } from './authenticator'; +export { authModulePinnipedProvider } from './module'; diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.test.ts b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts new file mode 100644 index 0000000000..ed285cff86 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts @@ -0,0 +1,260 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import request from 'supertest'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import { Server } from 'http'; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import session from 'express-session'; +import passport from 'passport'; +import { AddressInfo } from 'net'; +import { + AuthProviderRouteHandlers, + createOAuthRouteHandlers, +} from '@backstage/plugin-auth-node'; +import Router from 'express-promise-router'; +import { pinnipedAuthenticator } from './authenticator'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; + +describe('authModulePinnipedProvider', () => { + let app: express.Express; + let backstageServer: Server; + let appUrl: string; + let providerRouteHandler: AuthProviderRouteHandlers; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', + userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', + introspection_endpoint: 'https://pinniped.test/introspect.oauth2', + jwks_uri: 'https://pinniped.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + const clusterScopedIdToken = 'dummy-token'; + + beforeAll(async () => { + const keyPair = await generateKeyPair('ES256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'ES256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + mswServer.use( + rest.get( + 'https://federationDomain.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + rest.get( + 'https://pinniped.test/oauth2/authorize', + async (req, res, ctx) => { + const callbackUrl = new URL( + req.url.searchParams.get('redirect_uri')!, + ); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + callbackUrl.searchParams.set('scope', 'test-scope'); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }, + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); + + const secret = 'secret'; + app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, + }), + ) + .use(passport.initialize()) + .use(passport.session()); + await new Promise(resolve => { + backstageServer = app.listen(0, '0.0.0.0', () => { + appUrl = `http://127.0.0.1:${ + (backstageServer.address() as AddressInfo).port + }`; + resolve(null); + }); + }); + + mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough())); + + providerRouteHandler = createOAuthRouteHandlers({ + authenticator: pinnipedAuthenticator, + appUrl, + baseUrl: `${appUrl}/api/auth`, + isOriginAllowed: _ => true, + providerId: 'pinniped', + config: new ConfigReader({ + federationDomain: 'https://federationDomain.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + resolverContext: { + issueToken: async _ => ({ token: '' }), + findCatalogUser: async _ => ({ + entity: { + apiVersion: '', + kind: '', + metadata: { name: '' }, + }, + }), + signInWithCatalogUser: async _ => ({ token: '' }), + }, + }); + + const router = Router(); + router + .use( + '/api/auth/pinniped/start', + providerRouteHandler.start.bind(providerRouteHandler), + ) + .use( + '/api/auth/pinniped/handler/frame', + providerRouteHandler.frameHandler.bind(providerRouteHandler), + ); + app.use(router); + }); + + afterEach(() => { + backstageServer.close(); + }); + + it('should start', async () => { + const agent = request.agent(backstageServer); + const startResponse = await agent.get( + `/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); + + expect(startResponse.status).toBe(302); + }); + + it('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => { + const agent = request.agent(''); + + // make /start request with audience parameter + const startResponse = await agent.get( + `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); + // follow redirect to authorization endpoint + const authorizationResponse = await agent.get( + startResponse.header.location, + ); + // follow redirect to token_endpoint + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + expect(handlerResponse.text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + profile: {}, + providerInfo: { + idToken: clusterScopedIdToken, + accessToken: 'accessToken', + scope: 'testScope', + }, + }, + }), + ), + ); + }); +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.ts b/plugins/auth-backend-module-pinniped-provider/src/module.ts new file mode 100644 index 0000000000..5fff30e82e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/module.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { createBackendModule } from '@backstage/backend-plugin-api'; +import { + authProvidersExtensionPoint, + commonSignInResolvers, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { pinnipedAuthenticator } from './authenticator'; + +/** @public */ +export const authModulePinnipedProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'pinniped-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'pinniped', + factory: createOAuthProviderFactory({ + authenticator: pinnipedAuthenticator, + signInResolverFactories: { + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index a01de2cf46..4b1a8a1f27 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -399,6 +399,7 @@ export type OAuthState = { scope?: string; redirectUrl?: string; flow?: string; + audience?: string; }; // @public (undocumented) diff --git a/plugins/auth-node/src/oauth/state.ts b/plugins/auth-node/src/oauth/state.ts index fc747d08a5..28f7d2fd2e 100644 --- a/plugins/auth-node/src/oauth/state.ts +++ b/plugins/auth-node/src/oauth/state.ts @@ -29,6 +29,7 @@ export type OAuthState = { scope?: string; redirectUrl?: string; flow?: string; + audience?: string; }; /** @public */ diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx index 6cda334cf1..2f3278dc6b 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Select } from '@backstage/core-components'; +import { Select, SelectedItems } from '@backstage/core-components'; import { Box } from '@material-ui/core'; import React, { useCallback } from 'react'; @@ -31,7 +31,10 @@ export type Props = { const curves: Array = ['curveMonotoneX', 'curveStepBefore']; export const CurveFilter = ({ value, onChange }: Props) => { - const handleChange = useCallback(v => onChange(v as Curve), [onChange]); + const handleChange = useCallback( + (v: SelectedItems) => onChange(v as Curve), + [onChange], + ); return ( diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx index 815ed1f292..c614ed2b58 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Select } from '@backstage/core-components'; +import { Select, SelectedItems } from '@backstage/core-components'; import { Box } from '@material-ui/core'; import React, { useCallback } from 'react'; import { Direction } from '../EntityRelationsGraph'; @@ -31,7 +31,10 @@ export type Props = { }; export const DirectionFilter = ({ value, onChange }: Props) => { - const handleChange = useCallback(v => onChange(v as Direction), [onChange]); + const handleChange = useCallback( + (v: SelectedItems) => onChange(v as Direction), + [onChange], + ); return ( diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx index c777953808..16000465b9 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx @@ -36,7 +36,7 @@ export const DefaultImportPage = () => { const theme = useTheme(); const configApi = useApi(configApiRef); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const contentItems = [ diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index f7086fce19..26b6b8eb52 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -43,7 +43,7 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { } = props; const configApi = useApi(configApiRef); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const catalogImportApi = useApi(catalogImportApiRef); const hasGithubIntegration = configApi.has('integrations.github'); diff --git a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx index 74ebd973d0..b0db174787 100644 --- a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx +++ b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx @@ -42,7 +42,7 @@ export const StepReviewLocation = ({ const configApi = useApi(configApiRef); const analytics = useAnalytics(); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const [submitted, setSubmitted] = useState(false); const [error, setError] = useState(); diff --git a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx index 91d60d4f13..077274a6e9 100644 --- a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx +++ b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx @@ -164,7 +164,7 @@ export const EntityPeekAheadPopover = (props: EntityPeekAheadPopoverProps) => { {entity.metadata.description} )} - {entity.spec?.type} + {entity.spec?.type?.toString()} {(entity.metadata.tags || []) .slice(0, maxTagChips) diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx index 09b944605f..d2eb6d4266 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx @@ -74,7 +74,10 @@ export function OverviewPage(props: { entity: AlphaEntity }) { {spec?.type && ( - + )} {metadata.uid && ( diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx index e458e7d157..8591cdad93 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx @@ -22,7 +22,7 @@ import { renderHook, RenderHookResult, } from '@testing-library/react-hooks'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { UseUnregisterEntityDialogState, useUnregisterEntityDialogState, @@ -85,7 +85,10 @@ describe('useUnregisterEntityDialogState', () => { }); it('goes through the happy unregister path', async () => { - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -114,7 +117,10 @@ describe('useUnregisterEntityDialogState', () => { entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION] = 'bootstrap:bootstrap'; - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -137,7 +143,10 @@ describe('useUnregisterEntityDialogState', () => { it('chooses only-delete when there was no location annotation', async () => { delete entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION]; - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -157,7 +166,10 @@ describe('useUnregisterEntityDialogState', () => { }); it('chooses only-delete when the location could not be found', async () => { - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index a190984ffe..9505dad889 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -128,7 +128,10 @@ function EntityLabels(props: { entity: Entity }) { /> )} {entity.spec?.lifecycle && ( - + )} ); diff --git a/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx index 4017ba8264..57bb9a2be0 100644 --- a/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx +++ b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog-react'; +import { humanizeEntityRef, useEntity } from '@backstage/plugin-catalog-react'; import { Box, Modal, makeStyles } from '@material-ui/core'; import FolderIcon from '@material-ui/icons/Folder'; import FileOutlinedIcon from '@material-ui/icons/InsertDriveFileOutlined'; @@ -181,7 +181,9 @@ export const FileExplorer = () => { } if (!value) { return ( - No code coverage found for ${entity} + + No code coverage found for {humanizeEntityRef(entity)} + ); } diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx index 3a6dca9417..4e255917d8 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx @@ -33,8 +33,9 @@ const items = [ }, ]; -const tooltipItems = () => - items.map(item => ); +const tooltipItems = items.map(item => ( + +)); describe('', () => { it('formats label and tooltip item text correctly', async () => { diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx index 7dd5b4253f..3a2f30bbfe 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; import { getByRole, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ProjectSelect } from './ProjectSelect'; @@ -28,7 +28,7 @@ const mockProjects = [ ]; describe('', () => { - let Component: React.ReactNode; + let Component: ComponentType; beforeEach(() => { Component = () => ( diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index c433bb1900..f030ec4708 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -44,7 +44,9 @@ import { useApi } from '@backstage/core-plugin-api'; export const LIMIT = 10; const AuditList = () => { - const [dismissedStored] = useLocalStorage(LIGHTHOUSE_INTRO_LOCAL_STORAGE); + const [dismissedStored] = useLocalStorage( + LIGHTHOUSE_INTRO_LOCAL_STORAGE, + ); const [dismissed, setDismissed] = useState(dismissedStored); const query = useQuery(); diff --git a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx index a47c346390..57a93ce0fe 100644 --- a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx +++ b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx @@ -74,7 +74,7 @@ export const GroupListPicker = (props: GroupListPickerProps) => { }, [catalogApi, groupTypes]); const handleChange = useCallback( - (_, v: GroupEntity | null) => { + (_: unknown, v: GroupEntity | null) => { onChange(v ?? undefined); setAnchorEl(null); }, diff --git a/plugins/playlist/package.json b/plugins/playlist/package.json index 5169e4828d..a479c2946d 100644 --- a/plugins/playlist/package.json +++ b/plugins/playlist/package.json @@ -59,6 +59,7 @@ "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", + "@backstage/plugin-search-common": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^9.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx index d5a52dc83c..529327776e 100644 --- a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx +++ b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx @@ -15,6 +15,7 @@ */ import { + CompoundEntityRef, Entity, getCompoundEntityRef, stringifyEntityRef, @@ -22,6 +23,7 @@ import { import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { CatalogEntityDocument } from '@backstage/plugin-catalog-common'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import type { SearchDocument } from '@backstage/plugin-search-common'; import { SearchBar, SearchContextProvider, @@ -131,13 +133,13 @@ export const AddEntitiesDrawer = ({ }; const addEntity = useCallback( - entityResult => { + (entityResult: SearchDocument) => { // TODO(kuangp): this parsing of the location is not great. Ideally `CatalogEntityDocument` // contains the `metadata.name` field so we can derive the full ref and we only fall back to // parsing location if it's missing (ie. for older versions) const match = entityResult.location.match(entityLocationRegex); if (match?.groups) { - onAdd(stringifyEntityRef(match?.groups)); + onAdd(stringifyEntityRef(match?.groups as CompoundEntityRef)); } else { // eslint-disable-next-line no-console console.error( diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx index c74859ce20..72d50506b0 100644 --- a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx @@ -72,7 +72,7 @@ export const PlaylistEntitiesTable = ({ ); const removeEntity = useCallback( - async (_, entity: Entity | Entity[]) => { + async (_: unknown, entity: Entity | Entity[]) => { try { const entityArray = [entity].flat(); const entityNames = entityArray.map( diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index ec95bad0e5..d8299a9b5f 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -29,7 +29,7 @@ import { useRouteRefParams, useApi, } from '@backstage/core-plugin-api'; -import { FormProps, IChangeEvent, withTheme } from '@rjsf/core'; +import { FormProps, IChangeEvent, ISubmitEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { ComponentType, useState } from 'react'; import { transformSchemaToProps } from './schema'; @@ -185,7 +185,7 @@ export const MultistepJsonForm = (props: MultistepJsonFormProps) => { formData={formData} formContext={{ formData }} onChange={onChange} - onSubmit={e => { + onSubmit={(e: ISubmitEvent) => { if (e.errors.length === 0) handleNext(); }} {...formProps} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx index 43b3b49bcf..e470267b23 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx @@ -28,7 +28,7 @@ import { Select, } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; -import { withTheme } from '@rjsf/core'; +import { ISubmitEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import CodeMirror from '@uiw/react-codemirror'; import React, { useCallback, useMemo, useState } from 'react'; @@ -104,7 +104,7 @@ export const CustomFieldExplorer = ({ }, [customFieldExtensions]); const handleSelectionChange = useCallback( - selection => { + (selection: FieldExtensionOptions) => { setSelectedField(selection); setFieldFormState({}); setFormState({}); @@ -113,7 +113,7 @@ export const CustomFieldExplorer = ({ ); const handleFieldConfigChange = useCallback( - state => { + (state: {}) => { setFieldFormState(state); setFormState({}); // Force TemplateEditorForm to re-render since some fields @@ -134,7 +134,9 @@ export const CustomFieldExplorer = ({ value={selectedField} label="Choose Custom Field Extension" labelId="select-field-label" - onChange={e => handleSelectionChange(e.target.value)} + onChange={e => + handleSelectionChange(e.target.value as FieldExtensionOptions) + } > {fieldOptions.map((option, idx) => ( @@ -158,7 +160,9 @@ export const CustomFieldExplorer = ({ noHtml5Validate formData={fieldFormState} formContext={{ fieldFormState }} - onSubmit={e => handleFieldConfigChange(e.formData)} + onSubmit={(e: ISubmitEvent) => + handleFieldConfigChange(e.formData) + } schema={selectedField.schema?.uiOptions || {}} >