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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
github-oauth.test.ts12.2 KB · 361 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
/**
 * Block L6 — "Sign in with GitHub" tests.
 *
 * Pure network helpers (`buildGithubAuthorizeUrl`, `exchangeGithubCode`,
 * `fetchGithubUserinfo`, `fetchGithubPrimaryEmail`) drive an injected
 * fetch — see K2's `crontech-deploy.test.ts` for the DI pattern these
 * mirror — so tests never touch the real GitHub API.
 *
 * `findOrCreateUserFromGithub` is exercised indirectly: we assert the
 * subject-prefix contract by inspecting the function's source — touching
 * the DB-backed flow itself is left to integration. Route-auth smokes
 * confirm `/login/github` redirects correctly when disabled vs enabled.
 */

import { describe, it, expect } from "bun:test";
import app from "../app";
import {
  buildGithubAuthorizeUrl,
  exchangeGithubCode,
  fetchGithubPrimaryEmail,
  fetchGithubUserinfo,
  type FetchImpl,
} from "../lib/github-oauth";
import { findOrCreateUserFromGithub } from "../lib/sso";
import type { SsoConfig } from "../db/schema";

// ----------------------------------------------------------------------------
// Test fixtures
// ----------------------------------------------------------------------------

function ghCfg(overrides: Partial<SsoConfig> = {}): SsoConfig {
  return {
    id: "github",
    enabled: true,
    providerName: "GitHub",
    issuer: "https://github.com",
    authorizationEndpoint: "https://github.com/login/oauth/authorize",
    tokenEndpoint: "https://github.com/login/oauth/access_token",
    userinfoEndpoint: "https://api.github.com/user",
    clientId: "Iv1.testclientid",
    clientSecret: "topsecret",
    scopes: "read:user user:email",
    allowedEmailDomains: null,
    autoCreateUsers: true,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  } as SsoConfig;
}

interface Capture {
  url: string;
  init: RequestInit;
}

function captureFetch(
  responder: (callIdx: number, url: string) => Response | Promise<Response>
): { calls: Capture[]; fn: FetchImpl } {
  const calls: Capture[] = [];
  const fn = (async (
    input: RequestInfo | URL,
    init: RequestInit = {}
  ): Promise<Response> => {
    const i = calls.length;
    const url = String(input);
    calls.push({ url, init });
    return responder(i, url);
  }) as unknown as FetchImpl;
  return { calls, fn };
}

function jsonResponse(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json" },
  });
}

// ----------------------------------------------------------------------------
// buildGithubAuthorizeUrl
// ----------------------------------------------------------------------------

describe("github-oauth — buildGithubAuthorizeUrl", () => {
  it("includes all required OAuth params", () => {
    const url = buildGithubAuthorizeUrl(
      ghCfg(),
      "state-xyz",
      "https://app.example.com/login/github/callback"
    );
    const u = new URL(url);
    expect(u.origin + u.pathname).toBe(
      "https://github.com/login/oauth/authorize"
    );
    expect(u.searchParams.get("client_id")).toBe("Iv1.testclientid");
    expect(u.searchParams.get("redirect_uri")).toBe(
      "https://app.example.com/login/github/callback"
    );
    expect(u.searchParams.get("response_type")).toBe("code");
    expect(u.searchParams.get("scope")).toBe("read:user user:email");
    expect(u.searchParams.get("state")).toBe("state-xyz");
  });

  it("falls back to default scopes when empty", () => {
    const url = buildGithubAuthorizeUrl(
      { ...ghCfg(), scopes: "" } as any,
      "s",
      "https://app/cb"
    );
    expect(new URL(url).searchParams.get("scope")).toBe(
      "read:user user:email"
    );
  });

  it("throws when client_id or endpoint is missing", () => {
    expect(() =>
      buildGithubAuthorizeUrl(
        { ...ghCfg(), clientId: null } as any,
        "s",
        "https://app/cb"
      )
    ).toThrow();
    expect(() =>
      buildGithubAuthorizeUrl(
        { ...ghCfg(), authorizationEndpoint: null } as any,
        "s",
        "https://app/cb"
      )
    ).toThrow();
  });
});

