From 338c58fb248e19d0b65a050074d82f2a83d28f21 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Nov 2026 11:20:15 -0600 Subject: [PATCH] test(auth): require existing membership and verify grant audit rollback Refuse grants without an existing active membership. Verify audit failures roll back permission changes. Co-Authored-By: Paperclip --- .../src/__tests__/agent-permission-grants.test.ts | 15 ++++++++++++++- server/src/routes/agent-permission-grants.ts | 5 ++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/agent-permission-grants.test.ts b/server/src/__tests__/agent-permission-grants.test.ts index 1ac61da295a..5dbbf69c0cc 100644 --- a/server/src/__tests__/agent-permission-grants.test.ts +++ b/server/src/__tests__/agent-permission-grants.test.ts @@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { and, eq } from "drizzle-orm"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, agents, authUsers, companies, companyMemberships, createDb, principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { agentPermissionGrantRoutes } from "../routes/agent-permission-grants.js"; import { agentCommentaryRoutes } from "../routes/agent-commentary.js"; import { errorHandler } from "../middleware/error-handler.js"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import * as activityLogging from "../services/activity-log.js"; describe("Board agent permission grants", () => { let database: Awaited>; @@ -103,6 +104,9 @@ describe("Board agent permission grants", () => { eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId))); expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); expect(await grants(f)).toEqual([]); + await db.delete(companyMemberships).where(and( + eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId))); + expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); }); it("serializes concurrent additions of the same grant", async () => { @@ -111,4 +115,13 @@ describe("Board agent permission grants", () => { expect(responses.map(response => response.status)).toEqual([200, 200]); expect(await grants(f)).toHaveLength(1); }); + + it("rolls back the grant if its audit record cannot be saved", async () => { + const f = await seed(); + const failure = vi.spyOn(activityLogging, "logActivity").mockRejectedValueOnce(new Error("Audit storage unavailable")); + try { + expect((await request(owner(f)).patch(path(f)).send(grant)).status).toBe(500); + expect(await grants(f)).toEqual([]); + } finally { failure.mockRestore(); } + }); }); diff --git a/server/src/routes/agent-permission-grants.ts b/server/src/routes/agent-permission-grants.ts index 28a0843e704..ae2e529d142 100644 --- a/server/src/routes/agent-permission-grants.ts +++ b/server/src/routes/agent-permission-grants.ts @@ -31,17 +31,16 @@ export function agentPermissionGrantRoutes(db: Db) { } if (enabled) { if (agent.status === "pending_approval" || agent.status === "terminated") { - throw conflict("Approve or restore the agent before granting permissions"); + throw conflict("Cannot grant permissions to an unapproved or terminated agent"); } const membership = await tx.select().from(companyMemberships).where(and( eq(companyMemberships.companyId, agent.companyId), eq(companyMemberships.principalType, "agent"), eq(companyMemberships.principalId, agent.id), )).for("update").then(rows => rows[0]); - if (membership && membership.status !== "active") { + if (!membership || membership.status !== "active") { throw conflict("Agent company membership is not active"); } - if (!membership) await access.ensureMembership(agent.companyId, "agent", agent.id); await tx.insert(principalPermissionGrants).values({ companyId: agent.companyId, principalType: "agent", principalId: agent.id, permissionKey, scope: null, grantedByUserId: req.actor.userId ?? null,