Files
PaperClipAI/server/src/__tests__/cli-auth-routes.test.ts
T
854af7df19 build(deps-dev): bump vitest from 4.1.11 to 5.0.3 (#12969)
Bumps
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
from 4.1.11 to 5.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.3</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li>Isolate <code>result.status</code> between <code>repeats</code> runs
 -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-6)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11218">vitest-dev/vitest#11218</a>
<a href="https://github.com/vitest-dev/vitest/commit/5dbebe9e3"><!-- raw
HTML omitted -->(5dbeb)<!-- raw HTML omitted --></a></li>
<li>Don't print an interceptor warning in browser mode  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11377">vitest-dev/vitest#11377</a>
<a href="https://github.com/vitest-dev/vitest/commit/15cc006aa"><!-- raw
HTML omitted -->(15cc0)<!-- raw HTML omitted --></a></li>
<li>Don't retry when <code>test.fails</code> expectedly failed  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-6)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11219">vitest-dev/vitest#11219</a>
<a href="https://github.com/vitest-dev/vitest/commit/b24585f08"><!-- raw
HTML omitted -->(b2458)<!-- raw HTML omitted --></a></li>
<li>Scope cache key generators to projects  -  by <a
href="https://github.com/ecoyoung"><code>@​ecoyoung</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11281">vitest-dev/vitest#11281</a>
and <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11301">vitest-dev/vitest#11301</a>
<a href="https://github.com/vitest-dev/vitest/commit/92ba7fc1d"><!-- raw
HTML omitted -->(92ba7)<!-- raw HTML omitted --></a></li>
<li><strong>browser</strong>:
<ul>
<li>Delay server <code>listen</code> until tests start running  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11366">vitest-dev/vitest#11366</a>
<a href="https://github.com/vitest-dev/vitest/commit/7d8ed3e9b"><!-- raw
HTML omitted -->(7d8ed)<!-- raw HTML omitted --></a></li>
<li>Check mock path boundaries  -  by <a
href="https://github.com/saryn17"><code>@​saryn17</code></a>,
<strong>Ryosei Sato</strong> and <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11361">vitest-dev/vitest#11361</a>
and <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11362">vitest-dev/vitest#11362</a>
<a href="https://github.com/vitest-dev/vitest/commit/1c3888bce"><!-- raw
HTML omitted -->(1c388)<!-- raw HTML omitted --></a></li>
<li>Keep config of browser-consumed environments  -  by <a
href="https://github.com/kasperpeulen"><code>@​kasperpeulen</code></a>
and <strong>Claude</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11378">vitest-dev/vitest#11378</a>
<a href="https://github.com/vitest-dev/vitest/commit/aafc0996f"><!-- raw
HTML omitted -->(aafc0)<!-- raw HTML omitted --></a></li>
<li>Ignore page crash while cancelling  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11386">vitest-dev/vitest#11386</a>
<a href="https://github.com/vitest-dev/vitest/commit/7c36748fa"><!-- raw
HTML omitted -->(7c367)<!-- raw HTML omitted --></a></li>
<li><code>toMatchScreenshot</code> uses wrong reference on retried tests
 -  by <a href="https://github.com/macarie"><code>@​macarie</code></a>
in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11393">vitest-dev/vitest#11393</a>
<a href="https://github.com/vitest-dev/vitest/commit/c22aba992"><!-- raw
HTML omitted -->(c22ab)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>cache</strong>:
<ul>
<li>Revalidate imports of cached modules  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11381">vitest-dev/vitest#11381</a>
<a href="https://github.com/vitest-dev/vitest/commit/38f98855f"><!-- raw
HTML omitted -->(38f98)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>deps</strong>:
<ul>
<li>Pin <code>why-is-node-running</code> to <code>3.2.1</code> to avoid
users running into <code>ERR_PNPM_TRUST_DOWNGRADE</code>  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11403">vitest-dev/vitest#11403</a>
<a href="https://github.com/vitest-dev/vitest/commit/f6c9a4977"><!-- raw
HTML omitted -->(f6c9a)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>expect</strong>:
<ul>
<li>Pass current equality testers to <code>expect.extend</code>
asymmetric matchers  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>Claude</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11401">vitest-dev/vitest#11401</a>
<a href="https://github.com/vitest-dev/vitest/commit/3e794a96b"><!-- raw
HTML omitted -->(3e794)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>jsdom</strong>:
<ul>
<li>Support Blob on jsdom 30.1  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11379">vitest-dev/vitest#11379</a>
<a href="https://github.com/vitest-dev/vitest/commit/6c49b7197"><!-- raw
HTML omitted -->(6c49b)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>pool</strong>:
<ul>
<li>Preserve unique pool ids when <code>groupOrder</code> is set  -  by
<a href="https://github.com/mtorp"><code>@​mtorp</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11392">vitest-dev/vitest#11392</a>
<a href="https://github.com/vitest-dev/vitest/commit/50312ebb4"><!-- raw
HTML omitted -->(50312)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>ui</strong>:
<ul>
<li>Split-pane handle overlapping iframe  -  by <a
href="https://github.com/macarie"><code>@​macarie</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11221">vitest-dev/vitest#11221</a>
<a href="https://github.com/vitest-dev/vitest/commit/f91db0dfd"><!-- raw
HTML omitted -->(f91db)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>vitest</strong>:
<ul>
<li>Remove root temp dir on close  -  by <a
href="https://github.com/abhinav-phi"><code>@​abhinav-phi</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11248">vitest-dev/vitest#11248</a>
<a href="https://github.com/vitest-dev/vitest/commit/7c7119cf7"><!-- raw
HTML omitted -->(7c711)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>vm</strong>:
<ul>
<li>Do not optimize deps from index.html  -  by <a
href="https://github.com/ezefernandezyf"><code>@​ezefernandezyf</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-6)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11329">vitest-dev/vitest#11329</a>
and <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11360">vitest-dev/vitest#11360</a>
<a href="https://github.com/vitest-dev/vitest/commit/caf2887de"><!-- raw
HTML omitted -->(caf28)<!-- raw HTML omitted --></a></li>
<li>Don't reuse scripts across vite environments  -  by <a
href="https://github.com/MO2k4"><code>@​MO2k4</code></a>, <strong>Martin
Oehlert</strong> and <strong>Claude</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11395">vitest-dev/vitest#11395</a>
<a href="https://github.com/vitest-dev/vitest/commit/346d3896b"><!-- raw
HTML omitted -->(346d3)<!-- raw HTML omitted --></a></li>
</ul>
</li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v5.0.2...v5.0.3">View
changes on GitHub</a></h5>
<h2>v5.0.2</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li>Bind <code>process</code> in case global is overwritten  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11343">vitest-dev/vitest#11343</a>
<a href="https://github.com/vitest-dev/vitest/commit/0b79231ad"><!-- raw
HTML omitted -->(0b792)<!-- raw HTML omitted --></a></li>
<li><strong>detect-async-leaks</strong>:
<ul>
<li>Ignore <code>process.stdio</code> handles  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11333">vitest-dev/vitest#11333</a>
<a href="https://github.com/vitest-dev/vitest/commit/0fd6b9790"><!-- raw
HTML omitted -->(0fd6b)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>expect</strong>:
<ul>
<li>Fix <code>toMatchObject</code> with asymmetric matchers  -  by <a
href="https://github.com/ShreeBohara"><code>@​ShreeBohara</code></a>,
<strong>Claude Opus 5</strong>, <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-5)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11100">vitest-dev/vitest#11100</a>
<a href="https://github.com/vitest-dev/vitest/commit/42523289e"><!-- raw
HTML omitted -->(42523)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>jsdom</strong>:
<ul>
<li>Fix <code>Request</code> with <code>Blob</code> body on jsdom 28+
 -  by <a
