Files
PaperClipAI/server/src/__tests__/cli-auth-routes.test.ts
T
DottaandPaperclip 2b4a1d7073 feat(dot): add guided invitations and persistent agent connections (#15581)
Add guided external-agent invitations with live Dot connection checks and ongoing access until revoked. Keep expired credentials invalid and enforce permissions for private avatar uploads. Preserve Runner recovery and remote launch boundaries.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-08 16:26:41 -05:00

493 lines
18 KiB
TypeScript

import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
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) {
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: [],
}),
);
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("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(mockLogActivity).toHaveBeenCalledTimes(1);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "board_api_key.created",
}),
);
});
it("logs approve activity for instance admins without company memberships", 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.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
userId: "admin-1",
requestedCompanyId: null,
boardApiKeyId: "board-key-2",
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("logs revoke activity with resolved audit company ids", 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.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
userId: "admin-2",
boardApiKeyId: "board-key-3",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-z",
action: "board_api_key.revoked",
}),
);
});
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(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "board_api_key.revoked",
}),
);
});
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();
});
});