import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __resetContainerCacheForTest } from "./net.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; const TRUSTED_PROXY_AUTH = { mode: "trusted-proxy" as const, trustedProxy: { userHeader: "x-forwarded-user", }, }; const TOKEN_AUTH = { mode: "token" as const, token: "test-token-123", }; describe("resolveGatewayRuntimeConfig", () => { describe("trusted-proxy auth mode", () => { // This test validates BOTH validation layers: // 1. CLI validation in src/cli/gateway-cli/run.ts (line 246) // 2. Runtime config validation in src/gateway/server-runtime-config.ts (line 99) // Both must allow lan binding when authMode === "trusted-proxy" it.each([ { name: "lan binding", cfg: { gateway: { bind: "lan" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: ["192.168.1.1"], controlUi: { allowedOrigins: ["https://control.example.com"] }, }, }, expectedBindHost: "0.0.0.0", }, { name: "loopback binding with 127.0.0.1 proxy", cfg: { gateway: { bind: "loopback" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: ["127.0.0.1"], }, }, expectedBindHost: "127.0.0.1", }, { name: "loopback binding with ::1 proxy", cfg: { gateway: { bind: "loopback" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: ["::1"] }, }, expectedBindHost: "127.0.0.1", }, { name: "loopback binding with loopback cidr proxy", cfg: { gateway: { bind: "loopback" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: ["127.0.0.0/8"], }, }, expectedBindHost: "127.0.0.1", }, ])("allows $name", async ({ cfg, expectedBindHost }) => { const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 }); expect(result.authMode).toBe("trusted-proxy"); expect(result.bindHost).toBe(expectedBindHost); }); it.each([ { name: "loopback binding without trusted proxies", cfg: { gateway: { bind: "loopback" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: [] }, }, expectedMessage: "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured", }, { name: "lan binding without trusted proxies", cfg: { gateway: { bind: "lan" as const, auth: TRUSTED_PROXY_AUTH, trustedProxies: [], controlUi: { allowedOrigins: ["https://control.example.com"] }, }, }, expectedMessage: "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured", }, ])("rejects $name", async ({ cfg, expectedMessage }) => { await expect(resolveGatewayRuntimeConfig({ cfg, port: 18789 })).rejects.toThrow( expectedMessage, ); }); it("allows loopback binding with non-loopback trusted proxies", async () => { const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { bind: "loopback", auth: TRUSTED_PROXY_AUTH, trustedProxies: ["10.0.0.1"], }, }, port: 18789, }); expect(result.authMode).toBe("trusted-proxy"); expect(result.bindHost).toBe("127.0.0.1"); }); }); describe("token/password auth modes", () => { let originalToken: string | undefined; beforeEach(() => { originalToken = process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_TOKEN; }); afterEach(() => { if (originalToken !== undefined) { process.env.OPENCLAW_GATEWAY_TOKEN = originalToken; } else { delete process.env.OPENCLAW_GATEWAY_TOKEN; } }); it.each([ { name: "lan binding with token", cfg: { gateway: { bind: "lan" as const, auth: TOKEN_AUTH, controlUi: { allowedOrigins: ["https://control.example.com"] }, }, }, expectedAuthMode: "token", expectedBindHost: "0.0.0.0", }, { name: "loopback binding with explicit none auth", cfg: { gateway: { bind: "loopback" as const, auth: { mode: "none" as const } } }, expectedAuthMode: "none", expectedBindHost: "127.0.0.1", }, ])("allows $name", async ({ cfg, expectedAuthMode, expectedBindHost }) => { const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 }); expect(result.authMode).toBe(expectedAuthMode); expect(result.bindHost).toBe(expectedBindHost); }); it.each([ { name: "token mode without token", cfg: { gateway: { bind: "lan" as const, auth: { mode: "token" as const } } }, expectedMessage: "gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)", }, { name: "lan binding with explicit none auth", cfg: { gateway: { bind: "lan" as const, auth: { mode: "none" as const } } }, expectedMessage: "refusing to bind gateway", }, { name: "loopback binding that resolves to non-loopback host", cfg: { gateway: { bind: "loopback" as const, auth: { mode: "none" as const } } }, host: "0.0.0.0", expectedMessage: "gateway bind=loopback resolved to non-loopback host", }, { name: "custom bind without customBindHost", cfg: { gateway: { bind: "custom" as const, auth: TOKEN_AUTH } }, expectedMessage: "gateway.bind=custom requires gateway.customBindHost", }, { name: "custom bind with invalid customBindHost", cfg: { gateway: { bind: "custom" as const, customBindHost: "192.168.001.100", auth: TOKEN_AUTH, }, }, expectedMessage: "gateway.bind=custom requires a valid IPv4 customBindHost", }, { name: "custom bind with mismatched resolved host", cfg: { gateway: { bind: "custom" as const, customBindHost: "192.168.1.100", auth: TOKEN_AUTH, }, }, host: "0.0.0.0", expectedMessage: "gateway bind=custom requested 192.168.1.100 but resolved 0.0.0.0", }, ])("rejects $name", async ({ cfg, host, expectedMessage }) => { await expect(resolveGatewayRuntimeConfig({ cfg, port: 18789, host })).rejects.toThrow( expectedMessage, ); }); it.each([ { name: "rejects non-loopback control UI when allowed origins are missing", cfg: { gateway: { bind: "lan" as const, auth: TOKEN_AUTH, }, }, expectedError: "non-loopback Control UI requires gateway.controlUi.allowedOrigins", }, { name: "allows non-loopback control UI without allowed origins when dangerous fallback is enabled", cfg: { gateway: { bind: "lan" as const, auth: TOKEN_AUTH, controlUi: { dangerouslyAllowHostHeaderOriginFallback: true, }, }, }, expectedBindHost: "0.0.0.0", }, { name: "allows non-loopback control UI when allowed origins collapse after trimming", cfg: { gateway: { bind: "lan" as const, auth: TOKEN_AUTH, controlUi: { allowedOrigins: [" https://control.example.com "], }, }, }, expectedBindHost: "0.0.0.0", }, ])("$name", async ({ cfg, expectedError, expectedBindHost }) => { if (expectedError) { await expect(resolveGatewayRuntimeConfig({ cfg, port: 18789 })).rejects.toThrow( expectedError, ); return; } const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 }); expect(result.bindHost).toBe(expectedBindHost); }); }); describe("container-aware bind default", () => { afterEach(() => { __resetContainerCacheForTest(); vi.restoreAllMocks(); }); it("defaults to auto (0.0.0.0) inside a container with auth configured", async () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { auth: TOKEN_AUTH, controlUi: { allowedOrigins: ["https://control.example.com"] }, }, }, port: 18789, }); expect(result.bindHost).toBe("0.0.0.0"); }); it("rejects container auto-bind with auth but without allowedOrigins (origin check preserved)", async () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists await expect( resolveGatewayRuntimeConfig({ cfg: { gateway: { auth: TOKEN_AUTH } }, port: 18789, }), ).rejects.toThrow(/non-loopback Control UI requires gateway\.controlUi\.allowedOrigins/); }); it("rejects container auto-bind without auth (security invariant preserved)", async () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists await expect( resolveGatewayRuntimeConfig({ cfg: { gateway: { auth: { mode: "none" } } }, port: 18789, }), ).rejects.toThrow(/refusing to bind gateway/); }); it("respects explicit loopback config even inside a container", async () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { bind: "loopback", auth: { mode: "none" } } }, port: 18789, }); expect(result.bindHost).toBe("127.0.0.1"); }); it("falls back to loopback inside a container when tailscale serve is enabled", async () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { auth: { mode: "none" }, tailscale: { mode: "serve" }, }, }, port: 18789, }); // Tailscale serve requires loopback — container auto-detection must not // override this constraint when bind is unset. expect(result.bindHost).toBe("127.0.0.1"); }); it("falls back to loopback inside a container when tailscale funnel is enabled", async () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { auth: { mode: "password", password: "test-pw" }, tailscale: { mode: "funnel" }, }, }, port: 18789, }); expect(result.bindHost).toBe("127.0.0.1"); }); it("respects explicit lan config inside a container (requires auth)", async () => { const fs = require("node:fs"); vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { bind: "lan", auth: TOKEN_AUTH, controlUi: { allowedOrigins: ["https://control.example.com"] }, }, }, port: 18789, }); expect(result.bindHost).toBe("0.0.0.0"); }); }); describe("HTTP security headers", () => { const cases = [ { name: "resolves strict transport security headers from config", strictTransportSecurity: " max-age=31536000; includeSubDomains ", expected: "max-age=31536000; includeSubDomains", }, { name: "does not set strict transport security when explicitly disabled", strictTransportSecurity: false, expected: undefined, }, { name: "does not set strict transport security when the value is blank", strictTransportSecurity: " ", expected: undefined, }, ] satisfies ReadonlyArray<{ name: string; strictTransportSecurity: string | false; expected: string | undefined; }>; it.each(cases)("$name", async ({ strictTransportSecurity, expected }) => { const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { bind: "loopback", auth: { mode: "none" }, http: { securityHeaders: { strictTransportSecurity, }, }, }, }, port: 18789, }); expect(result.strictTransportSecurityHeader).toBe(expected); }); }); });