diff --git a/packages/keiko-contracts/src/git-commit-policy.test.ts b/packages/keiko-contracts/src/git-commit-policy.test.ts index b9bedba1..28eccf78 100644 --- a/packages/keiko-contracts/src/git-commit-policy.test.ts +++ b/packages/keiko-contracts/src/git-commit-policy.test.ts @@ -103,6 +103,16 @@ describe("validateGitCommitMessage per-violation cases", () => { expect(violationsOf("feat(ui): add flow", emptyPattern)).not.toContain("missing-issue-key"); }); + it("stays total and fails closed when an enabled issue-key pattern is not a valid regex", () => { + const brokenPattern: GitCommitMessagePolicy = { + ...DEFAULT, + requireIssueKey: { enabled: true, pattern: "[unterminated(" }, + }; + // A pure validator must never throw on a well-typed input; an unparseable enabled pattern blocks. + expect(() => validateGitCommitMessage("feat(ui): add flow #475", brokenPattern)).not.toThrow(); + expect(violationsOf("feat(ui): add flow #475", brokenPattern)).toContain("missing-issue-key"); + }); + it("missing-signoff only when sign-off is required", () => { const policy: GitCommitMessagePolicy = { ...DEFAULT, requireSignoff: true }; expect(violationsOf("feat(ui): add flow", policy)).toContain("missing-signoff"); diff --git a/packages/keiko-contracts/src/git-commit-policy.ts b/packages/keiko-contracts/src/git-commit-policy.ts index 168f359e..6b9c4e68 100644 --- a/packages/keiko-contracts/src/git-commit-policy.ts +++ b/packages/keiko-contracts/src/git-commit-policy.ts @@ -186,7 +186,15 @@ function checkIssueKey( if (!rule.enabled || rule.pattern.length === 0) { return undefined; } - const re = new RegExp(rule.pattern); + let re: RegExp; + try { + re = new RegExp(rule.pattern); + } catch { + // The rule is enabled but its pattern is not a valid regex (operator misconfiguration). Keep the + // validator total (a pure function must never throw on a well-typed input) AND fail closed: a key + // requirement that cannot be evaluated must block rather than silently pass. + return "missing-issue-key"; + } return re.test(message) ? undefined : "missing-issue-key"; } diff --git a/packages/keiko-server/src/gitDelivery/commitRoutes.test.ts b/packages/keiko-server/src/gitDelivery/commitRoutes.test.ts index c7f38caf..870b930c 100644 --- a/packages/keiko-server/src/gitDelivery/commitRoutes.test.ts +++ b/packages/keiko-server/src/gitDelivery/commitRoutes.test.ts @@ -221,6 +221,30 @@ describe("commit routes — central enforcement (real dispatch)", () => { }); expect(res.status).toBe(403); }); + + function postExec(body: unknown): Promise { + return fetch(`http://${UI_HOST}:${String(port)}${EXECUTE}`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Keiko-CSRF": "1" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + } + + it("rejects validation failures before execution", async () => { + expect( + (await postExec({ schemaVersion: "1", projectId, message: "feat: x", evil: 1 })).status, + ).toBe(400); + expect( + (await postExec({ schemaVersion: "1", projectId, message: "Authorization: Bearer abc" })) + .status, + ).toBe(400); + expect( + (await postExec({ schemaVersion: "1", projectId: "/no/such", message: "feat: x" })).status, + ).toBe(404); + const big = JSON.stringify({ schemaVersion: "1", projectId, message: "x".repeat(70 * 1024) }); + expect((await postExec(big)).status).toBe(413); + expect((await postExec("{ not json")).status).toBe(400); + }); }); describe("commit preview — read-only verification context (AC3)", () => { @@ -326,4 +350,94 @@ describe("commit execute — message policy gate + no-bypass (AC2/AC4/AC5)", () ); expect(adapter.calls()).toEqual([]); }); + + it("commits with allowEmpty and honours a granted approval object", async () => { + const adapter = recordingAdapter(); + const handler = createHandleCommitExecute({ + execution: seams({ adapterFactory: () => adapter.adapter }), + }); + const res = await handler( + ctxFor(EXECUTE, { + schemaVersion: "1", + projectId, + message: "chore: empty", + allowEmpty: true, + approval: { required: false }, + }), + deps(), + ); + expect((res.body as { status: string }).status).toBe("succeeded"); + expect(adapter.calls()).toEqual(["commit"]); + }); + + it("holds for approval when the trusted pack is approval-gated", async () => { + const adapter = recordingAdapter(); + const approvalGated: GitDeliveryRepoPolicyPack = { + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "repo", + rules: [{ actionKind: "commit", decision: "approval-gated", requiredApprovers: ["lead"] }], + defaultRule: { decision: "blocked" }, + }; + const handler = createHandleCommitExecute({ + execution: seams({ + adapterFactory: () => adapter.adapter, + policyPacks: { repoPack: approvalGated }, + }), + }); + const res = await handler( + ctxFor(EXECUTE, { schemaVersion: "1", projectId, message: "feat(ui): add flow" }), + deps(), + ); + expect((res.body as { status: string }).status).toBe("approval-required"); + expect(adapter.calls()).toEqual([]); + }); + + it("returns 409 worktree-unavailable when the live snapshot cannot be read", async () => { + const handler = createHandleCommitExecute({ + execution: seams({ snapshotReader: () => Promise.reject(new Error("not a git repo")) }), + }); + const res = await handler( + ctxFor(EXECUTE, { schemaVersion: "1", projectId, message: "feat(ui): add flow" }), + deps(), + ); + expect(res.status).toBe(409); + }); + + it("rejects a malformed approval and an oversized/invalid body", async () => { + const handler = createHandleCommitExecute({ execution: seams() }); + const bad = await handler( + ctxFor(EXECUTE, { + schemaVersion: "1", + projectId, + message: "feat: x", + approval: { required: "yes" }, + }), + deps(), + ); + expect(bad.status).toBe(400); + }); +}); + +describe("commit preview — default draft, policy block, and worktree failure", () => { + it("defaults an absent messageDraft to empty and reports a policy block reason", async () => { + const handler = createHandleCommitPreview({ + execution: seams({ policyPacks: { repoPack: BLOCK_ALL_PACK } }), + }); + const res = await handler(ctxFor(PREVIEW, { schemaVersion: "1", projectId }), deps()); + const body = res.body as GitDeliveryCommitPreviewBody & { policyBlockReason?: string }; + expect(body.policyOutcome).toBe("blocked"); + expect(body.policyBlockReason).toBeDefined(); + expect(body.messageValidation.ok).toBe(false); // empty draft → empty-subject + }); + + it("returns 409 when the worktree cannot be read", async () => { + const handler = createHandleCommitPreview({ + execution: seams({ snapshotReader: () => Promise.reject(new Error("not a git repo")) }), + }); + const res = await handler( + ctxFor(PREVIEW, { schemaVersion: "1", projectId, messageDraft: "feat: x" }), + deps(), + ); + expect(res.status).toBe(409); + }); }); diff --git a/packages/keiko-server/src/gitDelivery/execution.test.ts b/packages/keiko-server/src/gitDelivery/execution.test.ts new file mode 100644 index 00000000..bcede6a7 --- /dev/null +++ b/packages/keiko-server/src/gitDelivery/execution.test.ts @@ -0,0 +1,309 @@ +// Integration coverage for the governed local Git execution core (Issue #475, Epic #470). Exercises +// executeGovernedMutation with its DEFAULT seams (the real node adapter + the real read-only snapshot +// reader) against a disposable hermetic git repository, plus the pure response projection across every +// outcome status. This proves the whole local-execution stack end-to-end and covers the default-seam +// branches the route tests inject around. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { WorkspaceInfo } from "@oscharko-dev/keiko-workspace"; +import { + GIT_DELIVERY_POLICY_SCHEMA_VERSION, + GIT_DELIVERY_SCHEMA_VERSION, + type GitDeliveryRepoPolicyPack, +} from "@oscharko-dev/keiko-contracts"; +import type { GitMutationLifecycleResult } from "@oscharko-dev/keiko-tools"; +import { buildRedactor } from "../index.js"; +import type { EvidenceStore } from "@oscharko-dev/keiko-evidence"; +import { createInMemoryUiStore } from "../store/index.js"; +import { + executeGovernedMutation, + gitDeliveryMutationResponse, + KEIKO_DEFAULT_LOCAL_GIT_POLICY_PACK, + resolveProjectWorkspace, + type GitDeliveryExecutionSeams, +} from "./execution.js"; + +let root: string; + +function git(args: readonly string[]): string { + return execFileSync("git", [...args], { cwd: root, encoding: "utf8" }); +} + +function workspaceInfo(rootPath: string): WorkspaceInfo { + return { + root: rootPath, + name: undefined, + version: undefined, + testFramework: "unknown", + sourceDirs: [], + testDirs: [], + languages: [], + ignoreLines: [], + }; +} + +const ALLOW_LOCAL: GitDeliveryRepoPolicyPack = { + schemaVersion: GIT_DELIVERY_POLICY_SCHEMA_VERSION, + repoId: "repo", + rules: [], + defaultRule: { + decision: "constrained", + constraints: [{ kind: "risk-class-ceiling", maxRiskClass: "local-mutation" }], + }, +}; + +function captureStore(): { store: EvidenceStore; count: () => number } { + const docs = new Map(); + return { + store: { + put: (id, json): string => { + docs.set(id, json); + return id; + }, + list: () => [...docs.keys()], + get: (id) => docs.get(id), + delete: (id) => docs.delete(id), + }, + count: (): number => { + let n = 0; + for (const json of docs.values()) { + const doc = JSON.parse(json) as { records?: unknown[] }; + n += Array.isArray(doc.records) ? doc.records.length : 0; + } + return n; + }, + }; +} + +// Real adapter + real snapshot reader: only the trusted policy pack is supplied (no adapter/reader/now +// seam), exercising the default-seam branches and the live read-only inspection + mutation boundary. +const REAL_SEAMS: GitDeliveryExecutionSeams = { policyPacks: { repoPack: ALLOW_LOCAL } }; + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "keiko-gd-exec-"))); + git(["init", "-q", "-b", "main"]); + git(["config", "user.email", "test@keiko.example"]); + git(["config", "user.name", "Keiko Test"]); + git(["config", "commit.gpgsign", "false"]); + writeFileSync(join(root, "a.txt"), "v1\n", "utf8"); + git(["add", "a.txt"]); + git(["commit", "-q", "-m", "base"]); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("executeGovernedMutation — real git through the default seams", () => { + it("creates a branch and records evidence", async () => { + const cap = captureStore(); + const deps = { evidenceStore: cap.store, redactor: buildRedactor({}) }; + const result = await executeGovernedMutation( + { + kind: "branch-create", + branchName: "feature/x", + baseBranchName: "main", + startPointRefHash: "HEAD", + }, + { required: false }, + workspaceInfo(root), + deps, + REAL_SEAMS, + ); + expect(result.outcome.status).toBe("succeeded"); + expect(git(["branch", "--list", "feature/x"])).toContain("feature/x"); + expect(cap.count()).toBe(1); + }); + + it("switches branch, stages a file, and commits — all through the kernel", async () => { + const cap = captureStore(); + const deps = { evidenceStore: cap.store, redactor: buildRedactor({}) }; + git(["branch", "feature/y"]); + const ws = workspaceInfo(root); + + const switched = await executeGovernedMutation( + { kind: "branch-switch", branchName: "feature/y" }, + { required: false }, + ws, + deps, + REAL_SEAMS, + ); + expect(switched.outcome.status).toBe("succeeded"); + expect(git(["rev-parse", "--abbrev-ref", "HEAD"]).trim()).toBe("feature/y"); + + writeFileSync(join(root, "b.txt"), "x\n", "utf8"); + const staged = await executeGovernedMutation( + { kind: "stage", pathspecs: ["b.txt"], includeUntracked: true }, + { required: false }, + ws, + deps, + REAL_SEAMS, + ); + expect(staged.outcome.status).toBe("succeeded"); + + const committed = await executeGovernedMutation( + { kind: "commit", message: "feat: add b", allowEmpty: false }, + { required: false }, + ws, + deps, + REAL_SEAMS, + ); + expect(committed.outcome.status).toBe("succeeded"); + expect(git(["log", "--oneline"])).toContain("feat: add b"); + expect(cap.count()).toBe(3); + }); + + it("blocks a commit at preflight when nothing is staged (no mutation)", async () => { + const cap = captureStore(); + const deps = { evidenceStore: cap.store, redactor: buildRedactor({}) }; + const result = await executeGovernedMutation( + { kind: "commit", message: "feat: nothing", allowEmpty: false }, + { required: false }, + workspaceInfo(root), + deps, + REAL_SEAMS, + ); + expect(result.outcome.status).toBe("blocked"); + expect(gitDeliveryMutationResponse(result).preflightFindingCodes).toContain( + "nothing-staged-to-commit", + ); + }); + + it("uses the default policy pack (local-mutation permitted) when no packs are injected", async () => { + const cap = captureStore(); + const deps = { evidenceStore: cap.store, redactor: buildRedactor({}) }; + // No seams at all → default node adapter, default reader, default clock/id, default local pack. + const result = await executeGovernedMutation( + { + kind: "branch-create", + branchName: "feature/z", + baseBranchName: "main", + startPointRefHash: "HEAD", + }, + { required: false }, + workspaceInfo(root), + deps, + {}, + ); + expect(result.outcome.status).toBe("succeeded"); + expect(KEIKO_DEFAULT_LOCAL_GIT_POLICY_PACK.defaultRule?.decision).toBe("constrained"); + }); + + it("surfaces a worktree read failure as a thrown error outside a git repository", async () => { + const bare = realpathSync(mkdtempSync(join(tmpdir(), "keiko-gd-nonrepo-"))); + const deps = { evidenceStore: captureStore().store, redactor: buildRedactor({}) }; + try { + await expect( + executeGovernedMutation( + { kind: "branch-switch", branchName: "main" }, + { required: false }, + workspaceInfo(bare), + deps, + REAL_SEAMS, + ), + ).rejects.toBeTruthy(); + } finally { + rmSync(bare, { recursive: true, force: true }); + } + }); +}); + +// ─── Pure response projection across every outcome status ──────────────────────────────────────── + +function lifecycle(outcome: GitMutationLifecycleResult["outcome"]): GitMutationLifecycleResult { + return { + envelope: { + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + actionId: "a1", + kind: "commit", + resolvedInputs: { + kind: "commit", + messageByteLength: 4, + stagedPathCount: 1, + allowEmptyCommit: false, + }, + policyDecision: { outcome: "allowed" }, + approvalRequirement: { required: false }, + preview: { + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + wouldCreateRemoteBranch: false, + wouldTriggerChecks: false, + }, + }, + outcome, + phaseReached: "result", + preflight: { ok: true, findings: [], blocking: [], advisory: [] }, + }; +} + +const exec = { + schemaVersion: GIT_DELIVERY_SCHEMA_VERSION, + outcome: "failed" as const, + durationMs: 1, + errorCode: "internal-error" as const, +}; + +describe("gitDeliveryMutationResponse — content-free projection of every outcome", () => { + it("succeeded", () => { + expect( + gitDeliveryMutationResponse( + lifecycle({ + status: "succeeded", + executionResult: { ...exec, outcome: "succeeded", errorCode: undefined }, + }), + ).status, + ).toBe("succeeded"); + }); + it("approval-required carries the required approvers", () => { + const body = gitDeliveryMutationResponse( + lifecycle({ status: "approval-required", requiredApprovers: ["lead"] }), + ); + expect(body.status).toBe("approval-required"); + expect(body.requiredApprovers).toEqual(["lead"]); + }); + it("policy-block carries the typed block reason", () => { + const body = gitDeliveryMutationResponse( + lifecycle({ + status: "blocked", + category: "policy-block", + blockReason: "policy-pack-blocked", + }), + ); + expect(body.blockReason).toBe("policy-pack-blocked"); + }); + it("failed and recovery-required carry the execution error code", () => { + expect( + gitDeliveryMutationResponse( + lifecycle({ status: "failed", category: "execution-failure", executionResult: exec }), + ).executionErrorCode, + ).toBe("internal-error"); + expect( + gitDeliveryMutationResponse( + lifecycle({ + status: "recovery-required", + category: "recovery-required", + executionResult: exec, + }), + ).executionErrorCode, + ).toBe("internal-error"); + }); +}); + +describe("resolveProjectWorkspace", () => { + it("resolves a registered project path and rejects an unknown one", () => { + const store = createInMemoryUiStore(); + const dir = realpathSync(mkdtempSync(join(tmpdir(), "keiko-gd-rp-"))); + try { + const proj = store.createProject(dir); + expect(resolveProjectWorkspace({ store }, proj.path)?.root).toBe(proj.path); + expect(resolveProjectWorkspace({ store }, "/repo/missing")).toBeUndefined(); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/keiko-server/src/gitDelivery/localMutationRoutes.test.ts b/packages/keiko-server/src/gitDelivery/localMutationRoutes.test.ts index d7224df1..c4101ad8 100644 --- a/packages/keiko-server/src/gitDelivery/localMutationRoutes.test.ts +++ b/packages/keiko-server/src/gitDelivery/localMutationRoutes.test.ts @@ -26,9 +26,10 @@ import { createUiServer, UI_HOST } from "../server.js"; import { buildCspHeader } from "../csp.js"; import { buildRedactor, createRunRegistry, type UiHandlerDeps } from "../index.js"; import { createInMemoryUiStore, type UiStore } from "../store/index.js"; -import type { RouteContext } from "../routes.js"; +import type { RouteContext, RouteResult } from "../routes.js"; import type { EvidenceStore } from "@oscharko-dev/keiko-evidence"; import { + createGitDeliveryLocalMutationRouteGroup, createHandleLocalMutation, type GitDeliveryLocalErrorBody, } from "./localMutationRoutes.js"; @@ -407,3 +408,78 @@ describe("local mutation routes — governed execution (direct handler + seams)" expect((res.body as { status: string }).status).toBe("succeeded"); }); }); + +describe("local mutation routes — real specs through the route group (direct handler + seams)", () => { + function handlerFor( + pattern: string, + s: GitDeliveryExecutionSeams, + ): (ctx: RouteContext, deps: UiHandlerDeps) => Promise { + const group = createGitDeliveryLocalMutationRouteGroup({ execution: s }); + const def = group.find((d) => d.pattern === pattern); + if (def === undefined) throw new Error(`no route for ${pattern}`); + return def.handler as (ctx: RouteContext, deps: UiHandlerDeps) => Promise; + } + + const CREATE = "/api/git-delivery/local-branch/create"; + const UNSTAGE = "/api/git-delivery/staging/unstage"; + + it("creates a branch via the real branch-create spec", async () => { + const adapter = recordingAdapter(); + const res = await handlerFor(CREATE, seams({ adapterFactory: () => adapter.adapter }))( + ctxFor(CREATE, { + schemaVersion: "1", + projectId, + branchName: "feature/new", + baseBranchName: "main", + startPointRefHash: "HEAD", + }), + deps(), + ); + expect((res.body as { status: string }).status).toBe("succeeded"); + expect(adapter.calls()).toEqual(["createBranch"]); + }); + + it("stages and unstages via the real staging specs", async () => { + const a1 = recordingAdapter(); + const staged = await handlerFor(STAGE, seams({ adapterFactory: () => a1.adapter }))( + ctxFor(STAGE, { schemaVersion: "1", projectId, pathspecs: ["src/a.ts"], includeUntracked: false }), + deps(), + ); + expect((staged.body as { status: string }).status).toBe("succeeded"); + expect(a1.calls()).toEqual(["stage"]); + + const a2 = recordingAdapter(); + const unstaged = await handlerFor(UNSTAGE, seams({ adapterFactory: () => a2.adapter }))( + ctxFor(UNSTAGE, { schemaVersion: "1", projectId, pathspecs: ["src/a.ts"] }), + deps(), + ); + expect((unstaged.body as { status: string }).status).toBe("succeeded"); + expect(a2.calls()).toEqual(["unstage"]); + }); + + it("400s when a required field is missing or malformed", async () => { + const missing = await handlerFor(CREATE, seams())( + ctxFor(CREATE, { schemaVersion: "1", projectId, branchName: "x" }), + deps(), + ); + expect(missing.status).toBe(400); + const badApproval = await handlerFor(SWITCH, seams())( + ctxFor(SWITCH, { schemaVersion: "1", projectId, branchName: "feature/x", approval: { required: "no" } }), + deps(), + ); + expect(badApproval.status).toBe(400); + }); + + it("returns 409 worktree-unavailable when the live snapshot cannot be read", async () => { + const res = await handlerFor( + SWITCH, + seams({ snapshotReader: () => Promise.reject(new Error("not a git repo")) }), + )(ctxFor(SWITCH, { schemaVersion: "1", projectId, branchName: "feature/x" }), deps()); + expect(res.status).toBe(409); + }); + + it("400s an unparseable JSON body", async () => { + const res = await handlerFor(SWITCH, seams())(ctxFor(SWITCH, "{ not json"), deps()); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/keiko-server/src/gitDelivery/requestGuards.ts b/packages/keiko-server/src/gitDelivery/requestGuards.ts index e0c1e005..a7b80370 100644 Binary files a/packages/keiko-server/src/gitDelivery/requestGuards.ts and b/packages/keiko-server/src/gitDelivery/requestGuards.ts differ