import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createSkillContext, readSkillContext, clearSkillContext, isContextExpired, } from "../src/enforcement/skill-context.js"; import { appendTrace, readTrace, countBlocked } from "../src/enforcement/skill-trace.js"; import { checkGuard } from "../src/enforcement/guard.js"; let testDir: string; beforeEach(async () => { testDir = await mkdtemp(join(tmpdir(), "rails-test-")); }); afterEach(async () => { await rm(testDir, { recursive: true, force: true }); }); describe("skill-context", () => { it("creates and reads context", async () => { const ctx = await createSkillContext(testDir, { skillName: "rails", pipelineId: "01TEST", ttlSeconds: 300, }); expect(ctx.skillName).toBe("rails"); expect(ctx.pipelineId).toBe("01TEST"); const read = await readSkillContext(testDir); expect(read).not.toBeNull(); expect(read!.skillName).toBe("rails"); }); it("returns null when no context exists", async () => { const read = await readSkillContext(testDir); expect(read).toBeNull(); }); it("clears context", async () => { await createSkillContext(testDir, { skillName: "rails", ttlSeconds: 300 }); const cleared = await clearSkillContext(testDir); expect(cleared).toBe(true); const read = await readSkillContext(testDir); expect(read).toBeNull(); }); it("detects expired context", () => { const ctx = { skillName: "rails", subcommand: "", pipelineId: "", contractId: "", sessionId: "", createdAt: new Date(Date.now() - 400_000).toISOString(), // 400s ago ttlSeconds: 300, }; expect(isContextExpired(ctx)).toBe(true); }); it("detects valid context", () => { const ctx = { skillName: "rails", subcommand: "", pipelineId: "", contractId: "", sessionId: "", createdAt: new Date().toISOString(), ttlSeconds: 300, }; expect(isContextExpired(ctx)).toBe(false); }); }); describe("skill-trace", () => { it("appends and reads trace entries", async () => { await appendTrace(testDir, { ts: Date.now(), tool: "Write", blocked: false, reason: "ok", }); await appendTrace(testDir, { ts: Date.now(), tool: "Bash", blocked: true, reason: "no-context", }); const entries = await readTrace(testDir); expect(entries).toHaveLength(2); expect(entries[1]!.blocked).toBe(true); }); it("counts blocked entries", async () => { await appendTrace(testDir, { ts: Date.now(), tool: "Write", blocked: false, reason: "ok" }); await appendTrace(testDir, { ts: Date.now(), tool: "Edit", blocked: true, reason: "no-ctx" }); await appendTrace(testDir, { ts: Date.now(), tool: "Bash", blocked: true, reason: "expired" }); expect(await countBlocked(testDir)).toBe(2); }); it("returns empty array when no trace file", async () => { expect(await readTrace(testDir)).toEqual([]); }); }); describe("guard", () => { it("blocks when no context exists", async () => { const result = await checkGuard(testDir, "Write"); expect(result.allowed).toBe(false); expect(result.reason).toContain("No skill context"); }); it("allows when valid context exists", async () => { await createSkillContext(testDir, { skillName: "rails", ttlSeconds: 300, }); const result = await checkGuard(testDir, "Write"); expect(result.allowed).toBe(true); expect(result.reason).toBe("ok"); }); it("blocks when context is expired", async () => { await createSkillContext(testDir, { skillName: "rails", ttlSeconds: 1, // 1 second TTL }); // Wait just over 1 second await new Promise((r) => setTimeout(r, 1100)); const result = await checkGuard(testDir, "Edit"); expect(result.allowed).toBe(false); expect(result.reason).toContain("expired"); }); it("allows when RAILS_ENFORCE=off", async () => { process.env["RAILS_ENFORCE"] = "off"; try { const result = await checkGuard(testDir, "Bash"); expect(result.allowed).toBe(true); expect(result.reason).toBe("enforcement-off"); } finally { delete process.env["RAILS_ENFORCE"]; } }); it("records blocked calls in trace", async () => { await checkGuard(testDir, "Write"); const entries = await readTrace(testDir); expect(entries.some((e) => e.blocked && e.tool === "Write")).toBe(true); }); });