mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Cases is an experimental feature; an instance admin turns it on in
Experimental settings
> - When Cases is off, every case and workflow route answers 403 "Cases
are disabled" with no other information
> - A chat agent asked to "open a customer case" could not tell whether
the denial was final or what to offer
> - In one eval run it created a project and a task in place of the
case; in another it spent most of its turn searching routes before it
replied
> - This pull request gives the denial a code and a final, actionable
remediation, like the board-only denial
> - The benefit is a fast, truthful reply that names the setting and
lets the person choose
## Linked Issues or Issue Description
**What happened**
In the chat eval cases.cases-disabled ("Open a customer case for this
bug." with Cases off), the agent got 403 "Cases are disabled". In one
run it then created a project and a bug task without asking. In a later
run it searched many routes and instance settings for about 100 seconds
before it replied.
**Expected behavior**
The denial says that Cases is off, that the denial is final, where an
instance admin turns it on, and that the agent must not create a
substitute record before the person chooses. The agent replies at once
and offers the setting or an ordinary task.
**Steps to reproduce**
1. Turn off Cases in Experimental settings.
2. As an agent, call GET /api/companies/{companyId}/cases.
3. See a bare 403 "Cases are disabled" with no code or remediation.
## What Changed
- Rebased on #20894, which already gave the case routes a
`cases_disabled` code, a final remediation and the hidden-settings
check. This PR moves that remediation into one helper used by every case
and workflow path, and adds: no substitute task or project before the
person chooses, and the offer of an ordinary task.
- New `casesDisabledError()` in `server/src/services/cases-disabled.ts`:
same 403 message, plus `code: "cases_disabled"` and a remediation. The
remediation says the denial is final, forbids a substitute task or
project before the person chooses, links `[Experimental
settings](/<prefix>/company/settings/instance/experimental)`, and offers
an ordinary task.
- Native tool results carry only the error message, so the native case
tools, workflow tools and runner tool authority inline the remediation
in the message (`casesDisabledError({ inlineRemediation: true })`).
- Case routes, workflow service, case handoffs, native case tools,
native workflow tools and the runner tool authority use the helper
instead of a bare `forbidden("Cases are disabled")`.
- Tests: `cases-routes.test.ts` checks the code and remediation;
`native-case-tools.integration.test.ts` checks the native message
carries the remediation (red on master, green here).
## Verification
- `npx vitest run src/__tests__/cases-routes.test.ts
src/services/case-handoffs.test.ts
src/services/native-runtime/native-case-tools.integration.test.ts
src/__tests__/heartbeat-case-handoff.test.ts` (166 passed).
- The cases.cases-disabled chat eval is rerun on a temporary branch;
results are recorded in the PR conversation.
## Risks
- Low risk. The status and message do not change. The error body adds
`code` and `remediation`.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.
## 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
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1470 lines
78 KiB
TypeScript
1470 lines
78 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
assets,
|
|
caseAttachments,
|
|
caseAccessSources,
|
|
caseHandoffs,
|
|
caseDocuments,
|
|
caseEvents,
|
|
caseIssueLinks,
|
|
caseLabels,
|
|
cases,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
documentAnnotationComments,
|
|
documentAnnotationThreads,
|
|
documents,
|
|
documentRevisions,
|
|
heartbeatRuns,
|
|
instanceSettings,
|
|
issueRelations,
|
|
issues,
|
|
labels,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { actorMiddleware } from "../middleware/auth.js";
|
|
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { buildCasePatchUpdateValues, caseRoutes } from "../routes/cases.js";
|
|
import { caseHandoffRoutes } from "../routes/case-handoffs.js";
|
|
import { canActorReadAssetContent } from "../services/asset-content-access.js";
|
|
import { caseActivityReadSqlCondition } from "../services/case-activity-access.js";
|
|
import { activityService } from "../services/activity.js";
|
|
import { agentActionAuditService } from "../services/agent-action-audit.js";
|
|
import { assertCaseTrustedForExecution, canActorReadCase, canActorReceiveCase, retainCaseAccessSource } from "../services/case-access.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
|
import type { StorageService } from "../storage/types.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres cases route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("cases routes", () => {
|
|
it("omits completedAt from non-status case patches", () => {
|
|
const now = new Date("2026-07-10T00:00:00.000Z");
|
|
const completedAt = new Date("2026-07-09T00:00:00.000Z");
|
|
|
|
expect(buildCasePatchUpdateValues({ title: "Rename" }, { status: "todo", completedAt: null }, now)).not.toHaveProperty("completedAt");
|
|
expect(buildCasePatchUpdateValues({ title: "Rename" }, { status: "done", completedAt }, now)).not.toHaveProperty("completedAt");
|
|
|
|
const statusPatch = buildCasePatchUpdateValues({ status: "done" }, { status: "todo", completedAt: null }, now);
|
|
expect(statusPatch).toHaveProperty("completedAt");
|
|
expect(statusPatch.completedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
const previousAgentJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
const liveEventUnsubscribers: Array<() => void> = [];
|
|
|
|
const storage: StorageService = {
|
|
provider: "local_disk",
|
|
async putFile(input) {
|
|
if (!Buffer.isBuffer(input.body)) throw new Error("Expected bounded bytes");
|
|
return {
|
|
provider: "local_disk",
|
|
objectKey: `${input.namespace}/${randomUUID()}`,
|
|
contentType: input.contentType,
|
|
byteSize: input.body.length,
|
|
sha256: createHash("sha256").update(input.body).digest("hex"),
|
|
originalFilename: input.originalFilename,
|
|
};
|
|
},
|
|
async getObject() {
|
|
throw new Error("not used");
|
|
},
|
|
async headObject() {
|
|
return { exists: false };
|
|
},
|
|
async deleteObject() {},
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
process.env.PAPERCLIP_AGENT_JWT_SECRET = "cases-routes-test-secret";
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cases-routes-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
for (const unsubscribe of liveEventUnsubscribers.splice(0)) unsubscribe();
|
|
await db.delete(activityLog);
|
|
await db.delete(documentAnnotationComments);
|
|
await db.delete(documentAnnotationThreads);
|
|
await db.delete(caseAttachments);
|
|
await db.delete(caseLabels);
|
|
await db.delete(caseDocuments);
|
|
await db.delete(caseIssueLinks);
|
|
await db.delete(caseEvents);
|
|
await db.delete(cases);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(documents);
|
|
await db.delete(assets);
|
|
await db.delete(labels);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(projects);
|
|
await db.delete(agents);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
await db.delete(instanceSettings);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
if (previousAgentJwtSecret === undefined) {
|
|
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
} else {
|
|
process.env.PAPERCLIP_AGENT_JWT_SECRET = previousAgentJwtSecret;
|
|
}
|
|
});
|
|
|
|
function app(actor: Express.Request["actor"]) {
|
|
const instance = express();
|
|
instance.use(express.json());
|
|
instance.use((req, _res, next) => {
|
|
req.actor = actor;
|
|
next();
|
|
});
|
|
instance.use("/api", caseRoutes(db, storage));
|
|
instance.use("/api", caseHandoffRoutes(db));
|
|
instance.use(errorHandler);
|
|
return instance;
|
|
}
|
|
|
|
function authenticatedApp() {
|
|
const instance = express();
|
|
instance.use(express.json());
|
|
instance.use(actorMiddleware(db, { deploymentMode: "authenticated" }));
|
|
instance.use("/api", caseRoutes(db, storage));
|
|
instance.use("/api", caseHandoffRoutes(db));
|
|
instance.use(errorHandler);
|
|
return instance;
|
|
}
|
|
|
|
async function enableCases() {
|
|
await instanceSettingsService(db).updateExperimental({ enableCases: true });
|
|
}
|
|
|
|
async function seedCompany(prefix = "CASE") {
|
|
const [company] = await db.insert(companies).values({
|
|
name: `${prefix} Co`,
|
|
issuePrefix: `${prefix}${randomUUID().replace(/-/g, "").slice(0, 4)}`,
|
|
}).returning();
|
|
return company!;
|
|
}
|
|
|
|
async function seedAgent(companyId: string) {
|
|
const [agent] = await db.insert(agents).values({
|
|
companyId,
|
|
name: "Cases Agent",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
}).returning();
|
|
return agent!;
|
|
}
|
|
|
|
const boardActor: Express.Request["actor"] = {
|
|
type: "board",
|
|
userId: "board-user",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
};
|
|
|
|
it("gates every case route when enableCases is off", async () => {
|
|
const company = await seedCompany("OFF");
|
|
const [caseRow] = await db.insert(cases).values({
|
|
companyId: company.id,
|
|
caseNumber: 1,
|
|
identifier: `${company.issuePrefix}-C1`,
|
|
caseType: "bug",
|
|
title: "Hidden case",
|
|
}).returning();
|
|
const http = request(app(boardActor));
|
|
|
|
const denied = await http.get(`/api/companies/${company.id}/cases`).expect(403);
|
|
// The denial is final and names the page where an instance admin turns Cases on.
|
|
expect(denied.body).toMatchObject({ error: "Cases are disabled", code: "cases_disabled" });
|
|
expect(denied.body.remediation).toContain("[Experimental settings](/<prefix>/company/settings/instance/experimental)");
|
|
expect(denied.body.remediation).toContain("Agent credentials cannot change that setting");
|
|
// Hiding only the Cases switch keeps the page, so the denial must not send the person there.
|
|
const previousHidden = process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
|
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.enableCases";
|
|
try {
|
|
const hiddenDenial = await http.get(`/api/companies/${company.id}/cases`).expect(403);
|
|
expect(hiddenDenial.body.remediation).toContain("An instance operator controls it.");
|
|
expect(hiddenDenial.body.remediation).not.toContain("/instance/experimental");
|
|
} finally {
|
|
if (previousHidden === undefined) delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
|
else process.env.PAPERCLIP_HIDDEN_SETTINGS = previousHidden;
|
|
}
|
|
await http.get(`/api/companies/${company.id}/cases/types`).expect(403);
|
|
await http.post(`/api/companies/${company.id}/cases`).send({ caseType: "bug", title: "Bug" }).expect(403);
|
|
await http.get(`/api/cases/${caseRow!.id}`).expect(403);
|
|
await http.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(403);
|
|
await http.put(`/api/cases/${caseRow!.id}/documents/body`).send({ body: "Body" }).expect(403);
|
|
await http.get(`/api/cases/${caseRow!.id}/documents/body/annotations`).expect(403);
|
|
await http.post(`/api/cases/${caseRow!.id}/links`).send({ issueId: randomUUID(), role: "work" }).expect(403);
|
|
await http.post(`/api/cases/${caseRow!.id}/attachments`).attach("file", Buffer.from("x"), "x.txt").expect(403);
|
|
await http.get(`/api/cases/${caseRow!.id}/events`).expect(403);
|
|
});
|
|
|
|
it("tells a caller that Cases is off for good and names the setting instead of a substitute", async () => {
|
|
const company = await seedCompany("OFFX");
|
|
const response = await request(app(boardActor)).get(`/api/companies/${company.id}/cases`).expect(403);
|
|
expect(response.body).toMatchObject({ error: "Cases are disabled", code: "cases_disabled" });
|
|
expect(response.body.remediation).toContain("this denial is final");
|
|
expect(response.body.remediation).toContain("Do not create a task, project or other record in place of the case before the person chooses");
|
|
expect(response.body.remediation).toContain("[Experimental settings](/<prefix>/company/settings/instance/experimental)");
|
|
expect(response.body.remediation).toContain("offer to track the request as an ordinary task instead");
|
|
});
|
|
|
|
it("returns not found for unknown case IDs on the former shared routes", async () => {
|
|
await enableCases();
|
|
const http = request(app(boardActor));
|
|
const unknownId = randomUUID();
|
|
await http.get(`/api/cases/${unknownId}`).expect(404);
|
|
await http.patch(`/api/cases/${unknownId}`).send({ status: "in_progress" }).expect(404);
|
|
await http.put(`/api/cases/${unknownId}/documents/body`).send({ body: "Body" }).expect(404);
|
|
await http.get(`/api/cases/${unknownId}/documents/body/revisions`).expect(404);
|
|
await http.get(`/api/cases/${unknownId}/events`).expect(404);
|
|
});
|
|
|
|
it("creates cases and upserts idempotently by type and key", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("UPS");
|
|
const http = request(app(boardActor));
|
|
|
|
const first = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({
|
|
caseType: "security",
|
|
key: "CVE-1",
|
|
title: "Investigate report",
|
|
fields: { severity: "high" },
|
|
})
|
|
.expect(201);
|
|
const second = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({
|
|
caseType: "security",
|
|
key: "CVE-1",
|
|
title: "Investigate report again",
|
|
fields: { severity: "critical" },
|
|
})
|
|
.expect(200);
|
|
|
|
expect(second.body.id).toBe(first.body.id);
|
|
expect(first.body.identifier).toBe(`${company.issuePrefix.toUpperCase()}-C1`);
|
|
const all = await db.select().from(cases);
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0]!.title).toBe("Investigate report again");
|
|
expect(all[0]!.fields).toEqual({ severity: "critical" });
|
|
});
|
|
|
|
it("converges concurrent keyed upserts to one case", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("RCE");
|
|
const http = request(app(boardActor));
|
|
|
|
const requests = [
|
|
http.post(`/api/companies/${company.id}/cases`).send({
|
|
caseType: "release_note",
|
|
key: "2026-07-07",
|
|
title: "Release note A",
|
|
fields: { channel: "stable" },
|
|
}),
|
|
http.post(`/api/companies/${company.id}/cases`).send({
|
|
caseType: "release_note",
|
|
key: "2026-07-07",
|
|
title: "Release note B",
|
|
fields: { channel: "canary" },
|
|
}),
|
|
];
|
|
|
|
const responses = await Promise.all(requests);
|
|
expect(responses.map((res) => res.status).sort()).toEqual([200, 201]);
|
|
expect(responses[0]!.body.id).toBe(responses[1]!.body.id);
|
|
|
|
const all = await db.select().from(cases);
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0]!.caseType).toBe("release_note");
|
|
expect(all[0]!.key).toBe("2026-07-07");
|
|
expect(["Release note A", "Release note B"]).toContain(all[0]!.title);
|
|
expect([{ channel: "stable" }, { channel: "canary" }]).toContainEqual(all[0]!.fields);
|
|
});
|
|
|
|
it("upserts keyless cases by company and type", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("NUL");
|
|
const http = request(app(boardActor));
|
|
|
|
const first = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "release_note", title: "Draft release note" })
|
|
.expect(201);
|
|
const second = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "release_note", title: "Updated release note" })
|
|
.expect(200);
|
|
|
|
expect(second.body.id).toBe(first.body.id);
|
|
expect(second.body.key).toBeNull();
|
|
expect(second.body.title).toBe("Updated release note");
|
|
const all = await db.select().from(cases);
|
|
expect(all).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects the removed createOnly option without changing existing passive records", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("NEW"), http = request(app(boardActor));
|
|
const original = await http.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "research", title: "Existing passive case" }).expect(201);
|
|
await http.post(`/api/companies/${company.id}/cases`)
|
|
.send({ createOnly: true, caseType: "research", title: "Must not overwrite" }).expect(400);
|
|
const all = await db.select().from(cases).where(eq(cases.companyId, company.id));
|
|
expect(all).toMatchObject([{ id: original.body.id, title: "Existing passive case", workIssueId: null }]);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, company.id))).toHaveLength(0);
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, company.id))).toHaveLength(0);
|
|
});
|
|
|
|
it("resolves cases by identifier", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("REF");
|
|
const http = request(app(boardActor));
|
|
|
|
const created = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "blog_post", key: "launch", title: "Launch post" })
|
|
.expect(201);
|
|
|
|
const byIdentifier = await http.get(`/api/cases/${created.body.identifier}`).expect(200);
|
|
expect(byIdentifier.body.id).toBe(created.body.id);
|
|
expect(byIdentifier.body.identifier).toMatch(/^REF[A-Z0-9]{4}-C1$/);
|
|
});
|
|
|
|
it("auto-links run writes to their issue with a work link and event", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("RUN");
|
|
const agent = await seedAgent(company.id);
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
status: "running",
|
|
});
|
|
const [issue] = await db.insert(issues).values({
|
|
companyId: company.id,
|
|
title: "Source task",
|
|
status: "in_progress",
|
|
executionRunId: runId,
|
|
}).returning();
|
|
const created = await request(app(boardActor))
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "bug", title: "Bug" })
|
|
.expect(201);
|
|
|
|
const agentActor: Express.Request["actor"] = {
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId,
|
|
source: "agent_jwt",
|
|
onBehalfOfUserId: null,
|
|
onBehalfOfMemberships: [],
|
|
};
|
|
await request(app(agentActor))
|
|
.patch(`/api/cases/${created.body.id}`)
|
|
.send({ fields: { rootCause: "missing coverage" } })
|
|
.expect(200);
|
|
|
|
const links = await db.select().from(caseIssueLinks);
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0]!.caseId).toBe(created.body.id);
|
|
expect(links[0]!.issueId).toBe(issue!.id);
|
|
expect(links[0]!.role).toBe("work");
|
|
expect(links[0]!.createdByRunId).toBe(runId);
|
|
|
|
const linkedEvents = await db.select().from(caseEvents).where(eq(caseEvents.kind, "issue_linked"));
|
|
expect(linkedEvents).toHaveLength(1);
|
|
expect(linkedEvents[0]!.actorAgentId).toBe(agent.id);
|
|
expect(linkedEvents[0]!.runId).toBe(runId);
|
|
expect(linkedEvents[0]!.payload).toMatchObject({ issueId: issue!.id, role: "work", autoLinked: true });
|
|
});
|
|
|
|
it("lets a run-scoped agent JWT complete the case happy path without manual linking", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("JWT");
|
|
const agent = await seedAgent(company.id);
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
status: "running",
|
|
});
|
|
const [issue] = await db.insert(issues).values({
|
|
companyId: company.id,
|
|
title: "Agent case source",
|
|
status: "in_progress",
|
|
executionRunId: runId,
|
|
}).returning();
|
|
const token = createLocalAgentJwt(agent.id, company.id, agent.adapterType, runId);
|
|
expect(token).toBeTruthy();
|
|
|
|
const http = request(authenticatedApp());
|
|
const createResponse = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.set("X-Paperclip-Run-Id", runId)
|
|
.send({
|
|
caseType: "blog_post",
|
|
key: "launch-post",
|
|
title: "Launch post",
|
|
fields: { slug: "launch-post", target_audience: "operators" },
|
|
})
|
|
.expect(201);
|
|
const caseId = createResponse.body.id as string;
|
|
|
|
await http
|
|
.put(`/api/cases/${createResponse.body.identifier}/documents/body`)
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.set("X-Paperclip-Run-Id", runId)
|
|
.send({ body: "# Launch\n\nDraft body." })
|
|
.expect(200);
|
|
|
|
await http
|
|
.patch(`/api/cases/${caseId}`)
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.set("X-Paperclip-Run-Id", runId)
|
|
.send({
|
|
status: "in_review",
|
|
fields: { slug: "launch-post", target_audience: "operators", publish_url: "https://example.com/launch" },
|
|
})
|
|
.expect(200);
|
|
|
|
await http
|
|
.post(`/api/cases/${caseId}/attachments`)
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.set("X-Paperclip-Run-Id", runId)
|
|
.attach("file", Buffer.from("asset"), "asset.txt")
|
|
.expect(201);
|
|
|
|
const links = await db.select().from(caseIssueLinks);
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0]).toMatchObject({
|
|
companyId: company.id,
|
|
caseId,
|
|
issueId: issue!.id,
|
|
role: "origin",
|
|
createdByRunId: runId,
|
|
});
|
|
|
|
const detail = await http
|
|
.get(`/api/cases/${createResponse.body.identifier}`)
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.set("X-Paperclip-Run-Id", runId)
|
|
.expect(200);
|
|
expect(detail.body.status).toBe("in_review");
|
|
expect(detail.body.documents).toHaveLength(1);
|
|
expect(detail.body.attachments).toHaveLength(1);
|
|
expect(detail.body.issueLinks).toHaveLength(1);
|
|
|
|
const eventRows = await db.select().from(caseEvents);
|
|
expect(eventRows.map((event) => event.kind)).toEqual(expect.arrayContaining([
|
|
"created",
|
|
"issue_linked",
|
|
"document_revised",
|
|
"status_changed",
|
|
"attachment_added",
|
|
]));
|
|
expect(eventRows.filter((event) => event.runId === runId)).toHaveLength(eventRows.length);
|
|
});
|
|
|
|
it("holds direct case status writes to the terminal handoff's primary-task authority", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("TRM");
|
|
const owner = await seedAgent(company.id);
|
|
const other = await seedAgent(company.id);
|
|
const [primary] = await db.insert(issues).values({
|
|
companyId: company.id,
|
|
identifier: `${company.issuePrefix.toUpperCase()}-1`,
|
|
title: "Primary case task",
|
|
status: "done",
|
|
assigneeAgentId: owner.id,
|
|
}).returning();
|
|
const [caseRow] = await db.insert(cases).values({
|
|
companyId: company.id,
|
|
caseNumber: 1,
|
|
identifier: `${company.issuePrefix.toUpperCase()}-C1`,
|
|
caseType: "customer",
|
|
key: "accepted-fix",
|
|
title: "Accepted fix",
|
|
status: "in_review",
|
|
workIssueId: primary!.id,
|
|
}).returning();
|
|
const agentActor = (agentId: string): Express.Request["actor"] => ({
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId,
|
|
source: "agent_key",
|
|
keyId: `key-${agentId}`,
|
|
onBehalfOfUserId: null,
|
|
onBehalfOfMemberships: [],
|
|
});
|
|
const otherHttp = request(app(agentActor(other.id)));
|
|
const readCase = async () => (await db.select().from(cases).where(eq(cases.id, caseRow!.id)))[0]!;
|
|
|
|
for (const status of ["done", "cancelled"] as const) {
|
|
const denied = await otherHttp.patch(`/api/cases/${caseRow!.id}`).send({ status }).expect(403);
|
|
expect(denied.body.error).toBe(`Agents may complete only their own case work. ${owner.name} (agent ${owner.id}) owns primary task ${primary!.identifier} and records the terminal handoff. Ask that agent on ${primary!.identifier} to close the case.`);
|
|
}
|
|
const upsert = await otherHttp
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "customer", key: "accepted-fix", title: "Accepted fix", status: "done" })
|
|
.expect(403);
|
|
expect(upsert.body.error).toMatch(/^Agents may complete only their own case work\. /);
|
|
expect(await readCase()).toMatchObject({ status: "in_review", completedAt: null });
|
|
|
|
// Fact edits and non-terminal status remain ordinary case writes.
|
|
await otherHttp.patch(`/api/cases/${caseRow!.id}`).send({ title: "Accepted checkout fix" }).expect(200);
|
|
await otherHttp.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(200);
|
|
|
|
// The owner still closes through the terminal handoff, not a raw status write.
|
|
const ownerHttp = request(app(agentActor(owner.id)));
|
|
await ownerHttp.patch(`/api/cases/${caseRow!.id}`).send({ status: "done" }).expect(422);
|
|
expect(await readCase()).toMatchObject({ status: "in_progress", completedAt: null });
|
|
await ownerHttp.patch(`/api/cases/${caseRow!.id}`).send({ status: "cancelled" }).expect(200);
|
|
const reopen = await otherHttp.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(403);
|
|
expect(reopen.body.error).toMatch(/^Agents may complete only their own case work\. /);
|
|
|
|
await request(app(boardActor)).patch(`/api/cases/${caseRow!.id}`).send({ status: "done" }).expect(200);
|
|
expect(await readCase()).toMatchObject({ status: "done", completedAt: expect.any(Date) });
|
|
|
|
// An upsert without status keeps the locked row's status and completion.
|
|
await otherHttp
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "customer", key: "accepted-fix", title: "Accepted checkout fix" })
|
|
.expect(200);
|
|
expect(await readCase()).toMatchObject({ status: "done", completedAt: expect.any(Date) });
|
|
});
|
|
|
|
it("rejects cross-company agent access across the cases route surface", async () => {
|
|
await enableCases();
|
|
const ownCompany = await seedCompany("OWN");
|
|
const otherCompany = await seedCompany("OTH");
|
|
const agent = await seedAgent(ownCompany.id);
|
|
const [otherIssue] = await db.insert(issues).values({
|
|
companyId: otherCompany.id,
|
|
identifier: `${otherCompany.issuePrefix.toUpperCase()}-1`,
|
|
title: "Other company task",
|
|
status: "todo",
|
|
}).returning();
|
|
const [caseRow] = await db.insert(cases).values({
|
|
companyId: otherCompany.id,
|
|
caseNumber: 1,
|
|
identifier: `${otherCompany.issuePrefix.toUpperCase()}-C1`,
|
|
caseType: "bug",
|
|
title: "Other company case",
|
|
}).returning();
|
|
const [ownCase] = await db.insert(cases).values({
|
|
companyId: ownCompany.id,
|
|
caseNumber: 1,
|
|
identifier: `${ownCompany.issuePrefix.toUpperCase()}-C1`,
|
|
caseType: "bug",
|
|
title: "Own company case",
|
|
}).returning();
|
|
await db.insert(caseEvents).values({
|
|
companyId: otherCompany.id,
|
|
caseId: caseRow!.id,
|
|
kind: "created",
|
|
actorType: "system",
|
|
payload: {},
|
|
});
|
|
|
|
const agentActor: Express.Request["actor"] = {
|
|
type: "agent",
|
|
companyId: ownCompany.id,
|
|
agentId: agent.id,
|
|
source: "agent_key",
|
|
keyId: "key-1",
|
|
onBehalfOfUserId: "user-1",
|
|
onBehalfOfMemberships: [],
|
|
};
|
|
const http = request(app(agentActor));
|
|
|
|
await http.get(`/api/companies/${otherCompany.id}/cases`).expect(403);
|
|
await http
|
|
.post(`/api/companies/${otherCompany.id}/cases`)
|
|
.send({ caseType: "bug", title: "Wrong company create" })
|
|
.expect(403);
|
|
await http.get(`/api/cases/${caseRow!.id}`).expect(404);
|
|
await http.get(`/api/cases/${caseRow!.identifier}`).expect(404);
|
|
await http.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(404);
|
|
await http.put(`/api/cases/${caseRow!.id}/documents/body`).send({ body: "Body" }).expect(404);
|
|
await http
|
|
.post(`/api/cases/${caseRow!.id}/links`)
|
|
.send({ issueId: otherIssue!.id, role: "reference" })
|
|
.expect(404);
|
|
await http
|
|
.post(`/api/cases/${caseRow!.id}/attachments`)
|
|
.attach("file", Buffer.from("artifact"), "artifact.txt")
|
|
.expect(404);
|
|
await http.get(`/api/cases/${caseRow!.id}/events`).expect(404);
|
|
await http.get(`/api/issues/${otherIssue!.id}/cases`).expect(404);
|
|
await http.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404);
|
|
|
|
const limitedBoardActor: Express.Request["actor"] = {
|
|
type: "board",
|
|
userId: "limited-board-user",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: [ownCompany.id],
|
|
memberships: [{ companyId: ownCompany.id, membershipRole: "operator", status: "active" }],
|
|
};
|
|
await db.insert(companyMemberships).values({ companyId: ownCompany.id, principalType: "user", principalId: "limited-board-user", status: "active", membershipRole: "operator" });
|
|
const limitedBoardHttp = request(app(limitedBoardActor));
|
|
const ownCaseResponse = await limitedBoardHttp.get(`/api/cases/${ownCase!.id}`).expect(200);
|
|
expect(ownCaseResponse.body.id).toBe(ownCase!.id);
|
|
await limitedBoardHttp.get(`/api/cases/${caseRow!.id}`).expect(404);
|
|
await limitedBoardHttp.get(`/api/cases/${caseRow!.identifier}`).expect(404);
|
|
await limitedBoardHttp.get(`/api/issues/${otherIssue!.id}/cases`).expect(404);
|
|
await limitedBoardHttp.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404);
|
|
|
|
const scopedAdminHttp = request(app({ ...limitedBoardActor, isInstanceAdmin: true }));
|
|
await scopedAdminHttp.get(`/api/cases/${caseRow!.id}`).expect(404);
|
|
await scopedAdminHttp.get(`/api/cases/${caseRow!.identifier}`).expect(404);
|
|
await scopedAdminHttp.get(`/api/issues/${otherIssue!.id}/cases`).expect(404);
|
|
await scopedAdminHttp.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404);
|
|
|
|
expect(await db.select().from(cases)).toHaveLength(2);
|
|
expect(await db.select().from(caseDocuments)).toHaveLength(0);
|
|
expect(await db.select().from(documents)).toHaveLength(0);
|
|
expect(await db.select().from(caseIssueLinks)).toHaveLength(0);
|
|
expect(await db.select().from(caseAttachments)).toHaveLength(0);
|
|
expect(await db.select().from(assets)).toHaveLength(0);
|
|
expect(await db.select().from(caseEvents)).toHaveLength(1);
|
|
});
|
|
|
|
it("returns identical cross-company and absent case handoff responses without mutating passive cases", async () => {
|
|
await enableCases();
|
|
const own = await seedCompany("HANDOFF-OWN"), foreign = await seedCompany("HANDOFF-FOREIGN");
|
|
const agent = await seedAgent(own.id);
|
|
const [ownCase, foreignCase] = await db.insert(cases).values([
|
|
{ companyId: own.id, caseNumber: 1, identifier: `${own.issuePrefix}-C1`, caseType: "work", title: "Own passive case" },
|
|
{ companyId: foreign.id, caseNumber: 1, identifier: `${foreign.issuePrefix}-C1`, caseType: "work", title: "Foreign confidential case", fields: { privateFact: "Keep this private" } },
|
|
]).returning();
|
|
await db.insert(companyMemberships).values({ companyId: own.id, principalType: "user", principalId: "handoff-operator", status: "active", membershipRole: "operator" });
|
|
const scopedBoard: Express.Request["actor"] = { type: "board", source: "session", userId: "handoff-operator",
|
|
companyIds: [own.id], isInstanceAdmin: false,
|
|
memberships: [{ companyId: own.id, membershipRole: "operator", status: "active" }] };
|
|
const actors: Express.Request["actor"][] = [
|
|
{ type: "agent", companyId: own.id, agentId: agent.id, source: "agent_key" },
|
|
scopedBoard, { ...scopedBoard, isInstanceAdmin: true },
|
|
];
|
|
// An authorized passive case has an empty history, proving the real router
|
|
// is mounted rather than accepting Express's unmatched-route 404.
|
|
await request(app(boardActor)).get(`/api/cases/${ownCase!.id}/handoffs`).expect(200);
|
|
const absentCaseId = randomUUID(), handoffId = randomUUID();
|
|
for (const actor of actors) {
|
|
const http = request(app(actor));
|
|
const presentHistory = await http.get(`/api/cases/${foreignCase!.id}/handoffs`).expect(404);
|
|
const absentHistory = await http.get(`/api/cases/${absentCaseId}/handoffs`).expect(404);
|
|
expect(presentHistory.body).toEqual(absentHistory.body);
|
|
for (const action of ["retry", "cancel"]) {
|
|
const presentControl = await http.post(`/api/cases/${foreignCase!.id}/handoffs/${handoffId}/${action}`).send({}).expect(404);
|
|
const absentControl = await http.post(`/api/cases/${absentCaseId}/handoffs/${handoffId}/${action}`).send({}).expect(404);
|
|
expect(presentControl.body).toEqual(absentControl.body);
|
|
}
|
|
}
|
|
expect(await db.select().from(cases).where(eq(cases.id, foreignCase!.id))).toEqual([foreignCase]);
|
|
expect(await db.select().from(caseHandoffs)).toHaveLength(0);
|
|
expect(await db.select().from(caseEvents)).toHaveLength(0);
|
|
expect(await db.select().from(issues)).toHaveLength(0);
|
|
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
|
|
expect(await db.select().from(activityLog)).toHaveLength(0);
|
|
});
|
|
|
|
it("supports documents, manual issue links, attachment links, events, and list filters", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("SUR");
|
|
const [label] = await db.insert(labels).values({
|
|
companyId: company.id,
|
|
name: "Needs Review",
|
|
color: "#f59e0b",
|
|
}).returning();
|
|
const [issue] = await db.insert(issues).values({
|
|
companyId: company.id,
|
|
identifier: `${company.issuePrefix.toUpperCase()}-12`,
|
|
title: "Related task",
|
|
status: "todo",
|
|
}).returning();
|
|
const http = request(app(boardActor));
|
|
const created = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "incident", title: "Production incident", status: "in_progress" })
|
|
.expect(201);
|
|
|
|
await http.patch(`/api/cases/${created.body.id}`).send({ labels: [label!.id] }).expect(200);
|
|
await http.put(`/api/cases/${created.body.identifier}/documents/runbook`).send({ body: "Steps" }).expect(200);
|
|
await http.post(`/api/cases/${created.body.id}/links`).send({ issueId: issue!.id, role: "reference" }).expect(201);
|
|
await http.post(`/api/cases/${created.body.id}/attachments`).attach("file", Buffer.from("artifact"), "artifact.txt").expect(201);
|
|
|
|
const activeList = await http
|
|
.get(`/api/companies/${company.id}/cases`)
|
|
.query({ status: "active", label: label!.id, q: "Production" })
|
|
.expect(200);
|
|
expect(activeList.body).toHaveLength(1);
|
|
expect(activeList.body[0].id).toBe(created.body.id);
|
|
|
|
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Launch" }).returning();
|
|
const [projectCase] = await db.insert(cases).values({
|
|
companyId: company.id,
|
|
projectId: project!.id,
|
|
caseNumber: 50,
|
|
identifier: `${company.issuePrefix.toUpperCase()}-C50`,
|
|
caseType: "brief",
|
|
title: "Project brief",
|
|
status: "draft",
|
|
}).returning();
|
|
const multiFiltered = await http
|
|
.get(`/api/companies/${company.id}/cases`)
|
|
.query({ types: ["incident", "brief"], statuses: ["in_progress", "draft"], projectIds: [project!.id], includeNoProject: "true" })
|
|
.expect(200);
|
|
expect(multiFiltered.body.map((row: { id: string }) => row.id).sort()).toEqual([created.body.id, projectCase!.id].sort());
|
|
|
|
await db.insert(cases).values(Array.from({ length: 205 }, (_, index) => ({
|
|
companyId: company.id,
|
|
caseNumber: 100 + index,
|
|
identifier: `${company.issuePrefix.toUpperCase()}-C${100 + index}`,
|
|
caseType: "incident",
|
|
title: `Filler incident ${index}`,
|
|
status: "in_progress",
|
|
updatedAt: new Date(`2030-01-01T00:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
|
})));
|
|
const deepFiltered = await http
|
|
.get(`/api/companies/${company.id}/cases`)
|
|
.query({ q: "Production", limit: 1 })
|
|
.expect(200);
|
|
expect(deepFiltered.body).toHaveLength(1);
|
|
expect(deepFiltered.body[0].id).toBe(created.body.id);
|
|
|
|
const detail = await http.get(`/api/cases/${created.body.identifier}`).expect(200);
|
|
expect(detail.body.labels).toHaveLength(1);
|
|
expect(detail.body.documents).toHaveLength(1);
|
|
expect(detail.body.issueLinks).toHaveLength(1);
|
|
expect(detail.body.attachments).toHaveLength(1);
|
|
|
|
const events = await http.get(`/api/cases/${created.body.id}/events`).expect(200);
|
|
expect(events.body.map((event: { kind: string }) => event.kind)).toEqual(
|
|
expect.arrayContaining(["created", "label_added", "document_revised", "issue_linked", "attachment_added"]),
|
|
);
|
|
const linkedEvent = events.body.find((event: { kind: string }) => event.kind === "issue_linked");
|
|
expect(linkedEvent.issue).toMatchObject({
|
|
id: issue!.id,
|
|
identifier: issue!.identifier,
|
|
title: "Related task",
|
|
status: "todo",
|
|
});
|
|
});
|
|
|
|
it("enriches events and revisions with actor name and run→issue attribution", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("ATT");
|
|
const agent = await seedAgent(company.id);
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
status: "running",
|
|
});
|
|
const [issue] = await db.insert(issues).values({
|
|
companyId: company.id,
|
|
title: "Attribution source task",
|
|
status: "in_progress",
|
|
executionRunId: runId,
|
|
}).returning();
|
|
|
|
const agentActor: Express.Request["actor"] = {
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId,
|
|
source: "agent_jwt",
|
|
onBehalfOfUserId: null,
|
|
onBehalfOfMemberships: [],
|
|
};
|
|
const http = request(app(agentActor));
|
|
|
|
const created = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "blog_post", title: "Attribution post" })
|
|
.expect(201);
|
|
// Two revisions on the body document.
|
|
const rev1 = await http
|
|
.put(`/api/cases/${created.body.id}/documents/body`)
|
|
.send({ body: "# v1" })
|
|
.expect(200);
|
|
await http
|
|
.put(`/api/cases/${created.body.id}/documents/body`)
|
|
.send({ body: "# v2", baseRevisionId: rev1.body.revision.id, changeSummary: "polish" })
|
|
.expect(200);
|
|
|
|
const events = await http.get(`/api/cases/${created.body.id}/events`).expect(200);
|
|
const revisedEvent = events.body.find((e: { kind: string }) => e.kind === "document_revised");
|
|
expect(revisedEvent.actorAgentName).toBe("Cases Agent");
|
|
expect(revisedEvent.issue).toMatchObject({ id: issue!.id, title: "Attribution source task" });
|
|
|
|
const revisions = await http
|
|
.get(`/api/cases/${created.body.id}/documents/body/revisions`)
|
|
.expect(200);
|
|
expect(revisions.body.revisions).toHaveLength(2);
|
|
expect(revisions.body.revisions[0].revisionNumber).toBe(2);
|
|
expect(revisions.body.revisions[0].body).toBe("# v2");
|
|
expect(revisions.body.revisions[0].changeSummary).toBe("polish");
|
|
expect(revisions.body.revisions[0].actorAgentName).toBe("Cases Agent");
|
|
expect(revisions.body.revisions[0].issue).toMatchObject({ id: issue!.id });
|
|
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, created.body.id));
|
|
const documentActivity = activity.filter(row => row.action === "case.document_revised");
|
|
expect(documentActivity).toHaveLength(2);
|
|
expect(documentActivity).toEqual(expect.arrayContaining([expect.objectContaining({
|
|
actorType: "agent", actorId: agent.id, agentId: agent.id, runId,
|
|
details: expect.objectContaining({ key: "body", documentKey: "body", documentId: rev1.body.document.id }),
|
|
})]));
|
|
});
|
|
|
|
it("locks, unlocks, deletes, and restores case documents through shared document controls", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("DOC");
|
|
const http = request(app(boardActor));
|
|
const created = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "blog_post", title: "Document controls" })
|
|
.expect(201);
|
|
const publishedRevisions: Record<string, unknown>[] = [];
|
|
liveEventUnsubscribers.push(subscribeCompanyLiveEvents(company.id, event => {
|
|
if (event.payload.action === "case.document_revised") publishedRevisions.push(event.payload);
|
|
}));
|
|
|
|
const firstRevision = await http
|
|
.put(`/api/cases/${created.body.id}/documents/body`)
|
|
.send({ body: "# v1" })
|
|
.expect(200);
|
|
const secondRevision = await http
|
|
.put(`/api/cases/${created.body.id}/documents/body`)
|
|
.send({ body: "# v2", baseRevisionId: firstRevision.body.revision.id })
|
|
.expect(200);
|
|
|
|
const loaded = await http.get(`/api/cases/${created.body.id}/documents/body`).expect(200);
|
|
expect(loaded.body.body).toBe("# v2");
|
|
expect(loaded.body.latestRevisionNumber).toBe(2);
|
|
|
|
const locked = await http.post(`/api/cases/${created.body.id}/documents/body/lock`).expect(200);
|
|
expect(locked.body.lockedAt).toBeTruthy();
|
|
await http
|
|
.put(`/api/cases/${created.body.id}/documents/body`)
|
|
.send({ body: "# blocked", baseRevisionId: secondRevision.body.revision.id })
|
|
.expect(409);
|
|
await http.delete(`/api/cases/${created.body.id}/documents/body`).expect(409);
|
|
|
|
const unlocked = await http.post(`/api/cases/${created.body.id}/documents/body/unlock`).expect(200);
|
|
expect(unlocked.body.lockedAt).toBeNull();
|
|
|
|
const restored = await http
|
|
.post(`/api/cases/${created.body.id}/documents/body/revisions/${firstRevision.body.revision.id}/restore`)
|
|
.expect(200);
|
|
expect(restored.body.document.body).toBe("# v1");
|
|
expect(restored.body.document.latestRevisionNumber).toBe(3);
|
|
expect(restored.body.restoredFromRevisionNumber).toBe(1);
|
|
|
|
const revisions = await http.get(`/api/cases/${created.body.id}/documents/body/revisions`).expect(200);
|
|
expect(revisions.body.revisions).toHaveLength(3);
|
|
expect(revisions.body.revisions[0].changeSummary).toBe("Restored from revision 1");
|
|
|
|
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, created.body.id));
|
|
const documentActivity = activity.filter(row => row.action === "case.document_revised");
|
|
expect(documentActivity).toHaveLength(3);
|
|
expect(publishedRevisions).toHaveLength(3);
|
|
const revisionsByNumber = new Map([firstRevision, secondRevision, restored].map((response, index) => [index + 1, response.body.revision.id]));
|
|
for (const row of documentActivity) {
|
|
const details = row.details as Record<string, unknown>;
|
|
expect(row).toMatchObject({ companyId: company.id, entityType: "case", entityId: created.body.id, actorType: "user", actorId: "board-user" });
|
|
expect(details).toMatchObject({ key: "body", documentKey: "body", documentId: firstRevision.body.document.id,
|
|
revisionId: revisionsByNumber.get(details.revisionNumber as number) });
|
|
expect(details).not.toHaveProperty("body");
|
|
expect(details).not.toHaveProperty("title");
|
|
expect(publishedRevisions).toContainEqual(expect.objectContaining({ entityType: "case", entityId: created.body.id,
|
|
action: "case.document_revised", details }));
|
|
}
|
|
expect(documentActivity.find(row => (row.details as Record<string, unknown>).revisionNumber === 3)?.details).toMatchObject({
|
|
restoredFromRevisionId: firstRevision.body.revision.id, restoredFromRevisionNumber: 1,
|
|
});
|
|
expect(JSON.stringify(publishedRevisions)).not.toMatch(/# v1|# v2|# blocked/);
|
|
|
|
await http.delete(`/api/cases/${created.body.id}/documents/body`).expect(200);
|
|
await http.get(`/api/cases/${created.body.id}`).expect(200).expect((res) => {
|
|
expect(res.body.documents).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
it("creates, replies to, resolves, reopens, and remaps case document annotations", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("ANN");
|
|
const http = request(app(boardActor));
|
|
const created = await http
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "brief", title: "Annotated case" })
|
|
.expect(201);
|
|
const document = await http
|
|
.put(`/api/cases/${created.body.id}/documents/body`)
|
|
.send({ body: "Alpha beta gamma" })
|
|
.expect(200);
|
|
|
|
const annotation = await http
|
|
.post(`/api/cases/${created.body.id}/documents/body/annotations`)
|
|
.send({
|
|
baseRevisionId: document.body.revision.id,
|
|
baseRevisionNumber: document.body.revision.revisionNumber,
|
|
selector: {
|
|
quote: { exact: "beta", prefix: "Alpha ", suffix: " gamma" },
|
|
position: { normalizedStart: 6, normalizedEnd: 10, markdownStart: 6, markdownEnd: 10 },
|
|
},
|
|
body: "Clarify this word.",
|
|
})
|
|
.expect(201);
|
|
|
|
expect(annotation.body.caseId).toBe(created.body.id);
|
|
expect(annotation.body.issueId).toBeNull();
|
|
expect(annotation.body.routineId).toBeNull();
|
|
expect(annotation.body.comments[0].caseId).toBe(created.body.id);
|
|
|
|
const listed = await http
|
|
.get(`/api/cases/${created.body.identifier}/documents/body/annotations?status=all&includeComments=true`)
|
|
.expect(200);
|
|
expect(listed.body).toHaveLength(1);
|
|
expect(listed.body[0].comments).toHaveLength(1);
|
|
|
|
const reply = await http
|
|
.post(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}/comments`)
|
|
.send({ body: "Added context." })
|
|
.expect(201);
|
|
expect(reply.body.caseId).toBe(created.body.id);
|
|
|
|
const resolved = await http
|
|
.patch(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`)
|
|
.send({ status: "resolved" })
|
|
.expect(200);
|
|
expect(resolved.body.status).toBe("resolved");
|
|
|
|
const reopened = await http
|
|
.patch(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`)
|
|
.send({ status: "open" })
|
|
.expect(200);
|
|
expect(reopened.body.status).toBe("open");
|
|
|
|
const updatedDocument = await http
|
|
.put(`/api/cases/${created.body.id}/documents/body`)
|
|
.send({
|
|
body: "Alpha beta gamma delta",
|
|
baseRevisionId: document.body.revision.id,
|
|
})
|
|
.expect(200);
|
|
const remapped = await http
|
|
.get(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`)
|
|
.expect(200);
|
|
expect(remapped.body.currentRevisionNumber).toBe(updatedDocument.body.revision.revisionNumber);
|
|
expect(remapped.body.comments).toHaveLength(2);
|
|
|
|
const activities = await db
|
|
.select({ action: activityLog.action, entityType: activityLog.entityType, entityId: activityLog.entityId })
|
|
.from(activityLog)
|
|
.where(eq(activityLog.entityId, created.body.id));
|
|
expect(activities).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_created" }),
|
|
expect.objectContaining({ entityType: "case", action: "case.document_annotation_comment_added" }),
|
|
expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_resolved" }),
|
|
expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_reopened" }),
|
|
expect.objectContaining({ entityType: "case", action: "case.document_annotation_remapped" }),
|
|
]));
|
|
});
|
|
|
|
it("lists children by parent, exposes parent in detail, and lists cases for an issue", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("TREE");
|
|
const boardHttp = request(app(boardActor));
|
|
const parent = await boardHttp
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "epic", title: "Parent epic" })
|
|
.expect(201);
|
|
const child = await boardHttp
|
|
.post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "task", title: "Child task", parentCaseId: parent.body.id })
|
|
.expect(201);
|
|
|
|
const children = await boardHttp
|
|
.get(`/api/companies/${company.id}/cases`)
|
|
.query({ parent: parent.body.id })
|
|
.expect(200);
|
|
expect(children.body).toHaveLength(1);
|
|
expect(children.body[0].id).toBe(child.body.id);
|
|
|
|
const searchOnly = await boardHttp
|
|
.get(`/api/companies/${company.id}/cases`)
|
|
.query({ q: "Child task" })
|
|
.expect(200);
|
|
expect(searchOnly.body.map((row: { id: string }) => row.id)).toEqual([child.body.id]);
|
|
|
|
const searchWithAncestors = await boardHttp
|
|
.get(`/api/companies/${company.id}/cases`)
|
|
.query({ q: "Child task", includeAncestors: "true" })
|
|
.expect(200);
|
|
expect(searchWithAncestors.body).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: child.body.id, matchesListFilters: true }),
|
|
expect.objectContaining({ id: parent.body.id, matchesListFilters: false }),
|
|
]));
|
|
|
|
const childDetail = await boardHttp.get(`/api/cases/${child.body.id}`).expect(200);
|
|
expect(childDetail.body.parent).toMatchObject({ id: parent.body.id, identifier: parent.body.identifier });
|
|
|
|
// Link the child case to an issue, then resolve cases-for-issue.
|
|
const [issue] = await db.insert(issues).values({
|
|
companyId: company.id,
|
|
title: "Issue with cases",
|
|
status: "todo",
|
|
}).returning();
|
|
await boardHttp
|
|
.post(`/api/cases/${child.body.id}/links`)
|
|
.send({ issueId: issue!.id, role: "work" })
|
|
.expect(201);
|
|
|
|
const forIssue = await boardHttp.get(`/api/issues/${issue!.id}/cases`).expect(200);
|
|
expect(forIssue.body).toHaveLength(1);
|
|
expect(forIssue.body[0]).toMatchObject({ role: "work" });
|
|
expect(forIssue.body[0].case).toMatchObject({ id: child.body.id, identifier: child.body.identifier, status: child.body.status });
|
|
});
|
|
it("carries glyph-only blocker state on a blocked case task without blocker details (BUG-07)", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("WORK-BLOCKED");
|
|
const [work, blocker] = await db.insert(issues).values([
|
|
{ companyId: company.id, title: "Blocked case work", identifier: `${company.issuePrefix}-1`, status: "blocked" },
|
|
{ companyId: company.id, title: "Hidden case blocker", identifier: `${company.issuePrefix}-2`, status: "todo" },
|
|
]).returning();
|
|
await db.insert(issueRelations).values({ companyId: company.id, issueId: blocker!.id, relatedIssueId: work!.id, type: "blocks" });
|
|
const [record] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`,
|
|
caseType: "factory", title: "Blocked case", workIssueId: work!.id }).returning();
|
|
const detail = await request(app(boardActor)).get(`/api/cases/${record!.id}`).expect(200);
|
|
expect(detail.body.workIssue).toMatchObject({ id: work!.id, status: "blocked", blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } });
|
|
expect(JSON.stringify(detail.body.workIssue)).not.toContain("Hidden case blocker");
|
|
expect(JSON.stringify(detail.body.workIssue)).not.toContain(blocker!.id);
|
|
|
|
// Related-task links and the activity feed row draw from the same state.
|
|
await db.insert(caseIssueLinks).values({ companyId: company.id, caseId: record!.id, issueId: work!.id, role: "reference" });
|
|
const withLink = await request(app(boardActor)).get(`/api/cases/${record!.id}`).expect(200);
|
|
expect(withLink.body.issueLinks[0].issue).toMatchObject({ id: work!.id, blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } });
|
|
|
|
await db.insert(caseEvents).values({ companyId: company.id, caseId: record!.id, kind: "issue_linked",
|
|
actorType: "system", payload: { issueId: work!.id } });
|
|
const events = await request(app(boardActor)).get(`/api/cases/${record!.id}/events`).expect(200);
|
|
const linked = events.body.find((event: { kind: string }) => event.kind === "issue_linked");
|
|
expect(linked.issue).toMatchObject({ id: work!.id, status: "blocked", blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } });
|
|
expect(JSON.stringify(linked.issue)).not.toContain("Hidden case blocker");
|
|
});
|
|
it("enriches case pages and detail with current work in bounded batch queries", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("WORK-LIST");
|
|
const sender = await seedAgent(company.id);
|
|
const recipient = await seedAgent(company.id);
|
|
await db.update(agents).set({ name: "Triage" }).where(eq(agents.id, sender.id));
|
|
await db.update(agents).set({ name: "Verifier" }).where(eq(agents.id, recipient.id));
|
|
const work = await db.insert(issues).values(Array.from({ length: 20 }, (_, index) => ({
|
|
companyId: company.id, title: `Task ${index}`, identifier: `${company.issuePrefix}-${index + 1}`,
|
|
assigneeAgentId: sender.id, status: "todo",
|
|
}))).returning();
|
|
const records = await db.insert(cases).values(work.map((issue, index) => ({
|
|
companyId: company.id, caseNumber: index + 1, identifier: `${company.issuePrefix}-C${index + 1}`,
|
|
caseType: "factory", title: `Case ${index}`, workIssueId: issue.id, phase: "triage", handoffVersion: 1,
|
|
}))).returning();
|
|
const pending = await db.insert(caseHandoffs).values(records.map((record, index) => ({
|
|
companyId: company.id, caseId: record.id, issueId: work[index]!.id, version: 1,
|
|
fromAgentId: sender.id, toAgentId: recipient.id, phase: "verification", instructions: "Check the saved work.",
|
|
actorType: "user" as const, actorId: "board-user", wakeupRequestId: randomUUID(),
|
|
actorContext: { schema: "paperclip.case-handoff-actor.v1", companyId: company.id, actorType: "user", actorId: "board-user",
|
|
source: "session", runId: null, keyId: null, sessionId: "INTERNAL_SESSION_CANARY", keyScope: { kind: "standard" },
|
|
onBehalfOfUserId: null, authorizingUserId: null },
|
|
idempotencyKey: randomUUID(), inputFingerprint: "a".repeat(64), state: "pending_dispatch" as const,
|
|
}))).returning();
|
|
const http = request(app(boardActor));
|
|
const select = vi.spyOn(db, "select");
|
|
try {
|
|
await http.get(`/api/companies/${company.id}/cases?limit=1`).expect(200);
|
|
const singleSelects = select.mock.calls.length;
|
|
select.mockClear();
|
|
const page = await http.get(`/api/companies/${company.id}/cases?limit=20`).expect(200);
|
|
expect(page.body).toHaveLength(20);
|
|
// These are actual Drizzle reads against PostgreSQL, observed without
|
|
// replacing their results. A task/handoff read per row fails this bound.
|
|
expect(select.mock.calls.length).toBeLessThanOrEqual(singleSelects + 1);
|
|
const first = page.body.find((row: { id: string }) => row.id === records[0]!.id);
|
|
expect(first).toMatchObject({ phase: "triage", handoffVersion: 1,
|
|
workIssue: { id: work[0]!.id, assigneeAgentId: sender.id, assigneeAgentName: "Triage", status: "todo" },
|
|
pendingHandoff: { id: pending[0]!.id, phase: "verification", state: "pending_dispatch", fromAgentName: "Triage", toAgentName: "Verifier" },
|
|
});
|
|
const detail = await http.get(`/api/cases/${records[0]!.id}`).expect(200);
|
|
expect(detail.body.workIssue).toEqual(first.workIssue);
|
|
expect(detail.body.pendingHandoff).toEqual(first.pendingHandoff);
|
|
for (const row of page.body) expect(row.pendingHandoff).not.toHaveProperty("actorContext");
|
|
expect(detail.body.pendingHandoff).not.toHaveProperty("actorContext");
|
|
expect(JSON.stringify([page.body, detail.body])).not.toContain("INTERNAL_SESSION_CANARY");
|
|
} finally {
|
|
select.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("paginates authorized filtered cases and discovers types outside the current page", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("LIST-PAGE");
|
|
const owner = await seedAgent(company.id);
|
|
const reader = await seedAgent(company.id);
|
|
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Project" }).returning();
|
|
const [privateSource] = await db.insert(issues).values({ companyId: company.id, title: "Private", visibility: "private", assigneeAgentId: owner.id }).returning();
|
|
const [hidden, first, second] = await db.insert(cases).values([
|
|
{ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, title: "Needle hidden", caseType: "factory", status: "in_progress", updatedAt: new Date("2030-01-04") },
|
|
{ companyId: company.id, caseNumber: 2, identifier: `${company.issuePrefix}-C2`, title: "Needle first", caseType: "factory", status: "in_progress", updatedAt: new Date("2030-01-03") },
|
|
{ companyId: company.id, caseNumber: 3, identifier: `${company.issuePrefix}-C3`, title: "Needle second", caseType: "factory", status: "in_progress", updatedAt: new Date("2030-01-02") },
|
|
{ companyId: company.id, caseNumber: 4, identifier: `${company.issuePrefix}-C4`, title: "Unrelated draft", caseType: "research", status: "draft" },
|
|
{ companyId: company.id, caseNumber: 5, identifier: `${company.issuePrefix}-C5`, title: "Needle project", caseType: "factory", status: "in_progress", projectId: project!.id, updatedAt: new Date("2030-01-05") },
|
|
]).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: hidden!.id, sourceKind: "issue", sourceId: privateSource!.id });
|
|
const [secretType] = await db.insert(cases).values({ companyId: company.id, caseNumber: 6,
|
|
identifier: `${company.issuePrefix}-C6`, title: "Private type", caseType: "secret-type" }).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: secretType!.id, sourceKind: "issue", sourceId: privateSource!.id });
|
|
const http = request(app({ type: "agent", companyId: company.id, agentId: reader.id, source: "agent_key" }));
|
|
const query = { q: "Needle", type: "factory", status: "in_progress", includeNoProject: "true", limit: 1 };
|
|
const firstPage = await http.get(`/api/companies/${company.id}/cases`).query(query).expect(200);
|
|
const secondPage = await http.get(`/api/companies/${company.id}/cases`).query({ ...query, offset: 1 }).expect(200);
|
|
expect(firstPage.body.map((row: { id: string }) => row.id)).toEqual([first!.id]);
|
|
expect(secondPage.body.map((row: { id: string }) => row.id)).toEqual([second!.id]);
|
|
await http.get(`/api/companies/${company.id}/cases`).query({ offset: -1 }).expect(400);
|
|
const types = await http.get(`/api/companies/${company.id}/cases/types`).expect(200);
|
|
expect(types.body).toEqual(["factory", "research"]);
|
|
await db.delete(issues).where(eq(issues.id, privateSource!.id));
|
|
expect((await http.get(`/api/companies/${company.id}/cases/types`).expect(200)).body).toEqual(types.body);
|
|
});
|
|
|
|
it("sorts and groups the full authorized result before pagination", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("LIST-ORDER");
|
|
const owner = await seedAgent(company.id);
|
|
const reader = await seedAgent(company.id);
|
|
const projectRows = await db.insert(projects).values([
|
|
{ companyId: company.id, name: "One" },
|
|
{ companyId: company.id, name: "Two" },
|
|
]).returning();
|
|
const [privateSource] = await db.insert(issues).values({
|
|
companyId: company.id, title: "Private", visibility: "private", assigneeAgentId: owner.id,
|
|
}).returning();
|
|
const statuses = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"];
|
|
const types = ["factory", "incident", "research"];
|
|
const records = await db.insert(cases).values(Array.from({ length: 207 }, (_, index) => ({
|
|
companyId: company.id, caseNumber: index + 1, identifier: `${company.issuePrefix}-C${index + 1}`,
|
|
title: `${index % 2 ? "Alpha" : "beta"} ${String(207 - index).padStart(3, "0")}`,
|
|
caseType: types[index % types.length]!, status: statuses[index % statuses.length]!,
|
|
projectId: index % 3 === 2 ? null : projectRows[index % 3]!.id,
|
|
createdAt: new Date(Date.UTC(2030, 0, 1, 0, index)),
|
|
updatedAt: new Date(Date.UTC(2031, 0, 1, 0, 207 - index)),
|
|
}))).returning();
|
|
const [hidden] = await db.insert(cases).values({
|
|
companyId: company.id, caseNumber: 208, identifier: `${company.issuePrefix}-C208`,
|
|
title: "000 confidential", caseType: "a-secret", status: "draft",
|
|
updatedAt: new Date("2035-01-01"),
|
|
}).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: hidden!.id, sourceKind: "issue", sourceId: privateSource!.id });
|
|
const http = request(app({ type: "agent", companyId: company.id, agentId: reader.id, source: "agent_key" }));
|
|
type SortField = "updated" | "created" | "title" | "status" | "type";
|
|
type GroupBy = "none" | "status" | "type" | "project";
|
|
type RecordRow = (typeof records)[number];
|
|
const compare = (left: string | number, right: string | number) => left < right ? -1 : left > right ? 1 : 0;
|
|
const sortValue = (row: RecordRow, field: SortField) => ({
|
|
updated: row.updatedAt.getTime(), created: row.createdAt.getTime(), title: row.title.toLowerCase(),
|
|
status: statuses.indexOf(row.status), type: row.caseType,
|
|
})[field];
|
|
const options: Array<{ sortField?: SortField; sortDir?: "asc" | "desc"; groupBy?: GroupBy }> = [
|
|
{}, { sortField: "updated", sortDir: "asc" }, { sortField: "created", sortDir: "asc" },
|
|
{ sortField: "title", sortDir: "asc" }, { sortField: "status", sortDir: "desc" },
|
|
{ sortField: "type", sortDir: "asc" },
|
|
{ groupBy: "status", sortField: "title", sortDir: "asc" },
|
|
{ groupBy: "type", sortField: "created", sortDir: "desc" },
|
|
{ groupBy: "project", sortField: "updated", sortDir: "asc" },
|
|
];
|
|
for (const option of options) {
|
|
const expected = [...records].sort((left, right) => {
|
|
let grouped = 0;
|
|
if (option.groupBy === "status") grouped = compare(statuses.indexOf(left.status), statuses.indexOf(right.status));
|
|
if (option.groupBy === "type") grouped = compare(left.caseType, right.caseType);
|
|
if (option.groupBy === "project") grouped = left.projectId === null ? Number(right.projectId !== null)
|
|
: right.projectId === null ? -1 : compare(left.projectId, right.projectId);
|
|
return grouped || (option.sortDir === "asc" ? 1 : -1) * compare(sortValue(left, option.sortField ?? "updated"), sortValue(right, option.sortField ?? "updated"))
|
|
|| compare(right.updatedAt.getTime(), left.updatedAt.getTime())
|
|
|| compare(right.createdAt.getTime(), left.createdAt.getTime()) || compare(right.id, left.id);
|
|
});
|
|
const first = await http.get(`/api/companies/${company.id}/cases`).query({ ...option, limit: 200 }).expect(200);
|
|
const second = await http.get(`/api/companies/${company.id}/cases`).query({ ...option, offset: 200, limit: 200 }).expect(200);
|
|
expect([...first.body, ...second.body].map((row: { id: string }) => row.id), JSON.stringify(option)).toEqual(expected.map((row) => row.id));
|
|
}
|
|
for (const invalid of [{ sortField: "assignee" }, { sortDir: "sideways" }, { groupBy: "private" }]) {
|
|
await http.get(`/api/companies/${company.id}/cases`).query(invalid).expect(400);
|
|
}
|
|
});
|
|
|
|
it("does not enrich readable cases with inaccessible tasks or out-of-bound agent names", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("LIST-BOUNDARY");
|
|
const owner = await seedAgent(company.id);
|
|
const reader = await seedAgent(company.id);
|
|
const rootId = randomUUID();
|
|
await db.update(agents).set({ name: "Private owner" }).where(eq(agents.id, owner.id));
|
|
await db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
|
|
trustBoundary: { mode: "low_trust_review", rootIssueId: rootId },
|
|
} } }).where(eq(agents.id, reader.id));
|
|
const [privateWork, allowedWork] = await db.insert(issues).values([
|
|
{ companyId: company.id, title: "Private task contents", visibility: "private", assigneeAgentId: owner.id },
|
|
{ id: rootId, companyId: company.id, title: "Allowed task", assigneeAgentId: reader.id },
|
|
]).returning();
|
|
const [malformed, allowed] = await db.insert(cases).values([
|
|
{ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, title: "Legacy public record", caseType: "factory", workIssueId: privateWork!.id },
|
|
{ companyId: company.id, caseNumber: 2, identifier: `${company.issuePrefix}-C2`, title: "Allowed record", caseType: "factory", workIssueId: allowedWork!.id },
|
|
]).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: allowed!.id, sourceKind: "issue", sourceId: allowedWork!.id });
|
|
await db.insert(caseHandoffs).values([malformed!, allowed!].map((record, index) => ({
|
|
companyId: company.id, caseId: record.id, issueId: record.workIssueId!, version: 1,
|
|
fromAgentId: owner.id, toAgentId: reader.id, phase: "verify", instructions: index ? "Allowed instruction" : "Private handoff contents",
|
|
actorType: "user" as const, actorId: "board-user", wakeupRequestId: randomUUID(),
|
|
idempotencyKey: randomUUID(), inputFingerprint: "a".repeat(64), state: "pending_dispatch" as const,
|
|
})));
|
|
const ordinary = request(app({ type: "agent", companyId: company.id, agentId: reader.id, source: "agent_key" }));
|
|
// A low-trust caller cannot read the unsourced company record at all.
|
|
const page = await ordinary.get(`/api/companies/${company.id}/cases`).expect(200);
|
|
expect(page.body).toHaveLength(1);
|
|
expect(page.body[0]).toMatchObject({ id: allowed!.id, workIssue: { id: allowedWork!.id },
|
|
pendingHandoff: { instructions: "Allowed instruction", fromAgentName: null, toAgentName: null } });
|
|
expect(JSON.stringify(page.body)).not.toContain("Private owner");
|
|
// Standard company access can read a legacy passive case, but its malformed
|
|
// primary link must not bypass the private task's independent boundary.
|
|
await db.update(agents).set({ permissions: {} }).where(eq(agents.id, reader.id));
|
|
const visible = await ordinary.get(`/api/cases/${malformed!.id}`).expect(200);
|
|
expect(visible.body.workIssue).toBeNull();
|
|
expect(visible.body.pendingHandoff).toBeNull();
|
|
expect(JSON.stringify(visible.body)).not.toContain("Private handoff contents");
|
|
});
|
|
|
|
it("filters retained private cases before pagination and protects nested content", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("PRIV");
|
|
const owner = await seedAgent(company.id);
|
|
const unrelated = await seedAgent(company.id);
|
|
const [source] = await db.insert(issues).values({ companyId: company.id, title: "Private source", visibility: "private", assigneeAgentId: owner.id }).returning();
|
|
const [hidden] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, caseType: "work", title: "Secret case", updatedAt: new Date("2030-01-01") }).returning();
|
|
const [open] = await db.insert(cases).values({ companyId: company.id, caseNumber: 2, identifier: `${company.issuePrefix}-C2`, caseType: "work", title: "Open case" }).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: hidden!.id, sourceKind: "issue", sourceId: source!.id });
|
|
const actor = { type: "agent" as const, companyId: company.id, agentId: unrelated.id, source: "agent_key" as const };
|
|
const http = request(app(actor));
|
|
const list = await http.get(`/api/companies/${company.id}/cases?limit=1`).expect(200);
|
|
expect(list.body.map((row: { id: string }) => row.id)).toEqual([open!.id]);
|
|
await http.get(`/api/cases/${hidden!.id}`).expect(404);
|
|
await http.get(`/api/cases/${hidden!.id}/events`).expect(404);
|
|
await http.get(`/api/cases/${hidden!.id}/documents/spec/revisions`).expect(404);
|
|
await http.get(`/api/cases/${hidden!.id}/documents/spec/annotations`).expect(404);
|
|
await http.patch(`/api/cases/${hidden!.id}`).send({ fields: { stolen: true } }).expect(404);
|
|
expect(await canActorReadCase(db, { ...actor, agentId: owner.id }, hidden!.id)).toBe(true);
|
|
});
|
|
|
|
it("retains source boundaries after unlink and fails closed after source deletion", async () => {
|
|
const company = await seedCompany("KEEP");
|
|
const owner = await seedAgent(company.id);
|
|
const [source] = await db.insert(issues).values({ companyId: company.id, title: "Source", visibility: "private", assigneeAgentId: owner.id }).returning();
|
|
const [record] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, caseType: "work", title: "Retained" }).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: record!.id, sourceKind: "issue", sourceId: source!.id });
|
|
await db.insert(caseIssueLinks).values({ companyId: company.id, caseId: record!.id, issueId: source!.id, role: "origin" });
|
|
await db.delete(caseIssueLinks).where(eq(caseIssueLinks.caseId, record!.id));
|
|
const actor = { type: "agent" as const, companyId: company.id, agentId: owner.id };
|
|
expect(await canActorReadCase(db, actor, record!.id)).toBe(true);
|
|
await db.delete(issues).where(eq(issues.id, source!.id));
|
|
expect(await canActorReadCase(db, actor, record!.id)).toBe(false);
|
|
expect(await db.select().from(caseAccessSources).where(eq(caseAccessSources.caseId, record!.id))).toHaveLength(1);
|
|
});
|
|
|
|
it("prospectively grants only the transferred task, preserving unrelated retained boundaries", async () => {
|
|
const company = await seedCompany("DEST");
|
|
const owner = await seedAgent(company.id);
|
|
const recipient = await seedAgent(company.id);
|
|
const [primary, unrelated] = await db.insert(issues).values([
|
|
{ companyId: company.id, title: "Primary", visibility: "private", assigneeAgentId: owner.id },
|
|
{ companyId: company.id, title: "Other source", visibility: "private", assigneeAgentId: owner.id },
|
|
]).returning();
|
|
const [record] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, caseType: "work", title: "Prospective", workIssueId: primary!.id }).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: record!.id, sourceKind: "issue", sourceId: primary!.id });
|
|
const actor = { type: "agent" as const, companyId: company.id, agentId: owner.id };
|
|
expect(await canActorReceiveCase(db, actor, record!, primary!.id, recipient.id)).toBe(true);
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: record!.id, sourceKind: "issue", sourceId: unrelated!.id });
|
|
expect(await canActorReceiveCase(db, actor, record!, primary!.id, recipient.id)).toBe(false);
|
|
});
|
|
|
|
it("keeps case assets and activity private, including detached attachment links", async () => {
|
|
const company = await seedCompany("EXTERNAL");
|
|
const owner = await seedAgent(company.id);
|
|
const outsider = await seedAgent(company.id);
|
|
const [source] = await db.insert(issues).values({ companyId: company.id, title: "Private source", visibility: "private", assigneeAgentId: owner.id }).returning();
|
|
const [privateCase, openCase] = await db.insert(cases).values([
|
|
{ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, caseType: "work", title: "Private" },
|
|
{ companyId: company.id, caseNumber: 2, identifier: `${company.issuePrefix}-C2`, caseType: "work", title: "Open" },
|
|
]).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: privateCase!.id, sourceKind: "issue", sourceId: source!.id });
|
|
const [asset] = await db.insert(assets).values({ companyId: company.id, provider: "local_disk", objectKey: `${company.id}/cases/${privateCase!.id}/2026/10/19/evidence.txt`, contentType: "text/plain", byteSize: 1, sha256: "a".repeat(64), originalFilename: "evidence.txt", createdByAgentId: owner.id }).returning();
|
|
await db.insert(caseAttachments).values({ companyId: company.id, caseId: privateCase!.id, assetId: asset!.id });
|
|
const actor = { type: "agent" as const, companyId: company.id, agentId: outsider.id };
|
|
expect(await canActorReadAssetContent(db, actor, asset!)).toBe(false);
|
|
expect(await canActorReadAssetContent(db, { ...actor, agentId: owner.id }, asset!)).toBe(true);
|
|
await db.delete(caseAttachments).where(eq(caseAttachments.assetId, asset!.id));
|
|
expect(await canActorReadAssetContent(db, actor, asset!)).toBe(false);
|
|
await db.insert(activityLog).values([
|
|
{ companyId: company.id, actorType: "agent", actorId: owner.id, agentId: owner.id, action: "case.updated", entityType: "case", entityId: privateCase!.id, details: { title: "Secret" }, createdAt: new Date("2030-01-01") },
|
|
{ companyId: company.id, actorType: "agent", actorId: owner.id, agentId: owner.id, action: "case.updated", entityType: "case", entityId: openCase!.id, details: { title: "Open" } },
|
|
]);
|
|
const caseReadCondition = await caseActivityReadSqlCondition(db, actor);
|
|
const activity = await activityService(db).list({ companyId: company.id, caseReadCondition, limit: 1 });
|
|
expect(activity.map(row => row.entityId)).toEqual([openCase!.id]);
|
|
const audit = await agentActionAuditService(db).list({ companyId: company.id, caseReadCondition, limit: 1 });
|
|
expect(audit.items.map(row => row.entityId)).toEqual([openCase!.id]);
|
|
expect(audit.nextCursor).toBeNull();
|
|
});
|
|
|
|
it("keeps source-scoped cases inside the canonical low-trust read boundary", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("SCOPE");
|
|
const scopedAgent = await seedAgent(company.id);
|
|
const rootId = randomUUID();
|
|
await db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", rootIssueId: rootId } } } }).where(eq(agents.id, scopedAgent.id));
|
|
const [root, outside] = await db.insert(issues).values([
|
|
{ id: rootId, companyId: company.id, title: "Allowed root", assigneeAgentId: scopedAgent.id },
|
|
{ companyId: company.id, title: "Outside root" },
|
|
]).returning();
|
|
const [allowedCase, outsideCase] = await db.insert(cases).values([
|
|
{ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, caseType: "work", title: "Allowed" },
|
|
{ companyId: company.id, caseNumber: 2, identifier: `${company.issuePrefix}-C2`, caseType: "work", title: "Outside", updatedAt: new Date("2030-01-01") },
|
|
]).returning();
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: allowedCase!.id, sourceKind: "issue", sourceId: root!.id });
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: outsideCase!.id, sourceKind: "issue", sourceId: outside!.id });
|
|
const actor = { type: "agent" as const, companyId: company.id, agentId: scopedAgent.id, source: "agent_key" as const };
|
|
expect(await canActorReadCase(db, actor, allowedCase!.id)).toBe(true);
|
|
expect(await canActorReadCase(db, actor, outsideCase!.id)).toBe(false);
|
|
const list = await request(app(actor)).get(`/api/companies/${company.id}/cases?limit=1`).expect(200);
|
|
expect(list.body.map((row: { id: string }) => row.id)).toEqual([allowedCase!.id]);
|
|
});
|
|
|
|
it("rejects quarantined run writes across standalone case routes without restricting trusted writes", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("WRITE-TRUST");
|
|
const agent = await seedAgent(company.id);
|
|
const [source] = await db.insert(issues).values({ companyId: company.id, title: "Review source", assigneeAgentId: agent.id }).returning();
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", rootIssueId: source!.id } } };
|
|
const [containedRun, trustedRun] = await db.insert(heartbeatRuns).values([
|
|
{ companyId: company.id, agentId: agent.id, issueId: source!.id, status: "running", contextSnapshot: { executionPolicy: policy } },
|
|
{ companyId: company.id, agentId: agent.id, issueId: source!.id, status: "running", contextSnapshot: { executionPolicy: {} } },
|
|
]).returning();
|
|
const board = request(app(boardActor));
|
|
const record = await board.post(`/api/companies/${company.id}/cases`).send({ caseType: "review", key: "existing", title: "Passive case", summary: "Trusted summary", fields: { trusted: true } }).expect(201);
|
|
await board.post(`/api/cases/${record.body.id}/links`).send({ issueId: source!.id, role: "work" }).expect(201);
|
|
await board.put(`/api/cases/${record.body.id}/documents/existing`).send({ body: "Trusted document" }).expect(200);
|
|
const eventsBefore = await db.select().from(caseEvents);
|
|
const sourcesBefore = await db.select().from(caseAccessSources);
|
|
const token = createLocalAgentJwt(agent.id, company.id, agent.adapterType, containedRun!.id);
|
|
const http = request(authenticatedApp());
|
|
const authorization = `Bearer ${token}`;
|
|
await http.get(`/api/cases/${record.body.id}`).set("Authorization", authorization).expect(200);
|
|
await http.post(`/api/companies/${company.id}/cases`).set("Authorization", authorization)
|
|
.send({ caseType: "review", key: "new", title: "Raw case", fields: { raw: "quarantined" } }).expect(403);
|
|
await http.post(`/api/companies/${company.id}/cases`).set("Authorization", authorization)
|
|
.send({ caseType: "review", key: "existing", title: "Raw upsert", summary: "Raw instructions" }).expect(403);
|
|
await http.patch(`/api/cases/${record.body.id}`).set("Authorization", authorization)
|
|
.send({ summary: "Raw instructions", fields: { raw: "quarantined" } }).expect(403);
|
|
await http.put(`/api/cases/${record.body.id}/documents/raw`).set("Authorization", authorization)
|
|
.send({ body: "Quarantined instructions" }).expect(403);
|
|
await http.post(`/api/cases/${record.body.id}/documents/existing/lock`).set("Authorization", authorization).expect(403);
|
|
await http.delete(`/api/cases/${record.body.id}/documents/existing`).set("Authorization", authorization).expect(403);
|
|
await http.post(`/api/cases/${record.body.id}/links`).set("Authorization", authorization)
|
|
.send({ issueId: source!.id, role: "work" }).expect(403);
|
|
await http.post(`/api/cases/${record.body.id}/attachments`).set("Authorization", authorization)
|
|
.attach("file", Buffer.from("Quarantined attachment"), "raw.txt").expect(403);
|
|
const [unchanged] = await db.select().from(cases).where(eq(cases.id, record.body.id));
|
|
expect(unchanged).toMatchObject({ title: "Passive case", summary: "Trusted summary", fields: { trusted: true }, workIssueId: null });
|
|
expect(await db.select().from(cases)).toHaveLength(1);
|
|
expect(await db.select().from(documents)).toHaveLength(1);
|
|
expect(await db.select().from(assets)).toHaveLength(0);
|
|
expect(await db.select().from(caseEvents)).toEqual(eventsBefore);
|
|
expect(await db.select().from(caseAccessSources)).toEqual(sourcesBefore);
|
|
const trusted = `Bearer ${createLocalAgentJwt(agent.id, company.id, agent.adapterType, trustedRun!.id)}`;
|
|
await http.patch(`/api/cases/${record.body.id}`).set("Authorization", trusted).send({ fields: { sanitized: true } }).expect(200);
|
|
await http.put(`/api/cases/${record.body.id}/documents/sanitized`).set("Authorization", trusted).send({ body: "Trusted finding" }).expect(200);
|
|
await expect(assertCaseTrustedForExecution(db, { id: record.body.id, companyId: company.id })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("blocks historical low-trust case output from execution while preserving authorized reads", async () => {
|
|
await enableCases();
|
|
const company = await seedCompany("HISTORICAL-TRUST");
|
|
const agent = await seedAgent(company.id);
|
|
const [source] = await db.insert(issues).values({ companyId: company.id, title: "Retained source", assigneeAgentId: agent.id }).returning();
|
|
const record = await request(app(boardActor)).post(`/api/companies/${company.id}/cases`)
|
|
.send({ caseType: "review", title: "Historical case", fields: { unreviewed: "Old raw output" } }).expect(201);
|
|
await retainCaseAccessSource(db, { companyId: company.id, caseId: record.body.id, sourceKind: "issue", sourceId: source!.id });
|
|
await expect(assertCaseTrustedForExecution(db, { id: record.body.id, companyId: company.id })).resolves.toBeUndefined();
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", rootIssueId: source!.id } } };
|
|
await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, source!.id));
|
|
await expect(assertCaseTrustedForExecution(db, { id: record.body.id, companyId: company.id })).rejects.toMatchObject({ status: 403 });
|
|
await db.update(issues).set({ executionPolicy: {} }).where(eq(issues.id, source!.id));
|
|
const [oldRun] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: source!.id,
|
|
status: "succeeded", contextSnapshot: { executionPolicy: policy } }).returning();
|
|
// Represents output persisted before cases enforced quarantine; current task
|
|
// and agent settings are now standard, but the recorded source run is not.
|
|
await db.insert(caseEvents).values({ companyId: company.id, caseId: record.body.id, kind: "fields_changed",
|
|
actorType: "agent", actorAgentId: agent.id, runId: oldRun!.id });
|
|
await expect(assertCaseTrustedForExecution(db, { id: record.body.id, companyId: company.id })).rejects.toMatchObject({ status: 403 });
|
|
const readable = await request(app(boardActor)).get(`/api/cases/${record.body.id}`).expect(200);
|
|
expect(readable.body.fields).toEqual({ unreviewed: "Old raw output" });
|
|
});
|
|
|
|
it("preserves historical source attribution for a delivered board-originated handoff", async () => {
|
|
const company = await seedCompany("HISTORY");
|
|
const previous = await seedAgent(company.id), recipient = await seedAgent(company.id);
|
|
const [task] = await db.insert(issues).values({ companyId: company.id, title: "Historical workflow", assigneeAgentId: recipient.id }).returning();
|
|
const [record] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, caseType: "work", title: "History", workIssueId: task!.id }).returning();
|
|
const [handoff] = await db.insert(caseHandoffs).values({ companyId: company.id, caseId: record!.id, issueId: task!.id, version: 1,
|
|
fromAgentId: previous.id, toAgentId: recipient.id, phase: "implementation", instructions: "Continue this work", actorType: "user", actorId: "fixture-board",
|
|
wakeupRequestId: randomUUID(), idempotencyKey: "history", inputFingerprint: "history", state: "delivered" }).returning();
|
|
await expect(db.delete(agents).where(eq(agents.id, previous.id))).rejects.toThrow();
|
|
expect((await db.select().from(caseHandoffs).where(eq(caseHandoffs.id, handoff!.id)))[0]!.fromAgentId).toBe(previous.id);
|
|
});
|
|
|
|
});
|