/** * Unit tests for FederationScopeSchema and parseFederationScope. * * Coverage: * - Valid: minimal scope * - Valid: full PRD §8.1 example * - Valid: resources + excluded_resources (no overlap) * - Invalid: empty resources * - Invalid: unknown resource value * - Invalid: resources / excluded_resources intersection * - Invalid: filter key not in resources * - Invalid: max_rows_per_query = 0 * - Invalid: max_rows_per_query = 10001 * - Invalid: not an object / null * - Defaults: include_personal defaults to true; excluded_resources defaults to [] * - Sentinel: console.warn fires for sensitive resources */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { parseFederationScope, FederationScopeError, FederationScopeSchema, } from './scope-schema.js'; afterEach(() => { vi.restoreAllMocks(); }); describe('parseFederationScope — valid inputs', () => { it('accepts a minimal scope (resources + max_rows_per_query only)', () => { const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 100, }); expect(scope.resources).toEqual(['tasks']); expect(scope.max_rows_per_query).toBe(100); expect(scope.excluded_resources).toEqual([]); expect(scope.filters).toBeUndefined(); }); it('accepts the full PRD §8.1 example', () => { const scope = parseFederationScope({ resources: ['tasks', 'notes', 'memory'], filters: { tasks: { include_teams: ['team_uuid_1', 'team_uuid_2'], include_personal: true }, notes: { include_personal: true, include_teams: [] }, memory: { include_personal: true }, }, excluded_resources: ['credentials', 'api_keys'], max_rows_per_query: 500, }); expect(scope.resources).toEqual(['tasks', 'notes', 'memory']); expect(scope.excluded_resources).toEqual(['credentials', 'api_keys']); expect(scope.filters?.tasks?.include_teams).toEqual(['team_uuid_1', 'team_uuid_2']); expect(scope.max_rows_per_query).toBe(500); }); it('accepts a scope with excluded_resources and no filter overlap', () => { const scope = parseFederationScope({ resources: ['tasks', 'notes'], excluded_resources: ['memory'], max_rows_per_query: 250, }); expect(scope.resources).toEqual(['tasks', 'notes']); expect(scope.excluded_resources).toEqual(['memory']); }); }); describe('parseFederationScope — defaults', () => { it('defaults excluded_resources to []', () => { const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 1 }); expect(scope.excluded_resources).toEqual([]); }); it('defaults include_personal to true when filter is provided without it', () => { const scope = parseFederationScope({ resources: ['tasks'], filters: { tasks: { include_teams: ['t1'] } }, max_rows_per_query: 10, }); expect(scope.filters?.tasks?.include_personal).toBe(true); }); }); describe('parseFederationScope — invalid inputs', () => { it('throws FederationScopeError for empty resources array', () => { expect(() => parseFederationScope({ resources: [], max_rows_per_query: 100 })).toThrow( FederationScopeError, ); }); it('throws for unknown resource value in resources', () => { expect(() => parseFederationScope({ resources: ['unknown_resource'], max_rows_per_query: 100 }), ).toThrow(FederationScopeError); }); it('throws when resources and excluded_resources intersect', () => { expect(() => parseFederationScope({ resources: ['tasks', 'memory'], excluded_resources: ['memory'], max_rows_per_query: 100, }), ).toThrow(FederationScopeError); }); it('throws when filters references a resource not in resources', () => { expect(() => parseFederationScope({ resources: ['tasks'], filters: { notes: { include_personal: true } }, max_rows_per_query: 100, }), ).toThrow(FederationScopeError); }); it('throws for max_rows_per_query = 0', () => { expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 0 })).toThrow( FederationScopeError, ); }); it('throws for max_rows_per_query = 10001', () => { expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 10001 })).toThrow( FederationScopeError, ); }); it('throws for null input', () => { expect(() => parseFederationScope(null)).toThrow(FederationScopeError); }); it('throws for non-object input (string)', () => { expect(() => parseFederationScope('not-an-object')).toThrow(FederationScopeError); }); }); describe('parseFederationScope — sentinel warning', () => { it('emits console.warn when resources includes "credentials"', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); parseFederationScope({ resources: ['tasks', 'credentials'], max_rows_per_query: 100, }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( '[FederationScope] WARNING: scope grants sensitive resource "credentials"', ), ); }); it('emits console.warn when resources includes "api_keys"', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); parseFederationScope({ resources: ['tasks', 'api_keys'], max_rows_per_query: 100, }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( '[FederationScope] WARNING: scope grants sensitive resource "api_keys"', ), ); }); it('does NOT emit console.warn for non-sensitive resources', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); parseFederationScope({ resources: ['tasks', 'notes', 'memory'], max_rows_per_query: 100 }); expect(warnSpy).not.toHaveBeenCalled(); }); }); describe('FederationScopeSchema — boundary values', () => { it('accepts max_rows_per_query = 1 (lower bound)', () => { const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 1 }); expect(result.success).toBe(true); }); it('accepts max_rows_per_query = 10000 (upper bound)', () => { const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 10000, }); expect(result.success).toBe(true); }); });