1184 lines
45 KiB
TypeScript
1184 lines
45 KiB
TypeScript
/**
|
|
* Morphit ops CLI — init wizard smoke runner.
|
|
*
|
|
* Exercises the non-interactive logic of the setup wizard:
|
|
* - Blurt account name validation
|
|
* - passphrase strength check
|
|
* - --out path resolution
|
|
* - config + keystore writer (against a temp directory)
|
|
*
|
|
* Doesn't exercise the readline prompts (tested manually via
|
|
* `tsx src/main.ts init`) or the system check (network-bound,
|
|
* runs as live integration).
|
|
*/
|
|
|
|
import { mkdtempSync, rmSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { parseEnv } from 'node:util';
|
|
|
|
import { validateBlurtAccountName } from '../src/init/chainCheck.ts';
|
|
import { checkPassphraseStrength } from '../src/init/encrypt.ts';
|
|
import { resolveOutputPath, writeWizardOutput } from '../src/init/render.ts';
|
|
import type { WizardAnswers } from '../src/init/render.ts';
|
|
import { generateOnionV3 } from '../src/init/torOnion.ts';
|
|
import { loadOperatorConfig } from '@morphit/operator-config';
|
|
|
|
let failures = 0;
|
|
let scenarios = 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(actual: unknown, expected: unknown, label: string): void {
|
|
const a = JSON.stringify(actual);
|
|
const e = JSON.stringify(expected);
|
|
if (a !== e) {
|
|
throw new Error(`${label}: expected ${e}, got ${a}`);
|
|
}
|
|
}
|
|
|
|
function assertTrue(cond: boolean, label: string): void {
|
|
if (!cond) throw new Error(`${label}: expected true`);
|
|
}
|
|
|
|
function assertContains(haystack: string, needle: string, label: string): void {
|
|
if (!haystack.includes(needle)) {
|
|
throw new Error(`${label}: expected to find ${JSON.stringify(needle)}`);
|
|
}
|
|
}
|
|
|
|
// ─── validateBlurtAccountName ────────────────────────────────────
|
|
|
|
scenario('validateBlurtAccountName: ok — simple valid name', () => {
|
|
assertEqual(validateBlurtAccountName('alice').ok, true, 'alice');
|
|
assertEqual(validateBlurtAccountName('bob123').ok, true, 'bob123');
|
|
assertEqual(validateBlurtAccountName('my-relay').ok, true, 'my-relay');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects too short', () => {
|
|
const r = validateBlurtAccountName('ab');
|
|
assertEqual(r.ok, false, 'too short ok');
|
|
assertContains(r.message ?? '', 'minimum 3', 'too short msg');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects too long', () => {
|
|
const r = validateBlurtAccountName('a'.repeat(17));
|
|
assertEqual(r.ok, false, 'too long ok');
|
|
assertContains(r.message ?? '', 'maximum 16', 'too long msg');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects starting with number', () => {
|
|
const r = validateBlurtAccountName('1alice');
|
|
assertEqual(r.ok, false, 'starts with num');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects starting with dash', () => {
|
|
const r = validateBlurtAccountName('-alice');
|
|
assertEqual(r.ok, false, 'starts with dash');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects uppercase', () => {
|
|
const r = validateBlurtAccountName('Alice');
|
|
assertEqual(r.ok, false, 'uppercase');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects underscores', () => {
|
|
const r = validateBlurtAccountName('alice_b');
|
|
assertEqual(r.ok, false, 'underscore');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects double dash', () => {
|
|
const r = validateBlurtAccountName('a--b');
|
|
assertEqual(r.ok, false, 'double dash');
|
|
});
|
|
|
|
scenario('validateBlurtAccountName: rejects trailing dash', () => {
|
|
const r = validateBlurtAccountName('alice-');
|
|
assertEqual(r.ok, false, 'trailing dash');
|
|
});
|
|
|
|
// ─── checkPassphraseStrength ─────────────────────────────────────
|
|
|
|
scenario('checkPassphraseStrength: rejects <8 chars', () => {
|
|
const r = checkPassphraseStrength('1234567');
|
|
assertEqual(r.ok, false, '7 chars');
|
|
});
|
|
|
|
scenario('checkPassphraseStrength: ok with warning at 8-11 chars', () => {
|
|
const r = checkPassphraseStrength('12345678');
|
|
assertEqual(r.ok, true, '8 chars ok');
|
|
assertTrue(r.message !== undefined, '8 chars has warning');
|
|
});
|
|
|
|
scenario('checkPassphraseStrength: ok no warning at 12+ chars', () => {
|
|
const r = checkPassphraseStrength('correcthorsebattery');
|
|
assertEqual(r.ok, true, '12+ ok');
|
|
assertEqual(r.message, undefined, '12+ no warning');
|
|
});
|
|
|
|
// ─── resolveOutputPath ───────────────────────────────────────────
|
|
|
|
scenario('resolveOutputPath: undefined returns default', () => {
|
|
assertEqual(resolveOutputPath(undefined, '/repo'), '/repo', 'undefined');
|
|
});
|
|
|
|
scenario('resolveOutputPath: empty string returns default', () => {
|
|
assertEqual(resolveOutputPath('', '/repo'), '/repo', 'empty');
|
|
});
|
|
|
|
scenario('resolveOutputPath: absolute path passes through', () => {
|
|
assertEqual(resolveOutputPath('/etc/morphit', '/repo'), '/etc/morphit', 'absolute');
|
|
});
|
|
|
|
// ─── writeWizardOutput ───────────────────────────────────────────
|
|
|
|
const sampleAnswers: WizardAnswers = {
|
|
instanceName: 'test-instance',
|
|
tagline: 'A test',
|
|
databaseUrl: 'postgres://test:secret@localhost/test',
|
|
blurtRpcEndpoints: ['https://rpc.beblurt.com', 'https://rpc.blurt.world'],
|
|
relayAccount: {
|
|
name: 'testrelay',
|
|
account: null,
|
|
chainLookupSucceeded: false
|
|
},
|
|
activeKey: {
|
|
mode: 'plaintext',
|
|
plaintextWif: '5J' + 'a'.repeat(50),
|
|
envelope: undefined,
|
|
passphraseHint: undefined
|
|
},
|
|
feesAccount: 'testrelay',
|
|
dailyCeiling: 25,
|
|
contactUrl: 'https://example.com/contact',
|
|
origin: null,
|
|
// cp474 — `ens` is REQUIRED by AltNetworkResult and was never added here.
|
|
altNetworks: { tor: null, lokinet: null, i2pB32: null, i2pName: null, nostr: null, ens: null },
|
|
feeExplorers: {
|
|
btc: ['https://blockstream.info/api', 'https://mempool.space/api'],
|
|
xmr: [
|
|
'https://xmrchain.net',
|
|
'https://localmonero.co/blocks',
|
|
'https://monerohash.com/explorer',
|
|
'https://exploremonero.com',
|
|
'https://moneroexplorer.org'
|
|
]
|
|
},
|
|
chatLinkExplorers: {
|
|
btc: 'https://mempool.space/tx/{txid}',
|
|
xmr: 'https://xmrchain.net/tx/{txid}',
|
|
bch: 'https://blockchair.com/bitcoin-cash/transaction/{txid}',
|
|
ltc: 'https://litecoinspace.org/tx/{txid}',
|
|
dash: 'https://insight.dash.org/insight/tx/{txid}',
|
|
doge: 'https://blockchair.com/dogecoin/transaction/{txid}',
|
|
zec: 'https://mainnet.zcashexplorer.app/transactions/{txid}',
|
|
arrr: 'https://explorer.piratechain.com/tx/{txid}',
|
|
dcr: 'https://dcrdata.decred.org/tx/{txid}',
|
|
sol: 'https://explorer.solana.com/tx/{txid}',
|
|
eth: 'https://eth.blockscout.com/tx/{txid}',
|
|
xrp: 'https://livenet.xrpl.org/transactions/{txid}',
|
|
usdt: {
|
|
erc20: 'https://etherscan.io/tx/{txid}',
|
|
trc20: 'https://tronscan.org/#/transaction/{txid}',
|
|
spl: 'https://solscan.io/tx/{txid}',
|
|
bep20: 'https://bscscan.com/tx/{txid}'
|
|
},
|
|
usdc: {
|
|
erc20: 'https://etherscan.io/tx/{txid}',
|
|
spl: 'https://solscan.io/tx/{txid}',
|
|
base: 'https://basescan.org/tx/{txid}',
|
|
polygon: 'https://polygonscan.com/tx/{txid}'
|
|
},
|
|
dai: {
|
|
erc20: 'https://etherscan.io/tx/{txid}',
|
|
polygon: 'https://polygonscan.com/tx/{txid}',
|
|
base: 'https://basescan.org/tx/{txid}',
|
|
arbitrum: 'https://arbiscan.io/tx/{txid}'
|
|
}
|
|
},
|
|
listingFee: {
|
|
targetUsd: 0.25,
|
|
btcSatoshis: 416,
|
|
xmrPiconero: 781_250_000,
|
|
fallbackBlurtPriceUsd: 0.002,
|
|
denominationFiat: 'USD',
|
|
source: 'default'
|
|
},
|
|
seo: { title: null, description: null, keywords: null },
|
|
backup: { enabled: false, backupDir: null, retainDays: null, dbContainer: null, dbName: 'morphit_indexer', dbUser: 'morphit_indexer' },
|
|
operatorTag: { tag: 'morphit' },
|
|
// Part 122 cp39 — disabledAssets fixture field. The sampleAnswers
|
|
// fixture had been missing `disabledAssets` since the wizard
|
|
// step was added in cp30; every writeWizardOutput-based scenario
|
|
// hit a TypeError until cp39 added it. Empty list = baseline
|
|
// "accept all assets" instance. Per-scenario overrides exercise
|
|
// the populated paths.
|
|
disabledAssets: { disabledTickers: [] },
|
|
// cp208 — disabledPaymentMethods fixture field. Added when the
|
|
// canonical-payment-method disable step (step 14) landed; without
|
|
// it every writeWizardOutput-based scenario fails to typecheck.
|
|
disabledPaymentMethods: { disabledKeys: [] },
|
|
// Part 121 cp9 — both Matrix surfaces opted-out in the
|
|
// baseline fixture. Per-scenario overrides exercise the
|
|
// populated paths.
|
|
matrix: { alertMxid: null, groupRoomAlias: null },
|
|
mcpServer: { enabled: true },
|
|
// cp182 — BunkerWeb decision. Baseline fixture is opted-out;
|
|
// the BunkerWeb-on/off rendering paths are exercised by the
|
|
// dedicated trusted-proxy scenarios below.
|
|
bunkerWeb: { enabled: false },
|
|
// cp182 — hardening checklist. Baseline does not generate the
|
|
// file; the dedicated hardening scenarios exercise generation.
|
|
hardening: { generateChecklist: false }
|
|
};
|
|
|
|
scenario('writeWizardOutput: writes config + env + keystore at expected paths', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
assertEqual(result.configPath, join(tmp, 'morphit.config.env'), 'config path');
|
|
assertEqual(result.envPath, join(tmp, 'morphit.env'), 'env path');
|
|
const expectedKeystore = join(tmp, 'apps', 'relay', 'keystore.wif');
|
|
assertEqual(result.keystorePath, expectedKeystore, 'keystore path');
|
|
assertTrue(result.configBytes > 0, 'config nonempty');
|
|
assertTrue(result.envBytes > 0, 'env nonempty');
|
|
assertTrue(result.keystoreBytes > 0, 'keystore nonempty');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: morphit.config.env contains operator-tunable keys', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
assertContains(content, 'MORPHIT_INSTANCE_NAME=test-instance', 'instance name');
|
|
// cp139-D-1 v2: morphit.config.env is the parseEnv consumer.
|
|
// Single-quoted form works for everything except embedded
|
|
// apostrophes; "A test" has no apostrophe → single-quoted.
|
|
assertContains(content, "MORPHIT_INSTANCE_TAGLINE='A test'", 'tagline (parseEnv = single-quoted)');
|
|
// cp193: MORPHIT_RELAY_SIGNUP_DAILY_CEILING is NOT operator-config
|
|
// allowlisted — it must NOT appear in morphit.config.env (doing so
|
|
// made the indexer reject the config on boot). It lives in
|
|
// morphit.env now; asserted in the critical-infra scenario below.
|
|
assertTrue(
|
|
!content.includes('MORPHIT_RELAY_SIGNUP_DAILY_CEILING'),
|
|
'signup ceiling must NOT be in morphit.config.env (not allowlisted)'
|
|
);
|
|
assertTrue(
|
|
!content.includes('MORPHIT_RELAY_TRUSTED_PROXY_IPS'),
|
|
'trusted-proxy-IPs must NOT be in morphit.config.env (not allowlisted)'
|
|
);
|
|
assertContains(content, 'MORPHIT_INDEXER_ACCOUNT_CREATION_FEE_BLURT=100', 'fee fallback');
|
|
assertContains(content, 'MORPHIT_INSTANCE_CONTACT_URL=https://example.com/contact', 'contact');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: morphit.env contains critical-infra keys', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const content = readFileSync(result.envPath, 'utf8');
|
|
assertContains(
|
|
content,
|
|
'MORPHIT_INDEXER_DATABASE_URL=postgres://test:secret@localhost/test',
|
|
'indexer db url'
|
|
);
|
|
assertContains(
|
|
content,
|
|
'MORPHIT_RELAY_DATABASE_URL=postgres://test:secret@localhost/test',
|
|
'relay db url'
|
|
);
|
|
assertContains(content, 'MORPHIT_RELAY_ACCOUNT=testrelay', 'relay account');
|
|
assertContains(content, 'MORPHIT_INDEXER_RELAY_ACCOUNT=testrelay', 'indexer relay account');
|
|
assertContains(content, 'MORPHIT_INDEXER_FEE_RECIPIENT=testrelay', 'fees account');
|
|
assertContains(content, 'MORPHIT_RELAY_ACTIVE_KEY_FILE=', 'key file');
|
|
// cp193 — these non-allowlisted keys moved here from
|
|
// morphit.config.env (where they crashed the indexer on boot).
|
|
assertContains(content, 'MORPHIT_RELAY_SIGNUP_DAILY_CEILING=25', 'signup ceiling now in morphit.env');
|
|
// cp194 — two REQUIRED indexer vars the wizard previously never
|
|
// wrote, so a wizard-configured indexer failed Zod validation at
|
|
// boot ("MORPHIT_INDEXER_PUBLIC_ORIGIN: Required" + posting pubkey).
|
|
assertContains(content, 'MORPHIT_INDEXER_PUBLIC_ORIGIN', 'indexer public origin written');
|
|
assertContains(
|
|
content,
|
|
'MORPHIT_INDEXER_OFFICIAL_POSTING_PUBKEY=BLT6CVC6C3PgmMe5xDtxFXJvGHaLnUTtcsK1ghHomDqLPWW7yeMp9',
|
|
'official posting pubkey (network constant) written'
|
|
);
|
|
// beta5 — fresh installs index from the Morphit genesis block, not
|
|
// block 0, so a new node is current within minutes instead of
|
|
// replaying years of pre-Morphit Blurt history.
|
|
assertContains(
|
|
content,
|
|
'MORPHIT_INDEXER_START_BLOCK=59441298',
|
|
'genesis start block written (current-in-minutes default)'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// cp193 — REGRESSION GUARD for the boot-crash the VPS sysadmin hit:
|
|
// the wizard wrote MORPHIT_RELAY_SIGNUP_DAILY_CEILING (and
|
|
// _TRUSTED_PROXY_IPS) into morphit.config.env, but those keys are not
|
|
// on the operator-config allowlist, so loadOperatorConfig() threw
|
|
// "[operator-config] ... contains keys not in the operator allowlist"
|
|
// and the indexer refused to boot. This renders the wizard's config
|
|
// and runs it through the REAL loader to prove it is accepted.
|
|
scenario('writeWizardOutput: generated morphit.config.env is accepted by loadOperatorConfig (cp193 boot regression)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
const priorOverride = process.env.MORPHIT_OPERATOR_CONFIG_FILE;
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
process.env.MORPHIT_OPERATOR_CONFIG_FILE = result.configPath;
|
|
// Must not throw. If it throws an allowlist error, the wizard
|
|
// produced a config the indexer can't boot with.
|
|
loadOperatorConfig();
|
|
assertTrue(true, 'loadOperatorConfig accepted the wizard config');
|
|
} finally {
|
|
if (priorOverride === undefined) delete process.env.MORPHIT_OPERATOR_CONFIG_FILE;
|
|
else process.env.MORPHIT_OPERATOR_CONFIG_FILE = priorOverride;
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: morphit.config.env does NOT contain critical-infra keys', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
assertTrue(!content.includes('MORPHIT_INDEXER_DATABASE_URL'), 'no db url in config');
|
|
assertTrue(!content.includes('MORPHIT_INDEXER_FEE_RECIPIENT'), 'no fees account in config');
|
|
assertTrue(!content.includes('MORPHIT_RELAY_ACTIVE_KEY_FILE'), 'no keystore path in config');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: omits optional keys when null', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const noOpt: WizardAnswers = { ...sampleAnswers, contactUrl: null };
|
|
const result = writeWizardOutput(noOpt, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
assertTrue(!content.includes('MORPHIT_INSTANCE_CONTACT_URL'), 'no contact url line');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: writes the 3 Tor HS files + the address env var when torOnion present', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const onion = generateOnionV3();
|
|
const withTor: WizardAnswers = {
|
|
...sampleAnswers,
|
|
altNetworks: { ...sampleAnswers.altNetworks, tor: onion.address },
|
|
torOnion: onion
|
|
};
|
|
const result = writeWizardOutput(withTor, tmp);
|
|
assertTrue(result.torHsDir !== null, 'torHsDir reported');
|
|
assertTrue(result.torHsAddress === onion.address, 'torHsAddress reported');
|
|
const dir = result.torHsDir as string;
|
|
const sec = join(dir, 'hs_ed25519_secret_key');
|
|
const pub = join(dir, 'hs_ed25519_public_key');
|
|
const host = join(dir, 'hostname');
|
|
assertTrue(existsSync(sec) && statSync(sec).size === 96, 'secret key is 96 bytes');
|
|
assertTrue((statSync(sec).mode & 0o077) === 0, 'secret key is owner-only');
|
|
assertTrue(existsSync(pub) && statSync(pub).size === 64, 'public key is 64 bytes');
|
|
assertTrue(readFileSync(host, 'utf8') === onion.address + '\n', 'hostname = address + newline');
|
|
const cfg = readFileSync(result.configPath, 'utf8');
|
|
assertTrue(
|
|
cfg.includes('MORPHIT_INSTANCE_TOR_ADDRESS=') && cfg.includes(onion.address),
|
|
'config carries the tor address env var'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: no Tor HS dir when no onion was generated', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const noTor: WizardAnswers = { ...sampleAnswers, torOnion: null };
|
|
const result = writeWizardOutput(noTor, tmp);
|
|
assertTrue(result.torHsDir === null, 'no torHsDir reported');
|
|
assertTrue(!existsSync(join(tmp, 'tor-hidden-service')), 'no tor-hidden-service directory written');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: hardening checklist reflects the operator\u2019s pillar confirmations', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const withHard: WizardAnswers = {
|
|
...sampleAnswers,
|
|
hardening: {
|
|
generateChecklist: true,
|
|
sshLockdown: true,
|
|
firewall: true,
|
|
autoUpdates: true,
|
|
kernelHardening: true,
|
|
intrusionDetection: false
|
|
}
|
|
};
|
|
const result = writeWizardOutput(withHard, tmp);
|
|
assertTrue(result.hardeningChecklistPath !== null, 'checklist written');
|
|
const md = readFileSync(result.hardeningChecklistPath as string, 'utf8');
|
|
assertTrue(md.includes('During setup you confirmed'), 'confirmation summary present');
|
|
assertTrue(md.includes('[x] SSH lockdown'), 'a confirmed pillar is checked');
|
|
assertTrue(
|
|
md.includes('[ ] Intrusion detection') && md.includes('strongly reconsider'),
|
|
'a declined pillar is unchecked + flagged'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─── cp139-D-1: per-consumer quote-format split ─────────────────
|
|
//
|
|
// morphit.config.env → parseEnv consumer (operator-config package).
|
|
// Format: double-quoted. parseEnv does NOT expand $/backtick
|
|
// inside double-quoted values (dotenv semantics). Apostrophes
|
|
// pass through verbatim — needed because parseEnv does NOT
|
|
// support the POSIX `'\''` close-escape-reopen idiom.
|
|
//
|
|
// morphit.env → bash consumer (sourced via `set -a; .` or
|
|
// systemd EnvironmentFile=).
|
|
// Format: single-quoted. Suppresses every form of bash
|
|
// expansion. Apostrophes use the POSIX close-escape-reopen
|
|
// idiom which bash understands but parseEnv doesn't.
|
|
//
|
|
// cp139-C-11 first switched both to single-quoted; cp139-D-1
|
|
// discovered the parseEnv/POSIX mismatch and split by consumer.
|
|
|
|
scenario('cp231: empty tagline omits MORPHIT_INSTANCE_TAGLINE entirely', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = { ...sampleAnswers, tagline: '' };
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
// The required instance name is still written...
|
|
assertContains(content, 'MORPHIT_INSTANCE_NAME=test-instance', 'name still written');
|
|
// ...but a skipped tagline must NOT emit a line at all, so an
|
|
// unbranded instance carries no placeholder description into the
|
|
// federated /instances directory or its SEO (the old wizard
|
|
// default 'A Morphit instance' did exactly that).
|
|
assertTrue(
|
|
!content.includes('MORPHIT_INSTANCE_TAGLINE'),
|
|
'empty tagline must omit MORPHIT_INSTANCE_TAGLINE entirely'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('cp139-D-1: $HOME in tagline (parseEnv consumer) is single-quoted', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = { ...sampleAnswers, tagline: 'Morphit $HOME instance' };
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
// cp139-D-1 v2: prefer single-quoted in parseEnv consumer
|
|
// (no apostrophe in value → single-quoted works). parseEnv
|
|
// reads $HOME inside single-quotes literally; bash never
|
|
// sources morphit.config.env so the would-be bash expansion
|
|
// is not a concern.
|
|
assertContains(content, "MORPHIT_INSTANCE_TAGLINE='Morphit $HOME instance'", 'single-quoted');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('cp139-D-1: command-substitution $(...) in tagline is single-quoted (parseEnv literal)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
tagline: 'evil $(curl http://x.example) instance'
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
// parseEnv reads $(curl ...) inside single-quotes literally.
|
|
assertContains(
|
|
content,
|
|
"MORPHIT_INSTANCE_TAGLINE='evil $(curl http://x.example) instance'",
|
|
'single-quoted (parseEnv literal)'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario("cp139-D-1: embedded apostrophe in tagline falls back to double-quoted (parseEnv consumer)", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
tagline: "alice's morphit"
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
// Apostrophe in value → can't use single-quoted (parseEnv
|
|
// doesn't support POSIX close-escape-reopen). Fall back to
|
|
// double-quoted; parseEnv reads $ inside double-quotes
|
|
// literally (no expansion). Apostrophe survives verbatim.
|
|
assertContains(content, 'MORPHIT_INSTANCE_TAGLINE="alice\'s morphit"', 'double-quoted fallback');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('cp139-D-1: bare-safe values still emit without quotes', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
// `test-instance` matches the bare regex — must emit without
|
|
// quotes.
|
|
assertContains(content, 'MORPHIT_INSTANCE_NAME=test-instance', 'bare emission');
|
|
assertTrue(
|
|
!content.includes("MORPHIT_INSTANCE_NAME='test-instance'"),
|
|
'bare must NOT be force-quoted'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario("cp139-D-1: bash consumer (morphit.env critical-infra) stays single-quoted", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
// morphit.env's writable values (DB URL + RPC list) are
|
|
// the bash consumer. The DB URL in sampleAnswers has a
|
|
// colon-port + an @ inside the password section so it'll
|
|
// pass through bare; but the RPC endpoints join is comma-
|
|
// separated which doesn't match the bare regex.
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
blurtRpcEndpoints: ['https://rpc1.example', 'https://rpc2.example']
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const env = readFileSync(result.envPath, 'utf8');
|
|
assertContains(
|
|
env,
|
|
"MORPHIT_INDEXER_RPC_ENDPOINTS='https://rpc1.example,https://rpc2.example'",
|
|
'bash consumer = single-quoted'
|
|
);
|
|
assertTrue(
|
|
!env.includes('MORPHIT_INDEXER_RPC_ENDPOINTS="https://'),
|
|
'bash consumer must NOT use double-quotes (would expand $-substring in URL params)'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario(
|
|
"cp139-D-1 negative: value with both ' and \" throws at quote() time (unrepresentable in parseEnv)",
|
|
() => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
// parseEnv supports neither escape form for the OTHER
|
|
// quote char inside a given quote. An operator typing
|
|
// `alice's "first" morphit` into the tagline is an edge
|
|
// case we surface loudly rather than silently corrupt.
|
|
// Wizard prompt layer is the right place to reject — for
|
|
// now the write throws, which is much better than
|
|
// truncating at parse time.
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
tagline: "alice's \"first\" morphit"
|
|
};
|
|
let threw = false;
|
|
try {
|
|
writeWizardOutput(answers, tmp);
|
|
} catch (err) {
|
|
if (
|
|
err instanceof Error &&
|
|
err.message.includes('both') &&
|
|
err.message.includes('unrepresentable')
|
|
) {
|
|
threw = true;
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
assertTrue(threw, 'expected quote() to throw on value with both apostrophe and double-quote');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
);
|
|
|
|
// ─── cp139-D-1: parseEnv round-trip invariant ───────────────────
|
|
//
|
|
// The big regression sentinel: the wizard's emitted morphit.config.env
|
|
// must round-trip cleanly through Node's parseEnv (operator-config's
|
|
// consumer). This is the test that would have CAUGHT cp139-D-1 if
|
|
// it had existed at cp139-C-11 ship time.
|
|
|
|
scenario(
|
|
"cp139-D-1 round-trip: tagline with apostrophe survives parseEnv read-back",
|
|
() => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
// "Berlin's first Morphit node." — the EXACT example string
|
|
// stepTagline shows the operator. cp139-C-11 would have
|
|
// emitted this as 'Berlin'\''s first Morphit node.' which
|
|
// parseEnv truncates to "Berlin" silently.
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
tagline: "Berlin's first Morphit node."
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
const parsed = parseEnv(content);
|
|
assertTrue(
|
|
parsed.MORPHIT_INSTANCE_TAGLINE === "Berlin's first Morphit node.",
|
|
`round-trip failed: parsed = ${JSON.stringify(parsed.MORPHIT_INSTANCE_TAGLINE)}, expected "Berlin's first Morphit node."`
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
);
|
|
|
|
scenario(
|
|
"cp139-D-1 round-trip: tagline with $HOME survives parseEnv read-back as literal",
|
|
() => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
tagline: 'My $HOME node'
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
const parsed = parseEnv(content);
|
|
// parseEnv doesn't expand $ inside double-quotes (dotenv
|
|
// semantics); the literal string $HOME survives.
|
|
assertTrue(
|
|
parsed.MORPHIT_INSTANCE_TAGLINE === 'My $HOME node',
|
|
`round-trip failed: parsed = ${JSON.stringify(parsed.MORPHIT_INSTANCE_TAGLINE)}`
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
);
|
|
|
|
scenario(
|
|
"cp139-D-1 round-trip: every config field round-trips through parseEnv",
|
|
() => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
// Hostile-ish values that hit the non-bare-safe regex path
|
|
// in each operator-tunable field. All must round-trip
|
|
// through Node's parseEnv (the canonical consumer for
|
|
// morphit.config.env).
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
instanceName: 'My Test Node', // space → quote path
|
|
tagline: "alice's tagline with $HOME", // apostrophe + $ literal
|
|
contactUrl: 'https://example.com/contact?to=alice@example.com', // @ + ? + =
|
|
origin: 'https://my-morphit.example.com:8443', // : (bare-safe)
|
|
operatorTag: {
|
|
tag: 'my-org.morphit-instance' // bare-safe
|
|
},
|
|
seo: {
|
|
title: "morphit's first instance", // apostrophe → double-quoted fallback
|
|
description: null,
|
|
keywords: null
|
|
}
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
const parsed = parseEnv(content);
|
|
|
|
// All fields below are in morphit.config.env (parseEnv
|
|
// consumer). OPERATOR_TAG lives in morphit.env per
|
|
// render.ts (bash consumer) and is exercised by a
|
|
// separate bash-consumer round-trip in edit-smoke.
|
|
const checks: Array<[string, string]> = [
|
|
['MORPHIT_INSTANCE_NAME', 'My Test Node'],
|
|
['MORPHIT_INSTANCE_TAGLINE', "alice's tagline with $HOME"],
|
|
['MORPHIT_INSTANCE_CONTACT_URL', 'https://example.com/contact?to=alice@example.com'],
|
|
['MORPHIT_INSTANCE_ORIGIN', 'https://my-morphit.example.com:8443'],
|
|
['MORPHIT_INSTANCE_SEO_TITLE', "morphit's first instance"]
|
|
];
|
|
for (const [key, expected] of checks) {
|
|
assertTrue(
|
|
parsed[key] === expected,
|
|
`${key}: parsed=${JSON.stringify(parsed[key])}, expected=${JSON.stringify(expected)}`
|
|
);
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
);
|
|
|
|
scenario('writeWizardOutput: writes MORPHIT_INSTANCE_ORIGIN when origin set', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const withOrigin: WizardAnswers = {
|
|
...sampleAnswers,
|
|
origin: 'https://alice-morphit.example'
|
|
};
|
|
const result = writeWizardOutput(withOrigin, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
assertContains(
|
|
content,
|
|
'MORPHIT_INSTANCE_ORIGIN=https://alice-morphit.example',
|
|
'origin written'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: omits MORPHIT_INSTANCE_ORIGIN when null', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
assertTrue(!content.includes('MORPHIT_INSTANCE_ORIGIN'), 'no origin line when null');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: keystore file has 0600 permissions', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const stat = statSync(result.keystorePath);
|
|
// Mask off the file-type bits, keep the permission bits.
|
|
const perms = stat.mode & 0o777;
|
|
assertEqual(perms, 0o600, 'keystore perms');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: config file has 0600 permissions', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const stat = statSync(result.configPath);
|
|
const perms = stat.mode & 0o777;
|
|
assertEqual(perms, 0o600, 'config perms');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: env file has 0600 permissions', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
const stat = statSync(result.envPath);
|
|
const perms = stat.mode & 0o777;
|
|
assertEqual(perms, 0o600, 'env perms');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: encrypted mode writes JSON envelope to keystore.json', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const encryptedAnswers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
activeKey: {
|
|
mode: 'encrypted',
|
|
plaintextWif: undefined,
|
|
envelope: {
|
|
v: 1,
|
|
kdf: 'scrypt',
|
|
kdf_params: { N: 131072, r: 8, p: 1, salt: 'fakesalt' },
|
|
cipher: 'aes-256-gcm',
|
|
iv: 'fakeiv',
|
|
ct: 'fakect'
|
|
},
|
|
passphraseHint: undefined
|
|
}
|
|
};
|
|
const result = writeWizardOutput(encryptedAnswers, tmp);
|
|
assertContains(result.keystorePath, 'keystore.json', 'json filename');
|
|
const content = readFileSync(result.keystorePath, 'utf8');
|
|
const parsed = JSON.parse(content);
|
|
assertEqual(parsed.v, 1, 'envelope v');
|
|
assertEqual(parsed.kdf, 'scrypt', 'envelope kdf');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: alt-network addresses written when present', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const withAlt: WizardAnswers = {
|
|
...sampleAnswers,
|
|
altNetworks: {
|
|
tor: 'abc.onion',
|
|
lokinet: null,
|
|
i2pB32: 'xyz.b32.i2p',
|
|
i2pName: 'morphit.i2p',
|
|
nostr: null,
|
|
// cp474 — REQUIRED by AltNetworkResult and absent until now. Absent it
|
|
// read `undefined`, and render.ts gates on `altNetworks.ens !== null`
|
|
// — which `undefined` PASSES — so this fixture was rendering a junk
|
|
// `MORPHIT_INSTANCE_ENS_NAME=undefined` line that nothing asserted on.
|
|
// Populate it so the ENS write path is actually exercised.
|
|
ens: 'morphit.eth'
|
|
}
|
|
};
|
|
const result = writeWizardOutput(withAlt, tmp);
|
|
const content = readFileSync(result.configPath, 'utf8');
|
|
assertContains(content, 'MORPHIT_INSTANCE_TOR_ADDRESS=abc.onion', 'tor written');
|
|
assertContains(content, 'MORPHIT_INSTANCE_I2P_B32_ADDRESS=xyz.b32.i2p', 'i2p b32 written');
|
|
assertContains(content, 'MORPHIT_INSTANCE_I2P_NAME_ADDRESS=morphit.i2p', 'i2p name written');
|
|
// cp474 — the ENS write path had no coverage at all.
|
|
assertContains(content, 'MORPHIT_INSTANCE_ENS_NAME=morphit.eth', 'ens written');
|
|
assertTrue(!content.includes('MORPHIT_INSTANCE_I2P_ADDRESS='), 'no legacy single-i2p key');
|
|
assertTrue(!content.includes('MORPHIT_INSTANCE_LOKINET_ADDRESS'), 'no lokinet');
|
|
assertTrue(!content.includes('MORPHIT_INSTANCE_NOSTR_PUBKEY'), 'no nostr');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─── Backup automation (Audit Part 32) ───────────────────────────
|
|
|
|
scenario('writeWizardOutput: backup disabled writes no backup.env', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const result = writeWizardOutput(sampleAnswers, tmp);
|
|
assertEqual(result.backupEnvPath, null, 'backupEnvPath null');
|
|
assertEqual(result.backupEnvBytes, 0, 'backupEnvBytes 0');
|
|
assertTrue(
|
|
!existsSync(join(tmp, 'ops/backup/backup.env')),
|
|
'no ops/backup/backup.env file written'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: backup enabled writes backup.env with operator values', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const withBackup: WizardAnswers = {
|
|
...sampleAnswers,
|
|
backup: {
|
|
enabled: true,
|
|
backupDir: '/data/morphit-backups',
|
|
retainDays: 14,
|
|
dbContainer: null,
|
|
dbName: 'morphit_indexer',
|
|
dbUser: 'morphit_indexer'
|
|
}
|
|
};
|
|
const result = writeWizardOutput(withBackup, tmp);
|
|
assertTrue(result.backupEnvPath !== null, 'backupEnvPath populated');
|
|
assertTrue(result.backupEnvBytes > 0, 'backupEnvBytes > 0');
|
|
const content = readFileSync(result.backupEnvPath!, 'utf8');
|
|
assertTrue(content.includes('BACKUP_DIR=/data/morphit-backups'), 'BACKUP_DIR honored');
|
|
assertTrue(content.includes('RETAIN_DAYS=14'), 'RETAIN_DAYS honored');
|
|
assertTrue(content.includes('DB_NAME=morphit_indexer'), 'DB_NAME default');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: backup.env has 0600 permissions', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const withBackup: WizardAnswers = {
|
|
...sampleAnswers,
|
|
backup: {
|
|
enabled: true,
|
|
backupDir: '/home/morphit/backups',
|
|
retainDays: 30,
|
|
dbContainer: null,
|
|
dbName: 'morphit_indexer',
|
|
dbUser: 'morphit_indexer'
|
|
}
|
|
};
|
|
const result = writeWizardOutput(withBackup, tmp);
|
|
const stats = statSync(result.backupEnvPath!);
|
|
const mode = stats.mode & 0o777;
|
|
assertEqual(mode, 0o600, 'backup.env is 0600');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─── Part 121 cp9 — Matrix surface emission ───────────────────────
|
|
|
|
scenario('writeWizardOutput: both Matrix surfaces opted-out → no Matrix block in env', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
writeWizardOutput(sampleAnswers, tmp);
|
|
const env = readFileSync(join(tmp, 'morphit.config.env'), 'utf-8');
|
|
assertTrue(!env.includes('MORPHIT_MATRIX_BOT_ALERT_MXID'), 'no MXID line when opted out');
|
|
assertTrue(
|
|
!env.includes('MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM'),
|
|
'no room line when opted out'
|
|
);
|
|
assertTrue(!env.includes('# Matrix surfaces'), 'no Matrix block heading when opted out');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: only MXID populated → only MORPHIT_MATRIX_BOT_ALERT_MXID emitted', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
matrix: { alertMxid: '@alice:matrix.org', groupRoomAlias: null }
|
|
};
|
|
writeWizardOutput(answers, tmp);
|
|
const env = readFileSync(join(tmp, 'morphit.config.env'), 'utf-8');
|
|
assertTrue(
|
|
env.includes('MORPHIT_MATRIX_BOT_ALERT_MXID=@alice:matrix.org'),
|
|
'MXID line present'
|
|
);
|
|
assertTrue(
|
|
!env.includes('MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM='),
|
|
'no room line when room is null'
|
|
);
|
|
assertTrue(env.includes('# Matrix surfaces'), 'Matrix block heading present');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: only room populated → only MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM emitted', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
matrix: { alertMxid: null, groupRoomAlias: '#agorise:matrix.org' }
|
|
};
|
|
writeWizardOutput(answers, tmp);
|
|
const env = readFileSync(join(tmp, 'morphit.config.env'), 'utf-8');
|
|
// # is NOT in quote()'s safe-char set (shell-comment hazard) so
|
|
// the value gets wrapped in quotes. cp139-C-11: quote() now
|
|
// uses SINGLE quotes for bash-safety (was double in earlier
|
|
// audit), and the regex accepts either form for posterity.
|
|
assertTrue(
|
|
/^MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM=(['"]?)#agorise:matrix\.org\1$/m.test(env),
|
|
'room line present (quoted or unquoted)'
|
|
);
|
|
assertTrue(
|
|
!env.includes('MORPHIT_MATRIX_BOT_ALERT_MXID='),
|
|
'no MXID line when MXID is null'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: both Matrix surfaces populated → both env lines emitted', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
matrix: {
|
|
alertMxid: '@alice:matrix.org',
|
|
groupRoomAlias: '#agorise:matrix.org'
|
|
}
|
|
};
|
|
writeWizardOutput(answers, tmp);
|
|
const env = readFileSync(join(tmp, 'morphit.config.env'), 'utf-8');
|
|
assertTrue(
|
|
env.includes('MORPHIT_MATRIX_BOT_ALERT_MXID=@alice:matrix.org'),
|
|
'MXID line present (unquoted: @ is in safe-char set)'
|
|
);
|
|
assertTrue(
|
|
/^MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM=(['"]?)#agorise:matrix\.org\1$/m.test(env),
|
|
'room line present (quoted: # is shell-comment hazard so quote() wraps it)'
|
|
);
|
|
// Critical: the room line must carry #-prefixed value, the
|
|
// MXID line @-prefixed value. If they got swapped, that's
|
|
// the @↔# replacement footgun made manifest.
|
|
const lines = env.split('\n');
|
|
const mxidLine = lines.find((l) => l.startsWith('MORPHIT_MATRIX_BOT_ALERT_MXID='));
|
|
const roomLine = lines.find((l) =>
|
|
l.startsWith('MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM=')
|
|
);
|
|
assertTrue(mxidLine !== undefined && /=(['"]?)@/.test(mxidLine), 'MXID line carries @');
|
|
assertTrue(roomLine !== undefined && /=(['"]?)#/.test(roomLine), 'room line carries #');
|
|
assertTrue(
|
|
mxidLine !== undefined && !/=(['"]?)#/.test(mxidLine),
|
|
'MXID line must NOT carry a # value (the @↔# footgun)'
|
|
);
|
|
assertTrue(
|
|
roomLine !== undefined && !/=(['"]?)@/.test(roomLine),
|
|
'room line must NOT carry an @ value (the @↔# footgun)'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─── cp182 — BunkerWeb trusted-proxy wiring ──────────────────────
|
|
|
|
scenario('writeWizardOutput: BunkerWeb enabled → MORPHIT_RELAY_TRUSTED_PROXY_IPS=172.20.0.0/16 emitted', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
bunkerWeb: { enabled: true }
|
|
};
|
|
writeWizardOutput(answers, tmp);
|
|
// cp193 — trusted-proxy IPs moved to morphit.env (not allowlisted
|
|
// for morphit.config.env).
|
|
const env = readFileSync(join(tmp, 'morphit.env'), 'utf-8');
|
|
assertTrue(
|
|
env.includes('MORPHIT_RELAY_TRUSTED_PROXY_IPS=172.20.0.0/16'),
|
|
'active trusted-proxy line present when BunkerWeb chosen'
|
|
);
|
|
assertTrue(
|
|
env.includes('Reverse proxy / trusted client IPs'),
|
|
'reverse-proxy section heading present'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: BunkerWeb disabled → trusted-proxy stays commented (no spoofable phantom range)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
bunkerWeb: { enabled: false }
|
|
};
|
|
writeWizardOutput(answers, tmp);
|
|
// cp193 — trusted-proxy IPs moved to morphit.env.
|
|
const env = readFileSync(join(tmp, 'morphit.env'), 'utf-8');
|
|
// The active (uncommented) assignment must NOT be present — a
|
|
// direct client could otherwise spoof X-Forwarded-For. The
|
|
// commented hint line (# MORPHIT_RELAY_TRUSTED_PROXY_IPS=) is fine.
|
|
assertTrue(
|
|
!/^MORPHIT_RELAY_TRUSTED_PROXY_IPS=/m.test(env),
|
|
'no active trusted-proxy assignment when serving direct'
|
|
);
|
|
assertTrue(
|
|
env.includes('# MORPHIT_RELAY_TRUSTED_PROXY_IPS='),
|
|
'commented trusted-proxy hint present for the direct-serve case'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─── cp182 — hardening checklist generation ──────────────────────
|
|
|
|
scenario('writeWizardOutput: hardening opted-in → morphit-hardening-checklist.md written with safety + domain', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
hardening: { generateChecklist: true }
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
assertTrue(result.hardeningChecklistPath !== null, 'result reports the checklist path');
|
|
const md = readFileSync(join(tmp, 'morphit-hardening-checklist.md'), 'utf-8');
|
|
assertTrue(md.includes('# Hardening checklist'), 'checklist title present');
|
|
assertTrue(md.includes('SSH LOCKOUT SAFETY'), 'SSH lockout-safety callout present');
|
|
// origin is null in the baseline fixture → placeholder domain.
|
|
assertTrue(md.includes('<your-domain>'), 'domain placeholder when origin not set');
|
|
assertTrue(md.includes('OPERATIONS.md §34'), 'points at the UFW/fail2ban reference');
|
|
// 0644, not 0600 — it is a runbook with no secrets.
|
|
const mode = statSync(join(tmp, 'morphit-hardening-checklist.md')).mode & 0o777;
|
|
assertTrue(mode === 0o644, `checklist is 0644 (got ${mode.toString(8)})`);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: hardening + BunkerWeb → checklist covers the BunkerWeb edge, not nginx', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
bunkerWeb: { enabled: true },
|
|
hardening: { generateChecklist: true }
|
|
};
|
|
writeWizardOutput(answers, tmp);
|
|
const md = readFileSync(join(tmp, 'morphit-hardening-checklist.md'), 'utf-8');
|
|
assertTrue(md.includes('AUTO_LETS_ENCRYPT'), 'BunkerWeb TLS path present');
|
|
assertTrue(md.includes('/etc/bunkerweb'), 'BunkerWeb copy step present');
|
|
assertTrue(!md.includes('ops/nginx/web.conf'), 'nginx placement omitted when BunkerWeb chosen');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: hardening + no BunkerWeb → checklist covers nginx + certbot, not BunkerWeb', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
bunkerWeb: { enabled: false },
|
|
hardening: { generateChecklist: true }
|
|
};
|
|
writeWizardOutput(answers, tmp);
|
|
const md = readFileSync(join(tmp, 'morphit-hardening-checklist.md'), 'utf-8');
|
|
assertTrue(md.includes('ops/nginx/web.conf'), 'nginx placement present for direct-serve');
|
|
assertTrue(md.includes('certbot'), 'certbot TLS path present for direct-serve');
|
|
assertTrue(!md.includes('AUTO_LETS_ENCRYPT'), 'BunkerWeb TLS path omitted when serving direct');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario('writeWizardOutput: hardening opted-out → no checklist file written', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'morphit-init-test-'));
|
|
try {
|
|
const answers: WizardAnswers = {
|
|
...sampleAnswers,
|
|
hardening: { generateChecklist: false }
|
|
};
|
|
const result = writeWizardOutput(answers, tmp);
|
|
assertTrue(result.hardeningChecklistPath === null, 'result reports no checklist path');
|
|
assertTrue(
|
|
!existsSync(join(tmp, 'morphit-hardening-checklist.md')),
|
|
'no checklist file on disk when opted out'
|
|
);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─── Summary ─────────────────────────────────────────────────────
|
|
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|