diff --git a/src/auth/guards/api-key-permissions.decorator.ts b/src/auth/guards/api-key-permissions.decorator.ts new file mode 100644 index 0000000..8f78c59 --- /dev/null +++ b/src/auth/guards/api-key-permissions.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const API_KEY_PERMISSIONS_KEY = 'api_key_permissions'; + +export const ApiKeyPermissions = (...permissions: string[]) => + SetMetadata(API_KEY_PERMISSIONS_KEY, permissions); diff --git a/src/auth/guards/api-key.guard.ts b/src/auth/guards/api-key.guard.ts new file mode 100644 index 0000000..b357fad --- /dev/null +++ b/src/auth/guards/api-key.guard.ts @@ -0,0 +1,115 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + ForbiddenException, + Inject, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { createHash } from 'crypto'; +import { SupabaseService } from '../../database/supabase.client'; +import { API_KEY_PERMISSIONS_KEY } from './api-key-permissions.decorator'; + +interface ApiKeyRecord { + id: string; + vendor_id: string; + name: string; + key_prefix: string; + key_hash: string; + permissions: string[]; + is_active: boolean; + last_used_at: string | null; + expires_at: string | null; + created_at: string; + updated_at: string; +} + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor( + private readonly supabaseService: SupabaseService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ + headers: Record; + apiKey?: ApiKeyRecord; + }>(); + + const apiKeyHeader = request.headers['x-api-key']; + + if (!apiKeyHeader || typeof apiKeyHeader !== 'string') { + throw new UnauthorizedException({ + code: 'API_KEY_MISSING', + message: 'X-API-Key header is required.', + }); + } + + const keyHash = createHash('sha256').update(apiKeyHeader).digest('hex'); + + const client = this.supabaseService.getServiceRoleClient(); + const { data, error } = await client + .from('api_keys') + .select('*') + .eq('key_hash', keyHash) + .single(); + + if (error || !data) { + throw new UnauthorizedException({ + code: 'API_KEY_INVALID', + message: 'Invalid API key.', + }); + } + + const keyRecord = data as unknown as ApiKeyRecord; + + if (!keyRecord.is_active) { + throw new UnauthorizedException({ + code: 'API_KEY_INACTIVE', + message: 'API key has been revoked.', + }); + } + + if (keyRecord.expires_at && new Date(keyRecord.expires_at) < new Date()) { + throw new UnauthorizedException({ + code: 'API_KEY_EXPIRED', + message: 'API key has expired.', + }); + } + + const requiredPermissions = this.reflector.get( + API_KEY_PERMISSIONS_KEY, + context.getHandler(), + ); + + if (requiredPermissions && requiredPermissions.length > 0) { + const keyPermissions: string[] = keyRecord.permissions ?? []; + const hasPermission = requiredPermissions.some((p) => keyPermissions.includes(p)); + if (!hasPermission) { + throw new ForbiddenException({ + code: 'API_KEY_INSUFFICIENT_PERMISSIONS', + message: 'API key does not have the required permissions for this resource.', + }); + } + } + + this.updateLastUsed(keyRecord.id); + + request.apiKey = keyRecord; + return true; + } + + private async updateLastUsed(keyId: string): Promise { + try { + const client = this.supabaseService.getServiceRoleClient(); + await client + .from('api_keys') + .update({ last_used_at: new Date().toISOString() }) + .eq('id', keyId); + } catch { + // Fire-and-forget — failure to update last_used_at should not block the request + } + } +} diff --git a/src/main.ts b/src/main.ts index 1f8d86d..919a2d3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,13 @@ import * as Sentry from '@sentry/nestjs'; -Sentry.init({ - dsn: process.env.SENTRY_DSN || undefined, - environment: process.env.NODE_ENV || 'development', - tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE - ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) - : 0.1, -}); +Sentry.init( + { + environment: process.env.NODE_ENV || 'development', + tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE + ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) + : 0.1, + } as Parameters[0], +); import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index f9ca940..77ddd3b 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; +import { ApiKeyGuard } from '../../auth/guards/api-key.guard'; import { SupabaseService } from '../../database/supabase.client'; import { UsersRepository } from '../../database/repositories/users.repository'; import { getJwtConfig } from '../../config/jwt.config'; @@ -19,7 +20,7 @@ import { getJwtConfig } from '../../config/jwt.config'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, SupabaseService, ConfigService, UsersRepository], - exports: [AuthService, JwtStrategy, PassportModule], + providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository], + exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule], }) export class AuthModule {} diff --git a/src/modules/vendors/dto/api-key-response.dto.ts b/src/modules/vendors/dto/api-key-response.dto.ts new file mode 100644 index 0000000..b704e2e --- /dev/null +++ b/src/modules/vendors/dto/api-key-response.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ApiKeyResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + vendorId: string; + + @ApiProperty() + name: string; + + @ApiProperty({ description: 'First 8 characters of the API key for identification' }) + keyPrefix: string; + + @ApiProperty({ type: [String] }) + permissions: string[]; + + @ApiProperty() + isActive: boolean; + + @ApiPropertyOptional() + lastUsedAt?: string; + + @ApiPropertyOptional() + expiresAt?: string; + + @ApiProperty() + createdAt: string; + + @ApiProperty() + updatedAt: string; +} + +export class ApiKeyCreatedResponseDto extends ApiKeyResponseDto { + @ApiProperty({ description: 'Full API key — this will only be shown once on creation' }) + fullKey: string; +} diff --git a/src/modules/vendors/dto/create-api-key.dto.ts b/src/modules/vendors/dto/create-api-key.dto.ts new file mode 100644 index 0000000..3d92f75 --- /dev/null +++ b/src/modules/vendors/dto/create-api-key.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsArray, IsOptional, IsDateString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateApiKeyDto { + @ApiProperty({ example: 'Production API Key' }) + @IsString() + name: string; + + @ApiProperty({ example: ['loans:read', 'transactions:read'] }) + @IsArray() + @IsString({ each: true }) + permissions: string[]; + + @ApiPropertyOptional({ example: '2027-06-27T00:00:00Z' }) + @IsOptional() + @IsDateString() + expiresAt?: string; +} diff --git a/src/modules/vendors/vendors.controller.ts b/src/modules/vendors/vendors.controller.ts index 90ac225..e42687c 100644 --- a/src/modules/vendors/vendors.controller.ts +++ b/src/modules/vendors/vendors.controller.ts @@ -1,7 +1,30 @@ -import { Controller, Get, Param, Query, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Query, + ParseUUIDPipe, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiQuery, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { VendorsService } from './vendors.service'; import { VendorResponseDto, VendorType } from './dto/vendor.dto'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto/api-key-response.dto'; @ApiTags('vendors') @Controller('vendors') @@ -26,4 +49,49 @@ export class VendorsController { async getById(@Param('id') id: string): Promise { return this.vendorsService.getById(id); } + + @Post('api-keys') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a new API key for the authenticated vendor' }) + @ApiResponse({ status: 201, description: 'API key created', type: ApiKeyCreatedResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) + async createApiKey( + @CurrentUser() user: { wallet: string }, + @Body() dto: CreateApiKeyDto, + ): Promise { + return this.vendorsService.createApiKey(user.wallet, dto); + } + + @Get('api-keys') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'List all API keys for the authenticated vendor' }) + @ApiResponse({ status: 200, description: 'List of API keys', type: [ApiKeyResponseDto] }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) + async listApiKeys( + @CurrentUser() user: { wallet: string }, + ): Promise { + return this.vendorsService.listApiKeys(user.wallet); + } + + @Delete('api-keys/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiBearerAuth() + @ApiOperation({ summary: 'Revoke an API key' }) + @ApiParam({ name: 'id', description: 'API key UUID' }) + @ApiResponse({ status: 204, description: 'API key revoked' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'API key not found' }) + async revokeApiKey( + @CurrentUser() user: { wallet: string }, + @Param('id', ParseUUIDPipe) keyId: string, + ): Promise { + return this.vendorsService.revokeApiKey(user.wallet, keyId); + } } diff --git a/src/modules/vendors/vendors.module.ts b/src/modules/vendors/vendors.module.ts index 766ba4b..a9af58c 100644 --- a/src/modules/vendors/vendors.module.ts +++ b/src/modules/vendors/vendors.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { VendorsService } from './vendors.service'; import { VendorsController } from './vendors.controller'; import { SupabaseService } from '../../database/supabase.client'; +import { VendorsRepository } from '../../database/repositories/vendors.repository'; +import { AuthModule } from '../auth/auth.module'; @Module({ - providers: [VendorsService, SupabaseService], + imports: [AuthModule], + providers: [VendorsService, VendorsRepository, SupabaseService], controllers: [VendorsController], exports: [VendorsService], }) diff --git a/src/modules/vendors/vendors.service.ts b/src/modules/vendors/vendors.service.ts index 553ee0c..0c9a10c 100644 --- a/src/modules/vendors/vendors.service.ts +++ b/src/modules/vendors/vendors.service.ts @@ -1,6 +1,17 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { createHash, randomBytes } from 'crypto'; import { SupabaseService } from '../../database/supabase.client'; +import { VendorsRepository } from '../../database/repositories/vendors.repository'; import { VendorResponseDto, VendorType } from './dto/vendor.dto'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto/api-key-response.dto'; + +const API_KEY_PREFIX = 'sfi_'; interface VendorRow { id: string; @@ -14,11 +25,28 @@ interface VendorRow { created_at: string; } +interface ApiKeyRow { + id: string; + vendor_id: string; + name: string; + key_prefix: string; + key_hash: string; + permissions: string[]; + is_active: boolean; + last_used_at: string | null; + expires_at: string | null; + created_at: string; + updated_at: string; +} + @Injectable() export class VendorsService { private readonly logger = new Logger(VendorsService.name); - constructor(private readonly supabaseService: SupabaseService) {} + constructor( + private readonly supabaseService: SupabaseService, + private readonly vendorsRepository: VendorsRepository, + ) {} async getAll(type?: VendorType): Promise { const client = this.supabaseService.getClient(); @@ -58,6 +86,114 @@ export class VendorsService { return this.mapToDto(data as VendorRow); } + async createApiKey(wallet: string, dto: CreateApiKeyDto): Promise { + const vendor = await this.vendorsRepository.findByWallet(wallet); + if (!vendor) { + throw new NotFoundException({ + code: 'VENDOR_NOT_FOUND', + message: 'No vendor found for this wallet address.', + }); + } + + const rawKey = API_KEY_PREFIX + randomBytes(32).toString('hex'); + const keyHash = createHash('sha256').update(rawKey).digest('hex'); + + const client = this.supabaseService.getServiceRoleClient(); + const { data, error } = await client + .from('api_keys') + .insert({ + vendor_id: vendor.id, + name: dto.name, + key_prefix: rawKey.substring(0, 8), + key_hash: keyHash, + permissions: dto.permissions, + expires_at: dto.expiresAt || null, + }) + .select('*') + .single(); + + if (error) { + this.logger.error(`Failed to create API key: ${error.message}`); + throw new InternalServerErrorException({ + code: 'DATABASE_API_KEY_CREATE_FAILED', + message: 'Failed to create API key.', + }); + } + + const keyData = data as unknown as ApiKeyRow; + + return { + ...this.mapApiKeyToDto(keyData), + fullKey: rawKey, + }; + } + + async listApiKeys(wallet: string): Promise { + const vendor = await this.vendorsRepository.findByWallet(wallet); + if (!vendor) { + throw new NotFoundException({ + code: 'VENDOR_NOT_FOUND', + message: 'No vendor found for this wallet address.', + }); + } + + const client = this.supabaseService.getServiceRoleClient(); + const { data, error } = await client + .from('api_keys') + .select('*') + .eq('vendor_id', vendor.id) + .order('created_at', { ascending: false }); + + if (error) { + this.logger.error(`Failed to list API keys: ${error.message}`); + throw new InternalServerErrorException({ + code: 'DATABASE_QUERY_ERROR', + message: 'Failed to list API keys.', + }); + } + + const rows: ApiKeyRow[] = (data ?? []) as ApiKeyRow[]; + return rows.map((row) => this.mapApiKeyToDto(row)); + } + + async revokeApiKey(wallet: string, keyId: string): Promise { + const vendor = await this.vendorsRepository.findByWallet(wallet); + if (!vendor) { + throw new NotFoundException({ + code: 'VENDOR_NOT_FOUND', + message: 'No vendor found for this wallet address.', + }); + } + + const client = this.supabaseService.getServiceRoleClient(); + const { data: existing, error: fetchError } = await client + .from('api_keys') + .select('id') + .eq('id', keyId) + .eq('vendor_id', vendor.id) + .single(); + + if (fetchError || !existing) { + throw new NotFoundException({ + code: 'API_KEY_NOT_FOUND', + message: 'API key not found or does not belong to this vendor.', + }); + } + + const { error: updateError } = await client + .from('api_keys') + .update({ is_active: false }) + .eq('id', keyId); + + if (updateError) { + this.logger.error(`Failed to revoke API key: ${updateError.message}`); + throw new InternalServerErrorException({ + code: 'DATABASE_API_KEY_REVOKE_FAILED', + message: 'Failed to revoke API key.', + }); + } + } + private mapToDto(data: VendorRow): VendorResponseDto { return { id: data.id, @@ -71,4 +207,19 @@ export class VendorsService { createdAt: data.created_at, }; } + + private mapApiKeyToDto(data: ApiKeyRow): ApiKeyResponseDto { + return { + id: data.id, + vendorId: data.vendor_id, + name: data.name, + keyPrefix: data.key_prefix, + permissions: data.permissions, + isActive: data.is_active, + lastUsedAt: data.last_used_at ?? undefined, + expiresAt: data.expires_at ?? undefined, + createdAt: data.created_at, + updatedAt: data.updated_at, + }; + } } diff --git a/supabase/migrations/20260627000000_api_keys.sql b/supabase/migrations/20260627000000_api_keys.sql new file mode 100644 index 0000000..9e5830c --- /dev/null +++ b/supabase/migrations/20260627000000_api_keys.sql @@ -0,0 +1,32 @@ +CREATE TABLE public.api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vendor_id UUID NOT NULL REFERENCES public.vendors(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + key_prefix VARCHAR(8) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + permissions JSONB NOT NULL DEFAULT '[]'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT true, + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_api_keys_key_hash ON public.api_keys(key_hash); +CREATE INDEX idx_api_keys_vendor_id ON public.api_keys(vendor_id); + +CREATE OR REPLACE FUNCTION public.update_updated_at_column() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_api_keys_updated_at + BEFORE UPDATE ON public.api_keys + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +ALTER TABLE public.api_keys ENABLE ROW LEVEL SECURITY; diff --git a/test/unit/modules/auth/api-key.guard.spec.ts b/test/unit/modules/auth/api-key.guard.spec.ts new file mode 100644 index 0000000..113c043 --- /dev/null +++ b/test/unit/modules/auth/api-key.guard.spec.ts @@ -0,0 +1,325 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UnauthorizedException, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ApiKeyGuard } from '../../../../src/auth/guards/api-key.guard'; +import { SupabaseService } from '../../../../src/database/supabase.client'; + +describe('ApiKeyGuard', () => { + let guard: ApiKeyGuard; + let mockSupabaseClient: Record; + let mockSupabaseService: { getServiceRoleClient: jest.Mock }; + let mockReflector: { get: jest.Mock }; + let mockContext: any; + + const activeKeyRecord = { + id: 'key-uuid', + vendor_id: 'vendor-uuid', + name: 'Test Key', + key_prefix: 'sfi_a1b2', + key_hash: 'abc123hash', + permissions: ['loans:read', 'loans:write'], + is_active: true, + last_used_at: null, + expires_at: null, + created_at: '2026-06-27T00:00:00Z', + updated_at: '2026-06-27T00:00:00Z', + }; + + const validApiKey = 'sfi_' + 'a'.repeat(64); + + beforeEach(async () => { + mockSupabaseClient = { + from: jest.fn(), + }; + + mockSupabaseService = { + getServiceRoleClient: jest.fn(() => mockSupabaseClient), + }; + + mockReflector = { + get: jest.fn().mockReturnValue(null), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyGuard, + { provide: SupabaseService, useValue: mockSupabaseService }, + { provide: Reflector, useValue: mockReflector }, + ], + }).compile(); + + guard = module.get(ApiKeyGuard); + + mockContext = { + switchToHttp: jest.fn().mockReturnThis(), + getRequest: jest.fn(), + getHandler: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // --------------------------------------------------------------------------- + // canActivate — basic scenarios + // --------------------------------------------------------------------------- + describe('canActivate', () => { + function setupRequest(headers: Record) { + mockContext.switchToHttp.mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ headers }), + }); + } + + function setupDbQuery(result: { data: any; error: any }) { + const singleFn = jest.fn().mockResolvedValue(result); + const eqFn = jest.fn().mockReturnValue({ single: singleFn }); + const selectFn = jest.fn().mockReturnValue({ eq: eqFn }); + const updateFn = jest.fn().mockResolvedValue({ error: null }); + const updateEqFn = jest.fn().mockReturnValue(updateFn); + + mockSupabaseClient.from.mockReturnValue({ + select: selectFn, + update: jest.fn().mockReturnValue({ eq: updateEqFn }), + }); + } + + it('should return true when X-API-Key is valid and active', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: activeKeyRecord, error: null }); + mockContext.getHandler.mockReturnValue(() => {}); + + const result = await guard.canActivate(mockContext); + expect(result).toBe(true); + }); + + it('should throw UnauthorizedException (API_KEY_MISSING) when X-API-Key header is absent', async () => { + setupRequest({}); + + await expect(guard.canActivate(mockContext)).rejects.toMatchObject({ + response: { code: 'API_KEY_MISSING' }, + }); + }); + + it('should throw UnauthorizedException (API_KEY_MISSING) when X-API-Key is not a string', async () => { + setupRequest({ 'x-api-key': ['key1', 'key2'] } as any); + + await expect(guard.canActivate(mockContext)).rejects.toMatchObject({ + response: { code: 'API_KEY_MISSING' }, + }); + }); + + it('should throw UnauthorizedException (API_KEY_INVALID) when key_hash not found', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: null, error: { message: 'No rows found' } }); + mockContext.getHandler.mockReturnValue(() => {}); + + await expect(guard.canActivate(mockContext)).rejects.toMatchObject({ + response: { code: 'API_KEY_INVALID' }, + }); + }); + + it('should throw UnauthorizedException (API_KEY_INVALID) when database error occurs', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: null, error: { message: 'DB error' } }); + mockContext.getHandler.mockReturnValue(() => {}); + + await expect(guard.canActivate(mockContext)).rejects.toMatchObject({ + response: { code: 'API_KEY_INVALID' }, + }); + }); + + it('should throw UnauthorizedException (API_KEY_INACTIVE) when key is revoked', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ + data: { ...activeKeyRecord, is_active: false }, + error: null, + }); + mockContext.getHandler.mockReturnValue(() => {}); + + await expect(guard.canActivate(mockContext)).rejects.toMatchObject({ + response: { code: 'API_KEY_INACTIVE' }, + }); + }); + + it('should throw UnauthorizedException (API_KEY_EXPIRED) when key is past expiry', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ + data: { + ...activeKeyRecord, + expires_at: new Date(Date.now() - 86400000).toISOString(), + }, + error: null, + }); + mockContext.getHandler.mockReturnValue(() => {}); + + await expect(guard.canActivate(mockContext)).rejects.toMatchObject({ + response: { code: 'API_KEY_EXPIRED' }, + }); + }); + + it('should not reject when expiry is in the future', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ + data: { + ...activeKeyRecord, + expires_at: new Date(Date.now() + 86400000).toISOString(), + }, + error: null, + }); + mockContext.getHandler.mockReturnValue(() => {}); + + const result = await guard.canActivate(mockContext); + expect(result).toBe(true); + }); + + it('should set request.apiKey with the key record', async () => { + const request = { headers: { 'x-api-key': validApiKey } }; + mockContext.switchToHttp.mockReturnValue({ + getRequest: jest.fn().mockReturnValue(request), + }); + setupDbQuery({ data: activeKeyRecord, error: null }); + mockContext.getHandler.mockReturnValue(() => {}); + + await guard.canActivate(mockContext); + expect(request).toHaveProperty('apiKey'); + expect((request as any).apiKey.id).toBe('key-uuid'); + }); + }); + + // --------------------------------------------------------------------------- + // canActivate — permission enforcement + // --------------------------------------------------------------------------- + describe('permission enforcement', () => { + function setupRequest(headers: Record) { + mockContext.switchToHttp.mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ headers }), + }); + } + + function setupDbQuery(result: { data: any; error: any }) { + const singleFn = jest.fn().mockResolvedValue(result); + const eqFn = jest.fn().mockReturnValue({ single: singleFn }); + const selectFn = jest.fn().mockReturnValue({ eq: eqFn }); + const updateFn = jest.fn().mockResolvedValue({ error: null }); + const updateEqFn = jest.fn().mockReturnValue(updateFn); + + mockSupabaseClient.from.mockReturnValue({ + select: selectFn, + update: jest.fn().mockReturnValue({ eq: updateEqFn }), + }); + } + + it('should pass when required permissions match key permissions', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: activeKeyRecord, error: null }); + mockReflector.get.mockReturnValue(['loans:read']); + mockContext.getHandler.mockReturnValue(() => {}); + + const result = await guard.canActivate(mockContext); + expect(result).toBe(true); + }); + + it('should pass when key has any of the required permissions', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: activeKeyRecord, error: null }); + mockReflector.get.mockReturnValue(['loans:write', 'transactions:read']); + mockContext.getHandler.mockReturnValue(() => {}); + + const result = await guard.canActivate(mockContext); + expect(result).toBe(true); + }); + + it('should throw ForbiddenException (API_KEY_INSUFFICIENT_PERMISSIONS) when key lacks required permissions', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: activeKeyRecord, error: null }); + mockReflector.get.mockReturnValue(['admin:write']); + mockContext.getHandler.mockReturnValue(() => {}); + + await expect(guard.canActivate(mockContext)).rejects.toMatchObject({ + response: { code: 'API_KEY_INSUFFICIENT_PERMISSIONS' }, + }); + }); + + it('should pass when no permissions are required on the endpoint', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: activeKeyRecord, error: null }); + mockReflector.get.mockReturnValue(null); + mockContext.getHandler.mockReturnValue(() => {}); + + const result = await guard.canActivate(mockContext); + expect(result).toBe(true); + }); + + it('should pass when required permissions is an empty array', async () => { + setupRequest({ 'x-api-key': validApiKey }); + setupDbQuery({ data: activeKeyRecord, error: null }); + mockReflector.get.mockReturnValue([]); + mockContext.getHandler.mockReturnValue(() => {}); + + const result = await guard.canActivate(mockContext); + expect(result).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // last_used_at update (fire-and-forget) + // --------------------------------------------------------------------------- + describe('last_used_at update', () => { + function setupRequest(headers: Record) { + mockContext.switchToHttp.mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ headers }), + }); + } + + it('should update last_used_at on successful authentication', async () => { + setupRequest({ 'x-api-key': validApiKey }); + + const singleFn = jest.fn().mockResolvedValue({ data: activeKeyRecord, error: null }); + const eqFn = jest.fn().mockReturnValue({ single: singleFn }); + const selectFn = jest.fn().mockReturnValue({ eq: eqFn }); + const updateEqFn = jest.fn().mockResolvedValue({ error: null }); + const updateFn = jest.fn().mockReturnValue({ eq: updateEqFn }); + + mockSupabaseClient.from.mockImplementation((table: string) => { + if (table === 'api_keys') { + return { + select: selectFn, + update: jest.fn().mockReturnValue({ eq: updateEqFn }), + }; + } + return { insert: jest.fn() }; + }); + + mockContext.getHandler.mockReturnValue(() => {}); + + await guard.canActivate(mockContext); + expect(mockSupabaseService.getServiceRoleClient).toHaveBeenCalledTimes(2); + }); + + it('should not throw when last_used_at update fails (fire-and-forget)', async () => { + setupRequest({ 'x-api-key': validApiKey }); + + const singleFn = jest.fn().mockResolvedValue({ data: activeKeyRecord, error: null }); + const eqFn = jest.fn().mockReturnValue({ single: singleFn }); + const selectFn = jest.fn().mockReturnValue({ eq: eqFn }); + const updateEqFn = jest.fn().mockRejectedValue(new Error('Network error')); + const updateFn = jest.fn().mockReturnValue({ eq: updateEqFn }); + + mockSupabaseClient.from.mockImplementation((table: string) => { + if (table === 'api_keys') { + return { + select: selectFn, + update: jest.fn().mockReturnValue({ eq: updateEqFn }), + }; + } + return { insert: jest.fn() }; + }); + + mockContext.getHandler.mockReturnValue(() => {}); + + const result = await guard.canActivate(mockContext); + expect(result).toBe(true); + }); + }); +});