mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The board checks that access data belongs to the signed-in account. > - The startup changes in #18790 added this account check. > - Two existing test fixtures did not supply the account identity. > - Cloud readiness now rejects those fixtures before their intended assertions. > - This pull request adds the matching user ID to each fixture. > - The tests can then check invitations and pending shell settings with the same access boundary as the app. ## Linked Issues or Issue Description Refs #18790, #18770. Cloud readiness [run 41665172584](https://github.com/paperclipai/paperclip/actions/runs/41665172584) at `f7ad1dc7cacedf7153a45e854ddfdd544aa87abd` failed both UI shards. The private invitation case showed an access error. Both pending-shell cases made two session reads. All three failures reproduce unchanged in the focused local run. The access query compares the response user ID with the current session. These fixtures omitted that field. The mismatch correctly starts account-change handling and prevents the tests from reaching their intended state. ## What Changed - Give the invitation fixture the same user ID as its signed-in invitee. - Give the pending-shell fixture the same user ID as its signed-in user. - Keep all assertions, account-change rejection, and production behavior unchanged. ## Verification - Before the repair: the focused App and ShellConfigurationGate run reproduced all three CI failures; 24 other cases passed. - After the repair: all 76 cases pass across App, ShellConfigurationGate, CloudAccessGate, board-access-query, and CompanyContext. These include account mismatch and revoked-access coverage. - `pnpm check:token-gates` and `git diff --check` pass. - Full `pnpm build` and `pnpm -r typecheck` pass on `fa4ec98859d573e4197bb022cedc76f36fad1cf8`. Current-head Greptile reviewed this commit at 5/5 with no findings. CI is still running. Normal squash auto-merge is enabled for this exact head and will wait for the required checks. - The full local workspace test suite is not repeated for this two-line fixture repair. The earlier full-suite chat flakiness and cleanup timeout remain recorded in #18770. This PR does not claim that those failures are repaired. - No API, visual, or operator documentation changes are needed for these fixture values. ## Risks Low risk. The change only adds existing response fields to two test fixtures. It does not relax account validation or change test expectations. Cloud readiness must still pass on the resulting master revision before rollout. ## Model Used OpenAI GPT-6 Codex, with reasoning, tool use, and code execution. The exact runtime model identifier and context-window size are not exposed in this session. ## 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 - [ ] 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>
366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { CloudAccessGate } from "./components/CloudAccessGate";
|
|
import { queryKeys } from "./lib/queryKeys";
|
|
import appSource from "./App.tsx?raw";
|
|
|
|
const mockHealthApi = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
}));
|
|
|
|
const mockAuthApi = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
const mockAccessApi = vi.hoisted(() => ({
|
|
getCurrentBoardAccess: vi.fn(),
|
|
claimBootstrapAdmin: vi.fn(),
|
|
}));
|
|
const beginCloudSignInMock = vi.hoisted(() => vi.fn());
|
|
vi.mock("@/lib/cloud-sign-in", () => ({
|
|
beginCloudSignIn: (url: string) => beginCloudSignInMock(url),
|
|
clearCloudSignInAttempt: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./api/health", () => ({
|
|
healthApi: mockHealthApi,
|
|
}));
|
|
|
|
vi.mock("./api/auth", () => ({
|
|
authApi: mockAuthApi,
|
|
}));
|
|
|
|
vi.mock("./api/access", () => ({
|
|
accessApi: mockAccessApi,
|
|
}));
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ to, children }: { to: string; children?: ReactNode }) => <a href={to}>{children}</a>,
|
|
Navigate: ({ to }: { to: string }) => <div>Navigate:{to}</div>,
|
|
Outlet: () => <div>Outlet content</div>,
|
|
Route: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
Routes: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }),
|
|
useParams: () => ({}),
|
|
}));
|
|
|
|
/**
|
|
* Waits on the condition, not on a fixed number of turns. A hand-rolled retry
|
|
* loop is ample on an idle machine and not when the suite runs many workers in
|
|
* parallel: it gives up after N turns and reports a failure on behaviour that
|
|
* works. `vi.waitFor` retries against a time budget, so a loaded worker gets
|
|
* more turns instead.
|
|
*
|
|
* Same replacement as #11499 and #11521, which fixed the shorter-budget
|
|
* instances of this in the routing tests.
|
|
*/
|
|
async function waitForText(container: HTMLElement, text: string) {
|
|
await vi.waitFor(() => expect(container.textContent).toContain(text));
|
|
}
|
|
|
|
function renderGate(container: HTMLElement, allowMembershipRequest = false, client?: QueryClient) {
|
|
const root = createRoot(container);
|
|
const queryClient = client ?? new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<CloudAccessGate allowMembershipRequest={allowMembershipRequest} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
return root;
|
|
}
|
|
|
|
function unmountRoot(root: ReturnType<typeof createRoot>) {
|
|
flushSync(() => {
|
|
root.unmount();
|
|
});
|
|
}
|
|
|
|
describe("CloudAccessGate", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockHealthApi.get.mockResolvedValue({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bootstrapStatus: "ready",
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renews a missing Cloud instance session without opening local auth", async () => {
|
|
mockHealthApi.get.mockResolvedValue({ deploymentMode: "authenticated", cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: "https://my-staging.paperclip.app", stackSlug: "team" } });
|
|
mockAuthApi.getSession.mockResolvedValue(null);
|
|
beginCloudSignInMock.mockReturnValue(true);
|
|
const root = renderGate(container);
|
|
await vi.waitFor(() => expect(beginCloudSignInMock).toHaveBeenCalledTimes(1));
|
|
expect(beginCloudSignInMock).toHaveBeenCalledWith("https://my-staging.paperclip.app/v1/stacks/team/entry-redirect?returnTo=%2Finstance%2Fsettings%2Fgeneral");
|
|
expect(container.textContent).not.toContain("Navigate:/auth");
|
|
expect(container.textContent).not.toContain("Outlet content");
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("does not mistake a session service failure for a signed-out user", async () => {
|
|
mockAuthApi.getSession.mockRejectedValue(new Error("Session service unavailable"));
|
|
const root = renderGate(container);
|
|
await waitForText(container, "Unable to load Paperclip");
|
|
expect(container.querySelector("button")?.textContent).toBe("Try again");
|
|
expect(container.textContent).not.toContain("Outlet content");
|
|
expect(container.textContent).not.toContain("Navigate:/auth");
|
|
expect(beginCloudSignInMock).not.toHaveBeenCalled();
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it.each([undefined, { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: "https://my.paperclip.app", stackSlug: "team" }])(
|
|
"does not require a session in local trusted mode, including with Cloud metadata %j", async (cloud) => {
|
|
mockHealthApi.get.mockResolvedValue({ deploymentMode: "local_trusted", cloud });
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await client.fetchQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => Promise.reject(new Error("Session service unavailable")),
|
|
}).catch(() => {});
|
|
const root = renderGate(container, false, client);
|
|
await waitForText(container, "Outlet content");
|
|
expect(beginCloudSignInMock).not.toHaveBeenCalled();
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("shows a no-access message for signed-in users without org access", async () => {
|
|
mockAuthApi.getSession.mockResolvedValue({
|
|
session: { id: "session-1", userId: "user-1" },
|
|
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
|
});
|
|
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
|
|
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
|
userId: "user-1",
|
|
isInstanceAdmin: false,
|
|
companyIds: [],
|
|
source: "session",
|
|
keyId: null,
|
|
});
|
|
|
|
const root = renderGate(container);
|
|
await waitForText(container, "No organization access");
|
|
|
|
expect(container.textContent).toContain("No organization access");
|
|
expect(container.textContent).not.toContain("Outlet content");
|
|
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("admits signed-in nonmembers only for the private invitation landing page", async () => {
|
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
|
|
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ userId: "invitee", isInstanceAdmin: false, companyIds: [] });
|
|
const root = renderGate(container, true);
|
|
await waitForText(container, "Outlet content");
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("still requires sign-in for the invitation landing page", async () => {
|
|
mockAuthApi.getSession.mockResolvedValue(null);
|
|
const root = renderGate(container, true);
|
|
await waitForText(container, "Navigate:/auth?next=");
|
|
expect(container.textContent).not.toContain("Outlet content");
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("still blocks invitation pages while cloud bootstrap is pending", async () => {
|
|
mockHealthApi.get.mockResolvedValue({ deploymentMode: "authenticated", deploymentExposure: "public", bootstrapStatus: "bootstrap_pending" });
|
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
|
|
const root = renderGate(container, true);
|
|
await waitForText(container, "This Paperclip is waiting on its first admin");
|
|
expect(container.textContent).not.toContain("Outlet content");
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("keeps invitation pages closed when cloud access checks fail", async () => {
|
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } });
|
|
mockAccessApi.getCurrentBoardAccess.mockRejectedValueOnce(new Error("Access check unavailable"));
|
|
const root = renderGate(container, true);
|
|
await waitForText(container, "Unable to load Paperclip");
|
|
expect(container.querySelector("button")?.textContent).toBe("Try again");
|
|
expect(container.textContent).not.toContain("Outlet content");
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("allows authenticated users with company access through to the board", async () => {
|
|
mockAuthApi.getSession.mockResolvedValue({
|
|
session: { id: "session-1", userId: "user-1" },
|
|
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
|
});
|
|
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
|
|
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
|
userId: "user-1",
|
|
isInstanceAdmin: false,
|
|
companyIds: ["company-1"],
|
|
source: "session",
|
|
keyId: null,
|
|
});
|
|
|
|
const root = renderGate(container);
|
|
await waitForText(container, "Outlet content");
|
|
|
|
expect(container.textContent).toContain("Outlet content");
|
|
expect(container.textContent).not.toContain("No organization access");
|
|
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("shows browser sign-in setup for signed-out private bootstrap-pending instances", async () => {
|
|
mockHealthApi.get.mockResolvedValue({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bootstrapStatus: "bootstrap_pending",
|
|
bootstrapInviteActive: false,
|
|
});
|
|
mockAuthApi.getSession.mockResolvedValue(null);
|
|
|
|
const root = renderGate(container);
|
|
await waitForText(container, "Finish setting up this Paperclip");
|
|
|
|
expect(container.textContent).toContain("Finish setting up this Paperclip");
|
|
expect(container.textContent).toContain("Sign in / Create account");
|
|
expect(container.textContent).toContain("npx paperclipai auth bootstrap-ceo");
|
|
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
|
|
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("shows the claim action for signed-in private bootstrap-pending instances", async () => {
|
|
mockHealthApi.get.mockResolvedValue({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
bootstrapStatus: "bootstrap_pending",
|
|
bootstrapInviteActive: false,
|
|
});
|
|
mockAuthApi.getSession.mockResolvedValue({
|
|
session: { id: "session-1", userId: "user-1" },
|
|
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
|
});
|
|
mockAccessApi.claimBootstrapAdmin.mockResolvedValue({ claimed: true, userId: "user-1" });
|
|
|
|
const root = renderGate(container);
|
|
await waitForText(container, "Claim this instance");
|
|
|
|
expect(container.textContent).toContain("Claim this instance");
|
|
expect(container.textContent).toContain("Signed in as user@example.com");
|
|
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
|
|
|
|
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
|
|
candidate.textContent?.includes("Claim this instance"),
|
|
);
|
|
expect(button).toBeTruthy();
|
|
flushSync(() => {
|
|
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await waitForText(container, "You're the instance admin");
|
|
|
|
expect(mockAccessApi.claimBootstrapAdmin).toHaveBeenCalledTimes(1);
|
|
expect(container.textContent).toContain("You're the instance admin");
|
|
expect(container.textContent).toContain("Continue to dashboard");
|
|
|
|
unmountRoot(root);
|
|
});
|
|
|
|
it("keeps public bootstrap-pending instances invite-only", async () => {
|
|
mockHealthApi.get.mockResolvedValue({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
bootstrapStatus: "bootstrap_pending",
|
|
bootstrapInviteActive: true,
|
|
});
|
|
mockAuthApi.getSession.mockResolvedValue({
|
|
session: { id: "session-1", userId: "user-1" },
|
|
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
|
});
|
|
|
|
const root = renderGate(container);
|
|
await waitForText(container, "This Paperclip is waiting on its first admin");
|
|
|
|
expect(container.textContent).toContain("This Paperclip is waiting on its first admin");
|
|
expect(container.textContent).toContain("invite-only mode");
|
|
expect(container.textContent).not.toContain("Claim this instance");
|
|
expect(container.textContent).not.toContain("Sign in / Create account");
|
|
expect(mockAccessApi.claimBootstrapAdmin).not.toHaveBeenCalled();
|
|
|
|
unmountRoot(root);
|
|
});
|
|
});
|
|
|
|
describe("Skill Studio routes", () => {
|
|
it("registers create mode before the skillId route in prefixed and unprefixed routing", () => {
|
|
const createRoute = 'path="skills/studio/new"';
|
|
const detailRoute = 'path="skills/studio/:skillId"';
|
|
const createIndexes = [...appSource.matchAll(new RegExp(createRoute, "g"))].map((match) => match.index ?? -1);
|
|
const detailIndexes = [...appSource.matchAll(new RegExp(detailRoute, "g"))].map((match) => match.index ?? -1);
|
|
|
|
expect(createIndexes).toHaveLength(2);
|
|
expect(detailIndexes).toHaveLength(2);
|
|
expect(createIndexes[0]).toBeLessThan(detailIndexes[0]!);
|
|
expect(createIndexes[1]).toBeLessThan(detailIndexes[1]!);
|
|
});
|
|
});
|
|
|
|
describe("Apps routes", () => {
|
|
it("uses one connector landing page and redirects retired browse, connections, and audit URLs", () => {
|
|
expect(appSource).toContain('<Route path="apps" element={<Browse />} />');
|
|
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
|
|
expect(appSource).toContain('<Route path="apps/connections" element={<Navigate to="/apps" replace />} />');
|
|
expect(appSource).toContain('<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />');
|
|
expect(appSource).toContain('<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />');
|
|
expect(appSource).not.toContain('import { Connections }');
|
|
expect(appSource).toContain('<Route path="apps/byo" element={<AppsConnect byoOnly />} />');
|
|
expect(appSource).toContain('path="apps/vercel-connect"');
|
|
expect(appSource).toContain('<AppsConnectEntryRoute credentialSource="vercel_connect" />');
|
|
expect(appSource).toContain('<AppsConnect credentialSource={credentialSource} />');
|
|
expect(appSource).toContain('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
|
|
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
|
|
expect(appSource).toContain('<Route path="apps/advanced/gateways" element={<GatewaysList />} />');
|
|
});
|
|
|
|
it("redirects legacy Rules and Health links to the remaining developer surfaces", () => {
|
|
expect(appSource).toContain('if (tab === "runtime" || tab === "audit") return "/apps";');
|
|
expect(appSource).toContain('if (tab === "policies") return "/apps/advanced/profiles";');
|
|
});
|
|
});
|
|
|
|
describe("Retired settings routes", () => {
|
|
it("redirects the removed heartbeats page to the settings root instead of dropping the route", () => {
|
|
expect(appSource).toContain(
|
|
'<Route path="company/settings/instance/heartbeats" element={<Navigate to="/company/settings" replace />} />',
|
|
);
|
|
expect(appSource).not.toContain("<InstanceSettings");
|
|
expect(appSource).not.toContain('"./pages/InstanceSettings"');
|
|
});
|
|
});
|
|
|
|
describe("Decisions routes", () => {
|
|
it("does not register decision-training views", () => {
|
|
expect(appSource).not.toContain('path="decisions/training"');
|
|
expect(appSource).not.toContain('path="decisions/training/:id"');
|
|
expect(appSource).not.toContain('path="training"');
|
|
expect(appSource).not.toContain('path="training/:id"');
|
|
});
|
|
});
|