morphit/apps/ops-cli/scripts/edit-active-key-smoke.ts

380 lines
13 KiB
TypeScript

/**
* edit-active-key smoke (cp167 LL #167).
*
* Locks in the behavior of the `morphit-ops edit-active-key`
* subcommand — the recovery path for operators who pasted the
* wrong key during the setup wizard (or who need to rotate
* after an on-chain account_update). Interactive prompts are
* skipped here; the smoke drives the file-level helpers:
*
* - readCriticalEnv parses morphit.env keystore + account
* - readCriticalEnv tolerates quoted + unquoted values
* - readCriticalEnv rejects missing keys with a clear message
* - loadCurrentKeystore detects encrypted-envelope mode
* - loadCurrentKeystore detects plaintext-WIF mode
* - loadCurrentKeystore rejects JSON-but-not-envelope
* - loadCurrentKeystore rejects garbage (neither envelope nor WIF)
* - atomicWrite produces 0600 + replaces target + no .tmp leftover
* - backupExistingKeystore creates a byte-identical .bak file
* - envelope parity: encryptEnvelope output round-trips through
* loadCurrentKeystore as 'encrypted' mode
*
* No private/active key is ever used here — fixtures are
* synthetic; the envelope smoke uses a fake WIF (51 chars) only
* to exercise the JSON shape.
*/
import {
mkdtempSync,
readFileSync,
writeFileSync,
existsSync,
readdirSync,
statSync
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
_testReadCriticalEnv as readCriticalEnv,
_testLoadCurrentKeystore as loadCurrentKeystore,
_testAtomicWrite as atomicWrite,
_testBackupExistingKeystore as backupExistingKeystore,
_testWipePriorKeystore as wipePriorKeystore
} from '../src/commands/editActiveKey.ts';
import { encryptEnvelope } from '../src/init/encrypt.ts';
let scenarios = 0;
let failures = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
}
function assertEqual<T>(actual: T, expected: T, label?: string): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`${label ?? 'value'}: expected ${e}, got ${a}`);
}
}
function assertContains(haystack: string, needle: string): void {
if (!haystack.includes(needle)) {
throw new Error(
`expected text to contain ${JSON.stringify(needle)}, got ${JSON.stringify(haystack.slice(0, 200))}`
);
}
}
function mkdtmp(label: string): string {
return mkdtempSync(join(tmpdir(), `morphit-eak-${label}-`));
}
function writeEnv(dir: string, content: string): string {
const p = join(dir, 'morphit.env');
writeFileSync(p, content, { mode: 0o600 });
return p;
}
console.log('edit-active-key-smoke');
console.log('──────────────────────────────────────────────────────');
// ─── readCriticalEnv ───────────────────────────────────────────
scenario('readCriticalEnv: extracts keystore path + relay account from morphit.env', () => {
const dir = mkdtmp('rce-basic');
writeEnv(
dir,
[
'# generated by morphit ops init',
'MORPHIT_RELAY_ACCOUNT=morphit-test',
'MORPHIT_RELAY_ACTIVE_KEY_FILE="apps/relay/keystore.json"',
'',
'DATABASE_URL=postgres://localhost:5432/morphit'
].join('\n')
);
const { keystorePath, relayAccount } = readCriticalEnv(dir);
assertEqual(keystorePath, 'apps/relay/keystore.json', 'keystorePath');
assertEqual(relayAccount, 'morphit-test', 'relayAccount');
});
scenario('readCriticalEnv: handles unquoted values', () => {
const dir = mkdtmp('rce-unquoted');
writeEnv(
dir,
[
'MORPHIT_RELAY_ACCOUNT=morphit-relay-2',
'MORPHIT_RELAY_ACTIVE_KEY_FILE=apps/relay/keystore.wif'
].join('\n')
);
const { keystorePath, relayAccount } = readCriticalEnv(dir);
assertEqual(keystorePath, 'apps/relay/keystore.wif');
assertEqual(relayAccount, 'morphit-relay-2');
});
scenario('readCriticalEnv: handles single-quoted values', () => {
const dir = mkdtmp('rce-singlequoted');
writeEnv(
dir,
[
"MORPHIT_RELAY_ACCOUNT='single-q'",
"MORPHIT_RELAY_ACTIVE_KEY_FILE='/tmp/keystore.wif'"
].join('\n')
);
const { keystorePath, relayAccount } = readCriticalEnv(dir);
assertEqual(keystorePath, '/tmp/keystore.wif');
assertEqual(relayAccount, 'single-q');
});
scenario('readCriticalEnv: rejects missing morphit.env with actionable message', () => {
const dir = mkdtmp('rce-missing');
let thrown: Error | undefined;
try {
readCriticalEnv(dir);
} catch (err) {
thrown = err as Error;
}
if (thrown === undefined) {
throw new Error('expected throw for missing morphit.env');
}
assertContains(thrown.message, "Can't find morphit.env");
assertContains(thrown.message, 'edit-active-key');
});
scenario('readCriticalEnv: rejects env without MORPHIT_RELAY_ACTIVE_KEY_FILE', () => {
const dir = mkdtmp('rce-no-keyfile');
writeEnv(dir, 'MORPHIT_RELAY_ACCOUNT=foo\n');
let thrown: Error | undefined;
try {
readCriticalEnv(dir);
} catch (err) {
thrown = err as Error;
}
if (thrown === undefined) {
throw new Error('expected throw for missing key');
}
assertContains(thrown.message, 'MORPHIT_RELAY_ACTIVE_KEY_FILE');
});
scenario('readCriticalEnv: rejects env without MORPHIT_RELAY_ACCOUNT', () => {
const dir = mkdtmp('rce-no-account');
writeEnv(dir, 'MORPHIT_RELAY_ACTIVE_KEY_FILE=keystore.wif\n');
let thrown: Error | undefined;
try {
readCriticalEnv(dir);
} catch (err) {
thrown = err as Error;
}
if (thrown === undefined) {
throw new Error('expected throw for missing account');
}
assertContains(thrown.message, 'MORPHIT_RELAY_ACCOUNT');
});
// ─── loadCurrentKeystore ────────────────────────────────────────
scenario('loadCurrentKeystore: detects encrypted-envelope mode', () => {
const dir = mkdtmp('lck-enc');
const wifFake = '5' + 'J'.repeat(50);
const env = encryptEnvelope(wifFake, 'smoke-passphrase-1234');
const ksPath = join(dir, 'keystore.json');
writeFileSync(ksPath, JSON.stringify(env, null, 2), { mode: 0o600 });
const current = loadCurrentKeystore(ksPath, 'smoke-account');
assertEqual(current.mode, 'encrypted');
assertEqual(current.relayAccount, 'smoke-account');
if (current.envelope === undefined) {
throw new Error('expected envelope to be present');
}
});
scenario('loadCurrentKeystore: detects plaintext-WIF mode', () => {
const dir = mkdtmp('lck-plain');
const wifShape = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFDS';
const ksPath = join(dir, 'keystore.wif');
writeFileSync(ksPath, wifShape, { mode: 0o600 });
const current = loadCurrentKeystore(ksPath, 'plain-acct');
assertEqual(current.mode, 'plaintext');
assertEqual(current.plaintextWif, wifShape);
});
scenario('loadCurrentKeystore: refuses JSON that is not a valid envelope', () => {
const dir = mkdtmp('lck-badjson');
const ksPath = join(dir, 'keystore.json');
writeFileSync(ksPath, JSON.stringify({ hello: 'world' }), { mode: 0o600 });
let thrown: Error | undefined;
try {
loadCurrentKeystore(ksPath, 'x');
} catch (err) {
thrown = err as Error;
}
if (thrown === undefined) {
throw new Error('expected throw');
}
assertContains(thrown.message, "isn't a valid encrypted envelope");
});
scenario('loadCurrentKeystore: refuses garbage that is neither envelope nor WIF', () => {
const dir = mkdtmp('lck-garbage');
const ksPath = join(dir, 'keystore.wif');
writeFileSync(ksPath, 'just some random text without WIF shape', { mode: 0o600 });
let thrown: Error | undefined;
try {
loadCurrentKeystore(ksPath, 'x');
} catch (err) {
thrown = err as Error;
}
if (thrown === undefined) {
throw new Error('expected throw');
}
assertContains(thrown.message, 'neither a JSON envelope nor a valid WIF');
});
scenario('loadCurrentKeystore: refuses missing file with actionable message', () => {
const dir = mkdtmp('lck-missing');
const ksPath = join(dir, 'keystore.wif');
let thrown: Error | undefined;
try {
loadCurrentKeystore(ksPath, 'x');
} catch (err) {
thrown = err as Error;
}
if (thrown === undefined) {
throw new Error('expected throw');
}
assertContains(thrown.message, 'Keystore file not found');
});
// ─── atomicWrite ───────────────────────────────────────────────
scenario('atomicWrite: writes 0600 + replaces target + leaves no .tmp leftover', () => {
const dir = mkdtmp('aw');
const target = join(dir, 'keystore.wif');
writeFileSync(target, 'OLD CONTENT', { mode: 0o600 });
atomicWrite(target, 'NEW CONTENT');
const body = readFileSync(target, 'utf8');
assertEqual(body, 'NEW CONTENT');
const st = statSync(target);
// Mode is mask + file type; we just want owner-rw and no other bits.
const mode = st.mode & 0o777;
assertEqual(mode, 0o600, 'file mode');
const leftovers = readdirSync(dir).filter((f) => f.includes('.tmp-'));
assertEqual(leftovers.length, 0, 'tmp leftover count');
});
scenario('atomicWrite: works when target does not exist yet', () => {
const dir = mkdtmp('aw-fresh');
const target = join(dir, 'keystore.json');
if (existsSync(target)) {
throw new Error('precondition: target should not exist');
}
atomicWrite(target, '{"v":1}');
const body = readFileSync(target, 'utf8');
assertEqual(body, '{"v":1}');
});
// ─── backupExistingKeystore ────────────────────────────────────
scenario('backupExistingKeystore: creates byte-identical .bak alongside the original', () => {
const dir = mkdtmp('bak');
const target = join(dir, 'keystore.wif');
const body = '5' + 'K'.repeat(50);
writeFileSync(target, body, { mode: 0o600 });
const bakPath = backupExistingKeystore(target);
if (!bakPath.startsWith(target + '.bak-')) {
throw new Error(`expected .bak suffix, got ${bakPath}`);
}
const bakBody = readFileSync(bakPath, 'utf8');
assertEqual(bakBody, body, 'backup body');
// Original still intact.
const origBody = readFileSync(target, 'utf8');
assertEqual(origBody, body, 'original body');
// Backup also 0600.
const mode = statSync(bakPath).mode & 0o777;
assertEqual(mode, 0o600, 'bak mode');
});
scenario('backupExistingKeystore: produces unique paths on rapid succession', () => {
const dir = mkdtmp('bak-uniq');
const target = join(dir, 'keystore.json');
writeFileSync(target, '{"v":1,"ct":"a"}', { mode: 0o600 });
const first = backupExistingKeystore(target);
// Spin briefly so Date.now() advances (resolution is ms).
const start = Date.now();
while (Date.now() === start) {
// busy-wait briefly
}
const second = backupExistingKeystore(target);
if (first === second) {
throw new Error(`expected distinct bak paths, both were ${first}`);
}
});
// ─── wipePriorKeystore (no-trace path) ─────────────────────────
scenario('wipePriorKeystore: file no longer exists at original path', () => {
const dir = mkdtmp('wipe-gone');
const target = join(dir, 'keystore.json');
const body = JSON.stringify({ v: 1, kdf: 'scrypt', cipher: 'aes-256-gcm', iv: 'x', ct: 'sensitive' });
writeFileSync(target, body, { mode: 0o600 });
wipePriorKeystore(target);
if (existsSync(target)) {
throw new Error(`expected target to be unlinked, but still exists at ${target}`);
}
});
scenario('wipePriorKeystore: no .bak left behind', () => {
const dir = mkdtmp('wipe-no-bak');
const target = join(dir, 'keystore.wif');
writeFileSync(target, '5' + 'K'.repeat(50), { mode: 0o600 });
wipePriorKeystore(target);
const baks = readdirSync(dir).filter((f) => f.includes('.bak-'));
assertEqual(baks.length, 0, 'unexpected .bak count');
});
scenario('wipePriorKeystore: handles tiny files without crashing', () => {
const dir = mkdtmp('wipe-tiny');
const target = join(dir, 'keystore.wif');
writeFileSync(target, 'x', { mode: 0o600 });
wipePriorKeystore(target);
if (existsSync(target)) {
throw new Error('tiny file should also be unlinked');
}
});
// ─── parity: encryptEnvelope output → loadCurrentKeystore ──────
scenario('parity: encryptEnvelope output round-trips through loadCurrentKeystore as encrypted', () => {
const dir = mkdtmp('parity');
const wifShape = '5' + 'L'.repeat(50);
const env = encryptEnvelope(wifShape, 'parity-passphrase-1234');
const ksPath = join(dir, 'keystore.json');
writeFileSync(ksPath, JSON.stringify(env, null, 2), { mode: 0o600 });
const current = loadCurrentKeystore(ksPath, 'parity-acct');
assertEqual(current.mode, 'encrypted');
if (current.envelope === undefined) {
throw new Error('envelope undefined after round trip');
}
const env2 = current.envelope as unknown as { v: number; cipher: string; iv: string; ct: string };
assertEqual(env2.v, env.v, 'envelope.v');
assertEqual(env2.cipher, env.cipher, 'envelope.cipher');
});
// ─── End ───────────────────────────────────────────────────────
console.log('');
console.log('──────────────────────────────────────────────────────');
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}