import { describe, expect, test } from 'bun:test'; import { type JailModeRecord, resolveJailPolicy } from '../../hooks/jail'; import { type HookExecutionInput, assembleHookExecutionSection, compareVersions, renderHookExecutionSection, } from './system-doctor'; describe('compareVersions', () => { test('detects ascending major/minor/patch', () => { expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); }); test('detects descending major/minor/patch', () => { expect(compareVersions('2.0.0', '1.0.0')).toBe(1); expect(compareVersions('1.1.0', '1.0.0')).toBe(1); expect(compareVersions('1.0.1', '1.0.0')).toBe(1); }); test('treats equal versions as equal', () => { expect(compareVersions('1.2.3', '1.2.3')).toBe(0); expect(compareVersions('0.1.9', '0.1.9')).toBe(0); }); test('catches the canonical drift case (loaded < workspace)', () => { // The case that triggered #2 in the first place: globally-installed // 0.1.8 vs. workspace 0.1.9. expect(compareVersions('0.1.8', '0.1.9')).toBe(-1); }); test('treats missing trailing segments as zeros', () => { expect(compareVersions('1.0', '1.0.0')).toBe(0); expect(compareVersions('1.0', '1.0.1')).toBe(-1); }); test('strips a leading v prefix', () => { expect(compareVersions('v1.2.3', '1.2.3')).toBe(0); }); }); describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => { const HOST = 'celilo-mgr'; const jailedRecord: JailModeRecord = { mode: 'jailed', backend: 'bubblewrap', host: HOST, recordedAt: '2026-08-28T09:00:00.000Z', }; const regressedRecord: JailModeRecord = { mode: 'unjailed', backend: 'none', reason: 'bubblewrap is installed but could not build a namespace, so hooks run unjailed.', host: HOST, recordedAt: '2026-08-28T09:00:00.000Z', lastJailed: { backend: 'bubblewrap', recordedAt: '2026-08-27T09:00:00.000Z' }, }; const render = (over: Partial) => renderHookExecutionSection({ availability: { backend: 'bubblewrap' }, policy: 'auto', source: 'env', envValue: 'auto', configValue: undefined, record: jailedRecord, host: HOST, monitored: true, exemptions: [], ...over, }); const text = (r: { lines: string[] }) => r.lines.join('\n'); test('a jailing host is a ✔ and nothing more', () => { const r = render({}); expect(text(r)).toContain('hooks run jailed (bubblewrap)'); expect(r.failCount).toBe(0); expect(r.warnCount).toBe(0); }); test('unjailed says why in the reason sentence, as a warning and not a failure', () => { const r = render({ availability: { backend: 'none', reason: 'bubblewrap is not installed, so hooks run unjailed. Install it (`apt install bubblewrap`) and re-run.', }, record: undefined, monitored: null, }); expect(text(r)).toContain('hooks run unjailed — bubblewrap is not installed'); expect(text(r)).toContain('no execution mode recorded yet'); expect(r.warnCount).toBe(1); expect(r.failCount).toBe(0); }); test('an off policy warns, and the reason claims no source of its own', () => { // This test used to be named "CELILO_HOOK_JAIL=off is reported as the // operator's own act" and asserted that string. It stopped being true when // ce-rez7 made `off` the DEFAULT: on a host where nobody set anything, the // doctor named a variable that was unset and an act nobody performed. The // source belongs on the source line, which is rendered separately. const fromDefault = render({ policy: 'off', source: 'default', envValue: undefined, record: undefined, }); expect(text(fromDefault)).toContain('hooks run unjailed'); expect(text(fromDefault)).toContain('policy default (off)'); expect(text(fromDefault)).not.toContain('CELILO_HOOK_JAIL=off'); expect(fromDefault.warnCount).toBe(1); const fromEnv = render({ policy: 'off', source: 'env', envValue: 'off', record: undefined, }); expect(text(fromEnv)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)'); expect(fromEnv.warnCount).toBe(1); }); test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => { // The doctor and the executor share one predicate (autoJailDefers), so // the doctor cannot report a jail the executor will not build. When D14 // lands and the flag flips, this goes back to ✔ with no code change here. const r = render({ availability: { backend: 'sandbox-exec' }, record: undefined }); expect(text(r)).toContain('hooks run unjailed — macOS hooks run unjailed'); expect(text(r)).not.toContain('hooks run jailed'); expect(r.warnCount).toBe(1); expect(r.failCount).toBe(0); }); test('required with no backend is a failure, because every hook fails', () => { const r = render({ policy: 'required', availability: { backend: 'none', reason: 'the profile did not load' }, record: undefined, }); expect(text(r)).toContain("the hook jail policy is 'required' and no jail is available"); expect(r.failCount).toBe(1); }); test('a host that used to jail and has stopped is a failure naming when', () => { const r = render({ availability: { backend: 'none', reason: 'the profile did not load' }, record: regressedRecord, }); expect(text(r)).toContain('ran hooks jailed (bubblewrap) until 2026-08-27T09:00:00.000Z'); expect(r.failCount).toBe(1); }); test('an unmonitored regression points at the self-monitor', () => { const r = render({ availability: { backend: 'none', reason: 'the profile did not load' }, record: regressedRecord, monitored: false, }); expect(text(r)).toContain('celilo monitor add hook_jail'); }); test('a jailing but unmonitored host is told the transition alerts only if monitored', () => { const r = render({ monitored: false }); expect(text(r)).toContain('celilo monitor add hook_jail'); expect(r.failCount).toBe(0); }); test('a dev box with no DB gets no monitor hint', () => { const r = render({ monitored: null }); expect(text(r)).not.toContain('monitor add'); }); test("another host's record reads as a move, not this host's history", () => { const r = render({ record: { ...regressedRecord, host: 'old-box' } }); expect(text(r)).toContain('belongs to host "old-box"'); expect(r.failCount).toBe(0); }); test('the source line names the environment when the env var won (D4)', () => { const r = render({ policy: 'off', source: 'env', envValue: 'off', record: undefined, }); expect(text(r)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)'); }); test('the source line names system config when the stored key won (D4)', () => { const r = render({ policy: 'required', source: 'config', configValue: 'required', record: undefined, }); expect(text(r)).toContain('policy from system config (hooks.jail_policy)'); expect(text(r)).not.toContain('policy from CELILO_HOOK_JAIL'); }); test('the source line names the default and how to change it (D4)', () => { // Ask the resolver what the default IS rather than naming it. The literal // that stood here said `auto`, and it kept passing after ce-rez7 flipped // the default to `off`, because it rendered a { policy, source } pair the // resolver can no longer produce. A correct assertion about a state that // never arrives. const fallback = resolveJailPolicy(undefined, undefined, undefined); const r = render({ policy: fallback.policy, source: fallback.source, envValue: undefined, record: undefined, }); expect(fallback.source).toBe('default'); expect(text(r)).toContain( `policy default (${fallback.policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change`, ); }); test('env and config disagreeing renders the losing value (D4)', () => { const r = render({ policy: 'required', source: 'env', envValue: 'required', configValue: 'off', record: undefined, }); expect(text(r)).toContain( "system config holds 'off' but CELILO_HOOK_JAIL='required' overrides it", ); }); test('env and config agreeing renders no disagreement line (D4)', () => { const r = render({ policy: 'off', source: 'env', envValue: 'off', configValue: 'off', record: undefined, }); expect(text(r)).not.toContain('system config holds'); }); test('a bad stored row renders as a failing check naming the row, not a crash', () => { const r = assembleHookExecutionSection({ envValue: () => undefined, configValue: () => 'alwayssafe', availability: () => ({ backend: 'bubblewrap' }), record: () => undefined, host: () => HOST, monitored: () => true, exemptions: () => [], }); expect(r.lines[0]).toBe('Hook execution'); expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy"); expect(r.failCount).toBe(1); expect(r.warnCount).toBe(0); }); test('an exemption is counted and named (per-module-jail-policy task 3.2)', () => { const r = render({ exemptions: [ { moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' }, { moduleId: 'vault', policy: 'auto', systemPolicy: 'auto' }, ], }); expect(text(r)).toContain("2 modules run under a weaker jail policy than the system's"); expect(text(r)).toContain('caddy (off)'); expect(text(r)).toContain('vault (auto)'); expect(r.warnCount).toBe(1); }); test('one exemption reads as singular', () => { const r = render({ exemptions: [{ moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' }], }); expect(text(r)).toContain('1 module runs under a weaker jail policy'); expect(text(r)).not.toContain('modules run'); }); test('no exemptions render no exemption line (per-module-jail-policy task 3.4)', () => { // The negative half: a surface that reports an exemption nobody set is // as bad as one that hides a real one. With no rows the section must be // word-for-word what it was before this slice. const r = render({}); expect(text(r)).not.toContain('weaker jail policy'); expect(text(r)).not.toContain('exempt'); expect(r.warnCount).toBe(0); }); });