Files
PaperClipAI/ui/src/lib/onboarding-state.test.ts
T
TonioandClaude Opus 5 66515582e4 fix(onboarding): do not restore wizard state for a company the user does not own (#11370)
Onboarding persists a draft to `localStorage`, including `createdCompanyId`.
That key is scoped per browser origin, not per account, so a browser that has
already run onboarding hands the stored company id to the next session on the
same origin — whoever is signed in. Every downstream call then targets a
company that account may not own: goals, agents and issues are created there,
and requests fail with authorization errors.

`restoreOnboardingState` now returns a saved draft only when the signed-in
account owns the company it names. Otherwise the draft is discarded and the
stale blob removed.

The wizard splits into a gate and an inner component because the inner one
has ~20 `useState(saved?.x ?? default)` initializers, and an initializer runs
only on the first render. Mounting before the restored draft is final locks
every field to its default with no way back, so the gate waits for the
company list while it is loading.

Ownership is judged only against a list that actually answered. Any company
query error makes it undecidable, whatever the list contains — the companies
cache is not account-scoped and survives sign-out, so a failed refetch after
an account switch can leave the previous account's companies in hand, and
trusting a non-empty list there would hand one account's draft to the next.
Nothing is restored and nothing is deleted in that state; the next successful
load decides.

Judging the draft and mounting the wizard are separate questions. The gate
withholds the wizard only while the list is *loading*, never on error: the
companies query sets `retry: false`, and with no companies the dashboard
offers a "Get Started" button wired to onboarding, so blocking there would
make that button do nothing at all. Mounting costs the draft nothing, because
the persist effect is itself gated on the wizard being open.

All four draft-storage call sites — read, write, cleanup, reset — go through
one guarded helper. Storage access throws outright where a browser denies it,
and each site sits in a render, an effect or a close handler, so an escaping
exception took down something the customer was using.

Lands the work from #9900 by @stubbi, whose two commits are included
unchanged with their authorship. The rebase, the error-path handling and the
storage guards are mine.

Follow-up filed separately: sign-out should remove account-scoped cached data
rather than invalidating two keys. This change is defensive and protects
onboarding only.

Tested: ui typecheck clean; 73 pass across the seven onboarding suites; full
ui suite 3952 pass, with one timezone-dependent IssueProperties failure
present on master in a file this does not touch. All CI gates green;
Greptile 5/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:08:48 -07:00

36 lines
1.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { restoreOnboardingState } from "./onboarding-state";
const companies = [{ id: "company-new" }];
describe("restoreOnboardingState", () => {
it("returns null for unusable input", () => {
expect(restoreOnboardingState(null, companies)).toBeNull();
expect(restoreOnboardingState("nonsense", companies)).toBeNull();
});
it("discards state whose company the user does not own", () => {
const saved = { step: 4, companyName: "Old Co", createdCompanyId: "company-old" };
expect(restoreOnboardingState(saved, companies)).toBeNull();
});
it("keeps state for a company the user does own", () => {
const saved = { step: 4, companyName: "New Co", createdCompanyId: "company-new" };
expect(restoreOnboardingState(saved, companies)?.companyName).toBe("New Co");
});
it("keeps in-progress state that has no company yet", () => {
const saved = { step: 1, companyName: "Draft" };
expect(restoreOnboardingState(saved, companies)?.companyName).toBe("Draft");
});
it("discards a company id against an empty (settled) companies list", () => {
// Per the CONTRACT in the JSDoc, callers must only invoke this once
// companies have settled. An empty settled list legitimately means the
// account owns no companies, so a saved company id is discarded exactly
// like an unowned one.
const saved = { step: 4, createdCompanyId: "company-new" };
expect(restoreOnboardingState(saved, [])).toBeNull();
});
});