215 lines
14 KiB
TypeScript
215 lines
14 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
|
|
const serverSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "server.ts"), "utf8");
|
|
const cspPolicySource = readFileSync(
|
|
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "apps", "web", "app", "utils", "security", "contentSecurityPolicy.ts"),
|
|
"utf8"
|
|
);
|
|
const staticAssetsSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "static-assets.ts"), "utf8");
|
|
const rootPackage = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
|
const webPackage = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "apps", "web", "package.json"), "utf8"));
|
|
|
|
describe("server runtime routing integration", () => {
|
|
test("delegates host routing decisions before React Router dispatch and rejects API-host traffic", () => {
|
|
expect(serverSource).toContain("getHostRoutingRuntimeDecision(request, {");
|
|
expect(serverSource).toContain("runtimeSurface: webConfig.runtimeSurface");
|
|
expect(serverSource).toContain("routing: webConfig.routing");
|
|
expect(serverSource).toContain('routingDecision.type === "redirect"');
|
|
expect(serverSource).toContain('routingDecision.target === "api"');
|
|
expect(serverSource).toContain('new Response("Not Found", { status: 404 })');
|
|
expect(serverSource).not.toContain('from "../apps/api/');
|
|
expect(serverSource).not.toContain("apiApp.fetch");
|
|
});
|
|
|
|
test("gates hosts and paths before static assets and React Router", () => {
|
|
const gateIndex = serverSource.indexOf("getHostRoutingRuntimeDecision(request, {");
|
|
const tenantResolutionIndex = serverSource.indexOf("await resolvePortalRuntimeTenant(routingDecision.tenantSlug)", gateIndex);
|
|
const tenantRewriteIndex = serverSource.indexOf("finalizePortalTenantRoutingDecision(routingDecision", tenantResolutionIndex);
|
|
const hostResolutionIndex = serverSource.indexOf("await resolvePortalRuntimeCustomHost(routingDecision.hostname)", gateIndex);
|
|
const healthHostResolutionIndex = serverSource.indexOf("await resolvePortalRuntimeCustomHostHealth(routingDecision.hostname)", gateIndex);
|
|
const hostRewriteIndex = serverSource.indexOf("finalizePortalHostRoutingDecision(routingDecision", hostResolutionIndex);
|
|
const staticIndex = serverSource.indexOf("await serveStaticAsset(req, res)", gateIndex);
|
|
const routerIndex = serverSource.indexOf("handleReactRouterRequest(manifestRequest)", gateIndex);
|
|
|
|
expect(gateIndex).toBeGreaterThan(0);
|
|
expect(tenantResolutionIndex).toBeGreaterThan(gateIndex);
|
|
expect(tenantRewriteIndex).toBeGreaterThan(tenantResolutionIndex);
|
|
expect(hostResolutionIndex).toBeGreaterThan(gateIndex);
|
|
expect(healthHostResolutionIndex).toBeGreaterThan(gateIndex);
|
|
expect(hostRewriteIndex).toBeGreaterThan(hostResolutionIndex);
|
|
expect(staticIndex).toBeGreaterThan(gateIndex);
|
|
expect(staticIndex).toBeGreaterThan(tenantRewriteIndex);
|
|
expect(staticIndex).toBeGreaterThan(hostRewriteIndex);
|
|
expect(routerIndex).toBeGreaterThan(staticIndex);
|
|
expect(serverSource).toContain("routingDecision.serveStaticAssets");
|
|
expect(serverSource).toContain("!routingDecision.reactRouterFallback");
|
|
expect(serverSource).not.toContain("resolveCustomerPortalFeatureEnabled");
|
|
expect(serverSource).not.toContain("finalizePortalFeatureRoutingDecision");
|
|
});
|
|
|
|
test("strips all client-supplied internal portal context headers", () => {
|
|
expect(serverSource).toContain('key.toLowerCase().startsWith("x-hivegrid-portal-")');
|
|
});
|
|
|
|
test("derives request protocol from validated config instead of spoofable forwarding headers", () => {
|
|
expect(serverSource).toContain("`${webConfig.routing.protocol}://${host}`");
|
|
expect(serverSource).toContain("`${webConfig.routing.protocol}://invalid.localhost`");
|
|
expect(serverSource).not.toContain('req.headers["x-forwarded-proto"]');
|
|
});
|
|
|
|
test("uses required web config for fail-fast production startup and listen host/port", () => {
|
|
expect(serverSource).toContain('import { loadRequiredWebConfig } from "@hivegrid/config"');
|
|
expect(serverSource).toContain("const webConfig = loadRequiredWebConfig()");
|
|
expect(serverSource).toContain("const PORT = webConfig.listen.port");
|
|
expect(serverSource).toContain("const HOST = webConfig.listen.host");
|
|
expect(serverSource).not.toContain("loadWebConfig().config");
|
|
expect(serverSource).not.toContain("parsePort(process.env.PORT)");
|
|
expect(serverSource).not.toContain("process.env.HOST");
|
|
});
|
|
|
|
test("selects the complete RLS readiness contract for each runtime surface", () => {
|
|
expect(serverSource).toContain('import { assertPortalRlsRuntimeReady } from "@hivegrid/db/portal-rls-runtime-readiness.server"');
|
|
expect(serverSource).toContain('webConfig.runtimeSurface === "portal"');
|
|
expect(serverSource).toContain("await assertPortalRlsRuntimeReady()");
|
|
expect(serverSource).toContain("await assertRlsRuntimeReady()");
|
|
});
|
|
|
|
test("delegates root start to the web runtime owner", () => {
|
|
expect(rootPackage.scripts.start).toBe("pnpm --filter @hivegrid/web start");
|
|
expect(webPackage.scripts.start).toContain("node ../../build/server-runtime/server.js");
|
|
expect(webPackage.scripts.start).not.toContain("tsx");
|
|
expect(rootPackage.scripts.start).not.toContain("tsx");
|
|
});
|
|
|
|
test("enters the request i18n scope before React Router runs loaders or actions", () => {
|
|
const scopeIndex = serverSource.indexOf("runWithRequestI18nScope(request");
|
|
const routerIndex = serverSource.indexOf("reactRouterRequestHandler(request)", scopeIndex);
|
|
|
|
expect(serverSource).toContain("build.entry.module");
|
|
expect(serverSource).toContain("Web server build is missing the request-scoped i18n entry wrapper");
|
|
expect(scopeIndex).toBeGreaterThan(0);
|
|
expect(routerIndex).toBeGreaterThan(scopeIndex);
|
|
});
|
|
|
|
test("resolves web build artifacts independently from caller cwd", () => {
|
|
expect(serverSource).toContain("const RUNTIME_DIRECTORY = path.dirname(fileURLToPath(import.meta.url))");
|
|
expect(serverSource).toContain('RUNTIME_DIRECTORY.endsWith(path.join("build", "server-runtime"))');
|
|
expect(serverSource).toContain("getWebBuildDirectory(runtimeBuildSurface)");
|
|
expect(serverSource).toContain("runtimeBuildSurface !== webConfig.runtimeSurface");
|
|
expect(serverSource).not.toContain("process.env.WEB_BUILD_DIRECTORY");
|
|
expect(serverSource).toContain('const BUILD_PATH = path.join(WEB_BUILD_DIRECTORY, "server", "index.js")');
|
|
expect(serverSource).toContain('const CLIENT_BUILD_DIRECTORY = path.join(WEB_BUILD_DIRECTORY, "client")');
|
|
expect(serverSource).not.toContain('path.resolve("build/server/index.js")');
|
|
expect(serverSource).not.toContain('path.resolve("build/client")');
|
|
});
|
|
|
|
test("validates and strips the reserved namespace before the selected route manifest", () => {
|
|
expect(serverSource).toContain("prepareReactRouterManifestRequest(routingDecision.request, runtimeBuildSurface)");
|
|
expect(serverSource).toContain("handleReactRouterRequest(manifestRequest)");
|
|
});
|
|
|
|
test("defines nonce-bound web security headers", () => {
|
|
expect(serverSource).toContain("function appendWebSecurityHeaders");
|
|
expect(serverSource).toContain("buildDocumentContentSecurityPolicy(options.documentNonce, routePolicy, options.documentPolicy)");
|
|
expect(serverSource).toContain("getRequestDocumentContentSecurityPolicyOptions(request, webConfig, process.env, browserBuildEnv)");
|
|
expect(serverSource).toContain("overwriteRequestCspNonce(headers)");
|
|
expect(serverSource).toContain("getRequestCspNonce(request)");
|
|
expect(serverSource).toContain("STATIC_ASSET_CONTENT_SECURITY_POLICY");
|
|
expect(cspPolicySource).toContain("`'self' 'nonce-${nonce}' 'strict-dynamic'`");
|
|
expect(cspPolicySource).toContain('["script-src-attr", "\'none\'"]');
|
|
expect(cspPolicySource).toContain("[\"style-src\", \"'self' 'unsafe-inline'\"]");
|
|
expect(serverSource).toContain('"Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"');
|
|
expect(serverSource).toContain('"X-Content-Type-Options": "nosniff"');
|
|
expect(serverSource).toContain('routeReferrerPolicy ?? "strict-origin-when-cross-origin"');
|
|
expect(serverSource).toContain('securityHeaders["X-Frame-Options"] = "SAMEORIGIN"');
|
|
expect(serverSource).toContain('removeHeader(headers, "X-Frame-Options")');
|
|
expect(serverSource).toContain('"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()"');
|
|
expect(serverSource).toContain('"Cross-Origin-Opener-Policy": allowsExternalFraming ? "unsafe-none" : "same-origin"');
|
|
expect(serverSource).toContain('"Cross-Origin-Resource-Policy": allowsExternalFraming ? "cross-origin" : "same-origin"');
|
|
expect(serverSource).toContain('"Cross-Origin-Embedder-Policy": "unsafe-none"');
|
|
expect(serverSource).toContain("headers.set(key, value)");
|
|
expect(serverSource).toContain("headers.setHeader(key, value)");
|
|
expect(serverSource).toContain("key.toLowerCase() === CSP_NONCE_REQUEST_HEADER");
|
|
expect(serverSource).toContain('allowsExternalFraming ? "unsafe-none" : "same-origin"');
|
|
expect(serverSource).toContain('allowsExternalFraming ? "cross-origin" : "same-origin"');
|
|
});
|
|
|
|
test("applies web security headers to forwarded responses", () => {
|
|
expect(serverSource).toMatch(
|
|
/for \(const \[key, value\] of response\.headers\)[\s\S]*res\.appendHeader\(key, value\);[\s\S]*appendWebSecurityHeaders\(res\);/
|
|
);
|
|
});
|
|
|
|
test("overrides every capability response with no-store and no-referrer before final security headers", () => {
|
|
expect(serverSource).toContain("getPortalCapabilityResponseSecurityHeaders(request, runtimeBuildSurface)");
|
|
expect(serverSource).toMatch(/portalCapabilityHeaders[\s\S]*res\.setHeader\(key, value\)[\s\S]*appendWebSecurityHeaders\(res/);
|
|
});
|
|
|
|
test("applies web security and representation headers to static assets", () => {
|
|
expect(serverSource).toContain('"Cache-Control": getStaticCacheControl');
|
|
expect(serverSource).toContain('headers["Content-Encoding"] = representation.encoding');
|
|
expect(serverSource).toContain('headers.Vary = "Accept-Encoding"');
|
|
expect(serverSource).toContain("isStaticAssetNotModified");
|
|
expect(serverSource).toMatch(/function getStaticHeaders[\s\S]*return appendWebSecurityHeaders\(headers\);/);
|
|
});
|
|
|
|
test("allows only the service worker to fetch same-origin static assets", () => {
|
|
expect(serverSource).toContain('requestUrl.pathname === "/sw.js"');
|
|
expect(serverSource).toContain('headers["Content-Security-Policy"] = SERVICE_WORKER_CONTENT_SECURITY_POLICY');
|
|
expect(cspPolicySource).toContain("STATIC_ASSET_CONTENT_SECURITY_POLICY.replace(\n \"connect-src 'none'\",\n \"connect-src 'self'\"");
|
|
});
|
|
|
|
test("sets a 1-day cache for /fonts and /images static assets", () => {
|
|
// Hash-named bundles get `immutable` because their URL contains the hash,
|
|
// but unhashed brand assets need a shorter TTL or stale-content lingers.
|
|
// See docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md.
|
|
expect(staticAssetsSource).toContain("public, max-age=86400, stale-while-revalidate=604800");
|
|
expect(staticAssetsSource).toMatch(/pathname === "\/fonts"[\s\S]*pathname\.startsWith\("\/fonts\/"\)/);
|
|
expect(staticAssetsSource).toMatch(/pathname === "\/images"[\s\S]*pathname\.startsWith\("\/images\/"\)/);
|
|
});
|
|
|
|
test("bounds slow requests, headers, keep-alive reuse and request bodies", () => {
|
|
expect(serverSource).toContain("server.requestTimeout = 120_000");
|
|
expect(serverSource).toContain("server.headersTimeout = 15_000");
|
|
expect(serverSource).toContain("server.keepAliveTimeout = 5_000");
|
|
expect(serverSource).toContain("server.maxHeadersCount = 100");
|
|
expect(serverSource).toContain("server.maxRequestsPerSocket = 1_000");
|
|
expect(serverSource).toContain("resolveWebRequestBodyLimit(req.url, webConfig.runtimeSurface)");
|
|
expect(serverSource).toContain("createLimitedRequestBody(req, bodyLimitBytes)");
|
|
});
|
|
|
|
test("rejects cross-origin browser mutations before React Router actions", () => {
|
|
expect(serverSource).toContain('import { validateMutationOrigin } from "./request-origin"');
|
|
expect(serverSource).toContain("validateMutationOrigin(manifestRequest)");
|
|
expect(serverSource).toContain('"cross-origin web mutation rejected"');
|
|
expect(serverSource).toContain('new Response("Forbidden"');
|
|
expect(serverSource.indexOf("validateMutationOrigin(manifestRequest)")).toBeLessThan(serverSource.indexOf("handleReactRouterRequest(manifestRequest)"));
|
|
});
|
|
|
|
test("uses a bounded graceful-shutdown deadline", () => {
|
|
expect(serverSource).toContain("server.closeIdleConnections()");
|
|
expect(serverSource).toContain("server.closeAllConnections()");
|
|
expect(serverSource).toContain("10_000");
|
|
});
|
|
|
|
test("wires server diagnostics through the structured logger", () => {
|
|
expect(serverSource).toContain('import { createChildLogger, defaultLogger } from "@hivegrid/logger"');
|
|
expect(serverSource).toContain("service: getWebServiceName(webConfig.runtimeSurface)");
|
|
expect(serverSource).toContain("surface: webConfig.runtimeSurface");
|
|
expect(serverSource).toContain('serverLogger.error({ error }, "server request failed")');
|
|
expect(serverSource).toContain('serverLogger.info({ host: address, port: PORT }, "server listening")');
|
|
expect(serverSource).toContain('serverLogger.error({ error }, "server shutdown failed")');
|
|
expect(serverSource).toContain('serverLogger.error({ error }, "static asset stream failed")');
|
|
expect(serverSource).toContain('event: "web.response.client_disconnected"');
|
|
expect(serverSource).toContain("if (request.signal.aborted)");
|
|
expect(serverSource).not.toContain("request.signal.aborted || res.destroyed");
|
|
});
|
|
|
|
test("does not leave active console diagnostics in the server runtime", () => {
|
|
expect(serverSource).not.toMatch(/console\.(?:log|error|warn|info)\s*\(/);
|
|
});
|
|
});
|