diff --git a/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts b/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts index b491f8e0..36376897 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts @@ -6,6 +6,7 @@ import { expectPreview, openMapper, preview, + withFormats, } from "./mapperHelpers"; /** @@ -42,11 +43,13 @@ async function openWithCsv(page: import("@playwright/test").Page, sample: string const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("csv"); - await page.getByLabel("source delimiter").selectOption(delimiter); - const box = page.getByRole("checkbox", { name: "source header row" }); - if (header) await box.check(); - else await box.uncheck(); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("csv"); + await page.getByLabel("source delimiter").selectOption(delimiter); + const box = page.getByRole("checkbox", { name: "source header row" }); + if (header) await box.check(); + else await box.uncheck(); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(sample); return subscriptionId; @@ -142,9 +145,11 @@ test("writing a delimited file takes its delimiter and header from the target si }) => { await openWithCsv(page, TRACKING, "|", false); - await page.getByLabel("To format").selectOption("csv"); - await page.getByLabel("target delimiter").selectOption(";"); - await page.getByRole("checkbox", { name: "target header row" }).check(); + await withFormats(page, async () => { + await page.getByLabel("To format").selectOption("csv"); + await page.getByLabel("target delimiter").selectOption(";"); + await page.getByRole("checkbox", { name: "target header row" }).check(); + }); const root = await rootListOverTheDocument(page); await page.getByRole("button", { name: "Settings for the list at the root" }).click(); @@ -163,7 +168,7 @@ test("writing a delimited file takes its delimiter and header from the target si test("a nested rule becomes a dotted column", async ({ page }) => { await openWithCsv(page, MOVEMENTS, ",", true); - await page.getByLabel("To format").selectOption("csv"); + await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); const root = await rootListOverTheDocument(page); // A row is flat, so the nesting has to land somewhere. The name box splits on dots, so @@ -176,7 +181,7 @@ test("a nested rule becomes a dotted column", async ({ page }) => { test("a shape a row cannot hold is refused with a reason", async ({ page }) => { await openWithCsv(page, MOVEMENTS, ",", true); - await page.getByLabel("To format").selectOption("csv"); + await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); // A list inside a row. There is no cell that holds one, and inventing a way to fit it — // joining the entries, taking the first — would lose data without a word. @@ -208,9 +213,11 @@ test("the carrier's whole file can be produced, trailer count and all", async ({ // client's ends with a record carrying how many records came before it. await openWithCsv(page, TRACKING, "|", false); - await page.getByLabel("To format").selectOption("csv"); - await page.getByLabel("target delimiter").selectOption("|"); - await page.getByRole("checkbox", { name: "target header row" }).uncheck(); + await withFormats(page, async () => { + await page.getByLabel("To format").selectOption("csv"); + await page.getByLabel("target delimiter").selectOption("|"); + await page.getByRole("checkbox", { name: "target header row" }).uncheck(); + }); const root = await rootListOverTheDocument(page); await page.getByRole("button", { name: "Settings for the list at the root" }).click(); @@ -261,7 +268,7 @@ test("counting is offered inside a list and nowhere else", async ({ page }) => { test("a file can be marked so Excel opens accented names correctly", async ({ page }) => { await openWithCsv(page, MOVEMENTS, ",", true); - await page.getByLabel("To format").selectOption("csv"); + await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); const root = await rootListOverTheDocument(page); await addListField(root, "the root list", "shipment", "ShipmentNumber"); @@ -270,7 +277,9 @@ test("a file can be marked so Excel opens accented names correctly", async ({ pa // The mark itself is invisible, so what is checked is that asking for it changes the // document the server produced rather than that anything looks different. const before = await preview(page).textContent(); - await page.getByRole("checkbox", { name: "write a byte-order mark" }).check(); + await withFormats(page, () => + page.getByRole("checkbox", { name: "write a byte-order mark" }).check(), + ); await expect .poll(async () => (await preview(page).textContent())?.charCodeAt(0), { timeout: 15000 }) .toBe(0xfeff); diff --git a/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts b/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts index c6117af7..5051677e 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts @@ -11,6 +11,7 @@ import { openMapper, saveAndReload, suggestionsFor, + withFormats, } from "./mapperHelpers"; /** @@ -59,7 +60,7 @@ const SOAP_REQUEST = ` page.getByLabel("From format").selectOption("xml")); await page.getByRole("textbox", { name: "Sample source document" }).fill(sample); return subscriptionId; } @@ -120,8 +121,10 @@ test("a mapping that writes XML takes its namespaces from the sample of the outp }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); await buildFromSample( @@ -152,8 +155,10 @@ test("a shape XML cannot hold is refused with a reason, not a broken document", }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); // JSON writes as many top-level keys as it likes; XML has exactly one root element. @@ -179,8 +184,10 @@ test("an element that carries both an attribute and a value maps as two rules", // same convention as reading, in reverse. const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); // The sample needs a value between the tags, not just the attribute: an element with @@ -200,8 +207,10 @@ test("an element that carries both an attribute and a value maps as two rules", test("an attribute can be added to an element by hand, without a sample", async ({ page }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); // Dots separate the levels, so `order.weight.@unit` puts the attribute on `weight` diff --git a/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts b/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts index fe0386e6..c83464e4 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts @@ -63,6 +63,26 @@ export async function openWithSample(page: Page, sample: unknown = SAMPLE): Prom return subscriptionId; } +/** + * Runs `set` with the format panel open, and closes it afterwards. + * + * What the mapping reads and writes sits behind a summary chip rather than in the + * toolbar row, so a test that changes a format has to open the panel first — and the + * panel covers the rules underneath, so it has to be closed before touching them. + */ +export async function withFormats(page: Page, set: () => Promise) { + const chip = page.getByRole("button", { name: "What this mapping reads and writes" }); + const panel = page.getByLabel("From format"); + + // Waited for on both sides because the chip toggles: acting before the panel has + // opened, or opening again before the last one has gone, closes it instead. + await chip.click(); + await expect(panel).toBeVisible({ timeout: 15000 }); + await set(); + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden({ timeout: 15000 }); +} + /** Pastes an output sample into the toolbar panel and builds the rules from it. */ export async function buildFromSample(page: Page, target: unknown) { await page.getByRole("button", { name: "Build from a sample of the output" }).click(); diff --git a/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts b/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts index 07137d34..9d320765 100644 --- a/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts @@ -220,7 +220,9 @@ test("the editor opens for the mapper you picked, not the one that is saved", as // Saved as the old mapper, picking the new one: the new editor, no save in between. await pickOption(page, "mapper adapter", "NativeMapper"); await link.click(); - await expect(page.getByLabel("From format")).toBeVisible({ timeout: 15000 }); + await expect( + page.getByRole("button", { name: "What this mapping reads and writes" }), + ).toBeVisible({ timeout: 15000 }); // And back the other way, which is the same bug reversed. await page.goto(`subscriptions/${subscriptionId}/mapper?mapper=NativeJSONMapper`); diff --git a/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts b/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts index 79d844e7..778df23b 100644 --- a/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts @@ -6,6 +6,7 @@ import { expectPreview, openMapper, preview, + withFormats, } from "./mapperHelpers"; /** @@ -40,7 +41,7 @@ test.beforeEach(async ({ page }) => { test("lays out a one-line XML sample, and the tree still reads it", async ({ page }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); + await withFormats(page, () => page.getByLabel("From format").selectOption("xml")); const sample = page.getByRole("textbox", { name: "Sample source document" }); await sample.fill(MINIFIED_XML); @@ -133,7 +134,7 @@ test("the mapped document is coloured, in whichever format it is written", async // Switching the output to XML colours it as XML, because the mapping declares the // format rather than the pane guessing from the text. - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, () => page.getByLabel("To format").selectOption("xml")); await page.getByRole("textbox", { name: "Output field name" }).first().fill("order"); await expect(preview.locator(".hljs-name").first()).toBeVisible({ timeout: 15000 }); }); diff --git a/SW.Bitween.Web/ClientApp/e2e/session-outage.spec.ts b/SW.Bitween.Web/ClientApp/e2e/session-outage.spec.ts new file mode 100644 index 00000000..34c75e6f --- /dev/null +++ b/SW.Bitween.Web/ClientApp/e2e/session-outage.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; +import { signInAsAdmin } from "./helpers"; + +/** + * A server that cannot answer is not a server saying "signed out". + * + * `getSession` used to swallow every failure and return null, which the guard reads as + * "not signed in" — so a rate-limited or briefly unreachable backend threw people to + * the sign-in page mid-task, with a perfectly good token still in localStorage. That is + * also what made a whole afternoon of rate-limited test runs look like an auth problem. + */ +test.beforeEach(async ({ page }) => { + await signInAsAdmin(page); +}); + +for (const status of [429, 500, 503]) { + test(`a ${status} from the profile call is an outage, not a sign-out`, async ({ page }) => { + await page.route("**/api/accounts/profile", (route) => + route.fulfill({ status, contentType: "application/json", body: "{}" }), + ); + + await page.goto("subscriptions"); + + await expect(page.getByRole("heading", { name: "Can't reach Bitween" })).toBeVisible({ + timeout: 15000, + }); + // The distinction that matters: still signed in, so nothing asks for a password + // and the token is left where it is. + await expect(page).not.toHaveURL(/\/login/); + expect(await page.evaluate(() => localStorage.getItem("access_token"))).toBeTruthy(); + }); +} + +test("a 401 still signs you out, because that one is the server's answer", async ({ page }) => { + // Both the profile read and the silent refresh behind it, so there is nothing left + // to restore the session with — which is a real, unrecoverable sign-out. + await page.route("**/api/accounts/profile", (route) => route.fulfill({ status: 401, body: "" })); + await page.route("**/api/accounts/login", (route) => route.fulfill({ status: 401, body: "" })); + + await page.goto("subscriptions"); + + await expect(page).toHaveURL(/\/login/, { timeout: 15000 }); + await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible(); +}); + +test("the outage screen recovers when the server comes back", async ({ page }) => { + let failing = true; + await page.route("**/api/accounts/profile", (route) => + failing ? route.fulfill({ status: 503, contentType: "application/json", body: "{}" }) : route.fallback(), + ); + + await page.goto("subscriptions"); + await expect(page.getByRole("heading", { name: "Can't reach Bitween" })).toBeVisible({ + timeout: 15000, + }); + + failing = false; + await page.getByRole("button", { name: "Try again" }).click(); + + await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible({ timeout: 15000 }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/session.ts b/SW.Bitween.Web/ClientApp/src/api/http/session.ts index 4a50bdb2..ea9378a1 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/session.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/session.ts @@ -65,6 +65,16 @@ let logoutInFlight: AbortController | null = null; const abortPendingLogout = () => logoutInFlight?.abort(); export const sessionMethods = { + /** + * The session this browser already holds, or null when there genuinely isn't one. + * + * Only an answer from the server counts as "signed out". This used to swallow every + * failure and return null, which reads as "no session" and sends the caller to the + * sign-in page — so a rate-limited or momentarily unreachable server signed people + * out mid-task while their token sat in localStorage, still perfectly good. Anything + * that is not an answer about *this* token is rethrown for the caller to show as the + * outage it is. + */ async getSession(): Promise { // No stored Jwt → anonymous; don't probe the backend (an expired token still // gets refreshed via cookie inside request() on its 401). The token is only @@ -72,9 +82,12 @@ export const sessionMethods = { if (!getToken()) return null; try { return await loadSession(); - } catch { - // Token invalid and no refresh cookie → signed out. Show login, don't fake it. - return null; + } catch (e) { + // Token refused and no refresh cookie left to replace it → signed out for real. + // `request()` has already cleared the token and told the app by this point. + if (e instanceof ApiRequestError && (e.code === "UNAUTHENTICATED" || e.code === "HTTP_403")) + return null; + throw e; } }, diff --git a/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx index 18088f4e..5a479a6f 100644 --- a/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx +++ b/SW.Bitween.Web/ClientApp/src/auth/SessionContext.tsx @@ -24,6 +24,14 @@ interface SessionContextValue { /** Re-fetch the session after profile changes. */ refresh: () => Promise; signOut: () => Promise; + /** + * Set when the server could not be asked whether this browser's token is still good + * — rate limited, down, or unreachable. Distinct from `session === null`, which + * means it answered and the answer was no. + */ + unreachable: boolean; + /** Ask again after `unreachable`. */ + retry: () => void; } const SessionContext = createContext(null); @@ -42,15 +50,32 @@ export function SessionProvider({ children }: { children: ReactNode }) { */ const generation = useRef(0); + const [unreachable, setUnreachable] = useState(false); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => { + setUnreachable(false); + setInitializing(true); + setAttempt((n) => n + 1); + }, []); + useEffect(() => { const startedAt = generation.current; api .getSession() .then((next) => { - if (generation.current === startedAt) setSession(next); + if (generation.current !== startedAt) return; + setSession(next); + setUnreachable(false); + }) + .catch(() => { + // Not "signed out": `getSession` only resolves to null when the server said so. + // Reaching here means it could not be asked, and the token this browser holds + // may well still be good — so keep it and say the server is unreachable rather + // than sending someone to sign in again for a blip. + if (generation.current === startedAt) setUnreachable(true); }) .finally(() => setInitializing(false)); - }, []); + }, [attempt]); const adoptSession = useCallback( (next: Session) => { @@ -150,8 +175,20 @@ export function SessionProvider({ children }: { children: ReactNode }) { adoptSession, refresh, signOut, + unreachable, + retry, }), - [session, initializing, signIn, signInWithMicrosoft, adoptSession, refresh, signOut], + [ + session, + initializing, + signIn, + signInWithMicrosoft, + adoptSession, + refresh, + signOut, + unreachable, + retry, + ], ); return {children}; diff --git a/SW.Bitween.Web/ClientApp/src/auth/guards.tsx b/SW.Bitween.Web/ClientApp/src/auth/guards.tsx index 7136a2f7..bacfbb78 100644 --- a/SW.Bitween.Web/ClientApp/src/auth/guards.tsx +++ b/SW.Bitween.Web/ClientApp/src/auth/guards.tsx @@ -1,13 +1,14 @@ import type { ReactNode } from "react"; import { Navigate, Outlet, useLocation } from "react-router"; -import { Lock } from "lucide-react"; +import { CloudOff, Lock } from "lucide-react"; import type { PermissionKey } from "../api"; import { labelIn, usePermissionCatalog } from "../api/permissions"; +import { Button } from "../components/ui/basics"; import { useSession } from "./SessionContext"; /** Redirects to /login when signed out; shows a splash while checking. */ export function RequireAuth() { - const { session, initializing } = useSession(); + const { session, initializing, unreachable, retry } = useSession(); const location = useLocation(); if (initializing) { @@ -17,6 +18,23 @@ export function RequireAuth() { ); } + // Nobody said this session was over — the server could not be asked. Sending someone + // to sign in here would be a lie, and one that loses the page they were on and invites + // them to re-enter a password they never needed to. + if (unreachable) { + return ( +
+ + + +

Can't reach Bitween

+

+ You are still signed in — the server just didn't answer. This usually clears on its own. +

+ +
+ ); + } if (!session) { return ; } diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx index c555fe02..061f6708 100644 --- a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useNavigate, useParams } from "react-router"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Check, Eraser, Eye, EyeOff, Link2, Redo2, Undo2 } from "lucide-react"; +import { ArrowLeft, Check, ChevronDown, Eraser, Eye, EyeOff, Link2, Redo2, Undo2 } from "lucide-react"; import { api } from "../../api"; import { keys } from "../../api/queryKeys"; import { @@ -26,6 +26,7 @@ import { import { Button, FormError } from "../ui/basics"; import { ConnectionLines, type Connection } from "../ui/ConnectionLines"; import { ConfirmDialog } from "../ui/overlays"; +import { Popover } from "../ui/Popover"; import { Select } from "../ui/forms"; import { BuildFromSample } from "./BuildFromSample"; import { OutputPanel } from "./OutputPanel"; @@ -173,44 +174,13 @@ function Editor({ target, onClose }: { target?: MappingTarget; onClose?: () => v Mapping -
- dispatch({ type: "SET_SOURCE_FORMAT", format })} - /> - {rules.sourceFormat === "csv" && ( - dispatch({ type: "SET_CSV_OPTIONS", side: "source", options })} - /> - )} - - → - - dispatch({ type: "SET_TARGET_FORMAT", format })} - /> - {rules.targetFormat === "csv" && ( - dispatch({ type: "SET_CSV_OPTIONS", side: "target", options })} - /> - )} -
+ -
- -
+ - dispatch({ type: "SET_TEST_PARTNER", partnerId: partner })} - /> + + + v icon={} onClick={() => dispatch({ type: "MATCH_SOURCES" })} /> - {match && } v onClick={() => dispatch({ type: "CLEAR_RULES" })} /> + {match && } +
+ dispatch({ type: "SET_TEST_PARTNER", partnerId: partner })} + /> +