Download server/src/__tests__/scripts/rotate-encryption-key-cli.test.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 6.54 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/__tests__/scripts/rotate-encryption-key-cli.test.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/server/src/__tests__/scripts/rotate-encryption-key-cli.test.ts
-
curl -L -o rotate-encryption-key-cli.test.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/__tests__/scripts/rotate-encryption-key-cli.test.ts
6.54 kB
| import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | |
| import { spawnSync } from 'child_process'; | |
| import { createRequire } from 'module'; | |
| import fs from 'fs'; | |
| import os from 'os'; | |
| import path from 'path'; | |
| import { fileURLToPath } from 'url'; | |
| import { initDb } from '../../db/index.js'; | |
| import { encryptWith, decryptWith } from '../../scripts/rotate-encryption-key.js'; | |
| // End-to-end coverage for the CLI itself, not just the exported helpers. The | |
| // script is an ESM module (server/package.json is "type": "module"), so a | |
| // stray `require()` in main() throws "require is not defined in ES module | |
| // scope" the moment `npm run rotate-encryption-key` is invoked — a failure the | |
| // unit tests cannot see, because they import rotateSecrets() directly. Only | |
| // actually running the script catches it. | |
| const require_ = createRequire(import.meta.url); | |
| const TSX_CLI = path.join(path.dirname(require_.resolve('tsx/package.json')), 'dist', 'cli.mjs'); | |
| const SCRIPT = fileURLToPath(new URL('../../scripts/rotate-encryption-key.ts', import.meta.url)); | |
| const OLD_HEX = 'a'.repeat(64); | |
| const NEW_HEX = 'b'.repeat(64); | |
| const OLD_KEY = Buffer.from(OLD_HEX, 'hex'); | |
| const NEW_KEY = Buffer.from(NEW_HEX, 'hex'); | |
| const API_KEY_SECRET = 'sk-live-rotate-me'; | |
| const PROFILE_SECRET = 'cp-live-rotate-me'; | |
| const RELAY_TOKEN = 'relay-bearer-rotate-me'; | |
| let tmpDir: string; | |
| let dbPath: string; | |
| /** A real on-disk DB with the production schema, seeded under the OLD key. */ | |
| function seedTempDb(): void { | |
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rotate-cli-')); | |
| dbPath = path.join(tmpDir, 'freeapi.db'); | |
| // Set before initDb so crypto.ts takes the env key and never writes a | |
| // .encryption-key file next to the temp DB. | |
| process.env.ENCRYPTION_KEY = OLD_HEX; | |
| const db = initDb(dbPath); | |
| const keyEnc = encryptWith(OLD_KEY, API_KEY_SECRET); | |
| db.prepare('INSERT INTO api_keys (platform, encrypted_key, iv, auth_tag, status, enabled) VALUES (?,?,?,?,?,?)') | |
| .run('openai', keyEnc.encrypted, keyEnc.iv, keyEnc.authTag, 'unknown', 1); | |
| const profileEnc = encryptWith(OLD_KEY, PROFILE_SECRET); | |
| db.prepare('INSERT INTO client_profiles (name, token_hash, encrypted_key, iv, auth_tag, enabled) VALUES (?,?,?,?,?,?)') | |
| .run('claude', 'hash-cli', profileEnc.encrypted, profileEnc.iv, profileEnc.authTag, 1); | |
| // Same encoding encodeFetchRelayToken() writes: JSON.stringify(encrypt(v)). | |
| const relayEnc = encryptWith(OLD_KEY, RELAY_TOKEN); | |
| db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') | |
| .run('fetch_relay_token', JSON.stringify(relayEnc)); | |
| db.close?.(); | |
| } | |
| /** Read every seeded ciphertext straight out of the file, no server involved. */ | |
| function readSecrets(): { key: [string, string, string]; profile: [string, string, string]; relay: [string, string, string] } { | |
| process.env.ENCRYPTION_KEY = OLD_HEX; | |
| const db = initDb(dbPath); | |
| const k = db.prepare('SELECT encrypted_key, iv, auth_tag FROM api_keys').get() as { encrypted_key: string; iv: string; auth_tag: string }; | |
| const p = db.prepare('SELECT encrypted_key, iv, auth_tag FROM client_profiles').get() as { encrypted_key: string; iv: string; auth_tag: string }; | |
| const s = db.prepare("SELECT value FROM settings WHERE key = 'fetch_relay_token'").get() as { value: string }; | |
| const relay = JSON.parse(s.value) as { encrypted: string; iv: string; authTag: string }; | |
| db.close?.(); | |
| return { | |
| key: [k.encrypted_key, k.iv, k.auth_tag], | |
| profile: [p.encrypted_key, p.iv, p.auth_tag], | |
| relay: [relay.encrypted, relay.iv, relay.authTag], | |
| }; | |
| } | |
| function runScript(args: string[]): { status: number | null; stdout: string; stderr: string } { | |
| // The script must not silently inherit the harness's own ENCRYPTION_KEY: | |
| // --old-key has to carry the whole story. | |
| const env = { ...process.env }; | |
| delete env.ENCRYPTION_KEY; | |
| const res = spawnSync(process.execPath, [TSX_CLI, SCRIPT, ...args], { encoding: 'utf8', env }); | |
| return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }; | |
| } | |
| describe('rotate-encryption-key CLI (end to end)', () => { | |
| beforeEach(() => { | |
| seedTempDb(); | |
| }); | |
| afterEach(() => { | |
| if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); | |
| delete process.env.ENCRYPTION_KEY; | |
| }); | |
| it('rotates every stored secret to the new key and leaves the old key unable to read them', () => { | |
| const before = readSecrets(); | |
| const run = runScript(['--old-key', OLD_HEX, '--new-key', NEW_HEX, '--db', dbPath]); | |
| expect(run.stderr).toBe(''); | |
| expect(run.status).toBe(0); | |
| // api key + client profile + relay token. | |
| expect(run.stdout).toContain('Rotated 3 value(s)'); | |
| const after = readSecrets(); | |
| expect(decryptWith(NEW_KEY, ...after.key)).toBe(API_KEY_SECRET); | |
| expect(decryptWith(NEW_KEY, ...after.profile)).toBe(PROFILE_SECRET); | |
| expect(decryptWith(NEW_KEY, ...after.relay)).toBe(RELAY_TOKEN); | |
| // The old key must no longer open any of them (GCM fails the auth tag). | |
| expect(() => decryptWith(OLD_KEY, ...after.key)).toThrow(); | |
| expect(() => decryptWith(OLD_KEY, ...after.profile)).toThrow(); | |
| expect(() => decryptWith(OLD_KEY, ...after.relay)).toThrow(); | |
| // Every ciphertext actually changed. | |
| expect(after.key[0]).not.toBe(before.key[0]); | |
| expect(after.profile[0]).not.toBe(before.profile[0]); | |
| expect(after.relay[0]).not.toBe(before.relay[0]); | |
| }, 60_000); | |
| it('--dry-run reports the work without changing a single row', () => { | |
| const before = readSecrets(); | |
| const run = runScript(['--old-key', OLD_HEX, '--new-key', NEW_HEX, '--db', dbPath, '--dry-run']); | |
| expect(run.stderr).toBe(''); | |
| expect(run.status).toBe(0); | |
| expect(run.stdout).toContain('[dry-run] would rotate 3 value(s)'); | |
| expect(run.stdout).toContain('settings.fetch_relay_token'); | |
| // Plaintext is never printed in full. | |
| expect(run.stdout).not.toContain(API_KEY_SECRET); | |
| const after = readSecrets(); | |
| expect(after).toEqual(before); | |
| // And the old key still opens everything. | |
| expect(decryptWith(OLD_KEY, ...after.key)).toBe(API_KEY_SECRET); | |
| expect(decryptWith(OLD_KEY, ...after.relay)).toBe(RELAY_TOKEN); | |
| }, 60_000); | |
| it('exits non-zero and writes nothing when the old key is wrong', () => { | |
| const before = readSecrets(); | |
| const run = runScript(['--old-key', 'c'.repeat(64), '--new-key', NEW_HEX, '--db', dbPath]); | |
| expect(run.status).toBe(1); | |
| expect(run.stderr).toMatch(/cannot decrypt/); | |
| expect(readSecrets()).toEqual(before); | |
| }, 60_000); | |
| }); | |