// @vitest-environment node import { describe, expect, it, vi } from "vitest"; import { dashboardBack } from "../src/browser/dashboard-back.js"; import { createMemoryHostHistory } from "../src/browser/host-history.js"; import { parseReturnTarget } from "../src/return-target.js"; describe("parseReturnTarget", () => { it("accepts a Dashboard page on this origin, as its canonical destination", () => { expect(parseReturnTarget("/")).toEqual({ kind: "overview" }); expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage", path: "", }); expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage", path: "", }); // Settings pages are Dashboard pages too (ADR 0061). expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" }); expect(parseReturnTarget("/settings/signage")).toEqual({ kind: "module-settings", moduleId: "signage", }); }); it("refuses anything that is not a string, or is empty or enormous", () => { for (const value of [undefined, null, 42, {}, ["/signage"], "", `/${"a".repeat(3000)}`]) { expect(parseReturnTarget(value), JSON.stringify(value)).toBeUndefined(); } }); it("refuses every way of naming another origin", () => { for (const value of [ "https://evil.example/signage", "http://localhost/signage", "//evil.example/signage", "/\\evil.example", "\\\\evil.example", "javascript:alert(1)", "data:text/html,hi", " /signage", "/signage\n", "/sign\tage", "signage", ]) { expect(parseReturnTarget(value), value).toBeUndefined(); } }); it("refuses traversal and encoded separators that would normalize into something else", () => { for (const value of [ "/signage/../settings", "/./signage", "/signage/%2e%2e/api", "/%2F%2Fevil.example", "/%5Cevil.example", "/signage%2Fx", ]) { expect(parseReturnTarget(value), value).toBeUndefined(); } }); it("refuses a query or fragment, and any path the Dashboard has no page for", () => { for (const value of [ "/signage?x=1", "/signage#top", "/api/v1/principal", "/api/auth/sign-out", "/assets/ekklesia-dashboard/app.js", "/display/signage", "/settings/displays/add", "/signage//", "/signage/a//b", ]) { expect(parseReturnTarget(value), value).toBeUndefined(); } }); it("accepts a module's own deeper route, whose page the host then checks (ADR 0063)", () => { expect(parseReturnTarget("/signage/timelines")).toEqual({ kind: "module", moduleId: "signage", path: "timelines", }); expect(parseReturnTarget("/media/sermons/123")).toEqual({ kind: "module", moduleId: "media", path: "sermons/123", }); }); }); describe("dashboardBack", () => { it("goes back to a validated origin when one was supplied", () => { const history = createMemoryHostHistory("/calendar"); const back = dashboardBack(history, { fallback: { kind: "overview" }, label: (destination) => (destination.kind === "module" ? "Back to Signage" : "Back"), returnTarget: "/signage", }); expect(back.href).toBe("/signage"); expect(back.label).toBe("Back to Signage"); back.onBack(); expect(history.entries).toEqual(["/calendar", "/signage"]); }); it("falls back to the page's own parent when the target is missing or unsafe", () => { for (const returnTarget of [ undefined, "https://evil.example/", "//evil.example", "/api/v1/principal", "/signage//", ]) { const history = createMemoryHostHistory("/calendar"); const back = dashboardBack(history, { fallback: { kind: "overview" }, returnTarget, }); expect(back.href, String(returnTarget)).toBe("/"); expect(back.label, String(returnTarget)).toBe("Back"); back.onBack(); expect(history.entries, String(returnTarget)).toEqual(["/calendar", "/"]); } }); it("never leaves the Dashboard through the browser's own history", () => { const history = createMemoryHostHistory("/calendar"); const go = vi.spyOn(history, "go"); dashboardBack(history, { fallback: { kind: "overview" } }).onBack(); expect(go).not.toHaveBeenCalled(); }); });