mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
128 lines
5.3 KiB
TypeScript
128 lines
5.3 KiB
TypeScript
import { decrypt_api_key, encrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
|
|
import {
|
|
assertNoMaskSentinels,
|
|
decryptBackendSecrets,
|
|
encryptStorageSecrets,
|
|
maskBackendOptions,
|
|
redactStorageSecrets,
|
|
unmaskStorageConfig,
|
|
} from '../../../../src/nest/storage/storage-secrets';
|
|
import { MASKED_SETTING_VALUE, type StorageConfig } from '@trek/shared';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
vi.mock('../../../../src/config', () => ({ ENCRYPTION_KEY: 'storage-secrets-test-key' }));
|
|
|
|
const S3_OPTIONS = {
|
|
endpoint: 'http://127.0.0.1:9000',
|
|
bucket: 'trek',
|
|
accessKeyId: 'ak',
|
|
secretAccessKey: 'sk-plain',
|
|
region: 'us-east-1',
|
|
keyPrefix: '',
|
|
retries: 1,
|
|
timeoutMs: 30000,
|
|
};
|
|
|
|
function s3Config(secretAccessKey: string): StorageConfig {
|
|
return {
|
|
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey } }],
|
|
categories: { backups: 'off-box' },
|
|
};
|
|
}
|
|
const LOCAL_ONLY: StorageConfig = {
|
|
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
|
|
categories: {},
|
|
};
|
|
|
|
describe('encryptStorageSecrets', () => {
|
|
it('encrypts plaintext secrets and round-trips through decrypt', () => {
|
|
const out = encryptStorageSecrets(s3Config('sk-plain'));
|
|
const stored = (out.backends[0]!.options as Record<string, unknown>).secretAccessKey as string;
|
|
expect(stored.startsWith('enc:v1:')).toBe(true);
|
|
expect(decrypt_api_key(stored)).toBe('sk-plain');
|
|
});
|
|
|
|
it('is idempotent — an enc:v1: value passes through byte-for-byte', () => {
|
|
const cipher = encrypt_api_key('sk');
|
|
const out = encryptStorageSecrets(s3Config(cipher));
|
|
expect((out.backends[0]!.options as Record<string, unknown>).secretAccessKey).toBe(cipher);
|
|
});
|
|
|
|
it('does not mutate its input', () => {
|
|
const input = s3Config('sk-plain');
|
|
void encryptStorageSecrets(input);
|
|
expect((input.backends[0]!.options as Record<string, unknown>).secretAccessKey).toBe('sk-plain');
|
|
});
|
|
});
|
|
|
|
describe('unmaskStorageConfig', () => {
|
|
it('replaces a mask echo with the stored ciphertext byte-for-byte', () => {
|
|
const cipher = encrypt_api_key('sk');
|
|
const stored = [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: cipher } }];
|
|
const out = unmaskStorageConfig(s3Config(MASKED_SETTING_VALUE), stored);
|
|
expect((out.backends[0]!.options as Record<string, unknown>).secretAccessKey).toBe(cipher);
|
|
});
|
|
|
|
it('throws for a mask with no stored counterpart (rename or new backend)', () => {
|
|
expect(() => unmaskStorageConfig(s3Config(MASKED_SETTING_VALUE), [])).toThrow(
|
|
"re-enter the secret 'secretAccessKey' for 'off-box'",
|
|
);
|
|
const renamed = [
|
|
{ name: 'old-name', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: encrypt_api_key('sk') } },
|
|
];
|
|
expect(() => unmaskStorageConfig(s3Config(MASKED_SETTING_VALUE), renamed)).toThrow(
|
|
"re-enter the secret 'secretAccessKey' for 'off-box'",
|
|
);
|
|
});
|
|
|
|
it('leaves non-mask values and secretless backends untouched, and tolerates a garbage stored row', () => {
|
|
expect(unmaskStorageConfig(s3Config('sk-new'), 'not-an-array')).toEqual(s3Config('sk-new'));
|
|
expect(unmaskStorageConfig(LOCAL_ONLY, undefined)).toEqual(LOCAL_ONLY);
|
|
});
|
|
});
|
|
|
|
describe('assertNoMaskSentinels', () => {
|
|
it('throws when a seed-style config carries the mask sentinel in a secret field', () => {
|
|
expect(() => assertNoMaskSentinels(s3Config(MASKED_SETTING_VALUE))).toThrow('mask');
|
|
expect(() => assertNoMaskSentinels(s3Config('sk'))).not.toThrow();
|
|
});
|
|
|
|
it('throws for a mask sentinel in a NON-secret field too — the check is all-field, not secret-only', () => {
|
|
const config: StorageConfig = {
|
|
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, accessKeyId: MASKED_SETTING_VALUE } }],
|
|
categories: {},
|
|
};
|
|
expect(() => assertNoMaskSentinels(config)).toThrow(
|
|
"backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('decryptBackendSecrets', () => {
|
|
it('decrypts enc:v1: secrets, passes plaintext through, throws on tampered ciphertext', () => {
|
|
const encrypted = s3Config(encrypt_api_key('sk-secret')).backends[0]!;
|
|
expect((decryptBackendSecrets(encrypted).options as Record<string, unknown>).secretAccessKey).toBe('sk-secret');
|
|
const plain = s3Config('sk-plain').backends[0]!;
|
|
expect((decryptBackendSecrets(plain).options as Record<string, unknown>).secretAccessKey).toBe('sk-plain');
|
|
expect(() => decryptBackendSecrets(s3Config('enc:v1:AAAA').backends[0]!)).toThrow(
|
|
"could not decrypt 'secretAccessKey'",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('maskBackendOptions / redactStorageSecrets', () => {
|
|
it('masks exactly the secret fields on GET-shaped options', () => {
|
|
const masked = maskBackendOptions('s3', { ...S3_OPTIONS });
|
|
expect(masked.secretAccessKey).toBe(MASKED_SETTING_VALUE);
|
|
expect(masked.accessKeyId).toBe('ak'); // never over-masked
|
|
expect(maskBackendOptions('local', { root: '/x' })).toEqual({ root: '/x' });
|
|
});
|
|
|
|
it('redacts secrets to *** for audit details', () => {
|
|
const out = redactStorageSecrets(s3Config('sk-plain'));
|
|
expect((out.backends[0]!.options as Record<string, unknown>).secretAccessKey).toBe('***');
|
|
expect((out.backends[0]!.options as Record<string, unknown>).accessKeyId).toBe('ak');
|
|
});
|
|
});
|