1/**
2 * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates:
3 *
4 * - `class GitCafeCredentials extends Context.Service` keyed
5 * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with
6 * `get(host) => Effect<GitCafeCredential, GitCafeCredentialUnavailableError>` and
7 * `invalidate(host) => Effect<void>`.
8 * - `interface GitCafeCredential { host; token: Redacted<string>; source: "env" | "cafe" }`.
9 * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`,
10 * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`).
11 * - `environmentToken(host, env): string | null` — `CAFE_TOKEN` only for the host `CAFE_HOST`
12 * names (git.cafe when unset).
13 * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and
14 * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`.
15 */
16import { assert, describe, it } from "@effect/vitest";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as PlatformError from "effect/PlatformError";
20import * as Redacted from "effect/Redacted";
21import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
24import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
25
26import * as GitCafeCredentials from "./GitCafeCredentials.ts";
27
28const processOutput = TestSourceControlHost.processOutput;
29
30function harness(
31 run: SourceControlHost.SourceControlHost["Service"]["process"]["run"],
32 env: NodeJS.ProcessEnv = {},
33) {
34 const calls: Array<SourceControlHost.SourceControlProcessInput> = [];
35 const layer = GitCafeCredentials.layer.pipe(
36 Layer.provide(
37 TestSourceControlHost.layer({
38 process: {
39 run: (input) => {
40 calls.push(input);
41 return run(input);
42 },
43 },
44 }),
45 ),
46 Layer.provide(Layer.succeed(HostProcess.Environment, env)),
47 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
48 );
49 return { layer, calls };
50}
51
52describe("GitCafeCredentials", () => {
53 it("hands CAFE_TOKEN only to the host cafe itself targets", () => {
54 const token = { CAFE_TOKEN: "env-token" };
55 assert.strictEqual(GitCafeCredentials.environmentToken("git.cafe", token), "env-token");
56 assert.isNull(GitCafeCredentials.environmentToken("staging.git.cafe", token));
57 for (const CAFE_HOST of ["https://staging.git.cafe/api", "staging.git.cafe"]) {
58 const staging = { ...token, CAFE_HOST };
59 assert.strictEqual(
60 GitCafeCredentials.environmentToken("staging.git.cafe", staging),
61 "env-token",
62 );
63 assert.isNull(GitCafeCredentials.environmentToken("git.cafe", staging));
64 }
65 assert.isNull(GitCafeCredentials.environmentToken("git.cafe", { CAFE_TOKEN: " " }));
66 });
67
68 it.effect("prefers CAFE_TOKEN without running cafe", () => {
69 const { layer, calls } = harness(() => Effect.die("cafe must not run"), {
70 CAFE_TOKEN: "env-token",
71 });
72 return Effect.gen(function* () {
73 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
74 const credential = yield* credentials.get("git.cafe");
75 assert.strictEqual(Redacted.value(credential.token), "env-token");
76 assert.strictEqual(credential.source, "env");
77 assert.deepStrictEqual(calls, []);
78 }).pipe(Effect.provide(layer));
79 });
80
81 it.effect("uses a token saved in Settings for git.cafe only, ahead of CAFE_TOKEN", () => {
82 const calls: Array<SourceControlHost.SourceControlProcessInput> = [];
83 const layer = GitCafeCredentials.layer.pipe(
84 Layer.provideMerge(
85 TestSourceControlHost.layer({
86 process: {
87 run: (input) => {
88 calls.push(input);
89 return Effect.succeed(processOutput("password=gct_staging\n"));
90 },
91 },
92 }),
93 ),
94 Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })),
95 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
96 );
97 return Effect.gen(function* () {
98 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
99 const settings = yield* TestSourceControlHost.TestSourceControlHostSettings;
100 yield* settings.update({ sourceControlHosts: { gitcafe: { token: "saved-token" } } });
101
102 const saved = yield* credentials.get("git.cafe");
103 assert.strictEqual(Redacted.value(saved.token), "saved-token");
104 assert.strictEqual(saved.source, "settings");
105 assert.deepStrictEqual(yield* credentials.cliEnv("git.cafe"), { CAFE_TOKEN: "saved-token" });
106 // Staging never receives the production token saved in Settings.
107 assert.deepStrictEqual(yield* credentials.cliEnv("staging.git.cafe"), {});
108 assert.strictEqual(
109 Redacted.value((yield* credentials.get("staging.git.cafe")).token),
110 "gct_staging",
111 );
112
113 // Removing it applies at once: the environment token takes over again.
114 yield* settings.update({ sourceControlHosts: { gitcafe: { token: "" } } });
115 assert.strictEqual((yield* credentials.get("git.cafe")).source, "env");
116 }).pipe(Effect.provide(layer));
117 });
118
119 it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => {
120 const { layer, calls } = harness(() =>
121 Effect.succeed(
122 processOutput("protocol=https\nhost=staging.git.cafe\nusername=alice\npassword=gct_1\n"),
123 ),
124 );
125 return Effect.gen(function* () {
126 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
127 // A token for staging is never taken from an environment that targets production.
128 const credential = yield* credentials.get("Staging.Git.Cafe");
129 assert.strictEqual(Redacted.value(credential.token), "gct_1");
130 assert.strictEqual(credential.source, "cafe");
131 yield* credentials.get("staging.git.cafe");
132 assert.strictEqual(calls.length, 1);
133 assert.strictEqual(calls[0]?.command, "cafe");
134 assert.deepStrictEqual(calls[0]?.args, [
135 "auth",
136 "http",
137 "credential",
138 "--host",
139 "https://staging.git.cafe/api",
140 "get",
141 ]);
142 assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n");
143 // A refused token is asked for again rather than reused.
144 yield* credentials.invalidate("staging.git.cafe");
145 yield* credentials.get("staging.git.cafe");
146 assert.strictEqual(calls.length, 2);
147 }).pipe(Effect.provide(layer));
148 });
149
150 it.effect.each([
151 [
152 "a missing CLI",
153 Effect.fail(
154 new VcsProcessSpawnError({
155 operation: "GitCafeCredentials.get",
156 command: "cafe",
157 cwd: "/server",
158 cause: PlatformError.systemError({
159 _tag: "NotFound",
160 module: "ChildProcess",
161 method: "spawn",
162 }),
163 }),
164 ),
165 "GitCafeCliMissingError",
166 ],
167 [
168 "a helper with no login",
169 Effect.fail(
170 new VcsProcessExitError({
171 operation: "GitCafeCredentials.get",
172 command: "cafe",
173 cwd: "/server",
174 exitCode: 1,
175 detail: "not signed in",
176 }),
177 ),
178 "GitCafeNotSignedInError",
179 ],
180 [
181 "an answer without a password",
182 Effect.succeed(processOutput("username=alice\n")),
183 "GitCafeNotSignedInError",
184 ],
185 ] as const)("reports %s", ([, result, tag]) => {
186 const { layer } = harness(() => result);
187 return Effect.gen(function* () {
188 const credentials = yield* GitCafeCredentials.GitCafeCredentials;
189 const error = yield* credentials.get("git.cafe").pipe(Effect.flip);
190 assert.strictEqual(error._tag, tag);
191 }).pipe(Effect.provide(layer));
192 });
193});