Files
plainleaf/client/spaces_ui/routes.test.ts
T
2026-07-23 14:52:48 +02:00

186 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from "vitest";
// `routes.ts` computes SPACES_BASE at module load from `document.baseURI`, so
// the globals must exist BEFORE the module is imported. Static imports are
// hoisted above any beforeEach, hence the dynamic import in `load()` plus a
// module-registry reset so each test gets a freshly evaluated copy.
async function load(pathname: string, search = "") {
(globalThis as any).document = { baseURI: "http://localhost:3000/.spaces/" };
(globalThis as any).location = {
origin: "http://localhost:3000",
pathname,
search,
};
const vitest = await import("vitest");
vitest.vi.resetModules();
return await import("./routes.ts");
}
beforeEach(() => {
(globalThis as any).document = undefined;
(globalThis as any).location = undefined;
});
afterEach(() => {
delete (globalThis as any).document;
delete (globalThis as any).location;
});
test("SPACES_BASE strips the trailing slash from the document base", async () => {
const { SPACES_BASE } = await load("/.spaces/");
expect(SPACES_BASE).toBe("/.spaces");
});
test("spacesUrl joins with exactly one slash", async () => {
const { spacesUrl } = await load("/.spaces/");
expect(spacesUrl("/users")).toBe("/.spaces/users");
expect(spacesUrl("users")).toBe("/.spaces/users");
});
test("the bare base is the spaces list", async () => {
const { parseSpacesRoute } = await load("/.spaces");
expect(parseSpacesRoute()).toEqual({ screen: "spaces" });
});
test("index.html is the spaces list, not a space id", async () => {
const { parseSpacesRoute } = await load("/.spaces/index.html");
expect(parseSpacesRoute()).toEqual({ screen: "spaces" });
});
test("static segments beat the space-id catch-all", async () => {
// This is the precedence property that mirrors the server's matchit routing.
// If it regresses, `/new` and `/users` silently become space ids.
expect((await load("/.spaces/new")).parseSpacesRoute()).toEqual({
screen: "space-new",
});
expect((await load("/.spaces/users")).parseSpacesRoute()).toEqual({
screen: "users",
});
expect((await load("/.spaces/users/new")).parseSpacesRoute()).toEqual({
screen: "user-new",
});
});
test("a bare single segment is a space id", async () => {
expect((await load("/.spaces/abc-123")).parseSpacesRoute()).toEqual({
screen: "space",
id: "abc-123",
});
});
test("percent-encoded ids and usernames are decoded", async () => {
expect((await load("/.spaces/users/a%20b")).parseSpacesRoute()).toEqual({
screen: "user",
username: "a b",
});
});
test("an unknown deep path is not-found", async () => {
expect((await load("/.spaces/a/b/c")).parseSpacesRoute()).toEqual({
screen: "not-found",
});
});
// --- safeSpacesDestination: the open-redirect guard -----------------------
test("safeSpacesDestination accepts an in-base path", async () => {
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination("/.spaces/users")).toBe("/.spaces/users");
// The query string and hash must round-trip too — a naive rewrite of the
// return statement (e.g. dropping `url.search`/`url.hash`) would still
// pass the assertion above since it has neither.
expect(safeSpacesDestination("/.spaces/users?tab=2#section")).toBe(
"/.spaces/users?tab=2#section",
);
});
test("safeSpacesDestination rejects a cross-origin destination", async () => {
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination("https://evil.example/.spaces/users")).toBe(
undefined,
);
});
test("safeSpacesDestination rejects a protocol-relative destination", async () => {
// "//evil.example/.spaces/x" parses as an absolute URL with a different
// origin — the classic open-redirect payload that looks like a local
// path. The pathname deliberately matches SPACES_BASE so this can only be
// caught by the origin check, not by the (separate) base-prefix check —
// "//evil.example/x" would have been rejected by the base-prefix check
// alone and wouldn't have exercised the origin check at all.
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination("//evil.example/.spaces/x")).toBe(undefined);
});
test("safeSpacesDestination rejects a same-origin path outside the base", async () => {
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination("/some-space/secret")).toBe(undefined);
});
test("safeSpacesDestination rejects a prefix-collision path", async () => {
// "/.spacesevil" starts with "/.spaces" but is NOT inside it; the guard
// compares against `${SPACES_BASE}/` for exactly this reason.
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination("/.spacesevil/x")).toBe(undefined);
});
test("safeSpacesDestination rejects the login page itself", async () => {
// Otherwise ?next=/.spaces/login bounces the user in a loop.
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination("/.spaces/login")).toBe(undefined);
});
test("safeSpacesDestination rejects empty and null", async () => {
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination(null)).toBe(undefined);
expect(safeSpacesDestination("")).toBe(undefined);
});
// Sweep of open-redirect payload shapes that a naive origin/path check could
// let slip through. The reviewer confirmed all of these are correctly
// rejected today — these tests exist so that a future simplification of the
// guard in safeSpacesDestination can't silently reintroduce one.
test.each([
"/\\evil.example",
"\\\\evil.example",
"https:/evil.example/x",
"https://evil@localhost:3000/.spaces/users",
"HTTPS://evil.example/x",
"javascript:alert(1)",
" //evil.example/x",
"/.spaces/../secret",
])("safeSpacesDestination rejects open-redirect payload: %s", async (payload) => {
const { safeSpacesDestination } = await load("/.spaces/login");
expect(safeSpacesDestination(payload)).toBe(undefined);
});
// --- loginUrl ---------------------------------------------------------------
test("loginUrl encodes a safe next destination as the query param", async () => {
const { loginUrl } = await load("/.spaces/users", "");
expect(loginUrl("/.spaces/users/alice")).toBe(
"/.spaces/login?next=%2F.spaces%2Fusers%2Falice",
);
});
test("loginUrl omits the next param when the destination is rejected", async () => {
// safeSpacesDestination rejects this (cross-origin), so it must not leak
// into the query string — an unguarded loginUrl would let a caller smuggle
// an open-redirect payload straight through as `next`.
const { loginUrl } = await load("/.spaces/users", "");
expect(loginUrl("https://evil.example/.spaces/users")).toBe("/.spaces/login");
});
test("loginUrl defaults next to the current location", async () => {
const { loginUrl } = await load("/.spaces/users", "?tab=2");
expect(loginUrl()).toBe("/.spaces/login?next=%2F.spaces%2Fusers%3Ftab%3D2");
});
test("loginUrl's default argument is still passed through the safety check", async () => {
// The default isn't a special case that bypasses safeSpacesDestination —
// if the current location itself isn't a safe destination (here, outside
// SPACES_BASE), calling loginUrl() with no argument must omit `next` too.
const { loginUrl } = await load("/somewhere-else", "");
expect(loginUrl()).toBe("/.spaces/login");
});