/** * 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> = {}) { 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; 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); }); }); });