mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 17:35:29 +02:00
test: coverage for the memorable bridge (remove-source regression for every KNOWN_HOOKS source)
- settings-hook: identity removal pinned for each source in KNOWN_HOOKS; list-items unknown flag and combined --owned-by/--command-regex - gstack-memorable: enable/disable failure paths (lock give-up exit 5 with the test-only lock timeout override, consent-write failures guarded by canRevokeWrites, canonical-version mismatch, no-bun status) - hook: non-object JSON, missing cwd, non-ASCII bytes, held-open stdin, shim without bun, stripControl, resolveVendor, runExternal ENOENT - egress-receipt: lockBudgetMs 0 and writeOutcome on garbage input - uninstall: no memorable entry present reports nothing removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
45035b7f94
commit
77b7b31892
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { canRevokeWrites } from './helpers/fs-caps';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -167,11 +168,7 @@ describe('enable', () => {
|
||||
const ro = { GSTACK_HOME: roState, GSTACK_STATE_ROOT: roState, GSTACK_STATE_DIR: roState };
|
||||
const r = run(['enable'], ro);
|
||||
fs.chmodSync(roState, 0o755);
|
||||
if (r.status === 0) {
|
||||
// running as a user that ignores file modes (root in CI): the write succeeded, nothing to assert on rollback
|
||||
expect(r.stdout).toContain('enabled');
|
||||
return;
|
||||
}
|
||||
if (!canRevokeWrites()) { expect(r.status).toBe(0); return; } // modes not enforced here: the write succeeds
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('could not record consent');
|
||||
expect(fs.existsSync(settings) ? (readSettings().hooks ?? {}).UserPromptSubmit : undefined).toBeUndefined(); // fresh registration rolled back
|
||||
@@ -300,6 +297,135 @@ describe('status (read-only)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable/disable failure paths (coverage audit)', () => {
|
||||
|
||||
function mixedCanonical(version: string, settingsHookBody?: string) {
|
||||
fs.rmSync(canonical);
|
||||
fs.mkdirSync(path.join(canonical, 'hosts', 'claude', 'hooks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(canonical, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(canonical, 'bin', 'gstack-session-update'), '#!/bin/sh\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(canonical, HOOK_REL), '#!/bin/sh\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(canonical, `${HOOK_REL}.ts`), '// twin\n');
|
||||
if (settingsHookBody) fs.writeFileSync(path.join(canonical, 'bin', 'gstack-settings-hook'), settingsHookBody, { mode: 0o755 });
|
||||
else { fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), path.join(canonical, 'bin', 'gstack-settings-hook')); fs.chmodSync(path.join(canonical, 'bin', 'gstack-settings-hook'), 0o755); }
|
||||
fs.writeFileSync(path.join(canonical, 'VERSION'), version);
|
||||
}
|
||||
|
||||
test('enable refuses on a VERSION mismatch alone (hook and twin present)', () => {
|
||||
mixedCanonical('0.0.0.0\n');
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain("is version '0.0.0.0'");
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
});
|
||||
|
||||
test('enable refuses when the stable hook manager does not know list-items', () => {
|
||||
mixedCanonical(fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8'), '#!/bin/sh\necho "Unknown action: $1" >&2\nexit 1\n');
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('does not know list-items');
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
});
|
||||
|
||||
test('enable refuses when BOTH gstack and the vendor are registered; disable then removes only gstack\'s entry', () => {
|
||||
writeSettings({ hooks: { UserPromptSubmit: [
|
||||
{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 }] },
|
||||
{ hooks: [{ type: 'command', command: vendorOwn() }] },
|
||||
] } });
|
||||
const before = fs.readFileSync(settings, 'utf8');
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('already registers this hook itself');
|
||||
expect(fs.readFileSync(settings, 'utf8')).toBe(before);
|
||||
const d = run(['disable']);
|
||||
expect(d.status).toBe(0);
|
||||
expect(commands()).toEqual([vendorOwn()]);
|
||||
});
|
||||
|
||||
test('enable passes the hook manager\'s lock give-up (exit 5) through and leaves the gate untouched', () => {
|
||||
fs.mkdirSync(`${settings}.lock`, { recursive: true });
|
||||
fs.writeFileSync(path.join(`${settings}.lock`, 'owner'), 'another-live-process');
|
||||
// the hook manager's give-up defaults to 10 s; its test-only override keeps this fast
|
||||
const r = run(['enable'], { GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '500' });
|
||||
expect(r.status).toBe(5);
|
||||
expect(r.stderr).toContain('settings hook update failed');
|
||||
expect(gate()).toBe('off');
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
test('disable surfaces a hook-manager lock give-up as exit 5 after flipping the gate off', () => {
|
||||
setGate('on');
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}` }] }] } });
|
||||
fs.mkdirSync(`${settings}.lock`, { recursive: true });
|
||||
fs.writeFileSync(path.join(`${settings}.lock`, 'owner'), 'another-live-process');
|
||||
const r = run(['disable'], { GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '500' });
|
||||
expect(r.status).toBe(5);
|
||||
expect(gate()).toBe('off');
|
||||
expect(r.stdout).toContain('consent: memorable_recall=off');
|
||||
expect(r.stderr).toContain('survived');
|
||||
}, 30_000);
|
||||
|
||||
test('a FRESH bridge lock held by another process makes enable exit 5 after the wait, lock left in place', () => {
|
||||
const lock = path.join(env.GSTACK_HOME, 'locks', 'memorable-bridge.lock');
|
||||
fs.mkdirSync(lock, { recursive: true });
|
||||
fs.writeFileSync(path.join(lock, 'ts'), String(Math.floor(Date.now() / 1000)));
|
||||
fs.writeFileSync(path.join(lock, 'owner'), '999999');
|
||||
const t0 = Date.now();
|
||||
const r = run(['enable']);
|
||||
expect(r.status).toBe(5);
|
||||
expect(r.stderr).toContain('another gstack-memorable is running');
|
||||
expect(Date.now() - t0).toBeGreaterThan(4000);
|
||||
expect(fs.existsSync(lock)).toBe(true);
|
||||
expect(fs.existsSync(settings)).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
test('consent-write failure with a PRE-EXISTING registration keeps the registration and restores the prior gate', () => {
|
||||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
||||
// state dir: gate already 'on' from an earlier enable, then made read-only
|
||||
setGate('on');
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 }] }] } });
|
||||
fs.chmodSync(path.join(env.GSTACK_HOME, 'config.yaml'), 0o444);
|
||||
fs.chmodSync(env.GSTACK_HOME, 0o555);
|
||||
const r = run(['enable']);
|
||||
fs.chmodSync(env.GSTACK_HOME, 0o755);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('could not record consent');
|
||||
expect(commands()).toEqual([`${canonical}/${HOOK_REL}`]); // pre-existing registration kept
|
||||
expect(gate()).toBe('on'); // prior value, not an assumed off
|
||||
});
|
||||
|
||||
test('disable reports a failed consent write, still removes the hook, exits 1', () => {
|
||||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
||||
setGate('on');
|
||||
writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}` }] }] } });
|
||||
fs.chmodSync(path.join(env.GSTACK_HOME, 'config.yaml'), 0o444);
|
||||
fs.chmodSync(env.GSTACK_HOME, 0o555);
|
||||
const r = run(['disable']);
|
||||
fs.chmodSync(env.GSTACK_HOME, 0o755);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('consent: could not set');
|
||||
expect(r.stdout).toContain('hook: removed');
|
||||
});
|
||||
|
||||
test('usage: no verb exits 1 with usage on stderr; -h exits 0 with usage on stdout', () => {
|
||||
const none = run([]);
|
||||
expect(none.status).toBe(1);
|
||||
expect(none.stderr).toContain('Usage: gstack-memorable');
|
||||
const help = run(['-h']);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain('Usage: gstack-memorable');
|
||||
});
|
||||
|
||||
test('status names the Windows deferral and counts real receipts for the sink', () => {
|
||||
expect(run(['status'], { GSTACK_MEMORABLE_TEST_UNAME: 'MINGW64_NT-10.0' }).stdout).toContain('platform: Windows is not supported');
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const w = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-egress-receipt'), 'write', '--sink', 'memorable-recall', '--host', 'local:/x/memorable', '--class', 'c', '--no-payload', '--consent', 'memorable_recall=on'], { env, encoding: 'utf8', timeout: 20_000 });
|
||||
expect(w.status).toBe(0);
|
||||
}
|
||||
expect(run(['status']).stdout).toContain('receipts: 2 for sink memorable-recall');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle lock and static pins', () => {
|
||||
test('two concurrent enables serialise: one entry, gate on, both exit 0', async () => {
|
||||
const kids = [0, 1].map(() => Bun.spawn(['bash', BIN, 'enable'], { env, stdout: 'pipe', stderr: 'pipe' }));
|
||||
|
||||
Reference in New Issue
Block a user