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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CaService } from './ca.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [CaService],
|
||||
exports: [CaService],
|
||||
providers: [CaService, GrantsService],
|
||||
exports: [CaService, GrantsService],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
36
apps/gateway/src/federation/grants.dto.ts
Normal file
36
apps/gateway/src/federation/grants.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsDateString, IsIn, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateGrantDto {
|
||||
@IsUUID()
|
||||
peerId!: string;
|
||||
|
||||
@IsUUID()
|
||||
subjectUserId!: string;
|
||||
|
||||
@IsObject()
|
||||
scope!: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class ListGrantsDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
peerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
subjectUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['pending', 'active', 'revoked', 'expired'])
|
||||
status?: 'pending' | 'active' | 'revoked' | 'expired';
|
||||
}
|
||||
|
||||
export class RevokeGrantDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
161
apps/gateway/src/federation/grants.service.ts
Normal file
161
apps/gateway/src/federation/grants.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Federation grants service — CRUD + status transitions (FED-M2-06).
|
||||
*
|
||||
* Business logic only. CSR/cert work is handled by M2-07.
|
||||
*
|
||||
* Status lifecycle:
|
||||
* pending → active (activateGrant, called by M2-07 enrollment controller after cert signed)
|
||||
* active → revoked (revokeGrant)
|
||||
* active → expired (expireGrant, called by M6 scheduler)
|
||||
*/
|
||||
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { type Db, and, eq, federationGrants } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { parseFederationScope } from './scope-schema.js';
|
||||
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||
|
||||
export type Grant = typeof federationGrants.$inferSelect;
|
||||
|
||||
@Injectable()
|
||||
export class GrantsService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Create a new grant in `pending` state.
|
||||
* Validates the scope against the federation scope JSON schema before inserting.
|
||||
*/
|
||||
async createGrant(dto: CreateGrantDto): Promise<Grant> {
|
||||
// Throws FederationScopeError (a plain Error subclass) on invalid scope.
|
||||
parseFederationScope(dto.scope);
|
||||
|
||||
const [grant] = await this.db
|
||||
.insert(federationGrants)
|
||||
.values({
|
||||
peerId: dto.peerId,
|
||||
subjectUserId: dto.subjectUserId,
|
||||
scope: dto.scope,
|
||||
status: 'pending',
|
||||
expiresAt: dto.expiresAt != null ? new Date(dto.expiresAt) : null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return grant!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single grant by ID. Throws NotFoundException if not found.
|
||||
*/
|
||||
async getGrant(id: string): Promise<Grant> {
|
||||
const [grant] = await this.db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.where(eq(federationGrants.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!grant) {
|
||||
throw new NotFoundException(`Grant ${id} not found`);
|
||||
}
|
||||
|
||||
return grant;
|
||||
}
|
||||
|
||||
/**
|
||||
* List grants with optional filters for peerId, subjectUserId, and status.
|
||||
*/
|
||||
async listGrants(filters: ListGrantsDto): Promise<Grant[]> {
|
||||
const conditions = [];
|
||||
|
||||
if (filters.peerId != null) {
|
||||
conditions.push(eq(federationGrants.peerId, filters.peerId));
|
||||
}
|
||||
if (filters.subjectUserId != null) {
|
||||
conditions.push(eq(federationGrants.subjectUserId, filters.subjectUserId));
|
||||
}
|
||||
if (filters.status != null) {
|
||||
conditions.push(eq(federationGrants.status, filters.status));
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return this.db.select().from(federationGrants);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a grant from `pending` → `active`.
|
||||
* Called by M2-07 enrollment controller after cert is signed.
|
||||
* Throws ConflictException if the grant is not in `pending` state.
|
||||
*/
|
||||
async activateGrant(id: string): Promise<Grant> {
|
||||
const grant = await this.getGrant(id);
|
||||
|
||||
if (grant.status !== 'pending') {
|
||||
throw new ConflictException(
|
||||
`Grant ${id} cannot be activated: expected status 'pending', got '${grant.status}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(federationGrants)
|
||||
.set({ status: 'active' })
|
||||
.where(eq(federationGrants.id, id))
|
||||
.returning();
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a grant from `active` → `revoked`.
|
||||
* Sets revokedAt and optionally revokedReason.
|
||||
* Throws ConflictException if the grant is not in `active` state.
|
||||
*/
|
||||
async revokeGrant(id: string, reason?: string): Promise<Grant> {
|
||||
const grant = await this.getGrant(id);
|
||||
|
||||
if (grant.status !== 'active') {
|
||||
throw new ConflictException(
|
||||
`Grant ${id} cannot be revoked: expected status 'active', got '${grant.status}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(federationGrants)
|
||||
.set({
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
revokedReason: reason ?? null,
|
||||
})
|
||||
.where(eq(federationGrants.id, id))
|
||||
.returning();
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a grant from `active` → `expired`.
|
||||
* Intended for use by the M6 scheduler.
|
||||
* Throws ConflictException if the grant is not in `active` state.
|
||||
*/
|
||||
async expireGrant(id: string): Promise<Grant> {
|
||||
const grant = await this.getGrant(id);
|
||||
|
||||
if (grant.status !== 'active') {
|
||||
throw new ConflictException(
|
||||
`Grant ${id} cannot be expired: expected status 'active', got '${grant.status}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(federationGrants)
|
||||
.set({ status: 'expired' })
|
||||
.where(eq(federationGrants.id, id))
|
||||
.returning();
|
||||
|
||||
return updated!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user