Skip to content
33 changes: 33 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions packages/backend/src/modules/rbacDynamicPluginsModule.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
156 changes: 156 additions & 0 deletions packages/backend/src/modules/resolverUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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<OAuthAuthenticatorResult<OidcAuthResult>>;

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('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();
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 unknown as SignInInfo<unknown>;
const ctx = {} as unknown as AuthResolverContext;

it('returns the result of the first resolver that succeeds', async () => {
const first: SignInResolver<unknown> = jest
.fn()
.mockResolvedValue(signInResult);
const second: SignInResolver<unknown> = 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<unknown> = jest
.fn()
.mockRejectedValue(new Error('no match'));
const succeeding: SignInResolver<unknown> = jest
.fn()
.mockResolvedValue(signInResult);

const result = await trySignInResolvers([failing, succeeding])(info, ctx);

expect(result).toBe(signInResult);
expect(failing).toHaveBeenCalledTimes(1);
expect(succeeding).toHaveBeenCalledTimes(1);
});

it('throws a descriptive error after attempting every resolver', async () => {
const first: SignInResolver<unknown> = jest
.fn()
.mockRejectedValue(new Error('no match'));
const second: SignInResolver<unknown> = jest
.fn()
.mockRejectedValue(new Error('no match'));

await expect(
trySignInResolvers([first, second])(info, ctx),
).rejects.toThrow(/unable to resolve user identity/);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
});
Loading
Loading