1import * as ACME from "@/ACME";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Test from "@/Test/Alchemy";
6import { Jose } from "@distilled.cloud/acme";
7import * as acme from "@distilled.cloud/acme/acme";
8import * as dns from "@distilled.cloud/cloudflare/dns";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Redacted from "effect/Redacted";
13import * as Result from "effect/Result";
14import * as Stream from "effect/Stream";
15
16const { test } = Test.make({
17 providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()),
18});
19const ca = ACME.LetsEncryptStaging;
20const zoneName =
21 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
22const name = `alchemy-acme-lifecycle.${zoneName}`;
23const resolveZone = Effect.gen(function* () {
24 const { accountId } = yield* yield* CloudflareEnvironment;
25 const zone = yield* findZoneByName({ accountId, name: zoneName });
26 if (!zone) return yield* Effect.fail(new Error(`zone ${zoneName} not found`));
27 return zone.id;
28});
29
30test.provider(
31 "keeps an unchanged certificate, renews, persists revocation settings and cleans DNS",
32 (stack) =>
33 Effect.gen(function* () {
34 yield* stack.destroy();
35 const zoneId = yield* resolveZone;
36 const program = (extra: Partial<ACME.CertificateProps> = {}) =>
37 Effect.gen(function* () {
38 const account = yield* ACME.Account("Account", {
39 ca,
40 termsOfServiceAgreed: true,
41 });
42 const cert = yield* ACME.Certificate("Certificate", {
43 account,
44 identifiers: [name],
45 solver: Cloudflare.DNS.AcmeSolver({ zoneId }),
46 ...extra,
47 });
48 return { account, cert };
49 });
50 const first = yield* stack.deploy(program());
51 const parsed = yield* ACME.parseCertificate(first.cert.certificate);
52 expect(parsed.dnsNames).toEqual([name]);
53 expect(first.cert.serial).toBe(parsed.serial);
54 expect(first.cert.issuer).toContain("STAGING");
55 expect(ACME.splitPemChain(first.cert.chain).length).toBeGreaterThan(1);
56 expect(Redacted.isRedacted(first.cert.privateKey)).toBe(true);
57 const unchanged = yield* stack.deploy(program());
58 expect(unchanged.cert.serial).toBe(first.cert.serial);
59 const renewed = yield* stack.deploy(program({ renewBefore: "365 days" }));
60 expect(renewed.cert.serial).not.toBe(first.cert.serial);
61 const configured = yield* stack.deploy(program({ revokeOnDelete: true }));
62 expect(configured.cert.serial).toBe(renewed.cert.serial);
63 const records = yield* dns.listRecords
64 .items({
65 zoneId,
66 name: { exact: `_acme-challenge.${name}` },
67 type: "TXT",
68 })
69 .pipe(Stream.runCollect);
70 expect(records).toEqual([]);
71 yield* stack.destroy();
72 const certificateKey = yield* ACME.privateKeyToJwk(
73 configured.cert.privateKey,
74 );
75 const revokedAgain = yield* acme
76 .revokeCertificate({
77 certificate: Jose.base64url(
78 ACME.fromPem(configured.cert.certificate),
79 ),
80 })
81 .pipe(
82 Effect.provide(ACME.accountLayer({ ca, accountKey: certificateKey })),
83 Effect.result,
84 );
85 expect(Result.isFailure(revokedAgain)).toBe(true);
86 if (Result.isFailure(revokedAgain))
87 expect(revokedAgain.failure._tag).toBe("AcmeAlreadyRevoked");
88 }),
89 {
90 tags: [
91 "provider:acme",
92 "provider:acme:account",
93 "provider:acme:certificate",
94 "provider:cloudflare",
95 "provider:cloudflare:dns",
96 "provider:cloudflare:zone",
97 "live",
98 ],
99 timeout: 180_000,
100 },
101);
102
103test.provider(
104 "changing the account replaces the certificate while retaining both accounts",
105 (stack) =>
106 Effect.gen(function* () {
107 yield* stack.destroy();
108 const zoneId = yield* resolveZone;
109 const program = (second: boolean) =>
110 Effect.gen(function* () {
111 const firstAccount = yield* ACME.Account("First", {
112 ca,
113 termsOfServiceAgreed: true,
114 });
115 const secondAccount = yield* ACME.Account("Second", {
116 ca,
117 termsOfServiceAgreed: true,
118 });
119 return yield* ACME.Certificate("Certificate", {
120 account: second ? secondAccount : firstAccount,
121 identifiers: [`alchemy-acme-account-switch.${zoneName}`],
122 solver: Cloudflare.DNS.AcmeSolver({ zoneId }),
123 revokeOnDelete: true,
124 });
125 });
126 const first = yield* stack.deploy(program(false));
127 const second = yield* stack.deploy(program(true));
128 expect(second.serial).not.toBe(first.serial);
129 expect(second.accountUrl).not.toBe(first.accountUrl);
130 yield* stack.destroy();
131 }),
132 {
133 tags: [
134 "provider:acme",
135 "provider:acme:account",
136 "provider:acme:certificate",
137 "provider:cloudflare",
138 "provider:cloudflare:dns",
139 "provider:cloudflare:zone",
140 "live",
141 ],
142 timeout: 180_000,
143 },
144);
145
146test.provider(
147 "fails with a typed error when no DNS solver is registered",
148 (stack) =>
149 Effect.gen(function* () {
150 yield* stack.destroy();
151 const result = yield* stack
152 .deploy(
153 Effect.gen(function* () {
154 const account = yield* ACME.Account("Account", {
155 ca,
156 termsOfServiceAgreed: true,
157 });
158 return yield* ACME.Certificate("Certificate", {
159 account,
160 identifiers: [`alchemy-acme-unsolvable.${zoneName}`],
161 solver: { type: "Nope.DNS" },
162 });
163 }),
164 )
165 .pipe(Effect.result);
166 expect(Result.isFailure(result)).toBe(true);
167 expect(JSON.stringify(result)).toContain("ACME.DnsSolverNotRegistered");
168 yield* stack.destroy();
169 }),
170 {
171 tags: [
172 "provider:acme",
173 "provider:acme:account",
174 "provider:acme:certificate",
175 "provider:cloudflare",
176 "live",
177 ],
178 timeout: 60_000,
179 },
180);