380 lines
13 KiB
TypeScript
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);
|
|
}
|