@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.
- package/dist/linux/container-env-quote.d.ts +39 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +14 -13
- package/dist/linux/container-form.d.ts.map +1 -1
- package/dist/linux/container-paths.d.ts +17 -0
- package/dist/linux/container-paths.d.ts.map +1 -0
- package/dist/linux/index.js +41 -8
- package/dist/linux/index.js.map +6 -4
- package/dist/unifi/acl-rule-ordering.d.ts +6 -1
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
- package/dist/unifi/acl-rule.d.ts +6 -1
- package/dist/unifi/acl-rule.d.ts.map +1 -1
- package/dist/unifi/dns-policy.d.ts +6 -1
- package/dist/unifi/dns-policy.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +4 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
- package/dist/unifi/firewall-policy.d.ts +6 -1
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +6 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +5 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +339 -86
- package/dist/unifi/index.js.map +10 -6
- package/dist/unifi/network-scope.d.ts +31 -0
- package/dist/unifi/network-scope.d.ts.map +1 -0
- package/dist/unifi/network-update.d.ts +44 -0
- package/dist/unifi/network-update.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +34 -13
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/policy.d.ts +89 -1
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +17 -43
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +6 -1
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
- package/dist/unifi/update-reconcile.d.ts +31 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +6 -1
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
- package/dist/unifi/wire-guard.d.ts +33 -0
- package/dist/unifi/wire-guard.d.ts.map +1 -0
- package/docs/unifi-network-write.md +90 -0
- package/docs/unifi.md +40 -39
- package/package.json +1 -1
- package/src/linux/container-env-chain.test.ts +135 -0
- package/src/linux/container-env-live.test.ts +115 -0
- package/src/linux/container-env-oracle.ts +109 -0
- package/src/linux/container-env-quadlet-oracle.ts +174 -0
- package/src/linux/container-env-quote.test.ts +131 -0
- package/src/linux/container-env-quote.ts +126 -0
- package/src/linux/container-fixture.test.ts +3 -1
- package/src/linux/container-form.ts +32 -28
- package/src/linux/container-paths.ts +21 -0
- package/src/unifi/fake-unifi.ts +15 -2
- package/src/unifi/handler-allow-news.test.ts +80 -0
- package/src/unifi/index.ts +21 -5
- package/src/unifi/network-scope.test.ts +224 -0
- package/src/unifi/network-scope.ts +75 -0
- package/src/unifi/network-update.test.ts +250 -0
- package/src/unifi/network-update.ts +95 -0
- package/src/unifi/network.test.ts +27 -1
- package/src/unifi/network.ts +58 -13
- package/src/unifi/policy.ts +128 -10
- package/src/unifi/resource.test.ts +15 -3
- package/src/unifi/resource.ts +64 -105
- package/src/unifi/update-reconcile.ts +126 -0
- package/src/unifi/wire-guard-anchor.test.ts +198 -0
- package/src/unifi/wire-guard-round2.test.ts +120 -0
- package/src/unifi/wire-guard.test.ts +218 -0
- package/src/unifi/wire-guard.ts +192 -0
- package/src/unifi/write-op-reference.test.ts +41 -4
- 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([]);
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* what those operations are named. `resource.ts`'s engine and `policy.ts`'s refusal never call one
|
|
5
5
|
* today; nothing in the TYPE SYSTEM stops a future family file from importing, say,
|
|
6
6
|
* `unifi_devices.adoptDevice` and calling it directly, bypassing `unifiOperations` altogether.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The wire guard (`wire-guard.ts`'s `guardedHttpClient`, `GetOnlyHttpClient` = empty allow
|
|
8
|
+
* list) is the runtime backstop if one ever did; this is the static one, so the mistake is
|
|
9
|
+
* caught at test time instead of by a wire-level defect.
|
|
9
10
|
*
|
|
10
11
|
* ⚠️ REWRITTEN (2026-09-26, red team, IMPORTANT-2). The previous version parsed import statements
|
|
11
12
|
* structurally (namespace alias + `.op`, or a named import's LOCAL name) and missed: the SDK's
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
*/
|
|
26
27
|
import { describe, expect, test } from 'bun:test';
|
|
27
28
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
|
-
import { dirname, join, resolve } from 'node:path';
|
|
29
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
29
30
|
import { fileURLToPath } from 'node:url';
|
|
30
31
|
import { stripComments, walk } from './scan-source.ts';
|
|
31
32
|
|
|
@@ -54,17 +55,34 @@ const sdkWriteOpNames = (): ReadonlySet<string> => {
|
|
|
54
55
|
|
|
55
56
|
const REAL_WRITE_OP_NAMES = sdkWriteOpNames();
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* ⛔ THE ONE FILE ALLOWED TO NAME ONE WRITE OP (Tim 2026-10-10: `Unifi.Network` update). Keyed by
|
|
60
|
+
* path relative to `src/unifi`. Widening this object is a policy change — `policy.ts` — and a
|
|
61
|
+
* test below pins it to exactly this entry so it cannot grow unnoticed. Everything else the SDK
|
|
62
|
+
* exports (`createNetwork`, `deleteNetwork`, `patchFirewallPolicy`, …) stays an offense even
|
|
63
|
+
* inside the allowlisted file.
|
|
64
|
+
*/
|
|
65
|
+
const WRITE_OP_ALLOWLIST: Record<string, ReadonlyArray<string>> = {
|
|
66
|
+
'network-update.ts': ['updateNetwork'],
|
|
67
|
+
};
|
|
68
|
+
|
|
57
69
|
/** Recursive by construction (IMPORTANT-2): a future `src/unifi/<subdir>/*.ts` is walked too. */
|
|
58
70
|
const sourceFiles = () => walk(DIR).filter((path) => path !== SELF);
|
|
59
71
|
|
|
60
72
|
/** Every offense in one file: any real SDK write-op name appearing as a token anywhere in its
|
|
61
73
|
* comment-stripped text, GATED on the file mentioning the SDK's package specifier at all (a file
|
|
62
74
|
* that never imports it — by any syntax — cannot meaningfully reference one of its exports). */
|
|
63
|
-
const offensesIn = (
|
|
75
|
+
const offensesIn = (
|
|
76
|
+
path: string,
|
|
77
|
+
rawSrc: string,
|
|
78
|
+
allowlist: Record<string, ReadonlyArray<string>> = WRITE_OP_ALLOWLIST,
|
|
79
|
+
): string[] => {
|
|
64
80
|
if (!rawSrc.includes(SDK_MARKER)) return [];
|
|
65
81
|
const codeSrc = stripComments(rawSrc);
|
|
82
|
+
const allowed = allowlist[relative(DIR, path)] ?? [];
|
|
66
83
|
const offenses: string[] = [];
|
|
67
84
|
for (const name of REAL_WRITE_OP_NAMES) {
|
|
85
|
+
if (allowed.includes(name)) continue;
|
|
68
86
|
if (new RegExp(`\\b${name}\\b`).test(codeSrc)) {
|
|
69
87
|
offenses.push(`${path}: references SDK write op '${name}'`);
|
|
70
88
|
}
|
|
@@ -169,6 +187,25 @@ describe('src/unifi never references an SDK write operation (T12)', () => {
|
|
|
169
187
|
}
|
|
170
188
|
});
|
|
171
189
|
|
|
190
|
+
test('the allowlist is exactly network-update.ts -> updateNetwork, nothing else', () => {
|
|
191
|
+
expect(WRITE_OP_ALLOWLIST).toEqual({ 'network-update.ts': ['updateNetwork'] });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('network-update.ts may name updateNetwork but still not create/delete/patch ops', () => {
|
|
195
|
+
const path = join(DIR, 'network-update.ts');
|
|
196
|
+
const src = (op: string) => `import * as n from '${SDK_MARKER}/networks';\nn.${op}({});\n`;
|
|
197
|
+
expect(offensesIn(path, src('updateNetwork'))).toEqual([]);
|
|
198
|
+
for (const op of ['createNetwork', 'deleteNetwork', 'patchFirewallPolicy']) {
|
|
199
|
+
expect(offensesIn(path, src(op))).toEqual([`${path}: references SDK write op '${op}'`]);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('any other file naming updateNetwork is an offense', () => {
|
|
204
|
+
const path = join(DIR, 'network.ts');
|
|
205
|
+
const src = `import * as n from '${SDK_MARKER}/networks';\nn.updateNetwork({});\n`;
|
|
206
|
+
expect(offensesIn(path, src)).toEqual([`${path}: references SDK write op 'updateNetwork'`]);
|
|
207
|
+
});
|
|
208
|
+
|
|
172
209
|
test('the walk is recursive -- a future src/unifi/<subdir>/*.ts is not silently unscanned', () => {
|
|
173
210
|
const probeDir = join(DIR, '.write-op-reference-recursion-probe');
|
|
174
211
|
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
|
-
});
|