import { afterEach, describe, expect, test, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { Route } from "react-router"; import { UserLogin } from "../../pages/UserLogin.tsx"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; import { calls, deferred, fail, route } from "../fetch.ts"; import { makeSession, wire } from "../fixtures.ts"; import { currentPath, renderWithContexts } from "../render.tsx"; const instance = vi.hoisted(() => ({ federationIncoming: true })); vi.mock("../../config/instance.ts", async (importOriginal) => ({ ...await importOriginal(), get FEDERATION_INCOMING() { return instance.federationIncoming; }, })); const home = home page

} />; describe("UserLogin", () => { function setup() { const user = userEvent.setup(); renderWithContexts(, { route: "/login", path: "/login", extraRoutes: home, }); return user; } test("logs in, stores the session and goes home", async () => { const session = makeSession({ username: "ann" }); route("POST /api/sessions", () => wire(session)); const user = setup(); expect(document.title).toMatch(/^Log in · /); await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}"); await screen.findByText("home page"); expect(calls("POST /api/sessions")[0].body).toEqual({ username: "ann", password: "hunter22", }); expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username) .toBe("ann"); }); test("both fields are required", async () => { const user = setup(); // The header has a "Log in" button too; this is the form's. await user.click( document.querySelector( ".auth-card button[type=submit]", )!, ); expect(calls("POST /api/sessions")).toHaveLength(0); expect(screen.getByPlaceholderText("Username")).toHaveAttribute( "aria-invalid", "true", ); }); test("shows the server's refusal", async () => { route("POST /api/sessions", () => fail("Invalid credentials", 401)); const user = setup(); await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}"); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Login failed"); expect(alert).toHaveTextContent("Invalid credentials"); expect(currentPath()).toBe("/login"); }); test("says when the server can't be reached", async () => { route("POST /api/sessions", () => new TypeError("Failed to fetch")); const user = setup(); await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); expect(await screen.findByRole("alert")).toHaveTextContent( /Could not connect/, ); }); test("the button says so while logging in", async () => { const pending = deferred(); route("POST /api/sessions", () => pending.promise); const user = setup(); await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); expect(await screen.findByRole("button", { name: "Logging in…" })) .toBeDisabled(); pending.resolve( new Response( JSON.stringify({ success: true, data: wire(makeSession()) }), ), ); await screen.findByText("home page"); }); test("password reset request", async () => { route("POST /api/password-resets", () => ({})); const user = setup(); await user.click(screen.getByRole("button", { name: "Forgot password?" })); await user.type( screen.getByPlaceholderText("Your email address"), " a@b.c {Enter}", ); await screen.findByText(/you'll receive a reset link/); expect(calls("POST /api/password-resets")[0].body).toEqual({ email: "a@b.c", }); }); test("a failed reset request is reported, not claimed as sent", async () => { route( "POST /api/password-resets", () => fail("Internal error", 500), ); const user = setup(); await user.click(screen.getByRole("button", { name: "Forgot password?" })); await user.type( screen.getByPlaceholderText("Your email address"), "a@b.c{Enter}", ); expect(await screen.findByRole("alert")).toHaveTextContent( "Request failed", ); expect(screen.queryByText(/you'll receive a reset link/)).not .toBeInTheDocument(); }); test("a sign-in follows ?next= on this site, and only there", async () => { route("POST /api/sessions", () => wire(makeSession())); const user = userEvent.setup(); renderWithContexts(, { route: `/login?next=${encodeURIComponent("/authorize?client_id=x")}`, path: "/login", extraRoutes: authorize

} />, }); await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await screen.findByText("authorize"); expect(currentPath()).toBe("/authorize?client_id=x"); }); test("ignores a ?next= that leaves the site", async () => { route("POST /api/sessions", () => wire(makeSession())); const user = userEvent.setup(); renderWithContexts(, { route: `/login?next=${encodeURIComponent("//evil.example")}`, path: "/login", extraRoutes: home, }); await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await screen.findByText("home page"); }); describe("with an account on another instance", () => { const assign = vi.fn(); afterEach(() => { vi.unstubAllGlobals(); assign.mockReset(); instance.federationIncoming = true; }); test("a trusted instance's button, or any address, starts the sign-in there", async () => { vi.stubGlobal("location", { ...globalThis.location, assign }); route("GET /api/federation/peers", () => [{ issuer: "https://music.example", host: "music.example", name: "Music", status: "trusted", createdAt: new Date().toISOString(), }]); route("POST /api/federation/logins", (req) => ({ authorizeUrl: `https://${ (req.body as { instance: string }).instance.replace(/^https:\/\//, "") }/authorize?state=s1`, })); const user = setup(); await user.click( await screen.findByRole("button", { name: /Log in with Music/ }), ); await waitFor(() => expect(assign).toHaveBeenCalledWith( "https://music.example/authorize?state=s1", ) ); // The callback finishes only the sign-in this tab started. expect(sessionStorage.getItem("federationState")).toBe("s1"); expect(calls("POST /api/federation/logins")[0].body).toEqual({ instance: "https://music.example", }); await user.clear( screen.getByPlaceholderText("Its address, like music.example"), ); await user.type( screen.getByPlaceholderText("Its address, like music.example"), " films.example {Enter}", ); await waitFor(() => expect(assign).toHaveBeenLastCalledWith( "https://films.example/authorize?state=s1", ) ); }); test("isn't offered where accounts of other instances don't sign in", () => { instance.federationIncoming = false; setup(); expect(screen.getByPlaceholderText("Username")).toBeInTheDocument(); expect(screen.queryByText(/another gerbeur/)).not.toBeInTheDocument(); expect(calls("GET /api/federation/peers")).toHaveLength(0); }); test("a refusal is shown under the field", async () => { route("GET /api/federation/peers", () => []); route( "POST /api/federation/logins", () => fail("Users of this instance need an invite to join", 403), ); const user = setup(); await user.type( screen.getByPlaceholderText("Its address, like music.example"), "films.example{Enter}", ); expect(await screen.findByRole("alert")).toHaveTextContent( "need an invite", ); }); }); });