diff --git a/src/api/client.ts b/src/api/client.ts index 0dc75eb..9b355a8 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,9 @@ -export type ApiSession = { accessToken: string; tenantId: string; username: string }; +export type ApiSession = { + accessToken: string; + tenantId: string; + username: string; + subjectId: string; +}; export type ApiDownload = { blob: Blob; fileName: string | null; contentType: string | null }; export class ApiError extends Error { @@ -12,6 +17,28 @@ export class ApiError extends Error { } } +export function accessTokenSubjectId(accessToken: string): string { + try { + const parts = accessToken.split("."); + if (parts.length !== 3 || !parts[1]) throw new Error("Malformed token."); + const base64 = parts[1].replaceAll("-", "+").replaceAll("_", "/") + + "=".repeat((4 - (parts[1].length % 4)) % 4); + const bytes = Uint8Array.from(atob(base64), (value) => value.charCodeAt(0)); + const payload = JSON.parse(new TextDecoder().decode(bytes)) as { + sub?: unknown; + }; + if ( + typeof payload.sub !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(payload.sub) + ) { + throw new Error("Token subject is missing."); + } + return payload.sub.toLowerCase(); + } catch { + throw new Error("The authenticated account identity is unavailable."); + } +} + export function resolveApiBaseUrl(): string { return trimTrailingSlash(import.meta.env.VITE_BUNKFY_API_BASE_URL?.trim() || "http://localhost:5194"); } diff --git a/src/app/session.tsx b/src/app/session.tsx index d6f2e81..873b444 100644 --- a/src/app/session.tsx +++ b/src/app/session.tsx @@ -12,6 +12,7 @@ import { apiDownload, apiRequest, apiStream, + accessTokenSubjectId, resolveApiBaseUrl, type ApiDownload, type ApiSession, @@ -126,7 +127,11 @@ export function SessionProvider({ children }: { children: ReactNode }) { headers: { "X-Tenant-Id": GLOBAL_IDENTITY_SCOPE }, }, ); - const refreshed = { ...identity, accessToken }; + const refreshed = { + ...identity, + accessToken, + subjectId: accessTokenSubjectId(accessToken), + }; if (!acceptsRefreshRef.current) { throw new Error("You are signed out."); } @@ -191,6 +196,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { ...response, tenantId: GLOBAL_IDENTITY_SCOPE, username: credentials.username, + subjectId: accessTokenSubjectId(response.accessToken), }); return null; }); @@ -219,6 +225,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { ...response, tenantId: GLOBAL_IDENTITY_SCOPE, username, + subjectId: accessTokenSubjectId(response.accessToken), }; try { const methods = await apiRequest( @@ -355,6 +362,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { ...response, tenantId: GLOBAL_IDENTITY_SCOPE, username: `${providerLabel(provider)} account`, + subjectId: accessTokenSubjectId(response.accessToken), }; try { const methods = await apiRequest( @@ -441,7 +449,11 @@ export function SessionProvider({ children }: { children: ReactNode }) { }, active, ); - setSession({ ...active, accessToken: response.accessToken }); + setSession({ + ...active, + accessToken: response.accessToken, + subjectId: accessTokenSubjectId(response.accessToken), + }); }, [setSession], ); @@ -458,7 +470,11 @@ export function SessionProvider({ children }: { children: ReactNode }) { }, active, ); - setSession({ ...active, accessToken: response.accessToken }); + setSession({ + ...active, + accessToken: response.accessToken, + subjectId: accessTokenSubjectId(response.accessToken), + }); return response.recoveryCodes; }, [setSession], diff --git a/src/app/workspace.tsx b/src/app/workspace.tsx index 7d7f4c8..434a783 100644 --- a/src/app/workspace.tsx +++ b/src/app/workspace.tsx @@ -8,12 +8,12 @@ import { useState, type ReactNode, } from "react"; -import type { - OrganizationListResponse, - OrganizationMembershipSummary, - Property, -} from "../api/types"; +import type { OrganizationMembershipSummary, Property } from "../api/types"; import { loadAllProperties } from "../features/properties/propertiesApi"; +import { + loadAllWorkspaces, + resolveSelectedWorkspaceId, +} from "../features/workspaces/workspacesApi"; import { useSession } from "./session"; const WORKSPACE_STORAGE_KEY = "bunkfy.workspace.current.v1"; @@ -45,23 +45,24 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { }); const workspacesQuery = useQuery({ queryKey: ["organizations", "mine"], - queryFn: () => request("/api/organizations?page=1&pageSize=100"), + queryFn: (context) => loadAllWorkspaces(request, context.signal), }); - const workspaces = workspacesQuery.data?.items ?? []; + const workspaces = workspacesQuery.data ?? []; useEffect(() => { - if (workspacesQuery.isLoading) return; - const selectedExists = workspaces.some( - (item) => item.organization.organizationId === selectedWorkspaceId, - ); - const nextId = selectedExists - ? selectedWorkspaceId - : workspaces[0]?.organization.organizationId ?? ""; + if (workspacesQuery.isLoading || workspacesQuery.error) return; + const nextId = resolveSelectedWorkspaceId(workspaces, selectedWorkspaceId); if (nextId !== selectedWorkspaceId) setSelectedWorkspaceIdState(nextId); selectWorkspace(nextId); if (nextId) localStorage.setItem(WORKSPACE_STORAGE_KEY, nextId); else localStorage.removeItem(WORKSPACE_STORAGE_KEY); - }, [selectWorkspace, selectedWorkspaceId, workspaces, workspacesQuery.isLoading]); + }, [ + selectWorkspace, + selectedWorkspaceId, + workspaces, + workspacesQuery.error, + workspacesQuery.isLoading, + ]); const setSelectedWorkspaceId = useCallback( (id: string) => { @@ -115,7 +116,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { selectedWorkspaceId, setSelectedWorkspaceId, refetchWorkspaces: async () => { - await workspacesQuery.refetch(); + await workspacesQuery.refetch({ throwOnError: true }); }, properties, propertiesLoading: propertiesQuery.isLoading, diff --git a/src/features/workspaces/WorkspaceInvitesSettings.tsx b/src/features/workspaces/WorkspaceInvitesSettings.tsx index fc6f1a9..7b2ef6b 100644 --- a/src/features/workspaces/WorkspaceInvitesSettings.tsx +++ b/src/features/workspaces/WorkspaceInvitesSettings.tsx @@ -28,6 +28,16 @@ import { SelectPicker } from "../../components/ui/SelectPicker"; import { AccessProfilePicker, PropertyScopeField } from "./WorkspaceAccessControls"; import { WorkspaceJoinRequestSettings } from "./WorkspaceJoinRequestSettings"; import { canReplaceJoinSource, isActiveJoinSource, joinSourceStatusLabel } from "./workspaceJoinSources"; +import { + clearWorkspaceJoinSourceIssuanceAttempt, + clearWorkspaceJoinSourceReplacementAttempt, + finalizeWorkspaceJoinSourceIssuance, + resolveWorkspaceJoinSourceIssuanceAttempt, + resolveWorkspaceJoinSourceReplacementAttempt, + workspaceJoinSourceIssuanceOutcome, + type WorkspaceJoinSourceIssuanceAttempt, + type WorkspaceJoinSourceReplacementAttempt, +} from "./workspaceJoinSourceAttempt"; const ACTIVE_PROFILE_PAGE_SIZE = 100; const SOURCE_PAGE_SIZE = 10; @@ -58,6 +68,29 @@ export function WorkspaceInvitesSettings({ ), }); + function handleIssuance( + kind: IssuedJoinLink["kind"], + issuance: WorkspaceStaffJoinSourceIssuance, + lifetimeHours: number, + action: "create" | "replace", + ) { + const outcome = workspaceJoinSourceIssuanceOutcome(issuance); + if (outcome.kind === "token") { + setTokenNotice(null); + setIssued({ kind, token: outcome.token, lifetimeHours }); + return; + } + + if (outcome.kind === "reconciled") { + setTokenNotice(action === "create" + ? "This source was already issued, so its one-time token cannot be shown again. Replace it to create a new link." + : "The replacement exists, but its one-time token was already returned and cannot be replayed. Replace it again if the link was lost."); + return; + } + + setTokenNotice("The server returned an inconsistent one-time-token receipt, so no token was displayed. Refresh the issued-source list before retrying or replacing it."); + } + return (
{profiles.data?.hasMore && ( @@ -73,28 +106,16 @@ export function WorkspaceInvitesSettings({ workspaceId={workspaceId} profiles={profiles.data.items.filter((profile) => profile.status === 1)} properties={properties} - onIssued={(kind, issuance, lifetimeHours) => { - if (!issuance.token) { - setTokenNotice("This source was already issued, so its one-time token cannot be shown again. Replace it to create a new link."); - return; - } - setTokenNotice(null); - setIssued({ kind, token: issuance.token, lifetimeHours }); - }} + onIssued={(kind, issuance, lifetimeHours) => + handleIssuance(kind, issuance, lifetimeHours, "create")} /> {tokenNotice &&
{tokenNotice}
} { - if (!issuance.token) { - setTokenNotice("The replacement exists, but its one-time token was already returned and cannot be replayed. Replace it again if the link was lost."); - return; - } - setTokenNotice(null); - setIssued({ kind, token: issuance.token, lifetimeHours }); - }} + onIssued={(kind, issuance, lifetimeHours) => + handleIssuance(kind, issuance, lifetimeHours, "replace")} /> )} @@ -116,20 +137,20 @@ function JoinSourceCreation({ onIssued: (kind: IssuedJoinLink["kind"], issuance: WorkspaceStaffJoinSourceIssuance, lifetimeHours: number) => void; }) { const { emailVerificationEnabled } = useProductCapabilities(); - const { request } = useSession(); + const { request, session } = useSession(); const queryClient = useQueryClient(); const reusableProfiles = profiles.filter((profile) => REUSABLE_PROFILE_KEYS.has(profile.key)); const [email, setEmail] = useState(""); const [inviteProfileId, setInviteProfileId] = useState(""); const [invitePropertyIds, setInvitePropertyIds] = useState([]); const [inviteLifetimeHours, setInviteLifetimeHours] = useState(72); - const [inviteSourceId, setInviteSourceId] = useState(() => crypto.randomUUID()); + const inviteAttempt = useRef(null); const [enrollmentProfileId, setEnrollmentProfileId] = useState(""); const [enrollmentPropertyIds, setEnrollmentPropertyIds] = useState([]); const [enrollmentLifetimeHours, setEnrollmentLifetimeHours] = useState(24); const [maximumClaims, setMaximumClaims] = useState(20); const [approvalMode, setApprovalMode] = useState("2"); - const [enrollmentSourceId, setEnrollmentSourceId] = useState(() => crypto.randomUUID()); + const enrollmentAttempt = useRef(null); useEffect(() => { if (!profiles.some((profile) => profile.profileId === inviteProfileId)) { @@ -143,13 +164,30 @@ function JoinSourceCreation({ }, [enrollmentProfileId, reusableProfiles]); const invite = useMutation({ - mutationFn: () => { + mutationFn: async () => { + const accountId = session?.subjectId; + if (!accountId) throw new Error("You are signed out."); const profile = profiles.find((item) => item.profileId === inviteProfileId); if (!profile) throw new Error("Choose a role for this invitation."); + inviteAttempt.current = await resolveWorkspaceJoinSourceIssuanceAttempt( + inviteAttempt.current, + accountId, + { + workspaceId, + kind: "invitation", + recipientEmail: email, + lifetimeHours: inviteLifetimeHours, + profileKey: profile.key, + profileId: profile.profileId, + propertyIds: invitePropertyIds, + maximumClaims: null, + approvalMode: null, + }, + ); return request("/api/workspace-staff-enrollment/sources/invitations", { method: "POST", body: JSON.stringify({ - sourceId: inviteSourceId, + sourceId: inviteAttempt.current.operationId, recipientEmail: email.trim() || null, lifetimeHours: inviteLifetimeHours, profileKey: profile.key, @@ -158,19 +196,46 @@ function JoinSourceCreation({ }); }, onSuccess: async (result) => { + const accountId = session?.subjectId; + if (!accountId) throw new Error("You are signed out."); + const outcome = await finalizeWorkspaceJoinSourceIssuance( + result, + () => clearWorkspaceJoinSourceIssuanceAttempt( + accountId, + workspaceId, + "invitation", + ), + ); + if (outcome.kind !== "invalid") inviteAttempt.current = null; onIssued("invitation", result, inviteLifetimeHours); - setInviteSourceId(crypto.randomUUID()); await queryClient.invalidateQueries({ queryKey: ["workspace-access", workspaceId, "join-sources"] }); }, }); const enrollment = useMutation({ - mutationFn: () => { + mutationFn: async () => { + const accountId = session?.subjectId; + if (!accountId) throw new Error("You are signed out."); const profile = reusableProfiles.find((item) => item.profileId === enrollmentProfileId); if (!profile) throw new Error("Choose a reusable low-privilege role."); + enrollmentAttempt.current = await resolveWorkspaceJoinSourceIssuanceAttempt( + enrollmentAttempt.current, + accountId, + { + workspaceId, + kind: "enrollment", + recipientEmail: null, + lifetimeHours: enrollmentLifetimeHours, + profileKey: profile.key, + profileId: profile.profileId, + propertyIds: enrollmentPropertyIds, + maximumClaims, + approvalMode: Number(approvalMode), + }, + ); return request("/api/workspace-staff-enrollment/sources/enrollment-links", { method: "POST", body: JSON.stringify({ - sourceId: enrollmentSourceId, + sourceId: enrollmentAttempt.current.operationId, lifetimeHours: enrollmentLifetimeHours, maximumClaims, approvalMode: Number(approvalMode), @@ -180,20 +245,28 @@ function JoinSourceCreation({ }); }, onSuccess: async (result) => { + const accountId = session?.subjectId; + if (!accountId) throw new Error("You are signed out."); + const outcome = await finalizeWorkspaceJoinSourceIssuance( + result, + () => clearWorkspaceJoinSourceIssuanceAttempt( + accountId, + workspaceId, + "enrollment", + ), + ); + if (outcome.kind !== "invalid") enrollmentAttempt.current = null; onIssued("enrollment", result, enrollmentLifetimeHours); - setEnrollmentSourceId(crypto.randomUUID()); await queryClient.invalidateQueries({ queryKey: ["workspace-access", workspaceId, "join-sources"] }); }, }); function changeInviteProfile(value: string) { setInviteProfileId(value); - setInviteSourceId(crypto.randomUUID()); } function changeEnrollmentProfile(value: string) { setEnrollmentProfileId(value); - setEnrollmentSourceId(crypto.randomUUID()); } return ( @@ -210,7 +283,7 @@ function JoinSourceCreation({ className="input input-bordered w-full" type="email" value={email} - onChange={(event) => { setEmail(event.target.value); setInviteSourceId(crypto.randomUUID()); }} + onChange={(event) => setEmail(event.target.value)} placeholder="staff@example.com" /> @@ -223,9 +296,9 @@ function JoinSourceCreation({ { setInvitePropertyIds(ids); setInviteSourceId(crypto.randomUUID()); }} + onChange={setInvitePropertyIds} /> - { setInviteLifetimeHours(value); setInviteSourceId(crypto.randomUUID()); }} /> + {invite.error && } diff --git a/src/features/workspaces/WorkspaceSettingsPage.tsx b/src/features/workspaces/WorkspaceSettingsPage.tsx index 267ea2d..315f9ef 100644 --- a/src/features/workspaces/WorkspaceSettingsPage.tsx +++ b/src/features/workspaces/WorkspaceSettingsPage.tsx @@ -25,6 +25,7 @@ import { type WorkspaceSettingsTab, } from "./workspaceSettingsAccess"; import { + clearWorkspaceUpdateAttempt, resolveWorkspaceUpdateAttempt, type WorkspaceUpdateAttempt, type WorkspaceUpdatePayload, @@ -131,6 +132,7 @@ export function WorkspaceSettingsPage() { @@ -160,6 +162,7 @@ export function WorkspaceSettingsPage() { )} {tab === "invites" && capabilities.canManageInvites && ( Promise; }) { @@ -186,18 +191,20 @@ function GeneralSettings({ const [slug, setSlug] = useState(workspace.slug); const updateAttempt = useRef(null); const update = useMutation({ - mutationFn: () => { + mutationFn: async () => { + if (!accountId) throw new Error("You are signed out."); const payload: WorkspaceUpdatePayload = { organizationId: workspace.organizationId, expectedVersion: workspace.version, name, slug, }; - updateAttempt.current = resolveWorkspaceUpdateAttempt( + updateAttempt.current = await resolveWorkspaceUpdateAttempt( updateAttempt.current, + accountId, payload, ); - return request( + const result = await request( `/api/organizations/${workspace.organizationId}`, { method: "PUT", @@ -209,10 +216,12 @@ function GeneralSettings({ }), }, ); + return result; }, onSuccess: async () => { - updateAttempt.current = null; await onSaved(); + await clearWorkspaceUpdateAttempt(accountId, workspace.organizationId); + updateAttempt.current = null; }, }); return ( diff --git a/src/features/workspaces/organizationOperationAttempt.ts b/src/features/workspaces/organizationOperationAttempt.ts new file mode 100644 index 0000000..5ac314d --- /dev/null +++ b/src/features/workspaces/organizationOperationAttempt.ts @@ -0,0 +1,160 @@ +const STORAGE_KEY_PREFIX = "bunkfy.organizations.pending-operation.v1"; + +export type OrganizationOperationScope = { + accountId: string; + workspaceId: string; + action: string; +}; + +export type OrganizationOperationAttempt = { + version: 1; + scopeDigest: string; + intentDigest: string; + operationId: string; +}; + +export type OrganizationOperationAttemptStorage = Pick< + Storage, + "getItem" | "setItem" | "removeItem" +>; + +export type OrganizationOperationAttemptOptions = { + storage?: OrganizationOperationAttemptStorage; + createOperationId?: () => string; +}; + +export async function resolveOrganizationOperationAttempt( + current: OrganizationOperationAttempt | null, + scope: OrganizationOperationScope, + intentValues: string[], + options: OrganizationOperationAttemptOptions = {}, +): Promise { + const storage = options.storage ?? window.sessionStorage; + const scopeDigest = await organizationOperationScopeDigest(scope); + const intentDigest = await organizationOperationIntentDigest( + scopeDigest, + intentValues, + ); + const candidate = isOrganizationOperationAttempt(current, scopeDigest) + ? current + : readAttempt(storage, scopeDigest); + + const attempt = candidate?.intentDigest === intentDigest + ? candidate + : { + version: 1 as const, + scopeDigest, + intentDigest, + operationId: (options.createOperationId ?? (() => crypto.randomUUID()))(), + }; + + // Persistence happens before the caller sends the request. If browser storage + // is unavailable, setItem throws and the mutation fails closed without a send. + storage.setItem(attemptStorageKey(scopeDigest), JSON.stringify(attempt)); + return attempt; +} + +export async function readOrganizationOperationAttempt( + scope: OrganizationOperationScope, + storage: OrganizationOperationAttemptStorage = window.sessionStorage, +): Promise { + return readAttempt( + storage, + await organizationOperationScopeDigest(scope), + ); +} + +export async function clearOrganizationOperationAttempt( + scope: OrganizationOperationScope, + storage: Pick = window.sessionStorage, +): Promise { + storage.removeItem(attemptStorageKey( + await organizationOperationScopeDigest(scope), + )); +} + +export async function organizationOperationScopeDigest( + scope: OrganizationOperationScope, +): Promise { + return hashValues([ + "bunkfy-organizations-operation-scope-v1", + scope.accountId.trim().toLowerCase(), + scope.workspaceId.trim().toLowerCase(), + scope.action.trim().toLowerCase(), + ]); +} + +async function organizationOperationIntentDigest( + scopeDigest: string, + intentValues: string[], +): Promise { + return hashValues([ + "bunkfy-organizations-operation-intent-v1", + scopeDigest, + ...intentValues, + ]); +} + +async function hashValues(values: string[]): Promise { + const encoder = new TextEncoder(); + const chunks = values.map((value) => encoder.encode(value)); + const byteLength = chunks.reduce( + (total, chunk) => total + 4 + chunk.byteLength, + 0, + ); + const input = new Uint8Array(byteLength); + const view = new DataView(input.buffer); + let offset = 0; + for (const chunk of chunks) { + view.setUint32(offset, chunk.byteLength); + offset += 4; + input.set(chunk, offset); + offset += chunk.byteLength; + } + + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)); + return Array.from(digest, (value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +function readAttempt( + storage: OrganizationOperationAttemptStorage, + scopeDigest: string, +): OrganizationOperationAttempt | null { + const key = attemptStorageKey(scopeDigest); + const raw = storage.getItem(key); + if (raw === null) return null; + + try { + const value = JSON.parse(raw) as Partial; + if (isOrganizationOperationAttempt(value, scopeDigest)) return value; + } catch { + // Corrupt records are removed below and are never reused. + } + + storage.removeItem(key); + return null; +} + +function isOrganizationOperationAttempt( + value: Partial | null, + scopeDigest: string, +): value is OrganizationOperationAttempt { + return value?.version === 1 && + value.scopeDigest === scopeDigest && + isDigest(value.intentDigest) && + isOperationId(value.operationId); +} + +function isDigest(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); +} + +function isOperationId(value: unknown): value is string { + return typeof value === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function attemptStorageKey(scopeDigest: string): string { + return `${STORAGE_KEY_PREFIX}:${scopeDigest}`; +} diff --git a/src/features/workspaces/workspaceCreationAttempt.ts b/src/features/workspaces/workspaceCreationAttempt.ts index 7afb23a..d56660e 100644 --- a/src/features/workspaces/workspaceCreationAttempt.ts +++ b/src/features/workspaces/workspaceCreationAttempt.ts @@ -1,22 +1,51 @@ +import { + clearOrganizationOperationAttempt, + readOrganizationOperationAttempt, + resolveOrganizationOperationAttempt, + type OrganizationOperationAttempt, + type OrganizationOperationAttemptOptions, + type OrganizationOperationScope, +} from "./organizationOperationAttempt"; + export type WorkspaceCreationPayload = { name: string; slug: string; }; -export type WorkspaceCreationAttempt = { - fingerprint: string; - operationId: string; -}; +export type WorkspaceCreationAttempt = OrganizationOperationAttempt; -export function resolveWorkspaceCreationAttempt( +export async function resolveWorkspaceCreationAttempt( current: WorkspaceCreationAttempt | null, + accountId: string, payload: WorkspaceCreationPayload, - createOperationId: () => string = () => crypto.randomUUID(), -): WorkspaceCreationAttempt { - const fingerprint = workspaceCreationFingerprint(payload); - return current?.fingerprint === fingerprint - ? current - : { fingerprint, operationId: createOperationId() }; + options: OrganizationOperationAttemptOptions = {}, +): Promise { + return resolveOrganizationOperationAttempt( + current, + workspaceCreationScope(accountId), + [workspaceCreationFingerprint(payload)], + options, + ); +} + +export async function clearWorkspaceCreationAttempt( + accountId: string, + storage?: Pick, +): Promise { + return clearOrganizationOperationAttempt( + workspaceCreationScope(accountId), + storage, + ); +} + +export async function readWorkspaceCreationAttempt( + accountId: string, + storage?: Parameters[1], +): Promise { + return readOrganizationOperationAttempt( + workspaceCreationScope(accountId), + storage, + ); } export function workspaceCreationFingerprint( @@ -27,3 +56,11 @@ export function workspaceCreationFingerprint( slug: payload.slug.trim().toLowerCase(), }); } + +function workspaceCreationScope(accountId: string): OrganizationOperationScope { + return { + accountId, + workspaceId: "new-workspace", + action: "create-workspace", + }; +} diff --git a/src/features/workspaces/workspaceJoinSourceAttempt.ts b/src/features/workspaces/workspaceJoinSourceAttempt.ts new file mode 100644 index 0000000..6eb008e --- /dev/null +++ b/src/features/workspaces/workspaceJoinSourceAttempt.ts @@ -0,0 +1,176 @@ +import type { WorkspaceStaffJoinSourceIssuance } from "../../api/types"; +import { + clearOrganizationOperationAttempt, + resolveOrganizationOperationAttempt, + type OrganizationOperationAttempt, + type OrganizationOperationAttemptOptions, + type OrganizationOperationScope, +} from "./organizationOperationAttempt"; + +export type WorkspaceJoinSourceKind = "invitation" | "enrollment"; + +export type WorkspaceJoinSourceIssuancePayload = { + workspaceId: string; + kind: WorkspaceJoinSourceKind; + recipientEmail: string | null; + lifetimeHours: number; + profileKey: string; + profileId: string; + propertyIds: string[]; + maximumClaims: number | null; + approvalMode: number | null; +}; + +export type WorkspaceJoinSourceIssuanceAttempt = OrganizationOperationAttempt; + +export type WorkspaceJoinSourceReplacementPayload = { + workspaceId: string; + sourceId: string; + sourceKind: number; + expectedVersion: number; + lifetimeHours: number; +}; + +export type WorkspaceJoinSourceReplacementAttempt = OrganizationOperationAttempt; + +export type WorkspaceJoinSourceIssuanceOutcome = + | { kind: "token"; token: string } + | { kind: "reconciled" } + | { kind: "invalid" }; + +/** + * Persists only an opaque SHA-256 intent digest and random retry identity. The + * UUID correlates retries; authorization still comes from the signed-in session. + */ +export async function resolveWorkspaceJoinSourceIssuanceAttempt( + current: WorkspaceJoinSourceIssuanceAttempt | null, + accountId: string, + payload: WorkspaceJoinSourceIssuancePayload, + options: OrganizationOperationAttemptOptions = {}, +): Promise { + return resolveOrganizationOperationAttempt( + current, + issuanceScope(accountId, payload.workspaceId, payload.kind), + [workspaceJoinSourceIssuanceFingerprint(payload)], + options, + ); +} + +export async function clearWorkspaceJoinSourceIssuanceAttempt( + accountId: string, + workspaceId: string, + kind: WorkspaceJoinSourceKind, + storage?: Pick, +): Promise { + return clearOrganizationOperationAttempt( + issuanceScope(accountId, workspaceId, kind), + storage, + ); +} + +export function workspaceJoinSourceIssuanceFingerprint( + payload: WorkspaceJoinSourceIssuancePayload, +): string { + return JSON.stringify({ + workspaceId: payload.workspaceId.trim().toLowerCase(), + kind: payload.kind, + recipientEmail: payload.recipientEmail?.trim().toLowerCase() || null, + lifetimeHours: payload.lifetimeHours, + profileKey: payload.profileKey.trim().toLowerCase(), + profileId: payload.profileId.trim().toLowerCase(), + propertyIds: normalizeIds(payload.propertyIds), + maximumClaims: payload.maximumClaims, + approvalMode: payload.approvalMode, + }); +} + +export async function resolveWorkspaceJoinSourceReplacementAttempt( + current: WorkspaceJoinSourceReplacementAttempt | null, + accountId: string, + payload: WorkspaceJoinSourceReplacementPayload, + options: OrganizationOperationAttemptOptions = {}, +): Promise { + return resolveOrganizationOperationAttempt( + current, + replacementScope(accountId, payload), + [workspaceJoinSourceReplacementFingerprint(payload)], + options, + ); +} + +export async function clearWorkspaceJoinSourceReplacementAttempt( + accountId: string, + payload: WorkspaceJoinSourceReplacementPayload, + storage?: Pick, +): Promise { + return clearOrganizationOperationAttempt( + replacementScope(accountId, payload), + storage, + ); +} + +export function workspaceJoinSourceReplacementFingerprint( + payload: WorkspaceJoinSourceReplacementPayload, +): string { + return JSON.stringify({ + workspaceId: payload.workspaceId.trim().toLowerCase(), + sourceId: payload.sourceId.trim().toLowerCase(), + sourceKind: payload.sourceKind, + expectedVersion: payload.expectedVersion, + lifetimeHours: payload.lifetimeHours, + }); +} + +function issuanceScope( + accountId: string, + workspaceId: string, + kind: WorkspaceJoinSourceKind, +): OrganizationOperationScope { + return { + accountId, + workspaceId, + action: `issue-${kind}`, + }; +} + +function replacementScope( + accountId: string, + payload: WorkspaceJoinSourceReplacementPayload, +): OrganizationOperationScope { + return { + accountId, + workspaceId: payload.workspaceId, + action: `replace-${payload.sourceKind}-${payload.sourceId}`, + }; +} + +export function workspaceJoinSourceIssuanceOutcome( + issuance: Pick, +): WorkspaceJoinSourceIssuanceOutcome { + const token = issuance.token; + if (issuance.alreadyIssued) { + return token === null + ? { kind: "reconciled" } + : { kind: "invalid" }; + } + + if (!token || token.trim() !== token) { + return { kind: "invalid" }; + } + + return { kind: "token", token }; +} + +export async function finalizeWorkspaceJoinSourceIssuance( + issuance: Pick, + clearAttempt: () => Promise, +): Promise { + const outcome = workspaceJoinSourceIssuanceOutcome(issuance); + if (outcome.kind !== "invalid") await clearAttempt(); + return outcome; +} + +function normalizeIds(ids: string[]): string[] { + return [...new Set(ids.map((id) => id.trim().toLowerCase()).filter(Boolean))] + .sort(); +} diff --git a/src/features/workspaces/workspaceOnboardingFlow.ts b/src/features/workspaces/workspaceOnboardingFlow.ts new file mode 100644 index 0000000..6a2293c --- /dev/null +++ b/src/features/workspaces/workspaceOnboardingFlow.ts @@ -0,0 +1,70 @@ +import { ApiError } from "../../api/client"; +import type { OrganizationMembershipSummary } from "../../api/types"; +import { completeCurrentStaffProfile, type StaffProfileDraft } from "./staffOnboarding"; +import { waitForWorkspaceAccess } from "./workspaceAccess"; +import type { WorkspaceCreationAttempt } from "./workspaceCreationAttempt"; + +type ApiRequest = (path: string, options?: RequestInit) => Promise; + +export type WorkspaceOnboardingContinuation = { + request: ApiRequest; + workspaceId: string; + staffProfile: StaffProfileDraft; + selectWorkspace: (workspaceId: string) => void; + refetchWorkspaces: () => Promise; + setSelectedWorkspaceId: (workspaceId: string) => void; + clearCreationAttempt: () => Promise; +}; + +export type WorkspaceOnboardingContinuationServices = { + waitForAccess?: typeof waitForWorkspaceAccess; + completeStaffProfile?: typeof completeCurrentStaffProfile; +}; + +/** + * The create operation id is also the organization id. A persisted attempt can + * therefore reconcile an acknowledged create after a same-tab page reload, + * without storing the workspace name or slug in browser storage. + */ +export async function recoverCreatedWorkspace( + request: ApiRequest, + attempt: WorkspaceCreationAttempt, +): Promise { + try { + const workspace = await request( + `/api/organizations/${encodeURIComponent(attempt.operationId)}`, + ); + if ( + workspace.organization.organizationId.toLowerCase() !== + attempt.operationId.toLowerCase() + ) { + throw new Error( + "The recovered workspace did not match the pending creation attempt.", + ); + } + return workspace; + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + throw error; + } +} + +/** + * The creation marker is deliberately cleared last. Every earlier step is + * safe to resume after a same-tab reload using the persisted operation id. + */ +export async function continueWorkspaceOnboarding( + continuation: WorkspaceOnboardingContinuation, + services: WorkspaceOnboardingContinuationServices = {}, +): Promise { + const waitForAccess = services.waitForAccess ?? waitForWorkspaceAccess; + const completeStaffProfile = + services.completeStaffProfile ?? completeCurrentStaffProfile; + + continuation.selectWorkspace(continuation.workspaceId); + await waitForAccess(continuation.request, continuation.workspaceId); + await continuation.refetchWorkspaces(); + continuation.setSelectedWorkspaceId(continuation.workspaceId); + await completeStaffProfile(continuation.request, continuation.staffProfile); + await continuation.clearCreationAttempt(); +} diff --git a/src/features/workspaces/workspaceUpdateAttempt.ts b/src/features/workspaces/workspaceUpdateAttempt.ts index fec5a6f..c19748c 100644 --- a/src/features/workspaces/workspaceUpdateAttempt.ts +++ b/src/features/workspaces/workspaceUpdateAttempt.ts @@ -1,3 +1,11 @@ +import { + clearOrganizationOperationAttempt, + resolveOrganizationOperationAttempt, + type OrganizationOperationAttempt, + type OrganizationOperationAttemptOptions, + type OrganizationOperationScope, +} from "./organizationOperationAttempt"; + export type WorkspaceUpdatePayload = { organizationId: string; expectedVersion: number; @@ -5,20 +13,31 @@ export type WorkspaceUpdatePayload = { slug: string; }; -export type WorkspaceUpdateAttempt = { - fingerprint: string; - operationId: string; -}; +export type WorkspaceUpdateAttempt = OrganizationOperationAttempt; -export function resolveWorkspaceUpdateAttempt( +export async function resolveWorkspaceUpdateAttempt( current: WorkspaceUpdateAttempt | null, + accountId: string, payload: WorkspaceUpdatePayload, - createOperationId: () => string = () => crypto.randomUUID(), -): WorkspaceUpdateAttempt { - const fingerprint = workspaceUpdateFingerprint(payload); - return current?.fingerprint === fingerprint - ? current - : { fingerprint, operationId: createOperationId() }; + options: OrganizationOperationAttemptOptions = {}, +): Promise { + return resolveOrganizationOperationAttempt( + current, + workspaceUpdateScope(accountId, payload.organizationId), + [workspaceUpdateFingerprint(payload)], + options, + ); +} + +export async function clearWorkspaceUpdateAttempt( + accountId: string, + workspaceId: string, + storage?: Pick, +): Promise { + return clearOrganizationOperationAttempt( + workspaceUpdateScope(accountId, workspaceId), + storage, + ); } export function workspaceUpdateFingerprint( @@ -31,3 +50,14 @@ export function workspaceUpdateFingerprint( slug: payload.slug.trim().toLowerCase(), }); } + +function workspaceUpdateScope( + accountId: string, + workspaceId: string, +): OrganizationOperationScope { + return { + accountId, + workspaceId, + action: "update-workspace", + }; +} diff --git a/src/features/workspaces/workspacesApi.ts b/src/features/workspaces/workspacesApi.ts new file mode 100644 index 0000000..f7c02ce --- /dev/null +++ b/src/features/workspaces/workspacesApi.ts @@ -0,0 +1,61 @@ +import type { + OrganizationListResponse, + OrganizationMembershipSummary, +} from "../../api/types"; + +type ApiRequest = (path: string, options?: RequestInit) => Promise; + +const PAGE_SIZE = 100; +const MAX_PAGES = 100; + +export async function loadAllWorkspaces( + request: ApiRequest, + signal?: AbortSignal, +): Promise { + const byId = new Map(); + + for (let page = 1; page <= MAX_PAGES; page += 1) { + throwIfAborted(signal); + const response = await request( + `/api/organizations?page=${page}&pageSize=${PAGE_SIZE}`, + { signal }, + ); + throwIfAborted(signal); + + const previousCount = byId.size; + for (const item of response.items) { + const id = item.organization.organizationId; + if (!byId.has(id)) byId.set(id, item); + } + + if (!response.hasMore) { + return [...byId.values()]; + } + + if (byId.size === previousCount) { + throw new Error( + `Workspace pagination made no progress on page ${page}.`, + ); + } + } + + throw new Error(`Workspace pagination exceeded ${MAX_PAGES} pages.`); +} + +export function resolveSelectedWorkspaceId( + workspaces: OrganizationMembershipSummary[], + selectedWorkspaceId: string, +): string { + return workspaces.some( + (item) => item.organization.organizationId === selectedWorkspaceId, + ) + ? selectedWorkspaceId + : workspaces[0]?.organization.organizationId ?? ""; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("The workspace request was aborted.", "AbortError"); +} diff --git a/tests/organization-operation-attempt.test.ts b/tests/organization-operation-attempt.test.ts new file mode 100644 index 0000000..142b98e --- /dev/null +++ b/tests/organization-operation-attempt.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + clearOrganizationOperationAttempt, + readOrganizationOperationAttempt, + resolveOrganizationOperationAttempt, + type OrganizationOperationScope, +} from "../src/features/workspaces/organizationOperationAttempt"; + +const scope: OrganizationOperationScope = { + accountId: "10000000-0000-4000-8000-000000000001", + workspaceId: "20000000-0000-4000-8000-000000000001", + action: "issue-invitation", +}; + +describe("organization operation attempt storage", () => { + it("stores only a version, opaque digests, and a random operation id", async () => { + const memory = storage(); + const attempt = await resolveOrganizationOperationAttempt( + null, + scope, + ["member@example.com", "front-desk", "property-secret-scope"], + { storage: memory.api, createOperationId: () => uuid(1) }, + ); + const persisted = [...memory.values.entries()].flat().join(" "); + + expect(attempt.intentDigest).toMatch(/^[a-f0-9]{64}$/); + expect(persisted).not.toContain("member@example.com"); + expect(persisted).not.toContain("front-desk"); + expect(persisted).not.toContain("property-secret-scope"); + expect(persisted).not.toContain(scope.accountId); + expect(persisted).not.toContain(scope.workspaceId); + }); + + it("removes corrupt and obsolete-version records instead of reusing them", async () => { + const memory = storage(); + const attempt = await resolveOrganizationOperationAttempt( + null, + scope, + ["intent"], + { storage: memory.api, createOperationId: () => uuid(1) }, + ); + const key = [...memory.values.keys()][0]!; + + memory.values.set(key, "not-json"); + expect(await readOrganizationOperationAttempt(scope, memory.api)).toBeNull(); + expect(memory.values.size).toBe(0); + + memory.values.set(key, JSON.stringify({ ...attempt, version: 2 })); + expect(await readOrganizationOperationAttempt(scope, memory.api)).toBeNull(); + expect(memory.values.size).toBe(0); + }); + + it("fails before a request can proceed when the attempt cannot persist", async () => { + await expect(resolveOrganizationOperationAttempt( + null, + scope, + ["intent"], + { + storage: { + getItem: () => null, + setItem: () => { + throw new DOMException("storage disabled", "SecurityError"); + }, + removeItem: () => undefined, + }, + createOperationId: () => uuid(1), + }, + )).rejects.toMatchObject({ name: "SecurityError" }); + }); + + it("clears only the exact account-workspace-action namespace", async () => { + const memory = storage(); + const otherScope = { ...scope, action: "issue-enrollment" }; + await resolveOrganizationOperationAttempt( + null, + scope, + ["intent"], + { storage: memory.api, createOperationId: () => uuid(1) }, + ); + const other = await resolveOrganizationOperationAttempt( + null, + otherScope, + ["intent"], + { storage: memory.api, createOperationId: () => uuid(2) }, + ); + + await clearOrganizationOperationAttempt(scope, memory.api); + expect(await readOrganizationOperationAttempt(scope, memory.api)).toBeNull(); + expect(await readOrganizationOperationAttempt(otherScope, memory.api)) + .toEqual(other); + }); +}); + +function storage() { + const values = new Map(); + return { + values, + api: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }; +} + +function uuid(index: number): string { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} diff --git a/tests/session-subject.test.ts b/tests/session-subject.test.ts new file mode 100644 index 0000000..1586f3e --- /dev/null +++ b/tests/session-subject.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { accessTokenSubjectId } from "../src/api/client"; + +describe("authenticated session subject", () => { + it("uses the immutable token subject rather than a display username", () => { + const subjectId = "10000000-0000-4000-8000-000000000001"; + const token = jwt({ sub: subjectId, label: "GitHub account" }); + + expect(accessTokenSubjectId(token)).toBe(subjectId); + }); + + it.each([ + "not-a-jwt", + jwt({}), + jwt({ sub: "GitHub account" }), + ])("fails closed for an unusable token subject", (token) => { + expect(() => accessTokenSubjectId(token)) + .toThrow("authenticated account identity is unavailable"); + }); +}); + +function jwt(payload: object): string { + return [ + encode({ alg: "none", typ: "JWT" }), + encode(payload), + "signature", + ].join("."); +} + +function encode(value: object): string { + return btoa(JSON.stringify(value)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} diff --git a/tests/workspace-creation-attempt.test.ts b/tests/workspace-creation-attempt.test.ts index dd5d501..f0440da 100644 --- a/tests/workspace-creation-attempt.test.ts +++ b/tests/workspace-creation-attempt.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + clearWorkspaceCreationAttempt, + readWorkspaceCreationAttempt, resolveWorkspaceCreationAttempt, type WorkspaceCreationPayload, } from "../src/features/workspaces/workspaceCreationAttempt"; @@ -8,42 +10,96 @@ const payload: WorkspaceCreationPayload = { name: "Harbor House", slug: "harbor-house", }; +const accountId = "10000000-0000-4000-8000-000000000001"; describe("workspace creation attempt", () => { - it("reuses one operation id for a normalized equivalent retry", () => { - const first = resolveWorkspaceCreationAttempt( + it("restores one operation id after a lost response and page reload", async () => { + const memory = storage(); + let allocations = 0; + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceCreationAttempt( null, + accountId, payload, - () => "operation-1", + options, ); - const retry = resolveWorkspaceCreationAttempt( - first, + const afterReload = await resolveWorkspaceCreationAttempt( + null, + accountId, { name: " Harbor House ", slug: " HARBOR-HOUSE " }, - () => "operation-2", + options, ); - expect(retry).toBe(first); - expect(retry.operationId).toBe("operation-1"); + expect(afterReload.operationId).toBe(first.operationId); + expect(allocations).toBe(1); }); - it("allocates a new operation id when normalized intent changes", () => { - const first = resolveWorkspaceCreationAttempt( + it("allocates a new operation id when normalized intent changes", async () => { + const memory = storage(); + let allocations = 0; + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceCreationAttempt( null, + accountId, payload, - () => "operation-1", + options, ); - const changedName = resolveWorkspaceCreationAttempt( + const changedName = await resolveWorkspaceCreationAttempt( first, + accountId, { ...payload, name: "Harbor Annex" }, - () => "operation-2", + options, ); - const changedSlug = resolveWorkspaceCreationAttempt( + const changedSlug = await resolveWorkspaceCreationAttempt( changedName, + accountId, { ...payload, name: "Harbor Annex", slug: "harbor-annex" }, - () => "operation-3", + options, ); - expect(changedName.operationId).toBe("operation-2"); - expect(changedSlug.operationId).toBe("operation-3"); + expect(changedName.operationId).toBe(uuid(2)); + expect(changedSlug.operationId).toBe(uuid(3)); + }); + + it("isolates accounts and clears only the requested account", async () => { + const memory = storage(); + const otherAccountId = "10000000-0000-4000-8000-000000000002"; + const first = await resolveWorkspaceCreationAttempt( + null, + accountId, + payload, + { storage: memory, createOperationId: () => uuid(1) }, + ); + const other = await resolveWorkspaceCreationAttempt( + null, + otherAccountId, + payload, + { storage: memory, createOperationId: () => uuid(2) }, + ); + + expect(other.operationId).not.toBe(first.operationId); + await clearWorkspaceCreationAttempt(accountId, memory); + expect(await readWorkspaceCreationAttempt(accountId, memory)).toBeNull(); + expect(await readWorkspaceCreationAttempt(otherAccountId, memory)) + .toEqual(other); }); }); + +function storage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; +} + +function uuid(index: number): string { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} diff --git a/tests/workspace-invites-workspace-switch.test.tsx b/tests/workspace-invites-workspace-switch.test.tsx new file mode 100644 index 0000000..63d61a8 --- /dev/null +++ b/tests/workspace-invites-workspace-switch.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OrganizationMembershipSummary } from "../src/api/types"; +import { WorkspaceSettingsPage } from "../src/features/workspaces/WorkspaceSettingsPage"; + +const testState = vi.hoisted(() => ({ + workspace: null as OrganizationMembershipSummary | null, + completions: new Map>(), + request: vi.fn(), + refetchWorkspaces: vi.fn(async () => undefined), +})); + +vi.mock("../src/app/session", () => ({ + useSession: () => ({ + request: testState.request, + session: { + subjectId: "30000000-0000-4000-8000-000000000001", + tenantId: testState.workspace?.organization.organizationId ?? "global", + username: "owner@example.com", + }, + }), +})); + +vi.mock("../src/app/workspace", () => ({ + useWorkspace: () => ({ + selectedWorkspace: testState.workspace, + properties: [], + refetchWorkspaces: testState.refetchWorkspaces, + }), +})); + +vi.mock("../src/app/permissions", () => ({ + permissions: { + accessProfilesRead: "access-control.profiles.read", + accessProfilesManage: "access-control.profiles.manage", + staffManage: "staff.manage", + retentionRead: "retention.read", + }, + tenantAccessScope: (tenantId: string) => `tenant:${tenantId}`, + usePermissions: () => ({ + isLoading: false, + error: null, + allows: () => true, + }), +})); + +vi.mock("../src/features/workspaces/WorkspaceInvitesSettings", async () => { + const { createElement, useEffect, useState } = await import("react"); + + return { + WorkspaceInvitesSettings: ({ workspaceId }: { workspaceId: string }) => { + const [token, setToken] = useState(null); + + useEffect(() => { + void testState.completions.get(workspaceId)?.then(setToken); + }, [workspaceId]); + + return createElement( + "output", + { "data-testid": "invite-completion" }, + `${workspaceId}:${token ?? "pending"}`, + ); + }, + }; +}); + +describe("workspace settings recovery boundaries", () => { + let container: HTMLDivElement; + let queryClient: QueryClient; + let root: Root; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + testState.completions.clear(); + testState.request.mockReset(); + testState.refetchWorkspaces.mockReset(); + testState.refetchWorkspaces.mockResolvedValue(undefined); + testState.workspace = workspace("a"); + window.sessionStorage.clear(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); + }); + + it("ignores a delayed workspace-A completion after switching to workspace B", async () => { + const completionA = deferred(); + const completionB = deferred(); + testState.completions.set(workspaceId("a"), completionA.promise); + testState.completions.set(workspaceId("b"), completionB.promise); + + await renderPage(); + await act(async () => { + inviteTab().dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(completionText()).toBe(`${workspaceId("a")}:pending`); + + testState.workspace = workspace("b"); + await renderPage(); + expect(completionText()).toBe(`${workspaceId("b")}:pending`); + + await act(async () => completionA.resolve("token-from-a")); + expect(completionText()).toBe(`${workspaceId("b")}:pending`); + expect(container.textContent).not.toContain("token-from-a"); + + await act(async () => completionB.resolve("token-from-b")); + expect(completionText()).toBe(`${workspaceId("b")}:token-from-b`); + }); + + it("replays the same update after PUT succeeds but the authoritative refetch fails", async () => { + testState.request.mockResolvedValue({ + ...testState.workspace!.organization, + version: 2, + }); + testState.refetchWorkspaces + .mockRejectedValueOnce(new Error("Workspace refresh failed.")) + .mockResolvedValueOnce(undefined); + + await renderPage(); + await clickSave(); + await act(async () => { + await vi.waitFor(() => { + expect(testState.request).toHaveBeenCalledTimes(1); + expect(testState.refetchWorkspaces).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Workspace refresh failed."); + }); + }); + expect(window.sessionStorage.length).toBe(1); + + await clickSave(); + await act(async () => { + await vi.waitFor(() => { + expect(testState.request).toHaveBeenCalledTimes(2); + expect(testState.refetchWorkspaces).toHaveBeenCalledTimes(2); + expect(container.textContent).not.toContain("Workspace refresh failed."); + }); + }); + + const requestBodies = testState.request.mock.calls.map((call) => + JSON.parse(String((call[1] as RequestInit).body)) as { + operationId: string; + expectedVersion: number; + }); + expect(requestBodies[1]?.operationId).toBe(requestBodies[0]?.operationId); + expect(requestBodies.map((body) => body.expectedVersion)).toEqual([1, 1]); + expect(window.sessionStorage.length).toBe(0); + }); + + async function renderPage() { + await act(async () => { + root.render( + + + , + ); + }); + } + + function inviteTab(): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")) + .find((candidate) => candidate.textContent?.includes("Invites")); + if (!button) throw new Error("Invites tab was not rendered."); + return button; + } + + function completionText(): string | null { + return container.querySelector('[data-testid="invite-completion"]')?.textContent ?? null; + } + + async function clickSave() { + const button = Array.from(container.querySelectorAll("button")) + .find((candidate) => candidate.textContent?.includes("Save changes")); + if (!button) throw new Error("Save changes button was not rendered."); + await act(async () => { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function workspace(suffix: "a" | "b"): OrganizationMembershipSummary { + const id = workspaceId(suffix); + return { + organization: { + organizationId: id, + scopeId: id, + name: `Workspace ${suffix.toUpperCase()}`, + slug: `workspace-${suffix}`, + status: "active", + activeOwnerCount: 1, + version: 1, + createdAtUtc: "2026-08-11T00:00:00Z", + lastChangedAtUtc: "2026-08-11T00:00:00Z", + }, + membership: { + membershipId: `20000000-0000-4000-8000-00000000000${suffix === "a" ? "1" : "2"}`, + organizationId: id, + subjectId: "30000000-0000-4000-8000-000000000001", + role: "owner", + status: "active", + version: 1, + joinedAtUtc: "2026-08-11T00:00:00Z", + lastChangedAtUtc: "2026-08-11T00:00:00Z", + }, + }; +} + +function workspaceId(suffix: "a" | "b"): string { + return `10000000-0000-4000-8000-00000000000${suffix === "a" ? "1" : "2"}`; +} diff --git a/tests/workspace-join-source-attempt.test.ts b/tests/workspace-join-source-attempt.test.ts new file mode 100644 index 0000000..b51c70f --- /dev/null +++ b/tests/workspace-join-source-attempt.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from "vitest"; +import { + clearWorkspaceJoinSourceIssuanceAttempt, + clearWorkspaceJoinSourceReplacementAttempt, + finalizeWorkspaceJoinSourceIssuance, + resolveWorkspaceJoinSourceIssuanceAttempt, + resolveWorkspaceJoinSourceReplacementAttempt, + workspaceJoinSourceIssuanceOutcome, + type WorkspaceJoinSourceIssuancePayload, + type WorkspaceJoinSourceReplacementPayload, +} from "../src/features/workspaces/workspaceJoinSourceAttempt"; + +const accountId = "10000000-0000-4000-8000-000000000001"; +const invitation: WorkspaceJoinSourceIssuancePayload = { + workspaceId: "20000000-0000-4000-8000-000000000001", + kind: "invitation", + recipientEmail: "member@example.com", + lifetimeHours: 72, + profileKey: "front-desk", + profileId: "30000000-0000-4000-8000-000000000001", + propertyIds: [ + "40000000-0000-4000-8000-000000000001", + "40000000-0000-4000-8000-000000000002", + ], + maximumClaims: null, + approvalMode: null, +}; + +const replacement: WorkspaceJoinSourceReplacementPayload = { + workspaceId: invitation.workspaceId, + sourceId: "50000000-0000-4000-8000-000000000001", + sourceKind: 1, + expectedVersion: 7, + lifetimeHours: 72, +}; + +describe("workspace join-source attempts", () => { + it("restores one source id after a lost response and reload", async () => { + const memory = storage(); + let allocations = 0; + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + invitation, + options, + ); + const afterReload = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + { + ...invitation, + recipientEmail: " MEMBER@EXAMPLE.COM ", + profileKey: " FRONT-DESK ", + propertyIds: [ + "40000000-0000-4000-8000-000000000002", + "40000000-0000-4000-8000-000000000001", + "40000000-0000-4000-8000-000000000001", + ], + }, + options, + ); + + expect(afterReload.operationId).toBe(first.operationId); + expect(allocations).toBe(1); + const persisted = memory.values(); + expect(persisted).not.toContain("member@example.com"); + expect(persisted).not.toContain("front-desk"); + expect(persisted).not.toContain(invitation.profileId); + }); + + it.each([ + ["workspace", { workspaceId: "20000000-0000-4000-8000-000000000002" }], + ["source kind", { kind: "enrollment" as const }], + ["recipient", { recipientEmail: "other@example.com" }], + ["lifetime", { lifetimeHours: 24 }], + ["profile key", { profileKey: "housekeeping" }], + ["exact profile", { profileId: "30000000-0000-4000-8000-000000000002" }], + ["property scope", { propertyIds: invitation.propertyIds.slice(0, 1) }], + ["claim capacity", { maximumClaims: 20 }], + ["approval mode", { approvalMode: 2 }], + ])("allocates a new source id when %s intent changes", async (_label, change) => { + const memory = storage(); + const first = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + invitation, + { storage: memory, createOperationId: () => uuid(1) }, + ); + const changed = await resolveWorkspaceJoinSourceIssuanceAttempt( + first, + accountId, + { ...invitation, ...change }, + { storage: memory, createOperationId: () => uuid(2) }, + ); + + expect(changed.operationId).toBe(uuid(2)); + }); + + it("isolates authenticated accounts and clears only after acknowledgement", async () => { + const memory = storage(); + const first = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + invitation, + { storage: memory, createOperationId: () => uuid(1) }, + ); + const other = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + "10000000-0000-4000-8000-000000000002", + invitation, + { storage: memory, createOperationId: () => uuid(2) }, + ); + expect(other.operationId).not.toBe(first.operationId); + + await clearWorkspaceJoinSourceIssuanceAttempt( + accountId, + invitation.workspaceId, + invitation.kind, + memory, + ); + const next = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + invitation, + { storage: memory, createOperationId: () => uuid(3) }, + ); + expect(next.operationId).toBe(uuid(3)); + }); + + it("restores one replacement id after a lost response and reload", async () => { + const memory = storage(); + let allocations = 0; + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + replacement, + options, + ); + const afterReload = await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + { ...replacement, sourceId: replacement.sourceId.toUpperCase() }, + options, + ); + + expect(afterReload.operationId).toBe(first.operationId); + expect(allocations).toBe(1); + }); + + it.each([ + ["workspace", { workspaceId: "20000000-0000-4000-8000-000000000002" }], + ["predecessor", { sourceId: "50000000-0000-4000-8000-000000000002" }], + ["source kind", { sourceKind: 2 }], + ["predecessor version", { expectedVersion: 8 }], + ["lifetime", { lifetimeHours: 24 }], + ])("allocates a new replacement id when %s intent changes", async (_label, change) => { + const memory = storage(); + const first = await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + replacement, + { storage: memory, createOperationId: () => uuid(1) }, + ); + const changed = await resolveWorkspaceJoinSourceReplacementAttempt( + first, + accountId, + { ...replacement, ...change }, + { storage: memory, createOperationId: () => uuid(2) }, + ); + + expect(changed.operationId).toBe(uuid(2)); + }); + + it("clears the exact replacement scope after a definitive response", async () => { + const memory = storage(); + await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + replacement, + { storage: memory, createOperationId: () => uuid(1) }, + ); + await clearWorkspaceJoinSourceReplacementAttempt( + accountId, + replacement, + memory, + ); + const next = await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + replacement, + { storage: memory, createOperationId: () => uuid(2) }, + ); + + expect(next.operationId).toBe(uuid(2)); + }); + + it("distinguishes a lost-response reconciliation from a fresh token", () => { + expect(workspaceJoinSourceIssuanceOutcome({ + alreadyIssued: false, + token: "one-time-token", + })).toEqual({ kind: "token", token: "one-time-token" }); + expect(workspaceJoinSourceIssuanceOutcome({ + alreadyIssued: true, + token: null, + })).toEqual({ kind: "reconciled" }); + }); + + it.each([ + ["a missing fresh token", { alreadyIssued: false, token: null }], + ["an empty fresh token", { alreadyIssued: false, token: "" }], + ["a blank fresh token", { alreadyIssued: false, token: " " }], + ["a padded fresh token", { alreadyIssued: false, token: " token " }], + ["a token on an already-issued receipt", { + alreadyIssued: true, + token: "contradictory-token", + }], + ["an empty token on an already-issued receipt", { + alreadyIssued: true, + token: "", + }], + ])("fails closed for %s", (_label, issuance) => { + expect(workspaceJoinSourceIssuanceOutcome(issuance)) + .toEqual({ kind: "invalid" }); + }); + + it("validates the receipt before clearing the durable attempt", async () => { + const events: string[] = []; + const issuance = { + get alreadyIssued() { + events.push("read-issued-state"); + return false; + }, + get token() { + events.push("read-token"); + return "one-time-token"; + }, + }; + + await expect(finalizeWorkspaceJoinSourceIssuance( + issuance, + async () => { + events.push("clear"); + }, + )).resolves.toEqual({ kind: "token", token: "one-time-token" }); + expect(events).toEqual(["read-token", "read-issued-state", "clear"]); + }); + + it("retains and reuses an issuance id after an invalid receipt", async () => { + const memory = storage(); + let allocations = 0; + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + invitation, + options, + ); + const invalid = await finalizeWorkspaceJoinSourceIssuance( + { alreadyIssued: true, token: "contradictory-token" }, + () => clearWorkspaceJoinSourceIssuanceAttempt( + accountId, + invitation.workspaceId, + invitation.kind, + memory, + ), + ); + + expect(invalid).toEqual({ kind: "invalid" }); + const afterReload = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + invitation, + options, + ); + expect(afterReload.operationId).toBe(first.operationId); + expect(allocations).toBe(1); + + await finalizeWorkspaceJoinSourceIssuance( + { alreadyIssued: true, token: null }, + () => clearWorkspaceJoinSourceIssuanceAttempt( + accountId, + invitation.workspaceId, + invitation.kind, + memory, + ), + ); + const next = await resolveWorkspaceJoinSourceIssuanceAttempt( + null, + accountId, + invitation, + options, + ); + expect(next.operationId).toBe(uuid(2)); + }); + + it("retains and reuses a replacement id after an invalid receipt", async () => { + const memory = storage(); + let allocations = 0; + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + replacement, + options, + ); + await expect(finalizeWorkspaceJoinSourceIssuance( + { alreadyIssued: false, token: " " }, + () => clearWorkspaceJoinSourceReplacementAttempt( + accountId, + replacement, + memory, + ), + )).resolves.toEqual({ kind: "invalid" }); + + const afterReload = await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + replacement, + options, + ); + expect(afterReload.operationId).toBe(first.operationId); + expect(allocations).toBe(1); + + await finalizeWorkspaceJoinSourceIssuance( + { alreadyIssued: false, token: "replacement-token" }, + () => clearWorkspaceJoinSourceReplacementAttempt( + accountId, + replacement, + memory, + ), + ); + const next = await resolveWorkspaceJoinSourceReplacementAttempt( + null, + accountId, + replacement, + options, + ); + expect(next.operationId).toBe(uuid(2)); + }); +}); + +function storage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + values: () => [...values.entries()].flat().join(" "), + }; +} + +function uuid(index: number): string { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} diff --git a/tests/workspace-onboarding-flow.test.ts b/tests/workspace-onboarding-flow.test.ts new file mode 100644 index 0000000..3f6d60a --- /dev/null +++ b/tests/workspace-onboarding-flow.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "../src/api/client"; +import type { OrganizationMembershipSummary } from "../src/api/types"; +import { + continueWorkspaceOnboarding, + recoverCreatedWorkspace, +} from "../src/features/workspaces/workspaceOnboardingFlow"; +import { + clearWorkspaceCreationAttempt, + readWorkspaceCreationAttempt, + resolveWorkspaceCreationAttempt, +} from "../src/features/workspaces/workspaceCreationAttempt"; + +const accountId = "10000000-0000-4000-8000-000000000001"; +const operationId = "20000000-0000-4000-8000-000000000001"; +const payload = { name: "Harbor House", slug: "harbor-house" }; +const staffProfile = { + displayName: "Ada Lovelace", + legalName: "", + workEmail: "ada@example.com", + workPhone: "", + jobTitle: "Owner", + department: "Operations", +}; + +describe("workspace onboarding recovery", () => { + it("keeps a pre-send marker when reload reconciliation returns 404", async () => { + const memory = storage(); + const attempt = await resolveWorkspaceCreationAttempt( + null, + accountId, + payload, + { storage: memory, createOperationId: () => operationId }, + ); + const request = async (path: string): Promise => { + expect(path).toBe(`/api/organizations/${operationId}`); + throw new ApiError("Not found", 404); + }; + + await expect(recoverCreatedWorkspace(request, attempt)).resolves.toBeNull(); + await expect(readWorkspaceCreationAttempt(accountId, memory)) + .resolves.toEqual(attempt); + + const afterReload = await resolveWorkspaceCreationAttempt( + null, + accountId, + payload, + { storage: memory, createOperationId: () => crypto.randomUUID() }, + ); + expect(afterReload.operationId).toBe(operationId); + }); + + it("restores the created organization from its durable operation id", async () => { + const memory = storage(); + const attempt = await resolveWorkspaceCreationAttempt( + null, + accountId, + payload, + { storage: memory, createOperationId: () => operationId }, + ); + const restored = workspace(operationId); + const request = async (path: string): Promise => { + expect(path).toBe(`/api/organizations/${operationId}`); + return restored as T; + }; + + await expect(recoverCreatedWorkspace(request, attempt)).resolves.toEqual(restored); + await expect(readWorkspaceCreationAttempt(accountId, memory)) + .resolves.toEqual(attempt); + }); + + it("fails closed when recovery returns a different organization", async () => { + const attempt = await resolveWorkspaceCreationAttempt( + null, + accountId, + payload, + { storage: storage(), createOperationId: () => operationId }, + ); + const request = async (): Promise => + workspace("20000000-0000-4000-8000-000000000099") as T; + + await expect(recoverCreatedWorkspace(request, attempt)).rejects.toThrow( + "did not match the pending creation attempt", + ); + }); + + it.each(["access", "refetch", "staff"] as const)( + "retains the same marker across a reload at the %s stage and clears it only after completion", + async (failureStage) => { + const memory = storage(); + const attempt = await resolveWorkspaceCreationAttempt( + null, + accountId, + payload, + { storage: memory, createOperationId: () => operationId }, + ); + let failOnce = true; + const events: string[] = []; + const continuation = { + request: async (): Promise => ({} as T), + workspaceId: operationId, + staffProfile, + selectWorkspace: (workspaceId: string) => { + events.push(`select:${workspaceId}`); + }, + refetchWorkspaces: async () => { + events.push("refetch"); + if (failOnce && failureStage === "refetch") { + throw new Error("refetch interrupted"); + } + }, + setSelectedWorkspaceId: (workspaceId: string) => { + events.push(`selected:${workspaceId}`); + }, + clearCreationAttempt: async () => { + events.push("clear"); + await clearWorkspaceCreationAttempt(accountId, memory); + }, + }; + const services = { + waitForAccess: async () => { + events.push("access"); + if (failOnce && failureStage === "access") { + throw new Error("access interrupted"); + } + }, + completeStaffProfile: async () => { + events.push("staff"); + if (failOnce && failureStage === "staff") { + throw new Error("staff interrupted"); + } + return {} as never; + }, + }; + + await expect(continueWorkspaceOnboarding(continuation, services)) + .rejects.toThrow(`${failureStage} interrupted`); + expect(events).not.toContain("clear"); + await expect(readWorkspaceCreationAttempt(accountId, memory)) + .resolves.toEqual(attempt); + + // A same-tab reload loses React refs but restores the persisted identity. + const afterReload = await readWorkspaceCreationAttempt(accountId, memory); + expect(afterReload?.operationId).toBe(operationId); + failOnce = false; + events.length = 0; + await continueWorkspaceOnboarding(continuation, services); + + expect(events).toEqual([ + `select:${operationId}`, + "access", + "refetch", + `selected:${operationId}`, + "staff", + "clear", + ]); + await expect(readWorkspaceCreationAttempt(accountId, memory)) + .resolves.toBeNull(); + }, + ); +}); + +function workspace(organizationId: string): OrganizationMembershipSummary { + return { + organization: { + organizationId, + scopeId: organizationId, + name: "Harbor House", + slug: "harbor-house", + status: "active", + activeOwnerCount: 1, + version: 1, + createdAtUtc: "2026-08-11T00:00:00Z", + lastChangedAtUtc: "2026-08-11T00:00:00Z", + }, + membership: { + membershipId: "30000000-0000-4000-8000-000000000001", + organizationId, + subjectId: accountId, + role: "owner", + status: "active", + version: 1, + joinedAtUtc: "2026-08-11T00:00:00Z", + lastChangedAtUtc: "2026-08-11T00:00:00Z", + }, + }; +} + +function storage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; +} diff --git a/tests/workspace-update-attempt.test.ts b/tests/workspace-update-attempt.test.ts index 573831a..49064d4 100644 --- a/tests/workspace-update-attempt.test.ts +++ b/tests/workspace-update-attempt.test.ts @@ -1,52 +1,117 @@ import { describe, expect, it } from "vitest"; import { + clearWorkspaceUpdateAttempt, resolveWorkspaceUpdateAttempt, type WorkspaceUpdatePayload, } from "../src/features/workspaces/workspaceUpdateAttempt"; const payload: WorkspaceUpdatePayload = { - organizationId: "10000000-0000-0000-0000-000000000001", + organizationId: "10000000-0000-4000-8000-000000000001", expectedVersion: 7, name: "Harbor House", slug: "harbor-house", }; +const accountId = "20000000-0000-4000-8000-000000000001"; describe("workspace update attempts", () => { - it("keeps one operation id for a normalized retry", () => { + it("restores one operation id after a lost response and settings reload", async () => { + const memory = storage(); let allocations = 0; - const allocate = () => `operation-${++allocations}`; - const first = resolveWorkspaceUpdateAttempt(null, payload, allocate); - const retry = resolveWorkspaceUpdateAttempt( - first, + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceUpdateAttempt( + null, + accountId, + payload, + options, + ); + const afterReload = await resolveWorkspaceUpdateAttempt( + null, + accountId, { ...payload, name: " Harbor House ", slug: " HARBOR-HOUSE ", }, - allocate, + options, ); - expect(retry).toBe(first); - expect(retry.operationId).toBe("operation-1"); + expect(afterReload.operationId).toBe(first.operationId); expect(allocations).toBe(1); }); - it("allocates a new operation for changed intent or version", () => { + it("allocates a new operation for changed intent or version", async () => { + const memory = storage(); let allocations = 0; - const allocate = () => `operation-${++allocations}`; - const first = resolveWorkspaceUpdateAttempt(null, payload, allocate); - const changed = resolveWorkspaceUpdateAttempt( + const options = { + storage: memory, + createOperationId: () => uuid(++allocations), + }; + const first = await resolveWorkspaceUpdateAttempt( + null, + accountId, + payload, + options, + ); + const changed = await resolveWorkspaceUpdateAttempt( first, + accountId, { ...payload, name: "Harbor Annex" }, - allocate, + options, ); - const newerVersion = resolveWorkspaceUpdateAttempt( + const newerVersion = await resolveWorkspaceUpdateAttempt( changed, + accountId, { ...payload, name: "Harbor Annex", expectedVersion: 8 }, - allocate, + options, + ); + + expect(changed.operationId).toBe(uuid(2)); + expect(newerVersion.operationId).toBe(uuid(3)); + }); + + it("isolates workspace scope and clears only a definitive success", async () => { + const memory = storage(); + const first = await resolveWorkspaceUpdateAttempt( + null, + accountId, + payload, + { storage: memory, createOperationId: () => uuid(1) }, + ); + const otherWorkspace = await resolveWorkspaceUpdateAttempt( + null, + accountId, + { ...payload, organizationId: "10000000-0000-4000-8000-000000000002" }, + { storage: memory, createOperationId: () => uuid(2) }, ); - expect(changed.operationId).toBe("operation-2"); - expect(newerVersion.operationId).toBe("operation-3"); + expect(otherWorkspace.operationId).not.toBe(first.operationId); + await clearWorkspaceUpdateAttempt( + accountId, + payload.organizationId, + memory, + ); + const restoredOther = await resolveWorkspaceUpdateAttempt( + null, + accountId, + { ...payload, organizationId: "10000000-0000-4000-8000-000000000002" }, + { storage: memory, createOperationId: () => uuid(3) }, + ); + expect(restoredOther.operationId).toBe(otherWorkspace.operationId); }); }); + +function storage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; +} + +function uuid(index: number): string { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} diff --git a/tests/workspaces-api.test.ts b/tests/workspaces-api.test.ts new file mode 100644 index 0000000..59d7645 --- /dev/null +++ b/tests/workspaces-api.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { + OrganizationListResponse, + OrganizationMembershipSummary, +} from "../src/api/types"; +import { + loadAllWorkspaces, + resolveSelectedWorkspaceId, +} from "../src/features/workspaces/workspacesApi"; + +describe("workspace API pagination", () => { + it("stops after one full page when there is no continuation", async () => { + const paths: string[] = []; + const result = await loadAllWorkspaces(async (path: string): Promise => { + paths.push(path); + return page(range(1, 100), false) as T; + }); + + expect(result).toHaveLength(100); + expect(paths).toEqual(["/api/organizations?page=1&pageSize=100"]); + }); + + it("loads workspace 101 and preserves it as the selected tenant", async () => { + const responses = [ + page(range(1, 100), true), + page([101], false), + ]; + const result = await loadAllWorkspaces(async (): Promise => + responses.shift() as T); + + expect(result).toHaveLength(101); + expect(result.at(-1)?.organization.organizationId) + .toBe(workspaceId(101)); + expect(resolveSelectedWorkspaceId(result, workspaceId(101))) + .toBe(workspaceId(101)); + }); + + it("passes one abort signal through and stops before another page", async () => { + const controller = new AbortController(); + const signals: Array = []; + let calls = 0; + + await expect(loadAllWorkspaces(async ( + _path: string, + options?: RequestInit, + ): Promise => { + calls += 1; + signals.push(options?.signal); + controller.abort(new DOMException("cancelled", "AbortError")); + return page([1], true) as T; + }, controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + + expect(calls).toBe(1); + expect(signals).toEqual([controller.signal]); + }); + + it("deduplicates page-boundary overlap in first-seen order", async () => { + const responses = [ + page([1, 2], true), + page([2, 3], false), + ]; + const result = await loadAllWorkspaces(async (): Promise => + responses.shift() as T); + + expect(result.map((item) => item.organization.organizationId)) + .toEqual([workspaceId(1), workspaceId(2), workspaceId(3)]); + }); + + it("fails instead of looping when a continued page adds no workspace", async () => { + const responses = [ + page([1, 2], true), + page([1, 2], true), + ]; + + await expect(loadAllWorkspaces(async (): Promise => + responses.shift() as T)) + .rejects.toThrow("made no progress on page 2"); + }); +}); + +function page(ids: number[], hasMore: boolean): OrganizationListResponse { + return { + items: ids.map(summary), + page: 1, + pageSize: 100, + hasMore, + }; +} + +function range(start: number, end: number): number[] { + return Array.from({ length: end - start + 1 }, (_, index) => start + index); +} + +function summary(id: number): OrganizationMembershipSummary { + const organizationId = workspaceId(id); + return { + organization: { + organizationId, + scopeId: organizationId, + name: `Workspace ${id}`, + slug: `workspace-${id}`, + status: "active", + activeOwnerCount: 1, + version: 1, + createdAtUtc: "2026-08-11T00:00:00Z", + lastChangedAtUtc: "2026-08-11T00:00:00Z", + }, + membership: { + membershipId: `20000000-0000-4000-8000-${String(id).padStart(12, "0")}`, + organizationId, + subjectId: "member-1", + role: "owner", + status: "active", + version: 1, + joinedAtUtc: "2026-08-11T00:00:00Z", + lastChangedAtUtc: "2026-08-11T00:00:00Z", + }, + }; +} + +function workspaceId(id: number): string { + return `10000000-0000-4000-8000-${String(id).padStart(12, "0")}`; +}