add an actual mock implementation of permissions

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2025-06-10 15:28:05 +02:00
parent c273b51430
commit 6dfb7be913
12 changed files with 219 additions and 120 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': minor
---
Added `mockServices.permissions()` that can return actual results.
+1
View File
@@ -52,6 +52,7 @@
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/types": "workspace:^",
"@keyv/memcache": "^2.0.1",
"@keyv/redis": "^4.0.1",
+10 -6
View File
@@ -6,6 +6,7 @@
import { ActionsRegistryService } from '@backstage/backend-plugin-api';
import { ActionsService } from '@backstage/backend-plugin-api';
import { AuditorService } from '@backstage/backend-plugin-api';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { AuthService } from '@backstage/backend-plugin-api';
import { Backend } from '@backstage/backend-app-api';
import { BackendFeature } from '@backstage/backend-plugin-api';
@@ -299,14 +300,17 @@ export namespace mockServices {
partialImpl?: Partial<LoggerService> | undefined,
) => ServiceMock<LoggerService>;
}
export function permissions(options?: {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}): PermissionsService;
// (undocumented)
export namespace permissions {
const // (undocumented)
factory: () => ServiceFactory<PermissionsService, 'plugin', 'singleton'>;
const // (undocumented)
mock: (
partialImpl?: Partial<PermissionsService> | undefined,
) => ServiceMock<PermissionsService>;
const factory: (options?: {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}) => ServiceFactory<PermissionsService, 'plugin', 'singleton'>;
const mock: (
partialImpl?: Partial<PermissionsService> | undefined,
) => ServiceMock<PermissionsService>;
}
// (undocumented)
export namespace permissionsRegistry {
@@ -0,0 +1,57 @@
/*
* Copyright 2025 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 { AuthorizeResult } from '@backstage/plugin-permission-common';
import { MockPermissionsService } from './MockPermissionsService';
describe('MockPermissionsService', () => {
it('should return the correct result', async () => {
const permission = {
permission: {
name: 'test',
type: 'basic',
attributes: {},
},
} as const;
const defaultService = new MockPermissionsService();
const allowService = new MockPermissionsService({
result: AuthorizeResult.ALLOW,
});
const denyService = new MockPermissionsService({
result: AuthorizeResult.DENY,
});
await expect(defaultService.authorize([permission])).resolves.toEqual([
{
...permission,
result: AuthorizeResult.ALLOW,
},
]);
await expect(allowService.authorize([permission])).resolves.toEqual([
{
...permission,
result: AuthorizeResult.ALLOW,
},
]);
await expect(denyService.authorize([permission])).resolves.toEqual([
{
...permission,
result: AuthorizeResult.DENY,
},
]);
});
});
@@ -0,0 +1,52 @@
/*
* Copyright 2025 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 { PermissionsService } from '@backstage/backend-plugin-api';
import {
AuthorizePermissionRequest,
AuthorizePermissionResponse,
AuthorizeResult,
QueryPermissionRequest,
QueryPermissionResponse,
} from '@backstage/plugin-permission-common';
export class MockPermissionsService implements PermissionsService {
readonly #result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
constructor(options?: {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}) {
this.#result = options?.result ?? AuthorizeResult.ALLOW;
}
async authorize(
requests: AuthorizePermissionRequest[],
): Promise<AuthorizePermissionResponse[]> {
return requests.map(request => ({
...request,
result: this.#result,
}));
}
async authorizeConditional(
requests: QueryPermissionRequest[],
): Promise<QueryPermissionResponse[]> {
return requests.map(request => ({
...request,
result: this.#result,
}));
}
}
@@ -36,6 +36,7 @@ import {
DiscoveryService,
HttpAuthService,
LoggerService,
PermissionsService,
RootConfigService,
ServiceFactory,
ServiceRef,
@@ -45,6 +46,7 @@ import {
} from '@backstage/backend-plugin-api';
import { ConfigReader } from '@backstage/config';
import { EventsService, eventsServiceRef } from '@backstage/plugin-events-node';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { JsonObject } from '@backstage/types';
import { Knex } from 'knex';
import { MockAuthService } from './MockAuthService';
@@ -55,6 +57,7 @@ import { mockCredentials } from './mockCredentials';
import { MockEventsService } from './MockEventsService';
import { actionsServiceFactory } from '@backstage/backend-defaults/actions';
import { actionsRegistryServiceFactory } from '@backstage/backend-defaults/actionsRegistry';
import { MockPermissionsService } from './MockPermissionsService';
/** @internal */
function createLoggerMock() {
@@ -475,8 +478,37 @@ export namespace mockServices {
);
}
/**
* Creates a functional mock implementation of the
* {@link @backstage/backend-plugin-api#PermissionsService}.
*/
export function permissions(options?: {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}): PermissionsService {
return new MockPermissionsService(options);
}
export namespace permissions {
export const factory = () => permissionsServiceFactory;
/**
* Creates a mock factory for the
* {@link @backstage/backend-plugin-api#coreServices.permissions}. Just
* returns the given `result` if you supply one. Otherwise, it returns the
* regular default permissions factory.
*/
export const factory = (options?: {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}) =>
options?.result
? createServiceFactory({
service: coreServices.permissions,
deps: {},
factory: () => new MockPermissionsService(options),
})
: permissionsServiceFactory;
/**
* Creates a mock of the
* {@link @backstage/backend-plugin-api#coreServices.permissions},
* optionally with some given method implementations.
*/
export const mock = simpleMock(coreServices.permissions, () => ({
authorize: jest.fn(),
authorizeConditional: jest.fn(),
@@ -56,7 +56,7 @@ describe('createRouter readonly disabled', () => {
let app: express.Express | Server;
let refreshService: RefreshService;
let locationAnalyzer: jest.Mocked<LocationAnalyzer>;
const permissionsService = mockServices.permissions.mock();
const permissionsService = mockServices.permissions();
beforeEach(async () => {
entitiesCatalog = {
@@ -799,12 +799,6 @@ describe('createRouter readonly disabled', () => {
metadata: { name: 'n' },
};
permissionsService.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
orchestrator.process.mockResolvedValueOnce({
ok: true,
state: {},
@@ -845,12 +839,6 @@ describe('createRouter readonly disabled', () => {
metadata: { name: 'invalid*name' },
};
permissionsService.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
orchestrator.process.mockResolvedValueOnce({
ok: false,
errors: [new Error('Invalid entity name')],
@@ -888,12 +876,6 @@ describe('createRouter readonly disabled', () => {
metadata: { name: 'n' },
};
permissionsService.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
const response = await request(app)
.post('/validate-entity')
.send({ entity, location: null });
@@ -940,7 +922,7 @@ describe('createRouter readonly and raw json enabled', () => {
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
let app: express.Express;
let locationService: jest.Mocked<LocationService>;
const permissionsService = mockServices.permissions.mock();
const permissionsService = mockServices.permissions();
beforeAll(async () => {
entitiesCatalog = {
@@ -1144,7 +1126,7 @@ describe('NextRouter permissioning', () => {
let locationService: jest.Mocked<LocationService>;
let app: express.Express;
let refreshService: RefreshService;
const permissionsService = mockServices.permissions.mock();
const permissionsService = mockServices.permissions();
const fakeRule = createPermissionRule({
name: 'FAKE_RULE',
@@ -18,30 +18,18 @@ import request from 'supertest';
import {
mockCredentials,
mockServices,
type ServiceMock,
startTestBackend,
} from '@backstage/backend-test-utils';
import { kubernetesObjectsProviderExtensionPoint } from '@backstage/plugin-kubernetes-node';
import {
createBackendModule,
type PermissionsService,
} from '@backstage/backend-plugin-api';
import { createBackendModule } from '@backstage/backend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { ExtendedHttpServer } from '@backstage/backend-defaults/rootHttpRouter';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
describe('resourcesRoutes', () => {
let app: ExtendedHttpServer;
const permissionsMock: ServiceMock<PermissionsService> =
mockServices.permissions.mock({
authorize: jest.fn(),
authorizeConditional: jest.fn(),
});
const startPermissionDeniedTestServer = async () => {
permissionsMock.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const { server } = await startTestBackend({
features: [
mockServices.rootConfig.factory({
@@ -52,7 +40,7 @@ describe('resourcesRoutes', () => {
},
},
}),
permissionsMock.factory,
mockServices.permissions.factory({ result: AuthorizeResult.DENY }),
import('@backstage/plugin-kubernetes-backend'),
],
});
@@ -35,7 +35,6 @@ import {
} from './KubernetesProxy';
import { setupServer } from 'msw/node';
import {
ServiceMock,
mockCredentials,
mockServices,
registerMswTestHooks,
@@ -43,10 +42,7 @@ import {
} from '@backstage/backend-test-utils';
import { rest } from 'msw';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
PermissionsService,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { createBackendModule } from '@backstage/backend-plugin-api';
import {
AuthMetadata,
KubernetesObjectsProvider,
@@ -64,11 +60,6 @@ describe('API integration tests', () => {
const happyK8SResult = {
items: [{ clusterOne: { pods: [{ metadata: { name: 'pod1' } }] } }],
};
const permissionsMock: ServiceMock<PermissionsService> =
mockServices.permissions.mock({
authorize: jest.fn(),
authorizeConditional: jest.fn(),
});
const minimalValidConfigService = mockServices.rootConfig.factory({
data: {
kubernetes: {
@@ -93,13 +84,10 @@ describe('API integration tests', () => {
},
});
const startPermissionDeniedTestServer = async () => {
permissionsMock.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const { server } = await startTestBackend({
features: [
minimalValidConfigService,
permissionsMock.factory,
mockServices.permissions.factory({ result: AuthorizeResult.DENY }),
import('@backstage/plugin-kubernetes-backend'),
],
});
@@ -22,7 +22,6 @@ import {
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import { NotFoundError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
KubernetesRequestAuth,
@@ -78,7 +77,7 @@ describe('KubernetesProxy', () => {
>(),
};
const permissionApi = mockServices.permissions.mock();
const permissionApi = mockServices.permissions();
const mockDisocveryApi = mockServices.discovery.mock();
registerMswTestHooks(worker);
@@ -156,9 +155,6 @@ describe('KubernetesProxy', () => {
authStrategy,
discovery: mockDisocveryApi,
});
permissionApi.authorize.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
});
it('should return a ERROR_NOT_FOUND if no clusters are found', async () => {
@@ -195,22 +195,7 @@ const createTestRouter = async (
jest.spyOn(taskBroker, 'event$');
const catalog = catalogServiceMock.mock();
const permissions = mockServices.permissions.mock();
permissions.authorizeConditional.mockImplementation(async p =>
p.map(innerP => ({
...innerP,
result: AuthorizeResult.ALLOW,
})),
);
permissions.authorize.mockImplementation(async p =>
p.map(innerP => ({
...innerP,
result: AuthorizeResult.ALLOW,
})),
);
const permissions = mockServices.permissions();
const auth = mockServices.auth();
const httpAuth = mockServices.httpAuth();
const events = mockServices.events();
@@ -517,14 +502,16 @@ describe('scaffolder router', () => {
it('filters parameters that the user is not authorized to see', async () => {
const { router, permissions } = await createTestRouter();
permissions.authorizeConditional.mockImplementationOnce(async () => [
{
result: AuthorizeResult.DENY,
},
{
result: AuthorizeResult.ALLOW,
},
]);
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementationOnce(async () => [
{
result: AuthorizeResult.DENY,
},
{
result: AuthorizeResult.ALLOW,
},
]);
const response = await request(router)
.get(
@@ -541,21 +528,23 @@ describe('scaffolder router', () => {
it('filters parameters that the user is not authorized to see in case of conditional decision', async () => {
const { permissions, router } = await createTestRouter();
permissions.authorizeConditional.mockImplementation(async () => [
{
conditions: {
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementation(async () => [
{
conditions: {
resourceType: 'scaffolder-template',
rule: 'HAS_TAG',
params: { tag: 'parameters-tag' },
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-template',
rule: 'HAS_TAG',
params: { tag: 'parameters-tag' },
result: AuthorizeResult.CONDITIONAL,
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-template',
result: AuthorizeResult.CONDITIONAL,
},
{
result: AuthorizeResult.ALLOW,
},
]);
{
result: AuthorizeResult.ALLOW,
},
]);
const response = await request(router)
.get(
@@ -721,14 +710,16 @@ describe('scaffolder router', () => {
it('filters steps that the user is not authorized to see', async () => {
const { router, permissions, taskBroker } = await createTestRouter();
permissions.authorizeConditional.mockImplementation(async () => [
{
result: AuthorizeResult.ALLOW,
},
{
result: AuthorizeResult.DENY,
},
]);
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementation(async () => [
{
result: AuthorizeResult.ALLOW,
},
{
result: AuthorizeResult.DENY,
},
]);
const broker = taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
const mockTemplate = generateMockTemplate();
@@ -786,21 +777,23 @@ describe('scaffolder router', () => {
it('filters steps that the user is not authorized to see in case of conditional decision', async () => {
const { permissions, router, taskBroker } = await createTestRouter();
permissions.authorizeConditional.mockImplementation(async () => [
{
result: AuthorizeResult.ALLOW,
},
{
conditions: {
resourceType: 'scaffolder-template',
rule: 'HAS_TAG',
params: { tag: 'steps-tag' },
jest
.spyOn(permissions, 'authorizeConditional')
.mockImplementation(async () => [
{
result: AuthorizeResult.ALLOW,
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-template',
result: AuthorizeResult.CONDITIONAL,
},
]);
{
conditions: {
resourceType: 'scaffolder-template',
rule: 'HAS_TAG',
params: { tag: 'steps-tag' },
},
pluginId: 'scaffolder',
resourceType: 'scaffolder-template',
result: AuthorizeResult.CONDITIONAL,
},
]);
const broker = taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
const mockTemplate = generateMockTemplate();
+1
View File
@@ -3776,6 +3776,7 @@ __metadata:
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/types": "workspace:^"
"@keyv/memcache": "npm:^2.0.1"
"@keyv/redis": "npm:^4.0.1"