Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/keiko-contracts/src/git-commit-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 9 additions & 1 deletion packages/keiko-contracts/src/git-commit-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down
114 changes: 114 additions & 0 deletions packages/keiko-server/src/gitDelivery/commitRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,30 @@ describe("commit routes — central enforcement (real dispatch)", () => {
});
expect(res.status).toBe(403);
});

function postExec(body: unknown): Promise<Response> {
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)", () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading