1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import { ChildProcessSpawner } from "effect/process";
7
8import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
9import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
10import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
11import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
12import * as GitHubCredentials from "./GitHubCredentials.ts";
13
14const TOKEN_VARIABLES = [
15 "GH_TOKEN",
16 "GITHUB_TOKEN",
17 "GH_ENTERPRISE_TOKEN",
18 "GITHUB_ENTERPRISE_TOKEN",
19] as const;
20
21/** Settings keep `enabled` explicit; a host the test leaves it off is enabled. */
22const hostSettings = (
23 hosts: Record<string, { readonly account?: string; readonly enabled?: boolean }>,
24) =>
25 Object.fromEntries(
26 Object.entries(hosts).map(([host, choice]) => [host, { enabled: true, ...choice }]),
27 );
28
29function harness(
30 hosts: Record<string, { readonly account?: string; readonly enabled?: boolean }> = {},
31 signedOut: ReadonlyArray<string> = [],
32 tokens: Record<string, string> = {},
33) {
34 const calls: Array<ReadonlyArray<string>> = [];
35 const run: SourceControlHost.SourceControlHost["Service"]["process"]["run"] = (input) =>
36 Effect.sync(() => {
37 calls.push(input.args);
38 const user = input.args[input.args.indexOf("--user") + 1];
39 if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) {
40 return {
41 exitCode: ChildProcessSpawner.ExitCode(0),
42 stdout: "",
43 stderr: "",
44 stdoutTruncated: false,
45 stderrTruncated: false,
46 };
47 }
48 return {
49 exitCode: ChildProcessSpawner.ExitCode(0),
50 stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n",
51 stderr: "",
52 stdoutTruncated: false,
53 stderrTruncated: false,
54 };
55 });
56 const layer = GitHubCredentials.layer.pipe(
57 Layer.provideMerge(
58 TestSourceControlHost.layer({
59 settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
60 sourceControlHosts: { github: { hosts: hostSettings(hosts), tokens } },
61 }),
62 process: { run },
63 }),
64 ),
65 Layer.provide(NodeCrypto.layer),
66 );
67 return { layer, calls };
68}
69
70describe("GitHubCredentials", () => {
71 beforeEach(() => {
72 for (const name of TOKEN_VARIABLES) vi.stubEnv(name, "");
73 });
74 afterEach(() => {
75 vi.unstubAllEnvs();
76 });
77
78 it.effect("asks gh for the active login when Settings pin nothing", () => {
79 const { layer, calls } = harness();
80 return Effect.gen(function* () {
81 const credentials = yield* GitHubCredentials.GitHubCredentials;
82 const credential = yield* credentials.get("GitHub.com");
83 expect(Redacted.value(credential.token)).toBe("active-token");
84 expect(calls).toEqual([["auth", "token", "--hostname", "github.com"]]);
85 }).pipe(Effect.provide(layer));
86 });
87
88 it.effect("passes --user for an account pinned in Settings", () => {
89 const { layer, calls } = harness({ "github.com": { account: "work" } });
90 return Effect.gen(function* () {
91 const credentials = yield* GitHubCredentials.GitHubCredentials;
92 const credential = yield* credentials.get("github.com");
93 expect(Redacted.value(credential.token)).toBe("token-for-work");
94 expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]);
95 }).pipe(Effect.provide(layer));
96 });
97
98 it.effect("fails a host turned off in Settings without asking gh", () => {
99 const { layer, calls } = harness({ "github.com": { enabled: false } });
100 return Effect.gen(function* () {
101 const credentials = yield* GitHubCredentials.GitHubCredentials;
102 const error = yield* Effect.flip(credentials.get("github.com"));
103 expect(error._tag).toBe("GitHubHostDisabledError");
104 expect(error.message).toBe(
105 "GitHub host github.com is turned off in Settings → Source Control.",
106 );
107 expect(calls).toEqual([]);
108 }).pipe(Effect.provide(layer));
109 });
110
111 it.effect("lets an environment token win over a pinned account, as gh does", () => {
112 vi.stubEnv("GH_TOKEN", "from-env");
113 const { layer, calls } = harness({ "github.com": { account: "work" } });
114 return Effect.gen(function* () {
115 const credentials = yield* GitHubCredentials.GitHubCredentials;
116 const credential = yield* credentials.get("github.com");
117 expect(Redacted.value(credential.token)).toBe("from-env");
118 expect(credential.source).toBe("env");
119 expect(calls).toEqual([]);
120 }).pipe(Effect.provide(layer));
121 });
122
123 it.effect("picks up a changed account on the next request without a restart", () => {
124 const { layer, calls } = harness();
125 return Effect.gen(function* () {
126 const credentials = yield* GitHubCredentials.GitHubCredentials;
127 const settings = yield* TestSourceControlHost.TestSourceControlHostSettings;
128 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
129
130 yield* settings.update({
131 sourceControlHosts: {
132 github: { hosts: { "github.com": { account: "work", enabled: true } } },
133 },
134 });
135 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work");
136
137 yield* settings.update({
138 sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } },
139 });
140 expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe(
141 "GitHubHostDisabledError",
142 );
143
144 yield* settings.update({ sourceControlHosts: { github: { hosts: {} } } });
145 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
146 // The unpinned token stayed cached; only the newly pinned account cost a gh call.
147 expect(calls).toHaveLength(2);
148 }).pipe(Effect.provide(layer));
149 });
150
151 it.effect("falls back to the active login when the pinned one is no longer signed in", () => {
152 const { layer, calls } = harness({ "github.com": { account: "gone" } }, ["gone"]);
153 return Effect.gen(function* () {
154 const credentials = yield* GitHubCredentials.GitHubCredentials;
155 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
156 expect(calls).toEqual([
157 ["auth", "token", "--hostname", "github.com", "--user", "gone"],
158 ["auth", "token", "--hostname", "github.com"],
159 ]);
160 }).pipe(Effect.provide(layer));
161 });
162
163 it.effect("uses a token saved in Settings before GH_TOKEN and gh", () => {
164 vi.stubEnv("GH_TOKEN", "env-token");
165 const { layer, calls } = harness({}, [], { "github.com": "saved-token" });
166 return Effect.gen(function* () {
167 const credentials = yield* GitHubCredentials.GitHubCredentials;
168 const credential = yield* credentials.get("github.com");
169 expect(Redacted.value(credential.token)).toBe("saved-token");
170 expect(credential.source).toBe("settings");
171 expect(calls).toEqual([]);
172 }).pipe(Effect.provide(layer));
173 });
174
175 it.effect("keeps a host turned off even with a token saved in Settings", () => {
176 const { layer } = harness({ "github.com": { enabled: false } }, [], {
177 "github.com": "saved-token",
178 });
179 return Effect.gen(function* () {
180 const credentials = yield* GitHubCredentials.GitHubCredentials;
181 expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe(
182 "GitHubHostDisabledError",
183 );
184 }).pipe(Effect.provide(layer));
185 });
186});