File size: 4,008 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import type { Request } from 'express';
import { AuthController } from './auth.controller';
import { AuditAction } from '../audit/entities/audit-log.entity';
import type { ApiKey } from './entities/api-key.entity';
import type { AuthService } from './auth.service';
import type { AuditService } from '../audit/audit.service';

// API-key lifecycle operations (create / delete / revoke) must leave an audit trail — they were
// previously unrecorded. These assert the controller emits the matching audit action with the acting
// admin key, the resolved client IP, and the target key in metadata.
describe('AuthController — API-key lifecycle audit logging', () => {
  const actor = { id: 'admin-key', name: 'admin' } as unknown as ApiKey;
  const makeReq = (): Request =>
    ({ method: 'POST', path: '/auth/api-keys', clientIp: '203.0.113.7' }) as unknown as Request;

  let authService: {
    createApiKey: jest.Mock;
    findOne: jest.Mock;
    update: jest.Mock;
    delete: jest.Mock;
    revoke: jest.Mock;
  };
  let auditService: { logInfo: jest.Mock };
  let controller: AuthController;

  beforeEach(() => {
    const createdKey = {
      id: 'k1',
      name: 'new-key',
      role: 'user',
      keyPrefix: 'ow_',
      isActive: true,
      usageCount: 0,
      createdAt: new Date(),
    };
    authService = {
      createApiKey: jest.fn().mockResolvedValue({ apiKey: createdKey, rawKey: 'raw-secret' }),
      findOne: jest.fn().mockResolvedValue({
        id: 'k1',
        name: 'target-key',
        role: 'viewer',
        allowedIps: null,
        allowedSessions: null,
        expiresAt: null,
      }),
      update: jest.fn().mockResolvedValue({ ...createdKey, role: 'admin' }),
      delete: jest.fn().mockResolvedValue(undefined),
      revoke: jest.fn().mockResolvedValue({ ...createdKey, isActive: false }),
    };
    auditService = { logInfo: jest.fn().mockResolvedValue(null) };
    controller = new AuthController(authService as unknown as AuthService, auditService as unknown as AuditService);
  });

  const lastContextFor = (
    action: AuditAction,
  ):
    | {
        apiKey?: ApiKey;
        ipAddress?: string;
        metadata?: { targetKeyId?: string; before?: { role?: string }; after?: { role?: string } };
      }
    | undefined => {
    const calls = auditService.logInfo.mock.calls as Array<
      [
        AuditAction,
        {
          apiKey?: ApiKey;
          ipAddress?: string;
          metadata?: { targetKeyId?: string; before?: { role?: string }; after?: { role?: string } };
        },
      ]
    >;
    return calls.find(c => c[0] === action)?.[1];
  };

  it('logs API_KEY_CREATED on create, with the acting key, IP, and target id', async () => {
    await controller.create({ name: 'new-key' }, makeReq(), actor);
    const ctx = lastContextFor(AuditAction.API_KEY_CREATED);
    expect(ctx).toBeDefined();
    expect(ctx?.apiKey).toBe(actor);
    expect(ctx?.ipAddress).toBe('203.0.113.7');
    expect(ctx?.metadata?.targetKeyId).toBe('k1');
    expect(JSON.stringify(auditService.logInfo.mock.calls)).not.toContain('raw-secret');
  });

  it('logs API_KEY_DELETED on delete', async () => {
    await controller.delete('k1', makeReq(), actor);
    expect(authService.delete).toHaveBeenCalledWith('k1');
    expect(lastContextFor(AuditAction.API_KEY_DELETED)?.metadata?.targetKeyId).toBe('k1');
  });

  it('logs API_KEY_UPDATED with before/after authorization state', async () => {
    await controller.update('k1', { role: 'admin' } as never, makeReq(), actor);
    const ctx = lastContextFor(AuditAction.API_KEY_UPDATED);
    expect(ctx?.apiKey).toBe(actor);
    expect(ctx?.metadata?.targetKeyId).toBe('k1');
    expect(ctx?.metadata?.before?.role).toBe('viewer');
    expect(ctx?.metadata?.after?.role).toBe('admin');
  });

  it('logs API_KEY_REVOKED on revoke', async () => {
    await controller.revoke('k1', makeReq(), actor);
    expect(lastContextFor(AuditAction.API_KEY_REVOKED)?.metadata?.targetKeyId).toBe('k1');
  });
});