Skip to content
File

Blob: tests/worker/config.test.ts

typescript248 lines
1import { env, exports } from "cloudflare:workers";
2import { describe, expect, it } from "vitest";
3 
4import { resolveBaseUrl, resolveIssuer } from "@/worker/config";
5import { isSafeHttpUrl } from "@/worker/services/url";
6 
7import { ISSUER } from "./helpers";
8 
9const SELF = exports.default;
10const REQUEST_URL = "http://127.0.0.1:8787/api/healthz";
11 
12const withEnv = (overrides: Partial<Env>): Env => ({ ...env, ...overrides }) as Env;
13 
14// /api/config gates the public Turnstile site key. Missing
15// TURNSTILE_SITE_KEY is a deployment error, not a runtime feature
16// toggle - the verifier already fails closed on missing
17// TURNSTILE_SECRET_KEY, and surfacing 503 here lets the sign-in /
18// invite-accept pages render an actionable error instead of an
19// indefinite loading spinner.
20//
21// /api/config also advertises the OIDC issuer (so the landing page
22// reflects the deployed origin) and the configured social providers
23// (so the UI only renders buttons that can complete the flow). A
24// provider counts as configured only when both client_id AND
25// client_secret are set; otherwise a half-configured provider would
26// fail at the IdP round-trip.
27describe("/api/config", () => {
28 it("returns 503 when TURNSTILE_SITE_KEY is unset", async () => {
29 const original = env.TURNSTILE_SITE_KEY;
30 try {
31 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = "";
32 const res = await SELF.fetch(`${ISSUER}/api/config`);
33 expect(res.status).toBe(503);
34 const body = (await res.json()) as { error?: string };
35 expect(body.error).toBe("turnstile_unavailable");
36 } finally {
37 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = original;
38 }
39 });
40 
41 it("returns siteKey + issuer + socialProviders + operator identity when configured", async () => {
42 const originalSite = env.TURNSTILE_SITE_KEY;
43 try {
44 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = "test-site-key";
45 const res = await SELF.fetch(`${ISSUER}/api/config`);
46 expect(res.status).toBe(200);
47 const body = (await res.json()) as {
48 turnstileSiteKey?: string;
49 issuer?: string;
50 socialProviders?: string[];
51 operatorName?: string;
52 operatorContactEmail?: string;
53 };
54 expect(body.turnstileSiteKey).toBe("test-site-key");
55 expect(body.issuer).toBe(ISSUER);
56 expect(Array.isArray(body.socialProviders)).toBe(true);
57 expect(body.operatorName).toBe("Test Operator");
58 expect(body.operatorContactEmail).toBe("ops@test.example");
59 } finally {
60 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = originalSite;
61 }
62 });
63 
64 it("returns 503 when OPERATOR_NAME or OPERATOR_CONTACT_EMAIL is unset", async () => {
65 const originalName = env.OPERATOR_NAME;
66 try {
67 (env as { OPERATOR_NAME: string }).OPERATOR_NAME = "";
68 const res = await SELF.fetch(`${ISSUER}/api/config`);
69 expect(res.status).toBe(503);
70 const body = (await res.json()) as { error?: string };
71 expect(body.error).toBe("operator_unconfigured");
72 } finally {
73 (env as { OPERATOR_NAME: string }).OPERATOR_NAME = originalName;
74 }
75 });
76 
77 it("excludes a provider when only its client_id is set (secret missing)", async () => {
78 const originalId = env.GITHUB_OAUTH_CLIENT_ID;
79 const originalSecret = env.GITHUB_OAUTH_CLIENT_SECRET;
80 const originalSite = env.TURNSTILE_SITE_KEY;
81 try {
82 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = "test-site-key";
83 (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = "gh-client-id";
84 (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = "";
85 const res = await SELF.fetch(`${ISSUER}/api/config`);
86 expect(res.status).toBe(200);
87 const body = (await res.json()) as { socialProviders?: string[] };
88 expect(body.socialProviders).not.toContain("github");
89 } finally {
90 (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = originalId;
91 (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = originalSecret;
92 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = originalSite;
93 }
94 });
95 
96 it("includes a provider when both client_id and client_secret are set", async () => {
97 const originalId = env.GITHUB_OAUTH_CLIENT_ID;
98 const originalSecret = env.GITHUB_OAUTH_CLIENT_SECRET;
99 const originalSite = env.TURNSTILE_SITE_KEY;
100 try {
101 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = "test-site-key";
102 (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = "gh-client-id";
103 (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = "gh-client-secret";
104 const res = await SELF.fetch(`${ISSUER}/api/config`);
105 expect(res.status).toBe(200);
106 const body = (await res.json()) as { socialProviders?: string[] };
107 expect(body.socialProviders).toContain("github");
108 } finally {
109 (env as { GITHUB_OAUTH_CLIENT_ID: string }).GITHUB_OAUTH_CLIENT_ID = originalId;
110 (env as { GITHUB_OAUTH_CLIENT_SECRET: string }).GITHUB_OAUTH_CLIENT_SECRET = originalSecret;
111 (env as { TURNSTILE_SITE_KEY: string }).TURNSTILE_SITE_KEY = originalSite;
112 }
113 });
114});
115 
116describe("resolveBaseUrl — canonicalization", () => {
117 it("returns the canonical origin for a plain https URL", () => {
118 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev" }), REQUEST_URL)).toBe(
119 "https://auth.limic.dev",
120 );
121 });
122 
123 it("strips a trailing slash", () => {
124 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev/" }), REQUEST_URL)).toBe(
125 "https://auth.limic.dev",
126 );
127 });
128 
129 it("strips the default port", () => {
130 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev:443/" }), REQUEST_URL)).toBe(
131 "https://auth.limic.dev",
132 );
133 });
134 
135 it("preserves a non-default port for loopback dev", () => {
136 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://localhost:5174" }), REQUEST_URL)).toBe(
137 "http://localhost:5174",
138 );
139 });
140 
141 it("accepts localhost subdomains and 127/8 addresses for loopback dev", () => {
142 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://acme.localhost:5176" }), REQUEST_URL)).toBe(
143 "http://acme.localhost:5176",
144 );
145 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://localhost.:5176" }), REQUEST_URL)).toBe(
146 "http://localhost.:5176",
147 );
148 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://127.1.2.3:5176" }), REQUEST_URL)).toBe(
149 "http://127.1.2.3:5176",
150 );
151 });
152 
153 it("rejects a non-root pathname", () => {
154 expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev/path" }), REQUEST_URL)).toThrow(
155 /BETTER_AUTH_URL/,
156 );
157 });
158 
159 it("rejects a query string", () => {
160 expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev?x=1" }), REQUEST_URL)).toThrow(
161 /BETTER_AUTH_URL/,
162 );
163 });
164 
165 it("rejects a hash fragment", () => {
166 expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://auth.limic.dev#frag" }), REQUEST_URL)).toThrow(
167 /BETTER_AUTH_URL/,
168 );
169 });
170 
171 it("rejects embedded credentials", () => {
172 expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://user:pass@auth.limic.dev" }), REQUEST_URL)).toThrow(
173 /BETTER_AUTH_URL/,
174 );
175 });
176 
177 it("rejects http on a non-loopback host", () => {
178 expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "http://auth.example" }), REQUEST_URL)).toThrow(
179 /BETTER_AUTH_URL/,
180 );
181 });
182 
183 it("rejects unsupported schemes", () => {
184 expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "ftp://x.example" }), REQUEST_URL)).toThrow(
185 /BETTER_AUTH_URL/,
186 );
187 });
188 
189 it("rejects an unparseable URL", () => {
190 expect(() => resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "not-a-url" }), REQUEST_URL)).toThrow(/BETTER_AUTH_URL/);
191 });
192 
193 it("never echoes offending env values into error messages", () => {
194 try {
195 resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "https://leak-user:leak-pass@auth.limic.dev" }), REQUEST_URL);
196 throw new Error("expected throw");
197 } catch (e) {
198 const msg = (e as Error).message;
199 expect(msg).not.toMatch(/leak-user/);
200 expect(msg).not.toMatch(/leak-pass/);
201 }
202 });
203 
204 it("falls back to the request origin when no value is configured", () => {
205 expect(resolveBaseUrl(withEnv({ BETTER_AUTH_URL: "" }), "https://foo.workers.dev/x")).toBe(
206 "https://foo.workers.dev",
207 );
208 });
209});
210 
211describe("safe HTTP URL validation", () => {
212 it("allows localhost subdomains with ports for local apps", () => {
213 expect(isSafeHttpUrl("http://acme.localhost:5176/cb")).toBe(true);
214 expect(isSafeHttpUrl("https://acme.localhost:5176")).toBe(true);
215 });
216 
217 it("rejects non-loopback http URLs", () => {
218 expect(isSafeHttpUrl("http://anvil.limic.dev")).toBe(false);
219 expect(isSafeHttpUrl("http://0.0.0.0:5176")).toBe(false);
220 });
221});
222 
223describe("resolveIssuer — canonicalization", () => {
224 it("canonicalizes a configured OIDC_ISSUER", () => {
225 expect(resolveIssuer(withEnv({ OIDC_ISSUER: "https://auth.limic.dev/" }), REQUEST_URL)).toBe(
226 "https://auth.limic.dev",
227 );
228 });
229 
230 it("rejects a pathful OIDC_ISSUER", () => {
231 expect(() => resolveIssuer(withEnv({ OIDC_ISSUER: "https://auth.limic.dev/oidc" }), REQUEST_URL)).toThrow(
232 /OIDC_ISSUER/,
233 );
234 });
235 
236 it("falls back to resolveBaseUrl when OIDC_ISSUER is empty", () => {
237 expect(resolveIssuer(withEnv({ OIDC_ISSUER: "", BETTER_AUTH_URL: "https://auth.limic.dev" }), REQUEST_URL)).toBe(
238 "https://auth.limic.dev",
239 );
240 });
241 
242 it("propagates a base-URL error when OIDC_ISSUER is empty and BETTER_AUTH_URL is malformed", () => {
243 expect(() =>
244 resolveIssuer(withEnv({ OIDC_ISSUER: "", BETTER_AUTH_URL: "https://auth.limic.dev/path" }), REQUEST_URL),
245 ).toThrow(/BETTER_AUTH_URL/);
246 });
247});