href="https://github.com/harshit-d3v"><code>@​harshit-d3v</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11295">vitest-dev/vitest#11295</a>
<a href="https://github.com/vitest-dev/vitest/commit/d1c3ecc93"><!-- raw
HTML omitted -->(d1c3e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>reporter</strong>:
<ul>
<li><code>agent</code> to respect <code>--silent</code>  -  by <a
href="https://github.com/Raj4478"><code>@​Raj4478</code></a> and <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11271">vitest-dev/vitest#11271</a>
<a href="https://github.com/vitest-dev/vitest/commit/5b95efb6d"><!-- raw
HTML omitted -->(5b95e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>reporters</strong>:
<ul>
<li>Handle concurrent <code>createReport</code> calls  -  by <a
href="https://github.com/7rulnik"><code>@​7rulnik</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11278">vitest-dev/vitest#11278</a>
<a href="https://github.com/vitest-dev/vitest/commit/e8e556ff7"><!-- raw
HTML omitted -->(e8e55)<!-- raw HTML omitted --></a></li>
<li><code>hanging-process</code> to use ESM entrypoint  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11316">vitest-dev/vitest#11316</a>
<a href="https://github.com/vitest-dev/vitest/commit/4e91e5668"><!-- raw
HTML omitted -->(4e91e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>spy</strong>:
<ul>
<li>Fix stack overflow when spying <code>Set.prototype.add</code>  -  by
<a href="https://github.com/fengmk2"><code>@​fengmk2</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11299">vitest-dev/vitest#11299</a>
<a href="https://github.com/vitest-dev/vitest/commit/a0a939653"><!-- raw
HTML omitted -->(a0a93)<!-- raw HTML omitted --></a></li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/33cadea62e8763c455c7fca38d9ab1dda87c5f75"><code>33cadea</code></a>
chore: release v5.0.3 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11409">#11409</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/346d3896b65c3c907174447a035807342799f346"><code>346d389</code></a>
fix(vm): don't reuse scripts across vite environments (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11395">#11395</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/f6c9a4977ad3363f796a737834572e54c6ad5c18"><code>f6c9a49</code></a>
fix(deps): pin <code>why-is-node-running</code> to <code>3.2.1</code> to
avoid users running into `...</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/062c75d8b63519211d951d8293ea81b5a9e3c124"><code>062c75d</code></a>
chore: fix standalone docs build, update exports maps (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11394">#11394</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/caf2887dee8987a60118d53933f6e9cabd6b3e2a"><code>caf2887</code></a>
fix(vm): do not optimize deps from index.html (fix <a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11329">#11329</a>)
(<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11360">#11360</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/50312ebb4eca6a98f6d0b2b61d5d9d38cbbabcef"><code>50312eb</code></a>
fix(pool): preserve unique pool ids when <code>groupOrder</code> is set
(<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11392">#11392</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/7c36748fad1eae9687312f2f7ceadce6ec88b5df"><code>7c36748</code></a>
fix(browser): ignore page crash while cancelling (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11386">#11386</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/92ba7fc1df16a4fa5bbee3f198c582fbd56689d8"><code>92ba7fc</code></a>
fix: scope cache key generators to projects (fix <a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11281">#11281</a>)
(<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11301">#11301</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/38f98855fa9cd7fd376afb84094eba0fda256a74"><code>38f9885</code></a>
fix(cache): revalidate imports of cached modules (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11381">#11381</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/b24585f08f2ea267746a2d6ca0e43edcbb29726f"><code>b24585f</code></a>
fix: don't retry when <code>test.fails</code> expectedly failed (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11219">#11219</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Priya Raman <priya.raman@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:13:46 -07:00

490 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(() => {
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();
});
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();
});
});