352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|