src/lib/auth-redirect.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterEach, describe, expect, it, beforeEach, vi } from "vitest";
import {
consumePostAuthRedirect,
destinationForOutcome,
rememberPostAuthRedirect,
sanitizeRedirectPath,
signInHref,
} from "./auth-redirect";
import { memoryStorage, useStorage } from "@/test/memory-storage";
// Every entry point routes through this rather than branching on a boolean of its
// own, so a status mapped wrong here is mapped wrong at all of them at once -
// which is exactly why it is one function and why it is tested directly.
describe("destinationForOutcome", () => {
it("honours where the visitor was headed, but only for a session", () => {
expect(destinationForOutcome("authenticated", "/dives/abc")).toBe(
"/dives/abc",
);
expect(destinationForOutcome("authenticated", null)).toBe("/home");
});
it("sanitizes the destination on the way through", () => {
// Sanitized here rather than at each call site: a crafted `?next=` reaches
// them as a prop.
expect(destinationForOutcome("authenticated", "//evil.example")).toBe(
"/home",
);
});
it("sends the two outcomes that are not a session to their own screens", () => {
expect(destinationForOutcome("onboarding_required", "/dives/abc")).toBe(
"/onboarding",
);
// Dropping `next` here is deliberate: an account that is not back yet has
// nothing to return to, and the restore screen ends at the default.
expect(destinationForOutcome("deletion_pending", "/dives/abc")).toBe(
"/restore",
);
});
});
describe("sanitizeRedirectPath", () => {
it("keeps a same-origin path", () => {
expect(sanitizeRedirectPath("/dives/abc?page=2")).toBe("/dives/abc?page=2");
});
it("rejects empty/missing values", () => {
expect(sanitizeRedirectPath(null)).toBeNull();
expect(sanitizeRedirectPath(undefined)).toBeNull();
expect(sanitizeRedirectPath("")).toBeNull();
});
it("rejects anything a browser could read as another origin", () => {
expect(sanitizeRedirectPath("https://evil.example")).toBeNull();
expect(sanitizeRedirectPath("//evil.example")).toBeNull();
expect(sanitizeRedirectPath("/\\evil.example")).toBeNull();
expect(sanitizeRedirectPath("dives")).toBeNull();
});
// The URL parser strips ASCII tab/newline/carriage-return *before* parsing,
// so "/\t/evil.example" is not caught by the "//" check above yet still
// resolves to https://evil.example/. Asserted against the real parser rather
// than by eyeballing the string, since that stripping is the whole point.
it("rejects control characters that the URL parser strips out", () => {
for (const control of ["\t", "\n", "\r"]) {
const crafted = `/${control}/evil.example`;
expect(
new URL(crafted, "https://opendiving.app/").origin,
`"${JSON.stringify(control)}" must be treated as cross-origin`,
).toBe("https://evil.example");
expect(sanitizeRedirectPath(crafted)).toBeNull();
}
});
it("rejects control characters anywhere in the value, not just up front", () => {
expect(sanitizeRedirectPath("/dives\n//evil.example")).toBeNull();
expect(sanitizeRedirectPath("/dives\t")).toBeNull();
});
it("rejects a leading space, which the parser also trims", () => {
expect(sanitizeRedirectPath(" //evil.example")).toBeNull();
expect(sanitizeRedirectPath(" /dives")).toBeNull();
});
// Each spelling passes a check on the raw string and normalises to a pathname of
// `//evil.example` - which Next's hard-navigation fallback hands to
// `location.assign`, where it is another origin. Asserted against the parser, as
// above, so the payload list cannot quietly stop being a list of real ones.
it.each([
"/.//evil.example",
"/..//evil.example",
"/./\\evil.example",
"/a/..//evil.example",
"/%2e//evil.example",
"/%2e%2e//evil.example",
"/%2E/%2e%2E//evil.example",
"/a/%2e%2e//evil.example",
])("rejects %s, whose dot segments collapse to //", (crafted) => {
expect(new URL(crafted, "https://opendiving.app/").pathname).toMatch(
/^\/\/evil\.example/,
);
expect(sanitizeRedirectPath(crafted)).toBeNull();
});
it("hands back the normalised path rather than the spelling it was given", () => {
expect(sanitizeRedirectPath("/dives/./abc/../def?x=1#y")).toBe(
"/dives/def?x=1#y",
);
expect(sanitizeRedirectPath("/a/..//dives")).toBeNull();
expect(sanitizeRedirectPath("/dives//abc")).toBe("/dives//abc");
});
it("still keeps ordinary paths with query strings and fragments", () => {
expect(sanitizeRedirectPath("/dives/abc?page=2#top")).toBe(
"/dives/abc?page=2#top",
);
expect(sanitizeRedirectPath("/sites/a%20b")).toBe("/sites/a%20b");
});
});
describe("signInHref", () => {
it("appends an encoded next parameter", () => {
expect(signInHref("/dives/abc?page=2")).toBe(
"/signin?next=%2Fdives%2Fabc%3Fpage%3D2",
);
});
it("omits next when there's nothing worth returning to", () => {
expect(signInHref(null)).toBe("/signin");
expect(signInHref("/")).toBe("/signin");
expect(signInHref("https://evil.example")).toBe("/signin");
});
it("never points back at the sign-in page itself", () => {
expect(signInHref("/signin?next=%2Fdives")).toBe("/signin");
});
});
const STORAGE_KEY = "opendiving:post-auth-redirect";
// `window.localStorage` is installed per test rather than used as jsdom provides
// it - see `test/memory-storage.ts` for why. Here it also has to be swappable
// per test, since two below are about the browser refusing storage outright.
describe("rememberPostAuthRedirect / consumePostAuthRedirect", () => {
beforeEach(() => {
useStorage(memoryStorage());
});
afterEach(() => {
vi.useRealTimers();
});
it("round-trips a destination exactly once", () => {
rememberPostAuthRedirect("/dives/abc");
expect(consumePostAuthRedirect()).toBe("/dives/abc");
expect(consumePostAuthRedirect()).toBeNull();
});
// The point of the storage is that the emailed link opens in a browsing
// context with no opener - one that gets a `sessionStorage` of its own but
// shares `localStorage` with the tab that asked for the link. jsdom has only
// the one context, so what's actually pinned here is the mechanism: the
// destination is in `localStorage` and nowhere else.
it("keeps the destination in localStorage, not the tab's own storage", () => {
rememberPostAuthRedirect("/dives/abc");
expect(window.localStorage.getItem(STORAGE_KEY)).not.toBeNull();
expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("clears any earlier destination when called without one", () => {
rememberPostAuthRedirect("/dives/abc");
rememberPostAuthRedirect(undefined);
expect(consumePostAuthRedirect()).toBeNull();
});
it("refuses to store an off-origin destination", () => {
rememberPostAuthRedirect("https://evil.example");
expect(consumePostAuthRedirect()).toBeNull();
});
// `localStorage` outlives the tab, so a destination nobody ever came back for
// must not sit there indefinitely. The window is deliberately far longer than
// any link's life - see the constant for why erring long is the safe side.
it("forgets a destination once it's a day old", () => {
vi.useFakeTimers();
rememberPostAuthRedirect("/dives/abc");
vi.advanceTimersByTime(23 * 60 * 60 * 1000);
const stillLive = window.localStorage.getItem(STORAGE_KEY);
expect(consumePostAuthRedirect()).toBe("/dives/abc");
// Put it back untouched and let it age out.
window.localStorage.setItem(STORAGE_KEY, stillLive!);
vi.advanceTimersByTime(2 * 60 * 60 * 1000);
expect(consumePostAuthRedirect()).toBeNull();
});
it("ignores - and clears - an entry it can't read", () => {
// Anything not written by `rememberPostAuthRedirect`: a hand-edited entry, a
// half-written value, a future format read by an older tab.
window.localStorage.setItem(STORAGE_KEY, "/dives/abc");
expect(consumePostAuthRedirect()).toBeNull();
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
});
// Safari private mode, storage disabled by policy, quota exhausted. Losing the
// destination is the accepted outcome; taking the sign-in down with it is not.
it("survives storage that throws", () => {
const denied: Storage = {
...memoryStorage(),
getItem: () => {
throw new Error("denied");
},
setItem: () => {
throw new Error("denied");
},
removeItem: () => {
throw new Error("denied");
},
};
useStorage(denied);
expect(() => rememberPostAuthRedirect("/dives/abc")).not.toThrow();
expect(consumePostAuthRedirect()).toBeNull();
});
it("survives storage that isn't there at all", () => {
useStorage(undefined);
expect(() => rememberPostAuthRedirect("/dives/abc")).not.toThrow();
expect(consumePostAuthRedirect()).toBeNull();
});
});