feat: verify email addresses and recover forgotten passwords
Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3581ad7c8e
commit
ea28f8aa2e
@@ -0,0 +1,481 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||
|
||||
/**
|
||||
* The host-owned recovery and verification screens (ADR 0038).
|
||||
*
|
||||
* Everything asserted here is a property of the *host's* browser surface: that an unverified account
|
||||
* never reads as authenticated, that a reset token is never persisted or rendered, that a query
|
||||
* string can only ever select among Ekklesia's own screens, and that no failure message carries a
|
||||
* provider detail. The server-side behaviour these screens sit on top of is proven separately, end
|
||||
* to end, in `apps/api/tests/email-verification.integration.ts`.
|
||||
*/
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.history.replaceState({}, "", "/");
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
consoleError.mockRestore();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
interface RecordedCall {
|
||||
readonly url: string;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
interface AuthStub {
|
||||
/** Status for `/api/auth/sign-in/email`. 403 with `EMAIL_NOT_VERIFIED` is the unverified case. */
|
||||
readonly signIn?: () => Response;
|
||||
readonly requestPasswordReset?: () => Response;
|
||||
readonly resetPassword?: () => Response;
|
||||
readonly sendVerificationEmail?: () => Response;
|
||||
}
|
||||
|
||||
interface Platform {
|
||||
readonly fetch: typeof fetch;
|
||||
readonly calls: RecordedCall[];
|
||||
}
|
||||
|
||||
function platform(stub: AuthStub = {}): Platform {
|
||||
const calls: RecordedCall[] = [];
|
||||
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ body: String(init?.body ?? ""), url });
|
||||
if (url.endsWith("/api/v1/principal")) {
|
||||
return Response.json({ kind: "anonymous" });
|
||||
}
|
||||
if (url.endsWith("/api/auth/sign-in/email")) {
|
||||
return (
|
||||
stub.signIn?.() ?? Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 })
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/api/auth/request-password-reset")) {
|
||||
return (
|
||||
stub.requestPasswordReset?.() ??
|
||||
Response.json({ message: "If this email exists in our system…", status: true })
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/api/auth/reset-password")) {
|
||||
return stub.resetPassword?.() ?? Response.json({ status: true });
|
||||
}
|
||||
if (url.endsWith("/api/auth/send-verification-email")) {
|
||||
return stub.sendVerificationEmail?.() ?? Response.json({ status: true });
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
return { calls, fetch: implementation as unknown as typeof fetch };
|
||||
}
|
||||
|
||||
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
|
||||
});
|
||||
}
|
||||
|
||||
function text(): string {
|
||||
return container.textContent ?? "";
|
||||
}
|
||||
|
||||
function query(testId: string): Element | null {
|
||||
return container.querySelector(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
async function setInput(testId: string, value: string): Promise<void> {
|
||||
const input = query(testId) as HTMLInputElement | null;
|
||||
if (!input) {
|
||||
throw new Error(`missing input ${testId}`);
|
||||
}
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function click(testId: string): Promise<void> {
|
||||
const element = query(testId) as HTMLElement | null;
|
||||
if (!element) {
|
||||
throw new Error(`missing element ${testId}`);
|
||||
}
|
||||
await act(async () => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(hostTestId: string): Promise<void> {
|
||||
const form = query(hostTestId)?.querySelector("form");
|
||||
if (!form) {
|
||||
throw new Error(`missing form in ${hostTestId}`);
|
||||
}
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("emailed-link landing parsing", () => {
|
||||
it("recognizes exactly Ekklesia's own two landings", () => {
|
||||
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
|
||||
expect(readAuthLanding("?ekklesia_auth=verify-email&error=TOKEN_EXPIRED")).toEqual({
|
||||
expired: true,
|
||||
kind: "verification-failed",
|
||||
});
|
||||
expect(readAuthLanding("?ekklesia_auth=verify-email&error=INVALID_TOKEN")).toEqual({
|
||||
expired: false,
|
||||
kind: "verification-failed",
|
||||
});
|
||||
expect(readAuthLanding("?ekklesia_auth=reset-password&token=abc123")).toEqual({
|
||||
kind: "reset-password",
|
||||
token: "abc123",
|
||||
});
|
||||
expect(readAuthLanding("?ekklesia_auth=reset-password&error=INVALID_TOKEN")).toEqual({
|
||||
kind: "reset-link-invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores anything that is not one of them, so a query cannot steer the shell", () => {
|
||||
for (const search of [
|
||||
"",
|
||||
"?",
|
||||
"?token=abc123",
|
||||
"?ekklesia_auth=dashboard",
|
||||
"?ekklesia_auth=",
|
||||
"?redirect=https://evil.example.test",
|
||||
"?ekklesia_auth[]=verify-email",
|
||||
]) {
|
||||
expect(readAuthLanding(search)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a reset landing with no usable token as an invalid link, never as a token", () => {
|
||||
for (const search of [
|
||||
"?ekklesia_auth=reset-password",
|
||||
"?ekklesia_auth=reset-password&token=",
|
||||
"?ekklesia_auth=reset-password&token=has%20space",
|
||||
`?ekklesia_auth=reset-password&token=${"a".repeat(257)}`,
|
||||
]) {
|
||||
expect(readAuthLanding(search)).toEqual({ kind: "reset-link-invalid" });
|
||||
}
|
||||
});
|
||||
|
||||
it("names no destination of any kind, so there is nothing to redirect to", () => {
|
||||
const landing = readAuthLanding(
|
||||
"?ekklesia_auth=verify-email&callbackURL=https://evil.example.test&next=//evil.example.test",
|
||||
);
|
||||
|
||||
expect(landing).toEqual({ kind: "verified" });
|
||||
expect(JSON.stringify(landing)).not.toContain("evil");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unverified sign-in", () => {
|
||||
const unverified = (): Response =>
|
||||
Response.json({ code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }, { status: 403 });
|
||||
|
||||
it("offers verification instead of a credentials error, and never renders the Dashboard", async () => {
|
||||
const stub = platform({ signIn: unverified });
|
||||
await render({ apiBaseUrl, fetch: stub.fetch });
|
||||
await setInput("login-email", "[email protected]");
|
||||
await setInput("login-password", "correct horse battery staple");
|
||||
|
||||
await submit("host-login");
|
||||
|
||||
expect(query("host-verification-required")).not.toBeNull();
|
||||
expect(query("host-login")).toBeNull();
|
||||
// Not "your password is wrong": it was right. And not the Dashboard either — the server minted
|
||||
// no session, so nothing about this state is authenticated.
|
||||
expect(text()).not.toContain("Email or password is incorrect");
|
||||
expect(text()).toContain("Your password was accepted");
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
});
|
||||
|
||||
it("resends from the address the person just typed, and says nothing about whether it exists", async () => {
|
||||
const stub = platform({ signIn: unverified });
|
||||
await render({ apiBaseUrl, fetch: stub.fetch });
|
||||
await setInput("login-email", "[email protected]");
|
||||
await setInput("login-password", "correct horse battery staple");
|
||||
await submit("host-login");
|
||||
|
||||
await submit("host-verification-required");
|
||||
|
||||
const resend = stub.calls.find((call) =>
|
||||
call.url.endsWith("/api/auth/send-verification-email"),
|
||||
);
|
||||
expect(resend?.body).toBe(JSON.stringify({ email: "[email protected]" }));
|
||||
expect(query("verification-resent")).not.toBeNull();
|
||||
// The wording is a conditional, not a confirmation that an account exists.
|
||||
expect(text()).toContain("If an unverified Ekklesia account exists");
|
||||
});
|
||||
|
||||
it("keeps the password out of every request but the sign-in itself", async () => {
|
||||
const stub = platform({ signIn: unverified });
|
||||
await render({ apiBaseUrl, fetch: stub.fetch });
|
||||
await setInput("login-email", "[email protected]");
|
||||
await setInput("login-password", "correct horse battery staple");
|
||||
await submit("host-login");
|
||||
await submit("host-verification-required");
|
||||
|
||||
for (const call of stub.calls) {
|
||||
expect(call.url).not.toContain("correct horse");
|
||||
if (!call.url.endsWith("/api/auth/sign-in/email")) {
|
||||
expect(call.body).not.toContain("correct horse");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgot password", () => {
|
||||
it("reaches the recovery form from the sign-in screen and back again", async () => {
|
||||
const stub = platform();
|
||||
await render({ apiBaseUrl, fetch: stub.fetch });
|
||||
|
||||
await click("login-forgot-password");
|
||||
expect(query("host-forgot-password")).not.toBeNull();
|
||||
|
||||
await click("forgot-cancel");
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows one generic completion state, whatever the address", async () => {
|
||||
const stub = platform();
|
||||
await render({ apiBaseUrl, fetch: stub.fetch });
|
||||
await click("login-forgot-password");
|
||||
await setInput("forgot-email", "[email protected]");
|
||||
|
||||
await submit("host-forgot-password");
|
||||
|
||||
expect(query("host-forgot-password-sent")).not.toBeNull();
|
||||
expect(text()).toContain("If an Ekklesia account exists for that address");
|
||||
// Nothing on the screen distinguishes an address with an account from one without.
|
||||
expect(text()).not.toContain("[email protected]");
|
||||
expect(text()).not.toContain("No account");
|
||||
});
|
||||
|
||||
it("submits once even when the form is submitted repeatedly", async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const stub = platform({
|
||||
requestPasswordReset: () => Response.json({ status: true }),
|
||||
});
|
||||
const gated = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith("/api/auth/request-password-reset")) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
return (stub.fetch as unknown as typeof fetch)(input, init);
|
||||
});
|
||||
await render({ apiBaseUrl, fetch: gated as unknown as typeof fetch });
|
||||
await click("login-forgot-password");
|
||||
await setInput("forgot-email", "[email protected]");
|
||||
|
||||
await submit("host-forgot-password");
|
||||
await submit("host-forgot-password");
|
||||
await submit("host-forgot-password");
|
||||
release?.();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Every one of these triggers an email. A duplicate is not merely untidy.
|
||||
const requests = stub.calls.filter((call) =>
|
||||
call.url.endsWith("/api/auth/request-password-reset"),
|
||||
);
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports a transport failure without a provider detail or a URL", async () => {
|
||||
const stub = platform({
|
||||
requestPasswordReset: () => new Response("relay 421 at smtp.example.org", { status: 500 }),
|
||||
});
|
||||
await render({ apiBaseUrl, fetch: stub.fetch });
|
||||
await click("login-forgot-password");
|
||||
await setInput("forgot-email", "[email protected]");
|
||||
|
||||
await submit("host-forgot-password");
|
||||
|
||||
expect(text()).toContain("Unable to sign in right now.");
|
||||
expect(text()).not.toContain("smtp.example.org");
|
||||
expect(text()).not.toContain("421");
|
||||
expect(text()).not.toContain(apiBaseUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset password", () => {
|
||||
const landing: HostAuthLanding = { kind: "reset-password", token: "reset-token-abc" };
|
||||
|
||||
it("sets a new password and returns cleanly to sign-in", async () => {
|
||||
const stub = platform();
|
||||
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
|
||||
expect(query("host-reset-password")).not.toBeNull();
|
||||
await setInput("reset-password", "a brand new passphrase");
|
||||
await setInput("reset-password-confirmation", "a brand new passphrase");
|
||||
|
||||
await submit("host-reset-password");
|
||||
|
||||
const request = stub.calls.find((call) => call.url.endsWith("/api/auth/reset-password"));
|
||||
expect(JSON.parse(request?.body ?? "{}")).toEqual({
|
||||
newPassword: "a brand new passphrase",
|
||||
token: "reset-token-abc",
|
||||
});
|
||||
expect(query("host-reset-password-complete")).not.toBeNull();
|
||||
expect(text()).toContain(
|
||||
"every existing Ekklesia session for this account has been signed out",
|
||||
);
|
||||
|
||||
await click("reset-return-to-sign-in");
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("blocks a mismatched confirmation in the browser, before anything is sent", async () => {
|
||||
const stub = platform();
|
||||
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
|
||||
await setInput("reset-password", "a brand new passphrase");
|
||||
await setInput("reset-password-confirmation", "a brand new passphrasf");
|
||||
|
||||
await submit("host-reset-password");
|
||||
|
||||
expect(text()).toContain("The two passwords do not match.");
|
||||
expect(stub.calls.some((call) => call.url.endsWith("/api/auth/reset-password"))).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces the server's password policy without inventing one of its own", async () => {
|
||||
const stub = platform({
|
||||
resetPassword: () => Response.json({ code: "PASSWORD_TOO_SHORT" }, { status: 400 }),
|
||||
});
|
||||
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
|
||||
await setInput("reset-password", "short");
|
||||
await setInput("reset-password-confirmation", "short");
|
||||
|
||||
await submit("host-reset-password");
|
||||
|
||||
// The server stays authoritative; the browser only echoes the bounds it was configured with.
|
||||
expect(text()).toContain("between 8 and 128 characters");
|
||||
const field = query("reset-password") as HTMLInputElement;
|
||||
expect(field.minLength).toBe(8);
|
||||
expect(field.maxLength).toBe(128);
|
||||
});
|
||||
|
||||
it("reports a spent or expired token as a link problem, not a password problem", async () => {
|
||||
const stub = platform({
|
||||
resetPassword: () => Response.json({ code: "INVALID_TOKEN" }, { status: 400 }),
|
||||
});
|
||||
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
|
||||
await setInput("reset-password", "a brand new passphrase");
|
||||
await setInput("reset-password-confirmation", "a brand new passphrase");
|
||||
|
||||
await submit("host-reset-password");
|
||||
|
||||
expect(text()).toContain("This password reset link is no longer valid");
|
||||
});
|
||||
|
||||
it("never persists the token, and never renders it", async () => {
|
||||
const stub = platform();
|
||||
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
|
||||
await setInput("reset-password", "a brand new passphrase");
|
||||
await setInput("reset-password-confirmation", "a brand new passphrase");
|
||||
await submit("host-reset-password");
|
||||
|
||||
// A reset token is a bearer credential for one account. It lives in React state for the life of
|
||||
// the form and nowhere else.
|
||||
expect(localStorage.length).toBe(0);
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
expect(text()).not.toContain("reset-token-abc");
|
||||
expect(container.innerHTML).not.toContain("reset-token-abc");
|
||||
expect(window.location.search).toBe("");
|
||||
for (const call of stub.calls) {
|
||||
expect(call.url).not.toContain("reset-token-abc");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a dead-end-free screen for an invalid reset link", async () => {
|
||||
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "reset-link-invalid" });
|
||||
|
||||
expect(query("host-reset-link-invalid")).not.toBeNull();
|
||||
expect(text()).toContain("Password reset links last one hour and can be used once");
|
||||
await click("reset-invalid-continue");
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verification landing", () => {
|
||||
it("confirms success and offers sign-in, without pretending to be signed in", async () => {
|
||||
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "verified" });
|
||||
|
||||
expect(query("host-verification-succeeded")).not.toBeNull();
|
||||
expect(text()).toContain("This address is confirmed");
|
||||
// Following an emailed link mints no session, so the Dashboard must not appear.
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
await click("verified-continue");
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("says an expired link has expired, which is the actionable version", async () => {
|
||||
await render(
|
||||
{ apiBaseUrl, fetch: platform().fetch },
|
||||
{ expired: true, kind: "verification-failed" },
|
||||
);
|
||||
|
||||
expect(query("host-verification-failed")).not.toBeNull();
|
||||
expect(text()).toContain("This link has expired");
|
||||
expect(text()).toContain("Ekklesia will send a new one");
|
||||
});
|
||||
|
||||
it("says an unusable link is invalid rather than guessing that it expired", async () => {
|
||||
await render(
|
||||
{ apiBaseUrl, fetch: platform().fetch },
|
||||
{ expired: false, kind: "verification-failed" },
|
||||
);
|
||||
|
||||
expect(text()).toContain("This link is not valid");
|
||||
expect(text()).not.toContain("has expired");
|
||||
});
|
||||
|
||||
it("still renders the Dashboard for someone who is already signed in", async () => {
|
||||
const authenticated = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v1/principal")) {
|
||||
return Response.json({ kind: "authenticated", userId: "user-1" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
||||
return Response.json({ groups: [], ungrouped: [] });
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
});
|
||||
|
||||
await render(
|
||||
{ apiBaseUrl, fetch: authenticated as unknown as typeof fetch },
|
||||
{ kind: "verified" },
|
||||
);
|
||||
|
||||
// The principal is still what decides. A landing parameter never promotes anyone.
|
||||
expect(query("host-verification-succeeded")).toBeNull();
|
||||
expect(query("host-login")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -295,7 +295,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
||||
});
|
||||
|
||||
it("renders an accessible semantic email/password form with no signup or dead reset action", async () => {
|
||||
it("renders an accessible semantic email/password form with a real reset action and no signup", async () => {
|
||||
const platform = statefulAuthPlatform();
|
||||
await render({ apiBaseUrl, fetch: platform.fetch });
|
||||
|
||||
@@ -312,8 +312,15 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
expect(password.labels?.[0]?.textContent).toContain("Password");
|
||||
expect(submit.textContent).toBe("Sign in");
|
||||
expect(form?.tagName).toBe("FORM");
|
||||
// Public registration remains disabled, so the shell still offers no way to ask for an account.
|
||||
expect(text()).not.toContain("Create account");
|
||||
expect(text()).not.toContain("Forgot password");
|
||||
expect(text()).not.toContain("Sign up");
|
||||
// Password recovery is no longer a dead action: Phase 7C Pass 2 gave it a real backend flow, so
|
||||
// the control exists and is a real button rather than decorative text.
|
||||
const forgot = query("login-forgot-password") as HTMLButtonElement;
|
||||
expect(forgot.tagName).toBe("BUTTON");
|
||||
expect(forgot.type).toBe("button");
|
||||
expect(forgot.textContent).toBe("Forgot password?");
|
||||
});
|
||||
|
||||
it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => {
|
||||
|
||||
Reference in New Issue
Block a user