Ctrl K
ComunidadGuías…

src/lib/auth-redirect.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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();
  });
});