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>
191 lines
7.8 KiB
TypeScript
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}\\/\\/`));
|
|
});
|
|
}); |