/** * 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): FederationContext { return { grantId: GRANT_ID, peerId: PEER_ID, subjectUserId: SUBJECT_USER_ID, scope, }; } function makeNativeRbac( result: Awaited>, ): 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' }, }, }); }); });