/** * The builder: that the canvas draws the flow, that selecting a node is what makes * it editable, that editing writes nothing until Save, and that the two safety * behaviours hold (the last flow survives, and a flow that could strand someone * unauthenticated says so). */ import { render, screen, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { MemoryRouter } from "react-router" import { beforeEach, describe, expect, it, vi } from "vitest" import { TooltipProvider } from "@/components/ui/tooltip" import { getActiveFlowId, getLoginFlows, saveLoginFlows, setActiveFlowId, type LoginFlowDefinition, } from "@/lib/login-flow" import { SignInFlowsClient } from "./sign-in-flows-client" /** Instant keystrokes — default userEvent delay times out under publish-smoke load. */ function setupUser() { return userEvent.setup({ delay: null }) } const navigate = vi.fn() vi.mock("react-router", async () => { const actual = await vi.importActual("react-router") return { ...actual, useNavigate: () => navigate } }) function renderBuilder(flows?: LoginFlowDefinition[], activeId?: string) { if (flows) saveLoginFlows(flows) if (activeId) setActiveFlowId(activeId) return render( , ) } const canvas = () => within(screen.getByRole("group", { name: /^canvas for/i })) /** The config now opens in a floating tool window, one node at a time. */ const inspector = () => within(screen.getByRole("dialog", { name: /^Configure / })) /** A flow whose only sign-in step is gated, which is the interesting shape. */ const conditionalAuth: LoginFlowDefinition = { id: "gated", name: "Gated SSO", steps: [ { id: "sso", kind: "auth", method: "sso", showWhen: [ { id: "c1", subject: { kind: "identifier" }, operator: "contains", value: "@bigu.edu", }, ], }, ], } describe("sign-in flow builder", () => { beforeEach(() => { window.localStorage.clear() navigate.mockClear() }) describe("the canvas", () => { it("draws the flow from start to finish", () => { renderBuilder() expect(canvas().getByText("Start")).toBeInTheDocument() expect(canvas().getByText("Username or email")).toBeInTheDocument() expect(canvas().getByRole("button", { name: /password/i })).toBeInTheDocument() expect(canvas().getByText("Signed in")).toBeInTheDocument() }) // A diagram of boxes and lines is unreadable without sight, so the structure // ships as prose too. it("describes itself in prose for screen readers", () => { renderBuilder() expect( screen.getByText(/Step 1, username or email, always runs\./i), ).toBeInTheDocument() }) // The whole point of a canvas: a branch and what it does are visible without // opening a panel, which is exactly what a stack of cards could not do. it("shows a choice step's branches and outcomes without opening anything", async () => { const user = setupUser() renderBuilder(undefined, "product") await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Pick an app" })) const drawn = canvas() expect(drawn.getByRole("button", { name: /clinical education/i })).toBeInTheDocument() expect(drawn.getByRole("button", { name: /exxat one/i })).toBeInTheDocument() expect(drawn.getAllByText(/goes to \/home, Clinical Education only/i)).not.toHaveLength(0) }) it("marks a step conditional and names the condition", () => { renderBuilder([conditionalAuth], "gated") expect( canvas().getByText(/Only when Username or email contains @bigu\.edu/i), ).toBeInTheDocument() }) }) describe("selecting a node", () => { it("puts that step in the properties pane", async () => { const user = setupUser() renderBuilder() // Nothing is selected, so no window is open. expect(screen.queryByRole("dialog", { name: /^Configure / })).not.toBeInTheDocument() await user.click(canvas().getByRole("button", { name: /password/i })) expect(inspector().getByRole("heading", { name: "Password" })).toBeInTheDocument() expect(inspector().getByText("Step 2")).toBeInTheDocument() expect(inspector().getByLabelText("Method")).toBeInTheDocument() }) it("puts a branch in the properties pane", async () => { const user = setupUser() renderBuilder(undefined, "product") await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Pick an app" })) await user.click(canvas().getByRole("button", { name: /clinical education/i })) expect( inspector().getByRole("heading", { name: "Clinical Education" }), ).toBeInTheDocument() expect(inspector().getByLabelText("Grants app")).toBeInTheDocument() }) it("puts what the session gets in the properties pane", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /^Signed in/ })) expect(inspector().getByRole("heading", { name: "Signed in" })).toBeInTheDocument() expect(inspector().getByLabelText("Lands on")).toBeInTheDocument() expect(inspector().getByLabelText("Your App")).toBeInTheDocument() expect(inspector().getByLabelText("More from Exxat")).toBeInTheDocument() expect(inspector().getByRole("radiogroup", { name: "Signs in as" })).toBeInTheDocument() }) }) /** * What a role is, in this builder: not a step, but a statement at the end of the * flow about the workspace the person arrives in. */ describe("what the session gets", () => { it("reads the effects off the canvas without opening anything", async () => { const user = setupUser() renderBuilder() await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Student with two apps" })) const drawn = canvas() expect(drawn.getByText("Clinical Education and Exxat One only")).toBeInTheDocument() expect(drawn.getByText("More from Exxat hidden")).toBeInTheDocument() expect(drawn.getByText("Lands on Products home")).toBeInTheDocument() }) it("says where a role flow lands when that is not home", async () => { const user = setupUser() renderBuilder() await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Student" })) expect(canvas().getByText("Lands on Student home")).toBeInTheDocument() }) it("hides a section on the flow, and writes it on Save", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /^Signed in/ })) await user.click(inspector().getByLabelText("More from Exxat")) // Same rule as every other edit: nothing reaches the sign-in page early. expect(getLoginFlows().find(flow => flow.id === "direct")?.session?.showMoreFromExxat) .not.toBe(false) await user.click(screen.getByRole("button", { name: "Save" })) expect( getLoginFlows().find(flow => flow.id === "direct")?.session?.showMoreFromExxat, ).toBe(false) expect(canvas().getByText("More from Exxat hidden")).toBeInTheDocument() }) /** * One control with three answers, which is the whole of the rule that students * never administer: picking Student is what un-picks Administrator, so there is * no state of this pane where both are true. */ it("makes the workspace role a choice rather than an admin toggle", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /^Signed in/ })) await user.click(inspector().getByRole("radio", { name: "Student" })) expect(inspector().getByRole("radio", { name: "Administrator" })).not.toBeChecked() await user.click(screen.getByRole("button", { name: "Save" })) expect(getLoginFlows().find(flow => flow.id === "direct")?.session?.role).toBe("student") expect(canvas().getByText("Signs in as a student")).toBeInTheDocument() }) /** * The pair of identities, which is an amendment to the role rather than a * fourth thing the flow hands out. Read back on the canvas for the same reason * every other claim is: the author should not have to open a pane to find out * that this flow puts two doors on the product card. */ it("hands out both identities, and the canvas says so", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /^Signed in/ })) await user.click(inspector().getByRole("radio", { name: "Student" })) await user.click(inspector().getByRole("checkbox", { name: /also opens as school/i })) await user.click(screen.getByRole("button", { name: "Save" })) expect(getLoginFlows().find(flow => flow.id === "direct")?.session?.opensAs).toEqual([ "student", "member", ]) expect(canvas().getByText("Opens as student or Teaching Assistant")).toBeInTheDocument() }) // The console is the one identity that cannot be half of a pair, so the // control is not offered beside it at all. it("offers no pair to an administrator", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /^Signed in/ })) expect(inspector().getByRole("radio", { name: "Administrator" })).toBeChecked() expect(inspector().queryByRole("checkbox", { name: /also opens as/i })).not.toBeInTheDocument() }) it("grants two apps from one flow", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /^Signed in/ })) await user.click(inspector().getByLabelText("Clinical Education")) await user.click(inspector().getByLabelText("Exxat One")) await user.click(screen.getByRole("button", { name: "Save" })) expect(getLoginFlows().find(flow => flow.id === "direct")?.session?.products).toEqual([ "exxat-prism", "exxat-one-schools", ]) }) // Every flow ends somewhere, so there is nothing to delete here. The toolbar // button would be a control with no meaning. it("offers no delete for the end of the flow", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /^Signed in/ })) expect(inspector().queryByRole("button", { name: /^Delete/ })).not.toBeInTheDocument() }) }) // The window is non-modal, which is the point: the canvas stays live behind it, // so you can click straight from one node to the next. describe("the inspector window", () => { it("swaps to the next node without being closed first", async () => { const user = setupUser() renderBuilder(undefined, "product") await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Pick an app" })) await user.click(canvas().getByRole("button", { name: /^Choice/ })) expect(inspector().getByRole("heading", { name: "Choice" })).toBeInTheDocument() await user.click(canvas().getByRole("button", { name: /clinical education/i })) expect( inspector().getByRole("heading", { name: "Clinical Education" }), ).toBeInTheDocument() expect(inspector().queryByLabelText("Question")).not.toBeInTheDocument() }) it("closes from its toolbar", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /password/i })) await user.click(inspector().getByRole("button", { name: /close the inspector/i })) expect(screen.queryByRole("dialog", { name: /^Configure / })).not.toBeInTheDocument() }) it("deletes the selected step from its toolbar", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /password/i })) await user.click(inspector().getByRole("button", { name: "Delete step" })) await user.click(screen.getByRole("button", { name: "Save" })) expect(getLoginFlows().find(flow => flow.id === "direct")!.steps).toHaveLength(0) // Deleting what it was editing has to close it, not leave it on a ghost. expect(screen.queryByRole("dialog", { name: /^Configure / })).not.toBeInTheDocument() }) }) // Sign-in is the one surface you cannot recover from inside the app, so a // half-typed flow must not be what it runs. describe("saving", () => { it("writes nothing until Save", async () => { const user = setupUser() renderBuilder() const name = screen.getByLabelText("Name") await user.clear(name) await user.type(name, "Renamed") expect(getLoginFlows().some(flow => flow.name === "Renamed")).toBe(false) expect(screen.getByText("Unsaved changes.")).toBeInTheDocument() await user.click(screen.getByRole("button", { name: "Save" })) expect(getLoginFlows().some(flow => flow.name === "Renamed")).toBe(true) }) it("keeps Save inert until something changes", () => { renderBuilder() expect(screen.getByRole("button", { name: "Save" })).toBeDisabled() }) it("has one flow selector, so two of them cannot disagree", () => { renderBuilder() expect(screen.queryByLabelText("Runs on the sign-in page")).not.toBeInTheDocument() expect(screen.getAllByLabelText("Flow")).toHaveLength(1) }) it("says which flow runs at sign-in, and only of that flow", async () => { const user = setupUser() renderBuilder(undefined, "direct") expect(screen.getByText("Runs at sign-in")).toBeInTheDocument() await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Faculty" })) expect(screen.queryByText("Runs at sign-in")).not.toBeInTheDocument() }) /** * The regression that made every role look identical: which flow runs used to * live in a second picker whose value sat in the draft, so choosing a role * applied nothing until Save and leaving the page any other way discarded it. * One selector now, and trying it is what points the runtime. */ it("points the sign-in page at the flow you are looking at", async () => { const user = setupUser() renderBuilder(undefined, "direct") await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Faculty" })) await user.click(screen.getByRole("button", { name: "Try this flow" })) expect(getActiveFlowId()).toBe("role-faculty") }) it("saves the draft on the way out when you try it", async () => { const user = setupUser() renderBuilder() const name = screen.getByLabelText("Name") await user.clear(name) await user.type(name, "Tried") await user.click(screen.getByRole("button", { name: "Try this flow" })) expect(getLoginFlows().some(flow => flow.name === "Tried")).toBe(true) }) }) describe("flow management", () => { async function openFlowMenu(user: ReturnType) { await user.click(screen.getByRole("button", { name: "Flow" })) } it("adds a flow that starts with a password step", async () => { const user = setupUser() renderBuilder() await openFlowMenu(user) await user.click(screen.getByRole("menuitem", { name: /new flow/i })) await user.click(screen.getByRole("button", { name: "Save" })) const created = getLoginFlows().find(flow => flow.name === "New flow")! expect(created.steps).toHaveLength(1) expect(created.steps[0]).toMatchObject({ kind: "auth", method: "password" }) }) it("duplicates under a new id, so edits do not bleed", async () => { const user = setupUser() renderBuilder() await openFlowMenu(user) await user.click(screen.getByRole("menuitem", { name: /duplicate/i })) await user.click(screen.getByRole("button", { name: "Save" })) const flows = getLoginFlows() const copy = flows.find(flow => flow.name === "Straight in copy")! expect(copy.id).not.toBe("direct") expect(copy.steps).toEqual(flows.find(flow => flow.id === "direct")!.steps) }) it("hands the active role over when the active flow is deleted", async () => { const user = setupUser() renderBuilder(undefined, "direct") await openFlowMenu(user) await user.click(screen.getByRole("menuitem", { name: /delete flow/i })) await user.click(screen.getByRole("button", { name: "Save" })) const flows = getLoginFlows() expect(flows.some(flow => flow.id === "direct")).toBe(false) expect(flows.some(flow => flow.id === getActiveFlowId())).toBe(true) }) it("refuses to delete the last flow", async () => { const user = setupUser() renderBuilder([conditionalAuth], "gated") await openFlowMenu(user) expect(screen.getByRole("menuitem", { name: /delete flow/i })).toHaveAttribute( "aria-disabled", "true", ) }) }) describe("editing steps", () => { it("adds a step from the toolbar", async () => { const user = setupUser() renderBuilder() await user.click(screen.getByRole("button", { name: /add choice/i })) await user.click(screen.getByRole("button", { name: "Save" })) const flow = getLoginFlows().find(current => current.id === "direct")! expect(flow.steps).toHaveLength(2) expect(flow.steps[1].kind).toBe("choice") }) it("inserts a step at a connector", async () => { const user = setupUser() renderBuilder() const inserts = canvas().getAllByRole("button", { name: "Insert a step here" }) await user.click(inserts[0]) await user.click(screen.getByRole("button", { name: "Save" })) const flow = getLoginFlows().find(current => current.id === "direct")! expect(flow.steps[0].kind).toBe("choice") expect(flow.steps[1]).toMatchObject({ kind: "auth" }) }) it("switches an auth step to single sign-on", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /password/i })) await user.click(inspector().getByLabelText("Method")) await user.click(screen.getByRole("option", { name: "Single sign-on" })) await user.click(screen.getByRole("button", { name: "Save" })) expect(getLoginFlows().find(flow => flow.id === "direct")!.steps[0]).toMatchObject({ method: "sso", }) }) /** * A branch can also say who signs in, for the flow where one person has two * identities and only they know which one today. The canvas has to read it * back, or the author has to open every branch to find out what the fan does. */ it("gives a branch its own role, and the canvas says so", async () => { const user = setupUser() renderBuilder(undefined, "product") await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Pick an app" })) await user.click(canvas().getByRole("button", { name: /clinical education/i })) await user.click(inspector().getByLabelText("Opens as")) await user.click(screen.getByRole("option", { name: "Student" })) await user.click(screen.getByRole("button", { name: "Save" })) const step = getLoginFlows().find(flow => flow.id === "product")!.steps[1] expect(step.kind === "choice" && step.options[0].role).toBe("student") expect( canvas().getAllByText(/goes to \/home, Clinical Education only, opens as a student/i), ).not.toHaveLength(0) }) it("hands the role back to the flow when the branch names nobody", async () => { const user = setupUser() renderBuilder(undefined, "role") await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Pick a role" })) await user.click(canvas().getByRole("button", { name: /^Student/ })) await user.click(inspector().getByLabelText("Opens as")) await user.click(screen.getByRole("option", { name: "Whoever the flow signs in" })) await user.click(screen.getByRole("button", { name: "Save" })) const step = getLoginFlows().find(flow => flow.id === "role")!.steps[1] expect(step.kind === "choice" && "role" in step.options[0]).toBe(false) }) // Two is what makes it a choice. One branch is a question with a single answer. it("refuses to take a choice below two branches", async () => { const user = setupUser() renderBuilder(undefined, "product") await user.click(screen.getByLabelText("Flow")) await user.click(screen.getByRole("option", { name: "Pick an app" })) await user.click(canvas().getByRole("button", { name: /clinical education/i })) expect(inspector().getByRole("button", { name: /delete branch/i })).toBeDisabled() }) }) describe("conditions", () => { it("adds one to a step, and the canvas says so", async () => { const user = setupUser() renderBuilder() await user.click(canvas().getByRole("button", { name: /password/i })) await user.click(inspector().getByRole("button", { name: /add condition/i })) await user.type(inspector().getByLabelText("Value"), "@bigu.edu") expect( canvas().getByText(/Only when Username or email contains @bigu\.edu/i), ).toBeInTheDocument() }) // The guarantee lives in the runtime, but the author deserves to know. it("warns when every sign-in step is conditional", () => { renderBuilder([conditionalAuth], "gated") expect( screen.getByText(/some people would reach the end without signing in/i), ).toBeInTheDocument() expect(canvas().getByText(/Password, added as a fallback/i)).toBeInTheDocument() }) it("stops warning once an unconditional sign-in step exists", async () => { const user = setupUser() renderBuilder([conditionalAuth], "gated") await user.click(screen.getByRole("button", { name: /add sign-in/i })) expect( screen.queryByText(/some people would reach the end without signing in/i), ).not.toBeInTheDocument() }) }) // Conditions are worth nothing if you cannot tell whether one fires, and you // cannot look at a sign-in page while signed in. describe("the tester", () => { it("marks a step skipped for an email its condition rejects", async () => { const user = setupUser() renderBuilder([conditionalAuth], "gated") await user.type(screen.getByLabelText("Try an email"), "dce@other.org") expect(canvas().getByText("Skipped")).toBeInTheDocument() }) it("marks nothing skipped for an email it accepts", async () => { const user = setupUser() renderBuilder([conditionalAuth], "gated") await user.type(screen.getByLabelText("Try an email"), "dce@bigu.edu") expect(canvas().queryByText("Skipped")).not.toBeInTheDocument() }) it("says so when a condition cannot be settled from the email alone", async () => { const user = setupUser() const flow: LoginFlowDefinition = { id: "chained", name: "Chained", steps: [ { id: "auth", kind: "auth", method: "password" }, { id: "role", kind: "choice", heading: "Who are you?", options: [ { id: "student", label: "Student", description: "", icon: "fa-light fa-user", grantsProduct: null, outcome: { kind: "continue" }, }, { id: "school", label: "School", description: "", icon: "fa-light fa-school", grantsProduct: null, outcome: { kind: "continue" }, }, ], }, { id: "app", kind: "choice", heading: "Which app?", options: [ { id: "a", label: "A", description: "", icon: "fa-light fa-a", grantsProduct: null, outcome: { kind: "land", path: "/home" }, }, { id: "b", label: "B", description: "", icon: "fa-light fa-b", grantsProduct: null, outcome: { kind: "land", path: "/home" }, }, ], showWhen: [ { id: "c", subject: { kind: "answer", stepId: "role" }, operator: "is", value: "school", }, ], }, ], } renderBuilder([flow], "chained") await user.type(screen.getByLabelText("Try an email"), "dce@school.edu") expect(canvas().getByText("Depends on an answer")).toBeInTheDocument() }) }) it("links to the sign-in page", async () => { const user = setupUser() renderBuilder() await user.click(screen.getByRole("button", { name: /open the sign-in page/i })) expect(navigate).toHaveBeenCalledWith("/login") }) })