src/components/auth/auth-form.test.tsx
Rama main · réplica de lectura del repositorio original.
import { afterEach, describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AuthForm } from "./auth-form";
import { memoryStorage, useStorage } from "@/test/memory-storage";
import { usePointer } from "@/test/pointer";
// What only a render can reach: which of the two "a link is on its way" paths
// remember the destination. The storage itself is unit-tested in
// `lib/auth-redirect.test.ts`, so it's mocked to a spy here.
//
// `vi.hoisted` because `vi.mock` is lifted above every other statement in the
// file, so a plain `const` here would not exist yet when the factory runs.
const {
rememberPostAuthRedirect,
requestEmailLink,
verifyEmailCode,
signInWithPasskey,
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
startAuthentication,
cancelCeremony,
requestSignInOptions,
router,
} = vi.hoisted(() => ({
rememberPostAuthRedirect: vi.fn(),
requestEmailLink: vi.fn(),
verifyEmailCode: vi.fn(),
signInWithPasskey: vi.fn(),
browserSupportsWebAuthn: vi.fn<() => boolean>(),
browserSupportsWebAuthnAutofill: vi.fn<() => Promise<boolean>>(),
startAuthentication: vi.fn(),
cancelCeremony: vi.fn(),
requestSignInOptions: vi.fn(),
// One stable object, per `DECISIONS.md` - a fresh router per call re-runs
// every effect that depends on it.
router: { push: vi.fn() },
}));
// The sanitizer and the default destination stay real - the passkey ceremony and
// the "check your email" card both route through them. Only the storage write is
// a spy.
vi.mock("@/lib/auth-redirect", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
rememberPostAuthRedirect,
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ requestEmailLink, verifyEmailCode, signInWithPasskey }),
}));
// The "check your email" card routes with the router once a code verifies. Its own
// behaviour is covered in `check-email-card.test.tsx`; what these tests need from
// it is only that it is handed the right request id.
vi.mock("next/navigation", () => ({ useRouter: () => router }));
// Pressing the Google button leaves the page entirely - it navigates to Google's
// authorization endpoint - and none of that is what these tests are about.
vi.mock("./google-auth-button", () => ({ GoogleAuthButton: () => null }));
// The real hook runs against these, so what these tests exercise is this form's
// own wiring into it - which method is offered, and when the armed ceremony is
// stood down. The ceremony's own behaviour is pinned in
// `hooks/usePasskeySignIn.test.tsx`.
vi.mock("@simplewebauthn/browser", () => ({
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
startAuthentication,
WebAuthnAbortService: { cancelCeremony },
WebAuthnError: class extends Error {},
}));
vi.mock("@/lib/api/passkeys", () => ({
passkeysAPI: { requestSignInOptions },
}));
// Captured before any test can fake the clock, so the one unavoidable real wait
// below has something real to wait on.
const realSetTimeout = globalThis.setTimeout;
beforeEach(() => {
vi.clearAllMocks();
// `window.localStorage` doesn't work under this runner as jsdom provides it -
// see `test/memory-storage.ts`. Nothing this form renders reads it any more,
// which is itself asserted below, and that assertion needs a real object to
// spy on rather than the `undefined` the runner would otherwise hand it.
useStorage(memoryStorage());
requestEmailLink.mockResolvedValue({ message: "sent", request_id: "req-1" });
verifyEmailCode.mockResolvedValue(true);
// The default is a browser with no WebAuthn at all, so every test that isn't
// about passkeys sees exactly the form it always did.
browserSupportsWebAuthn.mockReturnValue(false);
browserSupportsWebAuthnAutofill.mockResolvedValue(true);
requestSignInOptions.mockResolvedValue({
flow_id: "flow-1",
options: { challenge: "abc" },
});
startAuthentication.mockResolvedValue({ id: "credential-id" });
signInWithPasskey.mockResolvedValue(true);
});
afterEach(() => {
vi.useRealTimers();
});
async function requestLink(redirectTo: string | null, via?: string) {
const user = userEvent.setup();
render(<AuthForm redirectTo={redirectTo} via={via} />);
await user.type(screen.getByLabelText("Email"), "diver@example.com");
// Anchored, so it can't also match "Sign in with a passkey" beside it.
await user.click(screen.getByRole("button", { name: /^sign in$/i }));
await screen.findByText("Check your email");
return user;
}
// The code field is six single-character inputs in a `role="group"` (Base UI's
// one-time-password field), so there is no one element to type into or read back
// - focus walks itself from box to box as digits land. `check-email-card.test.tsx`
// holds the behaviour; these two only need the digits in and the value back out.
const codeBoxes = () =>
screen.getAllByRole("textbox", { name: /^Character \d of 6$/ });
const typedCode = () =>
codeBoxes()
.map((box) => (box as HTMLInputElement).value)
.join("");
async function typeCode(
user: ReturnType<typeof userEvent.setup>,
digits: string,
) {
await user.click(codeBoxes()[0]);
await user.keyboard(digits);
}
// Runs out the 30s resend cooldown without waiting 30 seconds.
//
// Two awkward constraints meet here. Testing Library's async helpers don't
// recognise Vitest's fake clock, so anything faked while a `findBy`/`waitFor` is
// outstanding hangs until the test times out - which is why the clock is only
// faked between them, never around them. And the first tick is already pending
// on the real clock by the time this is called, so that one seconds has to
// actually elapse; the twenty-nine after it are scheduled on the fake clock and
// can be stepped through. One second per `act`, because each tick is scheduled
// by the effect that runs after the previous tick's re-render.
async function runOutCooldown() {
vi.useFakeTimers();
await act(async () => {
await new Promise((resolve) => realSetTimeout(resolve, 1100));
});
for (let second = 0; second < 30; second++) {
await act(async () => {
vi.advanceTimersByTime(1000);
});
}
vi.useRealTimers();
}
describe("AuthForm heading", () => {
// The landing page's hero introduces the form itself, so a heading inside the
// card would only say the same thing twice one level down.
it("renders no heading when the page didn't ask for one", async () => {
render(<AuthForm redirectTo={null} />);
await screen.findByLabelText("Email");
expect(
screen.queryByRole("heading", { name: /sign in/i }),
).not.toBeInTheDocument();
});
// `/signin` is one of the few chrome-free routes that does not already fail
// axe's `page-has-heading-one`, and moving its heading into the card is what
// could have cost it that - so the level is asserted, not just the text.
it("makes the title the page's h1 when one is given", async () => {
render(
<AuthForm redirectTo={null} title="Sign in" description="No password." />,
);
const heading = await screen.findByRole("heading", { name: "Sign in" });
expect(heading.tagName).toBe("H1");
expect(screen.getByText("No password.")).toBeInTheDocument();
});
// And it has to survive the swap. `CheckEmailCard` replaces this component
// outright, so without the level travelling with it the page would lose its
// only `h1` the moment a link went out.
it("hands the h1 on to the card that replaces it", async () => {
const user = userEvent.setup();
render(<AuthForm redirectTo={null} title="Sign in" />);
await user.type(screen.getByLabelText("Email"), "diver@example.com");
await user.click(screen.getByRole("button", { name: /^sign in$/i }));
const heading = await screen.findByRole("heading", {
name: "Check your email",
});
expect(heading.tagName).toBe("H1");
});
// `/join`'s hero names its link in the card, one level under the page's `h1`,
// and the card that replaces the form keeps that level rather than jumping.
it("renders the title at the level asked for, before and after the swap", async () => {
const user = userEvent.setup();
render(
<AuthForm redirectTo={null} title="Invited from Reddit" titleAs="h2" />,
);
expect(
(await screen.findByRole("heading", { name: "Invited from Reddit" }))
.tagName,
).toBe("H2");
await user.type(screen.getByLabelText("Email"), "diver@example.com");
await user.click(screen.getByRole("button", { name: /^sign in$/i }));
const heading = await screen.findByRole("heading", {
name: "Check your email",
});
expect(heading.tagName).toBe("H2");
});
// On the landing page it stays an `h3`, nested under the hero's own `h1`.
it("leaves that card an h3 where the page has its own heading", async () => {
const user = userEvent.setup();
render(<AuthForm redirectTo={null} />);
await user.type(screen.getByLabelText("Email"), "diver@example.com");
await user.click(screen.getByRole("button", { name: /^sign in$/i }));
const heading = await screen.findByRole("heading", {
name: "Check your email",
});
expect(heading.tagName).toBe("H3");
});
});
describe("AuthForm", () => {
// The whole swap, end to end: the button the diver pressed is unmounted with
// the form around it, so something has to claim the focus it leaves behind -
// and the code is the only thing this card asks for.
it("leaves the caret in the code field once the link is on its way", async () => {
await requestLink(null);
expect(codeBoxes()[0]).toHaveFocus();
});
it("remembers where the diver was headed when requesting a link", async () => {
await requestLink("/dives/abc");
expect(rememberPostAuthRedirect).toHaveBeenCalledWith("/dives/abc");
});
// Called even with nothing to remember, which is what clears a destination
// abandoned earlier rather than letting it hijack this sign-in.
it("clears any earlier destination when there's none to store", async () => {
await requestLink(null);
expect(rememberPostAuthRedirect).toHaveBeenCalledWith(null);
});
// The resent link gets a fresh lifetime from the backend, so the destination
// needs a fresh one too - see the comment on the call.
it("re-stamps the destination when the link is resent", async () => {
const user = await requestLink("/dives/abc");
rememberPostAuthRedirect.mockClear();
await runOutCooldown();
await user.click(screen.getByRole("button", { name: /^resend link$/i }));
await waitFor(() =>
expect(rememberPostAuthRedirect).toHaveBeenCalledWith("/dives/abc"),
);
expect(requestEmailLink).toHaveBeenCalledTimes(2);
});
// Every sign-in not on a join link sends the address alone.
it("asks for a link with no join link unless it is on one", async () => {
await requestLink(null);
expect(requestEmailLink).toHaveBeenCalledWith(
"diver@example.com",
undefined,
);
});
// Each request is a row of its own and the gate reads `via` off the one that is
// redeemed, so a resent link without it would be refused as uninvited.
it("sends the join link with the first request and with every resend", async () => {
const user = await requestLink(null, "scubaboard");
await runOutCooldown();
await user.click(screen.getByRole("button", { name: /^resend link$/i }));
await waitFor(() => expect(requestEmailLink).toHaveBeenCalledTimes(2));
for (const call of requestEmailLink.mock.calls) {
expect(call).toEqual(["diver@example.com", "scubaboard"]);
}
});
// The API's refusal of a join link removed since the page loaded is a sentence
// of its own, and the form shows it as it arrives.
it("shows the API's sentence for a join link that is no longer live", async () => {
requestEmailLink.mockRejectedValue({
response: {
status: 403,
data: {
detail:
"This join link is no longer active. You can request an invitation from the home page.",
},
},
});
const user = userEvent.setup();
render(<AuthForm redirectTo={null} via="reddit" />);
await user.type(screen.getByLabelText("Email"), "diver@example.com");
await user.click(screen.getByRole("button", { name: /^sign in$/i }));
expect(
await screen.findByText(/this join link is no longer active/i),
).toBeInTheDocument();
});
// A resend supersedes the request row the previous email was about, so the code
// being typed has to be verified against the *new* one. Getting this wrong is
// invisible until a diver resends and then types the code they were sent second,
// which is the ordinary way this screen is used.
it("verifies the code against the resent link's request, not the first one", async () => {
const user = await requestLink(null);
requestEmailLink.mockResolvedValue({
message: "sent",
request_id: "req-2",
});
await runOutCooldown();
await user.click(screen.getByRole("button", { name: /^resend link$/i }));
await screen.findByText("Link resent - check your email.");
await typeCode(user, "481052");
expect(verifyEmailCode).toHaveBeenCalledWith("req-2", "481052");
});
// The code in the previous email no longer signs anyone in, so leaving it typed
// would only lead the diver into spending one of five attempts on it.
//
// Four digits, not six, and that is the whole point of the case. Six auto-submit
// the instant the last one lands, which leaves the card mid-verify - so a resend
// arriving on a *complete* code is a state no diver types their way into, and a
// test that used one would be asserting against a card that had already signed
// in. Half-typed is the only way the boxes are still full of a dead code when
// the resend lands, which is why this is the case worth pinning.
it("clears a half-typed code when the link is resent", async () => {
const user = await requestLink(null);
await typeCode(user, "4810");
// Both halves of "the resend is what cleared it": the digits really are in
// the boxes first, and nothing went out - so the failure path, which also
// clears, cannot be what empties them below.
expect(typedCode()).toBe("4810");
expect(verifyEmailCode).not.toHaveBeenCalled();
await runOutCooldown();
await user.click(screen.getByRole("button", { name: /^resend link$/i }));
await waitFor(() => expect(typedCode()).toBe(""));
expect(verifyEmailCode).not.toHaveBeenCalled();
});
});
describe("AuthForm passkeys", () => {
// The plain-HTTP LAN instance, where the browser exposes no WebAuthn API. The
// method hides itself rather than offering a button that can never work - the
// same shape `GoogleAuthButton` uses for a missing client ID.
it("offers no passkey button on a browser without WebAuthn", async () => {
render(<AuthForm redirectTo={null} />);
await screen.findByLabelText("Email");
expect(
screen.queryByRole("button", { name: /passkey/i }),
).not.toBeInTheDocument();
expect(requestSignInOptions).not.toHaveBeenCalled();
});
it("offers one where the browser has WebAuthn", async () => {
browserSupportsWebAuthn.mockReturnValue(true);
render(<AuthForm redirectTo={null} />);
expect(
await screen.findByRole("button", { name: /sign in with a passkey/i }),
).toBeInTheDocument();
});
// The dropdown is anchored to this field, and the browser will not arm a
// conditional ceremony without the `webauthn` token on it.
it("marks the email field as a passkey autofill target", async () => {
render(<AuthForm redirectTo={null} />);
expect(await screen.findByLabelText("Email")).toHaveAttribute(
"autocomplete",
"username webauthn",
);
});
it("arms the autofill ceremony on mount", async () => {
browserSupportsWebAuthn.mockReturnValue(true);
render(<AuthForm redirectTo={null} />);
await waitFor(() =>
expect(startAuthentication).toHaveBeenCalledWith(
expect.objectContaining({ useBrowserAutofill: true }),
),
);
});
// The "check your email" card replaces the whole form, taking the input the
// ceremony is anchored to with it.
it("stands the ceremony down when the sent card replaces the form", async () => {
browserSupportsWebAuthn.mockReturnValue(true);
await requestLink(null);
expect(cancelCeremony).toHaveBeenCalled();
});
});
// This form names no method this browser signed in with before, and keeps no
// key to read one from. The two halves are worth pinning separately: a returning
// visitor is told nothing, and the form does not go looking. The second is the
// half a rendered assertion cannot see - a build that read the key but rendered
// nothing would pass the first test.
describe("AuthForm and the browser's sign-in history", () => {
it("says nothing about how this browser signed in before", async () => {
render(<AuthForm redirectTo={null} />);
await screen.findByLabelText("Email");
expect(screen.queryByText(/last time you signed in/i)).toBeNull();
// Every method, in its usual order.
expect(screen.getByLabelText("Email")).toBeInTheDocument();
});
it("reads nothing out of this browser's storage", async () => {
const getItem = vi.spyOn(window.localStorage, "getItem");
render(<AuthForm redirectTo={null} />);
await screen.findByLabelText("Email");
expect(getItem).not.toHaveBeenCalled();
});
});
// iOS raises the keyboard only for a focus made during the tap, and the code boxes do
// not exist until the request is back - so the tap on Sign in has to open the number
// pad itself, and the boxes take it over.
describe("AuthForm on a phone", () => {
beforeEach(() => usePointer("coarse"));
const submitPending = async () => {
const user = userEvent.setup();
let send!: (sent: { message: string; request_id: string }) => void;
requestEmailLink.mockReturnValue(
new Promise((resolve) => {
send = resolve;
}),
);
render(<AuthForm redirectTo={null} />);
await user.type(screen.getByLabelText("Email"), "diver@example.com");
await user.click(screen.getByRole("button", { name: /^sign in$/i }));
return send;
};
it("holds the number pad open from the tap until the code boxes take it", async () => {
const send = await submitPending();
expect(document.activeElement).toHaveAttribute("inputmode", "numeric");
await act(async () => send({ message: "sent", request_id: "req-1" }));
await screen.findByText("Check your email");
expect(codeBoxes()[0]).toHaveFocus();
});
it("hands focus back to the email field when the request fails", async () => {
const user = userEvent.setup();
requestEmailLink.mockRejectedValue(new Error("offline"));
render(<AuthForm redirectTo={null} />);
await user.type(screen.getByLabelText("Email"), "diver@example.com");
await user.click(screen.getByRole("button", { name: /^sign in$/i }));
await waitFor(() => expect(screen.getByLabelText("Email")).toHaveFocus());
});
it("leaves focus alone with a mouse", async () => {
usePointer("fine");
await submitPending();
expect(document.activeElement).not.toHaveAttribute("inputmode", "numeric");
});
});
describe("AuthForm resend", () => {
// Inside the tap, like the Sign in above: once the request is back it is over.
it("puts the caret in the first box within the Resend tap", async () => {
const user = await requestLink(null);
await runOutCooldown();
(document.activeElement as HTMLElement).blur();
requestEmailLink.mockReturnValue(new Promise(() => {}));
await user.click(screen.getByRole("button", { name: /^resend link$/i }));
expect(codeBoxes()[0]).toHaveFocus();
});
});