322 lines
20 KiB
TypeScript
322 lines
20 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import {
|
|
getCanonicalHostRedirectUrl,
|
|
getHostAwareInternalPath,
|
|
getHostAwareInternalUrlPath,
|
|
getHostRoutingContext,
|
|
getHostRoutingContextFromRequest,
|
|
} from "./HostRouting.server";
|
|
import type { DomainRoutingConfig } from "@toolctrl/config";
|
|
|
|
const config: DomainRoutingConfig = {
|
|
appBaseDomain: "localhost:3000",
|
|
webAppHost: "app.localhost:3000",
|
|
adminAppHost: "admin.localhost:3000",
|
|
apiHost: "api.localhost:3000",
|
|
protocol: "http",
|
|
};
|
|
|
|
const env = {
|
|
APP_BASE_DOMAIN: config.appBaseDomain,
|
|
WEB_APP_HOST: config.webAppHost,
|
|
ADMIN_APP_HOST: config.adminAppHost,
|
|
API_HOST: config.apiHost,
|
|
APP_PROTOCOL: config.protocol,
|
|
};
|
|
|
|
describe("HostRouting", () => {
|
|
test("classifies canonical local hosts", () => {
|
|
expect(getHostRoutingContext("app.localhost:3000", config)).toBe("app");
|
|
expect(getHostRoutingContext("admin.localhost:3000", config)).toBe("admin");
|
|
expect(getHostRoutingContext("api.localhost:3000", config)).toBe("api");
|
|
expect(getHostRoutingContext("localhost:3000", config)).toBe("root");
|
|
});
|
|
|
|
test("maps host-aware app paths to canonical internal route paths", () => {
|
|
expect(getHostAwareInternalPath("/acme/dashboard", "app")).toBe("/acme/dashboard");
|
|
expect(getHostAwareInternalPath("/", "app")).toBe("/app");
|
|
});
|
|
|
|
test("maps app host request URLs without adding removed prefixes", () => {
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/acme/dashboard?tab=activity#events", config)).toBe("/acme/dashboard?tab=activity#events");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/app/acme/dashboard?tab=activity#events", config)).toBe(
|
|
"/acme/dashboard?tab=activity#events"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/app/acme/dashboard.data?_routes=routes%2Fapp.%24tenant%2Fdashboard", config)).toBe(
|
|
"/acme/dashboard.data?_routes=routes%2Fapp.%24tenant%2Fdashboard"
|
|
);
|
|
});
|
|
|
|
test("keeps public auth routes on app and admin hosts from becoming tenant routes", () => {
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/login?redirectTo=%2Facme%2Fdashboard", config)).toBe(
|
|
"/login?redirectTo=%2Facme%2Fdashboard"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/auth/magic-link", config)).toBe("/auth/magic-link");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/plan-required/acme", config)).toBe("/plan-required/acme");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/subscribe/acme?plan=pro", config)).toBe("/subscribe/acme?plan=pro");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/new-account", config)).toBe("/new-account");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/preferences", config)).toBe("/preferences");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/onboarding/session_123", config)).toBe("/onboarding/session_123");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/feature-disabled?feature=summary", config)).toBe("/feature-disabled?feature=summary");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/postman_collection.json", config)).toBe("/postman_collection.json");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/sitemap.xml", config)).toBe("/sitemap.xml");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/docs/en", config)).toBe("/docs/en");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/healthcheck", config)).toBe("/healthcheck");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/healthz", config)).toBe("/healthz");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/readyz", config)).toBe("/readyz");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/swagger", config)).toBe("/swagger");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/unauthorized/app.settings.members", config)).toBe("/unauthorized/app.settings.members");
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/404", config)).toBe("/404");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/login", config)).toBe("/login");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/preferences", config)).toBe("/preferences");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/onboarding/session_123", config)).toBe("/onboarding/session_123");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/feature-disabled?feature=summary", config)).toBe("/feature-disabled?feature=summary");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/postman_collection.json", config)).toBe("/postman_collection.json");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/sitemap.xml", config)).toBe("/sitemap.xml");
|
|
});
|
|
|
|
test("keeps React Router manifest discovery on canonical hosts", () => {
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/__manifest?paths=%2Fmfa-challenge&version=abc", config)).toBe(
|
|
"/__manifest?paths=%2Fmfa-challenge&version=abc"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/__manifest?paths=%2Facme%2Fdashboard&version=abc", config)).toBe(
|
|
"/__manifest?paths=%2Facme%2Fdashboard&version=abc"
|
|
);
|
|
});
|
|
|
|
test("keeps canonical admin host routes on the admin host", () => {
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/dashboard?tab=users#active", config)).toBe("/dashboard?tab=users#active");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/dashboard.data?_routes=routes%2Fdashboard", config)).toBe(
|
|
"/dashboard.data?_routes=routes%2Fdashboard"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/mfa-challenge?redirectTo=%2Fdashboard", config)).toBe(
|
|
"/mfa-challenge?redirectTo=%2Fdashboard"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/mfa-challenge.data?_routes=routes%2Fmfa-challenge", config)).toBe(
|
|
"/mfa-challenge.data?_routes=routes%2Fmfa-challenge"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/internal/ai/chat", config)).toBe("/internal/ai/chat");
|
|
});
|
|
|
|
test("maps host-aware admin and API paths", () => {
|
|
expect(getHostAwareInternalPath("/accounts", "admin")).toBe("/accounts");
|
|
expect(getHostAwareInternalPath("/users", "admin")).toBe("/users");
|
|
expect(getHostAwareInternalPath("/health", "api")).toBe("/v1/health");
|
|
expect(getHostAwareInternalPath("/healthz", "api")).toBe("/healthz");
|
|
expect(getHostAwareInternalPath("/readyz", "api")).toBe("/readyz");
|
|
expect(getHostAwareInternalPath("/v1/health", "api")).toBe("/v1/health");
|
|
expect(getHostAwareInternalPath("/api/v1/health", "api")).toBe("/v1/health");
|
|
});
|
|
|
|
test("maps API host request URLs without path double-prefixing and preserves suffixes", () => {
|
|
expect(getHostAwareInternalUrlPath("http://api.localhost:3000/health?check=deep#status", config)).toBe(
|
|
"/v1/health?check=deep#status"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://api.localhost:3000/v1/health?check=deep#status", config)).toBe(
|
|
"/v1/health?check=deep#status"
|
|
);
|
|
expect(getHostAwareInternalUrlPath("http://api.localhost:3000/api/v1/health?check=deep#status", config)).toBe(
|
|
"/v1/health?check=deep#status"
|
|
);
|
|
});
|
|
|
|
test("maps admin host request URLs without double-prefixing", () => {
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/accounts?tab=users#active", config)).toBe("/accounts?tab=users#active");
|
|
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/users?tab=users#active", config)).toBe("/users?tab=users#active");
|
|
});
|
|
|
|
test("uses forwarded host when deriving request context behind a proxy", () => {
|
|
const request = new Request("http://localhost:3000/acme/dashboard", {
|
|
headers: {
|
|
host: "localhost:3000",
|
|
"x-forwarded-host": " app.localhost:3000 , api.localhost:3000",
|
|
},
|
|
});
|
|
|
|
expect(getHostRoutingContextFromRequest(request, env)).toBe("app");
|
|
});
|
|
|
|
test("uses forwarded host for internal URL mapping behind a proxy", () => {
|
|
const request = new Request("http://localhost:3000/acme/dashboard?tab=activity#events", {
|
|
headers: {
|
|
host: "localhost:3000",
|
|
"x-forwarded-host": " app.localhost:3000 , api.localhost:3000",
|
|
},
|
|
});
|
|
|
|
expect(getHostAwareInternalUrlPath(request, config)).toBe("/acme/dashboard?tab=activity#events");
|
|
});
|
|
|
|
test("uses forwarded host for canonical redirects behind a proxy", () => {
|
|
const request = new Request("http://localhost:3000/settings?tab=users#active", {
|
|
headers: {
|
|
host: "localhost:3000",
|
|
"x-forwarded-host": " admin.localhost:3000 , app.localhost:3000",
|
|
},
|
|
});
|
|
|
|
expect(getCanonicalHostRedirectUrl(request, config)).toBeNull();
|
|
});
|
|
|
|
test("uses the first forwarded host value for routing", () => {
|
|
const request = new Request("http://localhost:3000/acme/dashboard?tab=activity#events", {
|
|
headers: {
|
|
host: "localhost:3000",
|
|
"x-forwarded-host": " admin.localhost:3000 , api.localhost:3000",
|
|
},
|
|
});
|
|
|
|
expect(getHostRoutingContextFromRequest(request, env)).toBe("admin");
|
|
expect(getCanonicalHostRedirectUrl(request, config)).toBe("http://app.localhost:3000/acme/dashboard?tab=activity#events");
|
|
});
|
|
|
|
test("does not let forwarded host spoof an already canonical direct host", () => {
|
|
const request = new Request("http://app.localhost:3000/dashboard?tab=activity#events", {
|
|
headers: {
|
|
host: "app.localhost:3000",
|
|
"x-forwarded-host": "admin.localhost:3000",
|
|
},
|
|
});
|
|
|
|
expect(getHostRoutingContextFromRequest(request, env)).toBe("app");
|
|
expect(getCanonicalHostRedirectUrl(request, config)).toBe("http://admin.localhost:3000/dashboard?tab=activity#events");
|
|
});
|
|
|
|
test("redirects root-host canonical admin and tenant paths to their canonical hosts", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/dashboard?tab=activity#events", config)).toBe(
|
|
"http://admin.localhost:3000/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/accounts", config)).toBe("http://admin.localhost:3000/accounts");
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/acme/dashboard?tab=activity#events", config)).toBe(
|
|
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/app/acme/dashboard?tab=activity#events", config)).toBe(
|
|
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/api/v1/health?check=deep#status", config)).toBe(
|
|
"http://api.localhost:3000/v1/health?check=deep#status"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin/settings?tab=users#active", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin", config)).toBeNull();
|
|
});
|
|
|
|
test("does not redirect a canonical route to itself when adminAppHost equals webAppHost (no 308 loop)", () => {
|
|
// Single-Host-Staging / Fehlkonfig: adminAppHost == webAppHost. /dashboard (Admin-Surface) wuerde sonst
|
|
// kanonisch auf denselben Host+Pfad umgeleitet -> 308-Endlosschleife. Der Self-Redirect-Guard liefert null.
|
|
const sameHostConfig: DomainRoutingConfig = { ...config, adminAppHost: config.webAppHost };
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/dashboard?tab=activity#events", sameHostConfig)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/accounts/users", sameHostConfig)).toBeNull();
|
|
// Getrennte Hosts (Prod) bleiben unveraendert -> Redirect feuert weiterhin.
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/dashboard?tab=activity#events", config)).toBe(
|
|
"http://admin.localhost:3000/dashboard?tab=activity#events"
|
|
);
|
|
});
|
|
|
|
test("redirects canonical route paths off the wrong canonical host", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/dashboard?tab=activity#events", config)).toBe(
|
|
"http://admin.localhost:3000/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/accounts/users?filter=active", config)).toBe(
|
|
"http://admin.localhost:3000/accounts/users?filter=active"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/internal/imports/dry-run", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/dashboard?tab=activity#events", config)).toBe(
|
|
"http://admin.localhost:3000/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/dashboard?tab=activity#events", config)).toBe(
|
|
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme", config)).toBe("http://app.localhost:3000/acme");
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/acme", config)).toBe("http://app.localhost:3000/acme");
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/acme/dashboard?tab=activity#events", config)).toBe(
|
|
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/portals/customer", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/widgets", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/workflow-engine", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/contact", config)).toBe("http://app.localhost:3000/acme/contact");
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/v1/users?cursor=next", config)).toBe("http://api.localhost:3000/v1/users?cursor=next");
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/v1/users?cursor=next", config)).toBe("http://api.localhost:3000/v1/users?cursor=next");
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/api/v1/health?check=deep#status", config)).toBe(
|
|
"http://api.localhost:3000/v1/health?check=deep#status"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/app/acme/dashboard?tab=activity#events", config)).toBe(
|
|
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/api/v1/health?check=deep#status", config)).toBe(
|
|
"http://api.localhost:3000/v1/health?check=deep#status"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/admin/settings?tab=users#active", config)).toBeNull();
|
|
});
|
|
|
|
test("redirects API host unversioned aliases to canonical v1 paths", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/health?check=deep#status", config)).toBe(
|
|
"http://api.localhost:3000/v1/health?check=deep#status"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/accounts?cursor=next", config)).toBe(
|
|
"http://api.localhost:3000/v1/accounts?cursor=next"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/auth/login", config)).toBe("http://api.localhost:3000/v1/auth/login");
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/analytics/events", config)).toBe("http://api.localhost:3000/v1/analytics/events");
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/entities/contact/rows", config)).toBe(
|
|
"http://api.localhost:3000/v1/entities/contact/rows"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/api-keys/validate", config)).toBe(
|
|
"http://api.localhost:3000/v1/api-keys/validate"
|
|
);
|
|
});
|
|
|
|
test("does not canonicalize unsupported API host aliases", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/webhooks/inbound", config)).toBe("http://app.localhost:3000/webhooks/inbound");
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/imports/dry-run", config)).toBe("http://app.localhost:3000/imports/dry-run");
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/workflows/run/workflow_1", config)).toBeNull();
|
|
});
|
|
|
|
test("keeps public root-host routes on the root host", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/login?redirectTo=%2Facme%2Fdashboard", config)).toBeNull();
|
|
});
|
|
|
|
test("redirects public web routes away from the API host", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/login?redirectTo=%2Facme%2Fdashboard", config)).toBe(
|
|
"http://app.localhost:3000/login?redirectTo=%2Facme%2Fdashboard"
|
|
);
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/healthcheck", config)).toBe("http://app.localhost:3000/healthcheck");
|
|
});
|
|
|
|
test("redirects app-host reserved admin slugs instead of treating them as tenants", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/crm/dashboard", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/accounts/users", config)).toBe("http://admin.localhost:3000/accounts/users");
|
|
});
|
|
|
|
test("does not canonicalize disabled plugin route trees to active hosts", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/crm/dashboard", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/internal/ai/chat", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/internal/api/__workflows/jobs", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/internal/api/workflows/run/workflow_1", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/workflows/run/workflow_1", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin/internal/ai/chat", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin/__workflows/jobs", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/knowledge-base/articles", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/help/getting-started", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/mcp", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/help/getting-started", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/mcp", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/internal/ai/chat", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/__workflows/jobs", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/knowledge-base/articles", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/acme/internal/ai/chat", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/crm", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/__widgets/widget_1", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/internal/ai/chat", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/__workflows/jobs", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/acme/internal/ai/chat", config)).toBeNull();
|
|
});
|
|
|
|
test("builds canonical redirect URLs for versioned API paths", () => {
|
|
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/v1/health?check=deep#status", config)).toBeNull();
|
|
expect(getCanonicalHostRedirectUrl("http://localhost:3000/v1/health?check=deep#status", config)).toBe(
|
|
"http://api.localhost:3000/v1/health?check=deep#status"
|
|
);
|
|
});
|
|
});
|