import { env, exports } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; import { resolveBaseUrl, resolveIssuer } from "@/worker/config"; import { isSafeHttpUrl } from "@/worker/services/url"; import { ISSUER } from "./helpers"; const SELF = exports.default; const REQUEST_URL = "http://127.0.0.1:8787/api/healthz"; const withEnv = (overrides: Partial): Env => ({ ...env, ...overrides }) as Env; // /api/config gates the public Turnstile site key. Missing // TURNSTILE_SITE_KEY is a deployment error, not a runtime feature // toggle - the verifier already fails closed on missing // TURNSTILE_SECRET_KEY, and surfacing 503 here lets the sign-in / // invite-accept pages render an actionable error instead of an // indefinite loading spinner. // // /api/config also advertises the OIDC issuer (so the landing page // reflects the deployed origin) and the configured social providers // (so the UI only renders buttons that can complete the flow). A // provider counts as configured only when both client_id AND // client_secret are set; otherwise a half-configured provider would // fail at the IdP round-trip. describe("/api/config", () => { it("returns 503 when TURNSTILE_SITE_KEY is unset", async () => { const original = env.TURNSTILE_SITE_KEY; try { (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = ""; const res = await SELF.fetch(`${ISSUER}/api/config`); expect(res.status).toBe(503); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("turnstile_unavailable"); } finally { (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = original; } }); it("returns siteKey + issuer + socialProviders + operator identity when configured", async () => { const originalSite = env.TURNSTILE_SITE_KEY; try { (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = "test-site-key"; const res = await SELF.fetch(`${ISSUER}/api/config`); expect(res.status).toBe(200); const body = (await res.json()) as { turnstileSiteKey?: string; issuer?: string; socialProviders?: string[]; operatorName?: string; operatorContactEmail?: string; }; expect(body.turnstileSiteKey).toBe("test-site-key"); expect(body.issuer).toBe(ISSUER); expect(Array.isArray(body.socialProviders)).toBe(true); expect(body.operatorName).toBe("Test Operator"); expect(body.operatorContactEmail).toBe("ops@test.example"); } finally { (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = originalSite; } }); it("returns 503 when OPERATOR_NAME or OPERATOR_CONTACT_EMAIL is unset", async () => { const originalName = env.OPERATOR_NAME; try { (env as { OPERATOR_NAME: string }).OPERATOR_NAME = ""; const res = await SELF.fetch(`${ISSUER}/api/config`); expect(res.status).toBe(503); const body = (await res.json()) as { error?: string }; expect(body.error).toBe("operator_unconfigured"); } finally { (env as { OPERATOR_NAME: string }).OPERATOR_NAME = originalName; } }); it("excludes a provider when only its client_id is set (secret missing)", async () => { const originalId = env.GITHUB_OAUTH_CLIENT_ID; const originalSecret = env.GITHUB_OAUTH_CLIENT_SECRET; const originalSite = env.TURNSTILE_SITE_KEY; try { (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = "test-site-key"; (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = "gh-client-id"; (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = ""; const res = await SELF.fetch(`${ISSUER}/api/config`); expect(res.status).toBe(200); const body = (await res.json()) as { socialProviders?: string[] }; expect(body.socialProviders).not.toContain("github"); } finally { (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = originalId; (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = originalSecret; (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = originalSite; } }); it("includes a provider when both client_id and client_secret are set", async () => { const originalId = env.GITHUB_OAUTH_CLIENT_ID; const originalSecret = env.GITHUB_OAUTH_CLIENT_SECRET; const originalSite = env.TURNSTILE_SITE_KEY; try { (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = "test-site-key"; (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = "gh-client-id"; (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = "gh-client-secret"; const res = await SELF.fetch(`${ISSUER}/api/config`); expect(res.status).toBe(200); const body = (await res.json()) as { socialProviders?: string[] }; expect(body.socialProviders).toContain("github"); } finally { (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = originalId; (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = originalSecret; (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = originalSite; } }); }); describe("resolveBaseUrl — canonicalization", () => { it("returns the canonical origin for a plain https URL", () => { expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev" }), REQUEST_URL)).toBe( "https://auth.limic.dev", ); }); it("strips a trailing slash", () => { expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev/" }), REQUEST_URL)).toBe( "https://auth.limic.dev", ); }); it("strips the default port", () => { expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev:443/" }), REQUEST_URL)).toBe( "https://auth.limic.dev", ); }); it("preserves a non-default port for loopback dev", () => { expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://localhost:5174" }), REQUEST_URL)).toBe( "http://localhost:5174", ); }); it("accepts localhost subdomains and 127/8 addresses for loopback dev", () => { expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://acme.localhost:5176" }), REQUEST_URL)).toBe( "http://acme.localhost:5176", ); expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://localhost.:5176" }), REQUEST_URL)).toBe( "http://localhost.:5176", ); expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://127.1.2.3:5176" }), REQUEST_URL)).toBe( "http://127.1.2.3:5176", ); }); it("rejects a non-root pathname", () => { expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev/path" }), REQUEST_URL)).toThrow( /BETTER_AUTH_URL/, ); }); it("rejects a query string", () => { expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev?x=1" }), REQUEST_URL)).toThrow( /BETTER_AUTH_URL/, ); }); it("rejects a hash fragment", () => { expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev#frag" }), REQUEST_URL)).toThrow( /BETTER_AUTH_URL/, ); }); it("rejects embedded credentials", () => { expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://user:pass@auth.limic.dev" }), REQUEST_URL)).toThrow( /BETTER_AUTH_URL/, ); }); it("rejects http on a non-loopback host", () => { expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://auth.example" }), REQUEST_URL)).toThrow( /BETTER_AUTH_URL/, ); }); it("rejects unsupported schemes", () => { expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "ftp://x.example" }), REQUEST_URL)).toThrow( /BETTER_AUTH_URL/, ); }); it("rejects an unparseable URL", () => { expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "not-a-url" }), REQUEST_URL)).toThrow(/BETTER_AUTH_URL/); }); it("never echoes offending env values into error messages", () => { try { resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://leak-user:leak-pass@auth.limic.dev" }), REQUEST_URL); throw new Error("expected throw"); } catch (e) { const msg = (e as Error).message; expect(msg).not.toMatch(/leak-user/); expect(msg).not.toMatch(/leak-pass/); } }); it("falls back to the request origin when no value is configured", () => { expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "" }), "https://foo.workers.dev/x")).toBe( "https://foo.workers.dev", ); }); }); describe("safe HTTP URL validation", () => { it("allows localhost subdomains with ports for local apps", () => { expect(isSafeHttpUrl("http://acme.localhost:5176/cb")).toBe(true); expect(isSafeHttpUrl("https://acme.localhost:5176")).toBe(true); }); it("rejects non-loopback http URLs", () => { expect(isSafeHttpUrl("http://anvil.limic.dev")).toBe(false); expect(isSafeHttpUrl("http://0.0.0.0:5176")).toBe(false); }); }); describe("resolveIssuer — canonicalization", () => { it("canonicalizes a configured OIDC_ISSUER", () => { expect(resolveIssuer(withEnv({ OIDC_ISSUER: "https://auth.limic.dev/" }), REQUEST_URL)).toBe( "https://auth.limic.dev", ); }); it("rejects a pathful OIDC_ISSUER", () => { expect(() => resolveIssuer(withEnv({ OIDC_ISSUER: "https://auth.limic.dev/oidc" }), REQUEST_URL)).toThrow( /OIDC_ISSUER/, ); }); it("falls back to resolveBaseUrl when OIDC_ISSUER is empty", () => { expect(resolveIssuer(withEnv({ OIDC_ISSUER: "", BETTER_AUTH_URL: "https://auth.limic.dev" }), REQUEST_URL)).toBe( "https://auth.limic.dev", ); }); it("propagates a base-URL error when OIDC_ISSUER is empty and BETTER_AUTH_URL is malformed", () => { expect(() => resolveIssuer(withEnv({ OIDC_ISSUER: "", BETTER_AUTH_URL: "https://auth.limic.dev/path" }), REQUEST_URL), ).toThrow(/BETTER_AUTH_URL/); }); });