@homeflare/alchemy 0.49.0 → 0.51.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 (104) hide show
  1. package/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
  2. package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
  3. package/dist/kubernetes/index.d.ts +12 -0
  4. package/dist/kubernetes/index.d.ts.map +1 -0
  5. package/dist/kubernetes/index.js +585 -0
  6. package/dist/kubernetes/index.js.map +19 -0
  7. package/dist/kubernetes/ready-checks.d.ts +45 -0
  8. package/dist/kubernetes/ready-checks.d.ts.map +1 -0
  9. package/dist/kubernetes/ready-classify.d.ts +23 -0
  10. package/dist/kubernetes/ready-classify.d.ts.map +1 -0
  11. package/dist/kubernetes/ready-connect.d.ts +29 -0
  12. package/dist/kubernetes/ready-connect.d.ts.map +1 -0
  13. package/dist/kubernetes/ready-declare.d.ts +5 -0
  14. package/dist/kubernetes/ready-declare.d.ts.map +1 -0
  15. package/dist/kubernetes/ready-errors.d.ts +80 -0
  16. package/dist/kubernetes/ready-errors.d.ts.map +1 -0
  17. package/dist/kubernetes/ready-poll.d.ts +61 -0
  18. package/dist/kubernetes/ready-poll.d.ts.map +1 -0
  19. package/dist/kubernetes/ready.d.ts +74 -0
  20. package/dist/kubernetes/ready.d.ts.map +1 -0
  21. package/dist/talos/fake-apiserver.d.ts +11 -2
  22. package/dist/talos/fake-apiserver.d.ts.map +1 -1
  23. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  24. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  25. package/dist/unifi/acl-rule.d.ts +6 -1
  26. package/dist/unifi/acl-rule.d.ts.map +1 -1
  27. package/dist/unifi/dns-policy.d.ts +6 -1
  28. package/dist/unifi/dns-policy.d.ts.map +1 -1
  29. package/dist/unifi/fake-unifi.d.ts +4 -0
  30. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  31. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  32. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  33. package/dist/unifi/firewall-policy.d.ts +6 -1
  34. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  35. package/dist/unifi/firewall-zone.d.ts +6 -1
  36. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  37. package/dist/unifi/index.d.ts +5 -3
  38. package/dist/unifi/index.d.ts.map +1 -1
  39. package/dist/unifi/index.js +339 -86
  40. package/dist/unifi/index.js.map +10 -6
  41. package/dist/unifi/network-scope.d.ts +31 -0
  42. package/dist/unifi/network-scope.d.ts.map +1 -0
  43. package/dist/unifi/network-update.d.ts +44 -0
  44. package/dist/unifi/network-update.d.ts.map +1 -0
  45. package/dist/unifi/network.d.ts +32 -12
  46. package/dist/unifi/network.d.ts.map +1 -1
  47. package/dist/unifi/policy.d.ts +89 -1
  48. package/dist/unifi/policy.d.ts.map +1 -1
  49. package/dist/unifi/resource.d.ts +17 -43
  50. package/dist/unifi/resource.d.ts.map +1 -1
  51. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  52. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  53. package/dist/unifi/update-reconcile.d.ts +31 -0
  54. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  55. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  56. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  57. package/dist/unifi/wire-guard.d.ts +33 -0
  58. package/dist/unifi/wire-guard.d.ts.map +1 -0
  59. package/docs/kubernetes-ready.md +146 -0
  60. package/docs/peers.md +13 -0
  61. package/docs/provider-standard.md +7 -0
  62. package/docs/unifi-network-write.md +90 -0
  63. package/docs/unifi.md +36 -36
  64. package/package.json +7 -1
  65. package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
  66. package/src/kubernetes/helm-chart-bounded.ts +110 -0
  67. package/src/kubernetes/index.ts +28 -0
  68. package/src/kubernetes/ready-backoff.test.ts +124 -0
  69. package/src/kubernetes/ready-checks.test.ts +106 -0
  70. package/src/kubernetes/ready-checks.ts +165 -0
  71. package/src/kubernetes/ready-classify.ts +76 -0
  72. package/src/kubernetes/ready-connect.ts +52 -0
  73. package/src/kubernetes/ready-declare.test.ts +85 -0
  74. package/src/kubernetes/ready-declare.ts +50 -0
  75. package/src/kubernetes/ready-errors.ts +102 -0
  76. package/src/kubernetes/ready-first-install.test.ts +126 -0
  77. package/src/kubernetes/ready-poll-states.test.ts +92 -0
  78. package/src/kubernetes/ready-poll.test.ts +194 -0
  79. package/src/kubernetes/ready-poll.ts +215 -0
  80. package/src/kubernetes/ready-rollout.test.ts +58 -0
  81. package/src/kubernetes/ready-secrecy.test.ts +131 -0
  82. package/src/kubernetes/ready.fixtures.ts +103 -0
  83. package/src/kubernetes/ready.test.ts +205 -0
  84. package/src/kubernetes/ready.ts +188 -0
  85. package/src/talos/fake-apiserver.ts +21 -3
  86. package/src/unifi/fake-unifi.ts +15 -2
  87. package/src/unifi/handler-allow-news.test.ts +80 -0
  88. package/src/unifi/index.ts +21 -5
  89. package/src/unifi/network-scope.test.ts +203 -0
  90. package/src/unifi/network-scope.ts +75 -0
  91. package/src/unifi/network-update.test.ts +250 -0
  92. package/src/unifi/network-update.ts +95 -0
  93. package/src/unifi/network.test.ts +27 -1
  94. package/src/unifi/network.ts +56 -12
  95. package/src/unifi/policy.ts +128 -10
  96. package/src/unifi/resource.test.ts +15 -3
  97. package/src/unifi/resource.ts +64 -105
  98. package/src/unifi/update-reconcile.ts +126 -0
  99. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  100. package/src/unifi/wire-guard-round2.test.ts +120 -0
  101. package/src/unifi/wire-guard.test.ts +218 -0
  102. package/src/unifi/wire-guard.ts +192 -0
  103. package/src/unifi/write-op-reference.test.ts +38 -2
  104. package/src/unifi/get-only-guard.test.ts +0 -165
