import { afterEach, describe, expect, test, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; 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, renderPage } from "../render.tsx"; const instance = vi.hoisted(() => ({ federationIncoming: true })); vi.mock("../../config/instance.ts", async (importOriginal) => ({ ...await importOriginal(), get FEDERATION_INCOMING() { return instance.federationIncoming; }, })); /** Waits for the sign-in to have left the login page for `path`. */ const landsOn = (path: string) => waitFor(() => expect(currentPath()).toBe(path)); describe("UserLogin", () => { function setup() { const user = userEvent.setup(); renderPage("/login"); 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(); await user.type(await screen.findByPlaceholderText("Username"), "ann"); expect(document.title).toMatch(/^Log in · /); await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}"); await landsOn("/"); 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(); await screen.findByPlaceholderText("Username"); // 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); await waitFor(() => 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(await screen.findByPlaceholderText("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(await screen.findByPlaceholderText("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(await screen.findByPlaceholderText("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 landsOn("/"); }); test("Forgot password? leads to the reset page", async () => { setup(); expect(await screen.findByRole("link", { name: "Forgot password?" })) .toHaveAttribute("href", "/reset-password"); }); test("a sign-in follows ?next= on this site, and only there", async () => { route("POST /api/sessions", () => wire(makeSession())); const user = userEvent.setup(); renderPage(`/login?next=${encodeURIComponent("/authorize?client_id=x")}`); await user.type(await screen.findByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await landsOn("/authorize?client_id=x"); }); test("ignores a ?next= that leaves the site", async () => { route("POST /api/sessions", () => wire(makeSession())); const user = userEvent.setup(); renderPage(`/login?next=${encodeURIComponent("//evil.example")}`); await user.type(await screen.findByPlaceholderText("Username"), "ann"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await landsOn("/"); }); 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("Your gerbeur's URL"), ); await user.type( screen.getByPlaceholderText("Your gerbeur's URL"), " 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", async () => { instance.federationIncoming = false; setup(); expect(await screen.findByPlaceholderText("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( await screen.findByPlaceholderText("Your gerbeur's URL"), "films.example{Enter}", ); expect(await screen.findByRole("alert")).toHaveTextContent( "need an invite", ); }); }); });