170 lines
5.8 KiB
TypeScript
170 lines
5.8 KiB
TypeScript
/**
|
|
* init-progress-smoke.
|
|
*
|
|
* Unit-tests the wizard "save-as-you-go" progress store (init/progress.ts):
|
|
* - round-trips the NON-SECRET answers,
|
|
* - the CRITICAL safety property: a private key / DB password can NEVER
|
|
* reach the progress file (type omits them AND saveProgress hard-strips
|
|
* them) — asserted by feeding an object that DOES carry secrets and
|
|
* proving none of that material lands on disk,
|
|
* - the file is written owner-only (no group/world access),
|
|
* - absent / corrupt / wrong-version files load as null,
|
|
* - clearProgress removes the file.
|
|
*
|
|
* The interactive resume PROMPT (offer / reuse / re-ask secrets) runs on a
|
|
* real TTY and is not exercised here; this covers the persistence core it
|
|
* relies on.
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import { mkdtempSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
// Point the progress file at a throwaway HOME so we never touch a real
|
|
// operator's resume file. progressFilePath() reads homedir() (which
|
|
// honours $HOME on POSIX) at call time, so setting this before any call
|
|
// is sufficient even with static imports.
|
|
process.env.HOME = mkdtempSync(join(tmpdir(), 'morphit-init-progress-'));
|
|
|
|
import {
|
|
type WizardProgress,
|
|
progressFilePath,
|
|
saveProgress,
|
|
loadProgress,
|
|
loadProgressSavedAt,
|
|
clearProgress,
|
|
describeAge
|
|
} from '../src/init/progress.ts';
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
function expect(name: string, cond: boolean, msg = ''): void {
|
|
if (cond) {
|
|
pass++;
|
|
console.log(` \u2713 ${name}`);
|
|
} else {
|
|
fail++;
|
|
console.log(` \u2717 ${name}${msg ? ` — ${msg}` : ''}`);
|
|
}
|
|
}
|
|
|
|
// A representative set of NON-SECRET answers (every field here is config,
|
|
// also written to the 0600 config file at the end anyway).
|
|
const sample: WizardProgress = {
|
|
instanceName: 'morphit.io',
|
|
tagline: 'free markets, no middleman',
|
|
relayAccount: { name: 'morphit', account: null, chainLookupSucceeded: false },
|
|
feesAccount: 'morphit-fees',
|
|
dailyCeiling: 25,
|
|
contactUrl: null,
|
|
origin: 'https://morphit.io',
|
|
bunkerWeb: { enabled: true },
|
|
matrix: { alertMxid: '@agorise:matrix.org', groupRoomAlias: '#agorise:matrix.org' }
|
|
};
|
|
|
|
// 1 — absent file → null
|
|
clearProgress();
|
|
expect('loadProgress() is null when no file exists', loadProgress() === null);
|
|
expect('loadProgressSavedAt() is null when no file exists', loadProgressSavedAt() === null);
|
|
|
|
// 2 — round-trip
|
|
saveProgress(sample);
|
|
expect('progress file exists after save', existsSync(progressFilePath()));
|
|
{
|
|
const back = loadProgress();
|
|
let ok = false;
|
|
try {
|
|
assert.deepStrictEqual(back, sample);
|
|
ok = true;
|
|
} catch {
|
|
ok = false;
|
|
}
|
|
expect('saveProgress + loadProgress round-trips the answers', ok);
|
|
expect('loadProgressSavedAt() returns an ISO timestamp', !!loadProgressSavedAt());
|
|
}
|
|
|
|
// 3 — file is owner-only (no group/world bits)
|
|
{
|
|
const mode = statSync(progressFilePath()).mode & 0o777;
|
|
expect('progress file is owner-only (no group/world access)', (mode & 0o077) === 0, `mode=${mode.toString(8)}`);
|
|
}
|
|
|
|
// 4 — CRITICAL: secrets can NEVER reach the file. Feed an object that DOES
|
|
// carry a DB password + a private key (cast past the type) and prove none
|
|
// of that material is written, and that load() returns it stripped.
|
|
{
|
|
const withSecrets = {
|
|
...sample,
|
|
databaseUrl: 'postgres://dbuser:SUPERSECRETPW@localhost:5432/morphit',
|
|
activeKey: {
|
|
mode: 'plaintext',
|
|
plaintextWif: '5JLEAKEDPRIVATEKEYdeadbeefdeadbeefdeadbeefdeadbeef',
|
|
envelope: undefined,
|
|
passphraseHint: 'my-secret-hint'
|
|
},
|
|
torOnion: {
|
|
address: 'efjprum3peosirjsilqv6lvlns3476t3njpngaexsyhangeb3mjo7sad.onion',
|
|
secretKeyFile: 'TORHSPRIVATEKEYMATERIALdeadbeef',
|
|
publicKeyFile: 'pub',
|
|
hostnameFile: 'host',
|
|
publicKey: 'pk'
|
|
}
|
|
};
|
|
saveProgress(withSecrets as unknown as WizardProgress);
|
|
const raw = readFileSync(progressFilePath(), 'utf8');
|
|
for (const needle of [
|
|
'SUPERSECRETPW',
|
|
'5JLEAKEDPRIVATEKEY',
|
|
'databaseUrl',
|
|
'activeKey',
|
|
'plaintextWif',
|
|
'envelope',
|
|
'passphraseHint',
|
|
'my-secret-hint',
|
|
'torOnion',
|
|
'TORHSPRIVATEKEYMATERIAL',
|
|
'secretKeyFile'
|
|
]) {
|
|
expect(`secret-exclusion: "${needle}" is absent from the file`, !raw.includes(needle), 'SECRET LEAK');
|
|
}
|
|
const back = (loadProgress() ?? {}) as Record<string, unknown>;
|
|
expect('loaded progress has no databaseUrl', !('databaseUrl' in back));
|
|
expect('loaded progress has no activeKey', !('activeKey' in back));
|
|
expect('loaded progress has no torOnion', !('torOnion' in back));
|
|
// the non-secret fields survive
|
|
expect('non-secret instanceName still present alongside stripped secrets', back.instanceName === 'morphit.io');
|
|
}
|
|
|
|
// 5 — corrupt / wrong-version files load as null (never throw)
|
|
{
|
|
writeFileSync(progressFilePath(), 'this is not json {{{', { mode: 0o600 });
|
|
expect('corrupt file loads as null', loadProgress() === null);
|
|
writeFileSync(
|
|
progressFilePath(),
|
|
JSON.stringify({ version: 999, savedAt: new Date().toISOString(), answers: { instanceName: 'x' } }),
|
|
{ mode: 0o600 }
|
|
);
|
|
expect('incompatible-version file loads as null', loadProgress() === null);
|
|
}
|
|
|
|
// 6 — clearProgress removes the file
|
|
clearProgress();
|
|
expect('clearProgress() removes the file', !existsSync(progressFilePath()));
|
|
expect('loadProgress() is null after clear', loadProgress() === null);
|
|
|
|
// 7 — describeAge sanity
|
|
expect('describeAge(null) is friendly', describeAge(null) === 'a previous run');
|
|
expect('describeAge(now) reads "just now"', describeAge(new Date().toISOString()) === 'just now');
|
|
{
|
|
const tenMinAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
expect('describeAge(10m ago) mentions minutes', /minute/.test(describeAge(tenMinAgo)));
|
|
}
|
|
|
|
console.log('');
|
|
console.log(`${pass} passed, ${fail} failed`);
|
|
if (fail > 0) {
|
|
console.log('\u2717 init-progress smoke FAILED');
|
|
process.exit(1);
|
|
}
|
|
console.log(`\u2713 all ${pass} init-progress checks passed`);
|