1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// The scoped API token the test harness mints propagates eventually-
19// consistently across Cloudflare's edge — ride out 403 blips
20// (`Forbidden`, declared in the distilled error union) on the test's
21// own out-of-band verification calls.
22const getRule = (accountId: string, ruleId: string) =>
23 zeroTrust.getDevicePosture({ accountId, ruleId }).pipe(
24 Effect.retry({
25 while: (e) => e._tag === "Forbidden",
26 schedule: Schedule.exponential("500 millis"),
27 times: 8,
28 }),
29 );
30
31const expectGone = (accountId: string, ruleId: string) =>
32 getRule(accountId, ruleId).pipe(
33 Effect.flatMap(() => Effect.fail({ _tag: "RuleNotDeleted" } as const)),
34 // A missing rule surfaces as `PostureRuleNotFound` (Cloudflare error
35 // code 6024) — that's the success condition here.
36 Effect.catchTag("PostureRuleNotFound", () => Effect.void),
37 Effect.retry({
38 while: (e) => e._tag === "RuleNotDeleted",
39 schedule: Schedule.max([
40 Schedule.exponential("500 millis"),
41 Schedule.recurs(10),
42 ]),
43 }),
44 );
45
46test.provider(
47 "create, update in place, and delete a posture rule",
48 (stack) =>
49 Effect.gen(function* () {
50 const { accountId } = yield* yield* CloudflareEnvironment;
51
52 yield* stack.destroy();
53
54 const rule = yield* stack.deploy(
55 Cloudflare.Devices.DevicePostureRule("WindowsOsVersion", {
56 name: "alchemy-test-posture-os",
57 type: "os_version",
58 description: "Require Windows 10.0.19045+",
59 match: [{ platform: "windows" }],
60 schedule: "5m",
61 input: {
62 operatingSystem: "windows",
63 operator: ">=",
64 version: "10.0.19045",
65 },
66 }),
67 );
68
69 expect(rule.postureRuleId).toBeTruthy();
70 expect(rule.accountId).toEqual(accountId);
71 expect(rule.name).toEqual("alchemy-test-posture-os");
72 expect(rule.type).toEqual("os_version");
73 expect(rule.description).toEqual("Require Windows 10.0.19045+");
74
75 // Out-of-band verify against the live API.
76 const live = yield* getRule(accountId, rule.postureRuleId);
77 expect(live.name).toEqual("alchemy-test-posture-os");
78 expect(live.type).toEqual("os_version");
79
80 // Update mutable props in place — same rule id.
81 const updated = yield* stack.deploy(
82 Cloudflare.Devices.DevicePostureRule("WindowsOsVersion", {
83 name: "alchemy-test-posture-os",
84 type: "os_version",
85 description: "Require Windows 10.0.22631+",
86 match: [{ platform: "windows" }],
87 schedule: "10m",
88 input: {
89 operatingSystem: "windows",
90 operator: ">=",
91 version: "10.0.22631",
92 },
93 }),
94 );
95 expect(updated.postureRuleId).toEqual(rule.postureRuleId);
96 expect(updated.description).toEqual("Require Windows 10.0.22631+");
97 expect(updated.schedule).toEqual("10m");
98
99 const live2 = yield* getRule(accountId, rule.postureRuleId);
100 expect(live2.description).toEqual("Require Windows 10.0.22631+");
101 expect(live2.schedule).toEqual("10m");
102
103 yield* stack.destroy();
104 yield* expectGone(accountId, rule.postureRuleId);
105 }).pipe(logLevel),
106 { tags: ["provider:cloudflare", "provider:cloudflare:devices", "live"] },
107);
108
109test.provider(
110 "changing the rule type triggers a replacement",
111 (stack) =>
112 Effect.gen(function* () {
113 const { accountId } = yield* yield* CloudflareEnvironment;
114
115 yield* stack.destroy();
116
117 const original = yield* stack.deploy(
118 Cloudflare.Devices.DevicePostureRule("Replace", {
119 name: "alchemy-test-posture-replace",
120 type: "firewall",
121 match: [{ platform: "mac" }],
122 schedule: "5m",
123 input: { enabled: true, operatingSystem: "mac" },
124 }),
125 );
126 expect(original.type).toEqual("firewall");
127
128 // `type` is immutable — diff must request a replacement.
129 const replaced = yield* stack.deploy(
130 Cloudflare.Devices.DevicePostureRule("Replace", {
131 name: "alchemy-test-posture-replace",
132 type: "disk_encryption",
133 match: [{ platform: "mac" }],
134 schedule: "5m",
135 input: { requireAll: true },
136 }),
137 );
138 expect(replaced.type).toEqual("disk_encryption");
139 expect(replaced.postureRuleId).not.toEqual(original.postureRuleId);
140
141 // The old rule must be gone after the replacement completes.
142 yield* expectGone(accountId, original.postureRuleId);
143
144 yield* stack.destroy();
145 yield* expectGone(accountId, replaced.postureRuleId);
146 }).pipe(logLevel),
147 { tags: ["provider:cloudflare", "provider:cloudflare:devices", "live"] },
148);
149
150test.provider(
151 "list enumerates the deployed posture rule",
152 (stack) =>
153 Effect.gen(function* () {
154 const { accountId } = yield* yield* CloudflareEnvironment;
155
156 yield* stack.destroy();
157
158 const deployed = yield* stack.deploy(
159 Cloudflare.Devices.DevicePostureRule("ListRule", {
160 name: "alchemy-test-posture-list",
161 type: "os_version",
162 match: [{ platform: "windows" }],
163 schedule: "5m",
164 input: {
165 operatingSystem: "windows",
166 operator: ">=",
167 version: "10.0.19045",
168 },
169 }),
170 );
171
172 const provider = yield* Provider.findProvider(
173 Cloudflare.Devices.DevicePostureRule,
174 );
175 const all = yield* provider.list();
176
177 // Exhaustive pagination must include the rule we just deployed.
178 expect(
179 all.some((rule) => rule.postureRuleId === deployed.postureRuleId),
180 ).toBe(true);
181
182 yield* stack.destroy();
183 yield* expectGone(accountId, deployed.postureRuleId);
184 }).pipe(logLevel),
185 { tags: ["provider:cloudflare", "provider:cloudflare:devices", "live"] },
186);