1import {
2 checkHttpStateStoreAuth,
3 describeStateStoreFailure,
4 makeHttpStateStore,
5} from "@/State/HttpStateStore.ts";
6import { describe, expect, it } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as TestClock from "effect/testing/TestClock";
10import * as Layer from "effect/Layer";
11import * as Logger from "effect/Logger";
12import * as Redacted from "effect/Redacted";
13import * as FetchHttpClient from "effect/http/FetchHttpClient";
14import * as HttpClientError from "effect/http/HttpClientError";
15import * as HttpClientRequest from "effect/http/HttpClientRequest";
16import * as HttpClientResponse from "effect/http/HttpClientResponse";
17import * as HttpApiError from "effect/http-api/HttpApiError";
18
19/**
20 * Hermetic tests driven by the production failure modes observed in
21 * Axiom (`prod-traces`, spans `state_store.*`):
22 *
23 * - `StateStoreError` with an EMPTY message (31 hits / 14 users on
24 * beta.57–59) — thrown from `mapStateStoreError` when the client
25 * error carried no message (e.g. the no-content 401).
26 * - opaque `Decode error (500 PUT <url>)` — worker 5xx bodies that
27 * fail response decoding.
28 * - `Transport error (GET .../state/stacks)` / `fetch failed` — not
29 * retried by `checkHttpStateStoreAuth`.
30 *
31 * All HTTP traffic is stubbed through the `FetchHttpClient.Fetch`
32 * reference — no sockets, no cloud.
33 */
34
35/** Minimal fetch signature — Bun's `typeof fetch` also demands `preconnect`. */
36type FetchStub = (
37 input: string | URL | Request,
38 init?: RequestInit,
39) => Promise<Response>;
40
41/** Build an HttpClient layer whose transport is the given fetch stub. */
42const stubHttpClient = (stub: FetchStub) =>
43 FetchHttpClient.layer.pipe(
44 Layer.provide(
45 Layer.succeed(FetchHttpClient.Fetch, stub as typeof globalThis.fetch),
46 ),
47 );
48
49const json = (body: unknown, status = 200) =>
50 new Response(JSON.stringify(body), {
51 status,
52 headers: { "content-type": "application/json" },
53 });
54
55const sampleValue = { fqn: "stack/scope/a", props: { hello: "world" } };
56
57const makeStore = makeHttpStateStore({
58 url: "https://state-store.test",
59 authToken: "token",
60 id: "test-http",
61});
62
63describe("describeStateStoreFailure", { tags: ["unit", "local"] }, () => {
64 it("never returns an empty message", () => {
65 class Empty extends Error {
66 readonly _tag = "SomeTaggedError";
67 constructor() {
68 super("");
69 }
70 }
71 const message = describeStateStoreFailure(new Empty());
72 expect(message.length).toBeGreaterThan(0);
73 });
74
75 it("maps Unauthorized errors to an actionable message", () => {
76 const message = describeStateStoreFailure(new HttpApiError.Unauthorized());
77 expect(message).toContain("unauthorized");
78 expect(message).toContain("alchemy profile edit");
79 });
80
81 it("appends the HTTP status when the error carries a response", () => {
82 const request = HttpClientRequest.put("https://state-store.test");
83 const response = HttpClientResponse.fromWeb(
84 request,
85 new Response(null, { status: 500 }),
86 );
87 const error = new HttpClientError.HttpClientError({
88 reason: new HttpClientError.StatusCodeError({ request, response }),
89 });
90 expect(describeStateStoreFailure(error)).toContain("500");
91 });
92
93 it("omits arbitrary error and cause messages", () => {
94 const cause = new Error("secret cause");
95 const outer = new Error("secret message");
96 outer.cause = cause;
97 const message = describeStateStoreFailure(outer);
98 expect(message).not.toContain("secret");
99 expect(message.length).toBeGreaterThan(0);
100 });
101
102 it("omits non-Error failures that may contain state", () => {
103 expect(describeStateStoreFailure("secret state")).not.toContain("secret");
104 });
105});
106
107describe("makeHttpStateStore", { tags: ["unit", "local"] }, () => {
108 // Regression: https://github.com/reve-ai/kommunikasie/commit/f2e7320ff261833092b84d8aa25e3563710661e8
109 // State encoding unwraps Redacted values before HTTP. Neither the request
110 // object nor transport/decoder messages may escape through diagnostics.
111 for (const failure of ["status", "decode", "transport"] as const) {
112 it.effect(
113 `does not expose serialized state after a ${failure} failure`,
114 () => {
115 const secret = "STATE_PAYLOAD_SENTINEL_91f4";
116 const logs: string[] = [];
117 let requestBody = "";
118 const logger = Logger.make(({ cause, message }) => {
119 logs.push(JSON.stringify({ cause, message }));
120 });
121 const stub: FetchStub = async (_input, init) => {
122 requestBody = await new Response(init?.body).text();
123 if (failure === "transport") throw new Error(secret);
124 return new Response(secret, {
125 status: failure === "status" ? 400 : 200,
126 headers: { "content-type": "application/json" },
127 });
128 };
129 return Effect.gen(function* () {
130 const store = yield* makeStore;
131 const fiber = yield* store
132 .set({
133 stack: "s",
134 stage: "dev",
135 fqn: "secret",
136 value: {
137 kind: "action",
138 actionType: "SecretState",
139 namespace: undefined,
140 fqn: "secret",
141 logicalId: "secret",
142 status: "ran",
143 downstream: [],
144 inputHash: "input-hash",
145 input: { token: Redacted.make(secret) },
146 output: null,
147 },
148 })
149 .pipe(Effect.flip, Effect.forkChild);
150 yield* TestClock.adjust("30 seconds");
151 const error = yield* Fiber.join(fiber);
152 expect(requestBody).toContain(secret);
153 expect(JSON.stringify({ error, logs })).not.toContain(secret);
154 expect(error.cause).toBeUndefined();
155 expect(error.message).not.toContain(secret);
156 expect(error.message.length).toBeGreaterThan(0);
157 }).pipe(
158 Effect.provide(stubHttpClient(stub)),
159 Effect.provide(Logger.layer([logger])),
160 );
161 },
162 );
163 }
164
165 it.live("retries transient 5xx and then succeeds", () => {
166 let calls = 0;
167 const stub: FetchStub = async () => {
168 calls++;
169 return calls < 3
170 ? new Response("internal error", { status: 500 })
171 : json(sampleValue);
172 };
173 return Effect.gen(function* () {
174 const store = yield* makeStore;
175 const result = yield* store.set({
176 stack: "s",
177 stage: "dev",
178 fqn: "stack/scope/a",
179 value: sampleValue as never,
180 });
181 expect(result).toEqual(sampleValue);
182 expect(calls).toBe(3);
183 }).pipe(Effect.provide(stubHttpClient(stub)));
184 });
185
186 it.live(
187 "surfaces a persistent 5xx as a StateStoreError with status context",
188 () => {
189 let calls = 0;
190 const stub: FetchStub = async () => {
191 calls++;
192 return new Response("secrets store binding unavailable", {
193 status: 500,
194 });
195 };
196 return Effect.gen(function* () {
197 const store = yield* makeStore;
198 const error = yield* store
199 .set({
200 stack: "s",
201 stage: "dev",
202 fqn: "stack/scope/a",
203 value: sampleValue as never,
204 })
205 .pipe(Effect.flip);
206 expect(error._tag).toBe("StateStoreError");
207 expect(error.message.trim().length).toBeGreaterThan(0);
208 expect(error.message).toContain("500");
209 // initial attempt + 5 bounded retries
210 expect(calls).toBe(6);
211 }).pipe(Effect.provide(stubHttpClient(stub)));
212 },
213 30_000,
214 );
215
216 it.live("maps a no-content 401 to a non-empty, actionable error", () => {
217 const stub: FetchStub = async () => new Response(null, { status: 401 });
218 return Effect.gen(function* () {
219 const store = yield* makeStore;
220 const error = yield* store.listStacks().pipe(Effect.flip);
221 expect(error._tag).toBe("StateStoreError");
222 expect(error.message.trim().length).toBeGreaterThan(0);
223 expect(error.message).toContain("unauthorized");
224 expect(error.message).toContain("alchemy profile edit");
225 }).pipe(Effect.provide(stubHttpClient(stub)));
226 });
227
228 it.live(
229 "maps a non-JSON 200 body to a non-empty StateStoreError instead of a bare SyntaxError",
230 () => {
231 const stub: FetchStub = async () =>
232 new Response("Alchemy State Store", {
233 status: 200,
234 headers: { "content-type": "application/json" },
235 });
236 return Effect.gen(function* () {
237 const store = yield* makeStore;
238 const error = yield* store.listStacks().pipe(Effect.flip);
239 expect(error._tag).toBe("StateStoreError");
240 expect(error.message.trim().length).toBeGreaterThan(0);
241 }).pipe(Effect.provide(stubHttpClient(stub)));
242 },
243 );
244});
245
246describe("checkHttpStateStoreAuth", { tags: ["unit", "local"] }, () => {
247 const check = checkHttpStateStoreAuth({
248 url: "https://state-store.test",
249 authToken: "token",
250 });
251
252 it.live("retries transport-level failures before succeeding", () => {
253 let calls = 0;
254 const stub: FetchStub = async () => {
255 calls++;
256 if (calls < 3) throw new TypeError("fetch failed");
257 return json([]);
258 };
259 return Effect.gen(function* () {
260 const isAuthed = yield* check;
261 expect(isAuthed).toBe(true);
262 expect(calls).toBe(3);
263 }).pipe(Effect.provide(stubHttpClient(stub)));
264 });
265
266 it.live("returns false on 401 without retrying", () => {
267 let calls = 0;
268 const stub: FetchStub = async () => {
269 calls++;
270 return new Response(null, { status: 401 });
271 };
272 return Effect.gen(function* () {
273 const isAuthed = yield* check;
274 expect(isAuthed).toBe(false);
275 expect(calls).toBe(1);
276 }).pipe(Effect.provide(stubHttpClient(stub)));
277 });
278});