// ----------------------------------------------------------------------------
// exchangeGithubCode
// ----------------------------------------------------------------------------

describe("github-oauth — exchangeGithubCode", () => {
  it("posts urlencoded body with Accept: application/json", async () => {
    const { calls, fn } = captureFetch(() =>
      jsonResponse({ access_token: "gho_xxx", token_type: "bearer" })
    );
    const out = await exchangeGithubCode(
      ghCfg(),
      "abc-code",
      "https://app/cb",
      fn
    );
    expect(out.accessToken).toBe("gho_xxx");
    expect(calls.length).toBe(1);
    expect(calls[0]!.url).toBe(
      "https://github.com/login/oauth/access_token"
    );
    expect(calls[0]!.init.method).toBe("POST");
    const headers = (calls[0]!.init.headers || {}) as Record<string, string>;
    // header keys are lowercased by our impl
    expect(headers["accept"] || headers["Accept"]).toBe("application/json");
    expect(
      (headers["content-type"] || headers["Content-Type"]) as string
    ).toContain("application/x-www-form-urlencoded");
    expect(String(calls[0]!.init.body)).toContain("code=abc-code");
    expect(String(calls[0]!.init.body)).toContain("client_id=Iv1.testclientid");
  });

  it("throws on non-2xx response", async () => {
    const { fn } = captureFetch(
      () => new Response("nope", { status: 500 })
    );
    await expect(
      exchangeGithubCode(ghCfg(), "c", "https://app/cb", fn)
    ).rejects.toThrow(/github token endpoint 500/);
  });

  it("throws when github returns an error JSON body", async () => {
    const { fn } = captureFetch(() =>
      jsonResponse({
        error: "bad_verification_code",
        error_description: "The code is invalid.",
      })
    );
    await expect(
      exchangeGithubCode(ghCfg(), "c", "https://app/cb", fn)
    ).rejects.toThrow(/bad_verification_code/);
  });

  it("throws when access_token is missing", async () => {
    const { fn } = captureFetch(() => jsonResponse({ token_type: "bearer" }));
    await expect(
      exchangeGithubCode(ghCfg(), "c", "https://app/cb", fn)
    ).rejects.toThrow(/missing access_token/);
  });
});

// ----------------------------------------------------------------------------
// fetchGithubUserinfo
// ----------------------------------------------------------------------------

describe("github-oauth — fetchGithubUserinfo", () => {
  it("parses a typical github /user response", async () => {
    const sample = {
      id: 12345,
      login: "octocat",
      name: "The Octocat",
      email: "octocat@example.com",
      avatar_url: "https://avatars.githubusercontent.com/u/12345",
    };
    const { calls, fn } = captureFetch(() => jsonResponse(sample));
    const u = await fetchGithubUserinfo("gho_abc", fn);
    expect(u).toEqual({
      id: 12345,
      login: "octocat",
      name: "The Octocat",
      email: "octocat@example.com",
      avatarUrl: "https://avatars.githubusercontent.com/u/12345",
    });
    expect(calls[0]!.url).toBe("https://api.github.com/user");
    const headers = (calls[0]!.init.headers || {}) as Record<string, string>;
    expect(headers["authorization"]).toBe("Bearer gho_abc");
  });

  it("tolerates null name and email + missing avatar", async () => {
    const { fn } = captureFetch(() =>
      jsonResponse({
        id: 99,
        login: "ghost",
        name: null,
        email: null,
      })
    );
    const u = await fetchGithubUserinfo("tok", fn);
    expect(u.id).toBe(99);
    expect(u.login).toBe("ghost");
    expect(u.name).toBeNull();
    expect(u.email).toBeNull();
    expect(u.avatarUrl).toBeNull();
  });

  it("throws when id or login is missing", async () => {
    const { fn } = captureFetch(() => jsonResponse({ login: "no-id" }));
    await expect(fetchGithubUserinfo("tok", fn)).rejects.toThrow(
      /missing id or login/
    );
  });

  it("throws on non-2xx", async () => {
    const { fn } = captureFetch(
      () => new Response("nope", { status: 401 })
    );
    await expect(fetchGithubUserinfo("tok", fn)).rejects.toThrow(
      /github \/user 401/
    );
  });
});

