358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* matrix-bot-lifecycle-smoke — locks down `morphit-ops matrix` + the
|
|
* matrix-bot lifecycle. Ken's requirement: the bot is installed by
|
|
* default but only RUNS when a valid alert username is configured; it
|
|
* auto-starts when the username is set, auto-stops when it is cleared,
|
|
* and every upgrade re-checks the field.
|
|
*
|
|
* What this guards (all pure / injected — no real systemd, sudo, stdin,
|
|
* or /etc):
|
|
* 1. matrixBotReadiness() decides run vs the precise not-ready reason
|
|
* for every env shape (no file / no mxid / #room / junk / no token /
|
|
* placeholder token / valid+token / multi-recipient).
|
|
* 2. parseMatrixBotEnvText() — comment-skip, quote-strip, last-wins.
|
|
* 3. upsertEnvKey()/clearEnvKey() — replace in place preserving the
|
|
* secret token + comments; append when absent; clear → empty.
|
|
* 4. systemctlArgv() sudo-awareness.
|
|
* 5. syncMatrixBotService() drives the right verbs: run+restart →
|
|
* daemon-reload+enable+restart; run → +start; !run → disable --now;
|
|
* non-zero exit → ok:false.
|
|
* 6. runMatrix() branches: set valid → write + start; set #room/junk →
|
|
* reject, NO write, NO systemd; set with no env file → exit 2; set
|
|
* valid but no token → write + STOP (no crash-loop) + hint; clear →
|
|
* write empty + stop; status ready+stopped → offer start; status
|
|
* no-mxid → no systemd; status not-installed → no systemd.
|
|
* 7. Wiring: main.ts imports + dispatches `matrix`; the menu exposes it.
|
|
*
|
|
* On success prints exactly one canonical line at column 0:
|
|
* ✓ all N matrix-bot-lifecycle-smoke scenarios passed
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
import {
|
|
matrixBotReadiness,
|
|
parseMatrixBotEnvText,
|
|
upsertEnvKey,
|
|
clearEnvKey,
|
|
systemctlArgv,
|
|
syncMatrixBotService,
|
|
MATRIX_BOT_UNIT,
|
|
type MatrixBotEnv,
|
|
type SystemctlExec
|
|
} from '../src/lib/matrixBot.ts';
|
|
import { runMatrix, type MatrixDeps } from '../src/commands/matrix.ts';
|
|
import type { ServiceState } from '../src/commands/health.ts';
|
|
import { MENU_GROUPS } from '../src/commands/mainMenu.ts';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const opsCliRoot = join(here, '..');
|
|
|
|
let checks = 0;
|
|
const failures: string[] = [];
|
|
function check(label: string, cond: boolean): void {
|
|
checks++;
|
|
if (!cond) failures.push(label);
|
|
}
|
|
|
|
async function quiet<T>(fn: () => Promise<T>): Promise<T> {
|
|
const orig = console.log;
|
|
console.log = () => {};
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
console.log = orig;
|
|
}
|
|
}
|
|
|
|
const env = (mxidRaw: string, tokenRaw: string, exists = true): MatrixBotEnv => ({
|
|
exists,
|
|
mxidRaw,
|
|
tokenRaw
|
|
});
|
|
|
|
/** Recording systemctl exec — captures argv, returns a fixed status. */
|
|
function recordingExec(status: number | null): {
|
|
exec: SystemctlExec;
|
|
calls: Array<{ cmd: string; args: readonly string[] }>;
|
|
} {
|
|
const calls: Array<{ cmd: string; args: readonly string[] }> = [];
|
|
const exec: SystemctlExec = (cmd, args) => {
|
|
calls.push({ cmd, args });
|
|
return { status };
|
|
};
|
|
return { exec, calls };
|
|
}
|
|
|
|
const CTX = (positional: readonly string[]) => ({
|
|
flags: {} as Readonly<Record<string, string>>,
|
|
positional,
|
|
colorEnabled: false
|
|
});
|
|
|
|
async function main(): Promise<void> {
|
|
// ── 1. matrixBotReadiness ─────────────────────────────────────
|
|
check('no env file → run:false no-env-file', (() => {
|
|
const r = matrixBotReadiness(env('', '', false));
|
|
return !r.run && r.reason === 'no-env-file';
|
|
})());
|
|
check('empty mxid → run:false no-mxid', (() => {
|
|
const r = matrixBotReadiness(env('', 'syt_tok'));
|
|
return !r.run && r.reason === 'no-mxid';
|
|
})());
|
|
check('valid mxid + token → run:true (1 recipient)', (() => {
|
|
const r = matrixBotReadiness(env('@agorise:matrix.org', 'syt_realtoken'));
|
|
return r.run && r.mxids.length === 1;
|
|
})());
|
|
check('valid mxid + NO token → run:false no-token', (() => {
|
|
const r = matrixBotReadiness(env('@agorise:matrix.org', ''));
|
|
return !r.run && r.reason === 'no-token';
|
|
})());
|
|
check('valid mxid + placeholder token → run:false placeholder-token', (() => {
|
|
const r = matrixBotReadiness(env('@agorise:matrix.org', 'syt_...'));
|
|
return !r.run && r.reason === 'placeholder-token';
|
|
})());
|
|
check('#room mxid → run:false mxid-is-room', (() => {
|
|
const r = matrixBotReadiness(env('#agorise:matrix.org', 'syt_tok'));
|
|
return !r.run && r.reason === 'mxid-is-room';
|
|
})());
|
|
check('junk mxid → run:false invalid-mxid', (() => {
|
|
const r = matrixBotReadiness(env('not-an-mxid', 'syt_tok'));
|
|
return !r.run && r.reason === 'invalid-mxid';
|
|
})());
|
|
check('multi-recipient valid + token → run:true (2 recipients)', (() => {
|
|
const r = matrixBotReadiness(env('@a:matrix.org, @b:matrix.org', 'syt_tok'));
|
|
return r.run && r.mxids.length === 2;
|
|
})());
|
|
check('multi-recipient with a #room → run:false mxid-is-room', (() => {
|
|
const r = matrixBotReadiness(env('@a:matrix.org,#pub:matrix.org', 'syt_tok'));
|
|
return !r.run && r.reason === 'mxid-is-room';
|
|
})());
|
|
|
|
// ── 2. parseMatrixBotEnvText ──────────────────────────────────
|
|
check('parse basic mxid + token', (() => {
|
|
const p = parseMatrixBotEnvText(
|
|
'MORPHIT_MATRIX_BOT_ALERT_MXID=@a:b\nMORPHIT_MATRIX_BOT_ACCESS_TOKEN=tok'
|
|
);
|
|
return p.mxidRaw === '@a:b' && p.tokenRaw === 'tok';
|
|
})());
|
|
check('parse skips commented lines', (() => {
|
|
const p = parseMatrixBotEnvText('# MORPHIT_MATRIX_BOT_ALERT_MXID=@x:y\n');
|
|
return p.mxidRaw === '';
|
|
})());
|
|
check('parse strips matching quotes', (() => {
|
|
const p = parseMatrixBotEnvText('MORPHIT_MATRIX_BOT_ALERT_MXID="@a:b"');
|
|
return p.mxidRaw === '@a:b';
|
|
})());
|
|
check('parse last-wins on duplicate keys', (() => {
|
|
const p = parseMatrixBotEnvText(
|
|
'MORPHIT_MATRIX_BOT_ALERT_MXID=@first:s\nMORPHIT_MATRIX_BOT_ALERT_MXID=@second:s'
|
|
);
|
|
return p.mxidRaw === '@second:s';
|
|
})());
|
|
|
|
// ── 3. upsertEnvKey / clearEnvKey ─────────────────────────────
|
|
const sampleEnv =
|
|
'# comment line\nMORPHIT_MATRIX_BOT_HOMESERVER=https://matrix.org\n' +
|
|
'MORPHIT_MATRIX_BOT_ACCESS_TOKEN=syt_secret_keep_me\n' +
|
|
'MORPHIT_MATRIX_BOT_ALERT_MXID=@old:matrix.org\n';
|
|
{
|
|
const next = upsertEnvKey(sampleEnv, 'MORPHIT_MATRIX_BOT_ALERT_MXID', '@new:matrix.org');
|
|
const p = parseMatrixBotEnvText(next);
|
|
check('upsert replaces MXID in place', p.mxidRaw === '@new:matrix.org');
|
|
check('upsert preserves the secret token', p.tokenRaw === 'syt_secret_keep_me');
|
|
check('upsert preserves comments', next.includes('# comment line'));
|
|
check('upsert does not duplicate the key', (next.match(/^MORPHIT_MATRIX_BOT_ALERT_MXID=/gm) ?? []).length === 1);
|
|
}
|
|
{
|
|
const cleared = clearEnvKey(sampleEnv, 'MORPHIT_MATRIX_BOT_ALERT_MXID');
|
|
const p = parseMatrixBotEnvText(cleared);
|
|
check('clear empties the MXID', p.mxidRaw === '');
|
|
check('clear preserves the token', p.tokenRaw === 'syt_secret_keep_me');
|
|
}
|
|
{
|
|
const appended = upsertEnvKey(
|
|
'MORPHIT_MATRIX_BOT_ACCESS_TOKEN=tok\n',
|
|
'MORPHIT_MATRIX_BOT_ALERT_MXID',
|
|
'@a:b'
|
|
);
|
|
const p = parseMatrixBotEnvText(appended);
|
|
check('upsert appends when key absent', p.mxidRaw === '@a:b' && p.tokenRaw === 'tok');
|
|
check('upsert output ends with exactly one newline', /[^\n]\n$/.test(appended));
|
|
}
|
|
|
|
// ── 4. systemctlArgv (sudo-awareness) ─────────────────────────
|
|
const asRoot = systemctlArgv(true, 'enable', MATRIX_BOT_UNIT);
|
|
check('root argv cmd = systemctl', asRoot.cmd === 'systemctl');
|
|
check('root argv = enable morphit-matrix-bot.service', asRoot.args.join(' ') === `enable ${MATRIX_BOT_UNIT}`);
|
|
const asUser = systemctlArgv(false, 'disable', '--now', MATRIX_BOT_UNIT);
|
|
check('non-root argv cmd = sudo', asUser.cmd === 'sudo');
|
|
check(
|
|
'non-root argv = systemctl disable --now morphit-matrix-bot.service',
|
|
asUser.args.join(' ') === `systemctl disable --now ${MATRIX_BOT_UNIT}`
|
|
);
|
|
|
|
// ── 5. syncMatrixBotService action mapping ────────────────────
|
|
{
|
|
const { exec, calls } = recordingExec(0);
|
|
const res = syncMatrixBotService(true, { restart: true, exec, root: true });
|
|
const verbs = calls.map((c) => c.args[0]);
|
|
check('run+restart → action enable-restart', res.action === 'enable-restart' && res.ok);
|
|
check('run+restart → daemon-reload + enable + restart', verbs.join(',') === 'daemon-reload,enable,restart');
|
|
}
|
|
{
|
|
const { exec, calls } = recordingExec(0);
|
|
const res = syncMatrixBotService(true, { restart: false, exec, root: true });
|
|
const verbs = calls.map((c) => c.args[0]);
|
|
check('run (no restart) → action enable-start', res.action === 'enable-start');
|
|
check('run (no restart) → ends in start', verbs[verbs.length - 1] === 'start');
|
|
}
|
|
{
|
|
const { exec, calls } = recordingExec(0);
|
|
const res = syncMatrixBotService(false, { exec, root: true });
|
|
check('!run → action disable-stop', res.action === 'disable-stop');
|
|
check('!run → single disable --now call', calls.length === 1 && calls[0]!.args.join(' ') === `disable --now ${MATRIX_BOT_UNIT}`);
|
|
}
|
|
{
|
|
const { exec } = recordingExec(1);
|
|
const res = syncMatrixBotService(true, { restart: true, exec, root: true });
|
|
check('non-zero systemctl → ok:false', res.ok === false);
|
|
}
|
|
|
|
// ── 6. runMatrix branches ─────────────────────────────────────
|
|
/** Build injected deps with recording writeMxid + sync. */
|
|
function deps(opts: {
|
|
readEnv: MatrixBotEnv;
|
|
state?: ServiceState;
|
|
writeOk?: boolean;
|
|
confirmYes?: boolean;
|
|
}): {
|
|
deps: MatrixDeps;
|
|
writes: Array<string>;
|
|
syncs: Array<{ run: boolean; restart: boolean }>;
|
|
} {
|
|
const writes: string[] = [];
|
|
const syncs: Array<{ run: boolean; restart: boolean }> = [];
|
|
return {
|
|
writes,
|
|
syncs,
|
|
deps: {
|
|
readEnv: () => opts.readEnv,
|
|
readState: (_u) => opts.state ?? 'inactive',
|
|
writeMxid: (v) => {
|
|
writes.push(v);
|
|
return opts.writeOk ?? true;
|
|
},
|
|
sync: (run, restart) => {
|
|
syncs.push({ run, restart });
|
|
return { action: run ? 'enable-restart' : 'disable-stop', ok: true };
|
|
},
|
|
confirm: async () => opts.confirmYes ?? true
|
|
}
|
|
};
|
|
}
|
|
|
|
// (a) set valid mxid, env exists, token present → write + sync(run)
|
|
{
|
|
const d = deps({ readEnv: env('@agorise:matrix.org', 'syt_realtoken') });
|
|
const rc = await quiet(() => runMatrix(CTX(['set', '@agorise:matrix.org']), d.deps));
|
|
check('set valid → exit 0', rc === 0);
|
|
check('set valid → wrote the mxid', d.writes.length === 1 && d.writes[0] === '@agorise:matrix.org');
|
|
check('set valid → sync(run=true,restart=true)', d.syncs.length === 1 && d.syncs[0]!.run === true && d.syncs[0]!.restart === true);
|
|
}
|
|
// (b) set #room → reject, NO write, NO sync
|
|
{
|
|
const d = deps({ readEnv: env('', 'syt_tok') });
|
|
const rc = await quiet(() => runMatrix(CTX(['set', '#pub:matrix.org']), d.deps));
|
|
check('set #room → exit 1', rc === 1);
|
|
check('set #room → NO write', d.writes.length === 0);
|
|
check('set #room → NO sync', d.syncs.length === 0);
|
|
}
|
|
// (c) set junk → reject, no write
|
|
{
|
|
const d = deps({ readEnv: env('', 'syt_tok') });
|
|
const rc = await quiet(() => runMatrix(CTX(['set', 'garbage']), d.deps));
|
|
check('set junk → exit 1', rc === 1);
|
|
check('set junk → NO write', d.writes.length === 0);
|
|
}
|
|
// (d) set valid but NO env file → exit 2, no write
|
|
{
|
|
const d = deps({ readEnv: env('', '', false) });
|
|
const rc = await quiet(() => runMatrix(CTX(['set', '@a:matrix.org']), d.deps));
|
|
check('set with no env file → exit 2', rc === 2);
|
|
check('set with no env file → NO write', d.writes.length === 0);
|
|
}
|
|
// (e) set valid but NO token → write + sync(false) (stop, no crash-loop)
|
|
{
|
|
const d = deps({ readEnv: env('@agorise:matrix.org', '') });
|
|
const rc = await quiet(() => runMatrix(CTX(['set', '@agorise:matrix.org']), d.deps));
|
|
check('set valid+no-token → exit 0', rc === 0);
|
|
check('set valid+no-token → still wrote the mxid', d.writes.length === 1);
|
|
check('set valid+no-token → sync(run=false) (kept stopped)', d.syncs.length === 1 && d.syncs[0]!.run === false);
|
|
}
|
|
// (f) clear → write empty + sync(false)
|
|
{
|
|
const d = deps({ readEnv: env('@old:matrix.org', 'syt_tok') });
|
|
const rc = await quiet(() => runMatrix(CTX(['clear']), d.deps));
|
|
check('clear → exit 0', rc === 0);
|
|
check('clear → wrote empty', d.writes.length === 1 && d.writes[0] === '');
|
|
check('clear → sync(run=false)', d.syncs.length === 1 && d.syncs[0]!.run === false);
|
|
}
|
|
// (g) status ready + stopped + confirm yes → sync(run)
|
|
{
|
|
const d = deps({ readEnv: env('@a:matrix.org', 'syt_tok'), state: 'inactive', confirmYes: true });
|
|
const rc = await quiet(() => runMatrix(CTX([]), d.deps));
|
|
check('status ready+stopped+yes → exit 0', rc === 0);
|
|
check('status ready+stopped+yes → sync(run=true)', d.syncs.length === 1 && d.syncs[0]!.run === true);
|
|
}
|
|
// (h) status no-mxid + stopped → no offer, no sync
|
|
{
|
|
const d = deps({ readEnv: env('', ''), state: 'inactive' });
|
|
const rc = await quiet(() => runMatrix(CTX([]), d.deps));
|
|
check('status no-mxid → exit 0', rc === 0);
|
|
check('status no-mxid → NO sync', d.syncs.length === 0);
|
|
}
|
|
// (i) status not-installed → no sync
|
|
{
|
|
const d = deps({ readEnv: env('@a:matrix.org', 'syt_tok'), state: 'not-installed' });
|
|
const rc = await quiet(() => runMatrix(CTX([]), d.deps));
|
|
check('status not-installed → exit 0', rc === 0);
|
|
check('status not-installed → NO sync', d.syncs.length === 0);
|
|
}
|
|
// (j) status running but not-ready → offer stop
|
|
{
|
|
const d = deps({ readEnv: env('@a:matrix.org', ''), state: 'active', confirmYes: true });
|
|
const rc = await quiet(() => runMatrix(CTX([]), d.deps));
|
|
check('status running+not-ready+yes → exit 0', rc === 0);
|
|
check('status running+not-ready+yes → sync(run=false)', d.syncs.length === 1 && d.syncs[0]!.run === false);
|
|
}
|
|
|
|
// ── 7. Wiring (static) ────────────────────────────────────────
|
|
const mainSrc = readFileSync(join(opsCliRoot, 'src', 'main.ts'), 'utf8');
|
|
check("main.ts imports runMatrix from './commands/matrix.ts'", /from '\.\/commands\/matrix\.ts'/.test(mainSrc));
|
|
check(
|
|
'main.ts dispatches the matrix subcommand',
|
|
/args\.subcommand === 'matrix'/.test(mainSrc) && /runMatrix\(/.test(mainSrc)
|
|
);
|
|
const upgradeSrc = readFileSync(join(opsCliRoot, 'src', 'commands', 'upgrade.ts'), 'utf8');
|
|
check('upgrade.ts calls syncMatrixBotService (re-checks on upgrade)', /syncMatrixBotService\(/.test(upgradeSrc));
|
|
check('upgrade.ts no longer unconditionally restarts the matrix-bot', !/'morphit-matrix-bot\.service'/.test(upgradeSrc));
|
|
const matrixItems = MENU_GROUPS.flatMap((g) => g.items).filter((i) => i.subcommand === 'matrix');
|
|
check('MENU_GROUPS exposes exactly one matrix item', matrixItems.length === 1);
|
|
|
|
// ── Result ────────────────────────────────────────────────────
|
|
if (failures.length > 0) {
|
|
console.error(`matrix-bot-lifecycle-smoke: ${failures.length} FAILED of ${checks}:`);
|
|
for (const f of failures) console.error(` ✗ ${f}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✓ all ${checks} matrix-bot-lifecycle-smoke scenarios passed`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('matrix-bot-lifecycle-smoke: threw:', err);
|
|
process.exit(1);
|
|
});
|