mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path
> - Paperclip lets people control their AI agents and permissions.
> - Remote operators need a browser-approved CLI login.
> - Proxies can send that login's approval link to an internal host.
> - Human member commands cannot grant an agent an explicit permission
such as commentary:view.
> - This change keeps approval on the canonical public origin and adds
Board-only single-grant commands.
> - The API checks company access and permission-management authority
and records each change.
> - An operator can sign in, grant one agent permission, and preserve
the agent's other grants.
## Linked Issues or Issue Description
**What happened?**
A proxied CLI login returned an approval URL on the upstream host. The
signed-in browser was on the public host. The CLI also had no command to
add an arbitrary explicit agent grant: member commands manage human
members, while agent permissions:update exposes fixed configuration
fields.
**Expected behavior**
Approval uses the configured canonical public origin. A Board operator
with users:manage_permissions can grant or revoke one explicit agent
permission without replacing other grants or activating an inactive
membership.
**Steps to reproduce**
Configure a public auth origin. Create a CLI challenge through a proxy
that replaces forwarded-host headers. Inspect the approval URL. Then try
to grant commentary:view to an agent through the CLI.
## What Changed
- Use the existing public-origin resolver for CLI approval URLs.
- Add agent permission:grant and permission:revoke commands.
- Add PATCH /api/agents/{id}/permission-grants with strict shared
validation and Board-only OpenAPI metadata.
- Serialize each mutation on the agent row. Preserve unrelated grants
and membership state. Commit the grant and its activity record together
and publish activity after commit.
- Document remote login and single-grant management.
## Verification
- The new approval-link regression failed before the fix and passes
after it.
- All 25 CLI auth route tests pass.
- All 72 affected CLI, login and OpenAPI tests pass.
- All 5 PostgreSQL grant tests pass, including actual commentary reads
(403 before grant, 200 after grant, 403 after revoke), preservation,
audit attribution, concurrent additions, invalid inputs, inactive or
missing membership, agent self-grants, cross-company denial, and
rollback on audit failure.
- Shared and CLI typechecks and server declaration-layer checks pass.
- pnpm verify requests broad CI coverage because doc/CLI.md is a global
verification input. Greptile reviewed final head
338c58fb24 at 5/5 with no findings. All 58
non-skipped checks pass on that head. CI run:
https://github.com/paperclipai/paperclip/actions/runs/42663640985. Live
target acceptance is in progress.
## Risks
This adds an explicit permission-management API. It requires Board
authentication, company access, and users:manage_permissions.
Foreign-company agents return 404. Granting a key intentionally makes
that key unscoped in its company; revocation removes that key. Other
keys and agent configuration remain unchanged. Inactive memberships and
unapproved or terminated agents cannot gain grants. No schema change is
needed. Revert this change to remove the new management surface;
existing grants retain their normal authorization rules.
## Model Used
OpenAI GPT-6 through Codex, with reasoning, code editing, shell tools,
and browser testing. The runtime does not expose a more specific model
ID or context window.
## 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>
590 lines
25 KiB
TypeScript
590 lines
25 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createInviteRateLimiter, type InviteRateLimiter } from "../services/invite-rate-limit.js";
|
|
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
isInstanceAdmin: vi.fn(),
|
|
hasPermission: vi.fn(),
|
|
canUser: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockBoardAuthService = vi.hoisted(() => ({
|
|
createCliAuthChallenge: vi.fn(),
|
|
describeCliAuthChallenge: vi.fn(),
|
|
approveCliAuthChallenge: vi.fn(),
|
|
cancelCliAuthChallenge: vi.fn(),
|
|
resolveBoardAccess: vi.fn(),
|
|
resolveBoardActivityCompanyIds: vi.fn(),
|
|
assertCurrentBoardKey: vi.fn(),
|
|
revokeBoardApiKey: vi.fn(),
|
|
listBoardApiKeys: vi.fn(),
|
|
createNamedBoardApiKey: vi.fn(),
|
|
getBoardApiKeyForUser: vi.fn(),
|
|
}));
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
accessService: () => mockAccessService,
|
|
agentService: () => mockAgentService,
|
|
boardAuthService: () => mockBoardAuthService,
|
|
logActivity: mockLogActivity,
|
|
notifyHireApproved: vi.fn(),
|
|
deduplicateAgentName: vi.fn((name: string) => name),
|
|
}));
|
|
|
|
function registerModuleMocks() {
|
|
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
|
|
|
vi.doMock("../services/index.js", () => ({
|
|
accessService: () => mockAccessService,
|
|
agentService: () => mockAgentService,
|
|
boardAuthService: () => mockBoardAuthService,
|
|
logActivity: mockLogActivity,
|
|
notifyHireApproved: vi.fn(),
|
|
deduplicateAgentName: vi.fn((name: string) => name),
|
|
}));
|
|
}
|
|
|
|
let appImportCounter = 0;
|
|
|
|
async function createApp(actor: any, db: any = {} as any, options: { authPublicBaseUrl?: string; cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) {
|
|
appImportCounter += 1;
|
|
const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`;
|
|
const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`;
|
|
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
|
import(routeModulePath) as Promise<typeof import("../routes/access.js")>,
|
|
import(middlewareModulePath) as Promise<typeof import("../middleware/index.js")>,
|
|
]);
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = {
|
|
...actor,
|
|
companyIds: Array.isArray(actor.companyIds) ? [...actor.companyIds] : actor.companyIds,
|
|
memberships: Array.isArray(actor.memberships)
|
|
? actor.memberships.map((membership: unknown) =>
|
|
typeof membership === "object" && membership !== null
|
|
? { ...membership }
|
|
: membership,
|
|
)
|
|
: actor.memberships,
|
|
};
|
|
next();
|
|
});
|
|
app.use(
|
|
"/api",
|
|
accessRoutes(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bindHost: "127.0.0.1",
|
|
allowedHostnames: [],
|
|
...options,
|
|
}),
|
|
);
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describe("cli auth routes", () => {
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("../services/index.js");
|
|
vi.doUnmock("../routes/authz.js");
|
|
vi.doUnmock("../routes/access.js");
|
|
vi.doUnmock("../middleware/index.js");
|
|
registerModuleMocks();
|
|
vi.resetAllMocks();
|
|
// Cold route transforms are fixture setup, not HTTP request latency.
|
|
// Use the hook budget while preserving the request tests' normal timeout.
|
|
await Promise.all([import("../routes/access.js"), import("../middleware/index.js")]);
|
|
});
|
|
|
|
it("creates a CLI auth challenge with approval metadata", async () => {
|
|
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
|
challenge: {
|
|
id: "12345678-1234-4123-8123-123456789abc",
|
|
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
|
},
|
|
challengeSecret: "pcp_cli_auth_secret",
|
|
pendingBoardToken: "pcp_board_token",
|
|
});
|
|
|
|
const app = await createApp({ type: "none", source: "none" });
|
|
const res = await request(app)
|
|
.post("/api/cli-auth/challenges")
|
|
.send({
|
|
command: "paperclipai company import",
|
|
clientName: "paperclipai cli",
|
|
requestedAccess: "board",
|
|
});
|
|
|
|
expect(res.status, res.text || JSON.stringify(res.body)).toBe(201);
|
|
expect(res.body).toMatchObject({
|
|
id: "12345678-1234-4123-8123-123456789abc",
|
|
token: "pcp_cli_auth_secret",
|
|
approvalPath: "/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret",
|
|
pollPath: "/cli-auth/challenges/12345678-1234-4123-8123-123456789abc",
|
|
expiresAt: "2026-03-23T13:00:00.000Z",
|
|
});
|
|
expect(res.body.boardApiToken).toBe("pcp_board_token");
|
|
expect(res.body.approvalUrl).toContain("/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
|
|
});
|
|
|
|
it("uses the configured public origin for CLI approval behind an upstream proxy", async () => {
|
|
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
|
challenge: {
|
|
id: "12345678-1234-4123-8123-123456789abc",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
challengeSecret: "pcp_cli_auth_secret",
|
|
pendingBoardToken: "pcp_board_token",
|
|
});
|
|
const app = await createApp({ type: "none", source: "none" }, {}, {
|
|
authPublicBaseUrl: "https://workspace.example.com/",
|
|
});
|
|
const res = await request(app)
|
|
.post("/api/cli-auth/challenges")
|
|
.set("Host", "upstream.example.net")
|
|
.set("X-Forwarded-Host", "upstream.example.net")
|
|
.set("X-Forwarded-Proto", "https")
|
|
.send({ command: "paperclipai auth login", requestedAccess: "board" });
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.approvalUrl).toBe(
|
|
`https://workspace.example.com${res.body.approvalPath}`,
|
|
);
|
|
});
|
|
|
|
it("bounds anonymous create and poll independently without trusting spoofed forwarding headers", async () => {
|
|
let now = 1_000;
|
|
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
|
challenge: { id: "12345678-1234-4123-8123-123456789abc", expiresAt: new Date(Date.now() + 60_000) },
|
|
challengeSecret: "pcp_cli_auth_secret", pendingBoardToken: "pcp_board_token",
|
|
});
|
|
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board" });
|
|
const app = await createApp({ type: "none", source: "none" }, {}, {
|
|
cliAuthCreateRateLimiter: createInviteRateLimiter({ maxRequests: 2, now: () => now }),
|
|
cliAuthPollRateLimiter: createInviteRateLimiter({ maxRequests: 2, now: () => now }),
|
|
});
|
|
const payload = { command: "paperclipai auth login", requestedAccess: "board" };
|
|
for (let i = 0; i < 2; i++) expect((await request(app).post("/api/cli-auth/challenges").set("X-Forwarded-For", `198.51.100.${i}`).send(payload)).status).toBe(201);
|
|
const blocked = await request(app).post("/api/cli-auth/challenges").set("X-Forwarded-For", "203.0.113.7").send(payload);
|
|
expect(blocked.status).toBe(429);
|
|
expect(blocked.headers["retry-after"]).toBe("60");
|
|
expect(blocked.headers["cache-control"]).toBe("no-store");
|
|
expect(blocked.headers["referrer-policy"]).toBe("no-referrer");
|
|
expect(mockBoardAuthService.createCliAuthChallenge).toHaveBeenCalledTimes(2);
|
|
const path = "/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret";
|
|
for (let i = 0; i < 2; i++) expect((await request(app).get(path).set("X-Forwarded-For", `198.51.100.${i}`)).status).toBe(200);
|
|
expect((await request(app).get(path).set("X-Forwarded-For", "203.0.113.8")).status).toBe(429);
|
|
expect(mockBoardAuthService.describeCliAuthChallenge).toHaveBeenCalledTimes(2);
|
|
now += 60_000;
|
|
expect((await request(app).post("/api/cli-auth/challenges").send(payload)).status).toBe(201);
|
|
expect((await request(app).get(path)).status).toBe(200);
|
|
});
|
|
|
|
it("preserves the normal one-second CLI poll cadence throughout the ten-minute challenge", async () => {
|
|
let now = Date.now();
|
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board" });
|
|
const app = await createApp({ type: "none", source: "none" });
|
|
try {
|
|
for (let i = 0; i < 600; i++) {
|
|
const response = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers["x-ratelimit-limit"]).toBe("120");
|
|
now += 1_000;
|
|
}
|
|
} finally { vi.restoreAllMocks(); }
|
|
});
|
|
|
|
it("keeps challenge capabilities out of HTTP caches and referrers", async () => {
|
|
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
|
challenge: { id: "12345678-1234-4123-8123-123456789abc", expiresAt: new Date(Date.now() + 60_000) },
|
|
challengeSecret: "pcp_cli_auth_secret", pendingBoardToken: "pcp_board_token",
|
|
});
|
|
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board" });
|
|
const app = await createApp({ type: "none", source: "none" });
|
|
const responses = [
|
|
await request(app).post("/api/cli-auth/challenges").send({ command: "paperclipai auth login" }),
|
|
await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret"),
|
|
await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc"),
|
|
await request(app).get("/api/cli-auth/challenges/not-a-uuid?token=pcp_cli_auth_secret"),
|
|
await request(app).post("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc/approve").send({ token: "pcp_cli_auth_secret" }),
|
|
];
|
|
for (const response of responses) {
|
|
expect(response.headers["cache-control"]).toBe("no-store");
|
|
expect(response.headers["referrer-policy"]).toBe("no-referrer");
|
|
}
|
|
});
|
|
|
|
it("does not offer approval for an inaccessible requested organization", async () => {
|
|
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
|
id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board", requestedCompanyId: "company-private",
|
|
});
|
|
const app = await createApp({ type: "board", source: "session", userId: "user-1", companyIds: ["company-public"] });
|
|
const response = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
|
|
expect(response.body.canApprove).toBe(false);
|
|
});
|
|
|
|
it("does not duplicate key-creation audit on a same-user approval retry", async () => {
|
|
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
|
status: "approved", created: false,
|
|
challenge: { id: "12345678-1234-4123-8123-123456789abc", boardApiKeyId: "board-key-1", requestedAccess: "board", expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
|
const app = await createApp({ type: "board", source: "session", userId: "user-1" });
|
|
const response = await request(app).post("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc/approve").send({ token: "pcp_cli_auth_secret" });
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.approved).toBe(true);
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects anonymous access to generic skill documents", async () => {
|
|
const indexApp = await createApp({ type: "none", source: "none" });
|
|
const skillApp = await createApp({ type: "none", source: "none" });
|
|
|
|
const indexRes = await request(indexApp).get("/api/skills/index");
|
|
const skillRes = await request(skillApp).get("/api/skills/paperclip");
|
|
|
|
expect(indexRes.status, JSON.stringify(indexRes.body)).toBe(401);
|
|
expect(skillRes.status, skillRes.text || JSON.stringify(skillRes.body)).toBe(401);
|
|
});
|
|
|
|
it("serves the invite-scoped paperclip skill anonymously for active invites", async () => {
|
|
const invite = {
|
|
id: "invite-1",
|
|
companyId: "company-1",
|
|
inviteType: "company_join",
|
|
allowedJoinTypes: "agent",
|
|
tokenHash: "hash",
|
|
defaultsPayload: null,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
invitedByUserId: null,
|
|
revokedAt: null,
|
|
acceptedAt: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
const db = {
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn().mockResolvedValue([invite]),
|
|
})),
|
|
})),
|
|
};
|
|
|
|
const app = await createApp({ type: "none", source: "none" }, db);
|
|
const res = await request(app).get("/api/invites/token-123/skills/paperclip");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("text/markdown");
|
|
expect(res.text).toContain("# Paperclip Skill");
|
|
});
|
|
|
|
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
|
|
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
|
id: "12345678-1234-4123-8123-123456789abc",
|
|
status: "pending",
|
|
command: "paperclipai company import",
|
|
clientName: "paperclipai cli",
|
|
requestedAccess: "board",
|
|
requestedCompanyId: null,
|
|
requestedCompanyName: null,
|
|
approvedAt: null,
|
|
cancelledAt: null,
|
|
expiresAt: "2026-03-23T13:00:00.000Z",
|
|
approvedByUser: null,
|
|
});
|
|
|
|
const app = await createApp({ type: "none", source: "none" });
|
|
const res = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.requiresSignIn).toBe(true);
|
|
expect(res.body.canApprove).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["cloud_tenant", "board", false, false, true],
|
|
["cloud_tenant", "instance_admin_required", false, false, false],
|
|
["session", "board", false, false, true],
|
|
["board_api_key", "board", false, true, false],
|
|
])("reports CLI approval for %s requesting %s", async (source, requestedAccess, isInstanceAdmin, requiresSignIn, canApprove) => {
|
|
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
|
id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess,
|
|
});
|
|
const app = await createApp({ type: "board", source, userId: "user-1", isInstanceAdmin });
|
|
const res = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ requiresSignIn, canApprove });
|
|
});
|
|
|
|
it.each(["PASTE_ID_HERE", "not-a-uuid"])("rejects malformed challenge ID %s before calling the service", async (id) => {
|
|
const app = await createApp({ type: "board", userId: "user-1", source: "session" });
|
|
const responses = [
|
|
await request(app).get(`/api/cli-auth/challenges/${id}?token=pcp_cli_auth_secret`),
|
|
await request(app).post(`/api/cli-auth/challenges/${id}/approve`).send({ token: "pcp_cli_auth_secret" }),
|
|
await request(app).post(`/api/cli-auth/challenges/${id}/cancel`).send({ token: "pcp_cli_auth_secret" }),
|
|
];
|
|
for (const response of responses) {
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toBe("Invalid CLI auth challenge ID");
|
|
}
|
|
expect(mockBoardAuthService.describeCliAuthChallenge).not.toHaveBeenCalled();
|
|
expect(mockBoardAuthService.approveCliAuthChallenge).not.toHaveBeenCalled();
|
|
expect(mockBoardAuthService.cancelCliAuthChallenge).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires authentication before approving even a malformed challenge ID", async () => {
|
|
const app = await createApp({ type: "none", source: "none" });
|
|
const res = await request(app)
|
|
.post("/api/cli-auth/challenges/PASTE_ID_HERE/approve")
|
|
.send({ token: "pcp_cli_auth_secret" });
|
|
expect(res.status).toBe(401);
|
|
expect(mockBoardAuthService.approveCliAuthChallenge).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves missing-secret and unknown-challenge responses", async () => {
|
|
const app = await createApp({ type: "none", source: "none" });
|
|
const path = "/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc";
|
|
const missing = await request(app).get(path);
|
|
expect(missing.status).toBe(404);
|
|
expect(mockBoardAuthService.describeCliAuthChallenge).not.toHaveBeenCalled();
|
|
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue(null);
|
|
const unknown = await request(app).get(`${path}?token=wrong-secret`);
|
|
expect(unknown.status).toBe(404);
|
|
});
|
|
|
|
it("cancels a valid challenge and preserves path whitespace normalization", async () => {
|
|
mockBoardAuthService.cancelCliAuthChallenge.mockResolvedValue({ status: "cancelled" });
|
|
const app = await createApp({ type: "none", source: "none" });
|
|
const id = "12345678-1234-4123-8123-123456789ABC";
|
|
const res = await request(app)
|
|
.post(`/api/cli-auth/challenges/${encodeURIComponent(` ${id} `)}/cancel`)
|
|
.send({ token: "pcp_cli_auth_secret" });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ status: "cancelled", cancelled: true });
|
|
expect(mockBoardAuthService.cancelCliAuthChallenge).toHaveBeenCalledWith(id, "pcp_cli_auth_secret");
|
|
});
|
|
|
|
it("approves a CLI auth challenge for a signed-in board user", async () => {
|
|
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
|
status: "approved",
|
|
challenge: {
|
|
id: "12345678-1234-4123-8123-123456789abc",
|
|
boardApiKeyId: "board-key-1",
|
|
requestedAccess: "board",
|
|
requestedCompanyId: "company-1",
|
|
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
|
},
|
|
});
|
|
mockBoardAuthService.resolveBoardAccess.mockResolvedValue({
|
|
user: { id: "user-1", name: "User One", email: "user@example.com" },
|
|
companyIds: ["company-1"],
|
|
isInstanceAdmin: false,
|
|
});
|
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["company-1"],
|
|
});
|
|
const res = await request(app)
|
|
.post("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc/approve")
|
|
.send({ token: "pcp_cli_auth_secret" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockBoardAuthService.approveCliAuthChallenge).toHaveBeenCalledWith(
|
|
"12345678-1234-4123-8123-123456789abc",
|
|
"pcp_cli_auth_secret",
|
|
"user-1",
|
|
);
|
|
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delegates instance-admin approval and atomic auditing to the service", async () => {
|
|
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
|
status: "approved",
|
|
challenge: {
|
|
id: "12345678-1234-4123-8123-123456789def",
|
|
boardApiKeyId: "board-key-2",
|
|
requestedAccess: "instance_admin_required",
|
|
requestedCompanyId: null,
|
|
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
|
},
|
|
});
|
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "admin-1",
|
|
source: "session",
|
|
isInstanceAdmin: true,
|
|
companyIds: [],
|
|
});
|
|
const res = await request(app)
|
|
.post("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789def/approve")
|
|
.send({ token: "pcp_cli_auth_secret" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockBoardAuthService.approveCliAuthChallenge).toHaveBeenCalledWith(
|
|
"12345678-1234-4123-8123-123456789def", "pcp_cli_auth_secret", "admin-1",
|
|
);
|
|
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delegates owner-bound revocation and atomic audit to the service", async () => {
|
|
mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({
|
|
id: "board-key-3",
|
|
userId: "admin-2",
|
|
});
|
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "admin-2",
|
|
keyId: "board-key-3",
|
|
source: "board_key",
|
|
isInstanceAdmin: true,
|
|
companyIds: [],
|
|
});
|
|
const res = await request(app).post("/api/cli-auth/revoke-current").send({});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockBoardAuthService.revokeBoardApiKey).toHaveBeenCalledWith("board-key-3", {
|
|
userId: "admin-2", revokedVia: "cli_auth_logout",
|
|
});
|
|
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates a named board API key and logs audit activity", async () => {
|
|
mockBoardAuthService.createNamedBoardApiKey.mockResolvedValue({
|
|
id: "board-key-4",
|
|
name: "external-admin",
|
|
token: "pcp_board_plaintext",
|
|
createdAt: new Date("2026-05-23T12:00:00.000Z"),
|
|
lastUsedAt: null,
|
|
revokedAt: null,
|
|
expiresAt: new Date("2026-06-23T12:00:00.000Z"),
|
|
});
|
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["11111111-1111-4111-8111-111111111111"]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "board_key",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["11111111-1111-4111-8111-111111111111"],
|
|
});
|
|
const res = await request(app)
|
|
.post("/api/board-api-keys")
|
|
.send({
|
|
name: "external-admin",
|
|
requestedCompanyId: "11111111-1111-4111-8111-111111111111",
|
|
expiresAt: "2026-06-23T12:00:00.000Z",
|
|
});
|
|
|
|
expect(res.status, res.text || JSON.stringify(res.body)).toBe(201);
|
|
expect(res.body).toMatchObject({
|
|
id: "board-key-4",
|
|
name: "external-admin",
|
|
token: "pcp_board_plaintext",
|
|
expiresAt: "2026-06-23T12:00:00.000Z",
|
|
});
|
|
expect(mockBoardAuthService.createNamedBoardApiKey).toHaveBeenCalledWith({
|
|
userId: "user-1",
|
|
name: "external-admin",
|
|
expiresAt: new Date("2026-06-23T12:00:00.000Z"),
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
companyId: "11111111-1111-4111-8111-111111111111",
|
|
action: "board_api_key.created",
|
|
details: expect.objectContaining({ name: "external-admin" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("lists and revokes named board API keys for the current board user", async () => {
|
|
const keyId = "55555555-5555-4555-8555-555555555555";
|
|
mockBoardAuthService.listBoardApiKeys.mockResolvedValue([
|
|
{
|
|
id: keyId,
|
|
name: "external-admin",
|
|
createdAt: new Date("2026-05-23T12:00:00.000Z"),
|
|
lastUsedAt: null,
|
|
revokedAt: null,
|
|
expiresAt: null,
|
|
},
|
|
]);
|
|
mockBoardAuthService.getBoardApiKeyForUser.mockResolvedValue({
|
|
id: keyId,
|
|
userId: "user-1",
|
|
name: "external-admin",
|
|
});
|
|
mockBoardAuthService.revokeBoardApiKey.mockResolvedValue({
|
|
id: keyId,
|
|
userId: "user-1",
|
|
name: "external-admin",
|
|
});
|
|
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
|
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "board_key",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["company-1"],
|
|
});
|
|
|
|
const listRes = await request(app).get("/api/board-api-keys");
|
|
expect(listRes.status).toBe(200);
|
|
expect(listRes.body[0]).toMatchObject({ id: keyId, name: "external-admin" });
|
|
expect(mockBoardAuthService.listBoardApiKeys).toHaveBeenCalledWith(
|
|
"user-1",
|
|
{ includeInactive: false },
|
|
);
|
|
|
|
const revokeRes = await request(app).delete(`/api/board-api-keys/${keyId}`);
|
|
expect(revokeRes.status).toBe(200);
|
|
expect(revokeRes.body).toEqual({ ok: true, keyId });
|
|
expect(mockBoardAuthService.revokeBoardApiKey).toHaveBeenCalledWith(keyId, {
|
|
userId: "user-1", revokedVia: "board_api_key_lifecycle",
|
|
});
|
|
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects malformed board API key IDs before database lookup", async () => {
|
|
const app = await createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "board_key",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["company-1"],
|
|
});
|
|
|
|
const res = await request(app).delete("/api/board-api-keys/not-a-uuid");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(mockBoardAuthService.getBoardApiKeyForUser).not.toHaveBeenCalled();
|
|
expect(mockBoardAuthService.revokeBoardApiKey).not.toHaveBeenCalled();
|
|
});
|
|
});
|