diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md index 950b342ba..b66b171d1 100644 --- a/docs/API_DOCS.md +++ b/docs/API_DOCS.md @@ -1884,6 +1884,11 @@ Mints a token for the signed-in user and role; it reads no body field and spends The write-through storage sync layer (see [`docs/STORAGE.md`](STORAGE.md)). Data is per-user, keyed by the session username. +In server storage mode a browser tab that has matched its copy of the workspace to the signed-in account sends that account's username, URI-encoded, in the `X-LibreDB-Workspace-Owner` header on every request it makes through `appFetch`, not only on the storage routes. +When the header is present and does not name the session's username, the middleware (`src/proxy.ts`) answers `409 { "error": "...", "code": "WORKSPACE_OWNER_MISMATCH" }` before any route runs, and the browser reloads the tab so the owner check runs again for the account signed in now. +A request without the header is not checked: MCP clients, the agent drive callback, external clients and local mode behave as before. +The public routes (`/api/auth/*`, `/api/storage/config`, `/api/db/health` and the others listed above) are not checked either, so a tab can still ask which account is signed in. + #### GET /api/storage/config Public. Returns the active storage configuration so the client can discover whether server-side storage is enabled. @@ -2267,6 +2272,7 @@ These are the values of the `code` field emitted by `createErrorResponse` (`src/ | `AUTH_ERROR` | Authentication failed (401) | | `CUSTOM_CONNECTIONS_DISABLED` | `ALLOW_CUSTOM_CONNECTIONS` is off and the request supplied a connection that is not a seed (403); see `GET /api/connections/policy`. A seed the caller's role may not open is refused with `AUTH_ERROR` (403) instead | | `AUTH_REQUIRED` | No Studio session, or one that no longer verifies (401). Answered by the middleware and the route-level session checks rather than `createErrorResponse`; the only 401 the browser answers by sending the user to sign in | +| `WORKSPACE_OWNER_MISMATCH` | The request's `X-LibreDB-Workspace-Owner` header names an account other than the signed-in one (409). Answered by the middleware rather than `createErrorResponse`; the browser reloads the tab for it (see [Storage API](#storage-api)) | | `TIMEOUT_ERROR` | Query exceeded time limit (408); `POST /api/ai/query-safety` answers it with 504 when the model does not answer in time | | `CONNECTION_ERROR` | Database connection failed (503) | | `POOL_EXHAUSTED` | Connection pool exhausted (503) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1246272c..905e12b5c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -256,6 +256,7 @@ Multi-statement queries execute sequentially via `POST /api/db/multi-query`, one - `postgres`: Server-side PostgreSQL via `pg` - **`useStorageSync` hook** in Studio.tsx: discovers mode at runtime via `/api/storage/config`, pulls on mount, pushes mutations (debounced 500ms) - **Migration**: First login auto-migrates localStorage to server; `libredb_server_migrated` flag prevents re-migration +- **Browser copy owner** (server mode): `libredb_workspace_owner` binds the browser copy to the signed-in account; a different account starts from its own server data and sign-out clears the copy (see [STORAGE.md](STORAGE.md#the-browser-copy-belongs-to-the-signed-in-account)) - **Graceful degradation**: If server unreachable, localStorage continues working ### 4.5. Client State Management diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 510260d77..5cd367d55 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -582,6 +582,10 @@ These are real, current, and not oversights. Each is a decision with a reason. - **Browser `localStorage` holds your credentials in plaintext.** It is the rendering source, and encrypting it would require a master password and a recovery flow, changing what the product is. This is why 0.1 and 1.1 matter as much as they do. + In server storage mode the browser copy belongs to the signed-in account: it is cleared on sign-out, and a different account signing in starts from its own server data. + Every request a tab sends after matching its copy names that account, and outside the public routes (`/api/auth/*`, `/api/db/health` and the rest of the public set in `src/proxy.ts`) the server refuses one that names an account other than the signed-in one, so a tab still open for one account never writes its workspace into another account's server storage. + A sign-out that could not complete, or a session that ended without one (expiry, a disabled account), leaves the copy in that browser until the next sign-in's owner check, which clears it for a different account. + In local mode it stays, and the next account to sign in on that browser profile sees and can open the connections saved there. - **Anyone who can read the server's environment can read the stored credentials.** 3.1 protects a stolen database file or dump on its own; it is not a vault. For `STORAGE_PROVIDER=sqlite` with no `STORAGE_ENCRYPTION_KEY` configured, that protection does not extend to a backup or volume diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 14ab92d4e..c7e62029a 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -57,7 +57,9 @@ LibreDB Studio supports three storage modes. Pick the one that fits your use cas Server storage (`sqlite` or `postgres`) gives each person their own private workspace by partitioning connections, saved queries, and settings by the login username. For a team of three or more, pair server storage with Single Sign-On via [OIDC](OIDC.md) to provide individual logins, personal workspaces, and an audit trail that attributes actions to each user. +In server storage the browser copy belongs to the signed-in account: signing out clears this browser's copy, open editor tabs included, and signing in as a different account starts from that account's server data. The default `local` mode stores data in the browser and does not isolate workspaces between people using the same browser profile. +In `local` mode the next account to sign in on that browser profile sees, and can open, the connections saved there before it. --- @@ -449,6 +451,7 @@ When you switch from local mode to SQLite or PostgreSQL, **existing browser data **No manual steps required.** Just change the env var and restart. > If multiple users were sharing a browser in local mode, only the data from the user who migrates first will be sent. Each user's server storage is isolated by their login email. +> From then on the browser copy belongs to that account (`libredb_workspace_owner`), and a different account signing in on the same browser starts from its own server data. For the full migration lifecycle and the underlying merge semantics, see [Migration Flow](#10-migration-flow) in Part 2. @@ -1203,6 +1206,19 @@ App Mount │ └─ serverMode: true ──┐ │ │ │ ┌──────────────────────▼──────────────────────┐ + │ │ Owner: GET /api/auth/me → username │ + │ │ ├─ Unreadable → syncError, isReady stays │ + │ │ │ false, nothing is pulled or pushed │ + │ │ ├─ libredb_workspace_owner = username │ + │ │ │ → keep the browser copy │ + │ │ ├─ No owner, never migrated → keep it, │ + │ │ │ owner = username (local-mode data, │ + │ │ │ migrated below into this account only) │ + │ │ └─ Otherwise → clear the browser copy, │ + │ │ mark it migrated, owner = username │ + │ └──────────────────────┬──────────────────────┘ + │ │ + │ ┌──────────────────────▼──────────────────────┐ │ │ Check libredb_server_migrated flag │ │ │ ├─ Not migrated → POST /api/storage/migrate│ │ │ │ (send all localStorage → server merge) │ @@ -1212,7 +1228,8 @@ App Mount │ │ │ ┌──────────────────────▼──────────────────────┐ │ │ Pull: GET /api/storage │ - │ │ → Write server data into localStorage │ + │ │ → Write server data into localStorage, │ + │ │ only while the owner is still username │ │ │ → Components re-render from localStorage │ │ └──────────────────────┬──────────────────────┘ │ │ @@ -1237,9 +1254,47 @@ When any `storage.*` mutation fires: - Reads each pending collection from `localStorage` - Sends `PUT /api/storage/[collection]` for each +### The Browser Copy Belongs to the Signed-In Account + +In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and it is compared with `GET /api/auth/me` before any page reads the copy. +Every page that reads it (the editor, the admin dashboard and the monitoring page) renders inside `WorkspaceOwnerGate`, which runs that check first; the lifecycle above runs it again when it starts, before anything is migrated or pulled, and from then on every request names the owner (see below). +The settings pages (`/settings/mcp`, `/settings/authenticator`) do not read the copy but render inside the gate too, because a sign-in can land on them, and the gate records the owner there. +While the check runs, those pages show a loading indicator; when the signed-in account cannot be read, they show a message instead of the copy. +They show it too when `GET /api/storage/config` cannot be read and the copy carries an owner or the migration flag, both written only in server mode; a copy with neither renders as it is, so local mode does not change. +The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the names of the collections a sign-out could not push, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it. +`clearAccountWorkspace()` in `src/lib/storage/local-storage.ts` holds that list. + +- **Same account:** the copy is kept, the collections a sign-out could not push are pushed, and the pull overwrites each collection the server holds, except one still waiting to be pushed; a collection the server does not hold yet stays local. +- **A different account, or a copy with no owner that was already migrated:** the copy is cleared first and recorded as this account's, so the account starts from its own server data. +- **A copy with no owner that was never migrated:** local-mode data, recorded as the signed-in account's and migrated into that account as described in [Migration Flow](#10-migration-flow). + A different account that signs in before the migration ran finds it owned by the first account, so the copy is cleared for it. +- **Sign-out** (the editor, the admin dashboard and the launch page): the signing-out tab's pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared. + Only the signing-out tab's pending collections are pushed first: a change waiting in another open tab reaches the server only if that tab pushes it before the session ends, and is refused after. + The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated. + A pending push that does not land does not stop the sign-out: when it failed because the session had already ended, the copy is cleared and the tab goes to sign in; otherwise (server storage unreachable) the session still ends, the copy stays for this account with the names of the collections that were not pushed, and the sign-out says so. + The next sign-in of the same account on this browser pushes those collections before it pulls, and a pull does not replace a collection still waiting to be pushed; the next sign-in of a different account clears the copy, those changes with it. + A change made in the signing-out tab while the sign-out request is out is not pushed; it goes with the cleared copy. + When the server refuses the sign-out, or its answer is lost, the tab asks `GET /api/auth/me`: with no session left the sign-out counts as done and the copy is cleared, and while the session goes on the copy stays, the sync goes on, and the sign-out says it did not complete. + When the storage mode cannot be read, a copy that carries the owner key or the migration flag fails the sign-out and stays; a copy with neither is local-mode data, and the session just ends. + A sign-out from a tab whose session another tab has meanwhile replaced with a different account's gets `409 WORKSPACE_OWNER_MISMATCH` on its push and reloads; a logout request it sends before the reload lands ends the session the browser holds, which is that other account's. +- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, or a different account signing in and landing on the editor, an admin page, the monitoring page or a settings page), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now. + A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for. + The editor's sync asks which account is signed in once more when it starts, and reloads the same way when that is no longer the account the page matched the copy for. +- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before the route runs, on every route that needs a session; the public routes (`src/proxy.ts`) are not checked ([API_DOCS.md](API_DOCS.md#storage-api)). +- **The embedded workspace:** none of this applies to `StudioWorkspace` from the npm package, which mounts no sync, gate or owner check: its host supplies the connections, and the editor tabs it keeps in the host page's storage are the host's to scope to its own accounts. + The page then reloads, and the owner check runs again for the account signed in now, so a push, a pull or a migration a page sends after another tab signed in as a different account never reaches that account's server data, also in a tab that stays visible. + In the browser the same rule holds: a page writes the copy (the storage facade, its editor tabs, its object-source drafts, and each collection of a pull) only while the owner key still names the account it claimed, so a page that has not reloaded yet writes nothing into the copy another tab claimed. +- **A session that ended on its own** (expiry, a disabled account) clears nothing: the copy stays until the next sign-in's owner check, which covers a different account on the first of these pages it opens. + A page inside `WorkspaceOwnerGate` that finds no session when it opens, or when its tab is shown again, shows neither the copy nor a message and goes to sign in. + It does not cover a copy written while `GET /api/storage/config` could not be read on a browser profile that had no owner and no migration flag yet: such a copy is treated as local-mode data, so the next account to sign in records it as its own and migrates it. +- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too, and that sign-in starts from the account's server data. + Open editor tabs from before the upgrade are not kept, since they are never stored on the server, and neither are object-source drafts or any change that had not reached the server. + +In local mode none of this runs: the browser copy is the only copy, it stays on sign-out, and no owner is recorded. + ### Graceful Degradation -- If `/api/storage/config` fails → stays in localStorage-only mode +- If `/api/storage/config` fails → stays in localStorage-only mode, unless the browser copy carries `libredb_workspace_owner` or `libredb_server_migrated`: then the pages that read the copy show a message instead of it, because the copy may belong to a different account - If push fails → logs warning, sets `syncError`, does **not** block the UI - Components always read from `localStorage` — no loading states for storage @@ -1251,16 +1306,19 @@ When a user first enables server mode (or a new user logs in for the first time) ``` 1. Hook detects serverMode = true -2. Checks localStorage('libredb_server_migrated') flag -3. If not migrated: +2. Reads the signed-in username (GET /api/auth/me), clears a browser copy that belongs to another account, and sets 'libredb_workspace_owner' to the username +3. Checks localStorage('libredb_server_migrated') flag +4. If not migrated: a. Reads whichever of the 13 collections exist in localStorage (a fresh browser with none simply sets the flag and skips) b. POST /api/storage/migrate with the collected payload c. Server calls provider.mergeData() — upserts each collection as a whole blob in one transaction d. Sets 'libredb_server_migrated' flag in localStorage -4. Pull: GET /api/storage → overwrite localStorage with server data -5. Subsequent mutations sync normally via push +5. Pull: GET /api/storage → overwrite localStorage with server data, unless a sign-out cleared the copy while the pull was out +6. Subsequent mutations sync normally via push ``` +Step 2 is described in [The Browser Copy Belongs to the Signed-In Account](#the-browser-copy-belongs-to-the-signed-in-account). + This ensures existing localStorage data is preserved when transitioning to server mode. > For the operator-facing summary of this behavior, see [Migration: Local to Server](#migration-local-to-server) in Part 1. diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 57d8413a7..293cb1e01 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,13 +1,18 @@ import { Suspense } from "react"; import AdminDashboard from "@/components/admin/AdminDashboard"; +import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate"; export default function AdminLayout({ children }: { children: React.ReactNode }) { return ( // The boundary is no longer required by the shell itself (AdminDashboard moved // from useSearchParams to usePathname, which needs no Suspense). It stays as the // streaming boundary for the section pages rendered into {children}. + // The gate: every admin section reads this browser's copy of the workspace, which must be the + // signed-in account's first. - {children} + + {children} + ); } diff --git a/src/app/launch/launch-client.tsx b/src/app/launch/launch-client.tsx index 45d844937..a5b202b7e 100644 --- a/src/app/launch/launch-client.tsx +++ b/src/app/launch/launch-client.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { appFetch, withBasePath } from "@/lib/config/base-path"; +import { releaseAccountWorkspace } from "@/lib/storage/sign-out"; const NO_TOKEN = "This address carries no launch token. Open Studio again from the platform that sent you, or sign in with your password."; @@ -111,8 +112,12 @@ function SessionConflict({ signedInAs, launchFor }: Conflict) { const [state, setState] = useState<"asking" | "signed-out" | "failed">("asking"); async function signOut() { - const response = await appFetch("/api/auth/logout", { method: "POST" }).catch(() => null); - setState(response?.ok ? "signed-out" : "failed"); + // In server storage mode the browser copy belongs to the signed-in account and is cleared once + // the session ended. + const result = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" })).catch( + () => null, + ); + setState(result?.signedOut ? "signed-out" : "failed"); } return ( diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index aaec87ac1..35a47bb7d 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -1,8 +1,13 @@ "use client"; +import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate"; import { MonitoringDashboard } from "@/components/monitoring/MonitoringDashboard"; export default function MonitoringPage() { // Middleware handles authentication, no need for client-side check - return ; + return ( + + + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index bb4ebb746..6a3f0f2e3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,10 @@ +import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate"; import Studio from "@/components/Studio"; export default function Page() { - return ; + return ( + + + + ); } diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx new file mode 100644 index 000000000..718e76b47 --- /dev/null +++ b/src/app/settings/layout.tsx @@ -0,0 +1,7 @@ +import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate"; + +export default function SettingsLayout({ children }: Readonly<{ children: React.ReactNode }>) { + // The gate: a sign-in can land on a settings page, so the browser copy is recorded as the + // signed-in account's here too, and every other tab of the browser profile follows that account. + return {children}; +} diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx index 896a6154a..72736b962 100644 --- a/src/components/admin/AdminDashboard.tsx +++ b/src/components/admin/AdminDashboard.tsx @@ -1,6 +1,7 @@ "use client"; import { appFetch } from "@/lib/config/base-path"; +import { releaseAccountWorkspace } from "@/lib/storage/sign-out"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import type { ReactNode } from "react"; @@ -29,8 +30,19 @@ export default function AdminDashboard({ children }: AdminDashboardProps) { const activeSection = adminSectionFromPathname(pathname); const handleLogout = async () => { - const res = await appFetch("/api/auth/logout", { method: "POST" }); - const data = await res.json(); + // In server storage mode the browser copy belongs to the signed-in account and is cleared once + // the session ended. + let data: { redirectUrl?: string }; + try { + const { signedOut, response } = await releaseAccountWorkspace(() => + appFetch("/api/auth/logout", { method: "POST" }), + ); + if (!signedOut) throw new Error("Sign-out did not complete"); + data = response ? await response.json() : {}; + } catch { + toast.error("Failed to logout"); + return; + } toast.success("Logged out successfully"); // In OIDC mode the route answers with the provider's end_session URL. The local cookie is gone diff --git a/src/components/agent/use-agent-run.ts b/src/components/agent/use-agent-run.ts index 647b5d1e3..5007f7cb2 100644 --- a/src/components/agent/use-agent-run.ts +++ b/src/components/agent/use-agent-run.ts @@ -3,6 +3,7 @@ import { appFetch } from "@/lib/config/base-path"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { isAgentModelCapability } from "@/lib/agent/capability-labels"; +import { AGENT_THREAD_KEY, holdsAccountWorkspace } from "@/lib/storage/local-storage"; // Type-only, so nothing of the probe — or of the AI SDK it runs — reaches this bundle. import type { AgentModelCapability } from "@/lib/agent/capability-probe"; import type { AgentLedgerEntry } from "@/lib/agent/run-store"; @@ -202,14 +203,15 @@ const isStartRefusalCode = (value: unknown): value is AgentStartRefusalCode => v /** * Where this browser remembers which conversation it was in. * - * localStorage only, and per browser rather than per user: it is a resumption hint, not - * user data, so it must never reach the storage layer or a server — the rule - * `lib/community/star-prompt.ts` states for the same reason. Every access is wrapped, and + * localStorage only: it is a resumption hint, not user data, so it must never reach the + * storage layer or a server: the rule `lib/community/star-prompt.ts` states for the same + * reason. It is part of the signed-in account's browser copy all the same, so in server + * storage mode signing out clears it (`clearAccountWorkspace`). Every access is wrapped, and * every failure degrades to "nothing was interrupted", which is the behaviour before this * existed: a rail that cannot say a conversation ended is strictly better than one that * cannot open a run (#518). */ -const THREAD_STORAGE_KEY = "libredb_agent_thread"; +const THREAD_STORAGE_KEY = AGENT_THREAD_KEY; /** A conversation this browser was in and is no longer following. */ export interface AgentInterruptedThread { @@ -288,9 +290,13 @@ function parseStoredThread(raw: string | null): AgentInterruptedThread | null { return { threadId: candidate.threadId, steps: candidate.steps }; } -/** Remember the conversation this run belongs to, or forget the one that no longer applies. */ +/** + * Remember the conversation this run belongs to, or forget the one that no longer applies. Only + * while the browser copy is still the account this tab claimed it for (`holdsAccountWorkspace`). + */ function rememberThread(thread: AgentThreadContext | null): void { try { + if (!holdsAccountWorkspace()) return; // Removed rather than left standing when a run belongs to no conversation: a stale // entry would tell the next mount a conversation was interrupted that had already ended. if (thread === null) localStorage.removeItem(THREAD_STORAGE_KEY); diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx new file mode 100644 index 000000000..6404e9a3c --- /dev/null +++ b/src/components/auth/WorkspaceOwnerGate.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { type ReactNode, useEffect, useState } from "react"; +import { ViewLoading } from "@/components/LazyView"; +import { reportSessionEnded } from "@/lib/config/base-path"; +import { logger } from "@/lib/logger"; +import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage"; +import { + claimWorkspaceForSignedInAccount, + readSignedInUsername, + SessionEndedError, +} from "@/lib/storage/workspace-owner"; + +/** + * Renders a page that reads this browser's copy of the workspace only once the copy is the + * signed-in account's. In server storage mode the browser copy belongs to the signed-in account, + * so a copy another account left behind is cleared before the page reads it, and signing in as + * a different account starts from that account's server data on every such page, not only the + * editor. The copy is shared by every tab of the browser profile: when another tab records a + * different owner or clears it (a sign-out, a different account signing in, or a claim for this + * same account that cleared it first), this page stops rendering and reloads, so the check runs + * again for the account signed in now. A sign-in can also land where nothing records an owner (an + * unknown address), so whenever the tab is shown again it asks which account is signed in, and + * reloads the same way when that is no longer the account it rendered for. A session that has + * ended, at the first check or when the tab is shown again, shows no page and no message: the tab + * goes to sign in, as every page does when the server reports an ended session. In local mode the + * page renders after the storage mode is known, with the copy as it is. + */ +export function WorkspaceOwnerGate({ children }: Readonly<{ children: ReactNode }>) { + const [state, setState] = useState<"checking" | "ready" | "failed">("checking"); + + useEffect(() => { + let cancelled = false; + let stopListening = () => {}; + claimWorkspaceForSignedInAccount().then( + (owner) => { + if (cancelled) return; + setState("ready"); + if (owner === null) return; + let left = false; + /** Stops rendering the page, once; answers whether this call is the one that did. */ + const drop = () => { + if (left || cancelled) return false; + left = true; + stopListening(); + setState("checking"); + return true; + }; + const leave = () => { + if (drop()) window.location.reload(); + }; + const onStorage = (event: StorageEvent) => { + if (event.key !== null && event.key !== WORKSPACE_OWNER_KEY) return; + // A removed owner key means the copy was cleared, also when another tab claiming for + // this same account writes the name again right after: this page's data is gone. + const removed = event.key === WORKSPACE_OWNER_KEY && event.newValue === null; + if (!removed && localStorage.getItem(WORKSPACE_OWNER_KEY) === owner) return; + leave(); + }; + const onShown = () => { + if (document.visibilityState !== "visible") return; + readSignedInUsername().then( + (username) => { + if (username !== owner) leave(); + }, + // Not knowing the account now says nothing about a switch: this page stays as it + // is, and the next request a page sends meets the session check as usual. A session + // that has ended is known: the page goes, and the tab goes to sign in. + (err: unknown) => { + if (err instanceof SessionEndedError) { + if (drop()) reportSessionEnded(); + return; + } + logger.warn("Could not ask which account is signed in when the tab was shown again", { + error: err instanceof Error ? err.message : String(err), + }); + }, + ); + }; + window.addEventListener("storage", onStorage); + document.addEventListener("visibilitychange", onShown); + stopListening = () => { + window.removeEventListener("storage", onStorage); + document.removeEventListener("visibilitychange", onShown); + }; + }, + (err: unknown) => { + if (cancelled) return; + if (err instanceof SessionEndedError) { + reportSessionEnded(); + return; + } + logger.warn("Could not match this browser's workspace to the signed-in account", { + error: err instanceof Error ? err.message : String(err), + }); + setState("failed"); + }, + ); + return () => { + cancelled = true; + stopListening(); + }; + }, []); + + if (state === "ready") return children; + if (state === "failed") { + return ( +

