) {
function Probe() {
const entry = useVerifyEntry();
- return {`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}
;
+ return (
+ <>
+ {`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}
+ {entry.resetPending ? "reset-pending" : "-"}
+ >
+ );
}
await act(async () => {
render();
@@ -67,14 +72,51 @@ describe("confirming an emailed verification link", () => {
expect(outcome()).toBe("ok_reset:revoked@example.com");
});
- it("reports invalid when the link is expired or already used", async () => {
- const verifyEmail = vi.fn().mockRejectedValue(new Error("400"));
+ // Errors are built INSIDE the rejection, not hoisted above the test: mountAt calls
+ // vi.resetModules(), so an HttpError imported earlier would be a different class identity than the
+ // one the hook imports afterwards, and `instanceof` would quietly miss.
+ const rejectWith = (make: (E: typeof import("./api/core").HttpError) => unknown) =>
+ vi.fn().mockImplementation(async () => {
+ const { HttpError } = await import("./api/core");
+ throw make(HttpError);
+ });
+
+ it("reports invalid when the SERVER says the link is expired or already used", async () => {
+ const verifyEmail = rejectWith((E) => new E(400, "verify", { code: "verify" }));
const outcome = await mountAt("/#verify=tok-dead", verifyEmail);
expect(outcome()).toBe("invalid:-");
});
+ // DX-16: the fragment is already cleared by the time this resolves, so reporting a transport
+ // failure as "that link is no longer valid" pushes the user into requesting a fresh link on the
+ // strength of a statement that is false — the token is untouched and retrying it works.
+ it("does not call the link dead on a gateway failure mid-deploy", async () => {
+ const verifyEmail = rejectWith((E) => new E(502, "Bad Gateway"));
+
+ const outcome = await mountAt("/#verify=tok-live", verifyEmail);
+
+ expect(outcome()).toBe("unreachable:-");
+ });
+
+ it("does not call the link dead when the connection drops with no HTTP status at all", async () => {
+ const verifyEmail = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
+
+ const outcome = await mountAt("/#verify=tok-offline", verifyEmail);
+
+ expect(outcome()).toBe("unreachable:-");
+ });
+
+ // A 400 that is not the verify refusal is still not a verdict on the token.
+ it("does not call the link dead on a 400 that is not the verify refusal", async () => {
+ const verifyEmail = rejectWith((E) => new E(400, "nope", { code: "other" }));
+
+ const outcome = await mountAt("/#verify=tok-other", verifyEmail);
+
+ expect(outcome()).toBe("unreachable:-");
+ });
+
it("clears the one-time query flags it owns even with nothing to confirm", async () => {
const verifyEmail = vi.fn();
@@ -97,6 +139,22 @@ describe("confirming an emailed verification link", () => {
expect(window.location.hash).toBe("#reset=unspent");
expect(window.location.search).toBe(""); // the flag it does own still goes
+ expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
+ });
+
+ // DX-17: the verify half got a hashchange listener in E9.5 S2, the reset half did not — so pasting
+ // a reset link into a tab already running the app was silently ignored. Our mails were plain text
+ // until 0.78.8, which makes pasting the normal way to use one.
+ it("notices a reset link pasted into a tab that is already running", async () => {
+ await mountAt("/", vi.fn());
+ expect(screen.getByTestId("reset").textContent).toBe("-");
+
+ await act(async () => {
+ window.history.replaceState(window.history.state, "", "/#reset=pasted");
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ });
+
+ expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
});
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
@@ -145,7 +203,12 @@ describe("a verification link that arrives as a fragment-only navigation", () =>
const verifyEmail = vi
.fn()
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
- .mockRejectedValueOnce(new Error("400"));
+ // The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
+ // token is spent, and this case is about the SECOND outcome replacing the first.
+ .mockImplementationOnce(async () => {
+ const { HttpError } = await import("./api/core");
+ throw new HttpError(400, "verify", { code: "verify" });
+ });
const outcome = await mountAt("/#verify=first-token", verifyEmail);
expect(outcome()).toBe("ok:first@example.com");