1import { assert, it } from "@effect/vitest";
2import { DeviceHostId, SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts";
3import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
4import * as Effect from "effect/Effect";
5
6import {
7 collectSourceControlHostSecretReferences,
8 invalidateSettingsSecrets,
9 resolveSourceControlHostSecrets,
10} from "./sourceControlHostSecrets.ts";
11
12const source = (reference: string) => ({
13 provider: SecretProviderId.make("homelab"),
14 reference,
15});
16
17const secretsByReference = new Map([
18 ["bitbucket#token", "bb_from_provider"],
19 ["github#token", "ghp_from_provider"],
20]);
21
22const perHostHosts = {
23 github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } },
24};
25
26const hosts = {
27 bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") },
28 gitcafe: { token: source("missing#token") },
29 github: { tokens: { "github.com": "ghp_literal" } },
30};
31
32function countingResolver() {
33 const reads: Array<string> = [];
34 const resolver = SecretResolver.SecretResolver.of({
35 label: (provider) => Effect.succeed(provider),
36 read: (references) =>
37 Effect.sync(() =>
38 references.map((reference) => {
39 reads.push(reference.reference);
40 return secretsByReference.get(reference.reference);
41 }),
42 ),
43 invalidate: () => Effect.void,
44 });
45 return { reads, resolver };
46}
47
48it.effect(
49 "replaces only the requested kind's credential references with the secrets they name",
50 () =>
51 Effect.gen(function* () {
52 const { reads, resolver } = countingResolver();
53 const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "bitbucket");
54 assert.deepEqual(resolved, {
55 ...hosts,
56 bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" },
57 });
58 assert.deepEqual(reads, ["bitbucket#token"]);
59 }),
60);
61
62it.effect("keeps a reference the provider cannot read, so it never passes for an unset one", () =>
63 Effect.gen(function* () {
64 const { reads, resolver } = countingResolver();
65 const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "gitcafe");
66 assert.deepEqual(resolved.gitcafe, { token: source("missing#token") });
67 assert.deepEqual(reads, ["missing#token"]);
68 }),
69);
70
71it.effect("leaves the resolver unused for a kind that holds no reference", () =>
72 Effect.gen(function* () {
73 const { reads, resolver } = countingResolver();
74 const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "github");
75 assert.strictEqual(resolved, hosts);
76 assert.deepEqual(reads, []);
77 }),
78);
79
80it.effect("replaces a per-host token reference and keeps the literal beside it", () =>
81 Effect.gen(function* () {
82 const { reads, resolver } = countingResolver();
83 const resolved = yield* resolveSourceControlHostSecrets(resolver, perHostHosts, "github");
84 assert.deepEqual(resolved, {
85 github: { tokens: { "github.com": "ghp_from_provider", "ghe.acme.test": "ghp_literal" } },
86 });
87 assert.deepEqual(reads, ["github#token"]);
88 }),
89);
90
91it("collects the references held in credential fields and skips literal values", () => {
92 assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [
93 source("missing#token"),
94 source("bitbucket#token"),
95 ]);
96 assert.deepEqual(collectSourceControlHostSecretReferences(perHostHosts), [
97 source("github#token"),
98 ]);
99 assert.deepEqual(
100 collectSourceControlHostSecretReferences({
101 bitbucket: { accessToken: "literal" },
102 github: { tokens: { "github.com": "ghp_literal" } },
103 }),
104 [],
105 );
106});
107
108it.effect("reads a collected reference again once it is invalidated", () =>
109 Effect.gen(function* () {
110 let current = "stale";
111 const cache = new Map<string, string>();
112 const resolver = SecretResolver.SecretResolver.of({
113 label: (provider) => Effect.succeed(provider),
114 read: (references) =>
115 Effect.sync(() =>
116 references.map((reference) => {
117 const hit = cache.get(reference.reference);
118 if (hit !== undefined) return hit;
119 cache.set(reference.reference, current);
120 return current;
121 }),
122 ),
123 invalidate: (references) =>
124 Effect.sync(() => {
125 if (references === undefined) cache.clear();
126 else for (const reference of references) cache.delete(reference.reference);
127 }),
128 });
129 const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket");
130 const usageLimitSources = {
131 [UsageLimitSourceId.make("referenced")]: {
132 kind: "cliproxy",
133 url: "https://hub.test",
134 managementKey: source("cliproxy#key"),
135 enabled: true,
136 },
137 [UsageLimitSourceId.make("literal")]: {
138 kind: "cliproxy",
139 url: "https://hub.test",
140 managementKey: "plain-key",
141 enabled: true,
142 },
143 } as const;
144 const easDeviceHosts = [
145 {
146 kind: "eas" as const,
147 id: DeviceHostId.make("ref"),
148 label: "Ref",
149 maxSessionMinutes: 30,
150 idleMinutes: 10,
151 maxConcurrentSessions: 2,
152 accessToken: source("expo#token"),
153 },
154 ];
155 const simlockDeviceHosts = [
156 {
157 kind: "simlock" as const,
158 id: DeviceHostId.make("pool"),
159 label: "Pool",
160 gatewayUrl: "https://simlock.test:4700",
161 agentToken: source("simlock#token"),
162 maxLeases: 2,
163 maxLeaseMinutes: 60,
164 },
165 ];
166 cache.set("cliproxy#key", "stale");
167 cache.set("voice#key", "stale");
168 cache.set("expo#token", "stale");
169 cache.set("simlock#token", "stale");
170 assert.deepEqual((yield* read()).bitbucket, {
171 email: "me@example.test",
172 accessToken: "stale",
173 });
174 current = "rotated";
175 yield* invalidateSettingsSecrets(
176 resolver,
177 Effect.succeed({
178 sourceControlHosts: hosts,
179 cloudSpeechCredentials: { openai: source("voice#key"), xai: "", openrouter: "" },
180 usageLimitSources,
181 textGenerators: {},
182 easDeviceHosts,
183 simlockDeviceHosts,
184 }),
185 );
186 assert.isFalse(cache.has("cliproxy#key"));
187 assert.isFalse(cache.has("voice#key"));
188 assert.isFalse(cache.has("expo#token"));
189 assert.isFalse(cache.has("simlock#token"));
190 assert.deepEqual((yield* read()).bitbucket, {
191 email: "me@example.test",
192 accessToken: "rotated",
193 });
194 current = "rotated again";
195 yield* invalidateSettingsSecrets(resolver, Effect.fail("settings unreadable"));
196 assert.deepEqual((yield* read()).bitbucket, {
197 email: "me@example.test",
198 accessToken: "rotated again",
199 });
200 }),
201);