import { BadRequestException, NotFoundException } from '@nestjs/common'; import type { Logger } from 'nestjs-pino'; import type { PrismaService } from 'nestjs-prisma'; import { UserScopesService } from './user-scopes.service'; interface PrismaStub { user: { findUnique: jest.Mock }; userScope: { findMany: jest.Mock; findUnique: jest.Mock; create: jest.Mock; delete: jest.Mock; }; structure: { findUnique: jest.Mock }; delegation: { findUnique: jest.Mock }; region: { findUnique: jest.Mock }; } function makeLogger() { return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & { log: jest.Mock; warn: jest.Mock; error: jest.Mock; }; } function makePrisma(overrides?: Partial): PrismaStub { return { user: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.user }, userScope: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), create: jest.fn(), delete: jest.fn().mockResolvedValue(undefined), ...overrides?.userScope, }, structure: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.structure }, delegation: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.delegation }, region: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.region }, }; } function makeSubject(overrides?: Partial): { service: UserScopesService; prisma: PrismaStub; logger: ReturnType; } { const prisma = makePrisma(overrides); const logger = makeLogger(); const service = new UserScopesService(prisma as unknown as PrismaService, logger); return { service, prisma, logger }; } const FOUND_USER = { id: 'user-uuid-1', person: { firstName: 'Jane', lastName: 'Doe', email: 'jane@apf.example' }, }; describe('UserScopesService.resolveUserByOid', () => { it('returns the User + composed displayName when found', async () => { const { service } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, }); const result = await service.resolveUserByOid('entra-oid'); expect(result).toEqual({ oid: 'entra-oid', userId: 'user-uuid-1', displayName: 'Jane Doe', email: 'jane@apf.example', }); }); it('throws NotFoundException when the User does not exist', async () => { const { service } = makeSubject(); await expect(service.resolveUserByOid('ghost-oid')).rejects.toBeInstanceOf(NotFoundException); }); it('falls back to "(unnamed)" when both firstName and lastName are empty', async () => { const { service } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue({ id: 'u', person: { firstName: '', lastName: '', email: null }, }), }, }); const result = await service.resolveUserByOid('o'); expect(result.displayName).toBe('(unnamed)'); expect(result.email).toBeNull(); }); }); describe('UserScopesService.list', () => { it('returns scopes ordered by kind then value with ISO timestamps', async () => { const { service, prisma } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, userScope: { findMany: jest.fn().mockResolvedValue([ { id: 's1', kind: 'etablissement', value: '0330800013', source: 'seed', createdAt: new Date('2026-05-26T10:00:00.000Z'), expiresAt: null, }, ]), findUnique: jest.fn(), create: jest.fn(), delete: jest.fn(), }, }); const result = await service.list('entra-oid'); expect(prisma.userScope.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: 'user-uuid-1' }, orderBy: [{ kind: 'asc' }, { value: 'asc' }], }), ); expect(result.scopes).toEqual([ { id: 's1', kind: 'etablissement', value: '0330800013', source: 'seed', createdAt: '2026-05-26T10:00:00.000Z', expiresAt: null, }, ]); }); }); describe('UserScopesService.grant — value-bearing kinds', () => { it('creates the row when the etablissement value matches an existing Structure', async () => { const { service, prisma } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) }, userScope: { findMany: jest.fn(), findUnique: jest.fn(), delete: jest.fn(), create: jest.fn().mockResolvedValue({ id: 'new-scope', kind: 'etablissement', value: '0330800013', source: 'admin-ui', createdAt: new Date('2026-05-26T10:00:00.000Z'), expiresAt: null, }), }, }); const out = await service.grant('entra-oid', { kind: 'etablissement', value: '0330800013', }); expect(prisma.structure.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { code: '0330800013' } }), ); expect(prisma.userScope.create).toHaveBeenCalledWith({ data: { userId: 'user-uuid-1', kind: 'etablissement', value: '0330800013', source: 'admin-ui', expiresAt: null, }, select: expect.any(Object), }); expect(out.scope.kind).toBe('etablissement'); expect(out.scope.value).toBe('0330800013'); expect(out.scope.source).toBe('admin-ui'); }); it('rejects when the value does not match any Structure row', async () => { const { service } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, structure: { findUnique: jest.fn().mockResolvedValue(null) }, }); await expect( service.grant('entra-oid', { kind: 'etablissement', value: 'bogus' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects when the value is empty for a value-bearing kind', async () => { const { service } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, }); await expect( service.grant('entra-oid', { kind: 'delegation', value: '' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('validates delegation values against the Delegation table', async () => { const { service, prisma } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, delegation: { findUnique: jest.fn().mockResolvedValue({ code: '33' }) }, userScope: { findMany: jest.fn(), findUnique: jest.fn(), delete: jest.fn(), create: jest.fn().mockResolvedValue({ id: 's', kind: 'delegation', value: '33', source: 'admin-ui', createdAt: new Date(), expiresAt: null, }), }, }); await service.grant('entra-oid', { kind: 'delegation', value: '33' }); expect(prisma.delegation.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { code: '33' } }), ); }); }); describe('UserScopesService.grant — valueless kinds', () => { it('writes value="" for unrestricted and rejects non-empty values', async () => { const { service, prisma } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, userScope: { findMany: jest.fn(), findUnique: jest.fn(), delete: jest.fn(), create: jest.fn().mockResolvedValue({ id: 's', kind: 'unrestricted', value: '', source: 'admin-ui', createdAt: new Date(), expiresAt: null, }), }, }); await service.grant('entra-oid', { kind: 'unrestricted' }); expect(prisma.userScope.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ value: '' }), }), ); await expect( service.grant('entra-oid', { kind: 'unrestricted', value: 'something' }), ).rejects.toBeInstanceOf(BadRequestException); }); }); describe('UserScopesService.grant — duplicate detection', () => { it('translates Prisma P2002 into a 400 with a friendly message', async () => { const { service } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) }, userScope: { findMany: jest.fn(), findUnique: jest.fn(), delete: jest.fn(), create: jest.fn().mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' })), }, }); await expect( service.grant('entra-oid', { kind: 'etablissement', value: '0330800013' }), ).rejects.toBeInstanceOf(BadRequestException); }); }); describe('UserScopesService.revoke', () => { it('deletes the row and returns the resolved tuple for audit', async () => { const { service, prisma } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, userScope: { findMany: jest.fn(), findUnique: jest.fn().mockResolvedValue({ id: 's1', userId: 'user-uuid-1', kind: 'delegation', value: '33', }), create: jest.fn(), delete: jest.fn().mockResolvedValue(undefined), }, }); const out = await service.revoke('entra-oid', 's1'); expect(prisma.userScope.delete).toHaveBeenCalledWith({ where: { id: 's1' } }); expect(out.revoked).toEqual({ kind: 'delegation', value: '33' }); }); it('throws NotFoundException when the scope belongs to a different user', async () => { const { service } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, userScope: { findMany: jest.fn(), findUnique: jest.fn().mockResolvedValue({ id: 's1', userId: 'other-user', kind: 'delegation', value: '33', }), create: jest.fn(), delete: jest.fn(), }, }); await expect(service.revoke('entra-oid', 's1')).rejects.toBeInstanceOf(NotFoundException); }); it('throws NotFoundException when no scope exists for that id', async () => { const { service } = makeSubject({ user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) }, }); await expect(service.revoke('entra-oid', 'ghost')).rejects.toBeInstanceOf(NotFoundException); }); });