Files
PaperClipAI/ui/src/pages/InviteLanding.test.tsx
T
Dotta 1955b0e2d8 Gate Paperclip Runner setup behind an experimental flag (#12656)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent adapters control how Paperclip starts and resumes an agent
runtime.
> - Paperclip Runner is an experimental Rust runtime and must stay
opt-in.
> - The server already rejected new runner selections when the flag was
off.
> - Some setup and onboarding views did not enforce the same boundary.
> - This pull request exposes the existing flag and applies it to every
new setup path.
> - The benefit is a safe rollout with unchanged legacy onboarding and
recoverable existing native runs.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves experimental adapter selection in Settings, onboarding,
new-agent setup, invite setup, and company import.

**Subsystem affected**

Cross-cutting: the React UI and the server onboarding seed service.

**Current behavior**

The server defaulted Paperclip Runner to off, but Settings did not
expose the flag. First-run onboarding could show the runner after
opt-in. A direct new-agent URL and some setup pickers could also reveal
native runner configuration before the availability check completed.

**Proposed behavior**

Settings has a default-off Paperclip Runner toggle. Explicit agent
configuration shows the runner only after the server reports that the
flag is enabled. First-run and invite onboarding always use legacy
adapters. Existing native agents and runs remain readable and
recoverable.

**Reason and benefit**

This keeps the experimental runtime out of normal onboarding. It also
gives administrators one clear opt-in before users can create a native
runner agent.

**Breaking changes**

None. Legacy adapter selection and execution stay unchanged. Existing
native records remain available.

## What Changed

- Added the Paperclip Runner opt-in to Experimental Settings.
- Refreshed adapter availability after the setting changes.
- Kept UI and server-seeded onboarding on legacy adapters.
- Made native runner choices fail closed in new-agent, invite, and
import setup.
- Preserved edit and recovery behavior for existing native agents and
runs.
- Added focused regression tests for flag-off and flag-on behavior.

## Verification

- GitHub Actions will run the repository test, typecheck, build, and
policy gates.
- Focused tests cover Settings, onboarding, agent creation, invite
setup, import setup, and server-seeded onboarding.
- No local test suite was run, per the maintainer request to use GitHub
Actions for verification.
- `git diff --check` passes.

## Risks

Low risk. The change narrows new adapter selection only. The server
remains the final enforcement point. Existing native records do not
depend on the current flag value for read or recovery behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5, with reasoning, tool use, and code execution.

## 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
- [ ] 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
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-09-01 05:57:40 -05:00

1235 lines
43 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InviteLandingPage } from "./InviteLanding";
import { queryKeys } from "../lib/queryKeys";
const getInviteMock = vi.hoisted(() => vi.fn());
const acceptInviteMock = vi.hoisted(() => vi.fn());
const getSessionMock = vi.hoisted(() => vi.fn());
const signInEmailMock = vi.hoisted(() => vi.fn());
const signUpEmailMock = vi.hoisted(() => vi.fn());
const healthGetMock = vi.hoisted(() => vi.fn());
const listCompaniesMock = vi.hoisted(() => vi.fn());
const setSelectedCompanyIdMock = vi.hoisted(() => vi.fn());
vi.mock("../api/access", () => ({
accessApi: {
getInvite: (token: string) => getInviteMock(token),
acceptInvite: (token: string, input: unknown) => acceptInviteMock(token, input),
},
}));
vi.mock("../api/auth", () => ({
authApi: {
getSession: () => getSessionMock(),
signInEmail: (input: unknown) => signInEmailMock(input),
signUpEmail: (input: unknown) => signUpEmailMock(input),
},
}));
vi.mock("../api/health", () => ({
healthApi: {
get: () => healthGetMock(),
},
}));
vi.mock("../api/companies", () => ({
companiesApi: {
list: () => listCompaniesMock(),
detachInflightList: () => undefined,
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: null,
selectedCompanyId: null,
companies: [],
selectionSource: "manual",
loading: false,
error: null,
setSelectedCompanyId: setSelectedCompanyIdMock,
reloadCompanies: vi.fn(),
createCompany: vi.fn(),
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
flushSync(() => {});
}
describe("InviteLandingPage", () => {
let container: HTMLDivElement;
beforeEach(() => {
localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value: vi.fn(() => ({
fillStyle: "",
fillRect: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
})),
});
Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
configurable: true,
value: vi.fn(() => "data:image/png;base64,stub"),
});
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
companyName: "Acme Robotics",
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
inviteType: "company_join",
allowedJoinTypes: "both",
humanRole: "operator",
expiresAt: "2027-03-07T00:10:00.000Z",
inviteMessage: "Welcome aboard.",
});
acceptInviteMock.mockReset();
healthGetMock.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
});
listCompaniesMock.mockResolvedValue([]);
getSessionMock.mockResolvedValue(null);
signInEmailMock.mockResolvedValue(undefined);
signUpEmailMock.mockResolvedValue(undefined);
setSelectedCompanyIdMock.mockReset();
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("keeps agent-invite onboarding on legacy adapters", async () => {
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
companyName: "Acme Robotics",
companyLogoUrl: null,
inviteType: "company_join",
allowedJoinTypes: "agent",
humanRole: null,
expiresAt: "2027-03-07T00:10:00.000Z",
inviteMessage: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const adapterSelect = Array.from(container.querySelectorAll("select")).find((select) =>
Array.from(select.options).some((option) => option.value === "claude_local"),
);
expect(adapterSelect).toBeTruthy();
expect(Array.from(adapterSelect!.options).map((option) => option.value))
.not.toContain("paperclip_runner");
await act(async () => {
root.unmount();
});
});
it("defaults invite auth to account creation and guides existing users back to sign in", async () => {
signUpEmailMock.mockRejectedValue(
Object.assign(new Error("User already exists. Use another email."), {
code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
status: 422,
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("You've been invited to join Paperclip");
expect(container.textContent).toContain("Join Acme Robotics");
expect(container.textContent).toContain("Create account");
expect(container.textContent).toContain("I already have an account");
expect(container.textContent).toContain("Message from inviter");
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
const inviteLogo = container.querySelector('img[alt="Acme Robotics logo"]');
expect(inviteLogo).not.toBeNull();
expect(inviteLogo?.className).toContain("object-contain");
expect(container.querySelector('input[name="name"]')).not.toBeNull();
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null;
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
expect(nameInput).not.toBeNull();
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
await act(async () => {
inputValueSetter!.call(nameInput, "Jane Example");
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
nameInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(passwordInput, "supersecret");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
expect(authForm).not.toBeNull();
await act(async () => {
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
await flushReact();
expect(signUpEmailMock).toHaveBeenCalledWith({
name: "Jane Example",
email: "jane@example.com",
password: "supersecret",
});
expect(container.textContent).toContain("An account already exists for jane@example.com. Sign in below to continue with this invite.");
expect(container.querySelector('input[name="name"]')).toBeNull();
expect(container.textContent).toContain("Sign in to continue");
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
await act(async () => {
root.unmount();
});
});
it("carries password-manager metadata and a11y attributes on the invite auth form", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement;
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
expect(nameInput).not.toBeNull();
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
// Default invite mode is sign-up.
expect(emailInput.getAttribute("autocomplete")).toBe("username");
expect(emailInput.getAttribute("type")).toBe("email");
expect(passwordInput.getAttribute("autocomplete")).toBe("new-password");
expect(nameInput.getAttribute("autocomplete")).toBe("name");
// Namespaced stable ids.
expect(emailInput.id).toBe("invite-email");
expect(passwordInput.id).toBe("invite-password");
expect(nameInput.id).toBe("invite-name");
// Required + programmatic required state.
expect(emailInput.required).toBe(true);
expect(emailInput.getAttribute("aria-required")).toBe("true");
expect(passwordInput.required).toBe(true);
expect(passwordInput.getAttribute("aria-required")).toBe("true");
expect(nameInput.required).toBe(true);
await act(async () => {
root.unmount();
});
});
it("renders invite auth errors in an alert region referenced by the inputs", async () => {
signInEmailMock.mockRejectedValue(
Object.assign(new Error("Invalid email or password"), {
code: "INVALID_EMAIL_OR_PASSWORD",
status: 401,
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "I already have an account",
);
await act(async () => {
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
await act(async () => {
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(passwordInput, "wrongpass");
passwordInput.dispatchEvent(new Event("input", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement;
await act(async () => {
authForm.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
const alert = container.querySelector('[role="alert"]') as HTMLElement;
expect(alert).not.toBeNull();
expect(alert.hasAttribute("aria-live")).toBe(false);
const errorId = alert.id;
expect(errorId.length).toBeGreaterThan(0);
expect(emailInput.getAttribute("aria-describedby")).toBe(errorId);
expect(emailInput.getAttribute("aria-invalid")).toBe("true");
expect(passwordInput.getAttribute("aria-describedby")).toBe(errorId);
expect(passwordInput.getAttribute("aria-invalid")).toBe("true");
await act(async () => {
root.unmount();
});
});
it("turns invalid sign-in responses into a clear invite-specific message", async () => {
signInEmailMock.mockRejectedValue(
Object.assign(new Error("Invalid email or password"), {
code: "INVALID_EMAIL_OR_PASSWORD",
status: 401,
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "I already have an account",
);
expect(existingAccountButton).not.toBeNull();
await act(async () => {
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
await act(async () => {
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(passwordInput, "wrongpass");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
expect(authForm).not.toBeNull();
await act(async () => {
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
expect(signInEmailMock).toHaveBeenCalledWith({
email: "jane@example.com",
password: "wrongpass",
});
expect(container.textContent).toContain(
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
);
await act(async () => {
root.unmount();
});
});
it("auto-accepts the invite after account creation and redirects into the company", async () => {
getSessionMock.mockResolvedValueOnce(null);
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "approved",
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null;
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
expect(nameInput).not.toBeNull();
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
await act(async () => {
inputValueSetter!.call(nameInput, "Jane Example");
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(passwordInput, "supersecret");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
expect(authForm).not.toBeNull();
await act(async () => {
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
await flushReact();
await flushReact();
expect(signUpEmailMock).toHaveBeenCalledWith({
name: "Jane Example",
email: "jane@example.com",
password: "supersecret",
});
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true);
expect(queryClient.getQueryData(queryKeys.companies.list("user-1"))).toMatchObject({
companies: [],
unauthorized: false,
});
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
await act(async () => {
root.unmount();
});
});
it("shows the pending approval page with the company icon and non-clickable access instructions", async () => {
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
expect(container.textContent).toContain("An organization admin must approve your request to join.");
expect(container.textContent).toContain(
"Ask them to visit Settings → Members to approve your request.",
);
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
expect(container.textContent).not.toContain("http://localhost/company/settings/members");
// The "Settings → Members" guidance addresses the company admin,
// not the requester. It must render as plain text so the requester cannot
// navigate themselves to /company/settings/members — a route they have no
// permission to view, which renders a misleading "No company access"
// panel and makes the invite flow look broken. See #6784.
const approvalAnchors = Array.from(container.querySelectorAll("a")).filter(
(link) => link.textContent === "Settings → Members",
);
expect(approvalAnchors).toHaveLength(0);
const approvalMentions =
container.textContent?.match(/Settings → Members/g) ?? [];
expect(approvalMentions).toHaveLength(2);
await act(async () => {
root.unmount();
});
});
it("auto-completes a previously accepted human invite after sign-in", async () => {
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
companyName: "Acme Robotics",
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
inviteType: "company_join",
allowedJoinTypes: "both",
humanRole: "operator",
expiresAt: "2027-03-07T00:10:00.000Z",
inviteMessage: "Welcome aboard.",
joinRequestStatus: "pending_approval",
joinRequestType: "human",
});
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "approved",
});
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true);
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
await act(async () => {
root.unmount();
});
});
it("asks unauthenticated users to sign in before completing an accepted human invite", async () => {
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
companyName: "Acme Robotics",
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
inviteType: "company_join",
allowedJoinTypes: "human",
humanRole: "operator",
expiresAt: "2027-03-07T00:10:00.000Z",
inviteMessage: "Welcome aboard.",
joinRequestStatus: "pending_approval",
joinRequestType: "human",
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
expect(acceptInviteMock).not.toHaveBeenCalled();
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
expect(container.textContent).toContain("Create your account");
expect(container.querySelector('[data-testid="invite-pending-approval"]')).toBeNull();
await act(async () => {
root.unmount();
});
});
it("redirects straight to the company after sign-in when the user already has access", async () => {
getSessionMock.mockResolvedValueOnce(null);
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "I already have an account",
);
expect(existingAccountButton).not.toBeNull();
await act(async () => {
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
await act(async () => {
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(passwordInput, "supersecret");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
expect(authForm).not.toBeNull();
await act(async () => {
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
expect(signInEmailMock).toHaveBeenCalledWith({
email: "jane@example.com",
password: "supersecret",
});
expect(acceptInviteMock).not.toHaveBeenCalled();
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
expect(queryClient.getQueryData(queryKeys.companies.list("user-1"))).toMatchObject({
companies: [{ id: "company-1", name: "Acme Robotics" }],
unauthorized: false,
});
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
await act(async () => {
root.unmount();
});
});
// The `["companies"]` cache entry is shared app-wide and carries no account
// identity, so a list fetched moments ago for a different account is still inside
// the app-wide staleTime. Membership decisions on this page — the post-sign-in
// redirect, the "already a member" panel, and clearing the pending invite token —
// must run against a list fetched for the account that is signed in now.
describe("membership checks against a previous account's cached company list", () => {
const PREVIOUS_ACCOUNT_COMPANIES = [{ id: "company-1", name: "Acme Robotics" }];
// Mirrors the app-wide default in main.tsx. Without it every cache entry is
// stale on read and the staleness these tests cover cannot occur.
// `ownerId` is whose list this warm entry is. Since #11488 the entry is keyed
// by account, so "a list left by a previous account" means putting it under
// that account's key — which is also why the page can no longer reach it.
// `null` is the signed-out / local_trusted key.
function createAppLikeQueryClient(ownerId: string | null) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000 } },
});
queryClient.setQueryData(queryKeys.companies.list(ownerId), {
companies: PREVIOUS_ACCOUNT_COMPANIES,
unauthorized: false,
});
return queryClient;
}
it("re-reads membership for the account that just signed in", async () => {
getSessionMock.mockResolvedValueOnce(null);
getSessionMock.mockResolvedValue({
session: { id: "session-2", userId: "user-2" },
user: {
id: "user-2",
name: "Sam Example",
email: "sam@example.com",
image: null,
},
});
// The account signing in here has no companies of its own.
listCompaniesMock.mockResolvedValue([]);
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
const root = createRoot(container);
const queryClient = createAppLikeQueryClient("user-1");
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "I already have an account",
);
await act(async () => {
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
await act(async () => {
inputValueSetter!.call(emailInput, "sam@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(passwordInput, "supersecret");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
await act(async () => {
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
await flushReact();
await flushReact();
expect(signInEmailMock).toHaveBeenCalledWith({
email: "sam@example.com",
password: "supersecret",
});
// The cached list was refused, not read as proof of membership.
expect(listCompaniesMock).toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.companies.list("user-2"))).toMatchObject({
companies: [],
unauthorized: false,
});
// The previous account's entry is untouched — and unreachable from here.
expect(queryClient.getQueryData(queryKeys.companies.list("user-1"))).toMatchObject({
companies: PREVIOUS_ACCOUNT_COMPANIES,
});
expect(setSelectedCompanyIdMock).not.toHaveBeenCalled();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
await act(async () => {
root.unmount();
});
});
it("re-reads membership on mount for an already signed-in account", async () => {
getSessionMock.mockResolvedValue({
session: { id: "session-2", userId: "user-2" },
user: {
id: "user-2",
name: "Sam Example",
email: "sam@example.com",
image: null,
},
});
listCompaniesMock.mockResolvedValue([]);
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
const root = createRoot(container);
const queryClient = createAppLikeQueryClient("user-1");
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
await flushReact();
expect(listCompaniesMock).toHaveBeenCalled();
expect(container.textContent).not.toContain("Already in this organization");
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
await act(async () => {
root.unmount();
});
});
it("keeps the pending invite token when the session has lapsed", async () => {
// Session gone server-side, previous account's list still warm in the cache.
// Nobody is signed in, so nothing here proves membership — the token has to
// survive for the sign-in that follows.
getSessionMock.mockResolvedValue(null);
const root = createRoot(container);
const queryClient = createAppLikeQueryClient("user-1");
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
await act(async () => {
root.unmount();
});
});
it("still reads the shared list as membership on a local_trusted instance", async () => {
// No accounts exist in this mode, so the shared list cannot belong to anyone
// else and stays authoritative.
healthGetMock.mockResolvedValue({ status: "ok", deploymentMode: "local_trusted" });
getSessionMock.mockResolvedValue(null);
const root = createRoot(container);
const queryClient = createAppLikeQueryClient(null);
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(container.textContent).toContain("Already in this organization");
expect(acceptInviteMock).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
});
it("shows invite details instead of auto-redirecting for signed-in existing members", async () => {
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Join Acme Robotics");
expect(container.textContent).toContain("Already in this organization");
expect(container.textContent).toContain("This account already belongs to Acme Robotics.");
expect(acceptInviteMock).not.toHaveBeenCalled();
const openButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Open organization",
);
expect(openButton).not.toBeNull();
await act(async () => {
openButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
await act(async () => {
root.unmount();
});
});
it("falls back to the generated company icon when the invite logo fails to load", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const logo = container.querySelector('img[alt="Acme Robotics logo"]') as HTMLImageElement | null;
expect(logo).not.toBeNull();
await act(async () => {
logo?.dispatchEvent(new Event("error"));
});
await flushReact();
expect(container.querySelector('img[alt="Acme Robotics logo"]')).toBeNull();
expect(container.querySelector('img[aria-hidden="true"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
it("normalizes the shared company cache envelope before checking membership", async () => {
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.companies.list("user-1"), {
companies: [],
unauthorized: false,
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
await act(async () => {
root.unmount();
});
});
it("waits for the membership check before showing invite acceptance to signed-in users", async () => {
let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null;
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
listCompaniesMock.mockImplementation(
() =>
new Promise<Array<{ id: string; name: string }>>((resolve) => {
resolveCompanies = resolve;
}),
);
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
expect(container.textContent).toContain("Checking your access...");
expect(container.textContent).not.toContain("Accept organization invite");
expect(acceptInviteMock).not.toHaveBeenCalled();
await act(async () => {
resolveCompanies?.([]);
});
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
await act(async () => {
root.unmount();
});
});
});