1import * as ACME from "@/ACME";
2import * as Fly from "@/Fly";
3import { FlyAuth } from "@/Fly/AuthProvider";
4import { fromAuthProvider } from "@/Fly/Credentials";
5import * as Alchemy from "@/index.ts";
6import * as Test from "@/Test/Alchemy";
7import * as Cloudflare from "@/Cloudflare";
8import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
9import { findZoneByName } from "@/Cloudflare/Zone/lookup";
10import * as machines from "@distilled.cloud/fly-io/machines";
11import * as Layer from "effect/Layer";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Redacted from "effect/Redacted";
15import * as Schedule from "effect/Schedule";
16import * as HttpClient from "effect/http/HttpClient";
17import * as HttpClientRequest from "effect/http/HttpClientRequest";
18import CertificatesApi, {
19 CertIp,
20 CertSite,
21} from "./fixtures/certificates-api.ts";
22
23/**
24 * Runtime certificate management on a Fly App: `request` a Fly-managed
25 * certificate, upload a Let's Encrypt staging certificate, check, get, and remove.
26 */
27const providers = Layer.mergeAll(
28 Fly.providers(),
29 Cloudflare.providers(),
30 ACME.providers(),
31);
32const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ providers });
33const zoneName =
34 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
35const UPLOAD_HOST = `alchemy-wc-upload.${zoneName}`;
36const REQUEST_HOST = `alchemy-wc-request.${zoneName}`;
37
38const Stack = Alchemy.Stack(
39 "FlyWriteCertificatesFixture",
40 { providers, state: Alchemy.localState() },
41 Effect.gen(function* () {
42 const site = yield* CertSite;
43 yield* CertIp;
44 const api = yield* CertificatesApi;
45 const { accountId } = yield* yield* CloudflareEnvironment;
46 const zone = yield* findZoneByName({ accountId, name: zoneName }).pipe(
47 Effect.orDie,
48 );
49 if (!zone)
50 return yield* Effect.die(new Error(`zone ${zoneName} not found`));
51 const account = yield* ACME.Account("Issuer", {
52 ca: ACME.LetsEncryptStaging,
53 termsOfServiceAgreed: true,
54 });
55 const certificate = yield* ACME.Certificate("Upload", {
56 account,
57 identifiers: [UPLOAD_HOST],
58 solver: Cloudflare.DNS.AcmeSolver({ zoneId: zone.id }),
59 revokeOnDelete: true,
60 });
61 return {
62 appName: site.appName,
63 url: api.url,
64 chain: certificate.chain,
65 privateKey: certificate.privateKey,
66 };
67 }),
68);
69
70// This hook builds/pushes the runtime image and issues a real certificate.
71// The test body's timeout does not cover setup; reserve time for both here.
72const stack = beforeAll(deploy(Stack), { timeout: 180_000 });
73afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
74
75interface Reply {
76 ok: boolean;
77 value?: any;
78 error?: string;
79}
80
81test(
82 "uploads, inspects and removes a custom certificate, and requests a Fly-managed one",
83 Effect.gen(function* () {
84 const { url, appName, chain, privateKey } = yield* stack;
85 const client = yield* HttpClient.HttpClient;
86
87 const call = (path: string, body?: unknown) =>
88 Effect.gen(function* () {
89 const request =
90 body === undefined
91 ? HttpClientRequest.get(`${url}${path}`)
92 : HttpClientRequest.post(`${url}${path}`).pipe(
93 HttpClientRequest.bodyJsonUnsafe(body),
94 );
95 const response = yield* client.execute(request);
96 const text = yield* response.text;
97 return JSON.parse(text || "null") as Reply;
98 });
99
100 const health = yield* call("/health").pipe(
101 Effect.retry({
102 schedule: Schedule.spaced("2 seconds"),
103 times: 8,
104 }),
105 );
106 expect(health.ok).toBe(true);
107
108 const uploaded = yield* call("/upload", {
109 hostname: UPLOAD_HOST,
110 fullchain: chain,
111 privateKey: Redacted.value(privateKey),
112 });
113 expect(uploaded.error).toBeUndefined();
114 expect(uploaded.ok).toBe(true);
115 expect(uploaded.value?.hostname).toBe(UPLOAD_HOST);
116 const observed = yield* machines
117 .getAppCertificate({
118 app_name: appName,
119 hostname: UPLOAD_HOST,
120 })
121 .pipe(Effect.provide(fromAuthProvider().pipe(Layer.provide(FlyAuth))));
122 expect(observed.hostname).toBe(UPLOAD_HOST);
123
124 const fetched = yield* call(`/get?hostname=${UPLOAD_HOST}`);
125 expect(fetched.ok).toBe(true);
126 expect(fetched.value?.hostname).toBe(UPLOAD_HOST);
127 expect(
128 (fetched.value?.certificates ?? []).some(
129 (c: { source?: string }) => c.source === "custom",
130 ),
131 ).toBe(true);
132
133 // Re-upload replaces in place (conflict → delete + create).
134 const reuploaded = yield* call("/upload", {
135 hostname: UPLOAD_HOST,
136 fullchain: chain,
137 privateKey: Redacted.value(privateKey),
138 });
139 expect(reuploaded.ok).toBe(true);
140
141 const checked = yield* call(`/check?hostname=${UPLOAD_HOST}`);
142 expect(checked.ok).toBe(true);
143 expect(checked.value?.hostname).toBe(UPLOAD_HOST);
144
145 const requested = yield* call(`/request?hostname=${REQUEST_HOST}`);
146 expect(requested.ok).toBe(true);
147 const requestedDetail = yield* call(`/get?hostname=${REQUEST_HOST}`);
148 expect(requestedDetail.value?.acme_requested).toBe(true);
149 expect(requestedDetail.value?.dns_requirements).toBeDefined();
150
151 const removed = yield* call(`/remove?hostname=${UPLOAD_HOST}`);
152 expect(removed.ok).toBe(true);
153 yield* call(`/remove?hostname=${REQUEST_HOST}`);
154 const gone = yield* call(`/get?hostname=${UPLOAD_HOST}`).pipe(
155 Effect.repeat({
156 schedule: Schedule.spaced("1 second"),
157 until: (reply) => reply.value === null,
158 times: 10,
159 }),
160 );
161 expect(gone.value).toBeNull();
162 // Removing again is a no-op.
163 expect((yield* call(`/remove?hostname=${UPLOAD_HOST}`)).ok).toBe(true);
164 }),
165 {
166 tags: [
167 "provider:acme",
168 "provider:acme:account",
169 "provider:acme:certificate",
170 "provider:cloudflare",
171 "provider:cloudflare:dns",
172 "provider:cloudflare:zone",
173 "provider:fly",
174 "provider:fly:app",
175 "provider:fly:certificate",
176 "provider:fly:ipassignment",
177 "provider:fly:machine",
178 "provider:fly:service",
179 "live",
180 ],
181 timeout: 240_000,
182 },
183);