+ Studio could not confirm the signed-in account, so this browser's workspace is not shown. Reload the page + to try again. +

+ ); + } + return ; +} diff --git a/src/components/object-source/source-drafts.ts b/src/components/object-source/source-drafts.ts index 940d9270b..6271e265b 100644 --- a/src/components/object-source/source-drafts.ts +++ b/src/components/object-source/source-drafts.ts @@ -1,5 +1,6 @@ import { SOURCE_CHARACTER_LIMIT } from "@/lib/db/object-kinds"; import type { ObjectEditRevision } from "@/lib/db/types"; +import { holdsAccountWorkspace, SOURCE_DRAFTS_KEY } from "@/lib/storage/local-storage"; /** * The unsaved edit of ONE part of an object's definition, held in this browser (#789 Phase 3, @@ -59,9 +60,10 @@ export interface SourceDraft { /** * The one key. Versioned, so a later shape change is a new key rather than a migration over a - * record a previous release wrote. + * record a previous release wrote. Spelled in `lib/storage/local-storage.ts`, which lists it among + * the keys of the signed-in account's browser copy. */ -export const DRAFT_KEY = "libredb_source_drafts_v1"; +export const DRAFT_KEY = SOURCE_DRAFTS_KEY; /** * 1,048,576 characters, which is 20 percent of the measured origin ceiling, and it is counted as @@ -194,7 +196,8 @@ export function readDraft(storage: Storage | null, key: string): SourceDraft | u * the last thing that happens. */ export function writeDraft(storage: Storage | null, key: string, draft: SourceDraft): DraftWrite { - if (storage === null) return { ok: false, reason: "unavailable" }; + // A store another tab has handed to a different account is not this page's to write. + if (storage === null || !holdsAccountWorkspace(storage)) return { ok: false, reason: "unavailable" }; if (draft.text.length > SOURCE_CHARACTER_LIMIT) return { ok: false, reason: "too-long" }; const next: Record = { ...readStore(storage), [key]: draft }; @@ -232,7 +235,7 @@ export function writeDraft(storage: Storage | null, key: string, draft: SourceDr * "another tab" grammar. Nothing in this module builds that banner (#789). */ export function dropDraft(storage: Storage | null, key: string): void { - if (storage === null) return; + if (storage === null || !holdsAccountWorkspace(storage)) return; const next = readStore(storage); delete next[key]; try { diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 0f46deef4..44b865112 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -6,6 +6,11 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { useToast } from "@/hooks/use-toast"; import { logger } from "@/lib/logger"; +import { releaseAccountWorkspace } from "@/lib/storage/sign-out"; + +/** What a sign-out says when changes that could not be pushed stay in this browser's copy. */ +const CHANGES_KEPT = + "Some changes could not be saved to server storage. This browser keeps them and saves them when this account signs in here again; a different account signing in here first discards them."; interface AuthUser { role?: string; @@ -47,9 +52,20 @@ export function useAuth() { const handleLogout = useCallback(async () => { try { - const res = await appFetch("/api/auth/logout", { method: "POST" }); - const data = await res.json(); - toast({ title: "Logged out", description: "You have been successfully logged out." }); + // In server storage mode the browser copy belongs to the signed-in account: it is pushed + // while the session cookie is still valid and cleared once the session ended, or kept for + // this account when a push did not land. The session-ended path above clears nothing; the + // owner check every page that reads the copy runs at the next sign-in covers a different + // account. + const { signedOut, response, changesKept } = await releaseAccountWorkspace(() => + appFetch("/api/auth/logout", { method: "POST" }), + ); + if (!signedOut) throw new Error("Sign-out did not complete"); + const data: { redirectUrl?: string } = response ? await response.json() : {}; + toast({ + title: "Logged out", + description: changesKept ? CHANGES_KEPT : "You have been successfully logged out.", + }); if (data.redirectUrl) { window.location.href = data.redirectUrl; diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts index 5fb3b0e19..b63d74b2c 100644 --- a/src/hooks/use-storage-sync.ts +++ b/src/hooks/use-storage-sync.ts @@ -1,6 +1,6 @@ "use client"; -import { appFetch } from "@/lib/config/base-path"; +import { appFetch, heldWorkspaceOwner } from "@/lib/config/base-path"; import { useState, useEffect, useRef, useCallback } from "react"; import { storage, @@ -9,9 +9,17 @@ import { type StorageData, STORAGE_COLLECTIONS, } from "@/lib/storage"; +import { + claimAccountWorkspace, + keepUnsavedCollections, + SERVER_MIGRATED_KEY as MIGRATION_FLAG, + takeUnsavedCollections, + WORKSPACE_OWNER_KEY, +} from "@/lib/storage/local-storage"; +import { registerWorkspaceSync } from "@/lib/storage/sign-out"; +import { readSignedInUsername } from "@/lib/storage/workspace-owner"; import { logger } from "@/lib/logger"; -const MIGRATION_FLAG = "libredb_server_migrated"; const DEBOUNCE_MS = 500; /** First retry delay after a failed push; doubles per consecutive failure. */ const RETRY_BASE_MS = 1000; @@ -33,6 +41,8 @@ export interface StorageSyncState { * - Discovers storage mode via GET /api/storage/config * - In server mode: pulls data on mount, pushes mutations (debounced) * - Handles first-login migration from localStorage to server + * - Binds the browser copy to the signed-in account (server mode): a copy another account + * left behind is cleared before anything is migrated, pulled or pushed * - Graceful degradation: if server unreachable, localStorage continues */ export function useStorageSync(): StorageSyncState { @@ -57,6 +67,14 @@ export function useStorageSync(): StorageSyncState { const pendingCollectionsRef = useRef>(new Set()); const retryDelayRef = useRef(RETRY_BASE_MS); const serverModeRef = useRef(false); + /** The push in flight, so a sign-out can wait for it while the session cookie is still valid. */ + const inFlightRef = useRef | null>(null); + /** + * Set while a sign-out is under way and after it: changes are queued but not pushed, and no + * timer pushes, so nothing pushes the copy once it is cleared. A sign-out that did not happen + * resets it (`resume`). + */ + const signingOutRef = useRef(false); /** * Whether this hook is still mounted. A push that is in flight when the user * navigates away resolves AFTER teardown, and the failure branch would arm a @@ -69,8 +87,8 @@ export function useStorageSync(): StorageSyncState { // `flushPending` and `schedulePush` depending on each other. const flushPendingRef = useRef<() => void>(() => {}); - // ── Flush pending collections ── - const flushPending = useCallback(async () => { + // ── Push pending collections ── + const pushPending = useCallback(async () => { // Declared inside the flush rather than as its own `useCallback`: it has // exactly one caller, and hoisting it only bought this memo a dependency // that could never change. It closes over nothing but refs, module-level @@ -104,9 +122,11 @@ export function useStorageSync(): StorageSyncState { setIsSyncing(true); try { - const outcomes = await Promise.all( + const pushes = Promise.all( collections.map(async (col) => [col, await pushToServer(col, getCollectionData(col))] as const), ); + inFlightRef.current = pushes; + const outcomes = await pushes; // A collection that failed to push is still only in localStorage. Putting // it back in the queue is what keeps the write recoverable — without this @@ -133,6 +153,14 @@ export function useStorageSync(): StorageSyncState { } }, []); + // ── Flush pending collections, unless a sign-out is under way ── + // Every timer goes through here, so a debounce or retry that fires during or after a sign-out + // sends nothing; the sign-out's own flush pushes through `pushPending` directly. + const flushPending = useCallback(async () => { + if (signingOutRef.current) return; + await pushPending(); + }, [pushPending]); + // Filled after commit, never during render: React forbids writing `ref.current` // while rendering, and the ref's only reader — the retry timer armed inside // `flushPending` — cannot fire before the first commit, so it never sees the @@ -146,6 +174,7 @@ export function useStorageSync(): StorageSyncState { const schedulePush = useCallback( (collection: string) => { pendingCollectionsRef.current.add(collection); + if (signingOutRef.current) return; if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } @@ -157,7 +186,14 @@ export function useStorageSync(): StorageSyncState { ); // ── Pull all data from server → localStorage ── - const pullFromServer = useCallback(async () => { + /** + * `owner` is the account the copy was claimed for. Each collection of the answer is written + * only while the copy still belongs to it, checked again before every write: a sign-out, or + * another tab claiming the copy for a different account, while the pull was out or between two + * of its writes, keeps the copy as that left it. A collection still waiting to be pushed keeps + * its local value. + */ + const pullFromServer = useCallback(async (owner: string) => { setIsSyncing(true); try { const res = await appFetch("/api/storage"); @@ -165,20 +201,12 @@ export function useStorageSync(): StorageSyncState { const data = (await res.json()) as Partial; // Write server data to localStorage (overwrite) - if (data.connections) writeCollectionToLocal("connections", data.connections); - if (data.history) writeCollectionToLocal("history", data.history); - if (data.saved_queries) writeCollectionToLocal("saved_queries", data.saved_queries); - if (data.schema_snapshots) writeCollectionToLocal("schema_snapshots", data.schema_snapshots); - if (data.saved_charts) writeCollectionToLocal("saved_charts", data.saved_charts); - if (data.active_connection_id !== undefined) - writeCollectionToLocal("active_connection_id", data.active_connection_id); - if (data.audit_log) writeCollectionToLocal("audit_log", data.audit_log); - if (data.masking_config) writeCollectionToLocal("masking_config", data.masking_config); - if (data.threshold_config) writeCollectionToLocal("threshold_config", data.threshold_config); - if (data.dismissed_seeds) writeCollectionToLocal("dismissed_seeds", data.dismissed_seeds); - if (data.favorite_connections) writeCollectionToLocal("favorite_connections", data.favorite_connections); - if (data.connection_order) writeCollectionToLocal("connection_order", data.connection_order); - if (data.connection_groups) writeCollectionToLocal("connection_groups", data.connection_groups); + for (const collection of STORAGE_COLLECTIONS) { + const value = data[collection]; + if (!isPulled(collection, value) || pendingCollectionsRef.current.has(collection)) continue; + if (localStorage.getItem(WORKSPACE_OWNER_KEY) !== owner) return; + writeCollectionToLocal(collection, value); + } setLastSyncedAt(new Date()); setSyncError(null); @@ -258,26 +286,54 @@ export function useStorageSync(): StorageSyncState { let cancelled = false; async function init() { + let ready = true; try { const res = await appFetch("/api/storage/config"); if (!res.ok || cancelled) return; const config = (await res.json()) as StorageConfigResponse; if (config.serverMode && !cancelled) { + // The browser copy belongs to the signed-in account. Without the account's name the + // copy cannot be matched to it, so it is neither used nor pushed: isReady stays false. + let username: string; + try { + username = await readSignedInUsername(); + } catch (err) { + ready = false; + const message = err instanceof Error ? err.message : String(err); + logger.warn("StorageSync could not read the signed-in account", { error: message }); + setSyncError(message); + return; + } + if (cancelled) return; + // The page's owner check claimed the copy for another account than the one signed in + // now: the page reloads, so the check runs again for this one. + const held = heldWorkspaceOwner(); + if (held !== null && held !== username) { + ready = false; + window.location.reload(); + return; + } + setIsServerMode(true); serverModeRef.current = true; - // Migration first, then pull + // Records `username` as the owner of the copy it keeps or clears. + claimAccountWorkspace(username); + // Migration first, then the changes a sign-out of this account could not push, then pull await migrateToServer(); + const unsaved = takeUnsavedCollections(); + for (const collection of unsaved) pendingCollectionsRef.current.add(collection); + if (unsaved.length > 0) await pushPending(); if (!cancelled) { - await pullFromServer(); + await pullFromServer(username); } } } catch { // Server unreachable — stay in local mode logger.debug("Storage server unreachable, staying in local mode"); } finally { - if (!cancelled) { + if (!cancelled && ready) { setIsReady(true); } } @@ -287,7 +343,40 @@ export function useStorageSync(): StorageSyncState { return () => { cancelled = true; }; - }, [migrateToServer, pullFromServer]); + }, [migrateToServer, pullFromServer, pushPending]); + + // ── Sign-out: push what is pending while the session cookie is still valid ── + useEffect(() => { + if (!isServerMode) return; + return registerWorkspaceSync({ + flush: async () => { + signingOutRef.current = true; + const clearTimers = () => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + }; + clearTimers(); + await inFlightRef.current; + // The push awaited above arms a retry when it failed: cleared again, and after this + // flush's own push for the same reason. + clearTimers(); + await pushPending(); + clearTimers(); + if (pendingCollectionsRef.current.size > 0) { + // Kept in the copy for this account's next sign-in here, which pushes them first. + keepUnsavedCollections(Array.from(pendingCollectionsRef.current)); + throw new Error("Unsaved changes could not be saved to server storage"); + } + }, + resume: () => { + signingOutRef.current = false; + // Held here again, so nothing is left for a later sign-in to push. + takeUnsavedCollections(); + const held = Array.from(pendingCollectionsRef.current); + for (const collection of held) schedulePush(collection); + }, + }); + }, [isServerMode, pushPending, schedulePush]); // ── Listen for storage mutations ── useEffect(() => { @@ -345,6 +434,14 @@ function getCollectionData(collection: string): unknown { } } +/** + * Whether the pull writes a collection the server answered: one it holds a value for, and + * `active_connection_id` also when that value is null (the account has no active connection). + */ +function isPulled(collection: string, value: unknown): boolean { + return collection === "active_connection_id" ? value !== undefined : Boolean(value); +} + /** Write server data directly to localStorage via storage key */ function writeCollectionToLocal(collection: string, data: unknown): void { const key = `libredb_${collection}`; diff --git a/src/hooks/use-tab-manager.ts b/src/hooks/use-tab-manager.ts index 2896f0795..391019fa3 100644 --- a/src/hooks/use-tab-manager.ts +++ b/src/hooks/use-tab-manager.ts @@ -12,7 +12,7 @@ import { objectPathLabel, pathKey } from "@/lib/db/object-path"; import { resolveTabType } from "@/lib/editor/tab-language"; import { logger } from "@/lib/logger"; import { newLocalId } from "@/lib/ids"; -import { workspaceTabsKey } from "@/lib/storage/local-storage"; +import { holdsAccountWorkspace, workspaceTabsKey } from "@/lib/storage/local-storage"; import { useStableCallback } from "@/hooks/use-stable-callback"; /** @@ -282,6 +282,8 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks if (!isWorkspaceHydrated || !storage) return; const timer = setTimeout(() => { + // A copy another tab has handed to a different account is not this page's to write. + if (!holdsAccountWorkspace(storage)) return; const serialized: PersistedWorkspaceState = { activeTabId, tabs: tabs.map((tab) => ({ diff --git a/src/lib/api/error-codes.ts b/src/lib/api/error-codes.ts index 6d50e3116..b1a709d62 100644 --- a/src/lib/api/error-codes.ts +++ b/src/lib/api/error-codes.ts @@ -45,6 +45,13 @@ export const ApiErrorCode = { */ AUTH_REQUIRED: "AUTH_REQUIRED", + /** + * The request names, in X-LibreDB-Workspace-Owner, an account other than the signed-in one + * (409): the tab claimed its browser copy for that account, and the session is now another + * account's. The browser reloads the tab for this code, so the owner check runs again. + */ + WORKSPACE_OWNER_MISMATCH: "WORKSPACE_OWNER_MISMATCH", + /** * The operator switched custom connections off (ALLOW_CUSTOM_CONNECTIONS) and the request supplied a * connection of its own (403). Distinct from AUTH_ERROR, which the same 403 carries when the role diff --git a/src/lib/config/base-path.ts b/src/lib/config/base-path.ts index 06e663fd2..e22924941 100644 --- a/src/lib/config/base-path.ts +++ b/src/lib/config/base-path.ts @@ -55,6 +55,15 @@ export function onSessionEnded(handler: () => void): () => void { }; } +/** + * Reports that the session has ended, from a check that is not an appFetch answer (GET + * /api/auth/me answering 401): the registered handler sends the tab to sign in. Nothing happens + * without one. + */ +export function reportSessionEnded(): void { + sessionEndedHandler?.(); +} + /** * Calls the registered handler when `response` is the session-required answer. Keyed on the code, * not the status: 401 also reports a database refusing its credentials and a model provider @@ -72,13 +81,70 @@ async function noticeSessionEnded(response: Response): Promise { if ((body as { code?: unknown } | null)?.code === SESSION_REQUIRED_CODE) sessionEndedHandler?.(); } +/** + * The request header that names the account this tab claimed the browser copy for, URI-encoded + * so any username fits a header value. The proxy (src/proxy.ts) answers 409 with + * WORKSPACE_OWNER_MISMATCH_CODE when it is not the signed-in account. + */ +export const WORKSPACE_OWNER_HEADER = "X-LibreDB-Workspace-Owner"; + +/** + * ApiErrorCode.WORKSPACE_OWNER_MISMATCH (src/lib/api/error-codes.ts), written out for the reason + * SESSION_REQUIRED_CODE is. A test pins the two together. + */ +export const WORKSPACE_OWNER_MISMATCH_CODE = "WORKSPACE_OWNER_MISMATCH"; + +let workspaceOwner: string | null = null; + +/** + * Records the account this tab claimed the browser copy for, in server storage mode + * (`claimAccountWorkspace`), so every request it sends from then on names it; null sends none. + * Nothing is held in local mode or before a claim. + */ +export function holdWorkspaceOwner(username: string | null): void { + workspaceOwner = username; +} + +/** The account this tab claimed the browser copy for, or null when it holds no claim. */ +export function heldWorkspaceOwner(): string | null { + return workspaceOwner; +} + +/** Adds the owner header to an app request; any other request, or no claim, passes as it came. */ +function withWorkspaceOwner(path: string, init: [RequestInit?], owner: string | null): [RequestInit?] { + if (owner === null || !path.startsWith("/") || path.startsWith("//")) return init; + const headers = new Headers(init[0]?.headers); + headers.set(WORKSPACE_OWNER_HEADER, encodeURIComponent(owner)); + return [{ ...init[0], headers }]; +} + +/** + * The browser copy this tab claimed belongs to a different account than the one signed in now + * (another tab signed in as someone else). The tab reloads, so the owner check runs again for the + * account signed in now. Reads a clone and never throws, as noticeSessionEnded does. + */ +async function noticeOwnerMismatch(response: Response, owner: string | null): Promise { + if (response.status !== 409 || owner === null) return; + let body: unknown; + try { + body = await response.clone().json(); + } catch { + return; + } + if ((body as { code?: unknown } | null)?.code === WORKSPACE_OWNER_MISMATCH_CODE) window.location.reload(); +} + /** * Preserve fetch's arguments and cancellation while applying the application's prefix. A * session-required 401 is also reported to the page's session-ended handler, when it has one; the - * response itself is returned unchanged either way. + * response itself is returned unchanged either way. While this tab holds a claim on the browser + * copy, every app request names its account (WORKSPACE_OWNER_HEADER), and an answer that it is not + * the signed-in account reloads the tab. */ export async function appFetch(path: string, ...init: [RequestInit?]): Promise { - const response = await fetch(withBasePath(path), ...init); + const owner = workspaceOwner; + const response = await fetch(withBasePath(path), ...withWorkspaceOwner(path, init, owner)); await noticeSessionEnded(response); + await noticeOwnerMismatch(response, owner); return response; } diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts index 964453e18..1f39b3144 100644 --- a/src/lib/storage/local-storage.ts +++ b/src/lib/storage/local-storage.ts @@ -4,7 +4,9 @@ * No event dispatching — that's the facade's responsibility. */ +import { heldWorkspaceOwner, holdWorkspaceOwner } from "@/lib/config/base-path"; import { logger } from "@/lib/logger"; +import { STORAGE_COLLECTIONS } from "./types"; const KEY_PREFIX = "libredb_"; @@ -56,10 +58,11 @@ export function readString(collection: string): string | null { /** * Write JSON to localStorage. - * Returns true on success, false on failure (e.g. QuotaExceededError). + * Returns true on success, false on failure (e.g. QuotaExceededError, or a copy this tab no + * longer holds: `holdsAccountWorkspace`). */ export function writeJSON(collection: string, data: unknown): boolean { - if (!isClient()) return false; + if (!isClient() || !holdsAccountWorkspace()) return false; try { localStorage.setItem(getKey(collection), JSON.stringify(data)); return true; @@ -74,10 +77,11 @@ export function writeJSON(collection: string, data: unknown): boolean { /** * Write raw string to localStorage. - * Returns true on success, false on failure (e.g. QuotaExceededError). + * Returns true on success, false on failure (e.g. QuotaExceededError, or a copy this tab no + * longer holds: `holdsAccountWorkspace`). */ export function writeString(collection: string, value: string): boolean { - if (!isClient()) return false; + if (!isClient() || !holdsAccountWorkspace()) return false; try { localStorage.setItem(getKey(collection), value); return true; @@ -94,7 +98,7 @@ export function writeString(collection: string, value: string): boolean { * Remove a key from localStorage. */ export function remove(collection: string): void { - if (!isClient()) return; + if (!isClient() || !holdsAccountWorkspace()) return; localStorage.removeItem(getKey(collection)); } @@ -112,6 +116,142 @@ export function workspaceTabsKey(connectionId: string): string { /** Drop a connection's saved editor tabs; the connection they belonged to is gone. */ export function removeWorkspaceTabs(connectionId: string): void { - if (!isClient()) return; + if (!isClient() || !holdsAccountWorkspace()) return; localStorage.removeItem(workspaceTabsKey(connectionId)); } + +/** + * The signed-in username the browser copy belongs to. Written in server storage mode only, by + * `claimAccountWorkspace` and `resetAccountWorkspace` (from `WorkspaceOwnerGate`, `useStorageSync` + * and the sign-out), which compare it with the signed-in account before the copy is used. + */ +export const WORKSPACE_OWNER_KEY = `${KEY_PREFIX}workspace_owner`; + +/** + * Set once this browser's copy has been handed to a server account: by `useStorageSync` after + * the migration, and by `resetAccountWorkspace` when the copy is cleared. + */ +export const SERVER_MIGRATED_KEY = `${KEY_PREFIX}server_migrated`; + +/** Unsaved object-source edits (`components/object-source/source-drafts.ts`). */ +export const SOURCE_DRAFTS_KEY = `${KEY_PREFIX}source_drafts_v1`; + +/** The agent conversation this browser was last in (`components/agent/use-agent-run.ts`). */ +export const AGENT_THREAD_KEY = `${KEY_PREFIX}agent_thread`; + +/** + * The collections a sign-out could not push (`useStorageSync`). Their changes stay in the copy, + * and the same account's next sign-in on this browser pushes them before it pulls. + */ +export const UNSAVED_COLLECTIONS_KEY = `${KEY_PREFIX}unsaved_collections`; + +/** Record the collections a sign-out could not push, while the copy is still this tab's. */ +export function keepUnsavedCollections(collections: readonly string[]): void { + if (!isClient() || !holdsAccountWorkspace()) return; + localStorage.setItem(UNSAVED_COLLECTIONS_KEY, JSON.stringify(collections)); +} + +/** + * The collections a sign-out could not push, removed from the copy: the caller pushes them now. + * Only names of collections the sync knows come back; a record that is not a list of names (a copy + * edited by hand) is dropped with a warning rather than failing the sync that reads it. + */ +export function takeUnsavedCollections(): string[] { + if (!isClient() || !holdsAccountWorkspace()) return []; + const raw = localStorage.getItem(UNSAVED_COLLECTIONS_KEY); + localStorage.removeItem(UNSAVED_COLLECTIONS_KEY); + if (raw === null) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + if (!Array.isArray(parsed)) { + logger.warn("Dropped a record of unsaved collections that is not a list", { key: UNSAVED_COLLECTIONS_KEY }); + return []; + } + const known: readonly string[] = STORAGE_COLLECTIONS; + return parsed.filter((name): name is string => typeof name === "string" && known.includes(name)); +} + +/** Whether the copy holds changes an earlier sign-out could not push. */ +export function hasUnsavedCollections(): boolean { + return isClient() && localStorage.getItem(UNSAVED_COLLECTIONS_KEY) !== null; +} + +/** + * Whether this tab may write the browser copy. Before a claim, and so always in local mode, it + * may. In server storage mode it may only while the copy still belongs to the account this tab + * claimed it for: once another tab of the browser profile has cleared it or recorded a different + * owner, this tab writes nothing more and reloads (`WorkspaceOwnerGate`). `storage` is the store + * the owner key is read from, for a writer that is handed its store. + */ +export function holdsAccountWorkspace(storage: Pick = localStorage): boolean { + const claimed = heldWorkspaceOwner(); + return claimed === null || storage.getItem(WORKSPACE_OWNER_KEY) === claimed; +} + +/** + * Every key that holds the signed-in account's browser copy: the synced collections, the + * unsynced drafts and hints, and the two keys that say whose copy it is. Per-browser + * preferences (theme, line numbers, the star prompt counters) are not on it. + */ +const ACCOUNT_KEYS: readonly string[] = [ + ...STORAGE_COLLECTIONS.map(getKey), + SOURCE_DRAFTS_KEY, + AGENT_THREAD_KEY, + UNSAVED_COLLECTIONS_KEY, + SERVER_MIGRATED_KEY, + WORKSPACE_OWNER_KEY, +]; + +/** + * Remove the signed-in account's browser copy, editor tabs included, and nothing else. In + * server storage mode the browser copy belongs to the signed-in account: signing out clears + * it, and signing in as a different account starts from that account's server data. + */ +export function clearAccountWorkspace(): void { + if (!isClient()) return; + for (const key of ACCOUNT_KEYS) localStorage.removeItem(key); + // Collected first: removing while walking by index would skip the key after each removal. + const tabKeys: string[] = []; + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + if (key?.startsWith(`${WORKSPACE_TABS_KEY_PREFIX}:`)) tabKeys.push(key); + } + for (const key of tabKeys) localStorage.removeItem(key); +} + +/** + * Clear the browser copy and mark it as handed to a server account, so `claimAccountWorkspace` + * never takes what is written to it afterwards for local-mode data. `owner` is the account the + * emptied copy now belongs to, or null after a sign-out. + */ +export function resetAccountWorkspace(owner: string | null): void { + if (!isClient()) return; + clearAccountWorkspace(); + localStorage.setItem(SERVER_MIGRATED_KEY, new Date().toISOString()); + if (owner !== null) localStorage.setItem(WORKSPACE_OWNER_KEY, owner); +} + +/** + * Server storage mode: keep the browser copy only for the account it belongs to, before anything + * reads it, and hold that account for the requests this tab sends (`holdWorkspaceOwner`). The + * same owner keeps it. A copy with no owner that was never handed to a server + * account is local-mode data: it now belongs to the signed-in account and is kept for + * `useStorageSync` to migrate into that account alone (docs/STORAGE.md). Anything else is + * cleared, and the signed-in account starts from its own server data. + */ +export function claimAccountWorkspace(username: string): void { + if (!isClient()) return; + // Every request this tab sends from now on names the account it claimed the copy for. + holdWorkspaceOwner(username); + const owner = localStorage.getItem(WORKSPACE_OWNER_KEY); + if (owner === username) return; + if (owner === null && localStorage.getItem(SERVER_MIGRATED_KEY) === null) { + localStorage.setItem(WORKSPACE_OWNER_KEY, username); + return; + } + resetAccountWorkspace(username); +} diff --git a/src/lib/storage/sign-out.ts b/src/lib/storage/sign-out.ts new file mode 100644 index 000000000..c8269dd92 --- /dev/null +++ b/src/lib/storage/sign-out.ts @@ -0,0 +1,105 @@ +/** + * What an explicit sign-out does to this browser's copy of the workspace. + * + * In server storage mode the browser copy belongs to the signed-in account, so signing out + * pushes whatever is still waiting to be pushed while the session cookie is still valid, ends + * the session, and only then clears the copy. A push that did not land does not stop the sign-out: + * when the session had already ended the copy is cleared, and otherwise the session ends and the + * copy is kept for this account with the collections that were not pushed, which the same account's + * next sign-in here pushes before it pulls (`useStorageSync`). A sign-out the server + * refused while the session goes on leaves the copy in place. In local mode the browser copy is + * the only one and stays. + * + * Every sign-out path goes through `releaseAccountWorkspace()` with its POST /api/auth/logout. + * Only the Studio page mounts `useStorageSync`, which registers itself here while it is mounted; + * the admin dashboard and the launch page have nothing pending and only clear. + */ + +import { hasUnsavedCollections, resetAccountWorkspace } from "./local-storage"; +import { readServerMode, readSignedInUsername, SessionEndedError } from "./workspace-owner"; + +/** The mounted sync, as a sign-out sees it. */ +export interface WorkspaceSync { + /** Pushes every pending collection and holds later changes; rejects when one did not land. */ + flush(): Promise; + /** Pushes again: the sign-out did not happen and the session goes on. */ + resume(): void; +} + +/** How a sign-out ended. */ +export interface SignOutResult { + /** The session is over: the sign-out succeeded, or no session was left after it. */ + signedOut: boolean; + /** The successful POST /api/auth/logout answer, whose body may name the provider's sign-out address. */ + response: Response | null; + /** Changes that could not be pushed stay in this browser's copy for the account that signed out. */ + changesKept: boolean; +} + +let mountedSync: WorkspaceSync | null = null; + +/** Register the mounted sync. Returns the unregister function. */ +export function registerWorkspaceSync(sync: WorkspaceSync): () => void { + mountedSync = sync; + return () => { + if (mountedSync === sync) mountedSync = null; + }; +} + +/** Whether GET /api/auth/me says no session is left. Not knowing counts as a session that goes on. */ +async function sessionHasEnded(): Promise { + try { + await readSignedInUsername(); + return false; + } catch (err) { + return err instanceof SessionEndedError; + } +} + +/** + * Runs `signOut`. A refused or failed one is still a sign-out when no session is left after it (the + * server ended the session and the answer was an error or was lost); otherwise the sync resumes. + */ +async function endSession( + signOut: () => Promise, + sync: WorkspaceSync | null, + changesKept: boolean, +): Promise { + const response = await signOut().catch(() => null); + if (response?.ok) return { signedOut: true, response, changesKept }; + if (await sessionHasEnded()) return { signedOut: true, response: null, changesKept }; + sync?.resume(); + return { signedOut: false, response: null, changesKept: false }; +} + +/** + * Server mode: push what is pending, run `signOut`, and once the session is over clear this + * browser's copy, marked so nothing written to it afterwards is migrated into the next account. + * A pending push that did not land clears the copy without `signOut` when the session had already + * ended, and otherwise keeps it (`changesKept`) while the session still ends. Local mode, or a + * storage mode that cannot be read for a copy never bound to a server account: only `signOut`. + * Rejects, before `signOut` runs, when the storage mode cannot be read for a copy bound to a server + * account, so the caller reports a failed sign-out. + */ +export async function releaseAccountWorkspace(signOut: () => Promise): Promise { + if (!(await readServerMode())) return endSession(signOut, null, false); + + const sync = mountedSync; + // A page with no sync (the admin dashboard) cannot push what an earlier sign-out kept, so it + // keeps the copy for this account rather than clear those changes. + let changesKept = !sync && hasUnsavedCollections(); + if (sync) { + try { + await sync.flush(); + } catch { + if (await sessionHasEnded()) { + resetAccountWorkspace(null); + return { signedOut: true, response: null, changesKept: false }; + } + changesKept = true; + } + } + const result = await endSession(signOut, sync, changesKept); + if (result.signedOut && !changesKept) resetAccountWorkspace(null); + return result; +} diff --git a/src/lib/storage/workspace-owner.ts b/src/lib/storage/workspace-owner.ts new file mode 100644 index 000000000..8247695ba --- /dev/null +++ b/src/lib/storage/workspace-owner.ts @@ -0,0 +1,68 @@ +/** + * Whose browser copy this is, in server storage mode. The copy belongs to the signed-in account: + * every page that reads it (the editor, the admin dashboard, monitoring), and every settings page a + * sign-in can land on, first makes it the signed-in account's (`claimAccountWorkspace`), so a + * different account starts from its own server data. + */ + +import { appFetch } from "@/lib/config/base-path"; +import { claimAccountWorkspace, SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "./local-storage"; +import type { StorageConfigResponse } from "./types"; + +/** GET /api/auth/me answered 401: there is no session any more, so no account is signed in. */ +export class SessionEndedError extends Error { + constructor() { + super("The session has ended"); + this.name = "SessionEndedError"; + } +} + +/** + * The signed-in account's username, from GET /api/auth/me. Throws when it cannot be read: + * `SessionEndedError` when the session has ended, an ordinary error otherwise. + */ +export async function readSignedInUsername(): Promise { + const res = await appFetch("/api/auth/me"); + if (res.status === 401) throw new SessionEndedError(); + if (!res.ok) throw new Error(`Could not read the signed-in account: HTTP ${res.status}`); + const body = (await res.json()) as { user?: { username?: unknown } }; + const username = body.user?.username; + if (typeof username !== "string" || username === "") { + throw new Error("Could not read the signed-in account: no username"); + } + return username; +} + +/** + * Whether server storage mode is on, from GET /api/storage/config. A storage mode that cannot be + * read throws when the copy carries an owner or the migration flag, which only server mode + * writes, so such a copy is never handled unchecked; a copy with neither counts as local mode, as + * `useStorageSync` counts it. + */ +export async function readServerMode(): Promise { + try { + const res = await appFetch("/api/storage/config"); + if (!res.ok) throw new Error(`Storage mode unavailable: HTTP ${res.status}`); + return ((await res.json()) as StorageConfigResponse).serverMode; + } catch (err) { + if (isServerBoundCopy()) throw err; + return false; + } +} + +/** + * Server mode: make the browser copy the signed-in account's before a page reads it. Local mode: + * nothing. Throws in server mode when the signed-in account cannot be read: the copy must not be + * used. A storage mode that cannot be read is judged as `readServerMode` judges it. Answers the + * account the copy now belongs to, or null when no account was matched (local mode). + */ +export async function claimWorkspaceForSignedInAccount(): Promise { + if (!(await readServerMode())) return null; + const username = await readSignedInUsername(); + claimAccountWorkspace(username); + return username; +} + +function isServerBoundCopy(): boolean { + return localStorage.getItem(WORKSPACE_OWNER_KEY) !== null || localStorage.getItem(SERVER_MIGRATED_KEY) !== null; +} diff --git a/src/proxy.ts b/src/proxy.ts index cf88cf78b..25696b21a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,4 +1,5 @@ -import { withBasePath } from "@/lib/config/base-path"; +import { WORKSPACE_OWNER_HEADER, withBasePath } from "@/lib/config/base-path"; +import { ApiErrorCode } from "@/lib/api/error-codes"; import { NextRequest, NextResponse } from "next/server"; import { jwtVerify } from "jose"; import { AGENT_DRIVE_HEADER, AGENT_DRIVE_PATH, verifyAgentDriveToken } from "@/lib/agent/drive-token"; @@ -36,6 +37,20 @@ const ORIGIN_MISMATCH_BODY = { retryable: false, }; +const WORKSPACE_OWNER_MISMATCH_BODY = { + error: "This page's workspace belongs to a different account than the one signed in. Reload the page.", + code: ApiErrorCode.WORKSPACE_OWNER_MISMATCH, +}; + +/** The account a workspace owner header names, or null when it is not a valid encoding. */ +function decodedOwner(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + export async function proxy(request: NextRequest) { // NextURL removes the configured basePath before exposing pathname; Next also // prefixes config.matcher at build time. Keep authorization checks app-relative. @@ -175,6 +190,15 @@ export async function proxy(request: NextRequest) { const { payload } = await jwtVerify(token, jwtSecret()); const role = payload.role as string; + // A tab that claimed the browser copy names that account on every request (docs/STORAGE.md). + // One that names a different account than the session's is answered here, before any route + // runs, and the tab reloads to claim the copy again. A request without the header (MCP, the + // agent drive callback, an external client, local mode) is not checked. + const claimed = request.headers.get(WORKSPACE_OWNER_HEADER); + if (claimed !== null && decodedOwner(claimed) !== payload.username) { + return withSecurityHeaders(NextResponse.json(WORKSPACE_OWNER_MISMATCH_BODY, { status: 409 })); + } + // RBAC: /admin only for admin if (pathname.startsWith("/admin") && role !== "admin") { // METERED through the anon bucket, exactly as the origin_mismatch line above is. Holding a diff --git a/tests/api/proxy.test.ts b/tests/api/proxy.test.ts index e717c32b8..6139f129b 100644 --- a/tests/api/proxy.test.ts +++ b/tests/api/proxy.test.ts @@ -396,6 +396,93 @@ describe("proxy", () => { }); }); + // ─────────────────────────────────────────────────────────────────────────── + // The account a tab claimed the browser copy for (X-LibreDB-Workspace-Owner) + // ─────────────────────────────────────────────────────────────────────────── + + describe("workspace owner header", () => { + async function tokenFor(username: string) { + return await new SignJWT({ role: "user", username }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(JWT_SECRET); + } + + function request(pathname: string, token: string, owner: string | null, method = "GET"): NextRequest { + const headers = new Headers({ + cookie: `auth-token=${token}`, + host: "localhost:3000", + origin: "http://localhost:3000", + }); + if (owner !== null) headers.set("X-LibreDB-Workspace-Owner", owner); + return new NextRequest(`http://localhost:3000${pathname}`, { method, headers }); + } + + /** NextResponse.next() marks the response so the route runs; a refusal never carries it. */ + const reachesRoute = (response: Response) => response.headers.get("x-middleware-next") === "1"; + + async function expectOwnerMismatch(response: Response) { + expect(response.status).toBe(409); + expect(reachesRoute(response)).toBe(false); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + const body = (await response.json()) as { code?: string }; + expect(body.code).toBe("WORKSPACE_OWNER_MISMATCH"); + } + + test("a request whose owner is the signed-in account reaches the route", async () => { + const token = await tokenFor("ana@libredb.org"); + const res = await proxy(request("/api/storage", token, encodeURIComponent("ana@libredb.org"))); + + expect(reachesRoute(res)).toBe(true); + }); + + test("a request whose owner is a different account answers 409 with the code", async () => { + const token = await tokenFor("bob@libredb.org"); + + await expectOwnerMismatch(await proxy(request("/api/storage", token, encodeURIComponent("ana@libredb.org")))); + }); + + test("an owner that does not decode is not the signed-in account", async () => { + const token = await tokenFor("ana@libredb.org"); + + await expectOwnerMismatch(await proxy(request("/api/storage", token, "%E0%A4%A"))); + }); + + test("a request without the header behaves as before", async () => { + const token = await tokenFor("ana@libredb.org"); + + expect(reachesRoute(await proxy(request("/api/storage", token, null)))).toBe(true); + expect(reachesRoute(await proxy(request("/", token, null)))).toBe(true); + }); + + test("a storage write carrying another account's owner is refused before the route runs", async () => { + const token = await tokenFor("bob@libredb.org"); + const put = request("/api/storage/connections", token, encodeURIComponent("ana@libredb.org"), "PUT"); + + await expectOwnerMismatch(await proxy(put)); + const own = request("/api/storage/connections", token, encodeURIComponent("bob@libredb.org"), "PUT"); + expect(reachesRoute(await proxy(own))).toBe(true); + }); + + test("public routes are not checked, so a tab can still ask which account is signed in", async () => { + const token = await tokenFor("bob@libredb.org"); + const stale = encodeURIComponent("ana@libredb.org"); + + expect(reachesRoute(await proxy(request("/api/auth/me", token, stale)))).toBe(true); + expect(reachesRoute(await proxy(request("/api/auth/logout", token, stale, "POST")))).toBe(true); + expect(reachesRoute(await proxy(request("/api/db/health", token, stale)))).toBe(true); + }); + + test("without a session the answer is still the session-required 401", async () => { + const req = new NextRequest("http://localhost:3000/api/storage", { + headers: { "X-LibreDB-Workspace-Owner": "ana%40libredb.org" }, + }); + + await expectSessionRequired(await proxy(req), "Authentication required"); + }); + }); + // ─────────────────────────────────────────────────────────────────────────── // The agent drive path (#329 T9) // diff --git a/tests/components/LaunchPage.test.tsx b/tests/components/LaunchPage.test.tsx index 8a4093e03..c44df670f 100644 --- a/tests/components/LaunchPage.test.tsx +++ b/tests/components/LaunchPage.test.tsx @@ -285,9 +285,12 @@ describe("the /launch page in a browser signed in as someone else", () => { launchFor: "bob@example.com", }; - function serveConflict(logout: () => Promise): ReturnType { + function serveConflict(logout: () => Promise, serverMode = false): ReturnType { const fetchMock = mock(async (url: string) => { if (String(url).endsWith("/api/auth/me")) return SIGNED_IN(); + if (String(url).endsWith("/api/storage/config")) { + return answer(200, { provider: serverMode ? "postgres" : "local", serverMode }); + } return String(url).endsWith("/api/auth/logout") ? logout() : answer(409, CONFLICT); }); globalThis.fetch = fetchMock as never; @@ -318,7 +321,7 @@ describe("the /launch page in a browser signed in as someone else", () => { expect( await view.findByText("You are signed out. Open Studio again from the platform to continue as bob@example.com."), ).not.toBeNull(); - const [url, init] = fetchMock.mock.calls[2] as unknown as [string, RequestInit]; + const [url, init] = fetchMock.mock.calls[3] as unknown as [string, RequestInit]; expect(url).toBe("/api/auth/logout"); expect(init.method).toBe("POST"); }); @@ -336,4 +339,60 @@ describe("the /launch page in a browser signed in as someone else", () => { ).not.toBeNull(); expect(view.getByRole("link", { name: "Stay signed in as ada@example.com" })).not.toBeNull(); }); + + describe("this browser's copy of the workspace", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }])); + localStorage.setItem("libredb_workspace_owner", "ada@example.com"); + }); + + afterEach(() => { + localStorage.clear(); + }); + + test("server mode: the session ends, then the copy is cleared", async () => { + let atLogout: string | null = "not called"; + serveConflict(async () => { + atLogout = localStorage.getItem("libredb_connections"); + return answer(200, { success: true }); + }, true); + const view = render(); + fireEvent.click(await view.findByRole("button", { name: "Sign out" })); + expect( + await view.findByText( + "You are signed out. Open Studio again from the platform to continue as bob@example.com.", + ), + ).not.toBeNull(); + expect(atLogout).toBe(JSON.stringify([{ id: "c1" }])); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + }); + + test("server mode: a sign-out the server refused says so and keeps the copy", async () => { + serveConflict(async () => answer(500, { error: "Server error" }), true); + const view = render(); + fireEvent.click(await view.findByRole("button", { name: "Sign out" })); + expect( + await view.findByText( + "Studio could not sign you out. Sign out from the editor, then open Studio again from the platform.", + ), + ).not.toBeNull(); + expect(view.queryByText(/You are signed out/)).toBeNull(); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("ada@example.com"); + }); + + test("local mode: the copy stays", async () => { + serveConflict(async () => answer(200, { success: true })); + const view = render(); + fireEvent.click(await view.findByRole("button", { name: "Sign out" })); + expect( + await view.findByText( + "You are signed out. Open Studio again from the platform to continue as bob@example.com.", + ), + ).not.toBeNull(); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + }); + }); }); diff --git a/tests/components/MonitoringPage.test.tsx b/tests/components/MonitoringPage.test.tsx index 2257870e8..d671f4554 100644 --- a/tests/components/MonitoringPage.test.tsx +++ b/tests/components/MonitoringPage.test.tsx @@ -10,22 +10,33 @@ mock.module("@/components/monitoring/MonitoringDashboard", () => ({ const { default: MonitoringPage } = await import("@/app/monitoring/page"); -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { cleanup, render } from "@testing-library/react"; +import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch"; describe("MonitoringPage", () => { + beforeEach(() => { + mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } }); + }); + afterEach(() => { cleanup(); + restoreGlobalFetch(); + }); + + test("renders MonitoringDashboard component", async () => { + const { findByTestId } = render(); + expect(await findByTestId("monitoring-dashboard")).not.toBeNull(); }); - test("renders MonitoringDashboard component", () => { - const { getByTestId } = render(); - expect(getByTestId("monitoring-dashboard")).not.toBeNull(); + test("renders MonitoringDashboard content", async () => { + const { findByText } = render(); + expect(await findByText("MonitoringDashboard Mock")).not.toBeNull(); }); - test("renders MonitoringDashboard content", () => { - const { getByText } = render(); - expect(getByText("MonitoringDashboard Mock")).not.toBeNull(); + test("renders the dashboard only once this browser's workspace is the signed-in account's", () => { + const { queryByTestId } = render(); + expect(queryByTestId("monitoring-dashboard")).toBeNull(); }); test("is a client component that renders directly", () => { diff --git a/tests/components/Page.test.tsx b/tests/components/Page.test.tsx index 6075c0731..a04f029aa 100644 --- a/tests/components/Page.test.tsx +++ b/tests/components/Page.test.tsx @@ -9,22 +9,33 @@ mock.module("@/components/Studio", () => ({ const { default: Page } = await import("@/app/page"); -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { cleanup, render } from "@testing-library/react"; +import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch"; describe("Page", () => { + beforeEach(() => { + mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } }); + }); + afterEach(() => { cleanup(); + restoreGlobalFetch(); + }); + + test("renders Studio component", async () => { + const { findByTestId } = render(); + expect(await findByTestId("studio")).not.toBeNull(); }); - test("renders Studio component", () => { - const { getByTestId } = render(); - expect(getByTestId("studio")).not.toBeNull(); + test("renders Studio content", async () => { + const { findByText } = render(); + expect(await findByText("Studio Mock")).not.toBeNull(); }); - test("renders Studio content", () => { - const { getByText } = render(); - expect(getByText("Studio Mock")).not.toBeNull(); + test("renders Studio only once this browser's workspace is the signed-in account's", () => { + const { queryByTestId } = render(); + expect(queryByTestId("studio")).toBeNull(); }); test("is a valid React component (returns JSX)", () => { diff --git a/tests/components/SettingsLayout.test.tsx b/tests/components/SettingsLayout.test.tsx new file mode 100644 index 000000000..22033e7f2 --- /dev/null +++ b/tests/components/SettingsLayout.test.tsx @@ -0,0 +1,65 @@ +import "../setup-dom"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { cleanup, render } from "@testing-library/react"; +import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch"; + +import SettingsLayout from "@/app/settings/layout"; +import McpSettingsPage from "@/app/settings/mcp/page"; + +const MCP_STATUS = { + state: "ready", + problems: [], + url: "https://studio.example.com/api/mcp", + tokenTtlDays: 30, + visibleConnections: 1, +}; + +describe("/settings layout", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", name: "Warehouse" }])); + localStorage.setItem("libredb_workspace_owner", "admin@libredb.org"); + localStorage.setItem("libredb_server_migrated", "2026-10-07"); + }); + + afterEach(() => { + cleanup(); + restoreGlobalFetch(); + localStorage.clear(); + }); + + test("server mode: a settings page records the signed-in account as the owner of the browser copy", async () => { + mockGlobalFetch({ + "/api/storage/config": { json: { provider: "postgres", serverMode: true } }, + "/api/auth/me": { json: { user: { username: "user@libredb.org" } } }, + "/api/mcp/token": { json: MCP_STATUS }, + }); + + const view = render( + + + , + ); + + expect(await view.findByRole("link", { name: /Back to the editor/i })).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org"); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + }); + + test("local mode: a settings page renders and leaves the browser copy as it is", async () => { + mockGlobalFetch({ + "/api/storage/config": { json: { provider: "local", serverMode: false } }, + "/api/mcp/token": { json: MCP_STATUS }, + }); + + const view = render( + + + , + ); + + expect(await view.findByRole("link", { name: /Back to the editor/i })).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org"); + expect(localStorage.getItem("libredb_connections")).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }])); + }); +}); diff --git a/tests/components/admin/AdminDashboard.test.tsx b/tests/components/admin/AdminDashboard.test.tsx index 1b5145041..e09c76fe8 100644 --- a/tests/components/admin/AdminDashboard.test.tsx +++ b/tests/components/admin/AdminDashboard.test.tsx @@ -26,7 +26,7 @@ import { render, fireEvent, waitFor, act, cleanup } from "@testing-library/react import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch"; import { mockRouterPush, mockRouterRefresh, setMockPathname, resetMockPathname } from "../../helpers/mock-navigation"; -import { mockToastSuccess } from "../../helpers/mock-sonner"; +import { mockToastError, mockToastSuccess } from "../../helpers/mock-sonner"; const { default: AdminDashboard } = await import("@/components/admin/AdminDashboard"); @@ -45,7 +45,10 @@ describe("AdminDashboard", () => { mockRouterRefresh.mockClear(); mockToastSuccess.mockClear(); setMockPathname("/admin/overview"); - mockGlobalFetch({ "/api/auth/logout": { json: { success: true } } }); + mockGlobalFetch({ + "/api/auth/logout": { json: { success: true } }, + "/api/storage/config": { json: { provider: "local", serverMode: false } }, + }); }); test("renders admin dashboard title", async () => { @@ -148,6 +151,7 @@ describe("AdminDashboard", () => { "/api/auth/logout": { json: { success: true, redirectUrl: "https://idp.example.com/logout?client_id=abc" }, }, + "/api/storage/config": { json: { provider: "local", serverMode: false } }, }); let renderResult: ReturnType; @@ -173,4 +177,111 @@ describe("AdminDashboard", () => { Object.defineProperty(window, "location", savedDescriptor); } }); + + describe("this browser's copy of the workspace", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }])); + localStorage.setItem("libredb_workspace_owner", "admin@libredb.org"); + }); + + afterEach(() => { + localStorage.clear(); + }); + + async function clickLogout() { + let renderResult: ReturnType; + await act(async () => { + renderResult = render(content); + }); + await act(async () => { + fireEvent.click(renderResult!.getByText("Logout").closest("button")!); + }); + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith("/login"); + }); + } + + test("server mode: the session ends, then the copy is cleared", async () => { + let atLogout: string | null = "not called"; + mockGlobalFetch({ + "/api/auth/logout": () => { + atLogout = localStorage.getItem("libredb_connections"); + return { json: { success: true } }; + }, + "/api/storage/config": { json: { provider: "postgres", serverMode: true } }, + }); + + await clickLogout(); + + expect(atLogout).toBe(JSON.stringify([{ id: "c1" }])); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + }); + + test("a sign-out that cannot complete says so and keeps the copy", async () => { + mockToastError.mockClear(); + mockGlobalFetch({ + "/api/auth/logout": { json: { success: true } }, + "/api/storage/config": { ok: false, status: 503, json: { error: "down" } }, + }); + + let renderResult: ReturnType; + await act(async () => { + renderResult = render(content); + }); + await act(async () => { + fireEvent.click(renderResult!.getByText("Logout").closest("button")!); + }); + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("Failed to logout"); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + }); + + test("a sign-out the server refused says so, stays, and keeps the copy", async () => { + mockToastError.mockClear(); + mockGlobalFetch({ + "/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } }, + "/api/storage/config": { json: { provider: "postgres", serverMode: true } }, + }); + + let renderResult: ReturnType; + await act(async () => { + renderResult = render(content); + }); + await act(async () => { + fireEvent.click(renderResult!.getByText("Logout").closest("button")!); + }); + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("Failed to logout"); + }); + + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockRouterPush).not.toHaveBeenCalled(); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + }); + + test("server mode: a refused sign-out after which no session is left clears the copy and goes to sign in", async () => { + mockGlobalFetch({ + "/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } }, + "/api/auth/me": { ok: false, status: 401, json: { authenticated: false } }, + "/api/storage/config": { json: { provider: "postgres", serverMode: true } }, + }); + + await clickLogout(); + + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + }); + + test("local mode: the copy stays", async () => { + await clickLogout(); + + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org"); + }); + }); }); diff --git a/tests/components/admin/AdminLayout.test.tsx b/tests/components/admin/AdminLayout.test.tsx index fd966cede..08fb12c55 100644 --- a/tests/components/admin/AdminLayout.test.tsx +++ b/tests/components/admin/AdminLayout.test.tsx @@ -9,24 +9,39 @@ mock.module("@/components/admin/AdminDashboard", () => ({ const { default: AdminLayout } = await import("@/app/admin/layout"); -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { cleanup, render } from "@testing-library/react"; +import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch"; describe("AdminLayout", () => { + beforeEach(() => { + mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } }); + }); + afterEach(() => { cleanup(); + restoreGlobalFetch(); }); - test("wraps children in AdminDashboard shell", () => { - const { getByTestId, getByText } = render( + test("wraps children in AdminDashboard shell", async () => { + const { findByTestId, getByText } = render( section content , ); - expect(getByTestId("admin-shell")).not.toBeNull(); + expect(await findByTestId("admin-shell")).not.toBeNull(); expect(getByText("section content")).not.toBeNull(); }); + test("renders the shell only once this browser's workspace is the signed-in account's", () => { + const { queryByTestId } = render( + + section content + , + ); + expect(queryByTestId("admin-shell")).toBeNull(); + }); + test("returns a Suspense boundary", () => { const element = AdminLayout({ children: React.createElement("div") }); expect(element.type).toBe(React.Suspense); diff --git a/tests/components/agent/AgentRail.test.tsx b/tests/components/agent/AgentRail.test.tsx index bf0d3e14a..b7d0a0f10 100644 --- a/tests/components/agent/AgentRail.test.tsx +++ b/tests/components/agent/AgentRail.test.tsx @@ -21,6 +21,8 @@ import type { AgentRunWorkflowType, AgentThreadContext } from "@/lib/agent/types import { getDBConfig } from "@/lib/db-ui-config"; import type { DatabaseConnection } from "@/lib/types"; import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch"; +import { holdWorkspaceOwner } from "@/lib/config/base-path"; +import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage"; /** * The standalone agent rail (#329 T10a): the gated surface, its two modes and the @@ -686,6 +688,39 @@ describe("AgentRail", () => { expect(localStorage.getItem("libredb_agent_thread")).toBeNull(); }); + test("a start neither records nor forgets a conversation once the copy is a different account's", async () => { + // Server storage mode: this tab claimed the copy for ana, and another tab has since + // recorded bob as its owner. The copy is bob's, so this tab writes nothing more to it. + const stored = JSON.stringify({ threadId: "arun_bob", steps: 2 }); + holdWorkspaceOwner("ana@libredb.org"); + localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org"); + localStorage.setItem("libredb_agent_thread", stored); + try { + for (const thread of [ + { threadId: "arun_a", steps: [{ runId: "arun_a", objective: "count by department" }], text: "Step 1" }, + null, + ]) { + mockAgentFetch([OPENED_LINE, STARTED_LINE, FINISHED_LINE], { + runId: "arun_b", + status: "queued", + mode: "planning", + thread, + }); + const view = render(); + fireEvent.change(view.getByTestId("agent-objective"), { target: { value: "chart those" } }); + await act(async () => { + fireEvent.click(view.getByTestId("agent-start")); + }); + await view.findByTestId("agent-run-id"); + cleanup(); + + expect(localStorage.getItem("libredb_agent_thread")).toBe(stored); + } + } finally { + holdWorkspaceOwner(null); + } + }); + test("a store that refuses the write costs the notice, never the run", async () => { // Safari private mode and a full quota both land here. The bookkeeping is a nudge; the // run is the work, and the same policy is written into `star-prompt.ts`. diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx new file mode 100644 index 000000000..93b25c218 --- /dev/null +++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx @@ -0,0 +1,460 @@ +import "../../setup-dom"; + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch"; +import { onSessionEnded } from "@/lib/config/base-path"; + +import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate"; + +const SERVER_MODE = { "/api/storage/config": { json: { provider: "postgres", serverMode: true } } }; + +/** A page that reads the browser copy the moment it renders, as the admin and editor pages do. */ +function CopyReader({ seen }: { seen: (string | null)[] }) { + seen.push(localStorage.getItem("libredb_connections"), localStorage.getItem("libredb_workspace_tabs_v1:default")); + return

workspace page

; +} + +describe("WorkspaceOwnerGate", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", name: "Warehouse" }])); + localStorage.setItem("libredb_workspace_tabs_v1:default", JSON.stringify({ tabs: [{ query: "SELECT 1" }] })); + localStorage.setItem("libredb_workspace_owner", "admin@libredb.org"); + localStorage.setItem("libredb_server_migrated", "2026-10-07"); + }); + + afterEach(() => { + cleanup(); + restoreGlobalFetch(); + localStorage.clear(); + }); + + test("server mode: a page never reads a copy that belongs to a different account", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "user@libredb.org" } } } }); + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + + expect(await view.findByText("workspace page")).not.toBeNull(); + expect(seen.every((value) => value === null)).toBe(true); + expect(localStorage.getItem("libredb_workspace_tabs_v1:default")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org"); + }); + + test("server mode: the same account's page reads its copy", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } }); + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + + expect(await view.findByText("workspace page")).not.toBeNull(); + expect(seen.at(-2)).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }])); + }); + + test("server mode: an unreadable signed-in account shows why and renders no page", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { ok: false, status: 503, json: { error: "down" } } }); + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + + const alert = await view.findByRole("alert"); + expect(alert.textContent).toContain("could not confirm the signed-in account"); + expect(seen).toEqual([]); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org"); + }); + + test("server mode: a session that has ended sends the tab to sign in, with neither the page nor an alert", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { status: 401, json: { authenticated: false } } }); + const signIn = mock(() => {}); + const unregister = onSessionEnded(signIn); + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + + await waitFor(() => expect(signIn).toHaveBeenCalledTimes(1)); + unregister(); + expect(view.queryByRole("alert")).toBeNull(); + expect(view.queryByText("workspace page")).toBeNull(); + expect(seen).toEqual([]); + }); + + test("a storage mode that cannot be read shows why and renders no page for a copy bound to a server account", async () => { + mockGlobalFetch({ "/api/storage/config": { ok: false, status: 502, json: { error: "down" } } }); + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + + expect((await view.findByRole("alert")).textContent).toContain("could not confirm the signed-in account"); + expect(seen).toEqual([]); + }); + + test("a storage mode that cannot be read renders the page for a copy never bound to a server account", async () => { + localStorage.removeItem("libredb_workspace_owner"); + localStorage.removeItem("libredb_server_migrated"); + mockGlobalFetch({ "/api/storage/config": { ok: false, status: 502, json: { error: "down" } } }); + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + + expect(await view.findByText("workspace page")).not.toBeNull(); + expect(seen.at(-2)).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }])); + }); + + test("local mode: the page reads the browser copy as before", async () => { + mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } }); + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + + expect(await view.findByText("workspace page")).not.toBeNull(); + expect(seen.at(-2)).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }])); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org"); + }); + + test("the page waits behind a loading indicator while the check is out, and an answer after unmount changes nothing", async () => { + let answer: (value: Response) => void = () => {}; + globalThis.fetch = (() => new Promise((resolve) => (answer = resolve))) as unknown as typeof fetch; + const seen: (string | null)[] = []; + + const view = render( + + + , + ); + expect(view.queryByText("workspace page")).toBeNull(); + const loading = view.getByTestId("view-loading"); + expect(loading.getAttribute("aria-label")).toBe("Checking the signed-in account"); + view.unmount(); + answer(new Response(JSON.stringify({ provider: "local", serverMode: false }), { status: 200 })); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(seen).toEqual([]); + }); + + test("an unreadable account answered after unmount changes nothing", async () => { + let answer: (value: Response) => void = () => {}; + mockGlobalFetch({ + ...SERVER_MODE, + "/api/auth/me": () => + new Promise((resolve) => { + answer = () => resolve({ ok: false, status: 503, json: { error: "down" } }); + }), + }); + + const view = render( + +

workspace page

+
, + ); + await waitFor(() => expect(answer).not.toBeNull()); + await new Promise((resolve) => setTimeout(resolve, 10)); + view.unmount(); + answer(new Response("")); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(view.queryByRole("alert")).toBeNull(); + }); + + describe("another tab changes whose copy this is", () => { + const original = window.location.reload; + let reload: ReturnType; + + beforeEach(() => { + reload = mock(() => {}); + Object.defineProperty(window.location, "reload", { value: reload, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(window.location, "reload", { value: original, configurable: true }); + }); + + function ownerChanged(key: string | null, owner: string | null) { + if (owner === null) localStorage.removeItem("libredb_workspace_owner"); + else localStorage.setItem("libredb_workspace_owner", owner); + act(() => { + window.dispatchEvent(new window.StorageEvent("storage", { key, newValue: owner })); + }); + } + + test("server mode: a different account signing in elsewhere drops the page and reloads", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } }); + const view = render( + +

workspace page

+
, + ); + expect(await view.findByText("workspace page")).not.toBeNull(); + + ownerChanged("libredb_workspace_owner", "user@libredb.org"); + + expect(view.queryByText("workspace page")).toBeNull(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + for (const [what, key] of [ + ["a sign-out elsewhere", "libredb_workspace_owner"], + ["a browser copy cleared elsewhere", null], + ] as const) { + test(`server mode: ${what} drops the page and reloads`, async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } }); + const view = render( + +

workspace page

+
, + ); + expect(await view.findByText("workspace page")).not.toBeNull(); + + ownerChanged(key, null); + + expect(view.queryByText("workspace page")).toBeNull(); + expect(reload).toHaveBeenCalledTimes(1); + }); + } + + test("server mode: other keys, and the same owner written again, keep the page", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } }); + const view = render( + +

workspace page

+
, + ); + expect(await view.findByText("workspace page")).not.toBeNull(); + + act(() => { + window.dispatchEvent(new window.StorageEvent("storage", { key: "libredb_history", newValue: "[]" })); + }); + ownerChanged("libredb_workspace_owner", "admin@libredb.org"); + + expect(view.queryByText("workspace page")).not.toBeNull(); + expect(reload).not.toHaveBeenCalled(); + }); + + test("server mode: the owner key removed elsewhere drops the page, even when the same name is written right after", async () => { + // Another tab claiming for the same account clears the copy and records the name again; this + // page's pulled data is gone from the copy all the same. + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } }); + const view = render( + +

workspace page

+
, + ); + expect(await view.findByText("workspace page")).not.toBeNull(); + + act(() => { + window.dispatchEvent( + new window.StorageEvent("storage", { + key: "libredb_workspace_owner", + oldValue: "admin@libredb.org", + newValue: null, + }), + ); + }); + + expect(view.queryByText("workspace page")).toBeNull(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + test("local mode: an owner key written elsewhere changes nothing", async () => { + mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } }); + const view = render( + +

workspace page

+
, + ); + expect(await view.findByText("workspace page")).not.toBeNull(); + + ownerChanged("libredb_workspace_owner", "user@libredb.org"); + + expect(view.queryByText("workspace page")).not.toBeNull(); + expect(reload).not.toHaveBeenCalled(); + }); + + test("the page stops listening once it is gone", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } }); + const view = render( + +

workspace page

+
, + ); + expect(await view.findByText("workspace page")).not.toBeNull(); + view.unmount(); + + ownerChanged("libredb_workspace_owner", "user@libredb.org"); + + expect(reload).not.toHaveBeenCalled(); + }); + }); + + describe("the tab is shown again", () => { + const original = window.location.reload; + let reload: ReturnType; + /** The account GET /api/auth/me names; null answers 503. */ + let signedIn: string | null; + + beforeEach(() => { + reload = mock(() => {}); + Object.defineProperty(window.location, "reload", { value: reload, configurable: true }); + signedIn = "admin@libredb.org"; + }); + + afterEach(() => { + Object.defineProperty(window.location, "reload", { value: original, configurable: true }); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + }); + + function shown(state: "visible" | "hidden" = "visible") { + Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); + act(() => { + document.dispatchEvent(new window.Event("visibilitychange")); + }); + } + + async function renderPage(routes: Parameters[0]) { + const fetchMock = mockGlobalFetch(routes); + const view = render( + +

workspace page

+
, + ); + expect(await view.findByText("workspace page")).not.toBeNull(); + return { view, fetchMock, asked: () => fetchMock.mock.calls.length }; + } + + const SERVER_ROUTES = { + ...SERVER_MODE, + "/api/auth/me": () => + signedIn === null ? { status: 503, json: { error: "down" } } : { json: { user: { username: signedIn } } }, + }; + + test("server mode: a different account signed in since then drops the page and reloads", async () => { + // A sign-in can land on a page that records no owner (an unknown address), so no storage + // event reaches this tab; the account is asked again when the tab is shown. + const { view } = await renderPage(SERVER_ROUTES); + signedIn = "user@libredb.org"; + + shown(); + + await waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + expect(view.queryByText("workspace page")).toBeNull(); + }); + + test("server mode: the same account keeps the page", async () => { + const { view, asked } = await renderPage(SERVER_ROUTES); + const before = asked(); + + shown(); + + await waitFor(() => expect(asked()).toBe(before + 1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(reload).not.toHaveBeenCalled(); + expect(view.queryByText("workspace page")).not.toBeNull(); + }); + + test("server mode: a session that has ended since then drops the page and sends the tab to sign in", async () => { + const signIn = mock(() => {}); + const unregister = onSessionEnded(signIn); + const { view } = await renderPage({ + ...SERVER_ROUTES, + "/api/auth/me": () => + signedIn === null + ? { status: 401, json: { authenticated: false } } + : { json: { user: { username: signedIn } } }, + }); + signedIn = null; + + shown(); + + await waitFor(() => expect(signIn).toHaveBeenCalledTimes(1)); + unregister(); + expect(view.queryByText("workspace page")).toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); + expect(reload).not.toHaveBeenCalled(); + }); + + test("server mode: an account that cannot be read now keeps the page as it is", async () => { + const { view, asked } = await renderPage(SERVER_ROUTES); + const before = asked(); + signedIn = null; + + shown(); + + await waitFor(() => expect(asked()).toBe(before + 1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(reload).not.toHaveBeenCalled(); + expect(view.queryByText("workspace page")).not.toBeNull(); + }); + + test("server mode: a tab that is hidden asks nothing", async () => { + const { asked } = await renderPage(SERVER_ROUTES); + const before = asked(); + + shown("hidden"); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(asked()).toBe(before); + expect(reload).not.toHaveBeenCalled(); + }); + + test("local mode: a tab shown again asks nothing", async () => { + const { asked } = await renderPage({ "/api/storage/config": { json: { provider: "local", serverMode: false } } }); + const before = asked(); + + shown(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(asked()).toBe(before); + expect(reload).not.toHaveBeenCalled(); + }); + + test("an answer that arrives after the page is gone, or after it already left, reloads once at most", async () => { + const { view } = await renderPage(SERVER_ROUTES); + signedIn = "user@libredb.org"; + + shown(); + ownerChanged(); + await waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + view.unmount(); + shown(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(reload).toHaveBeenCalledTimes(1); + + function ownerChanged() { + localStorage.setItem("libredb_workspace_owner", "user@libredb.org"); + act(() => { + window.dispatchEvent( + new window.StorageEvent("storage", { key: "libredb_workspace_owner", newValue: "user@libredb.org" }), + ); + }); + } + }); + }); +}); diff --git a/tests/hooks/use-auth.test.ts b/tests/hooks/use-auth.test.ts index 3c121885c..f71055bfb 100644 --- a/tests/hooks/use-auth.test.ts +++ b/tests/hooks/use-auth.test.ts @@ -8,6 +8,7 @@ import { renderHook, act, waitFor } from "@testing-library/react"; import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch"; import { useAuth } from "@/hooks/use-auth"; +import { registerWorkspaceSync } from "@/lib/storage/sign-out"; // ============================================================================= // useAuth Tests @@ -88,6 +89,7 @@ describe("useAuth", () => { test("handleLogout calls /api/auth/logout with POST method", async () => { const fetchMock = mockGlobalFetch({ "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } }, "/api/auth/logout": { ok: true, json: { success: true } }, }); @@ -112,6 +114,7 @@ describe("useAuth", () => { test('handleLogout calls router.push("/login") and router.refresh()', async () => { mockGlobalFetch({ "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } }, "/api/auth/logout": { ok: true, json: { success: true } }, }); @@ -132,6 +135,7 @@ describe("useAuth", () => { test("handleLogout shows success toast on success", async () => { mockGlobalFetch({ "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } }, "/api/auth/logout": { ok: true, json: { success: true } }, }); @@ -154,6 +158,7 @@ describe("useAuth", () => { test("handleLogout shows destructive toast on error", async () => { mockGlobalFetch({ "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } }, }); // Override fetch so logout throws a network error @@ -296,6 +301,12 @@ describe("useAuth", () => { globalThis.fetch = (async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url.includes("/api/auth/me")) return mePromise; + if (url.includes("/api/storage/config")) { + return new Response(JSON.stringify({ provider: "local", serverMode: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } if (url.includes("/api/auth/logout")) { return new Response(JSON.stringify({ success: true }), { status: 200, @@ -341,6 +352,7 @@ describe("useAuth", () => { mockGlobalFetch({ "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } }, "/api/auth/logout": { ok: true, json: { success: true, redirectUrl: "https://auth0.com/v2/logout?client_id=abc" }, @@ -369,9 +381,10 @@ describe("useAuth", () => { // ── Logout with non-ok response still navigates ─────────────────────────── - test("handleLogout navigates even if logout API returns non-ok", async () => { + test("handleLogout says a refused sign-out did not complete and stays on the page", async () => { mockGlobalFetch({ "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } }, "/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } }, }); @@ -385,14 +398,16 @@ describe("useAuth", () => { await result.current.handleLogout(); }); - // The fetch didn't throw, so logout path should succeed - expect(mockRouterPush).toHaveBeenCalledWith("/login"); + expect(mockRouterPush).not.toHaveBeenCalled(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockToastError).toHaveBeenCalledWith("Error", { description: "Failed to logout." }); }); test("auth hook sends requests under basePath and leaves Next router navigation logical", async () => { await withBasePathEnv("/~/libredb", async () => { const fetchMock = mockGlobalFetch({ "/~/libredb/api/auth/me": { json: { user: { role: "user" } } }, "/~/libredb/api/auth/logout": { json: { success: true } }, + "/~/libredb/api/storage/config": { json: { provider: "local", serverMode: false } }, }); const { result, unmount } = renderHook(() => useAuth()); try { @@ -407,4 +422,152 @@ describe("useAuth", () => { } }); }); + + // ── Sign-out and this browser's copy of the workspace ───────────────────── + + describe("sign-out and the browser copy", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }])); + localStorage.setItem("libredb_workspace_tabs_v1:c1", "[]"); + localStorage.setItem("libredb_workspace_owner", "user@libredb.org"); + }); + + afterEach(() => { + localStorage.clear(); + }); + + test("server mode: pending pushes go out, then the session ends, then the copy is cleared", async () => { + const order: string[] = []; + mockGlobalFetch({ + "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } }, + "/api/auth/logout": () => { + order.push(`logout:${localStorage.getItem("libredb_connections")}`); + return { ok: true, json: { success: true } }; + }, + }); + const unregister = registerWorkspaceSync({ + flush: async () => { + order.push(`push:${localStorage.getItem("libredb_connections")}`); + }, + resume: () => {}, + }); + + const { result } = renderHook(() => useAuth()); + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + await act(async () => { + await result.current.handleLogout(); + }); + unregister(); + + const copy = JSON.stringify([{ id: "c1" }]); + expect(order).toEqual([`push:${copy}`, `logout:${copy}`]); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_tabs_v1:c1")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + expect(mockRouterPush).toHaveBeenCalledWith("/login"); + }); + + test("server mode: a push that does not land ends the session, keeps the copy for this account and says so", async () => { + const fetchMock = mockGlobalFetch({ + "/api/auth/me": { ok: true, json: { user: { role: "user", username: "user@libredb.org" } } }, + "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } }, + "/api/auth/logout": { ok: true, json: { success: true } }, + }); + const unregister = registerWorkspaceSync({ + flush: async () => { + throw new Error("Unsaved changes could not be saved to server storage"); + }, + resume: () => {}, + }); + + const { result } = renderHook(() => useAuth()); + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + await act(async () => { + await result.current.handleLogout(); + }); + unregister(); + + expect(fetchMock.mock.calls.some((call) => String(call[0]).includes("/api/auth/logout"))).toBe(true); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org"); + expect(mockToastSuccess).toHaveBeenCalledWith("Logged out", { + description: + "Some changes could not be saved to server storage. This browser keeps them and saves them when this account signs in here again; a different account signing in here first discards them.", + }); + expect(mockRouterPush).toHaveBeenCalledWith("/login"); + }); + + test("server mode: a sign-out refused after which no session is left clears the copy and goes to sign in", async () => { + let signedIn = true; + mockGlobalFetch({ + "/api/auth/me": () => + signedIn + ? { ok: true, json: { user: { role: "user", username: "user@libredb.org" } } } + : { ok: false, status: 401, json: { authenticated: false } }, + "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } }, + "/api/auth/logout": () => { + signedIn = false; + return { ok: false, status: 500, json: { error: "Server error" } }; + }, + }); + + const { result } = renderHook(() => useAuth()); + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + await act(async () => { + await result.current.handleLogout(); + }); + + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + expect(mockToastError).not.toHaveBeenCalled(); + expect(mockRouterPush).toHaveBeenCalledWith("/login"); + }); + + test("server mode: a sign-out the server refused keeps the copy", async () => { + mockGlobalFetch({ + "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } }, + "/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } }, + }); + + const { result } = renderHook(() => useAuth()); + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + await act(async () => { + await result.current.handleLogout(); + }); + + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org"); + }); + + test("local mode: the copy stays", async () => { + mockGlobalFetch({ + "/api/auth/me": { ok: true, json: { user: { role: "user" } } }, + "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } }, + "/api/auth/logout": { ok: true, json: { success: true } }, + }); + + const { result } = renderHook(() => useAuth()); + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + await act(async () => { + await result.current.handleLogout(); + }); + + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_tabs_v1:c1")).toBe("[]"); + expect(mockRouterPush).toHaveBeenCalledWith("/login"); + }); + }); }); diff --git a/tests/hooks/use-tab-manager.test.ts b/tests/hooks/use-tab-manager.test.ts index a71928867..d9d128a9f 100644 --- a/tests/hooks/use-tab-manager.test.ts +++ b/tests/hooks/use-tab-manager.test.ts @@ -8,6 +8,7 @@ import { mockToastDefault, mockToastDismiss, mockToastError } from "../helpers/m import "../helpers/mock-navigation"; import { useTabManager, PREVIEW_PAGE_SIZE } from "@/hooks/use-tab-manager"; +import { holdWorkspaceOwner } from "@/lib/config/base-path"; import type { DatabaseConnection } from "@/lib/types"; import type { DetailedObject } from "@/lib/db/detailed-object"; import type { DatabaseObject } from "@/lib/db/types"; @@ -767,6 +768,40 @@ describe("useTabManager", () => { // ─── Persistence: Load Effect ─── + test("save in server mode: tabs are not written once another tab hands the copy to a different account", async () => { + holdWorkspaceOwner("ana@libredb.org"); + localStorage.setItem("libredb_workspace_owner", "ana@libredb.org"); + try { + const { result } = renderHook(() => + useTabManager({ activeConnection: null, metadata: null, schema: [], persistWorkspace: true }), + ); + await waitFor(() => { + expect(result.current.tabs).toHaveLength(1); + }); + act(() => { + result.current.addTab(); + }); + await waitFor( + () => { + expect(localStorage.getItem("libredb_workspace_tabs_v1:default")).not.toBeNull(); + }, + { timeout: 2000 }, + ); + + localStorage.removeItem("libredb_workspace_tabs_v1:default"); + localStorage.setItem("libredb_workspace_owner", "bob@libredb.org"); + act(() => { + result.current.addTab(); + }); + await new Promise((r) => setTimeout(r, 700)); + + expect(localStorage.getItem("libredb_workspace_tabs_v1:default")).toBeNull(); + } finally { + holdWorkspaceOwner(null); + localStorage.removeItem("libredb_workspace_owner"); + } + }); + test("load — empty storage with persistWorkspace defaults to DEFAULT_TAB", async () => { // No data in localStorage for this key const { result } = renderHook(() => diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts index f5b8b7494..e03701212 100644 --- a/tests/isolated/use-storage-sync.test.ts +++ b/tests/isolated/use-storage-sync.test.ts @@ -1,6 +1,6 @@ import "../setup-dom"; -import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, mock, spyOn, beforeEach, afterEach } from "bun:test"; import { renderHook, waitFor, act, cleanup } from "@testing-library/react"; import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch"; @@ -48,9 +48,19 @@ mock.module("@/lib/storage", () => ({ })); import { useStorageSync } from "@/hooks/use-storage-sync"; +import { releaseAccountWorkspace } from "@/lib/storage/sign-out"; +import { workspaceTabsKey } from "@/lib/storage/local-storage"; +import { heldWorkspaceOwner, holdWorkspaceOwner } from "@/lib/config/base-path"; // ── Helpers ───────────────────────────────────────────────────────────────── +const USERNAME = "user@libredb.org"; + +/** GET /api/auth/me for the signed-in account; every server-mode route map carries it. */ +const SIGNED_IN = { + "/api/auth/me": { ok: true, status: 200, json: { authenticated: true, user: { username: USERNAME, role: "user" } } }, +}; + function setupLocalMode() { return mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "local", serverMode: false } }, @@ -60,6 +70,7 @@ function setupLocalMode() { function setupServerMode(extraRoutes: Record = {}) { return mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: ["connections"] } }, "/api/storage": { ok: true, status: 200, json: { connections: [{ id: "server-c1" }] } }, ...extraRoutes, @@ -86,6 +97,7 @@ describe("useStorageSync", () => { afterEach(() => { restoreGlobalFetch(); cleanup(); + holdWorkspaceOwner(null); }); // ── Mode discovery ────────────────────────────────────────────────────── @@ -252,6 +264,7 @@ describe("useStorageSync", () => { mockStorage.getConnections.mockReturnValue([{ id: "test" }]); mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": () => { throw new Error("migrate down"); }, @@ -342,6 +355,7 @@ describe("useStorageSync", () => { localStorage.setItem("libredb_server_migrated", "true"); mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage": { ok: false, status: 500, json: { error: "DB error" } }, }); @@ -360,6 +374,7 @@ describe("useStorageSync", () => { localStorage.setItem("libredb_server_migrated", "true"); mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage": () => { throw new Error("pull down"); }, @@ -441,6 +456,35 @@ describe("useStorageSync", () => { expect(JSON.parse(stored!)).toEqual(groups); }); + test("stops writing once another tab hands the copy to a different account mid-pull", async () => { + const write = localStorage.setItem.bind(localStorage); + // Another tab's claim lands between two of this pull's writes. + const setItem = spyOn(localStorage, "setItem").mockImplementation((key: string, value: string) => { + write(key, value); + if (key === "libredb_connections") write("libredb_workspace_owner", "admin@libredb.org"); + }); + try { + setupServerMode({ + "/api/storage": { + ok: true, + status: 200, + json: { connections: [{ id: "server-c1" }], history: [{ id: "server-h1" }] }, + }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + } finally { + setItem.mockRestore(); + } + + expect(localStorage.getItem("libredb_connections")).toBe(JSON.stringify([{ id: "server-c1" }])); + expect(localStorage.getItem("libredb_history")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org"); + }); + test("removes active_connection_id from localStorage when server returns null", async () => { localStorage.setItem("libredb_server_migrated", "true"); localStorage.setItem("libredb_active_connection_id", "stale"); @@ -465,6 +509,7 @@ describe("useStorageSync", () => { localStorage.setItem("libredb_server_migrated", "true"); const fetchMock = mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } }, "/api/storage": { ok: true, status: 200, json: {} }, "/api/storage/connections": { ok: true, status: 200, json: { ok: true } }, @@ -509,6 +554,7 @@ describe("useStorageSync", () => { let attempts = 0; const fetchMock = mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } }, "/api/storage/connections": () => { attempts += 1; @@ -556,6 +602,7 @@ describe("useStorageSync", () => { let attempts = 0; mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } }, "/api/storage/connections": () => { attempts += 1; @@ -636,6 +683,7 @@ describe("useStorageSync", () => { mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } }, "/api/storage/connections": async () => { connectionsAttempts += 1; @@ -703,6 +751,7 @@ describe("useStorageSync", () => { mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } }, "/api/storage/connections": async () => { attempts += 1; @@ -738,6 +787,7 @@ describe("useStorageSync", () => { // Use a request handler that returns 500 specifically for PUT /connections const fetchMock = mockGlobalFetch({ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, "/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } }, "/api/storage/connections": { ok: false, status: 500, json: { error: "Write failed" } }, "/api/storage": { ok: true, status: 200, json: {} }, @@ -910,4 +960,679 @@ describe("useStorageSync", () => { expect(result.current.lastSyncedAt).toBeInstanceOf(Date); }); }); + + // ── The browser copy belongs to the signed-in account ───────────────── + + describe("the browser copy belongs to the signed-in account", () => { + const PREVIOUS = "admin@libredb.org"; + const FORM_CONNECTION = { id: "form-1", name: "Warehouse", password: "previous-account-password" }; + + /** The previous account's browser copy, as a sign-in on this browser finds it. */ + function seedPreviousCopy(owner: string | null, migrated: boolean) { + localStorage.setItem("libredb_connections", JSON.stringify([FORM_CONNECTION])); + localStorage.setItem("libredb_history", JSON.stringify([{ id: "h1", query: "SELECT 1" }])); + localStorage.setItem("libredb_active_connection_id", FORM_CONNECTION.id); + localStorage.setItem(workspaceTabsKey(FORM_CONNECTION.id), JSON.stringify([{ id: "t1", query: "SELECT 2" }])); + if (owner !== null) localStorage.setItem("libredb_workspace_owner", owner); + if (migrated) localStorage.setItem("libredb_server_migrated", "2026-10-01"); + } + + /** Every request body the hook sent: PUT /api/storage/* and POST /api/storage/migrate. */ + function sentBodies(fetchMock: FetchMock): string[] { + return (fetchMock.mock.calls as unknown[][]) + .map((c) => (c[1] as RequestInit | undefined)?.body) + .filter((body): body is string => typeof body === "string"); + } + + beforeEach(() => { + // The facade reads the browser copy itself, so a push carries exactly what is stored. + mockStorage.getConnections.mockImplementation(() => + JSON.parse(localStorage.getItem("libredb_connections") ?? "[]"), + ); + }); + + afterEach(() => { + mockStorage.getConnections.mockImplementation(() => [{ id: "c1" }]); + }); + + test("signing in as a different account starts from that account's server data", async () => { + seedPreviousCopy(PREVIOUS, true); + const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_history")).toBeNull(); + expect(localStorage.getItem("libredb_active_connection_id")).toBeNull(); + expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME); + expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate"); + + // A change after the sign-in pushes this account's copy, never the previous one. + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } })); + }); + await waitFor( + () => { + expect(calledPaths(fetchMock)).toContain("/api/storage/connections"); + }, + { timeout: 3000 }, + ); + expect(sentBodies(fetchMock).some((body) => body.includes(FORM_CONNECTION.password))).toBe(false); + }); + + test("a copy with no owner that was already handed to a server account is cleared", async () => { + seedPreviousCopy(null, true); + const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_history")).toBeNull(); + expect(localStorage.getItem("libredb_active_connection_id")).toBeNull(); + expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME); + expect(sentBodies(fetchMock).some((body) => body.includes(FORM_CONNECTION.password))).toBe(false); + }); + + test("a local-mode copy never handed to a server account is migrated into the signed-in account", async () => { + seedPreviousCopy(null, false); + const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(calledPaths(fetchMock)).toContain("/api/storage/migrate"); + expect(sentBodies(fetchMock).some((body) => body.includes(FORM_CONNECTION.id))).toBe(true); + expect(JSON.parse(localStorage.getItem("libredb_connections")!)).toEqual([FORM_CONNECTION]); + expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME); + }); + + test("the same account keeps its copy, and a collection the server lacks stays local", async () => { + seedPreviousCopy(USERNAME, true); + const fetchMock = setupServerMode({ + "/api/storage": { ok: true, status: 200, json: { history: [{ id: "server-h1", query: "SELECT 3" }] } }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(JSON.parse(localStorage.getItem("libredb_connections")!)).toEqual([FORM_CONNECTION]); + expect(JSON.parse(localStorage.getItem("libredb_history")!)).toEqual([{ id: "server-h1", query: "SELECT 3" }]); + expect(localStorage.getItem("libredb_active_connection_id")).toBe(FORM_CONNECTION.id); + expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME); + expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate"); + }); + + test("an unreadable signed-in account leaves the copy unused and unpushed", async () => { + seedPreviousCopy(PREVIOUS, true); + const fetchMock = mockGlobalFetch({ + "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + "/api/auth/me": { ok: false, status: 503, json: { error: "down" } }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.syncError).not.toBeNull(); + }); + + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } })); + }); + await act(async () => await new Promise((r) => setTimeout(r, 700))); + + expect(result.current.isReady).toBe(false); + expect(result.current.isServerMode).toBe(false); + expect( + calledPaths(fetchMock).filter((p) => p.startsWith("/api/storage/") && p !== "/api/storage/config"), + ).toEqual([]); + expect(calledPaths(fetchMock)).not.toContain("/api/storage"); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(PREVIOUS); + }); + + test("a signed-in answer without a username counts as unreadable", async () => { + seedPreviousCopy(PREVIOUS, true); + mockGlobalFetch({ + "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + "/api/auth/me": { ok: true, status: 200, json: { authenticated: true, user: {} } }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.syncError).not.toBeNull(); + }); + expect(result.current.isReady).toBe(false); + }); + + test("a page whose owner check matched another account reloads instead of claiming the copy again", async () => { + const original = window.location.reload; + const reload = mock(() => {}); + Object.defineProperty(window.location, "reload", { value: reload, configurable: true }); + try { + seedPreviousCopy(PREVIOUS, true); + // The page's owner check claimed the copy for PREVIOUS; another tab has since signed in. + holdWorkspaceOwner(PREVIOUS); + const fetchMock = setupServerMode(); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(reload).toHaveBeenCalledTimes(1); + }); + + expect(result.current.isReady).toBe(false); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(PREVIOUS); + expect(heldWorkspaceOwner()).toBe(PREVIOUS); + expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate"); + expect(calledPaths(fetchMock).filter((path) => path === "/api/storage")).toHaveLength(0); + } finally { + Object.defineProperty(window.location, "reload", { value: original, configurable: true }); + } + }); + + test("local mode clears nothing and records no owner", async () => { + seedPreviousCopy(PREVIOUS, true); + setupLocalMode(); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(JSON.parse(localStorage.getItem("libredb_connections")!)).toEqual([FORM_CONNECTION]); + expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).not.toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(PREVIOUS); + }); + + test("local mode on a fresh browser writes no owner", async () => { + setupLocalMode(); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + }); + }); + + // ── Sign-out ────────────────────────────────────────────────────────── + + describe("sign-out", () => { + /** Server mode with the collection routes ahead of the catch-all `/api/storage` pull. */ + function setupSignOut(collectionRoutes: Parameters[0]) { + return mockGlobalFetch({ + "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, + ...collectionRoutes, + }); + } + + /** POST /api/auth/logout as the sign-out paths send it; `status` is what the server answers. */ + function signOutAnswering(status: number, during: () => void = () => {}) { + return async () => { + during(); + return new Response(JSON.stringify({ success: status === 200 }), { status }); + }; + } + + test("pushes a pending change, then ends the session, then clears the browser copy", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + const order: string[] = []; + setupSignOut({ + "/api/storage/history": () => { + order.push(`push:${localStorage.getItem("libredb_workspace_owner")}`); + return { ok: true, status: 200, json: { ok: true } }; + }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }); + // Inside the debounce window: the push is still pending when the sign-out starts. + await act(async () => { + await releaseAccountWorkspace( + signOutAnswering(200, () => order.push(`sign-out:${localStorage.getItem("libredb_workspace_owner")}`)), + ); + }); + order.push(`cleared:${localStorage.getItem("libredb_workspace_owner")}`); + + expect(order).toEqual([`push:${USERNAME}`, `sign-out:${USERNAME}`, "cleared:null"]); + }); + + test("waits for a push already in flight before the session ends", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + let started = false; + let landed = false; + let landedAtSignOut = null as boolean | null; + setupSignOut({ + "/api/storage/history": async () => { + started = true; + await new Promise((r) => setTimeout(r, 150)); + landed = true; + return { ok: true, status: 200, json: { ok: true } }; + }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }); + await waitFor(() => { + expect(started).toBe(true); + }); + await act(async () => { + await releaseAccountWorkspace(signOutAnswering(200, () => (landedAtSignOut = landed))); + }); + + expect(landedAtSignOut).toBe(true); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + }); + + test("a pending push that does not land still ends the session and keeps the copy for this account", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + let signedOut = false; + setupSignOut({ + "/api/storage/history": { ok: false, status: 500, json: { error: "Write failed" } }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }); + let outcome: Awaited> | null = null; + await act(async () => { + outcome = await releaseAccountWorkspace(signOutAnswering(200, () => (signedOut = true))); + }); + + expect(outcome).toMatchObject({ signedOut: true, changesKept: true }); + expect(signedOut).toBe(true); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME); + }); + + /** Signs out of a mounted sync whose pending `history` push does not land; the copy is kept. */ + async function signOutKeepingHistory() { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + localStorage.setItem("libredb_history", JSON.stringify([{ id: "kept", query: "SELECT 'kept'" }])); + setupSignOut({ + "/api/storage/history": { ok: false, status: 503, json: { error: "Storage unreachable" } }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + const first = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(first.result.current.isReady).toBe(true); + }); + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }); + let outcome: Awaited> | null = null; + await act(async () => { + outcome = await releaseAccountWorkspace(signOutAnswering(200)); + }); + expect(outcome).toMatchObject({ signedOut: true, changesKept: true }); + first.unmount(); + restoreGlobalFetch(); + // The next sign-in loads a new page, which holds no claim yet. + holdWorkspaceOwner(null); + } + + test("changes a sign-out could not push are pushed at the same account's next sign-in, before the pull", async () => { + mockStorage.getHistory.mockImplementation(() => JSON.parse(localStorage.getItem("libredb_history") ?? "[]")); + try { + await signOutKeepingHistory(); + + const order: string[] = []; + const fetchMock = setupSignOut({ + "/api/storage/history": async (req: Request) => { + order.push(`push:${await req.text()}`); + return { ok: true, status: 200, json: { ok: true } }; + }, + "/api/storage": () => { + order.push("pull"); + return { ok: true, status: 200, json: { history: [{ id: "server", query: "SELECT 'server'" }] } }; + }, + }); + const second = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(second.result.current.isReady).toBe(true); + }); + + expect(order).toHaveLength(2); + expect(order[0]).toContain("kept"); + expect(order[1]).toBe("pull"); + expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate"); + // Pushed once: nothing is left to push at a later sign-in. + expect(localStorage.getItem("libredb_unsaved_collections")).toBeNull(); + } finally { + mockStorage.getHistory.mockImplementation(() => []); + } + }); + + test("kept changes that still cannot be pushed at the next sign-in are not replaced by the pull", async () => { + await signOutKeepingHistory(); + + setupSignOut({ + "/api/storage/history": { ok: false, status: 503, json: { error: "Storage unreachable" } }, + "/api/storage": { ok: true, status: 200, json: { history: [{ id: "server", query: "SELECT 'server'" }] } }, + }); + const second = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(second.result.current.isReady).toBe(true); + }); + + expect(localStorage.getItem("libredb_history")).toContain("kept"); + }); + + test("a different account signing in drops the kept changes with the rest of the copy", async () => { + await signOutKeepingHistory(); + + const fetchMock = mockGlobalFetch({ + "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + "/api/auth/me": { ok: true, status: 200, json: { user: { username: "other@libredb.org" } } }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + const second = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(second.result.current.isReady).toBe(true); + }); + + expect(calledPaths(fetchMock)).not.toContain("/api/storage/history"); + expect(localStorage.getItem("libredb_history")).toBeNull(); + expect(localStorage.getItem("libredb_unsaved_collections")).toBeNull(); + }); + + test("a sign-out the server refused after a push that did not land leaves nothing for a later sign-in to push", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + setupSignOut({ + "/api/storage/history": { ok: false, status: 503, json: { error: "Storage unreachable" } }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }); + let outcome: Awaited> | null = null; + await act(async () => { + outcome = await releaseAccountWorkspace(signOutAnswering(500)); + }); + + // The session goes on, and this page pushes the change itself. + expect(outcome).toMatchObject({ signedOut: false }); + expect(localStorage.getItem("libredb_unsaved_collections")).toBeNull(); + }); + + test("a pending push refused because the session had ended clears the copy", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", password: "pw" }])); + let sessionEnded = false; + let signedOut = false; + setupSignOut({ + "/api/auth/me": () => + sessionEnded ? { ok: false, status: 401, json: { authenticated: false } } : SIGNED_IN["/api/auth/me"], + "/api/storage/history": () => { + sessionEnded = true; + return { ok: false, status: 401, json: { error: "Session expired. Sign in again.", code: "AUTH_REQUIRED" } }; + }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }); + let outcome: Awaited> | null = null; + await act(async () => { + outcome = await releaseAccountWorkspace(signOutAnswering(200, () => (signedOut = true))); + }); + + expect(outcome).toMatchObject({ signedOut: true, changesKept: false }); + expect(signedOut).toBe(false); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + }); + + test("a retry armed while the sign-out waited for a failing push sends nothing afterwards", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + let pushes = 0; + let started = false; + setupSignOut({ + "/api/storage/history": async () => { + pushes += 1; + started = true; + await new Promise((r) => setTimeout(r, 100)); + return { ok: false, status: 503, json: { error: "Storage unreachable" } }; + }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }); + await waitFor(() => { + expect(started).toBe(true); + }); + await act(async () => { + await releaseAccountWorkspace(signOutAnswering(200)); + }); + const atSignOut = pushes; + await act(async () => await new Promise((r) => setTimeout(r, 2500))); + + expect(atSignOut).toBe(2); + expect(pushes).toBe(atSignOut); + }); + + test("pushes nothing after the browser copy is cleared", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + await act(async () => { + await releaseAccountWorkspace(signOutAnswering(200)); + }); + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } })); + }); + await act(async () => await new Promise((r) => setTimeout(r, 700))); + + expect(calledPaths(fetchMock)).not.toContain("/api/storage/connections"); + }); + + test("a sign-out the server refused keeps the copy and pushes the changes made meanwhile", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + const pushed: string[] = []; + setupSignOut({ + "/api/storage/history": () => { + pushed.push("history"); + return { ok: true, status: 200, json: { ok: true } }; + }, + "/api/storage/connections": () => { + pushed.push("connections"); + return { ok: true, status: 200, json: { ok: true } }; + }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + // A change made while the sign-out request is out is held, not pushed and not dropped. + await act(async () => { + await releaseAccountWorkspace( + signOutAnswering(500, () => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } })); + }), + ); + }); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME); + await waitFor( + () => { + expect(pushed).toContain("history"); + }, + { timeout: 3000 }, + ); + + // And the session goes on syncing as before. + act(() => { + window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } })); + }); + await waitFor( + () => { + expect(pushed).toContain("connections"); + }, + { timeout: 3000 }, + ); + }); + + test("a sign-out the server refused, with nothing held, pushes nothing", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + await act(async () => { + await releaseAccountWorkspace(signOutAnswering(500)); + }); + await act(async () => await new Promise((r) => setTimeout(r, 700))); + + expect( + calledPaths(fetchMock).filter((p) => p.startsWith("/api/storage/") && p !== "/api/storage/config"), + ).toEqual([]); + }); + + test("a sign-out while the first pull is out leaves the browser copy cleared", async () => { + localStorage.setItem("libredb_workspace_owner", USERNAME); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + let pullStarted = false; + let answerPull: () => void = () => {}; + setupSignOut({ + "/api/storage": () => + new Promise((resolve) => { + pullStarted = true; + answerPull = () => resolve({ ok: true, status: 200, json: { connections: [{ id: "server-c1" }] } }); + }), + }); + + const { result } = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(result.current.isServerMode).toBe(true); + expect(pullStarted).toBe(true); + }); + await act(async () => { + await releaseAccountWorkspace(signOutAnswering(200)); + }); + await act(async () => { + answerPull(); + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(result.current.isReady).toBe(true); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBeNull(); + }); + + test("whatever is written after a sign-out never reaches the next account", async () => { + const PREVIOUS = "admin@libredb.org"; + localStorage.setItem("libredb_workspace_owner", PREVIOUS); + localStorage.setItem("libredb_server_migrated", "2026-10-01"); + mockGlobalFetch({ + "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + "/api/auth/me": { ok: true, status: 200, json: { user: { username: PREVIOUS } } }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + const first = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(first.result.current.isReady).toBe(true); + }); + await act(async () => { + await releaseAccountWorkspace(signOutAnswering(200)); + }); + + // A query that finishes after the sign-out, in this tab or one still open, writes the copy. + localStorage.setItem("libredb_history", JSON.stringify([{ id: "h1", query: "SELECT * FROM prior_account" }])); + first.unmount(); + // The next sign-in loads a new page, which holds no claim yet. + holdWorkspaceOwner(null); + + const bodies: string[] = []; + const fetchMock = mockGlobalFetch({ + "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } }, + ...SIGNED_IN, + "/api/storage/": async (req: Request) => { + bodies.push(await req.text()); + return { ok: true, status: 200, json: { ok: true } }; + }, + "/api/storage": { ok: true, status: 200, json: {} }, + }); + const second = renderHook(() => useStorageSync()); + await waitFor(() => { + expect(second.result.current.isReady).toBe(true); + }); + + expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate"); + expect(bodies.some((body) => body.includes("prior_account"))).toBe(false); + expect(localStorage.getItem("libredb_history")).toBeNull(); + expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME); + }); + }); }); diff --git a/tests/unit/components/object-source-drafts.test.ts b/tests/unit/components/object-source-drafts.test.ts index 96384b816..d3d4b1a88 100644 --- a/tests/unit/components/object-source-drafts.test.ts +++ b/tests/unit/components/object-source-drafts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { DRAFT_BUDGET_CHARACTERS, DRAFT_KEY, @@ -10,6 +10,8 @@ import { } from "@/components/object-source/source-drafts"; import { SOURCE_CHARACTER_LIMIT } from "@/lib/db/object-kinds"; import type { ObjectEditRevision } from "@/lib/db/types"; +import { holdWorkspaceOwner } from "@/lib/config/base-path"; +import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage"; const BASE: ObjectEditRevision = { check: "guarded", token: "t", basis: "b", scope: "server" }; @@ -289,3 +291,23 @@ describe("the populations the eviction policy is actually for", () => { expect(readDraft(storage, "new")).toBeDefined(); }); }); + +describe("server storage mode: a store that another tab has handed to a different account", () => { + afterEach(() => { + holdWorkspaceOwner(null); + }); + + test("is neither written nor dropped from, and the write says it was unavailable", () => { + const storage = fakeStorage(); + holdWorkspaceOwner("ana@libredb.org"); + storage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org"); + expect(writeDraft(storage, "a", draft("SELECT 1", 1))).toEqual({ ok: true, evicted: [] }); + + storage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org"); + + expect(writeDraft(storage, "b", draft("SELECT 2", 2))).toEqual({ ok: false, reason: "unavailable" }); + dropDraft(storage, "a"); + expect(readDraft(storage, "a")?.text).toBe("SELECT 1"); + expect(readDraft(storage, "b")).toBeUndefined(); + }); +}); diff --git a/tests/unit/config/base-path.test.ts b/tests/unit/config/base-path.test.ts index da4523a8b..f6b1f70db 100644 --- a/tests/unit/config/base-path.test.ts +++ b/tests/unit/config/base-path.test.ts @@ -7,6 +7,10 @@ import { appFetch, currentAppPath, onSessionEnded, + reportSessionEnded, + heldWorkspaceOwner, + holdWorkspaceOwner, + WORKSPACE_OWNER_HEADER, } from "@/lib/config/base-path"; import { sessionRequiredBody } from "@/lib/api/session-ended"; @@ -201,6 +205,14 @@ describe("appFetch session-ended notice", () => { expect(response.bodyUsed).toBe(false); }); + test("reportSessionEnded calls the registered handler, and does nothing without one", () => { + reportSessionEnded(); + const handler = mock(() => {}); + unregister = onSessionEnded(handler); + reportSessionEnded(); + expect(handler).toHaveBeenCalledTimes(1); + }); + test("unregistering removes only the handler it registered", async () => { const first = mock(() => {}); const second = mock(() => {}); @@ -219,6 +231,93 @@ describe("appFetch session-ended notice", () => { }); }); +describe("appFetch and the account this tab claimed the browser copy for", () => { + const originalReload = window.location.reload; + let reload: ReturnType; + + afterEach(() => { + holdWorkspaceOwner(null); + Object.defineProperty(window.location, "reload", { value: originalReload, configurable: true }); + }); + + function sentHeaders(fetchMock: ReturnType): Headers { + const init = fetchMock.mock.calls.at(-1)?.[1] as RequestInit | undefined; + return new Headers(init?.headers); + } + + function answering(response: () => Response) { + const fetchMock = mock<(input: string, init?: RequestInit) => Promise>(async () => response()); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + } + + function watchReload() { + reload = mock(() => {}); + Object.defineProperty(window.location, "reload", { value: reload, configurable: true }); + } + + test("before a claim no owner header is sent, and the arguments pass through untouched", async () => { + const fetchMock = answering(() => json(200, {})); + const init = { method: "PUT", headers: { "Content-Type": "application/json" } }; + + await appFetch("/api/storage/connections", init); + + expect(heldWorkspaceOwner()).toBeNull(); + expect(fetchMock.mock.calls[0]).toEqual(["/api/storage/connections", init]); + }); + + test("while a claim is held every app request names the claimed account, keeping the caller's headers", async () => { + const fetchMock = answering(() => json(200, {})); + holdWorkspaceOwner("ana@libredb.org"); + + await appFetch("/api/storage/connections", { method: "PUT", headers: { "Content-Type": "application/json" } }); + const headers = sentHeaders(fetchMock); + expect(headers.get(WORKSPACE_OWNER_HEADER)).toBe("ana%40libredb.org"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe("PUT"); + + await appFetch("/api/auth/me"); + expect(sentHeaders(fetchMock).get(WORKSPACE_OWNER_HEADER)).toBe("ana%40libredb.org"); + }); + + test("a request that leaves the application never carries it", async () => { + const fetchMock = answering(() => json(200, {})); + holdWorkspaceOwner("ana@libredb.org"); + + await appFetch("https://example.com/x"); + await appFetch("//example.com/x"); + + expect(fetchMock.mock.calls.map((call) => call.length)).toEqual([1, 1]); + }); + + test("an owner mismatch while a claim is held reloads the tab, so the owner check runs again", async () => { + watchReload(); + holdWorkspaceOwner("ana@libredb.org"); + answering(() => json(409, { error: "x", code: "WORKSPACE_OWNER_MISMATCH" })); + + const response = await appFetch("/api/storage/connections"); + + expect(reload).toHaveBeenCalledTimes(1); + expect(response.status).toBe(409); + expect(response.bodyUsed).toBe(false); + }); + + test("any other 409, a body that is not JSON, or no claim held does not reload", async () => { + watchReload(); + holdWorkspaceOwner("ana@libredb.org"); + answering(() => json(409, { error: "conflict", code: "QUERY_ERROR" })); + await appFetch("/api/storage/connections"); + answering(() => new Response("", { status: 409 })); + await appFetch("/api/storage/connections"); + + holdWorkspaceOwner(null); + answering(() => json(409, { error: "x", code: "WORKSPACE_OWNER_MISMATCH" })); + await appFetch("/api/storage/connections"); + + expect(reload).not.toHaveBeenCalled(); + }); +}); + // next.config.ts reads this file, and a config file cannot resolve the alias an import would use: // an import here fails `next build` before it compiles anything ("Cannot find module"). test("base-path.ts stays import-free, so next.config.ts can load it", () => { diff --git a/tests/unit/lib/api/error-codes.test.ts b/tests/unit/lib/api/error-codes.test.ts index 3b8ee887b..45bc490c6 100644 --- a/tests/unit/lib/api/error-codes.test.ts +++ b/tests/unit/lib/api/error-codes.test.ts @@ -31,7 +31,7 @@ const GROUPS = { ], llm: ["LLM_SAFETY", "LLM_AUTH", "LLM_RATE_LIMIT", "LLM_CONFIG", "LLM_UNCONFIGURED", "LLM_STREAM", "LLM_ERROR"], rateLimit: ["RATE_LIMITED"], - session: ["AUTH_REQUIRED"], + session: ["AUTH_REQUIRED", "WORKSPACE_OWNER_MISMATCH"], connectionPolicy: ["CUSTOM_CONNECTIONS_DISABLED"], generic: ["INTERNAL_ERROR", "NETWORK_ERROR"], } as const; @@ -66,6 +66,11 @@ describe("ApiErrorCode", () => { expect(ApiErrorCode.AUTH_REQUIRED).not.toBe(ApiErrorCode.AUTH_ERROR); }); + test("contains the workspace owner mismatch code, distinct from the session-required code", () => { + expect(ApiErrorCode.WORKSPACE_OWNER_MISMATCH).toBe("WORKSPACE_OWNER_MISMATCH"); + expect(ApiErrorCode.WORKSPACE_OWNER_MISMATCH).not.toBe(ApiErrorCode.AUTH_REQUIRED); + }); + test("contains the custom-connections refusal code, distinct from a role refusal's AUTH_ERROR", () => { expect(ApiErrorCode.CUSTOM_CONNECTIONS_DISABLED).toBe("CUSTOM_CONNECTIONS_DISABLED"); expect(ApiErrorCode.CUSTOM_CONNECTIONS_DISABLED).not.toBe(ApiErrorCode.AUTH_ERROR); diff --git a/tests/unit/lib/api/session-ended.test.ts b/tests/unit/lib/api/session-ended.test.ts index 31709cf3f..52079d9ad 100644 --- a/tests/unit/lib/api/session-ended.test.ts +++ b/tests/unit/lib/api/session-ended.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { ApiErrorCode } from "@/lib/api/error-codes"; import { claimSignInRedirect, safeReturnPath, sessionRequiredBody, signInPath } from "@/lib/api/session-ended"; -import { SESSION_REQUIRED_CODE } from "@/lib/config/base-path"; +import { SESSION_REQUIRED_CODE, WORKSPACE_OWNER_MISMATCH_CODE } from "@/lib/config/base-path"; describe("sessionRequiredBody", () => { // base-path.ts writes the code out because next.config.ts reads that file and cannot resolve @@ -10,6 +10,10 @@ describe("sessionRequiredBody", () => { expect(SESSION_REQUIRED_CODE).toBe(ApiErrorCode.AUTH_REQUIRED); }); + test("the owner mismatch code appFetch keys on is the server's", () => { + expect(WORKSPACE_OWNER_MISMATCH_CODE).toBe(ApiErrorCode.WORKSPACE_OWNER_MISMATCH); + }); + test("names the AUTH_REQUIRED code the browser keys on", () => { expect(sessionRequiredBody("Authentication required")).toEqual({ error: "Authentication required", diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts index 829358040..9a8d5d2e9 100644 --- a/tests/unit/lib/storage/local-storage.test.ts +++ b/tests/unit/lib/storage/local-storage.test.ts @@ -1,11 +1,33 @@ -import { describe, test, expect, beforeEach } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; if (typeof globalThis.window === "undefined") { // @ts-expect-error — minimal window stub globalThis.window = globalThis; } -import { readJSON, writeJSON, readString, writeString, remove, getKey } from "@/lib/storage/local-storage"; +import { + readJSON, + writeJSON, + readString, + writeString, + remove, + getKey, + clearAccountWorkspace, + claimAccountWorkspace, + resetAccountWorkspace, + workspaceTabsKey, + removeWorkspaceTabs, + holdsAccountWorkspace, + WORKSPACE_OWNER_KEY, + SERVER_MIGRATED_KEY, + SOURCE_DRAFTS_KEY, + AGENT_THREAD_KEY, + UNSAVED_COLLECTIONS_KEY, + keepUnsavedCollections, + takeUnsavedCollections, +} from "@/lib/storage/local-storage"; +import { STORAGE_COLLECTIONS } from "@/lib/storage/types"; +import { heldWorkspaceOwner, holdWorkspaceOwner } from "@/lib/config/base-path"; describe("local-storage: getKey", () => { test("maps known collection names to libredb_ prefix keys", () => { @@ -116,3 +138,248 @@ describe("local-storage: remove", () => { expect(readString("active_connection_id")).toBeNull(); }); }); + +describe("local-storage: writes while another tab changed whose copy this is", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + holdWorkspaceOwner(null); + }); + + test("before a claim, as in local mode, every write lands whatever the owner key says", () => { + localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org"); + + expect(holdsAccountWorkspace()).toBe(true); + expect(writeJSON("history", [])).toBe(true); + expect(writeString("active_connection_id", "c1")).toBe(true); + }); + + test("server mode: the browser copy is written only while it still belongs to the claimed account", () => { + holdWorkspaceOwner("ana@libredb.org"); + localStorage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org"); + expect(holdsAccountWorkspace()).toBe(true); + expect(writeJSON("history", [{ id: "h1" }])).toBe(true); + localStorage.setItem(workspaceTabsKey("c1"), "[]"); + + localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org"); + + expect(holdsAccountWorkspace()).toBe(false); + expect(writeJSON("history", [{ id: "h2" }])).toBe(false); + expect(writeString("active_connection_id", "c2")).toBe(false); + remove("history"); + removeWorkspaceTabs("c1"); + expect(localStorage.getItem(getKey("history"))).toBe(JSON.stringify([{ id: "h1" }])); + expect(localStorage.getItem(getKey("active_connection_id"))).toBeNull(); + expect(localStorage.getItem(workspaceTabsKey("c1"))).toBe("[]"); + }); + + test("server mode: a copy cleared by a sign-out elsewhere is not written either", () => { + holdWorkspaceOwner("ana@libredb.org"); + resetAccountWorkspace(null); + + expect(writeJSON("connections", [{ id: "c1" }])).toBe(false); + expect(localStorage.getItem(getKey("connections"))).toBeNull(); + }); + + test("the owner is read from the storage it is given", () => { + holdWorkspaceOwner("ana@libredb.org"); + const other = { getItem: (key: string) => (key === WORKSPACE_OWNER_KEY ? "ana@libredb.org" : null) }; + + expect(holdsAccountWorkspace(other)).toBe(true); + expect(holdsAccountWorkspace()).toBe(false); + }); +}); + +describe("local-storage: the collections a sign-out could not push", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + holdWorkspaceOwner(null); + }); + + test("are kept in the copy and handed back once", () => { + holdWorkspaceOwner("ana@libredb.org"); + localStorage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org"); + + expect(takeUnsavedCollections()).toEqual([]); + keepUnsavedCollections(["history", "connections"]); + + expect(takeUnsavedCollections()).toEqual(["history", "connections"]); + expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBeNull(); + expect(takeUnsavedCollections()).toEqual([]); + }); + + test("hand back only the collection names they know, and nothing from a record that is not a list", () => { + holdWorkspaceOwner("ana@libredb.org"); + localStorage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org"); + + localStorage.setItem(UNSAVED_COLLECTIONS_KEY, JSON.stringify(["history", "not_a_collection", 7])); + expect(takeUnsavedCollections()).toEqual(["history"]); + + for (const corrupt of ["{not json", JSON.stringify({ history: true }), JSON.stringify("history")]) { + localStorage.setItem(UNSAVED_COLLECTIONS_KEY, corrupt); + expect(takeUnsavedCollections()).toEqual([]); + expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBeNull(); + } + }); + + test("are neither kept nor taken once the copy belongs to a different account", () => { + holdWorkspaceOwner("ana@libredb.org"); + localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org"); + + keepUnsavedCollections(["history"]); + expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBeNull(); + + localStorage.setItem(UNSAVED_COLLECTIONS_KEY, '["connections"]'); + expect(takeUnsavedCollections()).toEqual([]); + expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBe('["connections"]'); + }); +}); + +describe("local-storage: clearAccountWorkspace", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("the account keys are named once, with their stored spelling", () => { + expect(WORKSPACE_OWNER_KEY).toBe("libredb_workspace_owner"); + expect(SERVER_MIGRATED_KEY).toBe("libredb_server_migrated"); + expect(SOURCE_DRAFTS_KEY).toBe("libredb_source_drafts_v1"); + expect(AGENT_THREAD_KEY).toBe("libredb_agent_thread"); + expect(UNSAVED_COLLECTIONS_KEY).toBe("libredb_unsaved_collections"); + }); + + test("removes every key the signed-in account's browser copy is held under", () => { + for (const collection of STORAGE_COLLECTIONS) localStorage.setItem(getKey(collection), "x"); + localStorage.setItem(workspaceTabsKey("c1"), "[]"); + localStorage.setItem(workspaceTabsKey("seed:sample"), "[]"); + localStorage.setItem(SOURCE_DRAFTS_KEY, "{}"); + localStorage.setItem(AGENT_THREAD_KEY, "{}"); + localStorage.setItem(UNSAVED_COLLECTIONS_KEY, '["history"]'); + localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07"); + localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org"); + + clearAccountWorkspace(); + + expect(localStorage).toHaveLength(0); + }); + + test("keeps the per-browser preferences", () => { + const preferences = { + "editor-line-numbers": "false", + "libredb-theme": "dark", + libredb_star_prompt_query_count: "3", + libredb_star_prompt_handled: "1", + "another-app": "kept", + }; + for (const [key, value] of Object.entries(preferences)) localStorage.setItem(key, value); + localStorage.setItem(getKey("connections"), "[]"); + + clearAccountWorkspace(); + + expect(localStorage.getItem(getKey("connections"))).toBeNull(); + for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value); + }); +}); + +describe("local-storage: resetAccountWorkspace", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("clears the copy and leaves it marked as handed to a server account, with no owner", () => { + localStorage.setItem(getKey("history"), "[]"); + localStorage.setItem(workspaceTabsKey("c1"), "[]"); + localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org"); + + resetAccountWorkspace(null); + + expect(localStorage.getItem(getKey("history"))).toBeNull(); + expect(localStorage.getItem(workspaceTabsKey("c1"))).toBeNull(); + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull(); + }); + + test("records the given owner", () => { + resetAccountWorkspace("user@libredb.org"); + + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org"); + }); +}); + +describe("local-storage: claimAccountWorkspace", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem(getKey("connections"), '[{"id":"c1"}]'); + localStorage.setItem(workspaceTabsKey("default"), "[]"); + }); + + afterEach(() => { + holdWorkspaceOwner(null); + }); + + test("every outcome holds the signed-in account for the requests this tab sends", () => { + claimAccountWorkspace("admin@libredb.org"); + expect(heldWorkspaceOwner()).toBe("admin@libredb.org"); + claimAccountWorkspace("admin@libredb.org"); + expect(heldWorkspaceOwner()).toBe("admin@libredb.org"); + claimAccountWorkspace("user@libredb.org"); + expect(heldWorkspaceOwner()).toBe("user@libredb.org"); + }); + + test("the same owner keeps the copy", () => { + localStorage.setItem(WORKSPACE_OWNER_KEY, "user@libredb.org"); + localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07"); + + claimAccountWorkspace("user@libredb.org"); + + expect(localStorage.getItem(getKey("connections"))).not.toBeNull(); + expect(localStorage.getItem(workspaceTabsKey("default"))).toBe("[]"); + }); + + test("a copy with no owner that was never handed to a server account is kept for the signed-in account", () => { + claimAccountWorkspace("user@libredb.org"); + + expect(localStorage.getItem(getKey("connections"))).not.toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org"); + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull(); + }); + + test("a copy the first account kept and wrote to belongs to it, so a different account starts empty", () => { + localStorage.clear(); + claimAccountWorkspace("admin@libredb.org"); + localStorage.setItem(getKey("threshold_config"), '{"slowQueryMs":10}'); + + claimAccountWorkspace("user@libredb.org"); + + expect(localStorage.getItem(getKey("threshold_config"))).toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org"); + }); + + test("another owner's copy is cleared and the signed-in account becomes its owner", () => { + localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org"); + localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07"); + + claimAccountWorkspace("user@libredb.org"); + + expect(localStorage.getItem(getKey("connections"))).toBeNull(); + expect(localStorage.getItem(workspaceTabsKey("default"))).toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org"); + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull(); + }); + + test("a copy left after a sign-out is cleared, whatever was written to it since", () => { + resetAccountWorkspace(null); + localStorage.setItem(getKey("history"), '[{"id":"h1"}]'); + + claimAccountWorkspace("user@libredb.org"); + + expect(localStorage.getItem(getKey("history"))).toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org"); + }); +}); diff --git a/tests/unit/lib/storage/sign-out.test.ts b/tests/unit/lib/storage/sign-out.test.ts new file mode 100644 index 000000000..490385c76 --- /dev/null +++ b/tests/unit/lib/storage/sign-out.test.ts @@ -0,0 +1,296 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; + +if (typeof globalThis.window === "undefined") { + // @ts-expect-error: minimal window stub + globalThis.window = globalThis; +} + +import { mockGlobalFetch, restoreGlobalFetch } from "../../../helpers/mock-fetch"; +import { registerWorkspaceSync, releaseAccountWorkspace } from "@/lib/storage/sign-out"; +import { SERVER_MIGRATED_KEY, UNSAVED_COLLECTIONS_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage"; + +const CONNECTIONS = JSON.stringify([{ id: "c1", password: "pw" }]); + +/** GET /api/auth/me after the sign-out: the session goes on (200) or has ended (401). */ +type Session = "alive" | "ended"; + +function serverMode(serverModeOn: boolean, session: Session = "alive") { + return mockGlobalFetch({ + "/api/storage/config": { + ok: true, + status: 200, + json: { provider: serverModeOn ? "postgres" : "local", serverMode: serverModeOn }, + }, + "/api/auth/me": + session === "alive" + ? { json: { authenticated: true, user: { username: "admin@libredb.org" } } } + : { status: 401, json: { authenticated: false } }, + }); +} + +/** The POST /api/auth/logout stand-in: records what the copy held when the session ended. */ +function signOutAnswering(status: number, seen: (string | null)[] = []) { + return async () => { + seen.push(localStorage.getItem("libredb_connections")); + return new Response(JSON.stringify({ success: status === 200 }), { status }); + }; +} + +async function networkDown(): Promise { + throw new Error("network down"); +} + +/** A mounted sync whose flush rejects, as one does when a pending collection did not land. */ +function failingSync() { + const calls = { resumed: 0 }; + const unregister = registerWorkspaceSync({ + flush: async () => { + throw new Error("Unsaved changes could not be saved"); + }, + resume: () => (calls.resumed += 1), + }); + return { calls, unregister }; +} + +describe("releaseAccountWorkspace", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("libredb_connections", CONNECTIONS); + localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org"); + }); + + afterEach(() => { + restoreGlobalFetch(); + }); + + test("server mode: pushes what is pending, ends the session, then clears this browser's copy", async () => { + serverMode(true); + const order: string[] = []; + const unregister = registerWorkspaceSync({ + flush: async () => { + order.push(`flush:${localStorage.getItem("libredb_connections")}`); + }, + resume: () => order.push("resume"), + }); + + const result = await releaseAccountWorkspace(async () => { + order.push(`sign-out:${localStorage.getItem("libredb_connections")}`); + return new Response("{}", { status: 200 }); + }); + unregister(); + + expect(result.signedOut).toBe(true); + expect(result.changesKept).toBe(false); + expect(result.response?.ok).toBe(true); + expect(order).toEqual([`flush:${CONNECTIONS}`, `sign-out:${CONNECTIONS}`]); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull(); + // Marked, so whatever is written to the emptied copy later is never taken for local-mode data. + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull(); + }); + + test("server mode with no sync mounted: ends the session, then clears this browser's copy", async () => { + serverMode(true); + const seen: (string | null)[] = []; + const result = await releaseAccountWorkspace(signOutAnswering(200, seen)); + expect(result.signedOut).toBe(true); + expect(seen).toEqual([CONNECTIONS]); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + }); + + test("server mode with no sync mounted: changes an earlier sign-out kept stay with the copy", async () => { + // An editor sign-out could not push them, and this page (the admin dashboard) has no sync to push + // them now: clearing the copy here would lose what that sign-out promised to keep. + serverMode(true); + localStorage.setItem(UNSAVED_COLLECTIONS_KEY, JSON.stringify(["connections"])); + + const result = await releaseAccountWorkspace(signOutAnswering(200)); + + expect(result).toMatchObject({ signedOut: true, changesKept: true }); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBe(JSON.stringify(["connections"])); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org"); + }); + + test("server mode: a push that did not land because the session had ended clears the copy", async () => { + serverMode(true, "ended"); + const seen: (string | null)[] = []; + const { calls, unregister } = failingSync(); + + const result = await releaseAccountWorkspace(signOutAnswering(200, seen)); + unregister(); + + expect(result).toEqual({ signedOut: true, response: null, changesKept: false }); + expect(seen).toEqual([]); + expect(calls.resumed).toBe(0); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull(); + }); + + test("server mode: a push that did not land otherwise still ends the session and keeps the copy for this account", async () => { + serverMode(true); + const seen: (string | null)[] = []; + const { calls, unregister } = failingSync(); + + const result = await releaseAccountWorkspace(signOutAnswering(200, seen)); + unregister(); + + expect(result.signedOut).toBe(true); + expect(result.changesKept).toBe(true); + expect(seen).toEqual([CONNECTIONS]); + expect(calls.resumed).toBe(0); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org"); + }); + + test("server mode: a push that did not land, then a sign-out refused while the session goes on, resumes", async () => { + serverMode(true); + const { calls, unregister } = failingSync(); + + const result = await releaseAccountWorkspace(signOutAnswering(500)); + unregister(); + + expect(result).toEqual({ signedOut: false, response: null, changesKept: false }); + expect(calls.resumed).toBe(1); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + }); + + test("server mode: a push that did not land, then a sign-out that ended the session anyway, keeps the copy", async () => { + serverMode(true, "alive"); + const { unregister } = failingSync(); + const result = await releaseAccountWorkspace(async () => { + // The answer is lost after the server ended the session. + serverMode(true, "ended"); + throw new Error("connection reset"); + }); + unregister(); + + expect(result).toEqual({ signedOut: true, response: null, changesKept: true }); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + }); + + test("server mode: a sign-out the server refused while the session goes on keeps the copy and resumes the sync", async () => { + serverMode(true); + let resumed = 0; + const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) }); + + const result = await releaseAccountWorkspace(signOutAnswering(500)); + unregister(); + + expect(result.signedOut).toBe(false); + expect(resumed).toBe(1); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org"); + }); + + test("server mode: a sign-out request that fails while the session goes on keeps the copy and resumes the sync", async () => { + serverMode(true); + let resumed = 0; + const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) }); + + const result = await releaseAccountWorkspace(networkDown); + unregister(); + + expect(result.signedOut).toBe(false); + expect(resumed).toBe(1); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + }); + + test("server mode: a refused or failed sign-out after which no session is left clears the copy", async () => { + serverMode(true, "ended"); + let resumed = 0; + const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) }); + + const refused = await releaseAccountWorkspace(signOutAnswering(500)); + expect(refused).toEqual({ signedOut: true, response: null, changesKept: false }); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + + localStorage.setItem("libredb_connections", CONNECTIONS); + localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org"); + const failed = await releaseAccountWorkspace(networkDown); + unregister(); + + expect(failed.signedOut).toBe(true); + expect(resumed).toBe(0); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + }); + + test("server mode with no sync mounted: a refused sign-out keeps the copy", async () => { + serverMode(true); + expect((await releaseAccountWorkspace(signOutAnswering(500))).signedOut).toBe(false); + expect((await releaseAccountWorkspace(networkDown)).signedOut).toBe(false); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + }); + + test("local mode: the session ends, nothing is pushed and nothing is cleared", async () => { + serverMode(false); + let pushed = false; + const seen: (string | null)[] = []; + const unregister = registerWorkspaceSync({ + flush: async () => { + pushed = true; + }, + resume: () => {}, + }); + + const result = await releaseAccountWorkspace(signOutAnswering(200, seen)); + unregister(); + + expect(result.signedOut).toBe(true); + expect(pushed).toBe(false); + expect(seen).toEqual([CONNECTIONS]); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org"); + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull(); + }); + + test("local mode: a refused sign-out says so while the session goes on, and clears nothing when it has ended", async () => { + serverMode(false); + expect((await releaseAccountWorkspace(signOutAnswering(500))).signedOut).toBe(false); + + serverMode(false, "ended"); + expect((await releaseAccountWorkspace(signOutAnswering(500))).signedOut).toBe(true); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull(); + }); + + test("an unreadable storage mode fails the sign-out and clears nothing for a copy bound to a server account", async () => { + mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } }); + const seen: (string | null)[] = []; + + await expect(releaseAccountWorkspace(signOutAnswering(200, seen))).rejects.toThrow("HTTP 500"); + expect(seen).toEqual([]); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + }); + + test("an unreadable storage mode counts as local mode for a copy never bound to a server account", async () => { + localStorage.removeItem(WORKSPACE_OWNER_KEY); + mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } }); + const seen: (string | null)[] = []; + + const result = await releaseAccountWorkspace(signOutAnswering(200, seen)); + + expect(result.signedOut).toBe(true); + expect(seen).toEqual([CONNECTIONS]); + expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS); + expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull(); + }); + + test("unregistering a sync that was replaced leaves the newer one in place", async () => { + serverMode(true); + let newer = 0; + const unregisterOlder = registerWorkspaceSync({ flush: async () => {}, resume: () => {} }); + const unregisterNewer = registerWorkspaceSync({ + flush: async () => { + newer += 1; + }, + resume: () => {}, + }); + unregisterOlder(); + + await releaseAccountWorkspace(signOutAnswering(200)); + unregisterNewer(); + + expect(newer).toBe(1); + }); +}); diff --git a/tests/unit/lib/storage/workspace-owner.test.ts b/tests/unit/lib/storage/workspace-owner.test.ts new file mode 100644 index 000000000..0fe542f51 --- /dev/null +++ b/tests/unit/lib/storage/workspace-owner.test.ts @@ -0,0 +1,121 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; + +if (typeof globalThis.window === "undefined") { + // @ts-expect-error: minimal window stub + globalThis.window = globalThis; +} + +import { mockGlobalFetch, restoreGlobalFetch } from "../../../helpers/mock-fetch"; +import { + claimWorkspaceForSignedInAccount, + readSignedInUsername, + SessionEndedError, +} from "@/lib/storage/workspace-owner"; +import { SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage"; + +const SERVER_MODE = { "/api/storage/config": { json: { provider: "postgres", serverMode: true } } }; + +describe("claimWorkspaceForSignedInAccount", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }])); + localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org"); + localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07"); + }); + + afterEach(() => { + restoreGlobalFetch(); + }); + + test("server mode: a different account's copy is cleared and the signed-in account owns it", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "user@libredb.org" } } } }); + + await claimWorkspaceForSignedInAccount(); + + expect(localStorage.getItem("libredb_connections")).toBeNull(); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org"); + }); + + test("server mode: the same account keeps its copy", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } }); + + await claimWorkspaceForSignedInAccount(); + + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + }); + + test("server mode: an unreadable signed-in account throws and keeps the copy unclaimed", async () => { + mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { ok: false, status: 503, json: { error: "down" } } }); + + await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("HTTP 503"); + expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org"); + }); + + test("local mode: nothing changes and the signed-in account is not asked for", async () => { + const fetchMock = mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } }); + + await claimWorkspaceForSignedInAccount(); + + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + expect(fetchMock.mock.calls).toHaveLength(1); + }); + + test("a storage mode that cannot be read throws for a copy that was bound to a server account", async () => { + mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } }); + await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("HTTP 500"); + + localStorage.removeItem(WORKSPACE_OWNER_KEY); + globalThis.fetch = (async () => { + throw new Error("offline"); + }) as unknown as typeof fetch; + await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("offline"); + + localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org"); + localStorage.removeItem(SERVER_MIGRATED_KEY); + await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("offline"); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + }); + + test("a storage mode that cannot be read counts as local mode for a copy never bound to a server account", async () => { + localStorage.removeItem(WORKSPACE_OWNER_KEY); + localStorage.removeItem(SERVER_MIGRATED_KEY); + + mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } }); + await claimWorkspaceForSignedInAccount(); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + + globalThis.fetch = (async () => { + throw new Error("offline"); + }) as unknown as typeof fetch; + await claimWorkspaceForSignedInAccount(); + expect(localStorage.getItem("libredb_connections")).not.toBeNull(); + }); +}); + +describe("readSignedInUsername", () => { + afterEach(() => { + restoreGlobalFetch(); + }); + + test("answers the username of GET /api/auth/me", async () => { + mockGlobalFetch({ "/api/auth/me": { json: { user: { username: "user@libredb.org" } } } }); + expect(await readSignedInUsername()).toBe("user@libredb.org"); + }); + + test("a 401 throws SessionEndedError: the session has ended", async () => { + mockGlobalFetch({ "/api/auth/me": { status: 401, json: { authenticated: false } } }); + const failure = await readSignedInUsername().catch((err: unknown) => err); + expect(failure).toBeInstanceOf(SessionEndedError); + }); + + test("any other failure is not SessionEndedError", async () => { + mockGlobalFetch({ "/api/auth/me": { status: 503, json: { error: "down" } } }); + const failure = await readSignedInUsername().catch((err: unknown) => err); + expect(failure).not.toBeInstanceOf(SessionEndedError); + }); + + test("an answer without a username throws", async () => { + mockGlobalFetch({ "/api/auth/me": { json: { user: { username: "" } } } }); + await expect(readSignedInUsername()).rejects.toThrow("no username"); + }); +});