1import { readEnvCredentials } from "@/GitHub/AuthProvider";
2import { GitHubCredentials, fromToken } from "@/GitHub/Credentials";
3import {
4 githubHostname,
5 normalizeGitHubBaseUrl,
6 resolveGitHubBaseUrlFromEnv,
7} from "@/GitHub/BaseUrl";
8import { gitHubBaseUrlChanged, octokitFor } from "@/GitHub/Octokit";
9import * as ConfigProvider from "effect/ConfigProvider";
10import * as Effect from "effect/Effect";
11import * as Result from "effect/Result";
12import { describe, expect, test } from "alchemy-test";
13
14const normalize = (input: string) =>
15 Effect.runSync(normalizeGitHubBaseUrl(input));
16
17describe(
18 "normalizeGitHubBaseUrl",
19 { tags: ["unit", "provider:github", "local"] },
20 () => {
21 test("github.com hosts normalize to undefined (Octokit default)", () => {
22 expect(normalize("github.com")).toBeUndefined();
23 expect(normalize("https://github.com")).toBeUndefined();
24 expect(normalize("https://www.github.com/")).toBeUndefined();
25 expect(normalize("https://api.github.com")).toBeUndefined();
26 });
27
28 test("GitHub Enterprise Server hosts get /api/v3 appended", () => {
29 expect(normalize("github.example.com")).toBe(
30 "https://github.example.com/api/v3",
31 );
32 expect(normalize("https://github.example.com")).toBe(
33 "https://github.example.com/api/v3",
34 );
35 expect(normalize("https://github.example.com/")).toBe(
36 "https://github.example.com/api/v3",
37 );
38 });
39
40 test("an explicit API path is honored as-is", () => {
41 expect(normalize("https://github.example.com/api/v3")).toBe(
42 "https://github.example.com/api/v3",
43 );
44 expect(normalize("https://github.example.com/api/v3/")).toBe(
45 "https://github.example.com/api/v3",
46 );
47 });
48
49 test("non-default ports and http protocol are preserved", () => {
50 expect(normalize("http://github.example.com:8080")).toBe(
51 "http://github.example.com:8080/api/v3",
52 );
53 });
54
55 test("data-residency ghe.com hosts get the api. prefix", () => {
56 expect(normalize("acme.ghe.com")).toBe("https://api.acme.ghe.com");
57 expect(normalize("https://acme.ghe.com")).toBe(
58 "https://api.acme.ghe.com",
59 );
60 expect(normalize("https://api.acme.ghe.com")).toBe(
61 "https://api.acme.ghe.com",
62 );
63 });
64
65 test("invalid input fails with AuthError", () => {
66 const result = Effect.runSync(
67 Effect.result(normalizeGitHubBaseUrl("https://")),
68 );
69 expect(Result.isFailure(result)).toBe(true);
70 });
71 },
72);
73
74describe(
75 "githubHostname",
76 { tags: ["unit", "provider:github", "local"] },
77 () => {
78 test("GHES base URL yields the plain host", () => {
79 expect(githubHostname("https://github.example.com/api/v3")).toBe(
80 "github.example.com",
81 );
82 });
83
84 test("data-residency base URL drops the api. prefix", () => {
85 expect(githubHostname("https://api.acme.ghe.com")).toBe("acme.ghe.com");
86 });
87 },
88);
89
90describe(
91 "resolveGitHubBaseUrlFromEnv",
92 { tags: ["unit", "provider:github", "local"] },
93 () => {
94 // The default ConfigProvider snapshots process.env, so tests inject their
95 // environment via a ConfigProvider layer instead of mutating process.env.
96 const resolveWith = (env: Record<string, string>) =>
97 Effect.runSync(
98 resolveGitHubBaseUrlFromEnv.pipe(
99 Effect.provideService(
100 ConfigProvider.ConfigProvider,
101 ConfigProvider.fromEnv({ env }),
102 ),
103 ),
104 );
105
106 test("GITHUB_BASE_URL wins over GITHUB_API_URL", () => {
107 expect(
108 resolveWith({
109 GITHUB_BASE_URL: "https://github.example.com",
110 GITHUB_API_URL: "https://api.github.com",
111 }),
112 ).toBe("https://github.example.com/api/v3");
113 });
114
115 test("GITHUB_API_URL pointing at github.com resolves to undefined", () => {
116 expect(
117 resolveWith({ GITHUB_API_URL: "https://api.github.com" }),
118 ).toBeUndefined();
119 });
120
121 test("GH_HOST resolves as a bare hostname", () => {
122 expect(resolveWith({ GH_HOST: "github.example.com" })).toBe(
123 "https://github.example.com/api/v3",
124 );
125 });
126
127 test("resolves to undefined when nothing is set", () => {
128 expect(resolveWith({})).toBeUndefined();
129 });
130 },
131);
132
133describe(
134 "readEnvCredentials",
135 { tags: ["unit", "provider:github", "local"] },
136 () => {
137 const readWith = (env: Record<string, string>, configBaseUrl?: string) =>
138 Effect.runSync(
139 readEnvCredentials(configBaseUrl).pipe(
140 Effect.provideService(
141 ConfigProvider.ConfigProvider,
142 ConfigProvider.fromEnv({ env }),
143 ),
144 ),
145 );
146
147 test("enterprise token variables win on an enterprise host", () => {
148 const creds = readWith({
149 GITHUB_BASE_URL: "https://github.example.com",
150 GH_ENTERPRISE_TOKEN: "enterprise-token",
151 GITHUB_TOKEN: "standard-token",
152 });
153 expect(creds.baseUrl).toBe("https://github.example.com/api/v3");
154 expect(creds.source.details).toBe("GH_ENTERPRISE_TOKEN");
155 });
156
157 test("enterprise token variables are ignored on github.com", () => {
158 const creds = readWith({
159 GH_ENTERPRISE_TOKEN: "enterprise-token",
160 GITHUB_TOKEN: "standard-token",
161 });
162 expect(creds.baseUrl).toBeUndefined();
163 expect(creds.source.details).toBe("GITHUB_TOKEN");
164 });
165
166 test("an explicit config baseUrl wins over the environment", () => {
167 const creds = readWith(
168 {
169 GITHUB_BASE_URL: "https://other.example.com",
170 GITHUB_ACCESS_TOKEN: "token",
171 },
172 "https://github.example.com/api/v3",
173 );
174 expect(creds.baseUrl).toBe("https://github.example.com/api/v3");
175 expect(creds.source.details).toBe("GITHUB_ACCESS_TOKEN");
176 });
177
178 test("fails with AuthError when no token variable is set", () => {
179 const result = Effect.runSync(
180 Effect.result(
181 readEnvCredentials().pipe(
182 Effect.provideService(
183 ConfigProvider.ConfigProvider,
184 ConfigProvider.fromEnv({ env: {} }),
185 ),
186 ),
187 ),
188 );
189 expect(Result.isFailure(result)).toBe(true);
190 });
191 },
192);
193
194describe(
195 "gitHubBaseUrlChanged",
196 { tags: ["unit", "provider:github", "local"] },
197 () => {
198 // The comparison resolves each side through the full fallback chain, so it
199 // needs the ambient credentials host — `undefined` in props means "use the
200 // credentials' host", not "github.com".
201 const changed = (
202 a: string | undefined,
203 b: string | undefined,
204 credsBaseUrl?: string,
205 ) =>
206 Effect.runSync(
207 gitHubBaseUrlChanged({ baseUrl: a }, { baseUrl: b }).pipe(
208 Effect.provide(
209 fromToken(
210 "test-token",
211 credsBaseUrl !== undefined
212 ? { baseUrl: credsBaseUrl }
213 : undefined,
214 ),
215 ),
216 ),
217 );
218
219 test("cosmetic rewrites of the same host do not count as a change", () => {
220 expect(
221 changed("github.example.com", "https://github.example.com/api/v3"),
222 ).toBe(false);
223 expect(changed(undefined, "github.com")).toBe(false);
224 expect(changed("acme.ghe.com", "https://api.acme.ghe.com")).toBe(false);
225 });
226
227 test("moving between hosts counts as a change", () => {
228 expect(changed(undefined, "github.example.com")).toBe(true);
229 expect(changed("github.example.com", undefined)).toBe(true);
230 expect(changed("github.example.com", "other.example.com")).toBe(true);
231 });
232
233 test("making the ambient enterprise host explicit is not a change", () => {
234 // providers({ baseUrl: "github.example.com" }): prop undefined and prop
235 // "github.example.com" resolve to the same effective host.
236 expect(
237 changed(undefined, "github.example.com", "github.example.com"),
238 ).toBe(false);
239 expect(
240 changed("github.example.com", undefined, "github.example.com"),
241 ).toBe(false);
242 expect(
243 changed(
244 undefined,
245 "https://github.example.com/api/v3",
246 "github.example.com",
247 ),
248 ).toBe(false);
249 });
250
251 test("explicit github.com under an ambient enterprise host IS a change", () => {
252 // prop undefined resolves to the enterprise host; prop "github.com"
253 // pins the resource back to github.com — different physical instance.
254 expect(changed(undefined, "github.com", "github.example.com")).toBe(true);
255 expect(changed("github.com", undefined, "github.example.com")).toBe(true);
256 });
257
258 test("undefined on both sides is never a change", () => {
259 expect(changed(undefined, undefined)).toBe(false);
260 expect(changed(undefined, undefined, "github.example.com")).toBe(false);
261 });
262 },
263);
264
265describe("octokitFor", { tags: ["unit", "provider:github", "local"] }, () => {
266 const octokitOf = (
267 credsBaseUrl: string | undefined,
268 resourceBaseUrl: string | undefined,
269 ) =>
270 Effect.runSync(
271 octokitFor(resourceBaseUrl).pipe(
272 Effect.provide(
273 fromToken(
274 "test-token",
275 credsBaseUrl !== undefined ? { baseUrl: credsBaseUrl } : undefined,
276 ),
277 ),
278 ),
279 );
280
281 test("falls back to the credentials' host when no override is given", () => {
282 const octokit = octokitOf("github.example.com", undefined);
283 expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe(
284 "https://github.example.com/api/v3",
285 );
286 });
287
288 test("a per-resource baseUrl overrides the credentials' host", () => {
289 const octokit = octokitOf("github.example.com", "other.example.com");
290 expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe(
291 "https://other.example.com/api/v3",
292 );
293 });
294
295 test("an explicit github.com override wins over an enterprise credential host", () => {
296 const octokit = octokitOf("github.example.com", "github.com");
297 expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe(
298 "https://api.github.com",
299 );
300 });
301});
302
303describe("fromToken", { tags: ["unit", "provider:github", "local"] }, () => {
304 const octokitOf = (options?: { baseUrl?: string }) =>
305 Effect.runSync(
306 Effect.gen(function* () {
307 const creds = yield* yield* GitHubCredentials;
308 return creds.octokit();
309 }).pipe(Effect.provide(fromToken("test-token", options))),
310 );
311
312 test("defaults to api.github.com", () => {
313 const octokit = octokitOf();
314 expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe(
315 "https://api.github.com",
316 );
317 });
318
319 test("passes the normalized enterprise base URL to Octokit", () => {
320 const octokit = octokitOf({ baseUrl: "github.example.com" });
321 expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe(
322 "https://github.example.com/api/v3",
323 );
324 });
325});