Files
PaperClipAI/server/src/__tests__/activity-routes.test.ts
T
Devin FoleyandPaperclip 1a79522a4e perf: narrow run-to-issue authorization reads (#17405)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Run pages show links to issues associated with a run.
> - The issue-link route checks the run's company and issue visibility
before it reads those links.
> - That check needs four binding fields, but the route reads the full
run.
> - Full runs can include large prompt, output, and result fields.
> - This pull request uses the existing narrow authorization read.
> - The route transfers less database data and keeps its permission
checks and response contract.

## Linked Issues or Issue Description

**Problem or motivation**

`GET /api/heartbeat-runs/:runId/issues` calls `heartbeat.getRun` for a
permission check. Its consumers only need the run ID, company ID, scope
kind, and issue ID. The full read also fetches context, output excerpts,
result data, and runtime metadata. Repeated link reads pay for that
unused data.

**Proposed solution**

Use `getRunReadAccess`, the existing four-field read from #17348. Keep
authentication, company access, company-scope authorization, run issue
visibility, and linked-issue filtering in their current owners. Keep the
existing empty response for missing and inaccessible runs.

**Alternatives considered**

A new query contract is unnecessary because the existing read provides
all required fields. A permission cache could become stale. Changing the
full run reader could affect consumers that need run details.

**Roadmap alignment**

This is a focused improvement to an existing route. It adds no feature,
route, provider, cache, or background work. Searches for run-issue
links, narrow run authorization, and `getRunReadAccess` found no open
duplicate. Related work: #17348.

## What Changed

- Change the issue-link route to use the existing narrow run read.
- Reject full run reads in the route tests. Cover readable company and
issue scopes, denied issue access, missing bindings, anonymous requests,
and the missing/cross-company empty response.
- Add a developer note about the read contract.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/activity-routes.test.ts
src/services/heartbeat/run-state.test.ts
src/__tests__/agent-live-run-routes.test.ts`: 126 tests passed.
- `pnpm build`: passed.
- `pnpm -r typecheck`: passed.
- The route tests use the real run visibility helper with only the four
binding fields. The full reader throws if a route calls it. The existing
run-state suite checks the exact four-column selection and covers
database wiring with disposable Postgres.
- `git diff --check`: passed. Reviewed the committed diff for secrets
and private identifiers before push.
- Apex review: 5/5 on exact head
`888a4ce9ea88bb2b284eb5e28fafc01b6f48b386`, completed successfully, with
zero review threads.
- Full current-head CI passed: 54 successful checks and two expected
skips. [Workflow attempt
2](https://github.com/paperclipai/paperclip/actions/runs/40051399577/attempts/2).
[Initial serialized shard
failure](https://github.com/paperclipai/paperclip/actions/runs/40051399577/job/120744710081):
the first company-history case in unchanged
`agent-live-run-routes.test.ts` hit its 15-second timeout; the shard had
391 passing tests and one failure. That file, its agent route, the
heartbeat state reader, and the service export are unchanged from base.
The five company-history cases pass on unchanged base
`9fb28d06ffebbb615f413765078aeeb7c4291311` (9.12s suite) and this head
(8.29s suite). They also passed in the 126-test focused run. The timeout
was not reproduced locally; its cause is not established. Only the
failed shard was retried on the same head. It passed in attempt 2. This
establishes recovery; the initial timeout remains in this record and its
cause is unproven.
- The prior local full-suite failures and baseline triage remain
documented in #17384. No new local full-suite pass is claimed here. This
change uses focused local tests and the full current-head CI gate.
- No hosted user journey, staging deployment, production deployment, or
billed savings is claimed for this follow-up.

## Risks

- Run visibility depends on the canonical `scopeKind` and `issueId`. The
existing helper returns those same fields. It does not use prompt or
result data for access decisions.
- Linked issues still pass the existing SQL visibility filter. Missing
and cross-company runs still return `200 []`. Anonymous requests still
fail before the run read.
- The database query count stays the same. The change reduces data
selected for this authorization read.
- No schema or API response changes.

## Model Used

OpenAI Codex, GPT-6 family. The exact deployment model ID and context
window are not exposed in this session. Used reasoning, repository
tools, and code execution.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-23 19:59:21 -07:00

439 lines
17 KiB
TypeScript

import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockActivityService = vi.hoisted(() => ({
list: vi.fn(),
forIssue: vi.fn(),
runsForIssue: vi.fn(),
issuesForRun: vi.fn(),
create: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
getRun: vi.fn(),
getRunReadAccess: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getByIdentifier: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
canUser: vi.fn(),
}));
const mockAgentActionAuditService = vi.hoisted(() => ({
list: vi.fn(),
}));
const caseReadCondition = vi.hoisted(() => ({ policy: "authorized-cases" }));
vi.mock("../services/case-activity-access.js", () => ({
caseActivityReadSqlCondition: vi.fn(async () => caseReadCondition),
}));
vi.mock("../services/activity.js", () => ({
activityService: () => mockActivityService,
normalizeActivityLimit: (limit: number | undefined) => {
if (!Number.isFinite(limit)) return 100;
return Math.max(1, Math.min(500, Math.floor(limit ?? 100)));
},
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
}));
vi.mock("../services/authorization.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/authorization.js")>();
return {
...actual,
issueReadSqlCondition: vi.fn(async () => ({}) as any),
};
});
vi.mock("../services/agent-action-audit.js", () => ({
agentActionAuditService: () => mockAgentActionAuditService,
}));
async function createApp(
actor: Record<string, unknown> = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
database: unknown = {},
) {
vi.resetModules();
const [{ errorHandler }, { activityRoutes }] = await Promise.all([
import("../middleware/index.js") as Promise<typeof import("../middleware/index.js")>,
import("../routes/activity.js") as Promise<typeof import("../routes/activity.js")>,
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
...actor,
companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds,
};
next();
});
app.use("/api", activityRoutes(database as any));
app.use(errorHandler);
return app;
}
async function requestApp(
app: express.Express,
buildRequest: (baseUrl: string) => request.Test,
) {
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
const server = createServer(app);
try {
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected HTTP server to listen on a TCP port");
}
return await buildRequest(`http://127.0.0.1:${address.port}`);
} finally {
if (server.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
}
}
describe("activity routes", () => {
beforeEach(() => {
for (const mock of Object.values(mockActivityService)) mock.mockReset();
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
mockHeartbeatService.getRun.mockImplementation(() => {
throw new Error("Run issue links must use the narrow authorization read");
});
for (const mock of Object.values(mockIssueService)) mock.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.canUser.mockReset();
mockAgentActionAuditService.list.mockReset();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockAccessService.canUser.mockResolvedValue(false);
});
it("lets an agent read activity for an authorized issue", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1", companyId: "company-1", projectId: null, parentId: null,
assigneeAgentId: "agent-1", assigneeUserId: null, status: "done",
});
mockActivityService.forIssue.mockResolvedValue([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/TEST-1/activity"));
expect(res.status).toBe(200);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:read", resource: expect.objectContaining({ companyId: "company-1", issueId: "issue-1" }),
}));
expect(mockActivityService.forIssue).toHaveBeenCalledWith("issue-1");
expect(res.body).toEqual([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
});
it.each(["boundary", "company"])("denies agent activity reads outside the %s scope", async (kind) => {
const issue = { id: "issue-1", companyId: kind === "company" ? "company-2" : "company-1" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getByIdentifier.mockResolvedValue(issue);
mockAccessService.decide.mockResolvedValue({ allowed: false });
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity"));
expect(res.status).toBe(kind === "company" ? 404 : 403);
expect(mockActivityService.forIssue).not.toHaveBeenCalled();
});
it("returns redacted all-actors rows to a basic company reader", async () => {
mockAgentActionAuditService.list.mockResolvedValue({
items: [{
id: "activity-1",
companyId: "company-1",
actorType: "plugin",
actorId: "plugin-1",
action: "plugin.synced",
entityType: "company",
entityId: "company-1",
agentId: "agent-1",
runId: "run-1",
responsibleUserId: "user-2",
details: { privateAttribution: true },
createdAt: "2026-08-04T00:00:00.000Z",
entity: { issue: null, comment: null, document: null },
}],
nextCursor: null,
});
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/audit/agent-actions?actorScope=all");
expect(res.status).toBe(200);
expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({
companyId: "company-1",
actorScope: "all",
limit: 50,
caseReadCondition,
});
expect(res.body.items[0]).toMatchObject({
actorType: "plugin",
actorId: "plugin-1",
action: "plugin.synced",
entityType: "company",
entityId: "company-1",
createdAt: "2026-08-04T00:00:00.000Z",
agentId: null,
runId: null,
responsibleUserId: null,
details: null,
});
expect(res.body.accessTier).toBe("basic");
});
it("rejects attribution filters for a basic all-actors reader", async () => {
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/audit/agent-actions?actorScope=all&runId=00000000-0000-4000-8000-000000000001");
expect(res.status).toBe(403);
expect(res.body.error).toContain("audit:view_agent_actions");
expect(mockAgentActionAuditService.list).not.toHaveBeenCalled();
});
it("keeps attribution for a permitted all-actors reader", async () => {
mockAccessService.canUser.mockResolvedValue(true);
mockAgentActionAuditService.list.mockResolvedValue({
items: [{
id: "activity-1",
agentId: "agent-1",
runId: "run-1",
responsibleUserId: "user-2",
details: { attribution: true },
}],
nextCursor: null,
});
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/audit/agent-actions?actorScope=all&actorType=system");
expect(res.status).toBe(200);
expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({
companyId: "company-1",
actorScope: "all",
actorType: "system",
limit: 50,
caseReadCondition,
});
expect(res.body.items[0]).toMatchObject({
agentId: "agent-1",
runId: "run-1",
responsibleUserId: "user-2",
details: { attribution: true },
});
expect(res.body.accessTier).toBe("full");
});
it("limits company activity lists by default", async () => {
mockActivityService.list.mockResolvedValue([]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/companies/company-1/activity"));
expect(res.status).toBe(200);
expect(mockActivityService.list).toHaveBeenCalledWith({
companyId: "company-1",
agentId: undefined,
entityType: undefined,
entityId: undefined,
limit: 100,
readCondition: expect.anything(),
caseReadCondition,
});
});
it("caps requested company activity list limits", async () => {
mockActivityService.list.mockResolvedValue([]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl).get("/api/companies/company-1/activity?limit=5000&entityType=issue"),
);
expect(res.status).toBe(200);
expect(mockActivityService.list).toHaveBeenCalledWith({
companyId: "company-1",
agentId: undefined,
entityType: "issue",
entityId: undefined,
limit: 500,
readCondition: expect.anything(),
caseReadCondition,
});
});
it("resolves alphanumeric issue identifiers before loading runs", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-uuid-1",
companyId: "company-1",
});
mockActivityService.runsForIssue.mockResolvedValue([
{
runId: "run-1",
adapterType: "codex_local",
},
]);
const app = await createApp();
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/pc1a2-475/runs"));
expect(res.status).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-475");
expect(mockIssueService.getById).not.toHaveBeenCalled();
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1", "user-1");
expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]);
});
it("requires company access before creating activity events", async () => {
const app = await createApp();
const res = await requestApp(app, (baseUrl) => request(baseUrl)
.post("/api/companies/company-2/activity")
.send({
actorId: "user-1",
action: "test.event",
entityType: "issue",
entityId: "issue-1",
}));
expect(res.status).toBe(403);
expect(mockActivityService.create).not.toHaveBeenCalled();
});
it.each([null, { id: "issue-uuid-1", companyId: "company-2" }])(
"does not start run reads or retries for a missing or cross-company task", async (issue) => {
mockIssueService.getByIdentifier.mockResolvedValue(issue);
const app = await createApp();
const res = await request(app).get("/api/issues/TEST-1/runs");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Issue not found" });
expect(mockActivityService.runsForIssue).not.toHaveBeenCalled();
},
);
it("does not start run reads or retries when task read authorization is denied", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-uuid-1", companyId: "company-1" });
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "denied", explanation: "Denied by test policy." });
const app = await createApp();
const res = await request(app).get("/api/issues/TEST-1/runs");
expect(res.status).toBe(403);
expect(mockActivityService.runsForIssue).not.toHaveBeenCalled();
});
it("passes task read filtering when listing links for a readable company run", async () => {
mockHeartbeatService.getRunReadAccess.mockResolvedValue({
id: "run-1", companyId: "company-1", scopeKind: "company", issueId: null,
});
mockActivityService.issuesForRun.mockResolvedValue([{ issueId: "public-issue", title: "Visible task" }]);
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/heartbeat-runs/run-1/issues"));
expect(res.status).toBe(200);
expect(mockHeartbeatService.getRunReadAccess).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
const { issueReadSqlCondition } = await import("../services/authorization.js");
expect(issueReadSqlCondition).toHaveBeenCalledWith({}, expect.objectContaining({
type: "agent", companyId: "company-1", agentId: "agent-1",
}));
expect(mockActivityService.issuesForRun).toHaveBeenCalledWith("run-1", {});
expect(res.body).toEqual([{ issueId: "public-issue", title: "Visible task" }]);
});
it("returns 200 [] (not 404) when listing issues for another company's run, preserving API contract and the cross-tenant oracle", async () => {
mockHeartbeatService.getRunReadAccess.mockResolvedValue({
id: "run-2",
companyId: "company-2",
});
const app = await createApp();
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/run-2/issues"));
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
it("returns 200 [] (not 404) for a non-existent heartbeat run, matching the cross-tenant response", async () => {
mockHeartbeatService.getRunReadAccess.mockResolvedValue(null);
const app = await createApp();
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/missing-run/issues"));
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => {
const app = await createApp({ type: "none", source: "none" });
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/heartbeat-runs/missing-run/issues"));
expect(res.status).toBe(401);
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockHeartbeatService.getRunReadAccess).not.toHaveBeenCalled();
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
it.each([true, false])("preserves issue-scoped run authorization when read permission is %s", async (allowed) => {
mockHeartbeatService.getRunReadAccess.mockResolvedValue({
id: "run-1", companyId: "company-1", scopeKind: "issue", issueId: "issue-private",
});
mockAccessService.decide.mockImplementation(async ({ action }) => ({
allowed: action === "issue:read" ? allowed : true,
}));
mockActivityService.issuesForRun.mockResolvedValue([{ issueId: "visible-issue" }]);
const db = {
select: () => ({ from: () => ({ where: async () => [{ id: "issue-private" }] }) }),
};
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" }, db);
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/heartbeat-runs/run-1/issues"));
expect(res.status).toBe(200);
expect(res.body).toEqual(allowed ? [{ issueId: "visible-issue" }] : []);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:read", resource: expect.objectContaining({ companyId: "company-1", issueId: "issue-private" }),
}));
expect(mockHeartbeatService.getRunReadAccess).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockActivityService.issuesForRun).toHaveBeenCalledTimes(allowed ? 1 : 0);
});
it("does not read issue links for a run without a trusted issue binding", async () => {
mockHeartbeatService.getRunReadAccess.mockResolvedValue({
id: "run-1", companyId: "company-1", scopeKind: "issue", issueId: null,
});
const app = await createApp();
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/heartbeat-runs/run-1/issues"));
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
});
});