@homeflare/alchemy 0.50.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.
- 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 +32 -12
- 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 +36 -36
- package/package.json +1 -1
- 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 +203 -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 +56 -12
- 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 +38 -2
- package/src/unifi/get-only-guard.test.ts +0 -165
|
@@ -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
|
+
});
|
|
@@ -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 = (
|
|
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');
|