@homeflare/alchemy 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/linux/container-env-quote.d.ts +39 -0
  2. package/dist/linux/container-env-quote.d.ts.map +1 -0
  3. package/dist/linux/container-form.d.ts +14 -13
  4. package/dist/linux/container-form.d.ts.map +1 -1
  5. package/dist/linux/container-paths.d.ts +17 -0
  6. package/dist/linux/container-paths.d.ts.map +1 -0
  7. package/dist/linux/index.js +41 -8
  8. package/dist/linux/index.js.map +6 -4
  9. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  10. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  11. package/dist/unifi/acl-rule.d.ts +6 -1
  12. package/dist/unifi/acl-rule.d.ts.map +1 -1
  13. package/dist/unifi/dns-policy.d.ts +6 -1
  14. package/dist/unifi/dns-policy.d.ts.map +1 -1
  15. package/dist/unifi/fake-unifi.d.ts +4 -0
  16. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  17. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  18. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  19. package/dist/unifi/firewall-policy.d.ts +6 -1
  20. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  21. package/dist/unifi/firewall-zone.d.ts +6 -1
  22. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  23. package/dist/unifi/index.d.ts +5 -3
  24. package/dist/unifi/index.d.ts.map +1 -1
  25. package/dist/unifi/index.js +339 -86
  26. package/dist/unifi/index.js.map +10 -6
  27. package/dist/unifi/network-scope.d.ts +31 -0
  28. package/dist/unifi/network-scope.d.ts.map +1 -0
  29. package/dist/unifi/network-update.d.ts +44 -0
  30. package/dist/unifi/network-update.d.ts.map +1 -0
  31. package/dist/unifi/network.d.ts +34 -13
  32. package/dist/unifi/network.d.ts.map +1 -1
  33. package/dist/unifi/policy.d.ts +89 -1
  34. package/dist/unifi/policy.d.ts.map +1 -1
  35. package/dist/unifi/resource.d.ts +17 -43
  36. package/dist/unifi/resource.d.ts.map +1 -1
  37. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  38. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  39. package/dist/unifi/update-reconcile.d.ts +31 -0
  40. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  41. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  42. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  43. package/dist/unifi/wire-guard.d.ts +33 -0
  44. package/dist/unifi/wire-guard.d.ts.map +1 -0
  45. package/docs/unifi-network-write.md +90 -0
  46. package/docs/unifi.md +40 -39
  47. package/package.json +1 -1
  48. package/src/linux/container-env-chain.test.ts +135 -0
  49. package/src/linux/container-env-live.test.ts +115 -0
  50. package/src/linux/container-env-oracle.ts +109 -0
  51. package/src/linux/container-env-quadlet-oracle.ts +174 -0
  52. package/src/linux/container-env-quote.test.ts +131 -0
  53. package/src/linux/container-env-quote.ts +126 -0
  54. package/src/linux/container-fixture.test.ts +3 -1
  55. package/src/linux/container-form.ts +32 -28
  56. package/src/linux/container-paths.ts +21 -0
  57. package/src/unifi/fake-unifi.ts +15 -2
  58. package/src/unifi/handler-allow-news.test.ts +80 -0
  59. package/src/unifi/index.ts +21 -5
  60. package/src/unifi/network-scope.test.ts +224 -0
  61. package/src/unifi/network-scope.ts +75 -0
  62. package/src/unifi/network-update.test.ts +250 -0
  63. package/src/unifi/network-update.ts +95 -0
  64. package/src/unifi/network.test.ts +27 -1
  65. package/src/unifi/network.ts +58 -13
  66. package/src/unifi/policy.ts +128 -10
  67. package/src/unifi/resource.test.ts +15 -3
  68. package/src/unifi/resource.ts +64 -105
  69. package/src/unifi/update-reconcile.ts +126 -0
  70. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  71. package/src/unifi/wire-guard-round2.test.ts +120 -0
  72. package/src/unifi/wire-guard.test.ts +218 -0
  73. package/src/unifi/wire-guard.ts +192 -0
  74. package/src/unifi/write-op-reference.test.ts +41 -4
  75. package/src/unifi/get-only-guard.test.ts +0 -165
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The wire guard's ANCHORING, redirect handling and redaction — red team round 1 on PR 377.
3
+ * The first block runs a real `Bun.serve` through the real `FetchHttpClient` (no fake fetch), because
4
+ * the redirect finding is about what real fetch does with the fiber's `RequestInit`.
5
+ */
6
+ import { afterAll, describe, expect, test } from 'bun:test';
7
+ import * as Cause from 'effect/Cause';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Exit from 'effect/Exit';
10
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
11
+ import * as HttpClient from 'effect/http/HttpClient';
12
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
13
+ import { networkAllowedWrite } from './network.ts';
14
+ import { GetOnlyHttpClient, UnifiRefusedRequest, guardedHttpClient } from './wire-guard.ts';
15
+
16
+ const hits: Array<{ method: string; path: string }> = [];
17
+ let status = 307;
18
+ const server = Bun.serve({
19
+ port: 0,
20
+ hostname: '127.0.0.1',
21
+ fetch(req) {
22
+ const u = new URL(req.url);
23
+ hits.push({ method: req.method, path: u.pathname + u.search });
24
+ return u.pathname.endsWith('/networks/n')
25
+ ? new Response(null, { status, headers: { location: '/elsewhere/v1/sites/s/networks/O' } })
26
+ : Response.json({ ok: true });
27
+ },
28
+ });
29
+ afterAll(() => server.stop(true));
30
+
31
+ const ROOT = '/proxy/network/integration';
32
+ const PORT = server.port as number;
33
+ const BASE = `http://127.0.0.1:${PORT}${ROOT}`;
34
+ const ALLOW = [networkAllowedWrite({ siteId: 's', networkId: 'n' } as never)];
35
+
36
+ const call = (
37
+ method: string,
38
+ url: string,
39
+ base: string | null = BASE,
40
+ outer: FetchHttpClient.RequestInit['Service'] = {},
41
+ ) =>
42
+ Effect.runPromiseExit(
43
+ Effect.gen(function* () {
44
+ const client = yield* HttpClient.HttpClient;
45
+ return yield* client.execute(
46
+ HttpClientRequest.make(method as never)(url).pipe(
47
+ HttpClientRequest.bodyText('{"x":1}', 'application/json'),
48
+ ),
49
+ );
50
+ }).pipe(
51
+ Effect.provide(guardedHttpClient(ALLOW, base ?? undefined)),
52
+ // ⛔ An OUTER RequestInit on the calling fiber: `layerMergedContext` lets it replace the guard's.
53
+ Effect.provideService(FetchHttpClient.RequestInit, outer),
54
+ Effect.provide(FetchHttpClient.layer),
55
+ ),
56
+ );
57
+
58
+ const refusal = (exit: Exit.Exit<unknown, unknown>) => {
59
+ expect(Exit.isFailure(exit)).toBe(true);
60
+ if (!Exit.isFailure(exit)) throw new Error('unreachable');
61
+ expect(Cause.hasDies(exit.cause)).toBe(true);
62
+ const defect = Cause.squash(exit.cause);
63
+ expect(defect).toBeInstanceOf(UnifiRefusedRequest);
64
+ return defect as UnifiRefusedRequest;
65
+ };
66
+
67
+ describe('redirect: manual survives an OUTER FetchHttpClient.RequestInit', () => {
68
+ test('outer RequestInit, real 307 => exactly one request, then the refusal', async () => {
69
+ status = 307;
70
+ hits.length = 0;
71
+ const exit = await call('PUT', `${BASE}/v1/sites/s/networks/n`, BASE, {
72
+ keepalive: false,
73
+ });
74
+ expect(refusal(exit).message).toContain('307 redirect');
75
+ expect(hits).toEqual([{ method: 'PUT', path: `${ROOT}/v1/sites/s/networks/n` }]);
76
+ });
77
+
78
+ // ⚠️ Mutation gap: narrowing the 3xx check to 307 only. Real fetch with redirect:manual returns
79
+ // each of these as-is, and the guard must refuse every one.
80
+ test.each([301, 302, 303, 307, 308])('a %d answer is refused', async (code) => {
81
+ status = code;
82
+ hits.length = 0;
83
+ refusal(await call('PUT', `${BASE}/v1/sites/s/networks/n`));
84
+ expect(hits).toHaveLength(1);
85
+ });
86
+
87
+ test('a 3xx answer to HEAD is refused too (HEAD is not GET)', async () => {
88
+ status = 302;
89
+ // HEAD is not on the allow list, so it dies at the request guard before any redirect exists.
90
+ hits.length = 0;
91
+ refusal(await call('HEAD', `${BASE}/v1/sites/s/networks/n`));
92
+ expect(hits).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe('a 3xx answer to a GET is refused under BOTH postures (2026-10-10 symmetry fix)', () => {
97
+ // ⛔ ROUND-2 FINDING: `redirect: 'manual'` was scoped to a non-empty allow list, so reconcile's
98
+ // OWN GETs surfaced a raw 3xx and failed the SDK decode, while read/diff's GETs silently
99
+ // followed it. Both postures must behave identically — and a followed GET re-sends the request,
100
+ // API-key header included, to a URL the guard never vetted — so both refuse it, never follow.
101
+ // Real fetch, no fake: the point is what fetch does with the fiber's `RequestInit`.
102
+ test.each([
103
+ ['GetOnlyHttpClient (read/diff/delete)', () => GetOnlyHttpClient],
104
+ ['the row allow list (reconcile)', () => guardedHttpClient(ALLOW, BASE)],
105
+ ])('%s: refused, never followed', async (_n, layer) => {
106
+ status = 301;
107
+ hits.length = 0;
108
+ const exit = await Effect.runPromiseExit(
109
+ Effect.gen(function* () {
110
+ const client = yield* HttpClient.HttpClient;
111
+ return yield* client.execute(HttpClientRequest.get(`${BASE}/v1/sites/s/networks/n`));
112
+ }).pipe(Effect.provide(layer()), Effect.provide(FetchHttpClient.layer)),
113
+ );
114
+ const defect = refusal(exit);
115
+ expect(defect.method).toBe('GET');
116
+ expect(defect.message).toContain('301 redirect');
117
+ // Exactly ONE request reached the server: the redirect was never followed.
118
+ expect(hits).toEqual([{ method: 'GET', path: `${ROOT}/v1/sites/s/networks/n` }]);
119
+ });
120
+ });
121
+
122
+ describe('the allowed path is anchored to the configured base URL', () => {
123
+ test('the exact route passes', async () => {
124
+ status = 200;
125
+ hits.length = 0;
126
+ expect(Exit.isSuccess(await call('PUT', `${BASE}/v1/sites/s/networks/n`))).toBe(true);
127
+ expect(hits).toHaveLength(1);
128
+ });
129
+
130
+ test.each([
131
+ ['a query string', `${ROOT}/v1/sites/s/networks/n?force=true`],
132
+ ['a nested route', `${ROOT}/v1/sites/OTHER/v1/sites/s/networks/n`],
133
+ ['a doubled slash', `${ROOT}//v1/sites/s/networks/n`],
134
+ ['a doubled slash at the root', `//v1/sites/s/networks/n`],
135
+ ['a trailing slash', `${ROOT}/v1/sites/s/networks/n/`],
136
+ ['a fragment', `${ROOT}/v1/sites/s/networks/n#frag`],
137
+ ['a different base path', `/totally/different/v1/sites/s/networks/n`],
138
+ ])('%s is refused before the transport', async (_n, path) => {
139
+ status = 200;
140
+ hits.length = 0;
141
+ refusal(await call('PUT', `http://127.0.0.1:${PORT}${path}`));
142
+ expect(hits).toEqual([]);
143
+ });
144
+
145
+ test('another origin is refused even with the right path', async () => {
146
+ hits.length = 0;
147
+ refusal(await call('PUT', `http://127.0.0.1:${PORT + 1}${ROOT}/v1/sites/s/networks/n`));
148
+ expect(hits).toEqual([]);
149
+ });
150
+
151
+ test('no configured base URL allows nothing (fail closed)', async () => {
152
+ hits.length = 0;
153
+ refusal(await call('PUT', `${BASE}/v1/sites/s/networks/n`, null));
154
+ expect(hits).toEqual([]);
155
+ });
156
+
157
+ // ⚠️ Mutation gap: a regex-escaped id. A `.` in an id must be literal, never "any character".
158
+ test('an id is compared as text, never as a pattern', async () => {
159
+ status = 200;
160
+ const dotted = [networkAllowedWrite({ siteId: 's', networkId: 'a.c' } as never)];
161
+ const run = (id: string) =>
162
+ Effect.runPromiseExit(
163
+ Effect.gen(function* () {
164
+ const client = yield* HttpClient.HttpClient;
165
+ return yield* client.execute(
166
+ HttpClientRequest.put(`${BASE}/v1/sites/s/networks/${id}`).pipe(
167
+ HttpClientRequest.bodyText('{}', 'application/json'),
168
+ ),
169
+ );
170
+ }).pipe(
171
+ Effect.provide(guardedHttpClient(dotted, BASE)),
172
+ Effect.provide(FetchHttpClient.layer),
173
+ ),
174
+ );
175
+ expect(Exit.isSuccess(await run('a.c'))).toBe(true);
176
+ refusal(await run('abc'));
177
+ });
178
+ });
179
+
180
+ describe('refusal messages redact the cloud connector Console ID', () => {
181
+ test('the segment after consoles/ never reaches the message or the path field', async () => {
182
+ const cloud =
183
+ 'https://api.ui.com/v1/connector/consoles/SECRET-CONSOLE-ID/proxy/network/integration';
184
+ const exit = await Effect.runPromiseExit(
185
+ Effect.gen(function* () {
186
+ const client = yield* HttpClient.HttpClient;
187
+ return yield* client.execute(HttpClientRequest.put(`${cloud}/v1/sites/s/networks/OTHER`));
188
+ }).pipe(
189
+ Effect.provide(guardedHttpClient(ALLOW, cloud)),
190
+ Effect.provide(FetchHttpClient.layer),
191
+ ),
192
+ );
193
+ const defect = refusal(exit);
194
+ expect(defect.message).not.toContain('SECRET-CONSOLE-ID');
195
+ expect(defect.path).not.toContain('SECRET-CONSOLE-ID');
196
+ expect(defect.path).toContain('/consoles/<redacted>/proxy/network/integration');
197
+ });
198
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Red team round 2 on PR 377, against the wire guard: an OUTER `RequestInit` that tries to turn
3
+ * redirects back on (F2), a consumer transform that replaces `manual` (F3, the response-URL
4
+ * backstop) and a query parameter added with `setUrlParam` (F5). Real `Bun.serve` + real fetch.
5
+ */
6
+ import { afterAll, describe, expect, test } from 'bun:test';
7
+ import * as Cause from 'effect/Cause';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Exit from 'effect/Exit';
10
+ import * as Layer from 'effect/Layer';
11
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
12
+ import * as HttpClient from 'effect/http/HttpClient';
13
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
14
+ import { networkAllowedWrite } from './network.ts';
15
+ import { UnifiRefusedRequest, guardedHttpClient } from './wire-guard.ts';
16
+
17
+ const hits: Array<{ method: string; path: string }> = [];
18
+ const server = Bun.serve({
19
+ port: 0,
20
+ hostname: '127.0.0.1',
21
+ fetch(req) {
22
+ const u = new URL(req.url);
23
+ hits.push({ method: req.method, path: u.pathname + u.search });
24
+ return u.pathname.endsWith('/networks/n')
25
+ ? new Response(null, {
26
+ status: 307,
27
+ headers: { location: '/elsewhere/v1/sites/s/networks/O' },
28
+ })
29
+ : Response.json({ ok: true });
30
+ },
31
+ });
32
+ afterAll(() => server.stop(true));
33
+
34
+ const BASE = `http://127.0.0.1:${server.port}/proxy/network/integration`;
35
+ const ALLOW = [networkAllowedWrite({ siteId: 's', networkId: 'n' } as never)];
36
+ const PUT = () =>
37
+ HttpClientRequest.put(`${BASE}/v1/sites/s/networks/n`).pipe(
38
+ HttpClientRequest.bodyText('{"x":1}', 'application/json'),
39
+ );
40
+
41
+ const defectOf = (exit: Exit.Exit<unknown, unknown>) => {
42
+ expect(Exit.isFailure(exit)).toBe(true);
43
+ if (!Exit.isFailure(exit)) throw new Error('unreachable');
44
+ expect(Cause.hasDies(exit.cause)).toBe(true);
45
+ const defect = Cause.squash(exit.cause);
46
+ expect(defect).toBeInstanceOf(UnifiRefusedRequest);
47
+ return defect as UnifiRefusedRequest;
48
+ };
49
+
50
+ describe('an OUTER RequestInit that asks to follow redirects (F2)', () => {
51
+ test('redirect stays manual, and keepalive plus a tls option still reach fetch', async () => {
52
+ hits.length = 0;
53
+ const inits: Array<RequestInit & { tls?: unknown }> = [];
54
+ const spy = ((url: string | URL | Request, init?: RequestInit) => {
55
+ inits.push((init ?? {}) as RequestInit);
56
+ return fetch(url, init);
57
+ }) as typeof fetch;
58
+ const exit = await Effect.runPromiseExit(
59
+ HttpClient.HttpClient.pipe(
60
+ Effect.flatMap((client) => client.execute(PUT())),
61
+ Effect.provide(guardedHttpClient(ALLOW, BASE)),
62
+ Effect.provideService(FetchHttpClient.RequestInit, {
63
+ redirect: 'follow',
64
+ keepalive: true,
65
+ tls: { rejectUnauthorized: false },
66
+ } as never),
67
+ Effect.provideService(FetchHttpClient.Fetch, spy),
68
+ Effect.provide(FetchHttpClient.layer),
69
+ ),
70
+ );
71
+ expect(defectOf(exit).message).toContain('307 redirect');
72
+ expect(hits).toHaveLength(1);
73
+ expect(inits).toHaveLength(1);
74
+ expect(inits[0]?.redirect).toBe('manual');
75
+ expect(inits[0]?.keepalive).toBe(true);
76
+ expect(inits[0]?.tls).toEqual({ rejectUnauthorized: false });
77
+ });
78
+ });
79
+
80
+ describe('a consumer transform that replaces RequestInit without merging (F3 backstop)', () => {
81
+ test('a followed redirect dies on the response URL, even though manual was lost', async () => {
82
+ hits.length = 0;
83
+ const consumer = Layer.effect(
84
+ HttpClient.HttpClient,
85
+ Effect.map(HttpClient.HttpClient, (client) =>
86
+ HttpClient.transform(client, (effect) =>
87
+ Effect.provideService(effect, FetchHttpClient.RequestInit, { redirect: 'follow' }),
88
+ ),
89
+ ),
90
+ ).pipe(Layer.provide(FetchHttpClient.layer));
91
+ const exit = await Effect.runPromiseExit(
92
+ HttpClient.HttpClient.pipe(
93
+ Effect.flatMap((client) => client.execute(PUT())),
94
+ Effect.provide(guardedHttpClient(ALLOW, BASE)),
95
+ Effect.provide(consumer),
96
+ ),
97
+ );
98
+ // The redirect WAS followed (the consumer's transform won), so a second request hit the server...
99
+ expect(hits.map((h) => h.path)).toContain('/elsewhere/v1/sites/s/networks/O');
100
+ // ...but the guard refuses to hand the answer back as a success.
101
+ expect(defectOf(exit).message).toContain('response URL differs');
102
+ });
103
+ });
104
+
105
+ describe('query parameters added with setUrlParam (F5)', () => {
106
+ test('a PUT with ?force=true built by setUrlParam is refused before the transport', async () => {
107
+ hits.length = 0;
108
+ const exit = await Effect.runPromiseExit(
109
+ HttpClient.HttpClient.pipe(
110
+ Effect.flatMap((client) =>
111
+ client.execute(PUT().pipe(HttpClientRequest.setUrlParam('force', 'true'))),
112
+ ),
113
+ Effect.provide(guardedHttpClient(ALLOW, BASE)),
114
+ Effect.provide(FetchHttpClient.layer),
115
+ ),
116
+ );
117
+ defectOf(exit);
118
+ expect(hits).toEqual([]);
119
+ });
120
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `guardedHttpClient` (`wire-guard.ts`, B0a/T12) in isolation — against a bare `HttpClient`
3
+ * service, no `CredentialsFromEnv`, no real SDK operation — plus its installation in
4
+ * `unifiHandlers`. A `GET` reaches the wrapped transport; any other method dies with
5
+ * `UnifiRefusedRequest` BEFORE the fake server sees it (`fake.seen` stays empty) unless the row's
6
+ * allow entry names exactly that `(method, path)`.
7
+ *
8
+ * ⚠️ HISTORY (2026-09-26, red team, IMPORTANT-1): deleting `Effect.provide(GetOnlyHttpClient)` from
9
+ * `withCredentials` once left every test green, because the family tests call `unifiOperations`
10
+ * directly and bypass `unifiHandlers`. The `unifiHandlers` block below drives the real handler
11
+ * entry points through a `ConfigProvider` override (the pattern `opnsense/write-refusal.test.ts`
12
+ * uses for `CredentialsFromEnv`), so it fails if a guard is ever removed from `withCredentials`.
13
+ * ★ `fakeUnifi` records `seen` BEFORE calling `route`, so even a route that would throw still proves
14
+ * whether the guard let a request through — the assertions are on `seen`, never on a caught throw.
15
+ */
16
+ import { describe, expect, test } from 'bun:test';
17
+ import * as Cause from 'effect/Cause';
18
+ import * as ConfigProvider from 'effect/ConfigProvider';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Exit from 'effect/Exit';
21
+ import * as Layer from 'effect/Layer';
22
+ import * as HttpClient from 'effect/http/HttpClient';
23
+ import type * as HttpClientError from 'effect/http/HttpClientError';
24
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
25
+ import { FAKE_BASE, FAKE_KEY, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
26
+ import { networkAllowedWrite } from './network.ts';
27
+ import { type UnifiSpec, unifiHandlers } from './resource.ts';
28
+ import { GetOnlyHttpClient, UnifiRefusedRequest, guardedHttpClient } from './wire-guard.ts';
29
+
30
+ const ROOT = '/proxy/network/integration';
31
+ const NET_PATH = `${ROOT}/v1/sites/s/networks/n`;
32
+ const ALLOW = [networkAllowedWrite({ siteId: 's', networkId: 'n' } as never)];
33
+ const url = (path: string) => `https://unifi.example.com${path}`;
34
+
35
+ const send = (
36
+ method: string,
37
+ path: string,
38
+ fetchFn: typeof globalThis.fetch,
39
+ guard: Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient>,
40
+ ) =>
41
+ Effect.runPromiseExit(
42
+ Effect.gen(function* () {
43
+ const client = yield* HttpClient.HttpClient;
44
+ return yield* client.execute(HttpClientRequest.make(method as never)(url(path)));
45
+ }).pipe(Effect.provide(guard), Effect.provide(fakeUnifiLayer(fetchFn))),
46
+ );
47
+
48
+ const expectRefused = (exit: Exit.Exit<unknown, unknown>, method?: string) => {
49
+ expect(Exit.isFailure(exit)).toBe(true);
50
+ if (!Exit.isFailure(exit)) return;
51
+ expect(Cause.hasDies(exit.cause)).toBe(true);
52
+ expect(Cause.hasFails(exit.cause)).toBe(false);
53
+ const defect = Cause.squash(exit.cause);
54
+ expect(defect).toBeInstanceOf(UnifiRefusedRequest);
55
+ if (method) expect((defect as UnifiRefusedRequest).method).toBe(method);
56
+ };
57
+
58
+ const neverReached = (what: string) =>
59
+ fakeUnifi(() => {
60
+ throw new Error(`wire-guard.test.ts: unexpected ${what} reached the fake transport`);
61
+ });
62
+
63
+ describe('GetOnlyHttpClient (empty allow list)', () => {
64
+ test("a GET passes through and reaches the transport under redirect: 'manual' (LOW-7)", async () => {
65
+ const fake = fakeUnifi(() => Response.json({ ok: true }));
66
+ const exit = await send('GET', `${ROOT}/v1/sites/s/networks`, fake.fetch, GetOnlyHttpClient);
67
+ expect(Exit.isSuccess(exit)).toBe(true);
68
+ // 'manual' on GETs is DELIBERATE since 2026-10-10: the response guard refuses a GET's 3xx too,
69
+ // and under the default 'follow' the guard would never see it (the redirect would be followed).
70
+ expect(fake.seen).toEqual([
71
+ { method: 'GET', path: `${ROOT}/v1/sites/s/networks`, redirect: 'manual' },
72
+ ]);
73
+ });
74
+
75
+ test.each(['POST', 'PUT', 'PATCH', 'DELETE'])('%s dies before the transport', async (method) => {
76
+ const fake = neverReached(method);
77
+ expectRefused(await send(method, NET_PATH, fake.fetch, GetOnlyHttpClient), method);
78
+ // ⛔ THE PROOF: the transport never recorded the call -- refused in `mapRequestEffect`.
79
+ expect(fake.seen).toEqual([]);
80
+ });
81
+ });
82
+
83
+ describe('guardedHttpClient with the row allow entry', () => {
84
+ test('the one PUT to this row reaches the transport, with fetch redirect: manual', async () => {
85
+ const fake = fakeUnifi(() => Response.json({ ok: true }));
86
+ const exit = await send('PUT', NET_PATH, fake.fetch, guardedHttpClient(ALLOW, FAKE_BASE));
87
+ expect(Exit.isSuccess(exit)).toBe(true);
88
+ expect(fake.seen).toEqual([{ method: 'PUT', path: NET_PATH, redirect: 'manual' }]);
89
+ });
90
+
91
+ test.each([
92
+ ['the list route', 'PUT', `${ROOT}/v1/sites/s/networks`],
93
+ ['another network id', 'PUT', `${ROOT}/v1/sites/s/networks/other`],
94
+ ['an id this one is a prefix of', 'PUT', `${ROOT}/v1/sites/s/networks/n2`],
95
+ ['a sub-route of this network', 'PUT', `${NET_PATH}/extra`],
96
+ ['an id this one is a suffix of', 'PUT', `${ROOT}/v1/sites/s/networks/xn`],
97
+ ['another site', 'PUT', `${ROOT}/v1/sites/t/networks/n`],
98
+ ['another family', 'PUT', `${ROOT}/v1/sites/s/firewall/zones/z`],
99
+ ['POST on the row path', 'POST', NET_PATH],
100
+ ['PATCH on the row path', 'PATCH', NET_PATH],
101
+ ['DELETE on the row path', 'DELETE', NET_PATH],
102
+ ])('%s dies before the transport', async (_name, method, path) => {
103
+ const fake = neverReached(`${method} ${path}`);
104
+ expectRefused(
105
+ await send(method, path, fake.fetch, guardedHttpClient(ALLOW, FAKE_BASE)),
106
+ method,
107
+ );
108
+ expect(fake.seen).toEqual([]);
109
+ });
110
+
111
+ test('a 307 answer to the allowed PUT dies after exactly one request, never followed', async () => {
112
+ const fake = fakeUnifi(
113
+ () => new Response(null, { status: 307, headers: { location: 'https://evil.example/x' } }),
114
+ );
115
+ expectRefused(
116
+ await send('PUT', NET_PATH, fake.fetch, guardedHttpClient(ALLOW, FAKE_BASE)),
117
+ 'PUT',
118
+ );
119
+ expect(fake.seen).toHaveLength(1);
120
+ expect(fake.seen[0]?.redirect).toBe('manual');
121
+ });
122
+ });
123
+
124
+ interface ProbeProps {
125
+ id: string;
126
+ }
127
+
128
+ /** A `fetchLive` that sends one raw request of the given method (not a real SDK operation). */
129
+ const probeSpec = (
130
+ method: string,
131
+ withUpdate = false,
132
+ ): UnifiSpec<ProbeProps, ProbeProps, ProbeProps, HttpClientError.HttpClientError> => ({
133
+ type: 'Test.Probe',
134
+ describe: (props) => `probe/${props.id}`,
135
+ fetchLive: (props) =>
136
+ Effect.gen(function* () {
137
+ const client = yield* HttpClient.HttpClient;
138
+ yield* client.execute(HttpClientRequest.make(method as never)(`${FAKE_BASE}/probe`));
139
+ return props;
140
+ }),
141
+ attributes: (live) => live,
142
+ matches: () => true,
143
+ ...(withUpdate
144
+ ? {
145
+ update: {
146
+ allowedWrite: () => ({ method: 'PUT' as const, tail: '/probe' }),
147
+ patchKeys: [],
148
+ driftOf: () => [],
149
+ checkScope: () => Effect.void,
150
+ write: (live: ProbeProps) => Effect.succeed(live),
151
+ },
152
+ }
153
+ : {}),
154
+ });
155
+
156
+ const configProvider = ConfigProvider.layer(
157
+ ConfigProvider.fromUnknown({
158
+ UNIFI_NETWORK_API_BASE_URL: FAKE_BASE,
159
+ UNIFI_NETWORK_API_KEY: FAKE_KEY,
160
+ }),
161
+ );
162
+ const session = { note: () => Effect.void };
163
+ const args = { news: { id: 'w1' }, olds: undefined, output: undefined, session };
164
+
165
+ const runHandler = <A, E>(
166
+ effect: Effect.Effect<A, E, HttpClient.HttpClient>,
167
+ fetchFn: typeof globalThis.fetch,
168
+ ): Promise<Exit.Exit<A, E>> =>
169
+ Effect.runPromiseExit(
170
+ effect.pipe(Effect.provide(Layer.mergeAll(fakeUnifiLayer(fetchFn), configProvider))) as never,
171
+ ) as never;
172
+
173
+ describe('guards are installed in unifiHandlers (IMPORTANT-1, red team 2026-09-26)', () => {
174
+ test('a GET through the real read entry point reaches the transport', async () => {
175
+ const fake = fakeUnifi(() => Response.json({ id: 'w1' }));
176
+ const exit = await runHandler(
177
+ unifiHandlers(probeSpec('GET')).read({ olds: { id: 'w1' }, output: undefined }),
178
+ fake.fetch,
179
+ );
180
+ expect(Exit.isSuccess(exit)).toBe(true);
181
+ expect(fake.seen).toEqual([{ method: 'GET', path: `${ROOT}/probe`, redirect: 'manual' }]);
182
+ });
183
+
184
+ test('read dies on a PUT even when the spec declares update (guard scope is reconcile only)', async () => {
185
+ const fake = neverReached('PUT');
186
+ const exit = await runHandler(
187
+ unifiHandlers(probeSpec('PUT', true)).read({ olds: { id: 'w1' }, output: undefined }),
188
+ fake.fetch,
189
+ );
190
+ expectRefused(exit, 'PUT');
191
+ expect(fake.seen).toEqual([]);
192
+ });
193
+
194
+ test('reconcile dies on a PUT when the spec has no update (firewall-zone style)', async () => {
195
+ const fake = neverReached('PUT');
196
+ expectRefused(await runHandler(unifiHandlers(probeSpec('PUT')).reconcile(args), fake.fetch));
197
+ expect(fake.seen).toEqual([]);
198
+ });
199
+
200
+ test('reconcile lets the allowed PUT through when the spec declares update', async () => {
201
+ const fake = fakeUnifi(() => Response.json({ id: 'w1' }));
202
+ const exit = await runHandler(
203
+ unifiHandlers(probeSpec('PUT', true)).reconcile(args),
204
+ fake.fetch,
205
+ );
206
+ expect(Exit.isSuccess(exit)).toBe(true);
207
+ expect(fake.seen.map((s) => s.method)).toEqual(['PUT']);
208
+ });
209
+
210
+ test('reconcile still dies on a POST even when the spec declares update', async () => {
211
+ const fake = neverReached('POST');
212
+ expectRefused(
213
+ await runHandler(unifiHandlers(probeSpec('POST', true)).reconcile(args), fake.fetch),
214
+ 'POST',
215
+ );
216
+ expect(fake.seen).toEqual([]);
217
+ });
218
+ });