/** * The flow runtime: that `LoginPage` walks whatever steps the active flow * defines, and that a choice option's grant and landing take effect. * * The seeded flows are covered because they are what ships, and an authored flow * (role, then app, via a `continue` outcome) is covered because composing steps * is the reason flows are data at all. */ import { render, screen, waitFor } 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 { getAuthSession } from "@/lib/auth-session" import { SEEDED_FLOWS, flowSession, saveLoginFlows, setActiveFlowId, type FlowSession, type LoginFlowDefinition, } from "@/lib/login-flow" import { DEFAULT_LOGIN_SESSION, getLoginSession, grantedProducts, openAs, setLoginSession, type WorkspaceRole, } from "@/lib/login-session" import { isProductEntitled } from "@/lib/mock/product-catalog" import { ONBOARDING_COMPLETE_KEY, PRODUCTS_HOME_PATH } from "@/lib/post-auth-landing" import { STUDENT_HOME_PATH } from "@/lib/student-shell" import { isWorkspaceAdmin } from "@/lib/workspace-role" import { namespacedKey, setStorageItem } from "@exxatdesignux/ui/lib/persisted-state" import { LoginPage } from "./login-page" /** 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 } }) /** Installs `flows`, activates `activeId`, and renders the sign-in page. */ function renderLogin( activeId: string, { search = "", flows = SEEDED_FLOWS }: { search?: string; flows?: readonly LoginFlowDefinition[] } = {}, ) { saveLoginFlows([...flows]) setActiveFlowId(activeId) return render( , ) } async function submitIdentifier( user: ReturnType, identifier = "dce@school.edu", ) { await user.type(screen.getByLabelText("Username or email"), identifier) await user.click(screen.getByRole("button", { name: "Continue" })) } async function submitPassword(user: ReturnType) { await user.type(screen.getByLabelText("Password"), "anything") await user.click(screen.getByRole("button", { name: "Sign in" })) } async function signInWithPassword(user: ReturnType) { await submitIdentifier(user) await waitFor(() => { expect(screen.getByLabelText("Password")).toBeInTheDocument() }) await submitPassword(user) } describe("sign-in flow runtime", () => { beforeEach(() => { window.localStorage.clear() window.localStorage.setItem(namespacedKey(ONBOARDING_COMPLETE_KEY), "true") navigate.mockClear() }) it("signs straight in when the flow has only an auth step", async () => { const user = userEvent.setup({ delay: null }) renderLogin("direct") await signInWithPassword(user) expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) }) describe("a choice step that grants an app", () => { it("asks after the password, then lands on the products home", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) expect( screen.getByRole("heading", { level: 1, name: /which app are you opening/i }), ).toBeInTheDocument() expect(navigate).not.toHaveBeenCalled() await user.click(screen.getByRole("button", { name: /clinical education/i })) expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) }) // The point of granting: the app you did not pick stops being yours and // becomes something the products home markets back to you. it("entitles only the granted app", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /clinical education/i })) expect(grantedProducts()).toEqual(["exxat-prism"]) expect(isProductEntitled("exxat-prism")).toBe(true) expect(isProductEntitled("exxat-one-schools")).toBe(false) }) it("swaps the entitlement when the other option is picked", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /exxat one/i })) expect(isProductEntitled("exxat-one-schools")).toBe(true) expect(isProductEntitled("exxat-prism")).toBe(false) }) // Design OS is the way back to the catalogue; custom products belong to the // workspace that made them. Neither is something to sell back. it("keeps Design OS and custom products entitled regardless", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /clinical education/i })) expect(isProductEntitled("exxat-design-os")).toBe(true) expect(isProductEntitled("exxat-custom")).toBe(true) }) it("leaves entitlement alone when nothing has been granted", () => { setActiveFlowId("direct") expect(isProductEntitled("exxat-prism")).toBe(true) expect(isProductEntitled("exxat-one-schools")).toBe(true) expect(isProductEntitled("exxat-curriculum-mapping")).toBe(true) expect(isProductEntitled("exxat-compliance")).toBe(true) }) it("drops the grant when the active flow changes", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /clinical education/i })) expect(grantedProducts()).toEqual(["exxat-prism"]) setActiveFlowId("role") expect(grantedProducts()).toBeNull() expect(isProductEntitled("exxat-one-schools")).toBe(true) }) }) describe("a choice step that branches on role", () => { it("sends a student to the student home", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role") await signInWithPassword(user) expect( screen.getByRole("heading", { level: 1, name: /how are you signing in/i }), ).toBeInTheDocument() await user.click(screen.getByRole("button", { name: /student/i })) expect(navigate).toHaveBeenCalledWith(STUDENT_HOME_PATH, { replace: true }) }) /** * The branch says who they are, even though the flow it belongs to does not. * "Pick a role" has one session config for every branch, so before the landing * was read the Student branch opened a session that still administered the * workspace: one typed `/admin` away from the console. */ it("makes that student a student, not just a visitor to their page", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role") await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /student/i })) expect(getLoginSession().role).toBe("student") expect(isWorkspaceAdmin()).toBe(false) }) it("sends a school to the products home", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role") await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /school/i })) expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) }) }) /** * The six role flows. Each is one password step, so what is being tested is not * the walk but the claims: where it lands, which apps it grants, and which of * the home sections it asks for. */ describe("the seeded role flows", () => { /** * A seeded flow as the builder saved it before `role` existed: the boolean it * had instead, and no role at all. Built from the seed rather than written out * so it keeps the steps and grants of the flow it is standing in for. */ function savedBeforeRoles(id: string, adminAccess: boolean): LoginFlowDefinition { const seed = SEEDED_FLOWS.find(flow => flow.id === id) if (!seed?.session) throw new Error(`no seeded session for ${id}`) const session: Record = { ...seed.session, adminAccess } delete session.role return { ...seed, session: session as unknown as FlowSession } } /** `flows` stands in for what this browser has stored, which is not always the seeds. */ async function signInAs( user: ReturnType, id: string, flows?: readonly LoginFlowDefinition[], ) { renderLogin(id, flows ? { flows } : undefined) await signInWithPassword(user) } it("gives faculty the workspace without the console", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-faculty") expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) const session = getLoginSession() expect(session.role).toBe("member") expect(session.showYourApp).toBe(true) expect(session.showMoreFromExxat).toBe(true) // Nothing granted, so entitlement is whatever the workspace has. expect(session.products).toBeNull() expect(isProductEntitled("exxat-prism")).toBe(true) expect(isProductEntitled("exxat-one-schools")).toBe(true) }) it("covers every program for Super Admin", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-super-admin") expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) expect(getLoginSession()).toEqual({ products: null, showYourApp: true, showMoreFromExxat: true, role: "administrator", opensAs: [], administeredProgramIds: null, }) }) it("covers three programs for Admin", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-admin") expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) expect(getLoginSession()).toEqual({ products: null, showYourApp: true, showMoreFromExxat: true, role: "administrator", opensAs: [], administeredProgramIds: ["som", "son", "sph"], }) }) it("lands a student on the student page rather than home", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-student") expect(navigate).toHaveBeenCalledWith(STUDENT_HOME_PATH, { replace: true }) }) // The case the single-grant field could not express: two apps is a different // person from one app, and neither is the whole workspace. it("grants a student with two apps both of them, and no store", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-student-two-apps") expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) const session = getLoginSession() expect(session.products).toEqual(["exxat-prism", "exxat-one-schools"]) expect(session.showMoreFromExxat).toBe(false) expect(isProductEntitled("exxat-prism")).toBe(true) expect(isProductEntitled("exxat-one-schools")).toBe(true) expect(isProductEntitled("exxat-people")).toBe(false) }) /** * Nothing is asked on the way in. The two identities travel with the session as * a pair, and the product card is what asks which one, so the answer can be * different tomorrow without signing out. */ it("closes the console and the store to a student with admin access", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-student-admin") expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) const session = getLoginSession() expect(session.role).toBe("student") expect(session.opensAs).toEqual(["student", "member"]) expect(session.showMoreFromExxat).toBe(false) expect(session.showYourApp).toBe(true) }) it("grants Exam Management only with workspace Directory access", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-exam-workspace") expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) const session = getLoginSession() expect(session.role).toBe("member") expect(session.products).toEqual(["exxat-exam-management"]) expect(session.showMoreFromExxat).toBe(false) expect(session.showYourApp).toBe(true) expect(isProductEntitled("exxat-exam-management")).toBe(true) expect(isProductEntitled("exxat-prism")).toBe(false) expect(isWorkspaceAdmin()).toBe(false) }) /** * The rule the role field exists for. Every student flow says `student`, and * there is no second field that could disagree, so "student who administers * the workspace" cannot be written down here or by hand in storage. */ it.each(["role-student", "role-student-two-apps", "role-student-admin"])( "signs %s in as a student, whatever else it grants", async id => { const user = userEvent.setup({ delay: null }) await signInAs(user, id) expect(getLoginSession().role).toBe("student") expect(isWorkspaceAdmin()).toBe(false) }, ) it("keeps a hand-edited student out of the console", () => { setStorageItem( "demo:login-session:v1", // The combination someone would reach for, spelled the old way and the // new way at once. The role is the only field read, so it wins. JSON.stringify({ products: null, role: "student", adminAccess: true }), ) expect(isWorkspaceAdmin()).toBe(false) }) /** * The console belongs to exactly two of the six, and it is a role rather than * a page: `isWorkspaceAdmin` is what the home tile, the switcher row, and the * `/admin` route all read, so this one assertion covers all three. */ it.each([ ["role-admin", true], ["role-super-admin", true], ["role-faculty", false], ["role-student", false], ["role-student-two-apps", false], ["role-student-admin", false], ])("%s administers the workspace: %s", async (id, allowed) => { const user = userEvent.setup({ delay: null }) await signInAs(user, id) expect(isWorkspaceAdmin()).toBe(allowed) }) // A student flow must not be talked out of the student page by a link // captured before anyone knew who was signing in. it("refuses a deep link that would leave the student page", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role-student", { search: "?next=%2Fprism%2Flibrary" }) await signInWithPassword(user) expect(navigate).toHaveBeenCalledWith(STUDENT_HOME_PATH, { replace: true }) }) it("honours a deep link for a role that lands on the products home", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role-admin", { search: "?next=%2Fprism%2Flibrary" }) await signInWithPassword(user) expect(navigate).toHaveBeenCalledWith("/prism/library", { replace: true }) }) /** * The same flow, whose option honours any link, given a link that leaves the * origin. `?next=` is narrowed where the page reads it, so a crafted link * cannot carry someone off-site the moment they finish typing a password, and * no later caller has to remember to check. */ it.each([ ["an absolute URL", "?next=https%3A%2F%2Fevil.example%2Fsteal"], ["a protocol-relative host", "?next=%2F%2Fevil.example"], ["a backslash the URL parser reads as a second slash", "?next=%2F%5Cevil.example"], ])("drops a deep link that would leave the origin: %s", async (_shape, search) => { const user = userEvent.setup({ delay: null }) renderLogin("role-admin", { search }) await signInWithPassword(user) expect(navigate).toHaveBeenCalledWith(PRODUCTS_HOME_PATH, { replace: true }) }) it("forgets what a role flow granted on the way out", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-student-two-apps") expect(getLoginSession().showMoreFromExxat).toBe(false) setActiveFlowId("role-admin") expect(getLoginSession().showMoreFromExxat).toBe(true) expect(grantedProducts()).toBeNull() }) /** * A session written before the role existed still has to answer. Both older * spellings were booleans that denied the console, and one that silently read * as `administrator` would hand it to the role that had just been denied it. */ it.each(["showAdmin", "adminAccess"])("reads a session that denied via %s", field => { setStorageItem( "demo:login-session:v1", JSON.stringify({ products: null, [field]: false }), ) expect(getLoginSession().role).toBe("member") expect(isWorkspaceAdmin()).toBe(false) }) /** * A *flow* written before the role existed is the harder case, and the one * that shipped broken: the old boolean could say "administers" and "does not" * and had no way to say "student", so a browser holding those copies signed a * student in as staff, with the coordinator's release notes to match. * * The two student flows below carry the booleans they were actually saved * with, which is why the repair cannot read the boolean: for the two-app * student it said `true`. `role-student-admin` is not among them because a * stored copy of that one is replaced outright now, covered below. */ it.each([ ["role-student-two-apps", true], ["role-student", false], ])("signs %s in as a student even when saved as adminAccess: %s", async (id, adminAccess) => { const user = userEvent.setup({ delay: null }) await signInAs(user, id, [savedBeforeRoles(id, adminAccess)]) expect(getLoginSession().role).toBe("student") expect(isWorkspaceAdmin()).toBe(false) }) // The other half of the same rule: a role the old boolean *could* express is // read from the boolean, or repairing the students would quietly undo a real // edit to one of the four flows that are not about students. it("takes a pre-rename Admin flow at its boolean", async () => { const user = userEvent.setup({ delay: null }) await signInAs(user, "role-admin", [savedBeforeRoles("role-admin", false)]) expect(getLoginSession().role).toBe("member") }) }) /** * One human, two identities in the same program: a student who also teaches. * Nobody but them knows which one they are opening Exxat as today, and the * answer is not a sign-in question — it is asked by the product card, so it can * be answered again tomorrow. What sign-in does is hand over the pair. */ describe("a student who also opens as the school", () => { it("asks nothing on the way in and hands over both identities", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role-student-admin") await signInWithPassword(user) expect(screen.queryByRole("button", { name: /as a student/i })).not.toBeInTheDocument() expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) expect(getLoginSession().opensAs).toEqual(["student", "member"]) }) /** Either door, and the console is shut behind both of them. */ it.each(["student", "member"])("opens as %s without the console", role => { setLoginSession({ ...DEFAULT_LOGIN_SESSION, role: "student", opensAs: ["student", "member"], }) openAs(role) expect(getLoginSession().role).toBe(role) expect(isWorkspaceAdmin()).toBe(false) // The pair survives the walk through, or coming back to take the other door // would mean signing out. expect(getLoginSession().opensAs).toEqual(["student", "member"]) }) /** * The only way to hold an identity is a sign-in that granted it. Without this, * "open as" is a console anyone can hand themselves from the browser console. */ it("refuses an identity the session never held", () => { setStorageItem( "demo:login-session:v1", JSON.stringify({ products: null, role: "student", opensAs: ["student", "member"] }), ) openAs("administrator") expect(getLoginSession().role).toBe("student") expect(isWorkspaceAdmin()).toBe(false) }) /** * The combination this whole field exists to make unwritable, written by hand. * A pair is dropped rather than trimmed: a student who could open as an * administrator is not a student with one fewer door, it is a broken record. */ it("drops a hand-written pair that includes the console", () => { setStorageItem( "demo:login-session:v1", JSON.stringify({ products: null, role: "student", opensAs: ["student", "administrator"] }), ) expect(getLoginSession().opensAs).toEqual([]) expect(isWorkspaceAdmin()).toBe(false) }) /** * A branch may still name who signs in — the "Pick a role" flow uses it, and * the runtime has to unwind it like a grant. Pick faculty, back out, pick * student, and the session that opens two steps later must not be a member's. */ it("forgets a role that was backed out of", async () => { const user = userEvent.setup({ delay: null }) const roleThenApp: LoginFlowDefinition = { id: "role-then-app", name: "Role, then app", steps: [ { id: "auth", kind: "auth", method: "password" }, { id: "hat", kind: "choice", heading: "How are you opening Exxat?", options: [ { id: "student", label: "As a student", description: "", icon: "fa-light fa-graduation-cap", grantsProduct: null, role: "student", outcome: { kind: "continue" }, }, { id: "faculty", label: "As faculty", description: "", icon: "fa-light fa-chalkboard-user", grantsProduct: null, role: "member", outcome: { kind: "continue" }, }, ], }, { id: "app", kind: "choice", heading: "Which app are you opening?", options: [ { id: "prism", label: "Clinical Education", description: "", icon: "fa-light fa-hospital-user", grantsProduct: "exxat-prism", outcome: { kind: "land", path: "/home" }, }, ], }, ], } renderLogin("role-then-app", { flows: [roleThenApp] }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /as faculty/i })) await user.click(screen.getByRole("button", { name: /back/i })) await user.click(screen.getByRole("button", { name: /as a student/i })) await user.click(screen.getByRole("button", { name: /clinical education/i })) expect(getLoginSession().role).toBe("student") }) /** * Stored flows replace the seeds, so a browser left on an earlier copy of this * flow keeps it for ever. There are two earlier copies to repair: one that * could only claim a single identity, and one that asked which identity at * sign-in. Both must end up handing over the pair, and neither may leave a * question on screen that no longer exists. */ it.each<[string, (seed: LoginFlowDefinition) => LoginFlowDefinition]>([ [ "claimed one identity", seed => ({ ...seed, session: { ...flowSession(seed), opensAs: [] } }), ], [ "asked at sign-in", seed => ({ ...seed, steps: [ ...seed.steps, { id: "hat", kind: "choice", heading: "How are you opening Exxat?", options: [ { id: "student", label: "As a student", description: "", icon: "fa-light fa-graduation-cap", grantsProduct: null, role: "student", outcome: { kind: "land", path: "/home" }, }, { id: "faculty", label: "As faculty", description: "", icon: "fa-light fa-chalkboard-user", grantsProduct: null, role: "member", outcome: { kind: "land", path: "/home" }, }, ], }, ], session: { ...flowSession(seed), opensAs: [] }, }), ], ])("repairs a stored copy that %s", async (_label, reshape) => { const user = userEvent.setup({ delay: null }) const seed = SEEDED_FLOWS.find(flow => flow.id === "role-student-admin") if (!seed) throw new Error("no seeded student-admin flow") renderLogin("role-student-admin", { flows: [reshape(seed)] }) await signInWithPassword(user) expect(screen.queryByRole("button", { name: /as a student/i })).not.toBeInTheDocument() expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) expect(getLoginSession().opensAs).toEqual(["student", "member"]) }) /** A flow someone has authored their own branch onto is left alone. */ it("leaves an authored branch in place", async () => { const user = userEvent.setup({ delay: null }) const seed = SEEDED_FLOWS.find(flow => flow.id === "role-student-admin") if (!seed) throw new Error("no seeded student-admin flow") const authored: LoginFlowDefinition = { ...seed, steps: [ ...seed.steps, { id: "mine", kind: "choice", heading: "A question I added myself", options: [ { id: "a", label: "Option A", description: "", icon: "fa-light fa-circle-dot", grantsProduct: null, outcome: { kind: "land", path: "/home" }, }, { id: "b", label: "Option B", description: "", icon: "fa-light fa-circle-dot", grantsProduct: null, outcome: { kind: "land", path: "/home" }, }, ], }, ], } renderLogin("role-student-admin", { flows: [authored] }) await signInWithPassword(user) expect( screen.getByRole("heading", { level: 1, name: /a question i added myself/i }), ).toBeInTheDocument() }) }) describe("the single sign-on auth step", () => { const ssoFlow: LoginFlowDefinition = { id: "sso", name: "SSO only", steps: [{ id: "auth", kind: "auth", method: "sso" }], } it("hands off instead of asking for a password", async () => { const user = userEvent.setup({ delay: null }) renderLogin("sso", { flows: [ssoFlow] }) await submitIdentifier(user) expect( screen.getByRole("heading", { level: 1, name: /continue with single sign-on/i }), ).toBeInTheDocument() expect(screen.queryByLabelText("Password")).not.toBeInTheDocument() await user.click(screen.getByRole("button", { name: /continue to your provider/i })) expect(getAuthSession()).toBe("dce@school.edu") expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) }) }) // Composing steps is the reason flows are data. A School option that continues // rather than landing produces "role, then app" with no new step kind. describe("an authored flow that chains two choice steps", () => { const chained: LoginFlowDefinition = { id: "chained", name: "Role then app", steps: [ { id: "auth", kind: "auth", method: "password" }, { id: "role", kind: "choice", heading: "How are you signing in?", options: [ { id: "student", label: "Student", description: "", icon: "fa-light fa-graduation-cap", grantsProduct: null, outcome: { kind: "land", path: STUDENT_HOME_PATH }, }, { id: "school", label: "School", description: "", icon: "fa-light fa-school", grantsProduct: null, outcome: { kind: "continue" }, }, ], }, { id: "app", kind: "choice", heading: "Which app are you opening?", options: [ { id: "prism", label: "Clinical Education", description: "", icon: "fa-light fa-hospital-user", grantsProduct: "exxat-prism", outcome: { kind: "land", path: "/home" }, }, { id: "one", label: "Exxat One", description: "", icon: "fa-light fa-building", grantsProduct: "exxat-one-schools", outcome: { kind: "land", path: "/home" }, }, ], }, ], } it("continues to the app step for a school, then grants that app", async () => { const user = userEvent.setup({ delay: null }) renderLogin("chained", { flows: [chained] }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /school/i })) expect( screen.getByRole("heading", { level: 1, name: /which app are you opening/i }), ).toBeInTheDocument() expect(navigate).not.toHaveBeenCalled() await user.click(screen.getByRole("button", { name: /exxat one/i })) expect(grantedProducts()).toEqual(["exxat-one-schools"]) expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) }) it("skips the app step for a student, who lands instead", async () => { const user = userEvent.setup({ delay: null }) renderLogin("chained", { flows: [chained] }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /student/i })) expect(navigate).toHaveBeenCalledWith(STUDENT_HOME_PATH, { replace: true }) }) it("walks back one step at a time", async () => { const user = userEvent.setup({ delay: null }) renderLogin("chained", { flows: [chained] }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /school/i })) await user.click(screen.getByRole("button", { name: /^back/i })) expect( screen.getByRole("heading", { level: 1, name: /how are you signing in/i }), ).toBeInTheDocument() await user.click(screen.getByRole("button", { name: /^back/i })) expect(screen.getByLabelText("Password")).toBeInTheDocument() }) }) describe("step mechanics", () => { // The session opens at the auth step in every flow, so backing out of a later // choice must not read as a failed sign-in. it("opens the session before asking, and keeps it when you go back", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role") await signInWithPassword(user) expect(getAuthSession()).toBe("dce@school.edu") await user.click(screen.getByRole("button", { name: /^back/i })) expect(screen.getByLabelText("Password")).toBeInTheDocument() expect(getAuthSession()).toBe("dce@school.edu") }) it("returns to the previous step on Escape", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) await user.keyboard("{Escape}") expect(screen.getByLabelText("Password")).toBeInTheDocument() }) it("focuses the first option so a keyboard user starts on it", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) expect(screen.getByRole("button", { name: /clinical education/i })).toHaveFocus() }) it("keeps exactly one h1 on a choice step", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product") await signInWithPassword(user) expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1) }) }) // A deep link captured before a branch can point at a surface the branch just // took away, so how much of it survives depends on the answer. describe("deep links through a branch", () => { it("honours one for a school, whose option restricts nothing", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role", { search: "?next=/prism/library" }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /school/i })) expect(navigate).toHaveBeenCalledWith("/prism/library", { replace: true }) }) it("drops a coordinator link when the answer is Student", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role", { search: "?next=/prism/library" }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /student/i })) expect(navigate).toHaveBeenCalledWith(STUDENT_HOME_PATH, { replace: true }) }) it("honours one inside the app that was granted", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product", { search: "?next=/prism/library" }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /clinical education/i })) expect(navigate).toHaveBeenCalledWith("/prism/library", { replace: true }) }) it("drops one pointing into the app that was not granted", async () => { const user = userEvent.setup({ delay: null }) renderLogin("product", { search: "?next=/prism/library" }) await signInWithPassword(user) await user.click(screen.getByRole("button", { name: /exxat one/i })) expect(navigate).toHaveBeenCalledWith("/home", { replace: true }) }) }) }) /** * Conditions. The interesting cases are the ones where a condition changes which * screens someone sees, and the floor that holds when an author gates every * sign-in step. */ describe("conditional flows", () => { beforeEach(() => { window.localStorage.clear() window.localStorage.setItem(namespacedKey(ONBOARDING_COMPLETE_KEY), "true") navigate.mockClear() }) const gatedSso: LoginFlowDefinition = { id: "gated", name: "SSO for one school", steps: [ { id: "sso", kind: "auth", method: "sso", showWhen: [ { id: "c1", subject: { kind: "identifier" }, operator: "contains", value: "@bigu.edu" }, ], }, { id: "password", kind: "auth", method: "password", showWhen: [ { id: "c2", subject: { kind: "identifier" }, operator: "not_contains", value: "@bigu.edu", }, ], }, ], } it("routes an identifier its condition matches to single sign-on", async () => { const user = userEvent.setup({ delay: null }) renderLogin("gated", { flows: [gatedSso] }) await submitIdentifier(user, "dce@bigu.edu") expect(screen.getByRole("heading", { name: /continue with single sign-on/i })).toBeInTheDocument() expect(screen.queryByLabelText("Password")).not.toBeInTheDocument() }) it("routes an identifier it does not match to the password step", async () => { const user = userEvent.setup({ delay: null }) renderLogin("gated", { flows: [gatedSso] }) await submitIdentifier(user, "dce@other.org") expect(screen.getByLabelText("Password")).toBeInTheDocument() expect( screen.queryByRole("heading", { name: /continue with single sign-on/i }), ).not.toBeInTheDocument() }) it("hides an option whose condition fails", async () => { const user = userEvent.setup({ delay: null }) const flow: LoginFlowDefinition = { id: "staff-only", name: "Staff extra app", steps: [ { id: "auth", kind: "auth", method: "password" }, { id: "app", kind: "choice", heading: "Which app are you opening?", options: [ { id: "clinical", label: "Clinical Education", description: "", icon: "fa-light fa-graduation-cap", grantsProduct: null, outcome: { kind: "land", path: "/home" }, }, { id: "sites", label: "Exxat One", description: "", icon: "fa-light fa-hospital", grantsProduct: null, outcome: { kind: "land", path: "/home" }, showWhen: [ { id: "c", subject: { kind: "identifier" }, operator: "contains", value: "@staff", }, ], }, ], }, ], } renderLogin("staff-only", { flows: [flow] }) await submitIdentifier(user, "student@school.edu") await submitPassword(user) expect(screen.getByRole("button", { name: /clinical education/i })).toBeInTheDocument() // Absent, not disabled. A greyed-out door on a sign-in page is an invitation to // wonder what you did wrong, on the one screen with no way to ask. expect(screen.queryByRole("button", { name: /exxat one/i })).not.toBeInTheDocument() }) it("gates a step on an answer given earlier in the same run", async () => { const user = userEvent.setup({ delay: null }) const flow: LoginFlowDefinition = { id: "chained", name: "Role then app", steps: [ { id: "auth", kind: "auth", method: "password" }, { id: "role", kind: "choice", heading: "How do you use Exxat?", options: [ { id: "student", label: "Student", description: "", icon: "fa-light fa-user", grantsProduct: null, outcome: { kind: "land", path: STUDENT_HOME_PATH }, }, { id: "school", label: "School", description: "", icon: "fa-light fa-school", grantsProduct: null, outcome: { kind: "continue" }, }, ], }, { id: "app", kind: "choice", heading: "Which app are you opening?", options: [ { id: "clinical", label: "Clinical Education", description: "", icon: "fa-light fa-graduation-cap", grantsProduct: "exxat-prism", outcome: { kind: "land", path: "/home" }, }, { id: "sites", label: "Exxat One", description: "", icon: "fa-light fa-hospital", grantsProduct: "exxat-one-schools", outcome: { kind: "land", path: "/home" }, }, ], showWhen: [ { id: "c", subject: { kind: "answer", stepId: "role" }, operator: "is", value: "school" }, ], }, ], } renderLogin("chained", { flows: [flow] }) await submitIdentifier(user, "dce@school.edu") await submitPassword(user) await user.click(screen.getByRole("button", { name: /school/i })) expect( screen.getByRole("heading", { name: /which app are you opening/i }), ).toBeInTheDocument() }) it("skips that step for the answer the condition rejects", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role") await submitIdentifier(user, "student@school.edu") await submitPassword(user) await user.click(screen.getByRole("button", { name: /student/i })) expect(navigate).toHaveBeenCalledWith(STUDENT_HOME_PATH, { replace: true }) }) /** * The floor. A flow can be edited into a shape where nothing authenticates, and * sign-in has to hold anyway, so the runtime asks for a password rather than * trusting the builder's warning. */ it("asks for a password when no auth step applied", async () => { const user = userEvent.setup({ delay: null }) const flow: LoginFlowDefinition = { id: "leaky", name: "Every sign-in gated", steps: [ { id: "sso", kind: "auth", method: "sso", showWhen: [ { id: "c", subject: { kind: "identifier" }, operator: "contains", value: "@bigu.edu" }, ], }, ], } renderLogin("leaky", { flows: [flow] }) await submitIdentifier(user, "stranger@nowhere.test") expect(screen.getByLabelText("Password")).toBeInTheDocument() expect(navigate).not.toHaveBeenCalled() await submitPassword(user) expect(navigate).toHaveBeenCalled() }) it("does not add a password step when an auth step already ran", async () => { const user = userEvent.setup({ delay: null }) renderLogin("gated", { flows: [gatedSso] }) await submitIdentifier(user, "dce@bigu.edu") await user.click(screen.getByRole("button", { name: /continue to your provider/i })) // The password step is conditional on *not* being @bigu.edu, so nothing else // applies and the fallback must not fire on top of a completed SSO step. expect(screen.queryByLabelText("Password")).not.toBeInTheDocument() expect(navigate).toHaveBeenCalled() }) it("forgets an answer when Back leaves the step that gave it", async () => { const user = userEvent.setup({ delay: null }) renderLogin("role") await submitIdentifier(user, "dce@school.edu") await submitPassword(user) await user.click(screen.getByRole("button", { name: /back/i })) expect(screen.getByLabelText("Password")).toBeInTheDocument() }) })