1import * as Fly from "@/Fly";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import type { HttpClientError } from "effect/http/HttpClientError";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import type * as HttpClientResponse from "effect/http/HttpClientResponse";
13import BindingsApi from "./fixtures/bindings-api.ts";
14import {
15 BoxKey,
16 Marker,
17 PLAINTEXT,
18 PublicIp,
19 SECRET_NAME,
20 SignKey,
21 Site,
22} from "./fixtures/bindings-shared.ts";
23
24const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
25 providers: Fly.providers(),
26});
27
28const logLevel = Effect.provideService(
29 MinimumLogLevel,
30 process.env.DEBUG ? "Debug" : "Info",
31);
32
33class NotReady extends Data.TaggedError("NotReady")<{
34 status: number;
35 body?: unknown;
36}> {
37 override get message() {
38 return this.body === undefined
39 ? `status ${this.status}`
40 : `status ${this.status}: ${JSON.stringify(this.body)}`;
41 }
42}
43
44const Stack = Alchemy.Stack(
45 "FlyBindingsFixture",
46 {
47 providers: Fly.providers(),
48 state: Alchemy.localState(),
49 },
50 Effect.gen(function* () {
51 const site = yield* Site;
52 const secret = yield* Marker;
53 const box = yield* BoxKey;
54 const signing = yield* SignKey;
55 const ip = yield* PublicIp;
56 const api = yield* BindingsApi;
57 return {
58 appName: site.appName,
59 secretName: secret.name,
60 boxName: box.name,
61 signName: signing.name,
62 ip: ip.ip,
63 url: api.url ?? `https://${site.appName}.fly.dev`,
64 };
65 }),
66);
67
68const stack = beforeAll(deploy(Stack), { timeout: 180_000 });
69
70afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
71 timeout: 120_000,
72});
73
74const retryTransient = {
75 while: (e: NotReady | HttpClientError) =>
76 e._tag === "NotReady" &&
77 (e.status === 0 ||
78 e.status === 404 ||
79 e.status === 502 ||
80 e.status === 503),
81 schedule: Schedule.spaced("2 seconds"),
82 times: 8,
83} as const;
84
85/** Decode the body, turning any non-200 (or undecodable body) into `NotReady`. */
86const readJson = (
87 res: HttpClientResponse.HttpClientResponse,
88): Effect.Effect<unknown, NotReady> =>
89 res.json.pipe(
90 Effect.catch(() => Effect.fail(new NotReady({ status: res.status }))),
91 Effect.flatMap((body) =>
92 res.status === 200
93 ? Effect.succeed(body)
94 : Effect.fail(new NotReady({ status: res.status, body })),
95 ),
96 );
97
98const getJson = (path: string) =>
99 Effect.gen(function* () {
100 const { url } = yield* stack;
101 const client = yield* HttpClient.HttpClient;
102 return yield* client.get(`${url}${path}`).pipe(
103 Effect.timeoutOrElse({
104 duration: "8 seconds",
105 orElse: () => Effect.fail(new NotReady({ status: 0 })),
106 }),
107 Effect.flatMap(readJson),
108 Effect.retry(retryTransient),
109 Effect.timeout("45 seconds"),
110 );
111 });
112
113const postJson = (path: string, body: unknown) =>
114 Effect.gen(function* () {
115 const { url } = yield* stack;
116 return yield* HttpClient.execute(
117 HttpClientRequest.post(`${url}${path}`).pipe(
118 HttpClientRequest.bodyJsonUnsafe(body),
119 ),
120 ).pipe(
121 Effect.timeoutOrElse({
122 duration: "8 seconds",
123 orElse: () => Effect.fail(new NotReady({ status: 0 })),
124 }),
125 Effect.flatMap(readJson),
126 Effect.retry(retryTransient),
127 Effect.timeout("45 seconds"),
128 );
129 });
130
131describe(
132 "Fly Bindings",
133 {
134 tags: [
135 "provider:fly",
136 "provider:fly:app",
137 "provider:fly:ipassignment",
138 "provider:fly:secret",
139 "provider:fly:secretkey",
140 "provider:fly:service",
141 "provider:fly:sprite",
142 "live",
143 ],
144 },
145 () => {
146 test(
147 "fixture is reachable over fly.dev",
148 Effect.gen(function* () {
149 const out = yield* stack;
150 expect(out.url).toContain(".fly.dev");
151 expect(out.ip).toEqual(expect.any(String));
152 const body = (yield* getJson("/health")) as {
153 ok: boolean;
154 appName?: string;
155 secretName?: string;
156 hasFlySecretMarkerEnv?: boolean;
157 hasToken?: boolean;
158 };
159 expect(body.ok).toEqual(true);
160 expect(body.appName).toEqual(out.appName);
161 expect(body.secretName).toEqual(out.secretName);
162 expect(body.hasToken).toEqual(true);
163 expect(body.hasFlySecretMarkerEnv).toEqual(false);
164 }).pipe(logLevel),
165 { timeout: 120_000 },
166 );
167
168 describe("Exec", () => {
169 test(
170 "executes on a Sprite from the Service",
171 Effect.gen(function* () {
172 const body = (yield* getJson("/sprite")) as {
173 stdout: string;
174 exitCode: number;
175 };
176 expect(body.stdout.trim()).toEqual("sprite-runtime-binding");
177 expect(body.exitCode).toEqual(0);
178 }).pipe(logLevel),
179 { timeout: 60_000 },
180 );
181 });
182
183 describe("GetSecret", () => {
184 test(
185 "reads the App secret from the Service",
186 Effect.gen(function* () {
187 const out = yield* stack;
188 const body = (yield* getJson("/secret")) as {
189 ok: boolean;
190 name: string;
191 hasValue: boolean;
192 };
193 expect(body.ok).toEqual(true);
194 expect(body.name).toEqual(out.secretName);
195 expect(body.name).toEqual(SECRET_NAME);
196 expect(body.hasValue).toEqual(true);
197 }).pipe(logLevel),
198 { timeout: 60_000 },
199 );
200 });
201
202 describe("ListSecrets", () => {
203 test(
204 "lists secrets on the App from the Service",
205 Effect.gen(function* () {
206 const out = yield* stack;
207 const body = (yield* getJson("/secrets")) as { names: string[] };
208 expect(body.names).toContain(out.secretName);
209 }).pipe(logLevel),
210 { timeout: 60_000 },
211 );
212 });
213
214 describe("WriteSecret", () => {
215 test(
216 "creates a secret from the Service",
217 Effect.gen(function* () {
218 const created = (yield* postJson("/secret", {
219 name: "BINDING_CREATED",
220 value: "from-fixture",
221 })) as { ok: boolean; name: string };
222 expect(created.ok).toEqual(true);
223 const listed = (yield* getJson("/secrets")) as { names: string[] };
224 expect(listed.names).toContain("BINDING_CREATED");
225 }).pipe(logLevel),
226 { timeout: 60_000 },
227 );
228 });
229
230 describe("Encrypt", () => {
231 test(
232 "encrypts and decrypts on the Service",
233 Effect.gen(function* () {
234 const enc = (yield* postJson("/encrypt", { text: PLAINTEXT })) as {
235 ciphertext: string;
236 };
237 expect(enc.ciphertext.length).toBeGreaterThan(0);
238 const dec = (yield* postJson("/decrypt", {
239 ciphertext: enc.ciphertext,
240 })) as { text: string };
241 expect(dec.text).toEqual(PLAINTEXT);
242 }).pipe(logLevel),
243 { timeout: 60_000 },
244 );
245 });
246
247 describe("Sign", () => {
248 test(
249 "signs and verifies on the Service",
250 Effect.gen(function* () {
251 const signed = (yield* postJson("/sign", { text: PLAINTEXT })) as {
252 signature: string;
253 };
254 expect(signed.signature.length).toBeGreaterThan(0);
255 const checked = (yield* postJson("/verify", {
256 text: PLAINTEXT,
257 signature: signed.signature,
258 })) as { valid: boolean };
259 expect(checked.valid).toEqual(true);
260 }).pipe(logLevel),
261 { timeout: 60_000 },
262 );
263 });
264 },
265);