@@ -0,0 +1,192 @@
1
+ /**
2
+ * B0a / T12 DEFENSE IN DEPTH, AT THE WIRE. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at
3
+ * `reconcile`/`destroy`; this guard stops the same thing one layer lower. It wraps whatever
4
+ * `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in production,
5
+ * `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same "wrap, don't
6
+ * replace" seam), so an SDK write operation called by mistake, bypassing `unifiOperations`, still
7
+ * never reaches the vendor API. `write-op-reference.test.ts` proves no such call exists outside
8
+ * `network-update.ts`; this is what stops one from ever taking effect.
9
+ *
10
+ * ★ A GET ALWAYS PASSES THE REQUEST GUARD; ANY OTHER METHOD PASSES ONLY WHEN `(method, pathname)`
11
+ * MATCHES AN ENTRY OF THE `allow` LIST. `unifiHandlers` builds that list PER ROW
12
+ * (`spec.update.allowedWrite(news)`): the one PUT that can pass is to this row's own
13
+ * `/v1/sites/<siteId>/networks/<networkId>` under the CONFIGURED base URL (local and
14
+ * cloud-connector base paths differ, so the base is passed in and compared exactly, `isAllowed`).
15
+ * A PUT to another network id, the list route, or another family dies here.
16
+ *
17
+ * ⚠️ `Effect.die`, NOT A TYPED FAILURE — every generated SDK operation declares its OWN closed
18
+ * error union from the pinned OpenAPI spec and generated files are never hand-edited, so a typed
19
+ * `Effect.fail` here would be a failure absent from every op's declared type. A defect is the
20
+ * sound way to add a "structurally impossible" failure underneath types this package does not
21
+ * own, like `openbao`'s `refuse()` helpers.
22
+ *
23
+ * ⛔ REDIRECTS (LOW-7), REFUSED FOR EVERY METHOD — GETs included, 2026-10-10. The layer sets fetch
24
+ * `redirect: 'manual'` on EVERY guarded request and the guard dies on any 3xx answer. Default
25
+ * fetch follows a redirect and re-sends the request to a `Location` this guard never saw — a PUT
26
+ * with its body on 307/308, a GET still carrying the API-key header — so following is never
27
+ * safe. Scoping `manual` to a non-empty allow list (the pre-2026-10-10 shape) also made the two
28
+ * postures DIVERGE by accident: reconcile's own GETs ran manual, so a 3xx surfaced raw and failed
29
+ * the SDK decode, while read/diff under `GetOnlyHttpClient` followed the redirect silently. One
30
+ * rule for both postures now: a redirect is refused everywhere, fail closed.
31
+ */
32
+ import * as Effect from 'effect/Effect';
33
+ import * as Option from 'effect/Option';
34
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
35
+ import * as HttpClient from 'effect/http/HttpClient';
36
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
37
+ import type * as HttpClientResponse from 'effect/http/HttpClientResponse';
38
+ import { UNIFI_WRITE_POLICY } from './policy.ts';
39
+
40
+ /**
41
+ * One non-GET request the guard may let through. `tail` is the exact route below the configured
42
+ * base URL (`/v1/sites/<site>/networks/<id>`): the guard compares the request's origin and path to
43
+ * `baseUrl + tail` and requires an empty query and fragment (`isAllowed`).
44
+ */
45
+ export type AllowedWrite = { readonly method: 'PUT'; readonly tail: string };
46
+
47
+ export class UnifiRefusedRequest extends Error {
48
+ constructor(
49
+ readonly method: string,
50
+ /** PATH ONLY, never the full URL — see `requestPath` below. */
51
+ readonly path: string,
52
+ detail = "not on this row's allow list",
53
+ ) {
54
+ super(
55
+ `UniFi HttpClient guard: refused ${method} ${path} (${detail}) -- ${UNIFI_WRITE_POLICY}. ` +
56
+ 'This is a defect, not a recoverable condition: something under src/unifi sent a request ' +
57
+ "policy.ts's per-operation refusal should already have made impossible. Fix the kit " +
58
+ 'code that produced this request.',
59
+ );
60
+ this.name = 'UnifiRefusedRequest';
61
+ }
62
+ }
63
+
64
+ /** @deprecated renamed `UnifiRefusedRequest`; kept so existing consumer imports do not break. */
65
+ export const UnifiNonGetRequest = UnifiRefusedRequest;
66
+ export type UnifiNonGetRequest = UnifiRefusedRequest;
67
+
68
+ /**
69
+ * ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID
70
+ * (`docs/unifi.md`'s Credentials section, T3) — a defect message can still reach a log or a CI
71
+ * failure body. Falls back to a fixed placeholder on a parse failure so a malformed URL can
72
+ * never leak through unredacted.
73
+ */
74
+ const requestPath = (url: string): string => {
75
+ try {
76
+ // ⛔ The cloud connector path is `/v1/connector/consoles/<consoleId>/...`: the id is dropped too.
77
+ return new URL(url).pathname.replace(/(\/consoles\/)[^/]*/g, '$1<redacted>');
78
+ } catch {
79
+ return '<unparseable request url>';
80
+ }
81
+ };
82
+
83
+ /**
84
+ * ⛔ ANCHORED TO THE CONFIGURED BASE URL, NOT A SUFFIX. The request must share the base's origin,
85
+ * its path must be exactly `<base path><tail>` (so `//v1/...`, another base path or a nested
86
+ * `/v1/sites/X/v1/sites/s/...` all miss), and it must carry no query or fragment (`?force=true`).
87
+ * Without a base URL nothing is allowed: fail closed.
88
+ */
89
+ const isAllowed = (
90
+ allow: ReadonlyArray<AllowedWrite>,
91
+ baseUrl: string | undefined,
92
+ method: string,
93
+ request: HttpClientRequest.HttpClientRequest,
94
+ ) => {
95
+ if (baseUrl === undefined) return false;
96
+ try {
97
+ const base = new URL(baseUrl);
98
+ // ⛔ `toUrl`, NOT `request.url`: `setUrlParam` keeps its parameters OUTSIDE `request.url`, so
99
+ // the bare string looked query-free while the wire request carried `?force=true` (round 2, F5).
100
+ const resolved = HttpClientRequest.toUrl(request);
101
+ if (Option.isNone(resolved)) return false;
102
+ const target = resolved.value;
103
+ if (target.origin !== base.origin || target.search !== '' || target.hash !== '') return false;
104
+ const basePath = base.pathname.replace(/\/+$/, '');
105
+ return allow.some(
106
+ (entry) => entry.method === method && target.pathname === `${basePath}${entry.tail}`,
107
+ );
108
+ } catch {
109
+ return false;
110
+ }
111
+ };
112
+
113
+ /** True when the response says it came from a different URL than the request named. */
114
+ const followedElsewhere = (res: HttpClientResponse.HttpClientResponse): boolean => {
115
+ const sent = HttpClientRequest.toUrl(res.request);
116
+ return res.url !== '' && Option.isSome(sent) && res.url !== sent.value.href.split('#')[0];
117
+ };
118
+
119
+ const guard = (
120
+ client: HttpClient.HttpClient,
121
+ allow: ReadonlyArray<AllowedWrite>,
122
+ baseUrl: string | undefined,
123
+ ) =>
124
+ client.pipe(
125
+ HttpClient.mapRequestEffect((request) =>
126
+ request.method === 'GET' || isAllowed(allow, baseUrl, request.method, request)
127
+ ? Effect.succeed(request)
128
+ : Effect.die(new UnifiRefusedRequest(request.method, requestPath(request.url))),
129
+ ),
130
+ HttpClient.transformResponse((response) =>
131
+ Effect.flatMap(response, (res) =>
132
+ // ⛔ ANY METHOD: a GET's 3xx is refused exactly like a PUT's — see REDIRECTS in the header.
133
+ res.status >= 300 && res.status < 400
134
+ ? Effect.die(
135
+ new UnifiRefusedRequest(
136
+ res.request.method,
137
+ requestPath(res.request.url),
138
+ `answered a ${res.status} redirect`,
139
+ ),
140
+ )
141
+ : // ⛔ BACKSTOP (round 2, F3): if a consumer's own transform replaced `manual` and fetch
142
+ // followed a redirect, the response URL is no longer the request's. Die on that.
143
+ followedElsewhere(res)
144
+ ? Effect.die(
145
+ new UnifiRefusedRequest(
146
+ res.request.method,
147
+ requestPath(res.request.url),
148
+ 'the response URL differs from the request URL (a redirect was followed)',
149
+ ),
150
+ )
151
+ : Effect.succeed(res),
152
+ ),
153
+ ),
154
+ );
155
+
156
+ /**
157
+ * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
158
+ * for everything downstream, with the guarded version. Exported so `wire-guard.test.ts` can prove
159
+ * the mechanism against a bare `HttpClient`, without `CredentialsFromEnv` or any env var.
160
+ */
161
+ export const guardedHttpClient = (allow: ReadonlyArray<AllowedWrite>, baseUrl?: string) => {
162
+ return HttpClient.layerMergedContext(
163
+ Effect.map(HttpClient.HttpClient, (client) =>
164
+ // ⛔ MANUAL REDIRECT ON BOTH POSTURES, not just an allow list: the response guard refuses a
165
+ // 3xx for GETs too, and under the default 'follow' a GET redirect is silently followed —
166
+ // the guard would never see it, and read/diff would diverge from reconcile again.
167
+ HttpClient.transform(guard(client, allow, baseUrl), manualRedirect),
168
+ ),
169
+ );
170
+ };
171
+
172
+ /**
173
+ * ⛔ `redirect: 'manual'` IS SET PER REQUEST, ON THE CALLING FIBER'S `RequestInit`. A layer-provided
174
+ * `RequestInit` is silently REPLACED by an outer one (Effect 4.0.1 `layerMergedContext` merges the
175
+ * caller's context over the layer's), which would turn redirects back on. Same read-merge-provide
176
+ * pattern as `openbao/bao-http.ts`'s `overSocket`; outer fetch options are kept.
177
+ */
178
+ const manualRedirect = <E, R>(
179
+ effect: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
180
+ ): Effect.Effect<HttpClientResponse.HttpClientResponse, E, R> =>
181
+ Effect.flatMap(Effect.serviceOption(FetchHttpClient.RequestInit), (existing) =>
182
+ Effect.provideService(effect, FetchHttpClient.RequestInit, {
183
+ ...Option.getOrElse(existing, () => ({})),
184
+ redirect: 'manual',
185
+ }),
186
+ );
187
+
188
+ /**
189
+ * No write may pass; the posture of `read`/`diff`/`delete` and of every non-updatable family. A 3xx
190
+ * answer to a GET is refused here exactly as under the allow-list posture — see REDIRECTS above.
191
+ */
192
+ export const GetOnlyHttpClient = guardedHttpClient([]);
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { describe, expect, test } from 'bun:test';
27
27
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
28
- import { dirname, join, resolve } from 'node:path';
28
+ import { dirname, join, relative, resolve } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { stripComments, walk } from './scan-source.ts';
31
31
 
