| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import { MinimumLogLevel } from "effect/References"; |
| 9 | import { CA_CERT_1, CA_CERT_2 } from "../MtlsCertificate/fixtures/certs.ts"; |
| 10 | |
| 11 | const { test } = Test.make({ |
| 12 | providers: Cloudflare.providers(), |
| 13 | state: Cloudflare.state(), |
| 14 | }); |
| 15 | |
| 16 | const logLevel = Effect.provideService( |
| 17 | MinimumLogLevel, |
| 18 | process.env.DEBUG ? "Debug" : "Info", |
| 19 | ); |
| 20 | |
| 21 | // The standard testing account has an Access mTLS certificate quota of zero: |
| 22 | // `POST /accounts/{id}/access/certificates` fails with code 12130 |
| 23 | // "maximum number of certificates has been reached" even though the account |
| 24 | // has no certificates. The full lifecycle is gated behind an entitled |
| 25 | // account supplied via env; the probe test always runs and pins the typed |
| 26 | // quota tag. |
| 27 | const entitled = !!process.env.CLOUDFLARE_TEST_ACCESS_MTLS; |
| 28 | |
| 29 | test.provider.skipIf(entitled)( |
| 30 | "unentitled accounts surface the typed AccessCertificateQuotaExceeded error", |
| 31 | (stack) => |
| 32 | Effect.gen(function* () { |
| 33 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 34 | yield* stack.destroy(); |
| 35 | |
| 36 | const error = yield* zeroTrust |
| 37 | .createAccessCertificateForAccount({ |
| 38 | accountId, |
| 39 | name: "alchemy-access-cert-quota-probe", |
| 40 | certificate: CA_CERT_1, |
| 41 | }) |
| 42 | .pipe(Effect.flip); |
| 43 | expect(error._tag).toEqual("AccessCertificateQuotaExceeded"); |
| 44 | |
| 45 | // Reads on the same account succeed (and show no certificates), |
| 46 | // proving the gate is on creation, not on the API token. |
| 47 | const direct = yield* zeroTrust |
| 48 | .getAccessCertificateForAccount({ |
| 49 | accountId, |
| 50 | certificateId: "00000000-0000-0000-0000-000000000000", |
| 51 | }) |
| 52 | .pipe( |
| 53 | Effect.catchTag("AccessCertificateNotFound", () => |
| 54 | Effect.succeed(undefined), |
| 55 | ), |
| 56 | ); |
| 57 | expect(direct).toBeUndefined(); |
| 58 | }).pipe(logLevel), |
| 59 | { |
| 60 | tags: ["provider:cloudflare", "provider:cloudflare:access", "live"], |
| 61 | timeout: 120_000, |
| 62 | }, |
| 63 | ); |
| 64 | |
| 65 | test.provider.skipIf(!entitled)( |
| 66 | "create, update hostnames, replace on PEM change, destroy", |
| 67 | (stack) => |
| 68 | Effect.gen(function* () { |
| 69 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 70 | |
| 71 | yield* stack.destroy(); |
| 72 | |
| 73 | const cert = yield* stack.deploy( |
| 74 | Effect.gen(function* () { |
| 75 | return yield* Cloudflare.Access.Certificate("BasicCert", { |
| 76 | certificate: CA_CERT_1, |
| 77 | }); |
| 78 | }), |
| 79 | ); |
| 80 | |
| 81 | expect(cert.certificateId).toBeDefined(); |
| 82 | expect(cert.accountId).toEqual(accountId); |
| 83 | expect(cert.fingerprint).toBeDefined(); |
| 84 | expect(cert.associatedHostnames).toEqual([]); |
| 85 | |
| 86 | const actual = yield* zeroTrust.getAccessCertificateForAccount({ |
| 87 | accountId, |
| 88 | certificateId: cert.certificateId, |
| 89 | }); |
| 90 | expect(actual.id).toEqual(cert.certificateId); |
| 91 | expect(actual.name).toEqual(cert.name); |
| 92 | |
| 93 | // Update — associate a hostname in place (same id). |
| 94 | const updated = yield* stack.deploy( |
| 95 | Effect.gen(function* () { |
| 96 | return yield* Cloudflare.Access.Certificate("BasicCert", { |
| 97 | certificate: CA_CERT_1, |
| 98 | associatedHostnames: ["access-cert.alchemy-test-2.us"], |
| 99 | }); |
| 100 | }), |
| 101 | ); |
| 102 | expect(updated.certificateId).toEqual(cert.certificateId); |
| 103 | expect(updated.associatedHostnames).toEqual([ |
| 104 | "access-cert.alchemy-test-2.us", |
| 105 | ]); |
| 106 | |
| 107 | // Replace — a different PEM is immutable on the API, so the resource |
| 108 | // is replaced with a new certificate id and fingerprint. |
| 109 | const replaced = yield* stack.deploy( |
| 110 | Effect.gen(function* () { |
| 111 | return yield* Cloudflare.Access.Certificate("BasicCert", { |
| 112 | certificate: CA_CERT_2, |
| 113 | }); |
| 114 | }), |
| 115 | ); |
| 116 | expect(replaced.certificateId).not.toEqual(cert.certificateId); |
| 117 | expect(replaced.fingerprint).not.toEqual(cert.fingerprint); |
| 118 | |
| 119 | yield* stack.destroy(); |
| 120 | |
| 121 | const afterDestroy = yield* zeroTrust |
| 122 | .getAccessCertificateForAccount({ |
| 123 | accountId, |
| 124 | certificateId: replaced.certificateId, |
| 125 | }) |
| 126 | .pipe( |
| 127 | Effect.catchTag("AccessCertificateNotFound", () => |
| 128 | Effect.succeed(undefined), |
| 129 | ), |
| 130 | ); |
| 131 | expect(afterDestroy?.id ?? undefined).toBeUndefined(); |
| 132 | }).pipe(logLevel), |
| 133 | { |
| 134 | tags: ["provider:cloudflare", "provider:cloudflare:access", "live"], |
| 135 | timeout: 120_000, |
| 136 | }, |
| 137 | ); |
| 138 | |
| 139 | // Canonical `list()` test (account-scoped collection). Enumeration via |
| 140 | // `listAccessCertificatesForAccount` works on any account regardless of the |
| 141 | // create quota, so the probe (which only reads) always runs. On an entitled |
| 142 | // account we additionally deploy a certificate and assert it appears in the |
| 143 | // exhaustively-paginated result. |
| 144 | test.provider( |
| 145 | "list enumerates account access certificates", |
| 146 | (stack) => |
| 147 | Effect.gen(function* () { |
| 148 | yield* stack.destroy(); |
| 149 | |
| 150 | const provider = yield* Provider.findProvider( |
| 151 | Cloudflare.Access.Certificate, |
| 152 | ); |
| 153 | |
| 154 | if (entitled) { |
| 155 | const deployed = yield* stack.deploy( |
| 156 | Effect.gen(function* () { |
| 157 | return yield* Cloudflare.Access.Certificate("ListCert", { |
| 158 | certificate: CA_CERT_1, |
| 159 | }); |
| 160 | }), |
| 161 | ); |
| 162 | |
| 163 | const all = yield* provider.list(); |
| 164 | expect( |
| 165 | all.some((c) => c.certificateId === deployed.certificateId), |
| 166 | ).toBe(true); |
| 167 | |
| 168 | yield* stack.destroy(); |
| 169 | } else { |
| 170 | // Unentitled accounts can't create certificates, but enumeration must |
| 171 | // still succeed and return a typed array (empty when the account has |
| 172 | // none) — proving the pagination path is wired correctly. |
| 173 | const all = yield* provider.list(); |
| 174 | expect(Array.isArray(all)).toBe(true); |
| 175 | } |
| 176 | }).pipe(logLevel), |
| 177 | { |
| 178 | tags: ["provider:cloudflare", "provider:cloudflare:access", "live"], |
| 179 | timeout: 120_000, |
| 180 | }, |
| 181 | ); |