Files
paperclip/tests/e2e/signoff-policy.spec.ts
Dotta 7a329fb8bb Harden API route authorization boundaries (#4122)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The REST API is the control-plane boundary for companies, agents,
plugins, adapters, costs, invites, and issue mutations.
> - Several routes still relied on broad board or company access checks
without consistently enforcing the narrower actor, company, and
active-checkout boundaries those operations require.
> - That can allow agents or non-admin users to mutate sensitive
resources outside the intended governance path.
> - This pull request hardens the route authorization layer and adds
regression coverage for the audited API surfaces.
> - The benefit is tighter multi-company isolation, safer plugin and
adapter administration, and stronger enforcement of active issue
ownership.

## What Changed

- Added route-level authorization checks for budgets, plugin
administration/scoped routes, adapter management, company import/export,
direct agent creation, invite test resolution, and issue mutation/write
surfaces.
- Enforced active checkout ownership for agent-authenticated issue
mutations, while preserving explicit management overrides for permitted
managers.
- Restricted sensitive adapter and plugin management operations to
instance-admin or properly scoped actors.
- Tightened company portability and invite probing routes so agents
cannot cross company boundaries.
- Updated access constants and the Company Access UI copy for the new
active-checkout management grant.
- Added focused regression tests covering cross-company denial, agent
self-mutation denial, admin-only operations, and active checkout
ownership.
- Rebased the branch onto `public-gh/master` and fixed validation
fallout from the rebase: heartbeat-context route ordering and a company
import/export e2e fixture that now opts out of direct-hire approval
before using direct agent creation.
- Updated onboarding and signoff e2e setup to create seed agents through
`/agent-hires` plus board approval, so they remain compatible with the
approval-gated new-agent default.
- Addressed Greptile feedback by removing a duplicate company export API
alias, avoiding N+1 reporting-chain lookups in active-checkout override
checks, allowing agent mutations on unassigned `in_progress` issues, and
blocking NAT64 invite-probe targets.

## Verification

- `pnpm exec vitest run
server/src/__tests__/issues-goal-context-routes.test.ts
cli/src/__tests__/company-import-export-e2e.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/adapter-routes-authz.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/company-portability-routes.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/invite-test-resolution-route.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/agent-adapter-validation-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/invite-test-resolution-route.test.ts`
- `pnpm -r typecheck`
- `pnpm --filter server typecheck`
- `pnpm --filter ui typecheck`
- `pnpm build`
- `pnpm test:e2e -- tests/e2e/onboarding.spec.ts
tests/e2e/signoff-policy.spec.ts`
- `pnpm test:e2e -- tests/e2e/signoff-policy.spec.ts`
- `pnpm test:run` was also run. It failed under default full-suite
parallelism with two order-dependent failures in
`plugin-routes-authz.test.ts` and `routines-e2e.test.ts`; both files
passed when rerun directly together with `pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/routines-e2e.test.ts`.

## Risks

- Medium risk: this changes authorization behavior across multiple
sensitive API surfaces, so callers that depended on broad board/company
access may now receive `403` or `409` until they use the correct
governance path.
- Direct agent creation now respects the company-level board-approval
requirement; integrations that need pending hires should use
`/api/companies/:companyId/agent-hires`.
- Active in-progress issue mutations now require checkout ownership or
an explicit management override, which may reveal workflow assumptions
in older automation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent, tool-using workflow with local shell,
Git, GitHub CLI, and repository tests.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 10:56:48 -05:00

444 lines
17 KiB
TypeScript

import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test";
/**
* E2E: Signoff execution policy flow.
*
* Validates the full signoff lifecycle through the API and UI:
* 1. Create a company with executor + reviewer + approver agents
* 2. Create an issue with a two-stage execution policy (review → approval)
* 3. Executor marks done → issue routes to reviewer (in_review)
* 4. Reviewer approves → issue routes to approver
* 5. Approver approves → execution completes, issue marked done
* 6. Verify "changes requested" flow returns to executor
*
* Requires local_trusted deployment mode (set in playwright.config.ts webServer env).
*
* Agent auth flow:
* - Board request (local_trusted auto-auth) handles setup/teardown.
* - Agent-specific actions use API keys + heartbeat run IDs.
* - Reviewers/approvers invoke heartbeat runs (gets run IDs) then PATCH
* directly without checkout (checkout would force in_progress, breaking
* the in_review state the signoff policy requires).
*/
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
const BASE_URL = `http://127.0.0.1:${PORT}`;
const COMPANY_NAME = `E2E-Signoff-${Date.now()}`;
interface AgentAuth {
agentId: string;
token: string;
keyId: string;
request: APIRequestContext;
}
interface TestContext {
companyId: string;
companyPrefix: string;
executor: AgentAuth;
reviewer: AgentAuth;
approver: AgentAuth;
boardRequest: APIRequestContext;
issueIds: string[];
}
interface IssueRunLockState {
assigneeAgentId: string | null;
checkoutRunId: string | null;
executionRunId: string | null;
}
/** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */
async function createAgentRequest(token: string): Promise<APIRequestContext> {
return pwRequest.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
/** Invoke a heartbeat run for an agent, returning the run ID. */
async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promise<string> {
const res = await board.post(`${BASE_URL}/api/agents/${agentId}/heartbeat/invoke`);
expect(res.ok()).toBe(true);
const run = await res.json();
return run.id;
}
async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise<IssueRunLockState> {
const res = await board.get(`${BASE_URL}/api/issues/${issueId}`);
expect(res.ok()).toBe(true);
const issue = await res.json();
return {
assigneeAgentId: issue.assigneeAgentId ?? null,
checkoutRunId: issue.checkoutRunId ?? null,
executionRunId: issue.executionRunId ?? null,
};
}
/** PATCH an issue as an agent with a fresh heartbeat run ID. */
async function agentPatch(
board: APIRequestContext,
agent: AgentAuth,
issueId: string,
data: Record<string, unknown>,
) {
const runId = await invokeHeartbeat(board, agent.agentId);
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": runId },
data,
});
return res;
}
/** Checkout an issue as an agent, then PATCH it. Used for executor mark-done. */
async function agentCheckoutAndPatch(
board: APIRequestContext,
agent: AgentAuth,
issueId: string,
expectedStatuses: string[],
patchData: Record<string, unknown>,
) {
const runId = await invokeHeartbeat(board, agent.agentId);
// Checkout (sets executionRunId so PATCH is allowed)
const checkoutRes = await agent.request.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
headers: { "X-Paperclip-Run-Id": runId },
data: { agentId: agent.agentId, expectedStatuses },
});
if (!checkoutRes.ok()) {
if (checkoutRes.status() === 409) {
const issueRunLock = await getIssueRunLockState(board, issueId);
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId;
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": lockedRunId ?? runId },
data: patchData,
});
if (res.ok() && issueRunLock.assigneeAgentId === agent.agentId) {
return res;
}
}
// If agent checkout fails (e.g. run expired), fall back to board checkout
// then PATCH with the agent's identity
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
data: { agentId: agent.agentId, expectedStatuses },
});
if (!boardCheckout.ok()) {
throw new Error(`Board checkout failed: ${await boardCheckout.text()}`);
}
// Board PATCH (executor mark-done triggers signoff regardless of actor)
const res = await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
data: patchData,
});
return res;
}
// PATCH with agent identity
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": runId },
data: patchData,
});
return res;
}
async function setupCompany(boardRequest: APIRequestContext): Promise<TestContext> {
// Verify server is in local_trusted mode
const healthRes = await boardRequest.get(`${BASE_URL}/api/health`);
expect(healthRes.ok()).toBe(true);
const health = await healthRes.json();
if (health.deploymentMode !== "local_trusted") {
throw new Error(
`Signoff e2e tests require local_trusted deployment mode, ` +
`but server is in "${health.deploymentMode}" mode. ` +
`Set PAPERCLIP_DEPLOYMENT_MODE=local_trusted or use the webServer config.`,
);
}
// Create company
const companyRes = await boardRequest.post(`${BASE_URL}/api/companies`, {
data: { name: COMPANY_NAME },
});
if (!companyRes.ok()) {
const errBody = await companyRes.text();
throw new Error(`POST /api/companies → ${companyRes.status()}: ${errBody}`);
}
const company = await companyRes.json();
const companyId = company.id;
const companyPrefix = company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E";
// Helper: hire/approve agent + API key + request context
async function createAgent(name: string, role: string, title: string): Promise<AgentAuth> {
const agentRes = await boardRequest.post(`${BASE_URL}/api/companies/${companyId}/agent-hires`, {
data: {
name,
role,
title,
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["-e", "process.stdout.write('done\\n')"],
},
},
});
expect(agentRes.ok()).toBe(true);
const hire = await agentRes.json();
const agent = hire.agent;
if (hire.approval) {
const approvalRes = await boardRequest.post(`${BASE_URL}/api/approvals/${hire.approval.id}/approve`, {
data: { decisionNote: "Approved for signoff e2e setup." },
});
expect(approvalRes.ok()).toBe(true);
}
const keyRes = await boardRequest.post(`${BASE_URL}/api/agents/${agent.id}/keys`, {
data: { name: `e2e-${name.toLowerCase()}` },
});
expect(keyRes.ok()).toBe(true);
const keyData = await keyRes.json();
return {
agentId: agent.id,
token: keyData.token,
keyId: keyData.id,
request: await createAgentRequest(keyData.token),
};
}
const executor = await createAgent("Executor", "engineer", "Software Engineer");
const reviewer = await createAgent("Reviewer", "qa", "QA Engineer");
const approver = await createAgent("Approver", "cto", "CTO");
return {
companyId,
companyPrefix,
executor,
reviewer,
approver,
boardRequest,
issueIds: [],
};
}
async function createIssueWithPolicy(ctx: TestContext, title: string, stages?: unknown[]) {
const defaultStages = [
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
{ type: "approval", participants: [{ type: "agent", agentId: ctx.approver.agentId }] },
];
const res = await ctx.boardRequest.post(`${BASE_URL}/api/companies/${ctx.companyId}/issues`, {
data: {
title,
status: "in_progress",
assigneeAgentId: ctx.executor.agentId,
executionPolicy: { stages: stages ?? defaultStages },
},
});
expect(res.ok()).toBe(true);
const issue = await res.json();
ctx.issueIds.push(issue.id);
return issue;
}
test.describe("Signoff execution policy", () => {
let ctx: TestContext;
test.beforeAll(async () => {
const boardRequest = await pwRequest.newContext({ baseURL: BASE_URL });
ctx = await setupCompany(boardRequest);
});
test.afterAll(async () => {
if (!ctx) return;
const board = ctx.boardRequest;
// Dispose agent request contexts
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
await agent.request.dispose();
}
// Clean up issues, keys, agents, company (best-effort)
for (const issueId of ctx.issueIds) {
await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
data: { status: "cancelled", comment: "E2E test cleanup." },
}).catch(() => {});
}
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}/keys/${agent.keyId}`).catch(() => {});
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}`).catch(() => {});
}
await board.delete(`${BASE_URL}/api/companies/${ctx.companyId}`).catch(() => {});
await board.dispose();
});
test("happy path: executor → review → approval → done", async ({ page }) => {
const issue = await createIssueWithPolicy(ctx, "Signoff happy path");
const issueId = issue.id;
// Verify policy was saved
expect(issue.executionPolicy).toBeTruthy();
expect(issue.executionPolicy.stages).toHaveLength(2);
expect(issue.executionPolicy.stages[0].type).toBe("review");
expect(issue.executionPolicy.stages[1].type).toBe("approval");
// Step 1: Executor marks done → should route to reviewer
const step1Res = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Implemented the feature, ready for review." },
);
expect(step1Res.ok()).toBe(true);
const step1Issue = await step1Res.json();
expect(step1Issue.status).toBe("in_review");
expect(step1Issue.assigneeAgentId).toBe(ctx.reviewer.agentId);
expect(step1Issue.executionState).toBeTruthy();
expect(step1Issue.executionState.status).toBe("pending");
expect(step1Issue.executionState.currentStageType).toBe("review");
expect(step1Issue.executionState.returnAssignee).toMatchObject({
type: "agent",
agentId: ctx.executor.agentId,
});
// Step 2: Navigate to issue in UI and verify execution label
await page.goto(`/${ctx.companyPrefix}/issues/${issue.identifier}`);
await expect(page.locator("text=Review pending")).toBeVisible({ timeout: 10_000 });
// Step 3: Reviewer approves → should route to approver
const step3Res = await agentPatch(
ctx.boardRequest, ctx.reviewer, issueId,
{ status: "done", comment: "QA signoff complete. Looks good." },
);
expect(step3Res.ok()).toBe(true);
const step3Issue = await step3Res.json();
expect(step3Issue.status).toBe("in_review");
expect(step3Issue.assigneeAgentId).toBe(ctx.approver.agentId);
expect(step3Issue.executionState.status).toBe("pending");
expect(step3Issue.executionState.currentStageType).toBe("approval");
expect(step3Issue.executionState.completedStageIds).toHaveLength(1);
// Step 4: Verify UI shows approval pending
await page.reload();
await expect(page.locator("text=Approval pending")).toBeVisible({ timeout: 10_000 });
// Step 5: Approver approves → should complete
const step5Res = await agentPatch(
ctx.boardRequest, ctx.approver, issueId,
{ status: "done", comment: "Approved. Ship it." },
);
expect(step5Res.ok()).toBe(true);
const step5Issue = await step5Res.json();
expect(step5Issue.status).toBe("done");
expect(step5Issue.executionState.status).toBe("completed");
expect(step5Issue.executionState.completedStageIds).toHaveLength(2);
expect(step5Issue.executionState.lastDecisionOutcome).toBe("approved");
});
test("changes requested: reviewer bounces back to executor", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff changes requested");
const issueId = issue.id;
// Executor marks done → routes to reviewer
const doneRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Ready for review." },
);
expect(doneRes.ok()).toBe(true);
expect((await doneRes.json()).status).toBe("in_review");
// Reviewer requests changes → returns to executor
const changesRes = await agentPatch(
ctx.boardRequest, ctx.reviewer, issueId,
{ status: "in_progress", comment: "Needs another pass on edge cases." },
);
expect(changesRes.ok()).toBe(true);
const changesIssue = await changesRes.json();
expect(changesIssue.status).toBe("in_progress");
expect(changesIssue.assigneeAgentId).toBe(ctx.executor.agentId);
expect(changesIssue.executionState.status).toBe("changes_requested");
expect(changesIssue.executionState.lastDecisionOutcome).toBe("changes_requested");
// Executor re-submits → goes back to reviewer (same stage)
const resubmitRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Fixed the edge cases." },
);
expect(resubmitRes.ok()).toBe(true);
const resubmitIssue = await resubmitRes.json();
expect(resubmitIssue.status).toBe("in_review");
expect(resubmitIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
expect(resubmitIssue.executionState.status).toBe("pending");
expect(resubmitIssue.executionState.currentStageType).toBe("review");
});
test("comment required: approval without comment fails", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff comment required");
const issueId = issue.id;
// Executor marks done → routes to reviewer
await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Done." },
);
// Reviewer tries to approve without comment → should fail
const noCommentRes = await agentPatch(
ctx.boardRequest, ctx.reviewer, issueId,
{ status: "done" },
);
expect(noCommentRes.ok()).toBe(false);
const errorBody = await noCommentRes.json();
expect(JSON.stringify(errorBody)).toContain("comment");
});
test("non-participant cannot advance stage", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff access control");
const issueId = issue.id;
// Executor marks done → routes to reviewer
const doneRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Done." },
);
expect(doneRes.ok()).toBe(true);
// Verify issue is in_review with reviewer
const issueRes = await ctx.boardRequest.get(`${BASE_URL}/api/issues/${issueId}`);
const inReviewIssue = await issueRes.json();
expect(inReviewIssue.status).toBe("in_review");
expect(inReviewIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
expect(inReviewIssue.executionState.currentStageType).toBe("review");
// Non-participant (approver at this stage) tries to advance → should be rejected
const advanceRes = await agentPatch(
ctx.boardRequest, ctx.approver, issueId,
{ status: "done", comment: "I'm the approver, not the reviewer." },
);
expect(advanceRes.ok()).toBe(false);
expect(advanceRes.status()).toBeGreaterThanOrEqual(400);
});
test("review-only policy: reviewer approval completes execution", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff review-only", [
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
]);
// Executor marks done → routes to reviewer
const doneRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issue.id, ["in_progress"],
{ status: "done", comment: "Ready for review." },
);
expect(doneRes.ok()).toBe(true);
expect((await doneRes.json()).status).toBe("in_review");
// Reviewer approves → should complete immediately (no approval stage)
const approveRes = await agentPatch(
ctx.boardRequest, ctx.reviewer, issue.id,
{ status: "done", comment: "LGTM." },
);
expect(approveRes.ok()).toBe(true);
const doneIssue = await approveRes.json();
expect(doneIssue.status).toBe("done");
expect(doneIssue.executionState.status).toBe("completed");
expect(doneIssue.executionState.completedStageIds).toHaveLength(1);
});
});