From 1fd543fc7146da2ec5f75ed34577a6d3ad7becc3 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 17:01:15 -0300 Subject: [PATCH 1/8] test(backend): expand Layer 1 router tests and add Layer 2 integration pilot Implements RHIDP-13233 (Test Layer Infrastructure epic, RHIDP-13496). Layer 1 (unit/router): - licensed-users-info: cover /users/quantity (200 + 403), /users (200, CSV, 403), and add direct unit tests for rowToResponse (SQL/ISO/invalid dates) and permissionCheck (ALLOW/DENY). router.ts coverage 0 -> 94.6%. - licensed-users-info: new readBackstageTokenExpiration unit tests (default, in-range, min/max clamp). 100% covered. - dynamic-plugins-info: assert installer stripping, empty list, and auth enforcement on /loaded-plugins; refactor to a shared buildApp helper. Layer 2 (integration): - licensed-users-info: new startTestBackend pilot that boots the real plugin against SQLite and hits /health. plugin.ts coverage 0 -> 100%, runs in ~3s. Overall licensed-users-info coverage ~17% -> 86%. No cluster required. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/service/router.test.ts | 69 +++++-- .../readBackstageTokenExpiration.test.ts | 46 +++++ .../src/service/router.integration.test.ts | 50 +++++ .../src/service/router.test.ts | 194 +++++++++++++++++- 4 files changed, 333 insertions(+), 26 deletions(-) create mode 100644 plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts create mode 100644 plugins/licensed-users-info-backend/src/service/router.integration.test.ts diff --git a/plugins/dynamic-plugins-info-backend/src/service/router.test.ts b/plugins/dynamic-plugins-info-backend/src/service/router.test.ts index d0f4a58292..dfd19f2650 100644 --- a/plugins/dynamic-plugins-info-backend/src/service/router.test.ts +++ b/plugins/dynamic-plugins-info-backend/src/service/router.test.ts @@ -8,34 +8,71 @@ import { plugins } from '../../__fixtures__/data'; import { expectedList } from '../../__fixtures__/expected_result'; import { createRouter } from './router'; -describe('createRouter', () => { - let app: express.Express; - - beforeAll(async () => { - const pluginManager = new (DynamicPluginManager as any)(); - pluginManager._plugins = plugins; - - const router = await createRouter({ - pluginProvider: pluginManager, - discovery: mockServices.discovery(), - httpAuth: mockServices.httpAuth(), - config: mockServices.rootConfig(), - logger: mockServices.logger.mock(), - }); +const buildApp = async ( + pluginList: unknown[], + httpAuth = mockServices.httpAuth(), +) => { + // NOTE: the assertion is required to instantiate the manager without its + // runtime args and seed the private `_plugins` field directly from fixtures. + const pluginManager = new (DynamicPluginManager as any)(); + pluginManager._plugins = pluginList; - app = express(); - app = express().use(router); + const router = await createRouter({ + pluginProvider: pluginManager, + discovery: mockServices.discovery(), + httpAuth, + config: mockServices.rootConfig(), + logger: mockServices.logger.mock(), }); + return express().use(router); +}; + +describe('createRouter', () => { afterEach(() => { jest.resetAllMocks(); }); describe('GET /loaded-plugins', () => { it('returns the list of loaded dynamic plugins', async () => { + const app = await buildApp(plugins); + const response = await request(app).get('/loaded-plugins'); + expect(response.status).toEqual(200); expect(response.body).toEqual(expectedList); }); + + it('strips the installer details from node platform plugins', async () => { + const app = await buildApp(plugins); + + const response = await request(app).get('/loaded-plugins'); + + expect(response.body.length).toBeGreaterThan(0); + for (const plugin of response.body) { + expect(plugin).not.toHaveProperty('installer'); + } + }); + + it('returns an empty list when no dynamic plugins are loaded', async () => { + const app = await buildApp([]); + + const response = await request(app).get('/loaded-plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); + }); + + it('enforces authentication before returning the plugin list', async () => { + const httpAuth = mockServices.httpAuth(); + const credentialsSpy = jest.spyOn(httpAuth, 'credentials'); + + const app = await buildApp(plugins, httpAuth); + await request(app).get('/loaded-plugins'); + + expect(credentialsSpy).toHaveBeenCalledWith(expect.anything(), { + allow: ['user', 'service'], + }); + }); }); }); diff --git a/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts b/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts new file mode 100644 index 0000000000..0cc20e88b0 --- /dev/null +++ b/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { mockServices } from '@backstage/backend-test-utils'; + +import { readBackstageTokenExpiration } from './readBackstageTokenExpiration'; + +describe('readBackstageTokenExpiration', () => { + it('returns the default (3600s) when the key is not configured', () => { + const config = mockServices.rootConfig(); + expect(readBackstageTokenExpiration(config)).toEqual(3600); + }); + + it('returns the configured duration when within the allowed range', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { minutes: 30 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(1800); + }); + + it('clamps to the minimum (600s) when the configured value is too low', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { seconds: 100 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(600); + }); + + it('clamps to the maximum (86400s) when the configured value is too high', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { hours: 48 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(86400); + }); +}); diff --git a/plugins/licensed-users-info-backend/src/service/router.integration.test.ts b/plugins/licensed-users-info-backend/src/service/router.integration.test.ts new file mode 100644 index 0000000000..a30729a52d --- /dev/null +++ b/plugins/licensed-users-info-backend/src/service/router.integration.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils'; + +import request from 'supertest'; + +import { licensedUsersInfoPlugin } from '../plugin'; + +// The plugin reads `backend.database` directly via DatabaseManager.fromConfig +// and disables itself for a pure in-memory SQLite database. A SQLite database +// with an explicit (in-memory) directory keeps the plugin enabled while staying +// cluster-free — this is the real backend wiring, no mocks of the plugin itself. +const sqliteConfig = mockServices.rootConfig.factory({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: { directory: ':memory:' }, + }, + }, + }, +}); + +describe('licensed-users-info backend (Layer 2 integration)', () => { + it('starts the real plugin and serves the health endpoint', async () => { + const { server } = await startTestBackend({ + features: [licensedUsersInfoPlugin, sqliteConfig], + }); + + const response = await request(server).get( + '/api/licensed-users-info/health', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }, 30_000); +}); diff --git a/plugins/licensed-users-info-backend/src/service/router.test.ts b/plugins/licensed-users-info-backend/src/service/router.test.ts index 036119530e..ec9d5a061b 100644 --- a/plugins/licensed-users-info-backend/src/service/router.test.ts +++ b/plugins/licensed-users-info-backend/src/service/router.test.ts @@ -1,9 +1,11 @@ -import { mockServices } from '@backstage/backend-test-utils'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import express from 'express'; import request from 'supertest'; -import { createRouter } from './router'; +import { createRouter, permissionCheck, rowToResponse } from './router'; jest.mock('@backstage/backend-defaults/database', () => ({ DatabaseManager: { @@ -15,33 +17,205 @@ jest.mock('@backstage/backend-defaults/database', () => ({ }, })); +const mockGetQuantityRecordedActiveUsers = jest.fn(); +const mockGetListUsers = jest.fn(); +jest.mock('../database/databaseUserInfoStore', () => ({ + DatabaseUserInfoStore: jest.fn().mockImplementation(() => ({ + getQuantityRecordedActiveUsers: mockGetQuantityRecordedActiveUsers, + getListUsers: mockGetListUsers, + })), +})); + +const mockGetUserEntities = jest.fn(); +jest.mock('./catalogStore', () => ({ + CatalogEntityStore: jest.fn().mockImplementation(() => ({ + getUserEntities: mockGetUserEntities, + })), +})); + describe('createRouter', () => { let app: express.Express; + const permissions = mockServices.permissions.mock(); - beforeAll(async () => { - // TODO: Replace with module + const buildApp = async () => { const router = await createRouter({ logger: mockServices.logger.mock(), config: mockServices.rootConfig(), auth: mockServices.auth.mock(), discovery: mockServices.discovery.mock(), - permissions: mockServices.permissions.mock(), + permissions, httpAuth: mockServices.httpAuth.mock(), lifecycle: mockServices.lifecycle.mock(), }); - app = express().use(router); - }); + return express().use(router); + }; - beforeEach(() => { - jest.resetAllMocks(); + beforeEach(async () => { + jest.clearAllMocks(); + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + mockGetUserEntities.mockResolvedValue(new Map()); + app = await buildApp(); }); describe('GET /health', () => { - it('returns ok', async () => { + it('returns ok without requiring authorization', async () => { const response = await request(app).get('/health'); expect(response.status).toEqual(200); expect(response.body).toEqual({ status: 'ok' }); + expect(permissions.authorize).not.toHaveBeenCalled(); + }); + }); + + describe('GET /users/quantity', () => { + it('returns the number of recorded active users', async () => { + mockGetQuantityRecordedActiveUsers.mockResolvedValue(42); + + const response = await request(app).get('/users/quantity'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ quantity: 42 }); + }); + + it('returns 403 when the caller is not authorized', async () => { + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app).get('/users/quantity'); + + expect(response.status).toEqual(403); + expect(mockGetQuantityRecordedActiveUsers).not.toHaveBeenCalled(); + }); + }); + + describe('GET /users', () => { + const userRow = { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01 12:00:00', + }; + + it('returns the list of users enriched with catalog profile data', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + mockGetUserEntities.mockResolvedValue( + new Map([ + [ + 'user:default/jdoe', + { + spec: { + profile: { + displayName: 'John Doe', + email: 'jdoe@example.com', + }, + }, + }, + ], + ]), + ); + + const response = await request(app).get('/users'); + + expect(response.status).toEqual(200); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toMatchObject({ + userEntityRef: 'user:default/jdoe', + displayName: 'John Doe', + email: 'jdoe@example.com', + }); + }); + + it('returns the list as CSV when the content-type is text/csv', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + + const response = await request(app) + .get('/users') + .set('content-type', 'text/csv'); + + expect(response.status).toEqual(200); + expect(response.headers['content-type']).toContain('text/csv'); + expect(response.text).toContain('userEntityRef'); + expect(response.text).toContain('user:default/jdoe'); + }); + + it('returns 403 when the caller is not authorized', async () => { + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app).get('/users'); + + expect(response.status).toEqual(403); + expect(mockGetListUsers).not.toHaveBeenCalled(); }); }); }); + +describe('rowToResponse', () => { + it('parses a SQL timestamp and back-dates it by the token expiration window', () => { + const result = rowToResponse( + { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01 12:00:00', + }, + 3600, + ); + + expect(result.userEntityRef).toEqual('user:default/jdoe'); + // 12:00:00 UTC minus a 3600s window => 11:00:00 UTC + expect(result.lastAuthTime).toEqual('Thu, 01 Jan 2026 11:00:00 GMT'); + }); + + it('falls back to JS Date parsing for ISO timestamps', () => { + const result = rowToResponse( + { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01T12:00:00.000Z', + }, + 3600, + ); + + expect(result.lastAuthTime).toEqual('Thu, 01 Jan 2026 11:00:00 GMT'); + }); + + it('throws when the timestamp cannot be parsed', () => { + expect(() => + rowToResponse( + { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: 'not-a-date', + }, + 3600, + ), + ).toThrow('Failed to parse expiration date format'); + }); +}); + +describe('permissionCheck', () => { + const credentials = mockCredentials.user(); + + it('resolves when the decision is ALLOW', async () => { + const permissions = mockServices.permissions.mock(); + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + await expect( + permissionCheck(permissions, credentials), + ).resolves.toBeUndefined(); + }); + + it('throws NotAllowedError when the decision is DENY', async () => { + const permissions = mockServices.permissions.mock(); + permissions.authorize.mockResolvedValue([{ result: AuthorizeResult.DENY }]); + + await expect(permissionCheck(permissions, credentials)).rejects.toThrow( + NotAllowedError, + ); + }); +}); From b7c7478018c29bf82565e5c74525068bf65eaef2 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 17:25:12 -0300 Subject: [PATCH 2/8] test(backend): close coverage gaps and simplify provider stub Address review feedback on the Layer 1+2 backend tests: - licensed-users-info: cover the SQLite in-memory auto-disable path (empty router + warn), the CSV conversion failure (500), and the no-catalog-match enrichment branch. Add Layer 1 unit tests for CatalogEntityStore (ref mapping/filtering) and DatabaseUserInfoStore (count + "no user info found" error). Add clamp boundary cases. - dynamic-plugins-info: replace the DynamicPluginManager private-field hack with a stub against the public plugins() contract, cover the front-end plugin branch, and add a Layer 2 integration test that boots the real plugin via a mocked dynamicPluginsServiceRef. - Dedup the permissionCheck setup with a small helper. Both plugins now report 100% statement/branch/function/line coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/service/router.integration.test.ts | 60 ++++++++++++++ .../src/service/router.test.ts | 29 +++++-- .../database/databaseUserInfoStore.test.ts | 57 +++++++++++++ .../src/service/catalogStore.test.ts | 77 +++++++++++++++++ .../readBackstageTokenExpiration.test.ts | 14 ++++ .../src/service/router.test.ts | 82 ++++++++++++++++--- 6 files changed, 302 insertions(+), 17 deletions(-) create mode 100644 plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts create mode 100644 plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts create mode 100644 plugins/licensed-users-info-backend/src/service/catalogStore.test.ts diff --git a/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts b/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts new file mode 100644 index 0000000000..4e950cce66 --- /dev/null +++ b/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + DynamicPluginProvider, + dynamicPluginsServiceRef, +} from '@backstage/backend-dynamic-feature-service'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; + +import request from 'supertest'; + +import { plugins } from '../../__fixtures__/data'; +import { dynamicPluginsInfoPlugin } from '../plugin'; + +// The plugin resolves its dynamic-plugin source from `dynamicPluginsServiceRef`. +// A stub factory feeds the same fixtures the unit tests use, so the real plugin +// wiring runs end-to-end without a dynamic-plugin runtime. +const mockDynamicPluginsService = createServiceFactory({ + service: dynamicPluginsServiceRef, + deps: {}, + async factory() { + return { plugins: () => plugins } as unknown as DynamicPluginProvider; + }, +}); + +describe('dynamic-plugins-info backend (Layer 2 integration)', () => { + it('starts the real plugin and serves the loaded-plugins endpoint', async () => { + const { server } = await startTestBackend({ + features: [ + dynamicPluginsInfoPlugin, + mockDynamicPluginsService, + mockServices.rootConfig.factory(), + ], + }); + + const response = await request(server) + .get('/api/dynamic-plugins-info/loaded-plugins') + .set('Authorization', mockCredentials.user.header()); + + expect(response.status).toEqual(200); + expect(response.body.length).toBeGreaterThan(0); + }, 30_000); +}); diff --git a/plugins/dynamic-plugins-info-backend/src/service/router.test.ts b/plugins/dynamic-plugins-info-backend/src/service/router.test.ts index dfd19f2650..9bb0062494 100644 --- a/plugins/dynamic-plugins-info-backend/src/service/router.test.ts +++ b/plugins/dynamic-plugins-info-backend/src/service/router.test.ts @@ -1,4 +1,4 @@ -import { DynamicPluginManager } from '@backstage/backend-dynamic-feature-service'; +import { DynamicPluginProvider } from '@backstage/backend-dynamic-feature-service'; import { mockServices } from '@backstage/backend-test-utils'; import express from 'express'; @@ -12,13 +12,14 @@ const buildApp = async ( pluginList: unknown[], httpAuth = mockServices.httpAuth(), ) => { - // NOTE: the assertion is required to instantiate the manager without its - // runtime args and seed the private `_plugins` field directly from fixtures. - const pluginManager = new (DynamicPluginManager as any)(); - pluginManager._plugins = pluginList; + // The router only consumes `pluginProvider.plugins()`, so a stub against + // that public method is enough and avoids coupling to the manager internals. + const pluginProvider = { + plugins: () => pluginList, + } as unknown as DynamicPluginProvider; const router = await createRouter({ - pluginProvider: pluginManager, + pluginProvider, discovery: mockServices.discovery(), httpAuth, config: mockServices.rootConfig(), @@ -63,6 +64,22 @@ describe('createRouter', () => { expect(response.body).toEqual([]); }); + it('returns front-end plugins unchanged (no installer stripping)', async () => { + const frontendPlugin = { + name: 'backstage-plugin-example', + version: '1.0.0', + platform: 'web', + role: 'frontend-plugin', + }; + + const app = await buildApp([frontendPlugin]); + + const response = await request(app).get('/loaded-plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([frontendPlugin]); + }); + it('enforces authentication before returning the plugin list', async () => { const httpAuth = mockServices.httpAuth(); const credentialsSpy = jest.spyOn(httpAuth, 'credentials'); diff --git a/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts b/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts new file mode 100644 index 0000000000..1bb5d7a8a0 --- /dev/null +++ b/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { Knex } from 'knex'; + +import { DatabaseUserInfoStore, UserInfoRow } from './databaseUserInfoStore'; + +// Knex query builders are both thenable (resolve to rows) and chainable +// (`.count().first()`). The helper reproduces both shapes from a single call +// so the store can be unit-tested without a real database. +const mockDatabase = (rows: UserInfoRow[], countResult?: { count: number }) => { + const query: any = Promise.resolve(rows); + query.count = jest.fn().mockReturnValue({ + first: jest.fn().mockResolvedValue(countResult), + }); + return jest.fn().mockReturnValue(query) as unknown as Knex; +}; + +const userRow: UserInfoRow = { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01 12:00:00', +}; + +describe('DatabaseUserInfoStore', () => { + it('returns the recorded user rows', async () => { + const store = new DatabaseUserInfoStore(mockDatabase([userRow])); + + await expect(store.getListUsers()).resolves.toEqual([userRow]); + }); + + it('returns the active user count', async () => { + const store = new DatabaseUserInfoStore(mockDatabase([], { count: 7 })); + + await expect(store.getQuantityRecordedActiveUsers()).resolves.toEqual(7); + }); + + it('throws when the count query returns no result', async () => { + const store = new DatabaseUserInfoStore(mockDatabase([], undefined)); + + await expect(store.getQuantityRecordedActiveUsers()).rejects.toThrow( + 'No user info found', + ); + }); +}); diff --git a/plugins/licensed-users-info-backend/src/service/catalogStore.test.ts b/plugins/licensed-users-info-backend/src/service/catalogStore.test.ts new file mode 100644 index 0000000000..16477dbff9 --- /dev/null +++ b/plugins/licensed-users-info-backend/src/service/catalogStore.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { mockServices } from '@backstage/backend-test-utils'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; + +import { CatalogEntityStore } from './catalogStore'; + +const buildStore = (items: Entity[]) => { + const getEntities = jest.fn().mockResolvedValue({ items }); + const catalogClient = { getEntities } as unknown as CatalogClient; + const store = new CatalogEntityStore(catalogClient, mockServices.auth()); + return { store, getEntities }; +}; + +describe('CatalogEntityStore', () => { + it('keys User entities by a lowercased default-namespace reference', async () => { + const { store } = buildStore([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'JDoe' }, + spec: { profile: { displayName: 'John Doe' } }, + }, + ]); + + const entityMap = await store.getUserEntities(); + + expect(entityMap.size).toEqual(1); + expect(entityMap.get('user:default/jdoe')).toMatchObject({ + metadata: { name: 'JDoe' }, + }); + }); + + it('ignores non-User kinds and entities without a name', async () => { + const { store } = buildStore([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'team-a' }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: {} as Entity['metadata'], + }, + ]); + + const entityMap = await store.getUserEntities(); + + expect(entityMap.size).toEqual(0); + }); + + it('queries the catalog for User entities with an on-behalf-of token', async () => { + const { store, getEntities } = buildStore([]); + + await store.getUserEntities(); + + expect(getEntities).toHaveBeenCalledWith( + expect.objectContaining({ filter: { kind: 'User' } }), + expect.objectContaining({ token: expect.any(String) }), + ); + }); +}); diff --git a/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts b/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts index 0cc20e88b0..9852d90032 100644 --- a/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts +++ b/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts @@ -43,4 +43,18 @@ describe('readBackstageTokenExpiration', () => { }); expect(readBackstageTokenExpiration(config)).toEqual(86400); }); + + it('returns the minimum boundary (600s) unchanged', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { seconds: 600 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(600); + }); + + it('returns the maximum boundary (86400s) unchanged', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { hours: 24 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(86400); + }); }); diff --git a/plugins/licensed-users-info-backend/src/service/router.test.ts b/plugins/licensed-users-info-backend/src/service/router.test.ts index ec9d5a061b..7b177cb83e 100644 --- a/plugins/licensed-users-info-backend/src/service/router.test.ts +++ b/plugins/licensed-users-info-backend/src/service/router.test.ts @@ -3,6 +3,7 @@ import { NotAllowedError } from '@backstage/errors'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import express from 'express'; +import { json2csv } from 'json-2-csv'; import request from 'supertest'; import { createRouter, permissionCheck, rowToResponse } from './router'; @@ -17,6 +18,13 @@ jest.mock('@backstage/backend-defaults/database', () => ({ }, })); +// Keep the real CSV serializer by default; individual tests override it to +// exercise the conversion-failure branch. +jest.mock('json-2-csv', () => { + const actual = jest.requireActual('json-2-csv'); + return { ...actual, json2csv: jest.fn(actual.json2csv) }; +}); + const mockGetQuantityRecordedActiveUsers = jest.fn(); const mockGetListUsers = jest.fn(); jest.mock('../database/databaseUserInfoStore', () => ({ @@ -140,6 +148,33 @@ describe('createRouter', () => { expect(response.text).toContain('user:default/jdoe'); }); + it('returns users without profile data when no catalog entity matches', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + mockGetUserEntities.mockResolvedValue(new Map()); + + const response = await request(app).get('/users'); + + expect(response.status).toEqual(200); + expect(response.body[0]).toEqual({ + userEntityRef: 'user:default/jdoe', + lastAuthTime: expect.any(String), + }); + }); + + it('returns 500 when CSV conversion fails', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + (json2csv as jest.Mock).mockImplementationOnce(() => { + throw new Error('conversion failed'); + }); + + const response = await request(app) + .get('/users') + .set('content-type', 'text/csv'); + + expect(response.status).toEqual(500); + expect(response.text).toContain('Error converting to CSV'); + }); + it('returns 403 when the caller is not authorized', async () => { permissions.authorize.mockResolvedValue([ { result: AuthorizeResult.DENY }, @@ -151,6 +186,31 @@ describe('createRouter', () => { expect(mockGetListUsers).not.toHaveBeenCalled(); }); }); + + describe('SQLite in-memory database', () => { + it('disables the router and warns when SQLite has no on-disk directory', async () => { + const logger = mockServices.logger.mock(); + const router = await createRouter({ + logger, + config: mockServices.rootConfig({ + data: { backend: { database: { client: 'better-sqlite3' } } }, + }), + auth: mockServices.auth.mock(), + discovery: mockServices.discovery.mock(), + permissions, + httpAuth: mockServices.httpAuth.mock(), + lifecycle: mockServices.lifecycle.mock(), + }); + const disabledApp = express().use(router); + + const response = await request(disabledApp).get('/health'); + + expect(response.status).toEqual(404); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('was disabled'), + ); + }); + }); }); describe('rowToResponse', () => { @@ -199,23 +259,23 @@ describe('rowToResponse', () => { describe('permissionCheck', () => { const credentials = mockCredentials.user(); - it('resolves when the decision is ALLOW', async () => { + const permissionsReturning = ( + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY, + ) => { const permissions = mockServices.permissions.mock(); - permissions.authorize.mockResolvedValue([ - { result: AuthorizeResult.ALLOW }, - ]); + permissions.authorize.mockResolvedValue([{ result }]); + return permissions; + }; + it('resolves when the decision is ALLOW', async () => { await expect( - permissionCheck(permissions, credentials), + permissionCheck(permissionsReturning(AuthorizeResult.ALLOW), credentials), ).resolves.toBeUndefined(); }); it('throws NotAllowedError when the decision is DENY', async () => { - const permissions = mockServices.permissions.mock(); - permissions.authorize.mockResolvedValue([{ result: AuthorizeResult.DENY }]); - - await expect(permissionCheck(permissions, credentials)).rejects.toThrow( - NotAllowedError, - ); + await expect( + permissionCheck(permissionsReturning(AuthorizeResult.DENY), credentials), + ).rejects.toThrow(NotAllowedError); }); }); From b5aadd5bb7ec8d31dd4ab9858cba37b5b501125f Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 18:26:27 -0300 Subject: [PATCH 3/8] test(backend): cover scalprum plugin and auth sign-in resolvers Expand Layer 1+2 coverage to the remaining backend code: - scalprum-backend: add a Layer 2 integration test (boots the real plugin via a mocked dynamicPluginsServiceRef) and a unit test for the "no matching package" warn branch. Plugin now at 100% coverage. - packages/backend auth resolvers (previously 0%): unit tests for resolverUtils (createOidcSubClaimResolver sub/id-token/mismatch paths, trySignInResolvers fallthrough) and rhdhSignInResolvers (preferred username, oauth2-proxy header precedence, LDAP uuid matching, Keycloak and Ping Identity sub-claim wiring, dangerous fallback options). resolverUtils.ts, rhdhSignInResolvers.ts and scalprum router/plugin all report 100% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/src/modules/resolverUtils.test.ts | 142 ++++++++++ .../src/modules/rhdhSignInResolvers.test.ts | 255 ++++++++++++++++++ .../src/service/router.integration.test.ts | 41 +++ .../src/service/router.test.ts | 37 +++ 4 files changed, 475 insertions(+) create mode 100644 packages/backend/src/modules/resolverUtils.test.ts create mode 100644 packages/backend/src/modules/rhdhSignInResolvers.test.ts create mode 100644 plugins/scalprum-backend/src/service/router.integration.test.ts diff --git a/packages/backend/src/modules/resolverUtils.test.ts b/packages/backend/src/modules/resolverUtils.test.ts new file mode 100644 index 0000000000..7736d551a6 --- /dev/null +++ b/packages/backend/src/modules/resolverUtils.test.ts @@ -0,0 +1,142 @@ +import type { OidcAuthResult } from '@backstage/plugin-auth-backend-module-oidc-provider'; +import { + AuthResolverContext, + BackstageSignInResult, + OAuthAuthenticatorResult, + SignInInfo, + SignInResolver, +} from '@backstage/plugin-auth-node'; + +import { decodeJwt } from 'jose'; + +import { + createOidcSubClaimResolver, + OidcProviderInfo, + trySignInResolvers, +} from './resolverUtils'; + +jest.mock('jose', () => ({ decodeJwt: jest.fn() })); + +const decodeJwtMock = decodeJwt as jest.Mock; + +const signInResult = { token: 'mock-token' } as BackstageSignInResult; + +const buildContext = () => + ({ + signInWithCatalogUser: jest.fn().mockResolvedValue(signInResult), + }) as unknown as AuthResolverContext; + +const KEYCLOAK: OidcProviderInfo = { + userIdKey: 'keycloak.org/id', + providerName: 'Keycloak', +}; + +const buildOidcInfo = (sub?: string, idToken?: string) => + ({ + result: { + fullProfile: { + userinfo: { sub }, + tokenset: { id_token: idToken }, + }, + }, + }) as unknown as SignInInfo>; + +describe('createOidcSubClaimResolver', () => { + beforeEach(() => jest.clearAllMocks()); + + it('signs in with the provider annotation when sub matches the id token', async () => { + decodeJwtMock.mockReturnValue({ sub: 'user-123' }); + const ctx = buildContext(); + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + const result = await resolver(buildOidcInfo('user-123', 'id.token'), ctx); + + expect(result).toBe(signInResult); + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'keycloak.org/id': 'user-123' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when the sub claim is missing', async () => { + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo(undefined, 'id.token'), buildContext()), + ).rejects.toThrow(/missing a 'sub' claim/); + }); + + it('throws when the id token is missing', async () => { + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo('user-123', undefined), buildContext()), + ).rejects.toThrow(/user ID token from Keycloak is missing/); + }); + + it('throws when the sub claim does not match the id token', async () => { + decodeJwtMock.mockReturnValue({ sub: 'someone-else' }); + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo('user-123', 'id.token'), buildContext()), + ).rejects.toThrow(/mismatching 'sub' claim/); + }); + + it('passes a dangerous entity ref fallback when explicitly allowed', async () => { + decodeJwtMock.mockReturnValue({ sub: 'user-123' }); + const ctx = buildContext(); + const resolver = createOidcSubClaimResolver(KEYCLOAK)({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver(buildOidcInfo('user-123', 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'keycloak.org/id': 'user-123' } }, + { dangerousEntityRefFallback: { entityRef: 'user-123' } }, + ); + }); +}); + +describe('trySignInResolvers', () => { + const info = {} as SignInInfo; + const ctx = {} as AuthResolverContext; + + it('returns the result of the first resolver that succeeds', async () => { + const first: SignInResolver = jest + .fn() + .mockResolvedValue(signInResult); + const second: SignInResolver = jest.fn(); + + const result = await trySignInResolvers([first, second])(info, ctx); + + expect(result).toBe(signInResult); + expect(second).not.toHaveBeenCalled(); + }); + + it('skips failing resolvers and uses the next that succeeds', async () => { + const failing: SignInResolver = jest + .fn() + .mockRejectedValue(new Error('no match')); + const succeeding: SignInResolver = jest + .fn() + .mockResolvedValue(signInResult); + + const result = await trySignInResolvers([failing, succeeding])(info, ctx); + + expect(result).toBe(signInResult); + expect(failing).toHaveBeenCalled(); + expect(succeeding).toHaveBeenCalled(); + }); + + it('throws a descriptive error when every resolver fails', async () => { + const failing: SignInResolver = jest + .fn() + .mockRejectedValue(new Error('no match')); + + await expect( + trySignInResolvers([failing, failing])(info, ctx), + ).rejects.toThrow(/unable to resolve user identity/); + }); +}); diff --git a/packages/backend/src/modules/rhdhSignInResolvers.test.ts b/packages/backend/src/modules/rhdhSignInResolvers.test.ts new file mode 100644 index 0000000000..821234c1ff --- /dev/null +++ b/packages/backend/src/modules/rhdhSignInResolvers.test.ts @@ -0,0 +1,255 @@ +import type { OAuth2ProxyResult } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider'; +import type { OidcAuthResult } from '@backstage/plugin-auth-backend-module-oidc-provider'; +import { + AuthResolverContext, + BackstageSignInResult, + OAuthAuthenticatorResult, + SignInInfo, +} from '@backstage/plugin-auth-node'; + +import { decodeJwt } from 'jose'; + +import { rhdhSignInResolvers } from './rhdhSignInResolvers'; + +jest.mock('jose', () => ({ decodeJwt: jest.fn() })); + +const decodeJwtMock = decodeJwt as jest.Mock; + +const signInResult = { token: 'mock-token' } as BackstageSignInResult; + +const buildContext = () => + ({ + signInWithCatalogUser: jest.fn().mockResolvedValue(signInResult), + }) as unknown as AuthResolverContext; + +const buildOidcInfo = (userinfo: Record, idToken?: string) => + ({ + result: { + fullProfile: { + userinfo, + tokenset: { id_token: idToken }, + }, + }, + }) as unknown as SignInInfo>; + +const buildProxyInfo = (headers: Record) => + ({ + result: { + getHeader: (name: string) => headers[name], + }, + }) as unknown as SignInInfo; + +beforeEach(() => jest.clearAllMocks()); + +describe('preferredUsernameMatchingUserEntityName', () => { + it('signs in using the preferred_username as the entity name', async () => { + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.preferredUsernameMatchingUserEntityName(); + + await resolver(buildOidcInfo({ preferred_username: 'jdoe' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'jdoe' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when the profile has no username', async () => { + const resolver = + rhdhSignInResolvers.preferredUsernameMatchingUserEntityName(); + + await expect(resolver(buildOidcInfo({}), buildContext())).rejects.toThrow( + 'OIDC user profile does not contain a username', + ); + }); + + it('passes a fallback entity ref when explicitly allowed', async () => { + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.preferredUsernameMatchingUserEntityName({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver(buildOidcInfo({ preferred_username: 'jdoe' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'jdoe' } }, + { dangerousEntityRefFallback: { entityRef: 'jdoe' } }, + ); + }); +}); + +describe('oauth2ProxyUserHeaderMatchingUserEntityName', () => { + const ORIGINAL_HEADER_ENV = process.env.OAUTH_USER_HEADER; + + afterEach(() => { + if (ORIGINAL_HEADER_ENV === undefined) { + delete process.env.OAUTH_USER_HEADER; + } else { + process.env.OAUTH_USER_HEADER = ORIGINAL_HEADER_ENV; + } + }); + + it('reads the user from the configured OAUTH_USER_HEADER', async () => { + process.env.OAUTH_USER_HEADER = 'x-custom-user'; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await resolver(buildProxyInfo({ 'x-custom-user': 'alice' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'alice' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('falls back to x-forwarded-preferred-username then x-forwarded-user', async () => { + delete process.env.OAUTH_USER_HEADER; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await resolver(buildProxyInfo({ 'x-forwarded-user': 'carol' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'carol' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when no user header is present', async () => { + delete process.env.OAUTH_USER_HEADER; + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await expect(resolver(buildProxyInfo({}), buildContext())).rejects.toThrow( + 'Request did not contain a user', + ); + }); + + it('passes a fallback entity ref when explicitly allowed', async () => { + delete process.env.OAUTH_USER_HEADER; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver( + buildProxyInfo({ 'x-forwarded-preferred-username': 'bob' }), + ctx, + ); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'bob' } }, + { dangerousEntityRefFallback: { entityRef: 'bob' } }, + ); + }); +}); + +describe('oidcLdapUuidMatchingAnnotation', () => { + it('signs in with the ldap-uuid annotation when the uuid matches the id token', async () => { + decodeJwtMock.mockReturnValue({ ldap_uuid: 'uuid-1' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await resolver(buildOidcInfo({ ldap_uuid: 'uuid-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'backstage.io/ldap-uuid': 'uuid-1' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('honors a custom ldapUuidKey option', async () => { + decodeJwtMock.mockReturnValue({ custom_uuid: 'uuid-2' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation({ + ldapUuidKey: 'custom_uuid', + }); + + await resolver(buildOidcInfo({ custom_uuid: 'uuid-2' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'backstage.io/ldap-uuid': 'uuid-2' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when the uuid is missing', async () => { + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await expect( + resolver(buildOidcInfo({}, 'id.token'), buildContext()), + ).rejects.toThrow(/missing the UUID/); + }); + + it('throws when the id token is missing', async () => { + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await expect( + resolver( + buildOidcInfo({ ldap_uuid: 'uuid-1' }, undefined), + buildContext(), + ), + ).rejects.toThrow(/user ID token from LDAP is missing/); + }); + + it('throws when the uuid does not match the id token', async () => { + decodeJwtMock.mockReturnValue({ ldap_uuid: 'someone-else' }); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await expect( + resolver( + buildOidcInfo({ ldap_uuid: 'uuid-1' }, 'id.token'), + buildContext(), + ), + ).rejects.toThrow(/mismatching UUID/); + }); + + it('passes a fallback entity ref when explicitly allowed', async () => { + decodeJwtMock.mockReturnValue({ ldap_uuid: 'uuid-1' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver(buildOidcInfo({ ldap_uuid: 'uuid-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'backstage.io/ldap-uuid': 'uuid-1' } }, + { dangerousEntityRefFallback: { entityRef: 'uuid-1' } }, + ); + }); +}); + +describe('oidc sub-claim resolvers', () => { + it('matches Keycloak users on the keycloak.org/id annotation', async () => { + decodeJwtMock.mockReturnValue({ sub: 'kc-1' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcSubClaimMatchingKeycloakUserId(); + + await resolver(buildOidcInfo({ sub: 'kc-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'keycloak.org/id': 'kc-1' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('matches Ping Identity users on the pingidentity.org/id annotation', async () => { + decodeJwtMock.mockReturnValue({ sub: 'ping-1' }); + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oidcSubClaimMatchingPingIdentityUserId(); + + await resolver(buildOidcInfo({ sub: 'ping-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'pingidentity.org/id': 'ping-1' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); +}); diff --git a/plugins/scalprum-backend/src/service/router.integration.test.ts b/plugins/scalprum-backend/src/service/router.integration.test.ts new file mode 100644 index 0000000000..369f325fe2 --- /dev/null +++ b/plugins/scalprum-backend/src/service/router.integration.test.ts @@ -0,0 +1,41 @@ +import { + DynamicPluginProvider, + dynamicPluginsServiceRef, +} from '@backstage/backend-dynamic-feature-service'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; + +import request from 'supertest'; + +import { scalprumPlugin } from '../plugin'; + +// The plugin resolves dynamic frontend packages from `dynamicPluginsServiceRef`. +// An empty stub exercises the real plugin wiring (router + unauthenticated auth +// policy) without a dynamic-plugin runtime. +const mockDynamicPluginsService = createServiceFactory({ + service: dynamicPluginsServiceRef, + deps: {}, + async factory() { + return { + plugins: () => [], + availablePackages: [], + } as unknown as DynamicPluginProvider; + }, +}); + +describe('scalprum backend (Layer 2 integration)', () => { + it('starts the real plugin and serves the plugins endpoint unauthenticated', async () => { + const { server } = await startTestBackend({ + features: [ + scalprumPlugin, + mockDynamicPluginsService, + mockServices.rootConfig.factory(), + ], + }); + + const response = await request(server).get('/api/scalprum/plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({}); + }, 30_000); +}); diff --git a/plugins/scalprum-backend/src/service/router.test.ts b/plugins/scalprum-backend/src/service/router.test.ts index c373838912..8d9ac560c6 100644 --- a/plugins/scalprum-backend/src/service/router.test.ts +++ b/plugins/scalprum-backend/src/service/router.test.ts @@ -193,4 +193,41 @@ describe('createRouter', () => { expect(warn).toHaveBeenCalledWith(tc.expectedWarning); } }); + + it('warns and skips a web plugin that has no matching package', async () => { + const warn = jest.fn(); + const logger: LoggerService = { + error: jest.fn(), + warn, + info: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + }; + + const pluginManager = { + availablePackages: [], + plugins: () => [ + { name: 'orphan-plugin', version: '1.0.0', platform: 'web' }, + ], + } as unknown as DynamicPluginManager; + + router = await createRouter({ + logger, + discovery: { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn().mockReturnValue('http://localhost:3000'), + }, + pluginManager, + config: mockServices.rootConfig(), + }); + + app.use('/scalprum', router); + const response = await request(app).get('/scalprum/plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({}); + expect(warn).toHaveBeenCalledWith( + 'Could not find package for plugin orphan-plugin@1.0.0', + ); + }); }); From cb0ef88ee87ed2218e7e6279a541c1c57b7f48c4 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 18:33:15 -0300 Subject: [PATCH 4/8] test(backend): tighten auth resolver assertions Address review feedback on the sign-in resolver tests: - oauth2-proxy: split the fallback test into an explicit precedence case (preferred-username wins when both headers are present) and the x-forwarded-user fallback, so the header ordering is actually pinned. - trySignInResolvers: assert each resolver is attempted exactly once for both the skip-then-succeed and all-fail paths, locking the iteration contract against short-circuit regressions. - createOidcSubClaimResolver: cover an id token that carries no sub claim. - Use `as unknown as` for the empty context/info doubles. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/src/modules/resolverUtils.test.ts | 28 ++++++++++++++----- .../src/modules/rhdhSignInResolvers.test.ts | 22 ++++++++++++++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/modules/resolverUtils.test.ts b/packages/backend/src/modules/resolverUtils.test.ts index 7736d551a6..d832c5d9d8 100644 --- a/packages/backend/src/modules/resolverUtils.test.ts +++ b/packages/backend/src/modules/resolverUtils.test.ts @@ -83,6 +83,15 @@ describe('createOidcSubClaimResolver', () => { ).rejects.toThrow(/mismatching 'sub' claim/); }); + it('throws when the id token carries no sub claim', async () => { + decodeJwtMock.mockReturnValue({}); + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo('user-123', 'id.token'), buildContext()), + ).rejects.toThrow(/mismatching 'sub' claim/); + }); + it('passes a dangerous entity ref fallback when explicitly allowed', async () => { decodeJwtMock.mockReturnValue({ sub: 'user-123' }); const ctx = buildContext(); @@ -100,8 +109,8 @@ describe('createOidcSubClaimResolver', () => { }); describe('trySignInResolvers', () => { - const info = {} as SignInInfo; - const ctx = {} as AuthResolverContext; + const info = {} as unknown as SignInInfo; + const ctx = {} as unknown as AuthResolverContext; it('returns the result of the first resolver that succeeds', async () => { const first: SignInResolver = jest @@ -126,17 +135,22 @@ describe('trySignInResolvers', () => { const result = await trySignInResolvers([failing, succeeding])(info, ctx); expect(result).toBe(signInResult); - expect(failing).toHaveBeenCalled(); - expect(succeeding).toHaveBeenCalled(); + expect(failing).toHaveBeenCalledTimes(1); + expect(succeeding).toHaveBeenCalledTimes(1); }); - it('throws a descriptive error when every resolver fails', async () => { - const failing: SignInResolver = jest + it('throws a descriptive error after attempting every resolver', async () => { + const first: SignInResolver = jest + .fn() + .mockRejectedValue(new Error('no match')); + const second: SignInResolver = jest .fn() .mockRejectedValue(new Error('no match')); await expect( - trySignInResolvers([failing, failing])(info, ctx), + trySignInResolvers([first, second])(info, ctx), ).rejects.toThrow(/unable to resolve user identity/); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/backend/src/modules/rhdhSignInResolvers.test.ts b/packages/backend/src/modules/rhdhSignInResolvers.test.ts index 821234c1ff..628c89d408 100644 --- a/packages/backend/src/modules/rhdhSignInResolvers.test.ts +++ b/packages/backend/src/modules/rhdhSignInResolvers.test.ts @@ -105,7 +105,27 @@ describe('oauth2ProxyUserHeaderMatchingUserEntityName', () => { ); }); - it('falls back to x-forwarded-preferred-username then x-forwarded-user', async () => { + it('prefers x-forwarded-preferred-username over x-forwarded-user', async () => { + delete process.env.OAUTH_USER_HEADER; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await resolver( + buildProxyInfo({ + 'x-forwarded-preferred-username': 'preferred', + 'x-forwarded-user': 'fallback', + }), + ctx, + ); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'preferred' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('falls back to x-forwarded-user when preferred-username is absent', async () => { delete process.env.OAUTH_USER_HEADER; const ctx = buildContext(); const resolver = From 89b099a91b0faac7b103466172f06e64a555a6c9 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 18:42:47 -0300 Subject: [PATCH 5/8] test(backend): broaden Layer 2 integration coverage A single startTestBackend smoke per plugin underused Layer 2. Add a distinct end-to-end scenario to each so the real wiring (HTTP routing, auth, config-driven init, static serving) is actually exercised: - scalprum: boot a web plugin from a mock dist-scalprum directory and assert both the /plugins listing and the statically served manifest. - dynamic-plugins-info: assert installer details are stripped over the real backend, and that service principals (not just users) are authorized to read the plugin list. - licensed-users-info: assert the plugin disables all routes (404) when configured with a pure in-memory SQLite database, through real init. All three plugins remain at 100% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/service/router.integration.test.ts | 33 ++++++-- .../src/service/router.integration.test.ts | 24 ++++++ .../src/service/router.integration.test.ts | 80 +++++++++++++++---- 3 files changed, 114 insertions(+), 23 deletions(-) diff --git a/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts b/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts index 4e950cce66..dd53865d30 100644 --- a/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts +++ b/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts @@ -40,15 +40,18 @@ const mockDynamicPluginsService = createServiceFactory({ }, }); +const startBackend = () => + startTestBackend({ + features: [ + dynamicPluginsInfoPlugin, + mockDynamicPluginsService, + mockServices.rootConfig.factory(), + ], + }); + describe('dynamic-plugins-info backend (Layer 2 integration)', () => { - it('starts the real plugin and serves the loaded-plugins endpoint', async () => { - const { server } = await startTestBackend({ - features: [ - dynamicPluginsInfoPlugin, - mockDynamicPluginsService, - mockServices.rootConfig.factory(), - ], - }); + it('serves the loaded-plugins endpoint to a user and strips installer details', async () => { + const { server } = await startBackend(); const response = await request(server) .get('/api/dynamic-plugins-info/loaded-plugins') @@ -56,5 +59,19 @@ describe('dynamic-plugins-info backend (Layer 2 integration)', () => { expect(response.status).toEqual(200); expect(response.body.length).toBeGreaterThan(0); + for (const plugin of response.body) { + expect(plugin).not.toHaveProperty('installer'); + } + }, 30_000); + + it('authorizes service principals to read the plugin list', async () => { + const { server } = await startBackend(); + + const response = await request(server) + .get('/api/dynamic-plugins-info/loaded-plugins') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toEqual(200); + expect(response.body.length).toBeGreaterThan(0); }, 30_000); }); diff --git a/plugins/licensed-users-info-backend/src/service/router.integration.test.ts b/plugins/licensed-users-info-backend/src/service/router.integration.test.ts index a30729a52d..65ebfb623a 100644 --- a/plugins/licensed-users-info-backend/src/service/router.integration.test.ts +++ b/plugins/licensed-users-info-backend/src/service/router.integration.test.ts @@ -34,6 +34,18 @@ const sqliteConfig = mockServices.rootConfig.factory({ }, }); +// Pure in-memory SQLite (no `directory`) trips the plugin's self-disable check. +const inMemoryConfig = mockServices.rootConfig.factory({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, +}); + describe('licensed-users-info backend (Layer 2 integration)', () => { it('starts the real plugin and serves the health endpoint', async () => { const { server } = await startTestBackend({ @@ -47,4 +59,16 @@ describe('licensed-users-info backend (Layer 2 integration)', () => { expect(response.status).toEqual(200); expect(response.body).toEqual({ status: 'ok' }); }, 30_000); + + it('disables all routes under a pure in-memory SQLite database', async () => { + const { server } = await startTestBackend({ + features: [licensedUsersInfoPlugin, inMemoryConfig], + }); + + const response = await request(server).get( + '/api/licensed-users-info/health', + ); + + expect(response.status).toEqual(404); + }, 30_000); }); diff --git a/plugins/scalprum-backend/src/service/router.integration.test.ts b/plugins/scalprum-backend/src/service/router.integration.test.ts index 369f325fe2..28ce8d6c5c 100644 --- a/plugins/scalprum-backend/src/service/router.integration.test.ts +++ b/plugins/scalprum-backend/src/service/router.integration.test.ts @@ -3,32 +3,42 @@ import { dynamicPluginsServiceRef, } from '@backstage/backend-dynamic-feature-service'; import { createServiceFactory } from '@backstage/backend-plugin-api'; -import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { + createMockDirectory, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; import request from 'supertest'; +import url from 'url'; + import { scalprumPlugin } from '../plugin'; // The plugin resolves dynamic frontend packages from `dynamicPluginsServiceRef`. -// An empty stub exercises the real plugin wiring (router + unauthenticated auth -// policy) without a dynamic-plugin runtime. -const mockDynamicPluginsService = createServiceFactory({ - service: dynamicPluginsServiceRef, - deps: {}, - async factory() { - return { - plugins: () => [], - availablePackages: [], - } as unknown as DynamicPluginProvider; - }, -}); +// A stub lets each test drive the real plugin wiring (router, static serving, +// unauthenticated auth policy) without a dynamic-plugin runtime. +const dynamicPluginsServiceWith = ( + plugins: unknown[], + availablePackages: unknown[], +) => + createServiceFactory({ + service: dynamicPluginsServiceRef, + deps: {}, + async factory() { + return { + plugins: () => plugins, + availablePackages, + } as unknown as DynamicPluginProvider; + }, + }); describe('scalprum backend (Layer 2 integration)', () => { - it('starts the real plugin and serves the plugins endpoint unauthenticated', async () => { + it('serves an empty plugin map unauthenticated when no frontend plugins are loaded', async () => { const { server } = await startTestBackend({ features: [ scalprumPlugin, - mockDynamicPluginsService, + dynamicPluginsServiceWith([], []), mockServices.rootConfig.factory(), ], }); @@ -38,4 +48,44 @@ describe('scalprum backend (Layer 2 integration)', () => { expect(response.status).toEqual(200); expect(response.body).toEqual({}); }, 30_000); + + it('lists a web plugin and serves its manifest as static content', async () => { + const mockDir = createMockDirectory(); + mockDir.setContent({ + 'dist-scalprum': { + 'plugin-manifest.json': JSON.stringify({ name: 'scalprum-plugin' }), + }, + }); + + const { server } = await startTestBackend({ + features: [ + scalprumPlugin, + dynamicPluginsServiceWith( + [{ name: 'frontend-plugin', version: '1.0.0', platform: 'web' }], + [ + { + manifest: { name: 'frontend-plugin', version: '1.0.0' }, + location: url.pathToFileURL(mockDir.path), + }, + ], + ), + mockServices.rootConfig.factory(), + ], + }); + + const list = await request(server).get('/api/scalprum/plugins'); + expect(list.status).toEqual(200); + expect(list.body['scalprum-plugin']).toMatchObject({ + name: 'scalprum-plugin', + manifestLocation: expect.stringContaining( + '/scalprum-plugin/plugin-manifest.json', + ), + }); + + const manifest = await request(server).get( + '/api/scalprum/scalprum-plugin/plugin-manifest.json', + ); + expect(manifest.status).toEqual(200); + expect(manifest.body).toEqual({ name: 'scalprum-plugin' }); + }, 30_000); }); From 117110cc3e9a23f33c7adbb7f6f6715bdc9efcda Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 18:45:14 -0300 Subject: [PATCH 6/8] test(backend): cover RBAC dynamic plugin-id normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pluginIDProviderService derives RBAC plugin ids from dynamic backend packages via a regex that strips scope/plugin prefixes and the -backend-dynamic suffix — previously untested. Add Layer 1 tests (via ServiceFactoryTester) covering the core ids, name normalization across @scope/plugin-, backstage-plugin- and bare prefix styles, and exclusion of non-backend packages. The factory logic is now 100% branch-covered. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modules/rbacDynamicPluginsModule.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 packages/backend/src/modules/rbacDynamicPluginsModule.test.ts diff --git a/packages/backend/src/modules/rbacDynamicPluginsModule.test.ts b/packages/backend/src/modules/rbacDynamicPluginsModule.test.ts new file mode 100644 index 0000000000..ddcb19356a --- /dev/null +++ b/packages/backend/src/modules/rbacDynamicPluginsModule.test.ts @@ -0,0 +1,67 @@ +import { + DynamicPluginProvider, + dynamicPluginsServiceRef, +} from '@backstage/backend-dynamic-feature-service'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { ServiceFactoryTester } from '@backstage/backend-test-utils'; + +import { pluginIDProviderService } from './rbacDynamicPluginsModule'; + +const pkg = (name: string, role = 'backend-plugin') => ({ + manifest: { name, backstage: { role } }, +}); + +const dynamicPluginsWith = (availablePackages: unknown[]) => + createServiceFactory({ + service: dynamicPluginsServiceRef, + deps: {}, + async factory() { + return { availablePackages } as unknown as DynamicPluginProvider; + }, + }); + +const resolvePluginIds = async (availablePackages: unknown[]) => { + const tester = ServiceFactoryTester.from(pluginIDProviderService, { + dependencies: [dynamicPluginsWith(availablePackages)], + }); + const provider = await tester.getSubject(); + return provider.getPluginIds(); +}; + +describe('pluginIDProviderService', () => { + it('always exposes the core plugin ids', async () => { + expect(await resolvePluginIds([])).toEqual([ + 'catalog', + 'scaffolder', + 'permission', + ]); + }); + + it('normalizes backend dynamic plugin package names across naming styles', async () => { + const ids = await resolvePluginIds([ + pkg('@backstage/plugin-foo-backend-dynamic'), + pkg('backstage-plugin-bar-backend-dynamic'), + pkg('@redhat/backstage-plugin-baz-backend-dynamic'), + pkg('custom-plugin-qux-backend-dynamic'), + ]); + + expect(ids).toEqual([ + 'catalog', + 'scaffolder', + 'permission', + 'foo', + 'bar', + 'baz', + 'qux', + ]); + }); + + it('ignores packages that are not backend plugins', async () => { + const ids = await resolvePluginIds([ + pkg('@backstage/plugin-frontend-dynamic', 'frontend-plugin'), + { manifest: { name: 'no-role-plugin' } }, + ]); + + expect(ids).toEqual(['catalog', 'scaffolder', 'permission']); + }); +}); From 2cf371b7114195df5284f93d63d8c4e81521aa50 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 18:52:06 -0300 Subject: [PATCH 7/8] ci(codecov): add component-level coverage breakdown The comment layout already renders a `components` section but no components were defined, so the dashboard and PR comment showed none. Define path-based components for the main source areas (backend plugins, backend app, frontend app, plugin-utils, dynamic-plugins utils) so coverage is visible per area. Components are views over existing uploads, so no CI changes are needed; statuses are informational while coverage matures, matching the project/patch approach. Validated via codecov.io/validate. Co-Authored-By: Claude Opus 4.7 (1M context) --- codecov.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/codecov.yml b/codecov.yml index d01e52090a..e4edafcd5f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -73,6 +73,39 @@ ignore: # - install-dynamic-plugins: the pytest-based install script coverage # Additional flags (overlays-e2e-*, community-*) will be introduced by # the respective Stories under RHIDP-11866 and RHIDP-11865. +# Components give a per-area coverage breakdown in the dashboard and in the +# PR comment (the comment layout above already renders a `components` section). +# Unlike flags, components are pure path-based views over the same uploads, so +# they need no CI changes. Statuses are informational while coverage matures, +# matching the project/patch approach above. +component_management: + default_rules: + statuses: + - type: project + target: auto + informational: true + individual_components: + - component_id: backend_plugins + name: Backend plugins + paths: + - plugins/*/src/** + - component_id: backend_app + name: Backend app + paths: + - packages/backend/src/** + - component_id: frontend_app + name: Frontend app + paths: + - packages/app/src/** + - component_id: plugin_utils + name: Plugin utils + paths: + - packages/plugin-utils/src/** + - component_id: dynamic_plugins_utils + name: Dynamic plugins utils + paths: + - dynamic-plugins/_utils/src/** + flag_management: default_rules: carryforward: true From 1fa927b16080548de63b396e9b8da82b41dd2215 Mon Sep 17 00:00:00 2001 From: Gustavo Lira Date: Wed, 20 May 2026 19:10:23 -0300 Subject: [PATCH 8/8] test(backend): address Qodo review on Layer 2 tests - scalprum integration test: create the mock directory at describe scope so its afterAll cleanup is registered and the temp dir does not leak across tests (Qodo: test isolation). - databaseUserInfoStore: expose the query spies from the Knex mock and assert the store calls the user_info table and the count query with the expected alias, so failures are clearer and less sensitive to the thenable/chainable interop (Qodo: mock robustness). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../database/databaseUserInfoStore.test.ts | 28 ++++++++++++------- .../src/service/router.integration.test.ts | 5 +++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts b/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts index 1bb5d7a8a0..70e378913d 100644 --- a/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts +++ b/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts @@ -19,13 +19,16 @@ import { DatabaseUserInfoStore, UserInfoRow } from './databaseUserInfoStore'; // Knex query builders are both thenable (resolve to rows) and chainable // (`.count().first()`). The helper reproduces both shapes from a single call -// so the store can be unit-tested without a real database. +// and exposes the spies so tests can assert the exact query methods the store +// invokes, keeping failures clear and less sensitive to the promise/method +// interop. const mockDatabase = (rows: UserInfoRow[], countResult?: { count: number }) => { + const first = jest.fn().mockResolvedValue(countResult); + const count = jest.fn().mockReturnValue({ first }); const query: any = Promise.resolve(rows); - query.count = jest.fn().mockReturnValue({ - first: jest.fn().mockResolvedValue(countResult), - }); - return jest.fn().mockReturnValue(query) as unknown as Knex; + query.count = count; + const table = jest.fn().mockReturnValue(query); + return { db: table as unknown as Knex, table, count, first }; }; const userRow: UserInfoRow = { @@ -35,20 +38,25 @@ const userRow: UserInfoRow = { }; describe('DatabaseUserInfoStore', () => { - it('returns the recorded user rows', async () => { - const store = new DatabaseUserInfoStore(mockDatabase([userRow])); + it('returns the recorded user rows from the user_info table', async () => { + const { db, table } = mockDatabase([userRow]); + const store = new DatabaseUserInfoStore(db); await expect(store.getListUsers()).resolves.toEqual([userRow]); + expect(table).toHaveBeenCalledWith('user_info'); }); - it('returns the active user count', async () => { - const store = new DatabaseUserInfoStore(mockDatabase([], { count: 7 })); + it('returns the active user count via a count query', async () => { + const { db, count } = mockDatabase([], { count: 7 }); + const store = new DatabaseUserInfoStore(db); await expect(store.getQuantityRecordedActiveUsers()).resolves.toEqual(7); + expect(count).toHaveBeenCalledWith('user_entity_ref as count'); }); it('throws when the count query returns no result', async () => { - const store = new DatabaseUserInfoStore(mockDatabase([], undefined)); + const { db } = mockDatabase([], undefined); + const store = new DatabaseUserInfoStore(db); await expect(store.getQuantityRecordedActiveUsers()).rejects.toThrow( 'No user info found', diff --git a/plugins/scalprum-backend/src/service/router.integration.test.ts b/plugins/scalprum-backend/src/service/router.integration.test.ts index 28ce8d6c5c..2965d1c83c 100644 --- a/plugins/scalprum-backend/src/service/router.integration.test.ts +++ b/plugins/scalprum-backend/src/service/router.integration.test.ts @@ -34,6 +34,10 @@ const dynamicPluginsServiceWith = ( }); describe('scalprum backend (Layer 2 integration)', () => { + // Created at describe scope so its afterAll cleanup is registered and the + // temporary directory does not leak across tests. + const mockDir = createMockDirectory(); + it('serves an empty plugin map unauthenticated when no frontend plugins are loaded', async () => { const { server } = await startTestBackend({ features: [ @@ -50,7 +54,6 @@ describe('scalprum backend (Layer 2 integration)', () => { }, 30_000); it('lists a web plugin and serves its manifest as static content', async () => { - const mockDir = createMockDirectory(); mockDir.setContent({ 'dist-scalprum': { 'plugin-manifest.json': JSON.stringify({ name: 'scalprum-plugin' }),