feat(federation): scope schema validator [FED-M2-03] (#489)
This commit was merged in pull request #489.
This commit is contained in:
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user