diff --git a/test/gbrain-detection-override.test.ts b/test/gbrain-detection-override.test.ts index 07d68577f..c4905d897 100644 --- a/test/gbrain-detection-override.test.ts +++ b/test/gbrain-detection-override.test.ts @@ -136,6 +136,27 @@ describe('gbrain detection override → gen-skill-docs', () => { } }); + test('with status "timeout" (slow-but-healthy, #1964), brain blocks render like "ok"', () => { + const { tmpHome, cleanup } = makeFixture( + JSON.stringify({ gbrain_local_status: 'timeout', gbrain_on_path: true, gbrain_version: 'test-0.41.0' }), + ); + try { + const snap = regenAndSnapshot({ + respectDetection: true, + tmpHome, + files: PROBE_FILES, + }); + const content = probeUnion(snap); + + // A slow engine must not silently suppress brain features — same + // treatment as "ok" (matches gstack-gbrain-detect --is-ok). + expect(content).toContain('## Save Results to Brain'); + expect(content).toContain('gbrain put "office-hours/'); + } finally { + cleanup(); + } + }); + test('with detected:false (status != "ok"), brain blocks stay suppressed', () => { const { tmpHome, cleanup } = makeFixture( JSON.stringify({ gbrain_local_status: 'no-cli', gbrain_on_path: false, gbrain_version: null }), diff --git a/test/gbrain-local-status.test.ts b/test/gbrain-local-status.test.ts index d85e3ea89..b3d3e398e 100644 --- a/test/gbrain-local-status.test.ts +++ b/test/gbrain-local-status.test.ts @@ -391,6 +391,20 @@ describe("lib/gbrain-local-status — cache behavior", () => { expect(localEngineStatus({ noCache: false })).toBe("ok"); }); + it("invalidates cache when GBRAIN_HOME changes (key invariant, codex D11)", () => { + env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true }); + restoreEnv = applyEnv(env); + expect(localEngineStatus({ noCache: false })).toBe("ok"); + + // Point GBRAIN_HOME at an empty dir: a stale cached "ok" must not win — + // gbrain_home is part of the cache key, so this re-probes and finds no + // config at the new location. + const altHome = join(env.tmp, "alt-gbrain-empty"); + mkdirSync(altHome, { recursive: true }); + process.env.GBRAIN_HOME = altHome; + expect(localEngineStatus({ noCache: false })).toBe("missing-config"); + }); + it("invalidates cache when HOME changes (key invariant)", () => { env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true }); restoreEnv = applyEnv(env); diff --git a/test/gbrain-sync-skip.test.ts b/test/gbrain-sync-skip.test.ts index 88ce924a8..1d7423c98 100644 --- a/test/gbrain-sync-skip.test.ts +++ b/test/gbrain-sync-skip.test.ts @@ -152,6 +152,36 @@ describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { } }, 30_000); // proceeding runs the real code-import path against the slow fake (~11s) + it("memory stage also PROCEEDS (with warning) on probe timeout (#1964)", () => { + const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true }); + try { + const r = runOrchestrator(env, ["--no-code", "--no-brain-sync"], { + GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300", + }); + const out = r.stdout + r.stderr; + expect(out).not.toContain("local engine timeout"); + expect(out).toContain("memory: engine probe timed out"); + } finally { + env.cleanup(); + } + }, 30_000); + + it("dream stage also PROCEEDS (with warning) on probe timeout (#1964)", () => { + const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true }); + try { + const r = runOrchestrator( + env, + ["--dream", "--no-code", "--no-memory", "--no-brain-sync"], + { GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300" }, + ); + const out = r.stdout + r.stderr; + expect(out).not.toContain("local engine timeout"); + expect(out).toContain("dream: engine probe timed out"); + } finally { + env.cleanup(); + } + }, 30_000); + it("SKIPs code stage when local engine is broken-db; brain-sync still attempted", () => { const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true }); try { diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index 75908a983..88b302db6 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -12,6 +12,7 @@ import { exitCodeFor, maskPreview, normalizeWithMap, + redactFindingSpans, type RepoVisibility, } from "../lib/redact-engine"; import { @@ -364,6 +365,52 @@ describe("masking + purity", () => { }); }); +describe("redactFindingSpans — machine-egress masking (#1947)", () => { + test("clean input passes through unchanged", () => { + const text = "push failed: remote rejected the branch"; + expect(redactFindingSpans(text, { repoVisibility: "private" })).toBe(text); + }); + + test("a single finding's span becomes , context survives", () => { + const token = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz"; + const out = redactFindingSpans(`auth ${token} rejected`, { repoVisibility: "private" }); + expect(out).toBe("auth rejected"); + }); + + test("multiple findings are all replaced (right-to-left splice keeps offsets valid)", () => { + const pat = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz"; + const aws = "AKIA1234567890ABCDEF"; + const out = redactFindingSpans(`first ${aws} then ${pat} end`, { + repoVisibility: "private", + }); + expect(out).toBe("first then end"); + }); + + test("fails closed (null) when a span cannot be relocated — never raw passthrough", () => { + // env.kv's span (the value) starts well past the regex match start (the + // var name), so locateSpan's rewind-2 re-exec misses it. The contract is + // null → caller drops the whole payload. The one thing that must never + // happen is the secret surviving in the output. + const secret = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq"; + const out = redactFindingSpans(`API_KEY=${secret}`, { repoVisibility: "private" }); + if (out !== null) { + // If locateSpan ever learns to find context-prefixed spans, masking + // must actually mask. + expect(out).not.toContain(secret); + } else { + expect(out).toBeNull(); + } + }); + + test("multiline input redacts a finding past the first line (locateSpan line/col path)", () => { + const token = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz"; + const out = redactFindingSpans(`line one\nline two has ${token}\nline three`, { + repoVisibility: "private", + }); + expect(out).toBe("line one\nline two has \nline three"); + }); +}); + describe("taxonomy integrity", () => { test("every pattern has a unique id", () => { const set = new Set(PATTERNS.map((p) => p.id)); diff --git a/test/security-dashboard-fallback.test.ts b/test/security-dashboard-fallback.test.ts index 55cba86d9..f23a6800e 100644 --- a/test/security-dashboard-fallback.test.ts +++ b/test/security-dashboard-fallback.test.ts @@ -174,6 +174,14 @@ describe("gstack-security-dashboard — never reports fake zeros (#1947)", () => expect(parsed.security.top_attack_domains[0].domain).toBe("evil.example"); }); + it("stale cache responses pass the stale flag through (json mode)", () => { + const staleBody = JSON.stringify({ ...JSON.parse(GOOD_BODY_MARKER), stale: true }); + const r = run(SEC_BIN, { mode: "ok", body: staleBody, json: true }); + const parsed = JSON.parse(r.stdout.trim()); + expect(parsed.status).toBe("ok"); + expect(parsed.stale).toBe(true); + }); + it("200 without marker (legacy backend) → figures shown with unverified note", () => { const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_LEGACY }); expect(r.stdout).toContain("Attacks detected last 7 days: 3"); @@ -186,6 +194,18 @@ describe("gstack-security-dashboard — never reports fake zeros (#1947)", () => expect(parsed.status).toBe("legacy_unverified"); expect(parsed.security.attacks_last_7_days).toBe(3); }); + + it("200 with a body missing .security → unknown backend_error, never 0", () => { + const r = run(SEC_BIN, { + mode: "ok", + body: JSON.stringify({ weekly_active: 42, status: "ok" }), + json: true, + }); + const parsed = JSON.parse(r.stdout.trim()); + expect(parsed.status).toBe("unknown"); + expect(parsed.reason).toBe("backend_error"); + expect(parsed.security).toBeNull(); + }); }); describe("gstack-community-dashboard — never reports fake zeros (#1947)", () => { @@ -212,4 +232,10 @@ describe("gstack-community-dashboard — never reports fake zeros (#1947)", () = expect(r.stdout).toContain("Weekly active installs: 42"); expect(r.stdout).toContain("unverified"); }); + + it("200 with a garbage body (no weekly_active) → unknown, never 0", () => { + const r = run(COMM_BIN, { mode: "ok", body: '{"error":"weird"}' }); + expect(r.stdout).toContain("unknown — backend error (HTTP 200)"); + expect(r.stdout).not.toContain("Weekly active installs:"); + }); }); diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 63c9e67f3..33e1db241 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -236,6 +236,36 @@ describe('gstack-telemetry-log', () => { expect(lines[0]).not.toContain(token); }); + test('truncates error_message to 200 chars after redaction (#1947)', () => { + setConfig('telemetry', 'anonymous'); + const long = 'x'.repeat(300); + run( + `${BIN}/gstack-telemetry-log --skill qa --duration 10 --outcome error --error-message '${long}' --session-id red-3`, + ); + + const events = parseJsonl(); + expect(events).toHaveLength(1); + expect(events[0].error_message.length).toBeLessThanOrEqual(200); + }); + + test('fails closed: error_message becomes null when the engine cannot relocate a span (#1947)', () => { + setConfig('telemetry', 'anonymous'); + const secret = '8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq'; + // env.kv-shaped finding (line-anchored, so the assignment leads the + // message): the span (value) starts past the regex match start, + // locateSpan misses it, redactFindingSpans returns null — the bin must + // drop the whole message, never pass it through raw. + run( + `${BIN}/gstack-telemetry-log --skill qa --duration 10 --outcome error --error-message 'API_KEY=${secret} rejected by daemon' --session-id red-4`, + ); + + const lines = readJsonl(); + expect(lines).toHaveLength(1); + const event = JSON.parse(lines[0]); + expect(event.error_message).toBeNull(); + expect(lines[0]).not.toContain(secret); + }); + test('creates analytics directory if missing', () => { // Remove analytics dir const analyticsDir = path.join(tmpDir, 'analytics');