feat(federation): grants service CRUD + status transitions (FED-M2-06) (#496)
This commit was merged in pull request #496.
This commit is contained in:
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Unit tests for GrantsService — federation grants CRUD + status transitions (FED-M2-06).
|
||||
*
|
||||
* Coverage:
|
||||
* - createGrant: validates scope via parseFederationScope
|
||||
* - createGrant: inserts with status 'pending'
|
||||
* - getGrant: returns grant when found
|
||||
* - getGrant: throws NotFoundException when not found
|
||||
* - listGrants: no filters returns all grants
|
||||
* - listGrants: filters by peerId
|
||||
* - listGrants: filters by subjectUserId
|
||||
* - listGrants: filters by status
|
||||
* - listGrants: multiple filters combined
|
||||
* - activateGrant: pending → active works
|
||||
* - activateGrant: non-pending throws ConflictException
|
||||
* - revokeGrant: active → revoked works, sets revokedAt
|
||||
* - revokeGrant: non-active throws ConflictException
|
||||
* - expireGrant: active → expired works
|
||||
* - expireGrant: non-active throws ConflictException
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { GrantsService } from '../grants.service.js';
|
||||
import { FederationScopeError } from '../scope-schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal valid federation scope for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
const VALID_SCOPE = {
|
||||
resources: ['tasks'] as const,
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
const PEER_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = 'u2222222-2222-2222-2222-222222222222';
|
||||
const GRANT_ID = 'g3333333-3333-3333-3333-333333333333';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a mock DB that mimics chained Drizzle query builder calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
status: 'pending',
|
||||
expiresAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb(
|
||||
overrides: {
|
||||
insertReturning?: unknown[];
|
||||
selectRows?: unknown[];
|
||||
updateReturning?: unknown[];
|
||||
} = {},
|
||||
) {
|
||||
const insertReturning = overrides.insertReturning ?? [makeMockGrant()];
|
||||
const selectRows = overrides.selectRows ?? [makeMockGrant()];
|
||||
const updateReturning = overrides.updateReturning ?? [makeMockGrant({ status: 'active' })];
|
||||
|
||||
// Drizzle returns a chainable builder; we need to mock the full chain.
|
||||
const returningInsert = vi.fn().mockResolvedValue(insertReturning);
|
||||
const valuesInsert = vi.fn().mockReturnValue({ returning: returningInsert });
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesInsert });
|
||||
|
||||
// select().from().where().limit()
|
||||
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||
// from returns something that is both thenable (for full-table select) and has .where()
|
||||
const fromSelect = vi.fn().mockReturnValue({
|
||||
where: whereSelect,
|
||||
limit: limitSelect,
|
||||
// Make it thenable for listGrants with no filters (await db.select().from(federationGrants))
|
||||
then: (resolve: (v: unknown) => unknown) => resolve(selectRows),
|
||||
});
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||
|
||||
const returningUpdate = vi.fn().mockResolvedValue(updateReturning);
|
||||
const whereUpdate = vi.fn().mockReturnValue({ returning: returningUpdate });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdate });
|
||||
const updateMock = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
return {
|
||||
insert: insertMock,
|
||||
select: selectMock,
|
||||
update: updateMock,
|
||||
// Expose internals for assertions
|
||||
_mocks: {
|
||||
insertReturning,
|
||||
valuesInsert,
|
||||
insertMock,
|
||||
limitSelect,
|
||||
whereSelect,
|
||||
fromSelect,
|
||||
selectMock,
|
||||
returningUpdate,
|
||||
whereUpdate,
|
||||
setMock,
|
||||
updateMock,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GrantsService', () => {
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
let service: GrantsService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
});
|
||||
|
||||
// ─── createGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('createGrant', () => {
|
||||
it('calls parseFederationScope — rejects an invalid scope', async () => {
|
||||
const invalidScope = { resources: [], max_rows_per_query: 0 };
|
||||
await expect(
|
||||
service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: invalidScope }),
|
||||
).rejects.toBeInstanceOf(FederationScopeError);
|
||||
});
|
||||
|
||||
it('inserts a grant with status pending and returns it', async () => {
|
||||
const result = await service.createGrant({
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
});
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'pending', peerId: PEER_ID, subjectUserId: USER_ID }),
|
||||
);
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('passes expiresAt as a Date when provided', async () => {
|
||||
await service.createGrant({
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
expiresAt: '2027-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets expiresAt to null when not provided', async () => {
|
||||
await service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE });
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getGrant ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getGrant', () => {
|
||||
it('returns the grant when found', async () => {
|
||||
const result = await service.getGrant(GRANT_ID);
|
||||
expect(result.id).toBe(GRANT_ID);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no rows returned', async () => {
|
||||
db = makeDb({ selectRows: [] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
await expect(service.getGrant(GRANT_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listGrants ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('listGrants', () => {
|
||||
it('queries without where clause when no filters provided', async () => {
|
||||
const result = await service.listGrants({});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies peerId filter', async () => {
|
||||
await service.listGrants({ peerId: PEER_ID });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies subjectUserId filter', async () => {
|
||||
await service.listGrants({ subjectUserId: USER_ID });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies status filter', async () => {
|
||||
await service.listGrants({ status: 'active' });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies multiple filters combined', async () => {
|
||||
await service.listGrants({ peerId: PEER_ID, status: 'pending' });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── activateGrant ────────────────────────────────────────────────────────
|
||||
|
||||
describe('activateGrant', () => {
|
||||
it('transitions pending → active and returns updated grant', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'pending' })],
|
||||
updateReturning: [makeMockGrant({ status: 'active' })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.activateGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'active' });
|
||||
expect(result.status).toBe('active');
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already active', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'active' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── revokeGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('revokeGrant', () => {
|
||||
it('transitions active → revoked and sets revokedAt', async () => {
|
||||
const revokedAt = new Date();
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.revokeGrant(GRANT_ID, 'test reason');
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'revoked',
|
||||
revokedAt: expect.any(Date),
|
||||
revokedReason: 'test reason',
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('revoked');
|
||||
});
|
||||
|
||||
it('sets revokedReason to null when not provided', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt: new Date() })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await service.revokeGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ revokedReason: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is pending', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── expireGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('expireGrant', () => {
|
||||
it('transitions active → expired and returns updated grant', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'expired' })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.expireGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'expired' });
|
||||
expect(result.status).toBe('expired');
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is pending', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user