Files
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

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();
});
});
});