@@ -54,17 +54,34 @@ const sdkWriteOpNames = (): ReadonlySet<string> => {
54
54
 
55
55
  const REAL_WRITE_OP_NAMES = sdkWriteOpNames();
56
56
 
57
+ /**
58
+ * ⛔ THE ONE FILE ALLOWED TO NAME ONE WRITE OP (Tim 2026-10-10: `Unifi.Network` update). Keyed by
59
+ * path relative to `src/unifi`. Widening this object is a policy change — `policy.ts` — and a
60
+ * test below pins it to exactly this entry so it cannot grow unnoticed. Everything else the SDK
61
+ * exports (`createNetwork`, `deleteNetwork`, `patchFirewallPolicy`, …) stays an offense even
62
+ * inside the allowlisted file.
63
+ */
64
+ const WRITE_OP_ALLOWLIST: Record<string, ReadonlyArray<string>> = {
65
+ 'network-update.ts': ['updateNetwork'],
66
+ };
67
+
57
68
  /** Recursive by construction (IMPORTANT-2): a future `src/unifi/<subdir>/*.ts` is walked too. */
58
69
  const sourceFiles = () => walk(DIR).filter((path) => path !== SELF);
59
70
 
60
71
  /** Every offense in one file: any real SDK write-op name appearing as a token anywhere in its
61
72
  * comment-stripped text, GATED on the file mentioning the SDK's package specifier at all (a file
62
73
  * that never imports it — by any syntax — cannot meaningfully reference one of its exports). */
63
- const offensesIn = (path: string, rawSrc: string): string[] => {
74
+ const offensesIn = (
75
+ path: string,
76
+ rawSrc: string,
77
+ allowlist: Record<string, ReadonlyArray<string>> = WRITE_OP_ALLOWLIST,
78
+ ): string[] => {
64
79
  if (!rawSrc.includes(SDK_MARKER)) return [];
65
80
  const codeSrc = stripComments(rawSrc);
81
+ const allowed = allowlist[relative(DIR, path)] ?? [];
66
82
  const offenses: string[] = [];
67
83
  for (const name of REAL_WRITE_OP_NAMES) {
84
+ if (allowed.includes(name)) continue;
68
85
  if (new RegExp(`\\b${name}\\b`).test(codeSrc)) {
69
86
  offenses.push(`${path}: references SDK write op '${name}'`);
70
87
  }
@@ -169,6 +186,25 @@ describe('src/unifi never references an SDK write operation (T12)', () => {
169
186
  }
170
187
  });
171
188
 
189
+ test('the allowlist is exactly network-update.ts -> updateNetwork, nothing else', () => {
190
+ expect(WRITE_OP_ALLOWLIST).toEqual({ 'network-update.ts': ['updateNetwork'] });
191
+ });
192
+
193
+ test('network-update.ts may name updateNetwork but still not create/delete/patch ops', () => {
194
+ const path = join(DIR, 'network-update.ts');
195
+ const src = (op: string) => `import * as n from '${SDK_MARKER}/networks';\nn.${op}({});\n`;
196
+ expect(offensesIn(path, src('updateNetwork'))).toEqual([]);
197
+ for (const op of ['createNetwork', 'deleteNetwork', 'patchFirewallPolicy']) {
198
+ expect(offensesIn(path, src(op))).toEqual([`${path}: references SDK write op '${op}'`]);
199
+ }
200
+ });
201
+
202
+ test('any other file naming updateNetwork is an offense', () => {
203
+ const path = join(DIR, 'network.ts');
204
+ const src = `import * as n from '${SDK_MARKER}/networks';\nn.updateNetwork({});\n`;
205
+ expect(offensesIn(path, src)).toEqual([`${path}: references SDK write op 'updateNetwork'`]);
206
+ });
207
+
172
208
  test('the walk is recursive -- a future src/unifi/<subdir>/*.ts is not silently unscanned', () => {
173
209
  const probeDir = join(DIR, '.write-op-reference-recursion-probe');
174
210
  const probeFile = join(probeDir, 'nested.ts');
@@ -1,165 +0,0 @@
1
- /**
2
- * `GetOnlyHttpClient` (`resource.ts`, B0a/T12) in isolation — against a bare `HttpClient` service,
3
- * no `CredentialsFromEnv`, no real SDK operation. Proves the guard's own mechanism: a `GET`
4
- * reaches the wrapped transport unchanged, and any other method dies with `UnifiNonGetRequest`
5
- * BEFORE the fake server ever sees it (`fake.seen` stays empty for the refused call).
6
- *
7
- * ⚠️ NOT ROUTED THROUGH `unifiHandlers`/`CredentialsFromEnv` ON PURPOSE — see `fake-unifi.ts`'s
8
- * own warning: `CredentialsFromEnv` resolves `UNIFI_NETWORK_*` through Effect's `Config`, which
9
- * this directory's existing tests all avoid depending on for the same reason. Since the guard
10
- * only touches the `HttpClient` service, testing it directly here is both simpler and a more
11
- * precise proof of the mechanism than routing real credentials through it would be.
12
- * ⚠️ CORRECTION (2026-09-26, red team): the line this replaces claimed `network.test.ts`/
13
- * `firewall-zone.test.ts` prove `withCredentials` lets a real GET through. They don't —
14
- * both call `unifiOperations(spec)` directly, which bypasses `unifiHandlers`/`withCredentials`
15
- * (and therefore this guard) entirely. Proof: deleting `Effect.provide(GetOnlyHttpClient)` from
16
- * `withCredentials` (`resource.ts`) left all 56 tests green before this file's own
17
- * `describe('GetOnlyHttpClient installed in unifiHandlers', …)` block below existed. THAT block
18
- * is what closes the gap: it drives `unifiHandlers(...).read`/`.reconcile` — the actual exported
19
- * entry points a real deploy calls — through a `ConfigProvider` override (the same pattern
20
- * `opnsense/write-refusal.test.ts` uses for `CredentialsFromEnv`), so it fails if the guard is
21
- * ever removed from `withCredentials` again.
22
- * ★ REUSES `fakeUnifi` (`fake-unifi.ts`), NOT AN AD HOC MOCK. `fakeUnifi`'s wrapper records
23
- * `seen` BEFORE calling `route`, so even a `route` that would throw still proves whether the
24
- * guard let a request through — the assertion is on `seen`, never on catching that throw.
25
- */
26
- import { describe, expect, test } from 'bun:test';
27
- import * as Cause from 'effect/Cause';
28
- import * as ConfigProvider from 'effect/ConfigProvider';
29
- import * as Effect from 'effect/Effect';
30
- import * as Exit from 'effect/Exit';
31
- import * as Layer from 'effect/Layer';
32
- import * as HttpClient from 'effect/http/HttpClient';
33
- import type * as HttpClientError from 'effect/http/HttpClientError';
34
- import * as HttpClientRequest from 'effect/http/HttpClientRequest';
35
- import { FAKE_BASE, FAKE_KEY, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
36
- import {
37
- GetOnlyHttpClient,
38
- UnifiNonGetRequest,
39
- type UnifiSpec,
40
- unifiHandlers,
41
- } from './resource.ts';
42
-
43
- const FAKE_PATH = '/proxy/network/integration/v1/sites/site-1/networks';
44
- const FAKE_URL = `https://unifi.example.com${FAKE_PATH}`;
45
-
46
- /** One request through `GetOnlyHttpClient`, over a fake transport built by `fakeUnifi`. */
47
- const sendGuarded = (method: string, fetchFn: typeof globalThis.fetch) =>
48
- Effect.runPromiseExit(
49
- Effect.gen(function* () {
50
- const client = yield* HttpClient.HttpClient;
51
- return yield* client.execute(HttpClientRequest.make(method as never)(FAKE_URL));
52
- }).pipe(Effect.provide(GetOnlyHttpClient), Effect.provide(fakeUnifiLayer(fetchFn))),
53
- );
54
-
55
- describe('GetOnlyHttpClient', () => {
56
- test('a GET request passes through unchanged and reaches the transport', async () => {
57
- const fake = fakeUnifi(() => Response.json({ ok: true }));
58
-
59
- const exit = await sendGuarded('GET', fake.fetch);
60
-
61
- expect(Exit.isSuccess(exit)).toBe(true);
62
- expect(fake.seen).toEqual([{ method: 'GET', path: FAKE_PATH }]);
63
- });
64
-
65
- test.each(['POST', 'PUT', 'PATCH', 'DELETE'])(
66
- '%s dies with UnifiNonGetRequest and never reaches the transport',
67
- async (method) => {
68
- const fake = fakeUnifi(() => {
69
- throw new Error(`get-only-guard.test.ts: unexpected ${method} reached the fake transport`);
70
- });
71
-
72
- const exit = await sendGuarded(method, fake.fetch);
73
-
74
- expect(Exit.isFailure(exit)).toBe(true);
75
- if (!Exit.isFailure(exit)) return;
76
- expect(Cause.hasDies(exit.cause)).toBe(true);
77
- expect(Cause.hasFails(exit.cause)).toBe(false);
78
- const defect = Cause.squash(exit.cause);
79
- expect(defect).toBeInstanceOf(UnifiNonGetRequest);
80
- expect((defect as UnifiNonGetRequest).method).toBe(method);
81
- // ⛔ THE PROOF: the fake transport never even recorded the call -- the guard refused the
82
- // request in `mapRequestEffect`, before dispatch, not after a failed response.
83
- expect(fake.seen).toEqual([]);
84
- },
85
- );
86
- });
87
-
88
- interface ProbeProps {
89
- id: string;
90
- }
91
-
92
- /**
93
- * A `fetchLive` that sends exactly one raw request over whatever `method` the test wants to
94
- * probe — not a real SDK operation, on purpose: this block proves the GUARD'S INSTALLATION in
95
- * `unifiHandlers`, not any one family's own all-GET behavior (`network.test.ts`/
96
- * `firewall-zone.test.ts` already prove that, for calls that bypass `unifiHandlers` entirely).
97
- */
98
- const probeSpec = (
99
- method: string,
100
- ): UnifiSpec<ProbeProps, ProbeProps, ProbeProps, HttpClientError.HttpClientError> => ({
101
- type: 'Test.Probe',
102
- describe: (props) => `probe/${props.id}`,
103
- fetchLive: (props) =>
104
- Effect.gen(function* () {
105
- const client = yield* HttpClient.HttpClient;
106
- yield* client.execute(HttpClientRequest.make(method as never)(`${FAKE_BASE}/probe`));
107
- return props;
108
- }),
109
- attributes: (live) => live,
110
- matches: () => true,
111
- });
112
-
113
- /**
114
- * Overrides the `ConfigProvider` `CredentialsFromEnv` resolves `UNIFI_NETWORK_*` against — the
115
- * SAME pattern `opnsense/write-refusal.test.ts` uses for its own `CredentialsFromEnv` — WITHOUT
116
- * touching real `process.env` (a parallel test file could race on that). `unifiHandlers`'s
117
- * `withCredentials` bakes in `CredentialsFromEnv` itself, so providing a `Credentials` layer from
118
- * OUTSIDE (the way `fake-unifi.ts`'s own `fakeUnifiLayer` does for tests that call `spec.fetchLive`
119
- * directly) would be shadowed and do nothing here — this ConfigProvider override is what actually
120
- * reaches it.
121
- */
122
- const fakeCredentialsConfigProvider = ConfigProvider.layer(
123
- ConfigProvider.fromUnknown({
124
- UNIFI_NETWORK_API_BASE_URL: FAKE_BASE,
125
- UNIFI_NETWORK_API_KEY: FAKE_KEY,
126
- }),
127
- );
128
-
129
- describe('GetOnlyHttpClient is installed in unifiHandlers (IMPORTANT-1, red team 2026-09-26)', () => {
130
- test('a GET through the real handler entry point still reaches the transport', async () => {
131
- const fake = fakeUnifi(() => Response.json({ id: 'w1' }));
132
- const attrs = await Effect.runPromise(
133
- unifiHandlers(probeSpec('GET'))
134
- .read({ olds: { id: 'w1' }, output: undefined })
135
- .pipe(
136
- Effect.provide(Layer.mergeAll(fakeUnifiLayer(fake.fetch), fakeCredentialsConfigProvider)),
137
- ),
138
- );
139
- expect(attrs).toEqual({ id: 'w1' });
140
- expect(fake.seen).toEqual([{ method: 'GET', path: '/proxy/network/integration/probe' }]);
141
- });
142
-
143
- test('a non-GET through the real handler entry point dies, never reaching the transport', async () => {
144
- const fake = fakeUnifi(() => {
145
- throw new Error('get-only-guard.test.ts: unexpected POST reached the fake transport');
146
- });
147
- const exit = await Effect.runPromiseExit(
148
- unifiHandlers(probeSpec('POST'))
149
- .read({ olds: { id: 'w1' }, output: undefined })
150
- .pipe(
151
- Effect.provide(Layer.mergeAll(fakeUnifiLayer(fake.fetch), fakeCredentialsConfigProvider)),
152
- ),
153
- );
154
- expect(Exit.isFailure(exit)).toBe(true);
155
- if (!Exit.isFailure(exit)) return;
156
- expect(Cause.hasDies(exit.cause)).toBe(true);
157
- expect(Cause.squash(exit.cause)).toBeInstanceOf(UnifiNonGetRequest);
158
- // ⛔ THIS IS THE PROOF THE FILE HEADER PROMISES. Verified by hand (2026-09-26): deleting
159
- // `Effect.provide(GetOnlyHttpClient)` from `withCredentials` (`resource.ts`) turns this
160
- // exact test into a passing `Response.json` round-trip with a non-empty `fake.seen` --
161
- // i.e. it FAILS the instant the guard installation regresses, which no test in this
162
- // directory could do before this block existed (IMPORTANT-1).
163
- expect(fake.seen).toEqual([]);
164
- });
165
- });