332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { mockPoolQuery, mockClientQuery, mockClientRelease, mockConnect } = vi.hoisted(() => {
|
|
const mockClientQuery = vi.fn();
|
|
const mockClientRelease = vi.fn();
|
|
return {
|
|
mockPoolQuery: vi.fn(),
|
|
mockClientQuery,
|
|
mockClientRelease,
|
|
mockConnect: vi.fn().mockResolvedValue({
|
|
query: mockClientQuery,
|
|
release: mockClientRelease,
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/infra/database/database.manager.js', () => ({
|
|
DatabaseManager: {
|
|
getInstance: () => ({
|
|
getPool: () => ({
|
|
query: mockPoolQuery,
|
|
connect: mockConnect,
|
|
}),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/infra/security/encryption.manager.js', () => ({
|
|
EncryptionManager: {
|
|
encrypt: (value: string) => `enc:${value}`,
|
|
decrypt: (value: string) => value.replace(/^enc:/, ''),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/utils/logger.js', () => ({
|
|
default: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
async function loadSecretService() {
|
|
const { SecretService } = await import('../../src/services/secrets/secret.service.js');
|
|
return SecretService.getInstance();
|
|
}
|
|
|
|
describe('SecretService anon key', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
mockPoolQuery.mockReset();
|
|
mockClientQuery.mockReset();
|
|
mockClientRelease.mockClear();
|
|
mockConnect.mockClear();
|
|
vi.stubEnv('ACCESS_ANON_KEY', '');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('generateAnonKey', () => {
|
|
it('generates an opaque key with the anon_ prefix', async () => {
|
|
const service = await loadSecretService();
|
|
const key = service.generateAnonKey();
|
|
|
|
expect(key).toMatch(/^anon_[0-9a-f]{64}$/);
|
|
expect(service.generateAnonKey()).not.toBe(key);
|
|
});
|
|
});
|
|
|
|
describe('verifyAnonKey', () => {
|
|
it('rejects values without the anon_ prefix without touching the database', async () => {
|
|
const service = await loadSecretService();
|
|
|
|
await expect(service.verifyAnonKey('ik_something')).resolves.toBe(false);
|
|
await expect(service.verifyAnonKey('')).resolves.toBe(false);
|
|
expect(mockPoolQuery).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts the active anon key', async () => {
|
|
mockPoolQuery.mockResolvedValue({
|
|
rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }],
|
|
});
|
|
const service = await loadSecretService();
|
|
|
|
await expect(service.verifyAnonKey('anon_active')).resolves.toBe(true);
|
|
await expect(service.verifyAnonKey('anon_wrong')).resolves.toBe(false);
|
|
});
|
|
|
|
it('deduplicates concurrent cold-cache loads into one query (no stampede)', async () => {
|
|
mockPoolQuery.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
setTimeout(
|
|
() => resolve({ rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }] }),
|
|
20
|
|
)
|
|
)
|
|
);
|
|
const service = await loadSecretService();
|
|
|
|
const results = await Promise.all([
|
|
service.verifyAnonKey('anon_active'),
|
|
service.verifyAnonKey('anon_active'),
|
|
service.verifyAnonKey('anon_wrong'),
|
|
service.verifyAnonKey('anon_active'),
|
|
]);
|
|
|
|
expect(results).toEqual([true, true, false, true]);
|
|
expect(mockPoolQuery).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('serves repeated verifications from the in-memory cache', async () => {
|
|
mockPoolQuery.mockResolvedValue({
|
|
rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }],
|
|
});
|
|
const service = await loadSecretService();
|
|
|
|
await service.verifyAnonKey('anon_active');
|
|
await service.verifyAnonKey('anon_active');
|
|
await service.verifyAnonKey('anon_active');
|
|
|
|
expect(mockPoolQuery).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('accepts a grace-period key until it expires, even when cached', async () => {
|
|
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
|
const pastExpiry = new Date(Date.now() - 1000);
|
|
mockPoolQuery.mockResolvedValue({
|
|
rows: [
|
|
{ value_ciphertext: 'enc:anon_new', expires_at: null },
|
|
{ value_ciphertext: 'enc:anon_in_grace', expires_at: futureExpiry },
|
|
{ value_ciphertext: 'enc:anon_lapsed', expires_at: pastExpiry },
|
|
],
|
|
});
|
|
const service = await loadSecretService();
|
|
|
|
await expect(service.verifyAnonKey('anon_new')).resolves.toBe(true);
|
|
await expect(service.verifyAnonKey('anon_in_grace')).resolves.toBe(true);
|
|
// Returned by the (mocked) query but already expired: must be skipped
|
|
await expect(service.verifyAnonKey('anon_lapsed')).resolves.toBe(false);
|
|
});
|
|
|
|
it('refreshes the cache after invalidation', async () => {
|
|
mockPoolQuery.mockResolvedValue({
|
|
rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }],
|
|
});
|
|
const service = await loadSecretService();
|
|
|
|
await service.verifyAnonKey('anon_active');
|
|
service.invalidateAnonKeyCache();
|
|
await service.verifyAnonKey('anon_active');
|
|
|
|
expect(mockPoolQuery).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('rotateAnonKey', () => {
|
|
it('moves the old key to a grace-period entry and inserts a new active key', async () => {
|
|
mockClientQuery.mockImplementation((sql: string) => {
|
|
if (sql.includes('SELECT id FROM system.secrets')) {
|
|
return Promise.resolve({ rows: [{ id: 'old-key-id' }] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
const service = await loadSecretService();
|
|
|
|
const result = await service.rotateAnonKey(48);
|
|
|
|
expect(result.newAnonKey).toMatch(/^anon_[0-9a-f]{64}$/);
|
|
const expectedExpiry = Date.now() + 48 * 60 * 60 * 1000;
|
|
expect(Math.abs(result.oldKeyExpiresAt.getTime() - expectedExpiry)).toBeLessThan(5000);
|
|
|
|
const calls = mockClientQuery.mock.calls.map(([sql]) => sql as string);
|
|
expect(calls[0]).toBe('BEGIN');
|
|
expect(calls.at(-1)).toBe('COMMIT');
|
|
|
|
const renameCall = mockClientQuery.mock.calls.find(([sql]) =>
|
|
(sql as string).includes('SET key = $1')
|
|
);
|
|
expect(renameCall?.[1]?.[0]).toMatch(/^ANON_KEY_OLD_\d+$/);
|
|
expect(renameCall?.[1]?.[2]).toBe('old-key-id');
|
|
|
|
const insertCall = mockClientQuery.mock.calls.find(([sql]) =>
|
|
(sql as string).includes('INSERT INTO system.secrets')
|
|
);
|
|
expect(insertCall?.[0]).toContain("'ANON_KEY'");
|
|
expect(insertCall?.[1]?.[0]).toBe(`enc:${result.newAnonKey}`);
|
|
});
|
|
|
|
it('rolls back when no active anon key exists', async () => {
|
|
mockClientQuery.mockImplementation((sql: string) => {
|
|
if (sql.includes('SELECT id FROM system.secrets')) {
|
|
return Promise.resolve({ rows: [] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
const service = await loadSecretService();
|
|
|
|
await expect(service.rotateAnonKey()).rejects.toThrow('Failed to rotate anon key');
|
|
|
|
const calls = mockClientQuery.mock.calls.map(([sql]) => sql as string);
|
|
expect(calls).toContain('ROLLBACK');
|
|
expect(calls).not.toContain('COMMIT');
|
|
expect(mockClientRelease).toHaveBeenCalled();
|
|
});
|
|
|
|
it('invalidates the verification cache so the new key is picked up immediately', async () => {
|
|
// Prime the cache with the old key
|
|
mockPoolQuery.mockResolvedValue({
|
|
rows: [{ value_ciphertext: 'enc:anon_old', expires_at: null }],
|
|
});
|
|
const service = await loadSecretService();
|
|
await service.verifyAnonKey('anon_old');
|
|
expect(mockPoolQuery).toHaveBeenCalledTimes(1);
|
|
|
|
mockClientQuery.mockImplementation((sql: string) => {
|
|
if (sql.includes('SELECT id FROM system.secrets')) {
|
|
return Promise.resolve({ rows: [{ id: 'old-key-id' }] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
const { newAnonKey } = await service.rotateAnonKey();
|
|
|
|
mockPoolQuery.mockResolvedValue({
|
|
rows: [{ value_ciphertext: `enc:${newAnonKey}`, expires_at: null }],
|
|
});
|
|
await expect(service.verifyAnonKey(newAnonKey)).resolves.toBe(true);
|
|
expect(mockPoolQuery).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('initializeAnonKey', () => {
|
|
it('creates a fresh opaque key when none is stored', async () => {
|
|
const service = await loadSecretService();
|
|
const getSecretByKey = vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
|
|
const createSecret = vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });
|
|
|
|
const key = await service.initializeAnonKey();
|
|
|
|
expect(key).toMatch(/^anon_[0-9a-f]{64}$/);
|
|
expect(getSecretByKey).toHaveBeenCalledWith('ANON_KEY');
|
|
expect(createSecret).toHaveBeenCalledWith({
|
|
key: 'ANON_KEY',
|
|
value: key,
|
|
isReserved: true,
|
|
});
|
|
});
|
|
|
|
it('migrates a legacy JWT-format anon key to an opaque key', async () => {
|
|
const legacyJwt = 'eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYW5vbiJ9.signature';
|
|
const service = await loadSecretService();
|
|
vi.spyOn(service, 'getSecretByKey').mockResolvedValue(legacyJwt);
|
|
const updateSecretByKey = vi.spyOn(service, 'updateSecretByKey').mockResolvedValue(true);
|
|
const createSecret = vi.spyOn(service, 'createSecret');
|
|
|
|
const key = await service.initializeAnonKey();
|
|
|
|
expect(key).toMatch(/^anon_[0-9a-f]{64}$/);
|
|
expect(updateSecretByKey).toHaveBeenCalledWith('ANON_KEY', {
|
|
value: key,
|
|
isReserved: true,
|
|
});
|
|
expect(createSecret).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps an existing opaque key untouched', async () => {
|
|
const service = await loadSecretService();
|
|
vi.spyOn(service, 'getSecretByKey').mockResolvedValue('anon_existing');
|
|
const createSecret = vi.spyOn(service, 'createSecret');
|
|
const updateSecretByKey = vi.spyOn(service, 'updateSecretByKey');
|
|
|
|
await expect(service.initializeAnonKey()).resolves.toBe('anon_existing');
|
|
expect(createSecret).not.toHaveBeenCalled();
|
|
expect(updateSecretByKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('seeds from the ACCESS_ANON_KEY environment variable when no key is stored', async () => {
|
|
vi.stubEnv('ACCESS_ANON_KEY', 'anon_from_environment');
|
|
const service = await loadSecretService();
|
|
vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
|
|
const createSecret = vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });
|
|
|
|
await expect(service.initializeAnonKey()).resolves.toBe('anon_from_environment');
|
|
expect(createSecret).toHaveBeenCalledWith({
|
|
key: 'ANON_KEY',
|
|
value: 'anon_from_environment',
|
|
isReserved: true,
|
|
});
|
|
});
|
|
|
|
it('normalizes an env-provided key without the anon_ prefix', async () => {
|
|
vi.stubEnv('ACCESS_ANON_KEY', 'bare-environment-key');
|
|
const service = await loadSecretService();
|
|
vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
|
|
vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });
|
|
|
|
await expect(service.initializeAnonKey()).resolves.toBe('anon_bare-environment-key');
|
|
});
|
|
|
|
it('trims whitespace from the env-provided key before storing', async () => {
|
|
vi.stubEnv('ACCESS_ANON_KEY', ' anon_padded_key ');
|
|
const service = await loadSecretService();
|
|
vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
|
|
vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });
|
|
|
|
await expect(service.initializeAnonKey()).resolves.toBe('anon_padded_key');
|
|
});
|
|
|
|
it('generates a key when the env value is only whitespace', async () => {
|
|
vi.stubEnv('ACCESS_ANON_KEY', ' ');
|
|
const service = await loadSecretService();
|
|
vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
|
|
vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });
|
|
|
|
await expect(service.initializeAnonKey()).resolves.toMatch(/^anon_[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('prefers the stored key over the environment variable', async () => {
|
|
vi.stubEnv('ACCESS_ANON_KEY', 'anon_from_environment');
|
|
const service = await loadSecretService();
|
|
vi.spyOn(service, 'getSecretByKey').mockResolvedValue('anon_stored');
|
|
const createSecret = vi.spyOn(service, 'createSecret');
|
|
|
|
await expect(service.initializeAnonKey()).resolves.toBe('anon_stored');
|
|
expect(createSecret).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|