mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Preserve governed Board CLI authority and strict verification fixtures on the Muse stack.
Keep approval/cancel ownership, current membership/key authority and durable first-change audit atomic. Bound Retry-After scheduling within the original challenge; never automatically resubmit consent. Retain descriptor ownership until identity-based cleanup and close it on success and error. Keep strict Git-origin and synchronized provider-entry fixture assertions.
Related public work: LukeAbell's existing PR #13919 proposes the same production descriptor-lifetime fix. This independently measured local integration retains the original tests and adds real-descriptor coverage. Do not treat this local integration as ownership of that contribution.
Carry actual merged UI source 896f3c7f3b through a normal forward merge, preserving canonical workspace recovery, Muse authority and existing migration bytes. Current-head CI and substantive review remain merge gates. Complete qualification of the final immutable canonical source and actual provider acceptance remain separate.
Refs #16482, #13919.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
138 lines
6.5 KiB
TypeScript
138 lines
6.5 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
getStoredBoardCredential,
|
|
loginBoardCli,
|
|
readBoardAuthStore,
|
|
removeStoredBoardCredential,
|
|
setStoredBoardCredential,
|
|
} from "../client/board-auth.js";
|
|
|
|
function createTempAuthPath(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
|
|
return path.join(dir, "auth.json");
|
|
}
|
|
|
|
describe("board auth store", () => {
|
|
it("returns an empty store when the file does not exist", () => {
|
|
const authPath = createTempAuthPath();
|
|
expect(readBoardAuthStore(authPath)).toEqual({
|
|
version: 1,
|
|
credentials: {},
|
|
});
|
|
});
|
|
|
|
it("stores and retrieves credentials by normalized api base", () => {
|
|
const authPath = createTempAuthPath();
|
|
setStoredBoardCredential({
|
|
apiBase: "http://localhost:3100/",
|
|
token: "token-123",
|
|
userId: "user-1",
|
|
storePath: authPath,
|
|
});
|
|
|
|
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
|
|
apiBase: "http://localhost:3100",
|
|
token: "token-123",
|
|
userId: "user-1",
|
|
});
|
|
});
|
|
|
|
it("removes stored credentials", () => {
|
|
const authPath = createTempAuthPath();
|
|
setStoredBoardCredential({
|
|
apiBase: "http://localhost:3100",
|
|
token: "token-123",
|
|
storePath: authPath,
|
|
});
|
|
|
|
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
|
|
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
|
|
});
|
|
});
|
|
|
|
|
|
describe("board CLI polling backoff", () => {
|
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
|
|
|
function harness(statuses: Array<{ status: number; body: unknown; retryAfter?: string }>, expiresIn = 10_000) {
|
|
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
const authPath = createTempAuthPath();
|
|
const created = { id: "12345678-1234-4123-8123-123456789abc", token: "synthetic-challenge-capability",
|
|
boardApiToken: "synthetic-board-token", approvalPath: "/cli-auth/approve", approvalUrl: null,
|
|
pollPath: "/cli-auth/challenges/12345678-1234-4123-8123-123456789abc",
|
|
expiresAt: new Date(Date.now() + expiresIn).toISOString(), suggestedPollIntervalMs: 1000 };
|
|
let creates = 0, polls = 0;
|
|
const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
if (init?.method === "POST") { creates++; return new Response(JSON.stringify(created), { status: 201 }); }
|
|
if (String(url).endsWith("/cli-auth/me")) return new Response(JSON.stringify({ userId: "synthetic-user" }));
|
|
polls++;
|
|
const next = statuses.shift();
|
|
if (!next) throw new Error("Unexpected additional poll");
|
|
return new Response(JSON.stringify(next.body), { status: next.status,
|
|
headers: next.retryAfter === undefined ? undefined : { "Retry-After": next.retryAfter } });
|
|
});
|
|
vi.stubGlobal("fetch", fetcher);
|
|
const outcome = loginBoardCli({ apiBase: "https://cli.example.invalid", requestedAccess: "board", storePath: authPath,
|
|
openBrowser: false, print: false }).then(value => ({ value }), error => ({ error }));
|
|
return { authPath, created, outcome, counts: () => ({ creates, polls }), cleanup: () => fs.rmSync(path.dirname(authPath), { recursive: true, force: true }) };
|
|
}
|
|
|
|
it("waits for numeric Retry-After and persists the original approved key", async () => {
|
|
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "2" }, { status: 200, body: { status: "approved" } }]);
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(1999);
|
|
expect(f.counts()).toEqual({ creates: 1, polls: 1 });
|
|
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)).toBeNull();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(await f.outcome).toHaveProperty("value.token", f.created.boardApiToken);
|
|
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)?.token).toBe(f.created.boardApiToken);
|
|
expect(f.counts()).toEqual({ creates: 1, polls: 2 });
|
|
} finally { f.cleanup(); }
|
|
});
|
|
|
|
it.each([undefined, "not-a-time", "-1", "0"])("uses normal polling when Retry-After is %s", async retryAfter => {
|
|
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter }, { status: 200, body: { status: "approved" } }]);
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(999); expect(f.counts()).toEqual({ creates: 1, polls: 1 });
|
|
await vi.advanceTimersByTimeAsync(1); expect(await f.outcome).toHaveProperty("value.token", f.created.boardApiToken);
|
|
expect(f.counts()).toEqual({ creates: 1, polls: 2 });
|
|
} finally { f.cleanup(); }
|
|
});
|
|
|
|
it("honors an HTTP-date Retry-After", async () => {
|
|
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "Thu, 01 Jan 2026 00:00:02 GMT" },
|
|
{ status: 200, body: { status: "approved" } }]);
|
|
try { await vi.advanceTimersByTimeAsync(2000); expect(await f.outcome).toHaveProperty("value.token", f.created.boardApiToken); }
|
|
finally { f.cleanup(); }
|
|
});
|
|
|
|
it("never extends the original challenge deadline for a long Retry-After", async () => {
|
|
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "3600" }], 1500);
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(await f.outcome).toHaveProperty("error.message", "CLI auth challenge expired before approval.");
|
|
expect(f.counts()).toEqual({ creates: 1, polls: 1 });
|
|
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)).toBeNull();
|
|
} finally { f.cleanup(); }
|
|
});
|
|
|
|
it.each(["cancelled", "expired"])("preserves %s after a rate-limit wait", async status => {
|
|
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "1" }, { status: 200, body: { status } }]);
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(await f.outcome).toHaveProperty("error.message", status === "cancelled" ? "CLI auth challenge was cancelled." : "CLI auth challenge expired before approval.");
|
|
expect(f.counts()).toEqual({ creates: 1, polls: 2 });
|
|
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)).toBeNull();
|
|
} finally { f.cleanup(); }
|
|
});
|
|
|
|
it("does not retry other HTTP failures", async () => {
|
|
const f = harness([{ status: 503, body: { error: "Unavailable" } }]);
|
|
try { expect(await f.outcome).toHaveProperty("error.message", "Unavailable"); expect(f.counts()).toEqual({ creates: 1, polls: 1 }); }
|
|
finally { f.cleanup(); }
|
|
});
|
|
});
|