1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as alerting from "@distilled.cloud/cloudflare/alerting";
6import { expect } from "alchemy-test";
7import * as Clock from "effect/Clock";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const EMAIL = "test@alchemy.run";
20
21const DAY_MS = 86_400_000;
22
23// Cloudflare rejects silence windows starting more than 90 days out, so the
24// window cannot be a fixed far-future constant. Derive it from the Effect
25// Clock, truncated to the UTC day boundary: deterministic within a test run
26// (and across reruns on the same day), always inside the 90-day limit.
27const silenceWindow = Effect.gen(function* () {
28 const now = yield* Clock.currentTimeMillis;
29 return yield* Effect.sync(() => {
30 const base = Math.floor(now / DAY_MS) * DAY_MS;
31 return {
32 start: new Date(base + 30 * DAY_MS).toISOString(),
33 end: new Date(base + 31 * DAY_MS).toISOString(),
34 extendedEnd: new Date(base + 32 * DAY_MS).toISOString(),
35 };
36 });
37});
38
39const sameInstant = (a: string | undefined | null, b: string) =>
40 a != null && Date.parse(a) === Date.parse(b);
41
42test.provider(
43 "create, update window in place, delete silence",
44 (stack) =>
45 Effect.gen(function* () {
46 const { accountId } = yield* yield* CloudflareEnvironment;
47 const { start, end, extendedEnd } = yield* silenceWindow;
48
49 yield* stack.destroy();
50
51 const initial = yield* stack.deploy(
52 Effect.gen(function* () {
53 const policy = yield* Cloudflare.Alerting.NotificationPolicy(
54 "SilencedPolicy",
55 {
56 alertType: "universal_ssl_event_type",
57 mechanisms: { email: [{ id: EMAIL }] },
58 },
59 );
60 return yield* Cloudflare.Alerting.Silence("Maintenance", {
61 policyId: policy.policyId,
62 startTime: start,
63 endTime: end,
64 });
65 }),
66 );
67
68 expect(initial.silenceId).toBeDefined();
69 expect(initial.accountId).toEqual(accountId);
70 expect(initial.policyId).toBeDefined();
71 expect(sameInstant(initial.startTime, start)).toBe(true);
72 expect(sameInstant(initial.endTime, end)).toBe(true);
73
74 // Verify out-of-band via the API.
75 const actual = yield* alerting.getSilence({
76 accountId,
77 silenceId: initial.silenceId,
78 });
79 expect(actual.policyId).toEqual(initial.policyId);
80 expect(sameInstant(actual.startTime, start)).toBe(true);
81 expect(sameInstant(actual.endTime, end)).toBe(true);
82
83 // Extend the window in place — same silence id.
84 const updated = yield* stack.deploy(
85 Effect.gen(function* () {
86 const policy = yield* Cloudflare.Alerting.NotificationPolicy(
87 "SilencedPolicy",
88 {
89 alertType: "universal_ssl_event_type",
90 mechanisms: { email: [{ id: EMAIL }] },
91 },
92 );
93 return yield* Cloudflare.Alerting.Silence("Maintenance", {
94 policyId: policy.policyId,
95 startTime: start,
96 endTime: extendedEnd,
97 });
98 }),
99 );
100
101 expect(updated.silenceId).toEqual(initial.silenceId);
102 expect(sameInstant(updated.endTime, extendedEnd)).toBe(true);
103
104 const afterUpdate = yield* alerting.getSilence({
105 accountId,
106 silenceId: initial.silenceId,
107 });
108 expect(sameInstant(afterUpdate.endTime, extendedEnd)).toBe(true);
109
110 yield* stack.destroy();
111
112 yield* waitForSilenceDeleted(accountId, initial.silenceId);
113 }).pipe(logLevel),
114 { tags: ["provider:cloudflare", "provider:cloudflare:alerting", "live"] },
115);
116
117test.provider(
118 "replaces silence when the policy changes",
119 (stack) =>
120 Effect.gen(function* () {
121 const { accountId } = yield* yield* CloudflareEnvironment;
122 const { start, end } = yield* silenceWindow;
123
124 yield* stack.destroy();
125
126 const deploySilence = (policyResourceId: "PolicyA" | "PolicyB") =>
127 stack.deploy(
128 Effect.gen(function* () {
129 const policyA = yield* Cloudflare.Alerting.NotificationPolicy(
130 "PolicyA",
131 {
132 alertType: "universal_ssl_event_type",
133 mechanisms: { email: [{ id: EMAIL }] },
134 },
135 );
136 const policyB = yield* Cloudflare.Alerting.NotificationPolicy(
137 "PolicyB",
138 {
139 alertType: "universal_ssl_event_type",
140 mechanisms: { email: [{ id: EMAIL }] },
141 },
142 );
143 const target = policyResourceId === "PolicyA" ? policyA : policyB;
144 const silence = yield* Cloudflare.Alerting.Silence("ReplaceMe", {
145 policyId: target.policyId,
146 startTime: start,
147 endTime: end,
148 });
149 return { policyA, policyB, silence };
150 }),
151 );
152
153 const initial = yield* deploySilence("PolicyA");
154 expect(initial.silence.policyId).toEqual(initial.policyA.policyId);
155
156 // Pointing the silence at a different policy is a replacement — the
157 // silence update API cannot move a silence between policies.
158 const replaced = yield* deploySilence("PolicyB");
159 expect(replaced.silence.policyId).toEqual(replaced.policyB.policyId);
160 expect(replaced.silence.silenceId).not.toEqual(initial.silence.silenceId);
161
162 // The replaced (old) silence must be gone.
163 yield* waitForSilenceDeleted(accountId, initial.silence.silenceId);
164
165 const actual = yield* alerting.getSilence({
166 accountId,
167 silenceId: replaced.silence.silenceId,
168 });
169 expect(actual.policyId).toEqual(replaced.policyB.policyId);
170
171 yield* stack.destroy();
172
173 yield* waitForSilenceDeleted(accountId, replaced.silence.silenceId);
174 }).pipe(logLevel),
175 { tags: ["provider:cloudflare", "provider:cloudflare:alerting", "live"] },
176);
177
178test.provider(
179 "list enumerates the deployed silence",
180 (stack) =>
181 Effect.gen(function* () {
182 const { accountId } = yield* yield* CloudflareEnvironment;
183 const { start, end } = yield* silenceWindow;
184
185 yield* stack.destroy();
186
187 const deployed = yield* stack.deploy(
188 Effect.gen(function* () {
189 const policy = yield* Cloudflare.Alerting.NotificationPolicy(
190 "ListedPolicy",
191 {
192 alertType: "universal_ssl_event_type",
193 mechanisms: { email: [{ id: EMAIL }] },
194 },
195 );
196 return yield* Cloudflare.Alerting.Silence("ListedSilence", {
197 policyId: policy.policyId,
198 startTime: start,
199 endTime: end,
200 });
201 }),
202 );
203
204 const provider = yield* Provider.findProvider(
205 Cloudflare.Alerting.Silence,
206 );
207 const all = yield* provider.list();
208
209 expect(all.some((s) => s.silenceId === deployed.silenceId)).toBe(true);
210
211 yield* stack.destroy();
212
213 yield* waitForSilenceDeleted(accountId, deployed.silenceId);
214 }).pipe(logLevel),
215 { tags: ["provider:cloudflare", "provider:cloudflare:alerting", "live"] },
216);
217
218const waitForSilenceDeleted = (accountId: string, silenceId: string) =>
219 alerting.getSilence({ accountId, silenceId }).pipe(
220 Effect.flatMap(() => Effect.fail({ _tag: "SilenceNotDeleted" } as const)),
221 Effect.catchTag("SilenceNotFound", () => Effect.void),
222 Effect.retry({
223 while: (e) => e._tag === "SilenceNotDeleted",
224 schedule: Schedule.max([
225 Schedule.exponential("500 millis"),
226 Schedule.recurs(10),
227 ]),
228 }),
229 );