Files
scan-receipts/tests/e2e/subdomain_routing.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

191 lines
7.8 KiB
TypeScript

/**
* Subdomain Routing Suite — pure logic, no Next.js Edge runtime required.
*
* Verifies the middleware contract in src/lib/routing/subdomain.ts and the
* return-origin helper in src/lib/seo/site.ts:
* - admin.* → /admin route group, with /api/* and /dashboard* passing through
* unchanged (fixes the 404 the admin UI would otherwise hit on its own
* subdomain);
* - app.* → dashboard surface only; auth/admin/legal redirect to the main
* host, everything else is a dashboard sub-route;
* - boundary-aware admin path detection (no accidental admin-gating of
* /administrator or /api/adminx);
* - strict first-party host validation (app.evil.example is never routed);
* - originForFirstPartyHost returns the origin the user started on.
*/
import { describe, test, expect } from "./runner";
import { siteUrl, hostIsSiteFirstParty, originForFirstPartyHost } from "../../src/lib/seo/site";
import { decideSubdomain, isAdminPath, isDashboardPath, MAIN_HOST_ONLY_PREFIXES } from "../../src/lib/routing/subdomain";
const siteHost = new URL(siteUrl).host;
const appHost = `app.${siteHost}`;
const adminHost = `admin.${siteHost}`;
describe("Subdomain routing — admin path detection (boundary-aware)", () => {
test("/admin and /admin/* are admin", () => {
expect(isAdminPath("/admin")).toBe(true);
expect(isAdminPath("/admin/")).toBe(true);
expect(isAdminPath("/admin/users")).toBe(true);
});
test("/api/admin and /api/admin/* are admin", () => {
expect(isAdminPath("/api/admin")).toBe(true);
expect(isAdminPath("/api/admin/stats")).toBe(true);
expect(isAdminPath("/api/admin/system/settings")).toBe(true);
});
test("prefix lookalikes are NOT admin — no boundary false-positives", () => {
expect(isAdminPath("/administrator")).toBe(false);
expect(isAdminPath("/api/adminx")).toBe(false);
expect(isAdminPath("/api/administrator")).toBe(false);
expect(isAdminPath("/admin_old")).toBe(false);
});
test("non-admin app routes are not admin", () => {
expect(isAdminPath("/")).toBe(false);
expect(isAdminPath("/dashboard")).toBe(false);
expect(isAdminPath("/api/scan")).toBe(false);
expect(isAdminPath("/api/auth/login")).toBe(false);
});
});
describe("Subdomain routing — dashboard path detection (boundary-aware)", () => {
test("/dashboard and /dashboard/* are dashboard", () => {
expect(isDashboardPath("/dashboard")).toBe(true);
expect(isDashboardPath("/dashboard/")).toBe(true);
expect(isDashboardPath("/dashboard/export")).toBe(true);
expect(isDashboardPath("/dashboard/onboarding")).toBe(true);
});
test("prefix lookalikes are NOT dashboard", () => {
expect(isDashboardPath("/dashboarding")).toBe(false);
expect(isDashboardPath("/api/dashboard")).toBe(false);
expect(isDashboardPath("/")).toBe(false);
expect(isDashboardPath("/auth/login")).toBe(false);
});
});
describe("Subdomain routing — admin.* branch", () => {
test("the admin host is first-party", () => {
expect(hostIsSiteFirstParty(adminHost)).toBe(true);
});
test("/admin pages pass through unchanged", () => {
const d1 = decideSubdomain(adminHost, "/admin");
const d2 = decideSubdomain(adminHost, "/admin/users");
expect(d1.kind).toBe("next");
expect(d2.kind).toBe("next");
});
test("/api/* passes through unchanged — the admin UI data calls keep working", () => {
const d1 = decideSubdomain(adminHost, "/api/admin/stats");
const d2 = decideSubdomain(adminHost, "/api/admin/users");
const d3 = decideSubdomain(adminHost, "/api/auth/login");
expect(d1.kind).toBe("next");
expect(d2.kind).toBe("next");
expect(d3.kind).toBe("next");
});
test("/dashboard* passes through unchanged — admin shell links to the dashboard", () => {
const d = decideSubdomain(adminHost, "/dashboard");
expect(d.kind).toBe("next");
});
test("root and unknown paths rewrite under /admin", () => {
const root = decideSubdomain(adminHost, "/");
expect(root.kind).toBe("rewrite");
if (root.kind === "rewrite") expect(root.pathname).toBe("/admin");
const login = decideSubdomain(adminHost, "/login");
expect(login.kind).toBe("rewrite");
if (login.kind === "rewrite") expect(login.pathname).toBe("/admin/login");
});
test("a spoofed admin host is never routed", () => {
expect(decideSubdomain("admin.evil.example", "/").kind).toBe("none");
expect(decideSubdomain(`admin.${siteHost}.evil.example`, "/").kind).toBe("none");
});
});
describe("Subdomain routing — app.* branch", () => {
test("root serves the dashboard", () => {
const d = decideSubdomain(appHost, "/");
expect(d.kind).toBe("rewrite");
if (d.kind === "rewrite") expect(d.pathname).toBe("/dashboard");
});
test("dashboard and API pass through unchanged", () => {
expect(decideSubdomain(appHost, "/dashboard").kind).toBe("next");
expect(decideSubdomain(appHost, "/dashboard/receipts").kind).toBe("next");
expect(decideSubdomain(appHost, "/api/scan").kind).toBe("next");
expect(decideSubdomain(appHost, "/api/admin/stats").kind).toBe("next");
});
test("unknown paths become dashboard sub-routes", () => {
const d = decideSubdomain(appHost, "/projects");
expect(d.kind).toBe("rewrite");
if (d.kind === "rewrite") expect(d.pathname).toBe("/dashboard/projects");
});
test("main-host-only content redirects to the bare domain", () => {
for (const prefix of MAIN_HOST_ONLY_PREFIXES) {
const d = decideSubdomain(appHost, prefix);
expect(d.kind).toBe("redirect");
if (d.kind === "redirect") expect(d.pathname).toBe(prefix);
}
const login = decideSubdomain(appHost, "/auth/login");
expect(login.kind).toBe("redirect");
const termsDeep = decideSubdomain(appHost, "/terms/extra");
expect(termsDeep.kind).toBe("redirect");
});
test("boundary: /authx and /administrator stay dashboard routes, not redirects", () => {
const authx = decideSubdomain(appHost, "/authx");
expect(authx.kind).toBe("rewrite");
if (authx.kind === "rewrite") expect(authx.pathname).toBe("/dashboard/authx");
const adminish = decideSubdomain(appHost, "/administrator");
expect(adminish.kind).toBe("rewrite");
if (adminish.kind === "rewrite") expect(adminish.pathname).toBe("/dashboard/administrator");
});
test("a spoofed app host is never routed", () => {
expect(decideSubdomain("app.evil.example", "/").kind).toBe("none");
expect(decideSubdomain(`app.${siteHost}.evil.example`, "/").kind).toBe("none");
});
});
describe("Subdomain routing — non-subdomain hosts", () => {
test("the bare site host and unrelated hosts get no subdomain decision", () => {
expect(decideSubdomain(siteHost, "/dashboard").kind).toBe("none");
expect(decideSubdomain(siteHost, "/").kind).toBe("none");
expect(decideSubdomain("evil.example", "/").kind).toBe("none");
});
});
describe("Origin helper — return to the host the user started on", () => {
test("first-party app./admin. hosts resolve to their own origin", () => {
const appUrl = originForFirstPartyHost(appHost);
expect(new URL(appUrl).host).toBe(appHost);
expect(hostIsSiteFirstParty(new URL(appUrl).host)).toBe(true);
const adminUrl = originForFirstPartyHost(adminHost);
expect(new URL(adminUrl).host).toBe(adminHost);
});
test("the bare site host keeps the site origin", () => {
expect(originForFirstPartyHost(siteHost)).toBe(siteUrl);
});
test("non-first-party hosts fall back to siteUrl — no open redirect", () => {
expect(originForFirstPartyHost("evil.example")).toBe(siteUrl);
expect(originForFirstPartyHost(`app.${siteHost}.evil.example`)).toBe(siteUrl);
expect(originForFirstPartyHost("")).toBe(siteUrl);
});
test("the origin keeps the site scheme but swaps the host", () => {
const protocol = new URL(siteUrl).protocol;
expect(originForFirstPartyHost(appHost)).toMatch(new RegExp(`^${protocol}\\/\\/`));
});
});