Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

google-oauth.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

google-oauth.test.tsBlame254 lines · 1 contributor
582cdacClaude1/**
2 * Smoke tests for the Google OAuth login flow + config page.
3 *
4 * Mirrors the structure of github-oauth.test.ts (if it exists). Covers:
5 * - /admin/google-oauth requires admin auth
6 * - /login/google redirects to /login?error= when not configured
7 * - The OAuth URL builder produces a valid Google authorize URL
8 * - The login page renders a "Sign in with Google" button when enabled
9 *
10 * These tests exercise the route registration + the pure helper functions
11 * in src/lib/google-oauth.ts. The full callback flow (token exchange +
12 * userinfo) requires a real Google response and is exercised manually
13 * during deploy validation.
14 */
15
16import { describe, it, expect } from "bun:test";
17import app from "../app";
18import {
19 buildGoogleAuthorizeUrl,
20 fetchGoogleUserinfo,
21 exchangeGoogleCode,
ad3ddf6Claude22 resolveGoogleRedirectUri,
582cdacClaude23} from "../lib/google-oauth";
24
25describe("google-oauth — route registration", () => {
26 it("/admin/google-oauth without auth redirects to /login", async () => {
27 const res = await app.request("/admin/google-oauth", {
28 redirect: "manual",
29 });
30 expect([302, 303]).toContain(res.status);
31 const loc = res.headers.get("location") || "";
32 expect(loc).toContain("/login");
33 });
34
35 it("/login/google when unconfigured redirects to /login with error", async () => {
36 const res = await app.request("/login/google", { redirect: "manual" });
37 expect([302, 303]).toContain(res.status);
38 const loc = res.headers.get("location") || "";
39 expect(loc).toContain("/login?error=");
40 });
41
42 it("/login/google/callback without code params redirects with error", async () => {
43 const res = await app.request("/login/google/callback", {
44 redirect: "manual",
45 });
46 expect([302, 303]).toContain(res.status);
47 const loc = res.headers.get("location") || "";
48 expect(loc).toContain("/login?error=");
49 });
50});
51
52describe("google-oauth — buildGoogleAuthorizeUrl", () => {
53 it("constructs a valid Google authorize URL with required params", () => {
54 const url = buildGoogleAuthorizeUrl(
55 {
56 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
57 clientId: "client-id-123",
58 scopes: "openid email profile",
59 },
60 "state-abc",
61 "https://example.com/login/google/callback",
62 "nonce-xyz"
63 );
64 const u = new URL(url);
65 expect(u.origin + u.pathname).toBe(
66 "https://accounts.google.com/o/oauth2/v2/auth"
67 );
68 expect(u.searchParams.get("client_id")).toBe("client-id-123");
69 expect(u.searchParams.get("response_type")).toBe("code");
70 expect(u.searchParams.get("scope")).toBe("openid email profile");
71 expect(u.searchParams.get("state")).toBe("state-abc");
72 expect(u.searchParams.get("nonce")).toBe("nonce-xyz");
73 expect(u.searchParams.get("redirect_uri")).toBe(
74 "https://example.com/login/google/callback"
75 );
76 // We force account picker so users with multiple Google accounts can
77 // choose; without this Google silently uses the most-recent one.
78 expect(u.searchParams.get("prompt")).toBe("select_account");
79 });
80
81 it("throws when authorization_endpoint or client_id is missing", () => {
82 expect(() =>
83 buildGoogleAuthorizeUrl(
84 {
85 authorizationEndpoint: null,
86 clientId: "abc",
87 scopes: null,
88 } as any,
89 "s",
90 "r",
91 "n"
92 )
93 ).toThrow();
94 expect(() =>
95 buildGoogleAuthorizeUrl(
96 {
97 authorizationEndpoint: "https://x",
98 clientId: null,
99 scopes: null,
100 } as any,
101 "s",
102 "r",
103 "n"
104 )
105 ).toThrow();
106 });
107});
108
ad3ddf6Claude109describe("google-oauth — resolveGoogleRedirectUri (self-healing)", () => {
110 const PATH = "/login/google/callback";
111
112 it("uses an explicit https base URL verbatim (trailing slash trimmed)", () => {
113 expect(
114 resolveGoogleRedirectUri({ configuredBaseUrl: "https://gluecron.com/" })
115 ).toBe(`https://gluecron.com${PATH}`);
116 });
117
118 it("ignores a localhost base URL and derives from the request", () => {
119 // The production failure mode: APP_BASE_URL unset → config.appBaseUrl
120 // defaults to http://localhost:3000. Behind Fly the edge sets
121 // X-Forwarded-Proto:https and forwards the real host, so we must still
122 // produce the public https callback.
123 expect(
124 resolveGoogleRedirectUri({
125 configuredBaseUrl: "http://localhost:3000",
126 forwardedProto: "https",
127 forwardedHost: "gluecron.com",
128 host: "gluecron.com",
129 requestUrl: "http://gluecron.com/login/google",
130 })
131 ).toBe(`https://gluecron.com${PATH}`);
132 });
133
134 it("upgrades a public host to https when no forwarded proto is present", () => {
135 expect(
136 resolveGoogleRedirectUri({
137 host: "gluecron.com",
138 requestUrl: "http://gluecron.com/login/google",
139 })
140 ).toBe(`https://gluecron.com${PATH}`);
141 });
142
143 it("honours X-Forwarded-Host over the raw Host header", () => {
144 expect(
145 resolveGoogleRedirectUri({
146 forwardedProto: "https",
147 forwardedHost: "gluecron.com",
148 host: "internal.fly.dev",
149 requestUrl: "http://internal.fly.dev/login/google",
150 })
151 ).toBe(`https://gluecron.com${PATH}`);
152 });
153
154 it("takes only the first value of a comma-listed forwarded header", () => {
155 expect(
156 resolveGoogleRedirectUri({
157 forwardedProto: "https, http",
158 forwardedHost: "gluecron.com, proxy.internal",
159 requestUrl: "http://x/login/google",
160 })
161 ).toBe(`https://gluecron.com${PATH}`);
162 });
163
164 it("keeps localhost on its request scheme for local dev", () => {
165 expect(
166 resolveGoogleRedirectUri({
167 host: "localhost:3000",
168 requestUrl: "http://localhost:3000/login/google",
169 })
170 ).toBe(`http://localhost:3000${PATH}`);
171 });
172
173 it("falls back to localhost when there is nothing to derive from", () => {
174 expect(resolveGoogleRedirectUri({})).toBe(
175 `http://localhost:3000${PATH}`
176 );
177 });
178});
179
582cdacClaude180describe("google-oauth — exchangeGoogleCode + fetchGoogleUserinfo", () => {
181 it("exchangeGoogleCode posts urlencoded body and returns access_token", async () => {
182 const captured: { url?: string; body?: string } = {};
183 const fakeFetch = (async (url: any, init: any) => {
184 captured.url = String(url);
185 captured.body = String(init.body);
186 return new Response(
187 JSON.stringify({ access_token: "tok-1", id_token: "id-1" }),
188 { status: 200, headers: { "content-type": "application/json" } }
189 );
a004c46Claude190 }) as unknown as typeof fetch;
582cdacClaude191
192 const result = await exchangeGoogleCode(
193 {
194 tokenEndpoint: "https://oauth2.googleapis.com/token",
195 clientId: "cid",
196 clientSecret: "secret",
197 },
198 "auth-code",
199 "https://example.com/cb",
200 fakeFetch
201 );
202 expect(result.accessToken).toBe("tok-1");
203 expect(result.idToken).toBe("id-1");
204 expect(captured.url).toBe("https://oauth2.googleapis.com/token");
205 expect(captured.body).toContain("grant_type=authorization_code");
206 expect(captured.body).toContain("code=auth-code");
207 expect(captured.body).toContain("client_id=cid");
208 });
209
210 it("fetchGoogleUserinfo parses sub + emailVerified correctly", async () => {
211 const fakeFetch = (async () =>
212 new Response(
213 JSON.stringify({
214 sub: "12345",
215 email: "user@example.com",
216 email_verified: true,
217 name: "Jane Doe",
218 picture: "https://lh.example/avatar.jpg",
219 }),
220 { status: 200, headers: { "content-type": "application/json" } }
a004c46Claude221 )) as unknown as typeof fetch;
582cdacClaude222
223 const info = await fetchGoogleUserinfo(
224 { userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo" },
225 "tok",
226 fakeFetch
227 );
228 expect(info.sub).toBe("12345");
229 expect(info.email).toBe("user@example.com");
230 expect(info.emailVerified).toBe(true);
231 expect(info.name).toBe("Jane Doe");
232 });
233
234 it("fetchGoogleUserinfo coerces email_verified=\"true\" string to bool", async () => {
235 const fakeFetch = (async () =>
236 new Response(
237 JSON.stringify({
238 sub: "999",
239 email: "x@y.z",
240 email_verified: "true",
241 name: null,
242 picture: null,
243 }),
244 { status: 200, headers: { "content-type": "application/json" } }
a004c46Claude245 )) as unknown as typeof fetch;
582cdacClaude246
247 const info = await fetchGoogleUserinfo(
248 { userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo" },
249 "tok",
250 fakeFetch
251 );
252 expect(info.emailVerified).toBe(true);
253 });
254});