The production composition hardwired the macOS Keychain (/usr/bin/security), which made Linux deployment impossible. Now: - SecretStore gains a stable provider identity persisted in secret_references.provider; services stop hardcoding 'macos-keychain' - shared reference codec (secret-reference.ts) understands both keychain:// and secret-file:// references - FileSecretStore: single 0600 JSON map under the data root, atomic temp+rename writes, serialized in-process, same validation envelope - production composition picks the backend via MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store elsewhere) and readiness probes the matching backend
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { openDatabase } from '../../infrastructure/database/database.js';
|
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
|
import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
|
import { ActivationError, activatePendingSecret } from './activate-pending-secret.js';
|
|
|
|
const secret = 'activation-secret-never-in-sqlite';
|
|
const databases: Database.Database[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const db of databases.splice(0)) db.close();
|
|
});
|
|
|
|
function pendingDatabase(): Database.Database {
|
|
const db = new Database(':memory:');
|
|
db.pragma('foreign_keys = ON');
|
|
migrateDatabase(db);
|
|
const now = '2026-07-16T00:00:00.000Z';
|
|
db.prepare(
|
|
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
|
).run('alpha', 'Alpha', 'https://203.0.113.8', 'none', 1, 3, now, now);
|
|
db.prepare('INSERT INTO app_settings VALUES (?,?,?,?)').run(
|
|
'legacy-import.instance.alpha',
|
|
JSON.stringify({
|
|
source: 'legacy-config',
|
|
secretPending: true,
|
|
requestedAuthMode: 'password',
|
|
secretPurpose: 'instance-password',
|
|
}),
|
|
now,
|
|
now,
|
|
);
|
|
databases.push(db);
|
|
return db;
|
|
}
|
|
|
|
class FakeStore implements SecretStore {
|
|
readonly provider = 'macos-keychain';
|
|
readonly sets: { key: SecretKey; value: string }[] = [];
|
|
readonly deletes: string[] = [];
|
|
setError?: Error;
|
|
deleteError?: Error;
|
|
reference = 'keychain://multi-simadmin/b3BhcXVl';
|
|
|
|
async set(key: SecretKey, value: string): Promise<string> {
|
|
this.sets.push({ key, value });
|
|
if (this.setError) throw this.setError;
|
|
return key.slot ? `${this.reference}-${key.slot}` : this.reference;
|
|
}
|
|
async get(): Promise<string | undefined> {
|
|
return undefined;
|
|
}
|
|
async delete(reference: string): Promise<boolean> {
|
|
this.deletes.push(reference);
|
|
if (this.deleteError) throw this.deleteError;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function allText(db: Database.Database): string {
|
|
const tables = db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
.all() as { name: string }[];
|
|
const rows: unknown[] = [];
|
|
for (const { name } of tables) {
|
|
const columns = (
|
|
db.prepare(`PRAGMA table_info("${name}")`).all() as { name: string; type: string }[]
|
|
)
|
|
.filter(({ type }) => type.toUpperCase().includes('TEXT'))
|
|
.map(({ name: column }) => `"${column}"`);
|
|
if (columns.length)
|
|
rows.push(...db.prepare(`SELECT ${columns.join(',')} FROM "${name}"`).all());
|
|
}
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
function state(db: Database.Database): Uint8Array {
|
|
return db.serialize();
|
|
}
|
|
|
|
describe('activatePendingSecret', () => {
|
|
it('writes keychain first then atomically activates password auth and clears pending metadata', async () => {
|
|
const db = pendingDatabase();
|
|
const store = new FakeStore();
|
|
|
|
const result = await activatePendingSecret({ db, store, instanceId: 'alpha', secret });
|
|
|
|
expect(store.sets).toHaveLength(1);
|
|
expect(store.sets[0]).toEqual({
|
|
key: {
|
|
instanceId: 'alpha',
|
|
purpose: 'instance-password',
|
|
slot: expect.stringMatching(/^[A-Za-z0-9_-]+$/),
|
|
},
|
|
value: secret,
|
|
});
|
|
const storedReference = `${store.reference}-${store.sets[0]?.key.slot}`;
|
|
expect(result).toEqual({
|
|
instanceId: 'alpha',
|
|
referenceId: 'legacy-secret:alpha:instance-password',
|
|
externalReference: storedReference,
|
|
configRevision: 4,
|
|
});
|
|
expect(
|
|
db.prepare('SELECT auth_mode,config_revision FROM instances WHERE id=?').get('alpha'),
|
|
).toEqual({
|
|
auth_mode: 'password',
|
|
config_revision: 4,
|
|
});
|
|
expect(
|
|
db
|
|
.prepare('SELECT id,instance_id,purpose,provider,external_reference FROM secret_references')
|
|
.get(),
|
|
).toEqual({
|
|
id: 'legacy-secret:alpha:instance-password',
|
|
instance_id: 'alpha',
|
|
purpose: 'instance-password',
|
|
provider: 'macos-keychain',
|
|
external_reference: storedReference,
|
|
});
|
|
const metadata = JSON.parse(
|
|
(
|
|
db
|
|
.prepare('SELECT value_json FROM app_settings WHERE key=?')
|
|
.get('legacy-import.instance.alpha') as { value_json: string }
|
|
).value_json,
|
|
);
|
|
expect(metadata).toMatchObject({ secretPending: false, requestedAuthMode: 'password' });
|
|
expect(JSON.stringify(metadata)).not.toContain(secret);
|
|
expect(allText(db)).not.toContain(secret);
|
|
});
|
|
|
|
it('rolls back every DB write and compensates keychain when the transaction fails', async () => {
|
|
const db = pendingDatabase();
|
|
const store = new FakeStore();
|
|
const before = state(db);
|
|
db.exec(
|
|
"CREATE TRIGGER fail_activation BEFORE INSERT ON secret_references BEGIN SELECT RAISE(ABORT, 'forced'); END",
|
|
);
|
|
const withTrigger = state(db);
|
|
|
|
await expect(
|
|
activatePendingSecret({ db, store, instanceId: 'alpha', secret }),
|
|
).rejects.toMatchObject({
|
|
code: 'DATABASE_FAILED',
|
|
});
|
|
expect(store.deletes).toHaveLength(1);
|
|
expect(store.deletes[0]).toBe(`${store.reference}-${store.sets[0]?.key.slot}`);
|
|
expect(state(db)).toEqual(withTrigger);
|
|
expect(withTrigger).not.toEqual(before);
|
|
});
|
|
|
|
it('returns a stable compensation error without leaking secret when cleanup fails', async () => {
|
|
const db = pendingDatabase();
|
|
db.exec(
|
|
"CREATE TRIGGER fail_activation BEFORE INSERT ON secret_references BEGIN SELECT RAISE(ABORT, 'forced'); END",
|
|
);
|
|
const store = new FakeStore();
|
|
store.deleteError = new Error(`malicious ${secret}`);
|
|
const error = await activatePendingSecret({ db, store, instanceId: 'alpha', secret }).catch(
|
|
(reason: unknown) => reason,
|
|
);
|
|
expect(error).toBeInstanceOf(ActivationError);
|
|
expect(error).toMatchObject({ code: 'COMPENSATION_FAILED' });
|
|
expect(
|
|
JSON.stringify({ name: (error as Error).name, message: (error as Error).message }),
|
|
).not.toContain(secret);
|
|
});
|
|
|
|
it('does not touch DB when store set fails', async () => {
|
|
const db = pendingDatabase();
|
|
const before = state(db);
|
|
const store = new FakeStore();
|
|
store.setError = new Error(`malicious ${secret}`);
|
|
const error = await activatePendingSecret({ db, store, instanceId: 'alpha', secret }).catch(
|
|
(reason: unknown) => reason,
|
|
);
|
|
expect(error).toMatchObject({ code: 'SECRET_STORE_FAILED' });
|
|
expect(state(db)).toEqual(before);
|
|
expect(
|
|
JSON.stringify({ name: (error as Error).name, message: (error as Error).message }),
|
|
).not.toContain(secret);
|
|
});
|
|
|
|
it('rejects duplicate activation before store access and leaves DB unchanged', async () => {
|
|
const db = pendingDatabase();
|
|
const firstStore = new FakeStore();
|
|
await activatePendingSecret({ db, store: firstStore, instanceId: 'alpha', secret });
|
|
const before = state(db);
|
|
const secondStore = new FakeStore();
|
|
await expect(
|
|
activatePendingSecret({ db, store: secondStore, instanceId: 'alpha', secret }),
|
|
).rejects.toMatchObject({ code: 'ALREADY_ACTIVE' });
|
|
expect(secondStore.sets).toHaveLength(0);
|
|
expect(state(db)).toEqual(before);
|
|
});
|
|
|
|
it('serializes concurrent activation so only one unique keychain item is created', async () => {
|
|
const db = pendingDatabase();
|
|
const store = new FakeStore();
|
|
|
|
const results = await Promise.allSettled([
|
|
activatePendingSecret({ db, store, instanceId: 'alpha', secret: `${secret}-one` }),
|
|
activatePendingSecret({ db, store, instanceId: 'alpha', secret: `${secret}-two` }),
|
|
]);
|
|
|
|
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
|
|
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1);
|
|
expect(store.sets).toHaveLength(1);
|
|
expect(store.sets[0]?.key.slot).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
expect(store.deletes).toHaveLength(0);
|
|
const saved = db.prepare('SELECT external_reference FROM secret_references').get() as {
|
|
external_reference: string;
|
|
};
|
|
expect(saved.external_reference).toContain(store.sets[0]?.key.slot);
|
|
});
|
|
|
|
it('serializes activation across two managed connections to the same physical database', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'secret-activation-'));
|
|
const path = join(directory, 'state.sqlite');
|
|
const first = openDatabase(path);
|
|
const second = openDatabase(path);
|
|
migrateDatabase(first);
|
|
const now = '2026-07-16T00:00:00.000Z';
|
|
first
|
|
.prepare(
|
|
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
|
)
|
|
.run('alpha', 'Alpha', 'https://203.0.113.8', 'none', 1, 3, now, now);
|
|
first
|
|
.prepare('INSERT INTO app_settings VALUES (?,?,?,?)')
|
|
.run(
|
|
'legacy-import.instance.alpha',
|
|
JSON.stringify({ secretPending: true, requestedAuthMode: 'password' }),
|
|
now,
|
|
now,
|
|
);
|
|
const store = new FakeStore();
|
|
try {
|
|
const results = await Promise.allSettled([
|
|
activatePendingSecret({ db: first, store, instanceId: 'alpha', secret: `${secret}-one` }),
|
|
activatePendingSecret({ db: second, store, instanceId: 'alpha', secret: `${secret}-two` }),
|
|
]);
|
|
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
|
|
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1);
|
|
expect(store.sets).toHaveLength(1);
|
|
expect(store.deletes).toHaveLength(0);
|
|
} finally {
|
|
second.close();
|
|
first.close();
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects empty secrets and invalid pending metadata before store access', async () => {
|
|
const db = pendingDatabase();
|
|
const store = new FakeStore();
|
|
await expect(
|
|
activatePendingSecret({ db, store, instanceId: 'alpha', secret: '' }),
|
|
).rejects.toMatchObject({ code: 'EMPTY_SECRET' });
|
|
db.prepare('UPDATE app_settings SET value_json=?').run(
|
|
JSON.stringify({ secretPending: false, requestedAuthMode: 'password' }),
|
|
);
|
|
await expect(
|
|
activatePendingSecret({ db, store, instanceId: 'alpha', secret }),
|
|
).rejects.toMatchObject({ code: 'NOT_PENDING' });
|
|
expect(store.sets).toHaveLength(0);
|
|
});
|
|
});
|