// ----------------------------------------------------------------------------
// fetchGithubPrimaryEmail — email-privacy fallback
// ----------------------------------------------------------------------------

describe("github-oauth — fetchGithubPrimaryEmail", () => {
  it("returns the primary+verified entry", async () => {
    const { calls, fn } = captureFetch(() =>
      jsonResponse([
        { email: "secondary@example.com", primary: false, verified: true },
        { email: "primary@example.com", primary: true, verified: true },
      ])
    );
    const email = await fetchGithubPrimaryEmail("tok", fn);
    expect(email).toBe("primary@example.com");
    expect(calls[0]!.url).toBe("https://api.github.com/user/emails");
  });

  it("returns null when the primary email is unverified", async () => {
    const { fn } = captureFetch(() =>
      jsonResponse([
        { email: "primary@example.com", primary: true, verified: false },
      ])
    );
    expect(await fetchGithubPrimaryEmail("tok", fn)).toBeNull();
  });

  it("returns null when no entry is both primary and verified", async () => {
    const { fn } = captureFetch(() =>
      jsonResponse([
        { email: "a@example.com", primary: false, verified: true },
        { email: "b@example.com", primary: true, verified: false },
      ])
    );
    expect(await fetchGithubPrimaryEmail("tok", fn)).toBeNull();
  });

  it("returns null on non-2xx response", async () => {
    const { fn } = captureFetch(
      () => new Response("nope", { status: 403 })
    );
    expect(await fetchGithubPrimaryEmail("tok", fn)).toBeNull();
  });

  it("returns null when the response is not an array", async () => {
    const { fn } = captureFetch(() => jsonResponse({ oops: true }));
    expect(await fetchGithubPrimaryEmail("tok", fn)).toBeNull();
  });

  it("returns null when fetch throws", async () => {
    const fn = (async () => {
      throw new Error("network down");
    }) as unknown as FetchImpl;
    expect(await fetchGithubPrimaryEmail("tok", fn)).toBeNull();
  });
});

// ----------------------------------------------------------------------------
// findOrCreateUserFromGithub — subject prefix contract
// ----------------------------------------------------------------------------

describe("github-oauth — findOrCreateUserFromGithub", () => {
  it("is exported and references the github:<id> subject namespace", () => {
    expect(typeof findOrCreateUserFromGithub).toBe("function");
    // Snapshot-test the subject-prefix contract by inspecting source — we
    // need the literal "github:" prefix to live in the function so that
    // multi-IdP `subject` collisions are impossible.
    const src = findOrCreateUserFromGithub.toString();
    expect(src).toContain("github:");
    expect(src).toMatch(/github:\$\{[^}]+\.id\}|github:`/);
  });
});

// ----------------------------------------------------------------------------
// Route auth smokes
// ----------------------------------------------------------------------------

describe("github-oauth — route auth", () => {
  it("GET /admin/github-oauth without auth → 302 /login", async () => {
    const res = await app.request("/admin/github-oauth");
    expect(res.status).toBe(302);
    expect(res.headers.get("location") || "").toContain("/login");
  });

  it("POST /admin/github-oauth without auth → 302 /login", async () => {
    const res = await app.request("/admin/github-oauth", {
      method: "POST",
      body: new URLSearchParams({ client_id: "x" }),
      headers: { "content-type": "application/x-www-form-urlencoded" },
    });
    expect(res.status).toBe(302);
    expect(res.headers.get("location") || "").toContain("/login");
  });

  it("GET /login/github when GitHub OAuth not configured → 302 /login?error=...", async () => {
    const res = await app.request("/login/github");
    expect(res.status).toBe(302);
    const loc = res.headers.get("location") || "";
    expect(loc).toContain("/login");
    expect(loc).toContain("error=");
  });

  it("GET /login/github/callback without state cookie → 302 /login", async () => {
    const res = await app.request(
      "/login/github/callback?code=abc&state=xyz"
    );
    expect(res.status).toBe(302);
    expect(res.headers.get("location") || "").toContain("/login");
  });
});