morphit/apps/ops-cli/scripts/matrix-test-command-smoke.ts

270 lines
9.1 KiB
TypeScript

#!/usr/bin/env tsx
/**
* matrix-test-command-smoke — `morphit-ops matrix test` decision logic.
*
* The command asks the RUNNING bot (via its loopback /self-test route) to DM
* a labelled test alert to the configured recipients. This smoke drives every
* branch with injected deps (env read, service state, the self-test POST) so
* the logic is verified without a live systemd, a running bot, or Matrix:
* - refuses (exit 1) with an actionable hint when no MXID / no env / no token
* - refuses (exit 1, no POST) when the bot isn't running
* - happy path: POSTs the self-test, confirms delivery + the invite hint
* - surfaces dry-run mode without claiming a real send
* - surfaces per-recipient delivery failures (exit 1)
* - maps a connection failure to a "couldn't reach the bot" message (exit 1)
* Plus the healthcheck-port parser (default / set / last-wins / quotes /
* garbage / out-of-range).
*/
import { runMatrix, type MatrixDeps, type MatrixSelfTestResult } from '../src/commands/matrix.ts';
import {
parseMatrixBotHealthcheckPort,
readMatrixBotHealthcheckPort,
MATRIX_BOT_DEFAULT_HEALTHCHECK_PORT,
type MatrixBotEnv
} from '../src/lib/matrixBot.ts';
import type { ServiceState } from '../src/commands/health.ts';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let failures = 0;
let n = 0;
function check(name: string, cond: boolean, detail = ''): void {
n++;
if (cond) {
console.log(` \u2713 ${name}`);
} else {
failures++;
console.error(` \u2717 ${name}${detail ? ` \u2014 ${detail}` : ''}`);
}
}
/** A ready env by default; override fields to exercise the not-ready branches. */
function env(over: Partial<MatrixBotEnv> = {}): MatrixBotEnv {
return { exists: true, mxidRaw: '@alice:matrix.org', tokenRaw: 'mat_realtoken', ...over };
}
async function run(deps: MatrixDeps): Promise<{ code: number; out: string }> {
const lines: string[] = [];
const origLog = console.log;
const origErr = console.error;
console.log = (...a: unknown[]): void => {
lines.push(a.map((x) => String(x)).join(' '));
};
console.error = (...a: unknown[]): void => {
lines.push(a.map((x) => String(x)).join(' '));
};
let code: number;
try {
code = await runMatrix({ flags: {}, positional: ['test'], colorEnabled: false }, deps);
} finally {
console.log = origLog;
console.error = origErr;
}
return { code, out: lines.join('\n') };
}
const okResult: MatrixSelfTestResult = {
ok: true,
dryRun: false,
recipients: 1,
sent: ['@alice:matrix.org'],
failed: []
};
const active = (): ServiceState => 'active';
// A deliberately non-default port: the ready-path scenarios inject the port
// reader so the smoke NEVER reads the real /etc/morphit/matrix-bot.env (which,
// on a host that has the bot installed, is root-owned 0600 and unreadable by a
// non-root CI runner — that EACCES crash is exactly what this guards against).
// Using a non-default value also proves the injected reader's port is what
// actually reaches the self-test POST, rather than a coincidental 9876.
const TEST_PORT = 12345;
async function main(): Promise<void> {
console.log('matrix-test-command-smoke');
// 1. no MXID configured
{
const { code, out } = await run({
readEnv: () => env({ mxidRaw: '' }),
readState: active,
selfTest: async () => okResult
});
check('no-mxid: exit 1', code === 1, `code=${code}`);
check('no-mxid: points at `matrix set`', /matrix set/.test(out));
check('no-mxid: does not attempt a send', !/Asking the bot/.test(out));
}
// 2. no env file at all
{
const { code, out } = await run({
readEnv: () => ({ exists: false, mxidRaw: '', tokenRaw: '' }),
readState: active,
selfTest: async () => okResult
});
check('no-env: exit 1', code === 1);
check('no-env: points at `matrix set`', /matrix set/.test(out));
}
// 3. MXID set but no token
{
const { code, out } = await run({
readEnv: () => env({ tokenRaw: '' }),
readState: active,
selfTest: async () => okResult
});
check('no-token: exit 1', code === 1);
check('no-token: mentions ACCESS_TOKEN', /ACCESS_TOKEN/.test(out));
}
// 4. ready, but bot not running — must NOT POST
{
let posted = false;
const { code, out } = await run({
readEnv: () => env(),
readState: () => 'inactive',
selfTest: async () => {
posted = true;
return okResult;
}
});
check('not-running: exit 1', code === 1);
check('not-running: says it is not running', /isn.t running/.test(out));
check('not-running: did NOT POST the self-test', posted === false);
}
// 5. happy path
{
let posted = false;
let portUsed = -1;
const { code, out } = await run({
readEnv: () => env(),
readState: active,
readHealthcheckPort: () => TEST_PORT,
selfTest: async (p) => {
posted = true;
portUsed = p;
return okResult;
}
});
check('happy: exit 0', code === 0, `code=${code}`);
// cp474 — `posted` is set inside the selfTest callback, which TS's flow
// analysis can't see, so it stayed narrowed to the literal `false` and
// `=== true` looked impossible. Pass the boolean straight through.
check('happy: POSTed the self-test', posted);
check('happy: threaded the configured healthcheck port to the POST', portUsed === TEST_PORT, `port=${portUsed}`);
check(
'happy: confirms delivery + invite-acceptance hint',
/Sent a test alert/.test(out) && /invite/.test(out)
);
}
// 6. dry-run mode
{
const { code, out } = await run({
readEnv: () => env(),
readState: active,
readHealthcheckPort: () => TEST_PORT,
selfTest: async () => ({ ok: true, dryRun: true, recipients: 1, sent: [], failed: [] })
});
check('dry-run: exit 0', code === 0);
check('dry-run: warns dry-run is ON', /Dry-run mode is ON/.test(out));
check('dry-run: does not claim a real send', !/Sent a test alert/.test(out));
}
// 7. delivery failure
{
const { code, out } = await run({
readEnv: () => env(),
readState: active,
readHealthcheckPort: () => TEST_PORT,
selfTest: async () => ({
ok: false,
dryRun: false,
recipients: 1,
sent: [],
failed: [{ mxid: '@alice:matrix.org', error: 'M_FORBIDDEN bad token' }]
})
});
check('fail: exit 1', code === 1);
check('fail: shows the per-recipient error', /M_FORBIDDEN bad token/.test(out));
check('fail: hints at the token', /token/.test(out));
}
// 8. connection failure (bot active but endpoint not answering)
{
const { code, out } = await run({
readEnv: () => env(),
readState: active,
readHealthcheckPort: () => TEST_PORT,
selfTest: async () => {
throw new Error(`connect ECONNREFUSED 127.0.0.1:${TEST_PORT}`);
}
});
check('conn-err: exit 1', code === 1);
check('conn-err: says it could not reach the bot', /Couldn.t reach/.test(out));
check('conn-err: surfaces the underlying error', /ECONNREFUSED/.test(out));
}
// ─── healthcheck-port parser ────────────────────────────────────────
check(
'port: default when key absent',
parseMatrixBotHealthcheckPort('FOO=bar\n') === MATRIX_BOT_DEFAULT_HEALTHCHECK_PORT
);
check(
'port: parses a set value',
parseMatrixBotHealthcheckPort('MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT=9999\n') === 9999
);
check(
'port: last-wins on duplicate keys',
parseMatrixBotHealthcheckPort(
'MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT=1111\nMORPHIT_MATRIX_BOT_HEALTHCHECK_PORT=2222\n'
) === 2222
);
check(
'port: strips surrounding quotes',
parseMatrixBotHealthcheckPort('MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT="8765"\n') === 8765
);
check(
'port: falls back on garbage',
parseMatrixBotHealthcheckPort('MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT=notaport\n') ===
MATRIX_BOT_DEFAULT_HEALTHCHECK_PORT
);
check(
'port: falls back on out-of-range',
parseMatrixBotHealthcheckPort('MORPHIT_MATRIX_BOT_HEALTHCHECK_PORT=99999\n') ===
MATRIX_BOT_DEFAULT_HEALTHCHECK_PORT
);
// ─── healthcheck-port READER: never throws on a file it can't read ──
// Regression for the cp277 CI failure: readMatrixBotHealthcheckPort used an
// existsSync pre-check, which a present-but-unreadable file passes — so the
// subsequent readFileSync threw EACCES (matrix-bot.env is root-owned 0600 and
// the forgejo runner is non-root), crashing the whole smoke. Both a missing
// file (ENOENT) and an unreadable one must fall back to the default port.
// We can't portably force EACCES while running as root, so we read a
// directory path (readFileSync → EISDIR), which throws for every uid and
// exercises the same catch.
check(
'port-read: missing file falls back to the default (no throw)',
readMatrixBotHealthcheckPort(
join(tmpdir(), `morphit-no-such-${process.pid}-${Date.now()}.env`)
) === MATRIX_BOT_DEFAULT_HEALTHCHECK_PORT
);
check(
'port-read: unreadable file (read throws) falls back to the default (no throw)',
readMatrixBotHealthcheckPort(tmpdir()) === MATRIX_BOT_DEFAULT_HEALTHCHECK_PORT
);
if (failures > 0) {
console.error(`\nmatrix-test-command-smoke: ${failures} failure(s) across ${n} checks`);
process.exit(1);
}
console.log(`\n\u2713 all ${n} matrix-test-command-smoke checks passed`);
}
main().catch((e: unknown) => {
console.error('matrix-test-command-smoke crashed:', e);
process.exit(1);
});