1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Result from "effect/Result";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Fly.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// Deterministic HMAC material. Never logged.
19const HMAC_A: ReadonlyArray<number> = Array.from(
20 { length: 32 },
21 (_, i) => (i * 7 + 3) % 256,
22);
23const HMAC_B: ReadonlyArray<number> = Array.from(
24 { length: 32 },
25 (_, i) => (i * 11 + 5) % 256,
26);
27
28const waitUntilGone = (appName: string, secretName: string) =>
29 machines
30 .getSecretKey({
31 app_name: appName,
32 secret_name: secretName,
33 })
34 .pipe(
35 Effect.as("found" as const),
36 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
37 Effect.repeat({
38 schedule: Schedule.spaced("1 second"),
39 until: (status) => status === "gone",
40 times: 10,
41 }),
42 );
43
44const waitAppGone = (appName: string) =>
45 machines.getApp({ app_name: appName }).pipe(
46 Effect.as("found" as const),
47 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
48 Effect.repeat({
49 schedule: Schedule.spaced("1 second"),
50 until: (status) => status === "gone",
51 times: 10,
52 }),
53 );
54
55/**
56 * Ungated probe: hit listSecretKeys without KMS entitlement so the
57 * distilled tag stays pinned. A missing app is `NotFound` when the token
58 * can use KMS; otherwise the typed entitlement tag is asserted.
59 */
60test.provider(
61 "listSecretKeys probe asserts a typed tag",
62 () =>
63 Effect.gen(function* () {
64 const result = yield* Effect.result(
65 machines.listSecretKeys({
66 app_name: "alchemy-kms-probe-missing",
67 }),
68 );
69 expect(Result.isFailure(result)).toBe(true);
70 if (Result.isFailure(result)) {
71 expect(["NotFound", "Forbidden"]).toContain(result.failure._tag);
72 }
73 }).pipe(logLevel),
74 {
75 tags: [
76 "provider:fly",
77 "provider:fly:machine",
78 "provider:fly:secretkey",
79 "live",
80 ],
81 timeout: 90_000,
82 },
83);
84
85test.provider(
86 "create, update, and delete a secret key",
87 (stack) =>
88 Effect.gen(function* () {
89 yield* stack.destroy();
90
91 const created = yield* stack.deploy(
92 Effect.gen(function* () {
93 const app = yield* Fly.App("Site");
94 const key = yield* Fly.SecretKey("Signing", {
95 app,
96 type: "hs256",
97 value: HMAC_A,
98 });
99 return { app, key };
100 }),
101 );
102
103 expect(created.key.appName).toEqual(created.app.appName);
104 expect(created.key.name).toEqual(expect.any(String));
105 expect(created.key.name.length).toBeGreaterThan(0);
106 expect(created.key.type).toEqual("hs256");
107
108 const fetched = yield* machines.getSecretKey({
109 app_name: created.key.appName,
110 secret_name: created.key.name,
111 });
112 expect(fetched.name).toEqual(created.key.name);
113 expect(fetched.type).toEqual("hs256");
114
115 const updated = yield* stack.deploy(
116 Effect.gen(function* () {
117 const app = yield* Fly.App("Site");
118 const key = yield* Fly.SecretKey("Signing", {
119 app,
120 type: "hs256",
121 value: HMAC_B,
122 });
123 return { app, key };
124 }),
125 );
126
127 expect(updated.key.appName).toEqual(created.key.appName);
128 expect(updated.key.name).toEqual(created.key.name);
129 expect(updated.key.type).toEqual("hs256");
130
131 const refetched = yield* machines.getSecretKey({
132 app_name: updated.key.appName,
133 secret_name: updated.key.name,
134 });
135 expect(refetched.name).toEqual(updated.key.name);
136
137 yield* stack.destroy();
138
139 const keyGone = yield* waitUntilGone(
140 created.key.appName,
141 created.key.name,
142 );
143 expect(keyGone).toEqual("gone");
144 const appGone = yield* waitAppGone(created.app.appName);
145 expect(appGone).toEqual("gone");
146 }).pipe(logLevel),
147 {
148 tags: [
149 "provider:fly",
150 "provider:fly:app",
151 "provider:fly:machine",
152 "provider:fly:secretkey",
153 "live",
154 ],
155 timeout: 120_000,
156 },
157);
158
159test.provider(
160 "generate a nacl_sign key",
161 (stack) =>
162 Effect.gen(function* () {
163 yield* stack.destroy();
164
165 const created = yield* stack.deploy(
166 Effect.gen(function* () {
167 const app = yield* Fly.App("Site");
168 const key = yield* Fly.SecretKey("Generated", {
169 app,
170 type: "nacl_sign",
171 });
172 return { app, key };
173 }),
174 );
175
176 expect(created.key.type).toEqual("nacl_sign");
177 expect(created.key.publicKey).toEqual(expect.any(String));
178
179 const fetched = yield* machines.getSecretKey({
180 app_name: created.key.appName,
181 secret_name: created.key.name,
182 });
183 expect(fetched.type).toEqual("nacl_sign");
184 expect(fetched.public_key).toEqual(created.key.publicKey);
185
186 yield* stack.destroy();
187
188 const gone = yield* waitUntilGone(created.key.appName, created.key.name);
189 expect(gone).toEqual("gone");
190 }).pipe(logLevel),
191 {
192 tags: [
193 "provider:fly",
194 "provider:fly:app",
195 "provider:fly:machine",
196 "provider:fly:secretkey",
197 "live",
198 ],
199 timeout: 120_000,
200 },
201);
202
203test.provider(
204 "replace when name changes",
205 (stack) =>
206 Effect.gen(function* () {
207 yield* stack.destroy();
208
209 const created = yield* stack.deploy(
210 Effect.gen(function* () {
211 const app = yield* Fly.App("Site");
212 const key = yield* Fly.SecretKey("ReplaceKey", {
213 app,
214 type: "nacl_sign",
215 });
216 return { app, key };
217 }),
218 );
219
220 const nextName =
221 created.key.name.slice(0, -1) +
222 (created.key.name.endsWith("z") ? "y" : "z");
223
224 const replaced = yield* stack.deploy(
225 Effect.gen(function* () {
226 const app = yield* Fly.App("Site");
227 const key = yield* Fly.SecretKey("ReplaceKey", {
228 app,
229 name: nextName,
230 type: "nacl_sign",
231 });
232 return { app, key };
233 }),
234 );
235
236 expect(replaced.key.name).toEqual(nextName);
237 expect(replaced.key.name).not.toEqual(created.key.name);
238 expect(replaced.key.appName).toEqual(created.key.appName);
239
240 const fetched = yield* machines.getSecretKey({
241 app_name: replaced.key.appName,
242 secret_name: replaced.key.name,
243 });
244 expect(fetched.name).toEqual(replaced.key.name);
245
246 const oldGone = yield* waitUntilGone(
247 created.key.appName,
248 created.key.name,
249 );
250 expect(oldGone).toEqual("gone");
251
252 yield* stack.destroy();
253
254 const gone = yield* waitUntilGone(
255 replaced.key.appName,
256 replaced.key.name,
257 );
258 expect(gone).toEqual("gone");
259 }).pipe(logLevel),
260 {
261 tags: [
262 "provider:fly",
263 "provider:fly:app",
264 "provider:fly:machine",
265 "provider:fly:secretkey",
266 "live",
267 ],
268 timeout: 120_000,
269 },
270);
271
272test.provider(
273 "list enumerates the deployed secret key",
274 (stack) =>
275 Effect.gen(function* () {
276 yield* stack.destroy();
277
278 const deployed = yield* stack.deploy(
279 Effect.gen(function* () {
280 const app = yield* Fly.App("Site");
281 const key = yield* Fly.SecretKey("ListKey", {
282 app,
283 type: "nacl_sign",
284 });
285 return { app, key };
286 }),
287 );
288
289 const provider = yield* Provider.findProvider(Fly.SecretKey);
290 const all = yield* provider.list();
291 const found = all.find(
292 (key) =>
293 key.appName === deployed.key.appName &&
294 key.name === deployed.key.name,
295 );
296 expect(found).toBeDefined();
297 expect(found?.type).toEqual(deployed.key.type);
298
299 yield* stack.destroy();
300
301 const gone = yield* waitUntilGone(
302 deployed.key.appName,
303 deployed.key.name,
304 );
305 expect(gone).toEqual("gone");
306 }).pipe(logLevel),
307 {
308 tags: [
309 "provider:fly",
310 "provider:fly:app",
311 "provider:fly:machine",
312 "provider:fly:secretkey",
313 "live",
314 ],
315 timeout: 120_000,
316 },
317);