Spaces:
Runtime error
Runtime error
| import { Test, TestingModule } from '@nestjs/testing'; | |
| import { getRepositoryToken } from '@nestjs/typeorm'; | |
| import { Repository } from 'typeorm'; | |
| import { UnauthorizedException, NotFoundException } from '@nestjs/common'; | |
| import { createHash, createHmac } from 'crypto'; | |
| import { AuthService, resolveSeedApiKey, bannerKeyLine } from './auth.service'; | |
| import { ApiKey, ApiKeyRole } from './entities/api-key.entity'; | |
| // Helpers | |
| const hashKey = (key: string) => createHash('sha256').update(key).digest('hex'); | |
| function createMockApiKey(overrides: Partial<ApiKey> = {}): ApiKey { | |
| return { | |
| id: 'uuid-1', | |
| name: 'Test Key', | |
| keyHash: hashKey('test-key'), | |
| keyPrefix: 'test-key-pre', | |
| role: ApiKeyRole.OPERATOR, | |
| allowedIps: null, | |
| allowedSessions: null, | |
| isActive: true, | |
| expiresAt: null, | |
| lastUsedAt: null, | |
| usageCount: 0, | |
| createdAt: new Date(), | |
| updatedAt: new Date(), | |
| ...overrides, | |
| }; | |
| } | |
| describe('resolveSeedApiKey (first-boot default admin key)', () => { | |
| const ORIGINAL_ENV = process.env; | |
| beforeEach(() => { | |
| process.env = { ...ORIGINAL_ENV }; | |
| delete process.env.API_MASTER_KEY; | |
| delete process.env.ALLOW_DEV_API_KEY; | |
| }); | |
| afterEach(() => { | |
| process.env = ORIGINAL_ENV; | |
| }); | |
| it('uses API_MASTER_KEY verbatim when set', () => { | |
| process.env.API_MASTER_KEY = 'my-explicit-master-key'; | |
| expect(resolveSeedApiKey()).toBe('my-explicit-master-key'); | |
| }); | |
| it('generates a random owa_k1_ key by default (no opt-in)', () => { | |
| expect(resolveSeedApiKey()).toMatch(/^owa_k1_[a-f0-9]{64}$/); | |
| }); | |
| it('returns the fixed dev-admin-key only when ALLOW_DEV_API_KEY=true', () => { | |
| process.env.ALLOW_DEV_API_KEY = 'true'; | |
| expect(resolveSeedApiKey()).toBe('dev-admin-key'); | |
| }); | |
| it('prefers API_MASTER_KEY over the dev opt-in', () => { | |
| process.env.API_MASTER_KEY = 'master-wins'; | |
| process.env.ALLOW_DEV_API_KEY = 'true'; | |
| expect(resolveSeedApiKey()).toBe('master-wins'); | |
| }); | |
| }); | |
| describe('bannerKeyLine (startup banner key masking)', () => { | |
| const FULL = 'owa_k1_0123456789abcdef0123456789abcdef'; | |
| it('prints the full key only when it was just created', () => { | |
| expect(bannerKeyLine(FULL, true)).toBe(FULL); | |
| }); | |
| it('masks the key on subsequent boots β the full secret is never re-logged', () => { | |
| const line = bannerKeyLine(FULL, false); | |
| expect(line).not.toContain('0123456789abcdef'); // the secret tail must not appear | |
| expect(line.startsWith('owa_k1_0')).toBe(true); // a short fingerprint is fine | |
| expect(line).toMatch(/data\/\.api-key|dashboard/); // points the operator to the real source | |
| }); | |
| it('passes a placeholder through unchanged', () => { | |
| expect(bannerKeyLine('(check dashboard for keys)', false)).toBe('(check dashboard for keys)'); | |
| }); | |
| }); | |
| describe('AuthService', () => { | |
| let service: AuthService; | |
| let repository: jest.Mocked<Partial<Repository<ApiKey>>>; | |
| beforeEach(async () => { | |
| repository = { | |
| count: jest.fn(), | |
| find: jest.fn(), | |
| findOne: jest.fn(), | |
| create: jest.fn(), | |
| save: jest.fn(), | |
| remove: jest.fn(), | |
| }; | |
| const module: TestingModule = await Test.createTestingModule({ | |
| providers: [ | |
| AuthService, | |
| { | |
| provide: getRepositoryToken(ApiKey, 'main'), | |
| useValue: repository, | |
| }, | |
| ], | |
| }).compile(); | |
| service = module.get<AuthService>(AuthService); | |
| }); | |
| // ββ createApiKey ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('createApiKey', () => { | |
| it('should generate a key with owa_k1_ prefix and save to DB', async () => { | |
| const mockSaved = createMockApiKey({ name: 'My Key' }); | |
| (repository.create as jest.Mock).mockReturnValue(mockSaved); | |
| (repository.save as jest.Mock).mockResolvedValue(mockSaved); | |
| const result = await service.createApiKey({ name: 'My Key' }); | |
| expect(result.rawKey).toMatch(/^owa_k1_[a-f0-9]{64}$/); | |
| expect(result.apiKey).toBe(mockSaved); | |
| expect(repository.create).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'My Key', | |
| role: ApiKeyRole.OPERATOR, // default | |
| }), | |
| ); | |
| }); | |
| it('should use the provided role instead of default', async () => { | |
| const mockSaved = createMockApiKey({ role: ApiKeyRole.ADMIN }); | |
| (repository.create as jest.Mock).mockReturnValue(mockSaved); | |
| (repository.save as jest.Mock).mockResolvedValue(mockSaved); | |
| await service.createApiKey({ name: 'Admin Key', role: ApiKeyRole.ADMIN }); | |
| expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ role: ApiKeyRole.ADMIN })); | |
| }); | |
| it('should store the SHA-256 hash, not the raw key', async () => { | |
| const mockSaved = createMockApiKey(); | |
| (repository.create as jest.Mock).mockReturnValue(mockSaved); | |
| (repository.save as jest.Mock).mockResolvedValue(mockSaved); | |
| const result = await service.createApiKey({ name: 'Test' }); | |
| const expectedHash = hashKey(result.rawKey); | |
| expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ keyHash: expectedHash })); | |
| }); | |
| }); | |
| // ββ findAll / findOne βββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('findAll', () => { | |
| it('should return all API keys ordered by createdAt DESC', async () => { | |
| const keys = [createMockApiKey(), createMockApiKey({ id: 'uuid-2' })]; | |
| (repository.find as jest.Mock).mockResolvedValue(keys); | |
| const result = await service.findAll(); | |
| expect(result).toHaveLength(2); | |
| expect(repository.find).toHaveBeenCalledWith({ order: { createdAt: 'DESC' } }); | |
| }); | |
| }); | |
| describe('findOne', () => { | |
| it('should return the API key if found', async () => { | |
| const key = createMockApiKey(); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| const result = await service.findOne('uuid-1'); | |
| expect(result).toBe(key); | |
| }); | |
| it('should throw NotFoundException if key not found', async () => { | |
| (repository.findOne as jest.Mock).mockResolvedValue(null); | |
| await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException); | |
| }); | |
| }); | |
| // ββ update ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('update', () => { | |
| it('should update only the provided fields', async () => { | |
| const key = createMockApiKey(); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| const result = await service.update('uuid-1', { name: 'Updated' }); | |
| expect(result.name).toBe('Updated'); | |
| expect(result.role).toBe(ApiKeyRole.OPERATOR); // unchanged | |
| }); | |
| it('evicts active WebSocket sockets when allowedSessions narrows', async () => { | |
| const evictApiKey = jest.fn(); | |
| jest | |
| .spyOn((service as unknown as { moduleRef: { get: (...a: unknown[]) => unknown } }).moduleRef, 'get') | |
| .mockReturnValue({ evictApiKey }); | |
| const key = createMockApiKey({ allowedSessions: ['sess-A', 'sess-B'] }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| await service.update('uuid-1', { allowedSessions: ['sess-A'] }); | |
| expect(evictApiKey).toHaveBeenCalledWith('uuid-1', 'authorization_changed'); | |
| }); | |
| it('evicts active WebSocket sockets when the role changes', async () => { | |
| const evictApiKey = jest.fn(); | |
| jest | |
| .spyOn((service as unknown as { moduleRef: { get: (...a: unknown[]) => unknown } }).moduleRef, 'get') | |
| .mockReturnValue({ evictApiKey }); | |
| const key = createMockApiKey({ role: ApiKeyRole.OPERATOR }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| await service.update('uuid-1', { role: ApiKeyRole.ADMIN }); | |
| expect(evictApiKey).toHaveBeenCalledWith('uuid-1', 'authorization_changed'); | |
| }); | |
| it('does not evict on a benign (name-only) update', async () => { | |
| const evictApiKey = jest.fn(); | |
| jest | |
| .spyOn((service as unknown as { moduleRef: { get: (...a: unknown[]) => unknown } }).moduleRef, 'get') | |
| .mockReturnValue({ evictApiKey }); | |
| const key = createMockApiKey({ name: 'original' }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| await service.update('uuid-1', { name: 'renamed' }); | |
| expect(evictApiKey).not.toHaveBeenCalled(); | |
| }); | |
| it('rejects demoting or expiring the last usable admin', async () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.ADMIN }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.count as jest.Mock).mockResolvedValue(0); | |
| await expect(service.update('uuid-1', { role: ApiKeyRole.OPERATOR })).rejects.toThrow(/last active admin/i); | |
| await expect( | |
| service.update('uuid-1', { expiresAt: new Date(Date.now() + 60_000).toISOString() }), | |
| ).rejects.toThrow(/last active admin/i); | |
| expect(repository.save).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| // ββ delete / revoke βββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('delete', () => { | |
| it('should remove the API key from DB', async () => { | |
| const key = createMockApiKey(); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.remove as jest.Mock).mockResolvedValue(key); | |
| await service.delete('uuid-1'); | |
| expect(repository.remove).toHaveBeenCalledWith(key); | |
| }); | |
| it('should throw NotFoundException for non-existent key', async () => { | |
| (repository.findOne as jest.Mock).mockResolvedValue(null); | |
| await expect(service.delete('nonexistent')).rejects.toThrow(NotFoundException); | |
| }); | |
| it('evicts active WebSocket sockets authenticated with the deleted key', async () => { | |
| const evictApiKey = jest.fn(); | |
| jest | |
| .spyOn((service as unknown as { moduleRef: { get: (...a: unknown[]) => unknown } }).moduleRef, 'get') | |
| .mockReturnValue({ evictApiKey }); | |
| const key = createMockApiKey(); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.remove as jest.Mock).mockResolvedValue(key); | |
| await service.delete('uuid-1'); | |
| expect(repository.remove).toHaveBeenCalledWith(key); | |
| expect(evictApiKey).toHaveBeenCalledWith('uuid-1', 'deleted'); | |
| }); | |
| it('rejects deleting the last usable admin but allows it when another usable admin exists', async () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.ADMIN }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.remove as jest.Mock).mockResolvedValue(key); | |
| (repository.count as jest.Mock).mockResolvedValueOnce(0).mockResolvedValueOnce(1); | |
| await expect(service.delete('uuid-1')).rejects.toThrow(/last active admin/i); | |
| await expect(service.delete('uuid-1')).resolves.toBeUndefined(); | |
| expect(repository.remove).toHaveBeenCalledTimes(1); | |
| }); | |
| }); | |
| describe('revoke', () => { | |
| it('should set isActive to false', async () => { | |
| const key = createMockApiKey({ isActive: true }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| const result = await service.revoke('uuid-1'); | |
| expect(result.isActive).toBe(false); | |
| }); | |
| it('evicts active WebSocket sockets authenticated with the revoked key', async () => { | |
| const evictApiKey = jest.fn(); | |
| jest | |
| .spyOn((service as unknown as { moduleRef: { get: (...a: unknown[]) => unknown } }).moduleRef, 'get') | |
| .mockReturnValue({ evictApiKey }); | |
| const key = createMockApiKey({ isActive: true }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| await service.revoke('uuid-1'); | |
| expect(key.isActive).toBe(false); | |
| expect(evictApiKey).toHaveBeenCalledWith('uuid-1', 'revoked'); | |
| }); | |
| it('does not roll back the revoke if WebSocket eviction throws (best-effort)', async () => { | |
| jest | |
| .spyOn((service as unknown as { moduleRef: { get: (...a: unknown[]) => unknown } }).moduleRef, 'get') | |
| .mockImplementation(() => { | |
| throw new Error('gateway unavailable'); | |
| }); | |
| const key = createMockApiKey({ isActive: true }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| const result = await service.revoke('uuid-1'); | |
| expect(result.isActive).toBe(false); // revoke still succeeded | |
| }); | |
| it('rejects revoking the last usable admin', async () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.ADMIN, isActive: true }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.count as jest.Mock).mockResolvedValue(0); | |
| await expect(service.revoke('uuid-1')).rejects.toThrow(/last active admin/i); | |
| expect(repository.save).not.toHaveBeenCalled(); | |
| expect(key.isActive).toBe(true); | |
| }); | |
| }); | |
| // ββ validateApiKey ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('validateApiKey', () => { | |
| it('should return the API key for a valid raw key', async () => { | |
| const rawKey = 'test-key'; | |
| const key = createMockApiKey({ keyHash: hashKey(rawKey) }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| const result = await service.validateApiKey(rawKey); | |
| expect(result.id).toBe(key.id); | |
| expect(result.usageCount).toBe(1); | |
| expect(result.lastUsedAt).toBeDefined(); | |
| }); | |
| it('coalesces the usage-stat write within the throttle window', async () => { | |
| const rawKey = 'recent-key'; | |
| const key = createMockApiKey({ keyHash: hashKey(rawKey), lastUsedAt: new Date(), usageCount: 5 }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| const result = await service.validateApiKey(rawKey); | |
| expect(repository.save).not.toHaveBeenCalled(); // throttled β no DB write this request | |
| expect(result.usageCount).toBe(6); // but the count is still reflected in-memory | |
| expect(result.lastUsedAt).toBeDefined(); | |
| }); | |
| it('flushes the usage-stat write once the throttle window has elapsed', async () => { | |
| const rawKey = 'stale-key'; | |
| const key = createMockApiKey({ | |
| keyHash: hashKey(rawKey), | |
| lastUsedAt: new Date(Date.now() - 5 * 60_000), | |
| usageCount: 5, | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| await service.validateApiKey(rawKey); | |
| expect(repository.save).toHaveBeenCalled(); // persisted after the window | |
| }); | |
| it('should throw UnauthorizedException for invalid key', async () => { | |
| (repository.findOne as jest.Mock).mockResolvedValue(null); | |
| await expect(service.validateApiKey('wrong-key')).rejects.toThrow(UnauthorizedException); | |
| }); | |
| it('should throw UnauthorizedException for revoked key', async () => { | |
| const key = createMockApiKey({ isActive: false, keyHash: hashKey('revoked') }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| await expect(service.validateApiKey('revoked')).rejects.toThrow('API key is revoked'); | |
| }); | |
| it('should throw UnauthorizedException for expired key', async () => { | |
| const expired = new Date(); | |
| expired.setDate(expired.getDate() - 1); | |
| const key = createMockApiKey({ expiresAt: expired, keyHash: hashKey('expired') }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| await expect(service.validateApiKey('expired')).rejects.toThrow('API key has expired'); | |
| }); | |
| it('should throw UnauthorizedException when IP is not allowed', async () => { | |
| const key = createMockApiKey({ | |
| allowedIps: ['10.0.0.1'], | |
| keyHash: hashKey('ip-restricted'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| await expect(service.validateApiKey('ip-restricted', '192.168.1.1')).rejects.toThrow('IP address not allowed'); | |
| }); | |
| it('should pass when client IP matches allowed IPs', async () => { | |
| const key = createMockApiKey({ | |
| allowedIps: ['10.0.0.1'], | |
| keyHash: hashKey('ip-ok'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| const result = await service.validateApiKey('ip-ok', '10.0.0.1'); | |
| expect(result.id).toBe(key.id); | |
| }); | |
| it('should fail closed when an IP whitelist is set but the client IP is unknown', async () => { | |
| const key = createMockApiKey({ | |
| allowedIps: ['10.0.0.1'], | |
| keyHash: hashKey('ip-no-client'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| await expect(service.validateApiKey('ip-no-client')).rejects.toThrow('Client IP could not be determined'); | |
| }); | |
| it('rejects a malformed client IP instead of coercing it into an allowed range', async () => { | |
| const key = createMockApiKey({ | |
| allowedIps: ['10.0.0.1/32'], | |
| keyHash: hashKey('ip-malformed'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| // The previous lenient parser read '10.0.0.1abc' as 10.0.0.1 and let it through; the shared | |
| // hardened matcher rejects a non-numeric octet, so the per-key whitelist holds. | |
| await expect(service.validateApiKey('ip-malformed', '10.0.0.1abc')).rejects.toThrow('IP address not allowed'); | |
| }); | |
| it('should throw UnauthorizedException when session not in allowedSessions', async () => { | |
| const key = createMockApiKey({ | |
| allowedSessions: ['session-A'], | |
| keyHash: hashKey('sess-restricted'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| await expect(service.validateApiKey('sess-restricted', undefined, 'session-B')).rejects.toThrow( | |
| 'API key not authorized for this session', | |
| ); | |
| }); | |
| }); | |
| // ββ hasPermission βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| describe('hasPermission', () => { | |
| it('should allow ADMIN to access ADMIN routes', () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.ADMIN }); | |
| expect(service.hasPermission(key, ApiKeyRole.ADMIN)).toBe(true); | |
| }); | |
| it('should allow ADMIN to access OPERATOR routes', () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.ADMIN }); | |
| expect(service.hasPermission(key, ApiKeyRole.OPERATOR)).toBe(true); | |
| }); | |
| it('should allow ADMIN to access VIEWER routes', () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.ADMIN }); | |
| expect(service.hasPermission(key, ApiKeyRole.VIEWER)).toBe(true); | |
| }); | |
| it('should deny VIEWER access to OPERATOR routes', () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.VIEWER }); | |
| expect(service.hasPermission(key, ApiKeyRole.OPERATOR)).toBe(false); | |
| }); | |
| it('should deny OPERATOR access to ADMIN routes', () => { | |
| const key = createMockApiKey({ role: ApiKeyRole.OPERATOR }); | |
| expect(service.hasPermission(key, ApiKeyRole.ADMIN)).toBe(false); | |
| }); | |
| }); | |
| // ββ hashKey (via validateApiKey) ββββββββββββββββββββββββββββββββββ | |
| describe('hashKey (determinism)', () => { | |
| it('should produce the same hash for the same input', () => { | |
| const key1 = createMockApiKey({ keyHash: hashKey('same-key') }); | |
| const key2 = createMockApiKey({ keyHash: hashKey('same-key') }); | |
| expect(key1.keyHash).toBe(key2.keyHash); | |
| }); | |
| it('should produce different hashes for different inputs', () => { | |
| expect(hashKey('key-a')).not.toBe(hashKey('key-b')); | |
| }); | |
| }); | |
| // ββ isIpAllowed / ipInCidr (via validateApiKey) βββββββββββββββββββ | |
| describe('IP CIDR validation (via validateApiKey)', () => { | |
| it('should allow IP within CIDR range', async () => { | |
| const key = createMockApiKey({ | |
| allowedIps: ['192.168.1.0/24'], | |
| keyHash: hashKey('cidr-ok'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| const result = await service.validateApiKey('cidr-ok', '192.168.1.100'); | |
| expect(result.id).toBe(key.id); | |
| }); | |
| it('should reject IP outside CIDR range', async () => { | |
| const key = createMockApiKey({ | |
| allowedIps: ['192.168.1.0/24'], | |
| keyHash: hashKey('cidr-fail'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| await expect(service.validateApiKey('cidr-fail', '10.0.0.1')).rejects.toThrow('IP address not allowed'); | |
| }); | |
| it('should handle mixed exact IP and CIDR entries', async () => { | |
| const key = createMockApiKey({ | |
| allowedIps: ['10.0.0.5', '192.168.0.0/16'], | |
| keyHash: hashKey('mixed'), | |
| }); | |
| (repository.findOne as jest.Mock).mockResolvedValue(key); | |
| (repository.save as jest.Mock).mockImplementation(k => Promise.resolve(k)); | |
| // Exact match | |
| const r1 = await service.validateApiKey('mixed', '10.0.0.5'); | |
| expect(r1.id).toBe(key.id); | |
| // Reset usage for second call | |
| key.usageCount = 0; | |
| // CIDR match | |
| const r2 = await service.validateApiKey('mixed', '192.168.50.1'); | |
| expect(r2.id).toBe(key.id); | |
| }); | |
| }); | |
| // ββ API_KEY_PEPPER wiring βββββββββββββββββββββββββββββββββββββββββ | |
| // Proves the service's hashing path actually reads the env var (not just the pure helper). We | |
| // assert on the keyHash the service QUERIES findOne with, since the mock returns regardless. | |
| describe('hashKey reads API_KEY_PEPPER', () => { | |
| const ORIGINAL_ENV = process.env; | |
| afterEach(() => { | |
| process.env = ORIGINAL_ENV; | |
| }); | |
| const queriedHash = async (rawKey: string): Promise<string> => { | |
| (repository.findOne as jest.Mock).mockResolvedValue(null); | |
| await expect(service.validateApiKey(rawKey)).rejects.toThrow(UnauthorizedException); | |
| const calls = (repository.findOne as jest.Mock).mock.calls as Array<[{ where: { keyHash: string } }]>; | |
| return calls[0][0].where.keyHash; | |
| }; | |
| it('hashes with HMAC-SHA256 when the pepper is set', async () => { | |
| process.env = { ...ORIGINAL_ENV, API_KEY_PEPPER: 'server-pepper' }; | |
| const queried = await queriedHash('owa_raw_key'); | |
| expect(queried).toBe(createHmac('sha256', 'server-pepper').update('owa_raw_key').digest('hex')); | |
| expect(queried).not.toBe(createHash('sha256').update('owa_raw_key').digest('hex')); | |
| }); | |
| it('hashes with plain SHA-256 when the pepper is unset (existing keys keep validating)', async () => { | |
| process.env = { ...ORIGINAL_ENV }; | |
| delete process.env.API_KEY_PEPPER; | |
| const queried = await queriedHash('owa_raw_key'); | |
| expect(queried).toBe(createHash('sha256').update('owa_raw_key').digest('hex')); | |
| }); | |
| }); | |
| }); | |