325 lines
8.9 KiB
TypeScript
325 lines
8.9 KiB
TypeScript
/**
|
|
* Unit tests for FederationScopeService (FED-M3-04).
|
|
*
|
|
* Coverage:
|
|
* - resource allowlist deny
|
|
* - excluded resource deny
|
|
* - invalid scope deny
|
|
* - invalid requested limit deny
|
|
* - native RBAC deny as subjectUserId
|
|
* - scope/native filter intersection for personal and team rows
|
|
* - native RBAC personal deny wins over scope include_personal allow/default
|
|
* - max_rows_per_query cap
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
|
|
import type { FederationContext } from '../federation-context.js';
|
|
|
|
const GRANT_ID = 'grant-1';
|
|
const PEER_ID = 'peer-1';
|
|
const SUBJECT_USER_ID = 'user-1';
|
|
|
|
function makeContext(scope: Record<string, unknown>): FederationContext {
|
|
return {
|
|
grantId: GRANT_ID,
|
|
peerId: PEER_ID,
|
|
subjectUserId: SUBJECT_USER_ID,
|
|
scope,
|
|
};
|
|
}
|
|
|
|
function makeNativeRbac(
|
|
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
|
|
): FederationNativeRbacEvaluator {
|
|
return {
|
|
evaluateReadAccess: vi.fn().mockResolvedValue(result),
|
|
};
|
|
}
|
|
|
|
describe('FederationScopeService', () => {
|
|
let service: FederationScopeService;
|
|
|
|
beforeEach(() => {
|
|
service = new FederationScopeService();
|
|
});
|
|
|
|
it('allows a granted resource and returns a capped query filter', async () => {
|
|
const nativeRbac = makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
});
|
|
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({
|
|
resources: ['tasks'],
|
|
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
|
|
max_rows_per_query: 50,
|
|
}),
|
|
resource: 'tasks',
|
|
requestedLimit: 500,
|
|
nativeRbac,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
allowed: true,
|
|
filter: {
|
|
resource: 'tasks',
|
|
subjectUserId: SUBJECT_USER_ID,
|
|
includePersonal: true,
|
|
teamIds: ['team-1'],
|
|
limit: 50,
|
|
maxRowsPerQuery: 50,
|
|
},
|
|
});
|
|
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
|
|
grantId: GRANT_ID,
|
|
peerId: PEER_ID,
|
|
subjectUserId: SUBJECT_USER_ID,
|
|
resource: 'tasks',
|
|
});
|
|
});
|
|
|
|
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
|
|
resource: 'notes',
|
|
nativeRbac: makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
}),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: true,
|
|
filter: {
|
|
includePersonal: true,
|
|
teamIds: ['team-1', 'team-2'],
|
|
limit: 100,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('honors include_personal false even when native RBAC allows personal rows', async () => {
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({
|
|
resources: ['memory'],
|
|
filters: { memory: { include_personal: false } },
|
|
max_rows_per_query: 25,
|
|
}),
|
|
resource: 'memory',
|
|
nativeRbac: makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: [] },
|
|
}),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: true,
|
|
filter: {
|
|
includePersonal: false,
|
|
teamIds: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({
|
|
resources: ['tasks'],
|
|
filters: { tasks: { include_personal: true } },
|
|
max_rows_per_query: 25,
|
|
}),
|
|
resource: 'tasks',
|
|
nativeRbac: makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: false, teamIds: ['team-1'] },
|
|
}),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: true,
|
|
filter: {
|
|
includePersonal: false,
|
|
teamIds: ['team-1'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({
|
|
resources: ['tasks'],
|
|
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
|
|
max_rows_per_query: 25,
|
|
}),
|
|
resource: 'tasks',
|
|
nativeRbac: makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: ['team-1'] },
|
|
}),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: true,
|
|
filter: {
|
|
includePersonal: false,
|
|
teamIds: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('denies invalid grant scope before RBAC evaluation', async () => {
|
|
const nativeRbac = makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: [] },
|
|
});
|
|
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({ resources: [], max_rows_per_query: 100 }),
|
|
resource: 'tasks',
|
|
nativeRbac,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
deny: {
|
|
code: 'invalid_scope',
|
|
stage: 'scope_parse',
|
|
statusCode: 400,
|
|
grantId: GRANT_ID,
|
|
subjectUserId: SUBJECT_USER_ID,
|
|
resource: 'tasks',
|
|
},
|
|
});
|
|
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies unsupported resource names before RBAC evaluation', async () => {
|
|
const nativeRbac = makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: [] },
|
|
});
|
|
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
resource: 'unknown_resource',
|
|
nativeRbac,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
deny: {
|
|
code: 'invalid_resource',
|
|
stage: 'resource_allowlist',
|
|
statusCode: 403,
|
|
},
|
|
});
|
|
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
|
|
const nativeRbac = makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: [] },
|
|
});
|
|
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({
|
|
resources: ['tasks'],
|
|
excluded_resources: ['credentials'],
|
|
max_rows_per_query: 100,
|
|
}),
|
|
resource: 'credentials',
|
|
nativeRbac,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
deny: {
|
|
code: 'resource_excluded',
|
|
stage: 'resource_exclusion',
|
|
statusCode: 403,
|
|
resource: 'credentials',
|
|
},
|
|
});
|
|
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies supported resources that are not granted by scope', async () => {
|
|
const nativeRbac = makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: [] },
|
|
});
|
|
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
resource: 'notes',
|
|
nativeRbac,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
deny: {
|
|
code: 'resource_not_granted',
|
|
stage: 'resource_allowlist',
|
|
statusCode: 403,
|
|
resource: 'notes',
|
|
},
|
|
});
|
|
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies invalid requested row limits before RBAC evaluation', async () => {
|
|
const nativeRbac = makeNativeRbac({
|
|
allowed: true,
|
|
access: { includePersonal: true, teamIds: [] },
|
|
});
|
|
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
resource: 'tasks',
|
|
requestedLimit: 0,
|
|
nativeRbac,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
allowed: false,
|
|
deny: {
|
|
code: 'invalid_limit',
|
|
stage: 'row_cap',
|
|
statusCode: 400,
|
|
details: { requestedLimit: 0 },
|
|
},
|
|
});
|
|
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
|
|
const result = await service.evaluateAccess({
|
|
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
resource: 'tasks',
|
|
nativeRbac: makeNativeRbac({
|
|
allowed: false,
|
|
reason: 'read:tasks denied',
|
|
details: { permission: 'tasks:read' },
|
|
}),
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
allowed: false,
|
|
deny: {
|
|
code: 'native_rbac_denied',
|
|
stage: 'native_rbac',
|
|
statusCode: 403,
|
|
message: 'read:tasks denied',
|
|
grantId: GRANT_ID,
|
|
peerId: PEER_ID,
|
|
subjectUserId: SUBJECT_USER_ID,
|
|
resource: 'tasks',
|
|
details: { permission: 'tasks:read' },
|
|
},
|
|
});
|
|
});
|
|
});
|