@homeflare/alchemy 0.56.0 → 0.58.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 +1 -1
- package/dist/unifi/acl-rule.d.ts +1 -1
- package/dist/unifi/dns-policy.d.ts +1 -1
- package/dist/unifi/firewall-policy-create.d.ts +55 -0
- package/dist/unifi/firewall-policy-create.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +3 -3
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-form.d.ts +27 -17
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +1 -1
- package/dist/unifi/firewall-policy.d.ts +45 -12
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/index.d.ts +1 -1
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +175 -28
- package/dist/unifi/index.js.map +11 -10
- package/dist/unifi/network-update.fixtures.d.ts +53 -0
- package/dist/unifi/network-update.fixtures.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +1 -1
- package/dist/unifi/policy.d.ts +22 -0
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +26 -7
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +1 -1
- package/dist/unifi/update-reconcile.d.ts +36 -2
- package/dist/unifi/update-reconcile.d.ts.map +1 -1
- package/dist/unifi/wifi-broadcast.d.ts +1 -1
- package/dist/unifi/wire-guard.d.ts +1 -1
- package/dist/unifi/wire-guard.d.ts.map +1 -1
- package/docs/distilled-interim.md +4 -2
- package/package.json +1 -1
- package/src/unifi/firewall-policy-create.ts +94 -0
- package/src/unifi/firewall-policy-drift.test.ts +64 -0
- package/src/unifi/firewall-policy-drift.ts +24 -7
- package/src/unifi/firewall-policy-form.ts +70 -17
- package/src/unifi/firewall-policy.test.ts +188 -1
- package/src/unifi/firewall-policy.ts +108 -12
- package/src/unifi/index.ts +1 -0
- package/src/unifi/network-update-accept.test.ts +59 -0
- package/src/unifi/network-update.fixtures.ts +74 -0
- package/src/unifi/network-update.test.ts +11 -69
- package/src/unifi/policy.ts +45 -12
- package/src/unifi/resource.test.ts +103 -0
- package/src/unifi/resource.ts +73 -21
- package/src/unifi/update-reconcile.ts +109 -5
- package/src/unifi/wire-guard.test.ts +86 -1
- package/src/unifi/wire-guard.ts +8 -1
- package/src/unifi/write-op-reference.test.ts +21 -7
|
@@ -15,17 +15,18 @@
|
|
|
15
15
|
* `deepEqual` (`stripNullish`) as ONE value, same proportion `acl-rule-form.ts`'s
|
|
16
16
|
* `enforcingDeviceFilter` gets (one level of structure, not exploded into scalars).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
|
|
22
|
-
* `FirewallPolicyApplicationFilter.applicationIds`, `
|
|
23
|
-
* `
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
18
|
+
* ★ SET-LIKE NESTED ARRAYS COMPARE AS SETS (closed 2026-10-19; was T15's accepted gap). The
|
|
19
|
+
* vendor's filter variants inside `source`/`destination`/`ipProtocolScope`/`schedule` carry
|
|
20
|
+
* arrays with no documented order: `FirewallPolicyIPAddressFilter.items`, the port filter's
|
|
21
|
+
* `items`, `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
|
|
22
|
+
* `FirewallPolicyApplicationFilter.applicationIds`, `FirewallPolicyRegionFilter.regions`,
|
|
23
|
+
* `FirewallPolicyVPNServerFilter.vpnServerIds`, `FirewallPolicyDomainFilter.domains`,
|
|
24
|
+
* `applicationCategoryIds` and `FirewallSchedule.repeatOnDays`. Measured:
|
|
25
|
+
* the controller (Network 11.0.83) stored a declared port list [443, 9100] as [9100, 443], so a
|
|
26
|
+
* correct create failed its read-back (`UnifiCreateDidNotConverge`, homeflare-network PR 79).
|
|
27
|
+
* `normalizeSetArrays` sorts every array under one of those keys (by canonical JSON) on BOTH
|
|
28
|
+
* sides before `deepEqual`. Membership still has to match exactly, and duplicates are kept, so a
|
|
29
|
+
* real change is never hidden.
|
|
29
30
|
*
|
|
30
31
|
* ⛔ `connectionStateFilter` IS THE ONE TOP-LEVEL PROPS ARRAY, AND IS A SET — "Match on firewall
|
|
31
32
|
* connection state. If null, matches all connection states" describes membership, not a sequence
|
|
@@ -54,26 +55,78 @@ export const normalizeConnectionStateFilter = (
|
|
|
54
55
|
value: readonly string[] | undefined,
|
|
55
56
|
): string[] | undefined => (value == null ? undefined : sortedSet(value));
|
|
56
57
|
|
|
58
|
+
/** Keys whose array value is a set in the vendor's firewall filter schemas (header). */
|
|
59
|
+
const SET_ARRAY_KEYS: ReadonlySet<string> = new Set([
|
|
60
|
+
'items',
|
|
61
|
+
'macAddresses',
|
|
62
|
+
'networkIds',
|
|
63
|
+
'applicationIds',
|
|
64
|
+
'regions',
|
|
65
|
+
'vpnServerIds',
|
|
66
|
+
'repeatOnDays',
|
|
67
|
+
'domains',
|
|
68
|
+
'applicationCategoryIds',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** Key-order-independent JSON: a hand-written `{start, stop, type}` and a decoded `{type, start,
|
|
72
|
+
* stop}` must sort to the same place (Opus red team, kit PR 466). */
|
|
73
|
+
const sortKeys = (value: unknown): unknown =>
|
|
74
|
+
Array.isArray(value)
|
|
75
|
+
? value.map(sortKeys)
|
|
76
|
+
: value !== null && typeof value === 'object'
|
|
77
|
+
? Object.fromEntries(
|
|
78
|
+
Object.keys(value as object)
|
|
79
|
+
.sort()
|
|
80
|
+
.map((k) => [k, sortKeys((value as Record<string, unknown>)[k])]),
|
|
81
|
+
)
|
|
82
|
+
: value;
|
|
83
|
+
const canonical = (value: unknown): string => JSON.stringify(sortKeys(normalizeSetArrays(value)));
|
|
84
|
+
|
|
85
|
+
/** A deep copy with every SET_ARRAY_KEYS array sorted by canonical JSON (duplicates kept). */
|
|
86
|
+
export const normalizeSetArrays = <T>(value: T): T => {
|
|
87
|
+
if (Array.isArray(value)) return value.map((v) => normalizeSetArrays(v)) as T;
|
|
88
|
+
if (value === null || typeof value !== 'object') return value;
|
|
89
|
+
const out: Record<string, unknown> = {};
|
|
90
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
91
|
+
out[key] =
|
|
92
|
+
SET_ARRAY_KEYS.has(key) && Array.isArray(inner)
|
|
93
|
+
? [...inner]
|
|
94
|
+
.map((v) => normalizeSetArrays(v))
|
|
95
|
+
.sort((x, y) =>
|
|
96
|
+
canonical(x) < canonical(y) ? -1 : canonical(x) > canonical(y) ? 1 : 0,
|
|
97
|
+
)
|
|
98
|
+
: normalizeSetArrays(inner);
|
|
99
|
+
}
|
|
100
|
+
return out as T;
|
|
101
|
+
};
|
|
102
|
+
|
|
57
103
|
/** ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` — `exactOptionalPropertyTypes`; see
|
|
58
|
-
* `network-form.ts`'s own header.
|
|
104
|
+
* `network-form.ts`'s own header.
|
|
105
|
+
*
|
|
106
|
+
* ⛔ `firewallPolicyId` IS OPTIONAL SINCE THE 2026-10-19 hf: CREATE LIFT. An adopt-only
|
|
107
|
+
* declaration (`declareFirewallPolicy`, every imported row) always carries the vendor id it was
|
|
108
|
+
* read from. A fresh `hf:` creatable declaration (`declareHfFirewallPolicy` below) does not have
|
|
109
|
+
* one yet — the vendor assigns it on `createFirewallPolicy`'s response — so `spec.fetchLive`
|
|
110
|
+
* (`firewall-policy.ts`) locates by `name` instead of `id` whenever this is absent (netbox's
|
|
111
|
+
* `locateOne` is the precedent for identity-by-natural-key over identity-by-vendor-id). */
|
|
59
112
|
export interface FirewallPolicyProps {
|
|
60
113
|
siteId: string;
|
|
61
|
-
firewallPolicyId
|
|
114
|
+
firewallPolicyId?: string | undefined;
|
|
62
115
|
action: firewall.FirewallPolicyAction;
|
|
63
116
|
/** Connection states this policy matches — compared as a SET, see the header. */
|
|
64
117
|
connectionStateFilter?: string[] | undefined;
|
|
65
118
|
description?: string | undefined;
|
|
66
|
-
/** Traffic destination — compound, compared wholesale; see the header
|
|
119
|
+
/** Traffic destination — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
67
120
|
destination: firewall.FirewallPolicyDestination;
|
|
68
121
|
enabled: boolean;
|
|
69
|
-
/** IP version + protocol scope — compound, compared wholesale; see the header
|
|
122
|
+
/** IP version + protocol scope — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
70
123
|
ipProtocolScope: firewall.FirewallPolicyIPProtocolScope;
|
|
71
124
|
ipsecFilter?: string | undefined;
|
|
72
125
|
loggingEnabled: boolean;
|
|
73
126
|
name: string;
|
|
74
|
-
/** Schedule this policy is active on — compound, compared wholesale; see the header
|
|
127
|
+
/** Schedule this policy is active on — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
75
128
|
schedule?: firewall.FirewallSchedule | undefined;
|
|
76
|
-
/** Traffic source — compound, compared wholesale; see the header
|
|
129
|
+
/** Traffic source — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
77
130
|
source: firewall.FirewallPolicySource;
|
|
78
131
|
}
|
|
79
132
|
|
|
@@ -23,7 +23,13 @@ import * as Effect from 'effect/Effect';
|
|
|
23
23
|
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
24
24
|
import { attributesOf } from './firewall-policy-form.ts';
|
|
25
25
|
import { driftOf, matches } from './firewall-policy-drift.ts';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
type FirewallPolicyProps,
|
|
28
|
+
declareFirewallPolicy,
|
|
29
|
+
hfFirewallPolicy,
|
|
30
|
+
spec,
|
|
31
|
+
} from './firewall-policy.ts';
|
|
32
|
+
import { UnifiCreateDidNotConverge, UnifiWriteRefused } from './policy.ts';
|
|
27
33
|
import { pageAll } from './paginate.ts';
|
|
28
34
|
import { unifiOperations } from './resource.ts';
|
|
29
35
|
|
|
@@ -152,6 +158,187 @@ describe('Unifi.FirewallPolicy write paths never reach the vendor API', () => {
|
|
|
152
158
|
});
|
|
153
159
|
});
|
|
154
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The hf: create lift (Tim 2026-10-19 ~16:20Z) -- the one write `Unifi.FirewallPolicy` may now
|
|
163
|
+
* make, and only for a declared `name` starting `hf: `. Mirrors the write-refusal tests above,
|
|
164
|
+
* plus the one path that now DOES reach the vendor API.
|
|
165
|
+
*/
|
|
166
|
+
const POLICIES_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/policies';
|
|
167
|
+
const NEW_ID = 'hf-new-1';
|
|
168
|
+
|
|
169
|
+
const emptyPage = () => Response.json({ count: 0, data: [], limit: 0, offset: 0, totalCount: 0 });
|
|
170
|
+
|
|
171
|
+
/** No `firewallPolicyId`: the shape `hfFirewallPolicy` declares before anything is created. */
|
|
172
|
+
const HF_PROPS: FirewallPolicyProps = {
|
|
173
|
+
siteId: 'site-1',
|
|
174
|
+
action: { type: 'ALLOW', allowReturnTraffic: true },
|
|
175
|
+
destination: { zoneId: 'zone-storage' },
|
|
176
|
+
enabled: true,
|
|
177
|
+
ipProtocolScope: { ipVersion: 'IPV4' },
|
|
178
|
+
loggingEnabled: true,
|
|
179
|
+
name: 'hf: CT100 to NAS https',
|
|
180
|
+
source: { zoneId: 'zone-internal' },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
describe('Unifi.FirewallPolicy hf: create', () => {
|
|
184
|
+
const ops = unifiOperations(spec);
|
|
185
|
+
|
|
186
|
+
test('an hf:-named create: one locate GET, one POST of only the declared fields, one read-back GET', async () => {
|
|
187
|
+
let createdLive: firewall.FirewallPolicy | undefined;
|
|
188
|
+
const fake = fakeUnifi((method, url) => {
|
|
189
|
+
if (url.pathname === POLICIES_PATH && method === 'GET') return emptyPage();
|
|
190
|
+
if (url.pathname === POLICIES_PATH && method === 'POST') {
|
|
191
|
+
createdLive = {
|
|
192
|
+
...HF_PROPS,
|
|
193
|
+
id: NEW_ID,
|
|
194
|
+
index: 0,
|
|
195
|
+
metadata: { origin: 'USER' },
|
|
196
|
+
} as firewall.FirewallPolicy;
|
|
197
|
+
return Response.json(createdLive);
|
|
198
|
+
}
|
|
199
|
+
if (url.pathname === `${POLICIES_PATH}/${NEW_ID}` && method === 'GET') {
|
|
200
|
+
return Response.json(createdLive);
|
|
201
|
+
}
|
|
202
|
+
return fakeFailure(400, 'unexpected request');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const attrs = await Effect.runPromise(
|
|
206
|
+
ops.reconcile(HF_PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
207
|
+
);
|
|
208
|
+
expect(attrs.firewallPolicyId).toBe(NEW_ID);
|
|
209
|
+
expect(attrs.name).toBe('hf: CT100 to NAS https');
|
|
210
|
+
expect(fake.seen.map((s) => `${s.method} ${s.path}`)).toEqual([
|
|
211
|
+
`GET ${POLICIES_PATH}?offset=0`,
|
|
212
|
+
`POST ${POLICIES_PATH}`,
|
|
213
|
+
`GET ${POLICIES_PATH}/${NEW_ID}`,
|
|
214
|
+
]);
|
|
215
|
+
// ⛔ ONLY the declared fields -- no firewallPolicyId (there was none), no stray key.
|
|
216
|
+
// `siteId` is a path label (`T.Http`'s `uri: "/v1/sites/{siteId}/..."`), not a body field.
|
|
217
|
+
expect(fake.seen[1]?.body).toEqual({
|
|
218
|
+
action: { type: 'ALLOW', allowReturnTraffic: true },
|
|
219
|
+
destination: { zoneId: 'zone-storage' },
|
|
220
|
+
enabled: true,
|
|
221
|
+
ipProtocolScope: { ipVersion: 'IPV4' },
|
|
222
|
+
loggingEnabled: true,
|
|
223
|
+
name: 'hf: CT100 to NAS https',
|
|
224
|
+
source: { zoneId: 'zone-internal' },
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('a non-hf: name refuses create, typed, sending the existence GET but no write', async () => {
|
|
229
|
+
// ★ The existence GET (does a row this name already matches exist?) runs for every name --
|
|
230
|
+
// the SAME GET a plain cold adoption needs (H6) -- and answers empty here. Only the WRITE
|
|
231
|
+
// is refused: `fake.seen.every(GET)` is this file's own standing idiom for that proof.
|
|
232
|
+
const fake = fakeUnifi((method, url) =>
|
|
233
|
+
method === 'GET' && url.pathname === POLICIES_PATH
|
|
234
|
+
? emptyPage()
|
|
235
|
+
: fakeFailure(400, 'unexpected request'),
|
|
236
|
+
);
|
|
237
|
+
const notHf: FirewallPolicyProps = { ...HF_PROPS, name: 'Not hf-named' };
|
|
238
|
+
const failure = await Effect.runPromise(
|
|
239
|
+
Effect.flip(ops.reconcile(notHf).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
240
|
+
);
|
|
241
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
242
|
+
expect((failure as UnifiWriteRefused).action).toBe('create');
|
|
243
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
244
|
+
expect(fake.seen.length).toBeGreaterThan(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('"hf:foo" with no space after the colon is NOT an hf: name -- refused, no write', async () => {
|
|
248
|
+
const fake = fakeUnifi((method, url) =>
|
|
249
|
+
method === 'GET' && url.pathname === POLICIES_PATH
|
|
250
|
+
? emptyPage()
|
|
251
|
+
: fakeFailure(400, 'unexpected request'),
|
|
252
|
+
);
|
|
253
|
+
const noSpace: FirewallPolicyProps = { ...HF_PROPS, name: 'hf:no-space' };
|
|
254
|
+
const failure = await Effect.runPromise(
|
|
255
|
+
Effect.flip(ops.reconcile(noSpace).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
256
|
+
);
|
|
257
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
258
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('a read-back mismatch after the POST fails typed (UnifiCreateDidNotConverge), not silently', async () => {
|
|
262
|
+
const fake = fakeUnifi((method, url) => {
|
|
263
|
+
if (url.pathname === POLICIES_PATH && method === 'GET') return emptyPage();
|
|
264
|
+
if (url.pathname === POLICIES_PATH && method === 'POST') {
|
|
265
|
+
return Response.json({ ...HF_PROPS, id: NEW_ID, index: 0, metadata: { origin: 'USER' } });
|
|
266
|
+
}
|
|
267
|
+
if (url.pathname === `${POLICIES_PATH}/${NEW_ID}` && method === 'GET') {
|
|
268
|
+
// The read-back disagrees with what was declared (e.g. the console normalized `enabled`).
|
|
269
|
+
return Response.json({
|
|
270
|
+
...HF_PROPS,
|
|
271
|
+
id: NEW_ID,
|
|
272
|
+
index: 0,
|
|
273
|
+
metadata: { origin: 'USER' },
|
|
274
|
+
enabled: false,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return fakeFailure(400, 'unexpected request');
|
|
278
|
+
});
|
|
279
|
+
const failure = await Effect.runPromise(
|
|
280
|
+
Effect.flip(ops.reconcile(HF_PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
281
|
+
);
|
|
282
|
+
expect(failure).toBeInstanceOf(UnifiCreateDidNotConverge);
|
|
283
|
+
expect((failure as UnifiCreateDidNotConverge).fields).toEqual(['enabled']);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('an hf:-named row with an id that 404s refuses -- never silently creates a duplicate (Opus red team)', async () => {
|
|
287
|
+
// ★ THE SCENARIO THE RED TEAM FOUND: an hf:-named row that ALREADY has Alchemy state's
|
|
288
|
+
// `firewallPolicyId` but whose live object is gone (state lost and rebuilt with no `olds`/
|
|
289
|
+
// `output`, a hand-deleted-and-recreated rule, a wrong id in a generated declaration) must
|
|
290
|
+
// still refuse "create" -- not fall through and POST a second copy just because the name
|
|
291
|
+
// happens to start `hf: ` (Tim's own hand-made console rules already do).
|
|
292
|
+
const hfWithId: FirewallPolicyProps = { ...HF_PROPS, firewallPolicyId: 'fp-gone' };
|
|
293
|
+
const fake = fakeUnifi((method, url) =>
|
|
294
|
+
method === 'GET' && url.pathname === `${POLICIES_PATH}/fp-gone`
|
|
295
|
+
? fakeFailure(404, 'not found')
|
|
296
|
+
: fakeFailure(400, 'unexpected request'),
|
|
297
|
+
);
|
|
298
|
+
const failure = await Effect.runPromise(
|
|
299
|
+
Effect.flip(ops.reconcile(hfWithId).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
300
|
+
);
|
|
301
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
302
|
+
expect((failure as UnifiWriteRefused).action).toBe('create');
|
|
303
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('hfFirewallPolicy throws immediately when props already carries a firewallPolicyId', () => {
|
|
307
|
+
const withId = { ...HF_PROPS, firewallPolicyId: 'fp-1' };
|
|
308
|
+
expect(() => hfFirewallPolicy('bad', withId)).toThrow(/already carries a firewallPolicyId/);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('update (drift) of an existing hf: row still refuses -- the lift is create only', async () => {
|
|
312
|
+
const hfWithId: FirewallPolicyProps = { ...HF_PROPS, firewallPolicyId: 'fp-hf-1' };
|
|
313
|
+
const fake = fakeUnifi((method, url) =>
|
|
314
|
+
method === 'GET' && url.pathname === `${POLICIES_PATH}/fp-hf-1`
|
|
315
|
+
? Response.json({
|
|
316
|
+
...HF_PROPS,
|
|
317
|
+
id: 'fp-hf-1',
|
|
318
|
+
index: 0,
|
|
319
|
+
metadata: { origin: 'USER' },
|
|
320
|
+
enabled: false,
|
|
321
|
+
})
|
|
322
|
+
: fakeFailure(400, 'unexpected request'),
|
|
323
|
+
);
|
|
324
|
+
const exit = await Effect.runPromiseExit(
|
|
325
|
+
ops.reconcile(hfWithId).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
326
|
+
);
|
|
327
|
+
expect(exit._tag).toBe('Failure');
|
|
328
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('delete of an hf: row still refuses -- no DELETE is ever sent', async () => {
|
|
332
|
+
const hfWithId: FirewallPolicyProps = { ...HF_PROPS, firewallPolicyId: 'fp-hf-1' };
|
|
333
|
+
const exit = await Effect.runPromiseExit(ops.destroy(hfWithId));
|
|
334
|
+
expect(exit._tag).toBe('Failure');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('hfFirewallPolicy throws immediately for a name that is not hf:-prefixed', () => {
|
|
338
|
+
expect(() => hfFirewallPolicy('bad', { ...HF_PROPS, name: 'Not hf-named' })).toThrow(/hf: /);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
155
342
|
describe("T9/B0b -- pageAll walks this family's own page shape (decode proof)", () => {
|
|
156
343
|
test('every row across several pages of getFirewallPolicies decodes, in order', async () => {
|
|
157
344
|
const rows = Array.from({ length: 7 }, (_, i) =>
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Unifi.FirewallPolicy` — one firewall policy on a UniFi Network site
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* `Unifi.FirewallPolicy` — one firewall policy on a UniFi Network site. READ/ADOPT-ONLY for every
|
|
3
|
+
* row (`policy.ts`), EXCEPT create for a row whose declared `name` starts `hf: `
|
|
4
|
+
* (`isHfPolicyName`, Tim 2026-10-19 ~16:20Z) — `update` and `delete` stay refused even for an
|
|
5
|
+
* `hf:` row. `Unifi.FirewallPolicyOrdering` (`firewall-policy-ordering.ts`) is the sibling
|
|
6
|
+
* resource for policy ORDER within a zone pair (T5) — one per `(sourceFirewallZoneId,
|
|
7
|
+
* destinationFirewallZoneId)`, still read-only; this lift does not touch it, so a created `hf:`
|
|
8
|
+
* row lands wherever the controller places a new policy, and placing it ahead of its zone pair's
|
|
9
|
+
* "Block All Traffic" (if the controller does not already do that) would need an ordering WRITE —
|
|
10
|
+
* a second, separate lift this PR does not make (see `docs/unifi-firewall-policy.md`).
|
|
7
11
|
*
|
|
8
12
|
* ★ T11 GATE, CHECKED 2026-09-26: the `Firewall` tag's 13 operations (5 already covered for
|
|
9
13
|
* `Unifi.FirewallZone` in `docs/unifi.md`, 8 new here for `FirewallPolicy` — `getFirewallPolicies`,
|
|
@@ -14,25 +18,47 @@
|
|
|
14
18
|
* that DID change elsewhere in the document. Corroborated independently by the A3 changeset's own
|
|
15
19
|
* broader 25-operation/139-schema re-check (`.changeset/unifi-network-a3-typed-discriminator-filters.md`).
|
|
16
20
|
* Full breakdown: `docs/unifi-firewall-policy.md`.
|
|
21
|
+
*
|
|
22
|
+
* ⛔ `fetchLive` LOCATES BY `name` WHEN `firewallPolicyId` IS ABSENT (an `hf:` creatable
|
|
23
|
+
* declaration never has the vendor id before its first successful create). A real site's policy
|
|
24
|
+
* count is in the hundreds (T9, 424 live policies), so this walks every page with `pageAll`
|
|
25
|
+
* rather than trusting any one page to hold the match — same reasoning netbox's `locateOne`
|
|
26
|
+
* gives for a server-side filter that cannot be trusted alone, except here there IS no
|
|
27
|
+
* server-side name filter at all, so every row is paged. More than one row sharing a name is a
|
|
28
|
+
* defect, not a reason to take the first: `policy.ts`'s naming convention is exactly `hf: <desc>`
|
|
29
|
+
* because a free-text description makes a collision astronomically unlikely, but if the console
|
|
30
|
+
* ever has one, this engine refuses to guess which row is "the" declared one.
|
|
17
31
|
*/
|
|
18
32
|
import { Resource } from 'alchemy';
|
|
19
33
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
20
34
|
import * as Provider from 'alchemy/Provider';
|
|
21
35
|
import * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
22
36
|
import * as Effect from 'effect/Effect';
|
|
37
|
+
import {
|
|
38
|
+
checkHfCreateScope,
|
|
39
|
+
firewallPolicyCreateAllowedWrite,
|
|
40
|
+
writeFirewallPolicyCreate,
|
|
41
|
+
} from './firewall-policy-create.ts';
|
|
42
|
+
import { driftOf } from './firewall-policy-drift.ts';
|
|
23
43
|
import {
|
|
24
44
|
type FirewallPolicyAttributes,
|
|
25
45
|
type FirewallPolicyProps,
|
|
26
46
|
attributesOf,
|
|
27
47
|
} from './firewall-policy-form.ts';
|
|
28
48
|
import { matches } from './firewall-policy-drift.ts';
|
|
49
|
+
import { type UnifiPaginationInconsistent, pageAll } from './paginate.ts';
|
|
29
50
|
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
30
51
|
import { unifiHandlers } from './resource.ts';
|
|
52
|
+
import type { UnifiWriteRefused } from './policy.ts';
|
|
31
53
|
|
|
32
54
|
export type { FirewallPolicyAttributes, FirewallPolicyProps } from './firewall-policy-form.ts';
|
|
33
55
|
export { declareFirewallPolicy } from './firewall-policy-form.ts';
|
|
34
56
|
export { driftOf } from './firewall-policy-drift.ts';
|
|
35
57
|
|
|
58
|
+
/** `checkHfCreateScope`'s typed refusal, union'd with the real create call's own error, same
|
|
59
|
+
* shape `network.ts`'s own `UpdateError` gives its write half. */
|
|
60
|
+
type CreateError = UnifiWriteRefused | firewall.CreateFirewallPolicyError;
|
|
61
|
+
|
|
36
62
|
export interface UnifiFirewallPolicy extends Resource<
|
|
37
63
|
'Unifi.FirewallPolicy',
|
|
38
64
|
FirewallPolicyProps,
|
|
@@ -45,26 +71,96 @@ export const UnifiFirewallPolicy = Resource<UnifiFirewallPolicy>('Unifi.Firewall
|
|
|
45
71
|
defaultRemovalPolicy: 'retain',
|
|
46
72
|
});
|
|
47
73
|
|
|
48
|
-
/** `firewallPolicy('block-iot-wan', props)` —
|
|
49
|
-
* `network.ts`. */
|
|
74
|
+
/** `firewallPolicy('block-iot-wan', props)` — for an IMPORTED, adopt-only row (always carries the
|
|
75
|
+
* vendor's `firewallPolicyId`). `adopt(true)` piped on by default (H5); see `network.ts`. */
|
|
50
76
|
export const firewallPolicy = (id: string, props: FirewallPolicyProps) =>
|
|
51
77
|
UnifiFirewallPolicy(id, props).pipe(adopt(true));
|
|
52
78
|
|
|
79
|
+
/**
|
|
80
|
+
* `hfFirewallPolicy('ct100-to-nas-https', props)` — for a NEW, kit-creatable row: `props` has no
|
|
81
|
+
* `firewallPolicyId` (the vendor assigns one), and `name` MUST start `hf: ` or this throws
|
|
82
|
+
* immediately, at declaration time, rather than waiting for `createReconcile`'s own typed refusal
|
|
83
|
+
* at deploy time. That typed refusal (`checkHfCreateScope`) is still the authoritative gate — this
|
|
84
|
+
* is a second, earlier check for the common case of a typo, not a replacement for it; a caller
|
|
85
|
+
* that builds `FirewallPolicyProps` by hand and calls `UnifiFirewallPolicy` directly still goes
|
|
86
|
+
* through `checkHfCreateScope` and is still refused the same way.
|
|
87
|
+
*
|
|
88
|
+
* ⛔ NO `adopt(true)` HERE. This is a create path, not an adoption: a cold read finding an
|
|
89
|
+
* existing row of this exact name is `Unowned`, same as any other family, and a plan that adopts
|
|
90
|
+
* one silently is not what "create" means for this lift.
|
|
91
|
+
*/
|
|
92
|
+
export const hfFirewallPolicy = (
|
|
93
|
+
id: string,
|
|
94
|
+
props: Omit<FirewallPolicyProps, 'firewallPolicyId'>,
|
|
95
|
+
) => {
|
|
96
|
+
// ⚠️ `Omit` only rejects an excess property on a FRESH object literal passed at the call site —
|
|
97
|
+
// a variable typed `FirewallPolicyProps` (which still has the field) passes the parameter type
|
|
98
|
+
// check silently (Opus red team, 2026-10-19, SECONDARY FINDING). Checked at runtime so this
|
|
99
|
+
// constructor cannot be the route past `checkHfCreateScope`'s own identity check either.
|
|
100
|
+
if (
|
|
101
|
+
'firewallPolicyId' in props &&
|
|
102
|
+
(props as { firewallPolicyId?: unknown }).firewallPolicyId !== undefined
|
|
103
|
+
) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`hfFirewallPolicy(${id}): props already carries a firewallPolicyId -- this constructor is ` +
|
|
106
|
+
'for a row that does not exist yet. A row with a vendor id is adopted or updated, never ' +
|
|
107
|
+
'created; use firewallPolicy() instead.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!props.name.startsWith('hf: ')) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`hfFirewallPolicy(${id}): name "${props.name}" does not start "hf: " -- only hf:-named ` +
|
|
113
|
+
'policies may be created by this kit (Tim 2026-10-19); rename the declaration.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return UnifiFirewallPolicy(id, props as FirewallPolicyProps);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** One page of `getFirewallPolicies`, walked end to end and narrowed to the sole row named
|
|
120
|
+
* `name` — the locate path `fetchLive` uses when `firewallPolicyId` is not yet known. */
|
|
121
|
+
const locateByName = (siteId: string, name: string) =>
|
|
122
|
+
pageAll(firewall.getFirewallPolicies, { siteId }).pipe(
|
|
123
|
+
Effect.map((rows) => {
|
|
124
|
+
const matching = rows.filter((row) => row.name === name);
|
|
125
|
+
if (matching.length > 1) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Unifi.FirewallPolicy: ${String(matching.length)} live policies are named "${name}" on ` +
|
|
128
|
+
`site ${siteId}. A name this engine locates by must be unique; rename one on the console.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return matching[0];
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
53
135
|
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
54
136
|
export const spec: UnifiSpec<
|
|
55
137
|
FirewallPolicyProps,
|
|
56
138
|
firewall.FirewallPolicy,
|
|
57
139
|
FirewallPolicyAttributes,
|
|
58
|
-
firewall.GetFirewallPolicyError
|
|
140
|
+
firewall.GetFirewallPolicyError | firewall.GetFirewallPoliciesError | UnifiPaginationInconsistent,
|
|
141
|
+
never,
|
|
142
|
+
CreateError
|
|
59
143
|
> = {
|
|
60
144
|
type: 'Unifi.FirewallPolicy',
|
|
61
|
-
describe: (props) =>
|
|
145
|
+
describe: (props) =>
|
|
146
|
+
props.firewallPolicyId === undefined
|
|
147
|
+
? `sites/${props.siteId}/firewall/policies/by-name/${props.name}`
|
|
148
|
+
: `sites/${props.siteId}/firewall/policies/${props.firewallPolicyId}`,
|
|
62
149
|
fetchLive: (props) =>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
150
|
+
props.firewallPolicyId === undefined
|
|
151
|
+
? locateByName(props.siteId, props.name)
|
|
152
|
+
: firewall
|
|
153
|
+
.getFirewallPolicy({ siteId: props.siteId, firewallPolicyId: props.firewallPolicyId })
|
|
154
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
66
155
|
attributes: attributesOf,
|
|
67
156
|
matches,
|
|
157
|
+
create: {
|
|
158
|
+
allowedWrite: firewallPolicyCreateAllowedWrite,
|
|
159
|
+
checkScope: checkHfCreateScope,
|
|
160
|
+
write: writeFirewallPolicyCreate,
|
|
161
|
+
identify: (props, written) => ({ ...props, firewallPolicyId: written.id }),
|
|
162
|
+
driftOf,
|
|
163
|
+
},
|
|
68
164
|
};
|
|
69
165
|
|
|
70
166
|
export const handlers = unifiHandlers(spec);
|
package/src/unifi/index.ts
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A live edit made since the last deploy that the declaration ALREADY matches (a re-import
|
|
3
|
+
* recorded it) is accepted, not refused: it is folded into the baseline, so it is neither a
|
|
4
|
+
* conflict nor part of the PUT patch. A live edit the declaration disagrees with is still
|
|
5
|
+
* refused (network-update.test.ts case 6). Measured case: homeflare-network VLAN 16,
|
|
6
|
+
* 2026-10-19 (mdnsForwardingEnabled edited on the console, re-imported, refused forever).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import type { NetworkProps } from './network.ts';
|
|
10
|
+
import {
|
|
11
|
+
BASE,
|
|
12
|
+
IPV6,
|
|
13
|
+
WITH_V6,
|
|
14
|
+
answering,
|
|
15
|
+
live,
|
|
16
|
+
putBody,
|
|
17
|
+
reconcile,
|
|
18
|
+
} from './network-update.fixtures.ts';
|
|
19
|
+
|
|
20
|
+
describe('Unifi.Network update: accepted live drift', () => {
|
|
21
|
+
test('a console edit the re-import recorded is accepted; the declared change still PUTs', async () => {
|
|
22
|
+
const liveBody = live({ mdnsForwardingEnabled: true });
|
|
23
|
+
const news = { ...WITH_V6, mdnsForwardingEnabled: true } as NetworkProps;
|
|
24
|
+
const route = answering(liveBody, () =>
|
|
25
|
+
Response.json({ ...liveBody, ipv6Configuration: IPV6 }),
|
|
26
|
+
);
|
|
27
|
+
const { fake, exit, notes } = reconcile(route, news, BASE);
|
|
28
|
+
expect((await exit)._tag).toBe('Success');
|
|
29
|
+
expect(fake.seen.map((s) => s.method)).toEqual(['GET', 'PUT']);
|
|
30
|
+
const body = putBody(fake.seen)[0] as Record<string, unknown>;
|
|
31
|
+
expect(body.ipv6Configuration).toEqual(IPV6);
|
|
32
|
+
expect(body.mdnsForwardingEnabled).toBe(true);
|
|
33
|
+
expect(notes.some((n) => n.includes('accepted: [mdnsForwardingEnabled]'))).toBe(true);
|
|
34
|
+
expect(notes.some((n) => /PUT changes \[ipv6Configuration\]/.test(n))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('accepted drift never widens the patch past the declared change', async () => {
|
|
38
|
+
const liveBody = live({ name: 'Renamed' });
|
|
39
|
+
const news = { ...WITH_V6, name: 'Renamed' } as NetworkProps;
|
|
40
|
+
const route = answering(liveBody, () =>
|
|
41
|
+
Response.json({ ...liveBody, ipv6Configuration: IPV6 }),
|
|
42
|
+
);
|
|
43
|
+
const { exit, notes } = reconcile(route, news, BASE);
|
|
44
|
+
expect((await exit)._tag).toBe('Success');
|
|
45
|
+
expect(notes.find((n) => n.includes('PUT changes'))).toContain(
|
|
46
|
+
'PUT changes [ipv6Configuration]',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('drift the declaration disagrees with stays refused, even beside an accepted field', async () => {
|
|
51
|
+
const liveBody = live({ mdnsForwardingEnabled: true, name: 'Renamed' });
|
|
52
|
+
const news = { ...WITH_V6, mdnsForwardingEnabled: true } as NetworkProps;
|
|
53
|
+
const { fake, exit } = reconcile(answering(liveBody), news, BASE);
|
|
54
|
+
const result = JSON.stringify(await exit);
|
|
55
|
+
expect(result).toContain('UnifiLiveDriftedSinceDeploy');
|
|
56
|
+
expect(result).toContain('"fields":["name"]');
|
|
57
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the `Unifi.Network` update tests (network-update.test.ts,
|
|
3
|
+
* network-update-accept.test.ts): the fake controller route, the base declaration and the
|
|
4
|
+
* reconcile driver. Extracted so neither test file passes the 250-line cap.
|
|
5
|
+
*/
|
|
6
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
9
|
+
import { type NetworkProps, spec } from './network.ts';
|
|
10
|
+
import { unifiOperations } from './resource.ts';
|
|
11
|
+
|
|
12
|
+
export const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
|
|
13
|
+
export const IPV6 = {
|
|
14
|
+
interfaceType: 'STATIC',
|
|
15
|
+
prefixLength: 64,
|
|
16
|
+
hostIpAddress: '2602:f660:0:16::254',
|
|
17
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
18
|
+
routerAdvertisement: { priority: 'HIGH' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const BASE: NetworkProps = {
|
|
22
|
+
siteId: 'site-1',
|
|
23
|
+
networkId: 'net-1',
|
|
24
|
+
name: 'Compute',
|
|
25
|
+
enabled: true,
|
|
26
|
+
management: 'ADVANCED',
|
|
27
|
+
vlanId: 16,
|
|
28
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
|
|
29
|
+
ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
|
|
30
|
+
};
|
|
31
|
+
export const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
|
|
32
|
+
|
|
33
|
+
/** Raw live object, with a key the SDK schema has never heard of. */
|
|
34
|
+
export const live = (extra: Record<string, unknown> = {}) => ({
|
|
35
|
+
id: 'net-1',
|
|
36
|
+
default: false,
|
|
37
|
+
metadata: { origin: 'USER' },
|
|
38
|
+
futureTopLevelKey: 'x',
|
|
39
|
+
...BASE,
|
|
40
|
+
siteId: undefined,
|
|
41
|
+
networkId: undefined,
|
|
42
|
+
...extra,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const reconcile = (
|
|
46
|
+
route: (method: string, url: URL) => Response,
|
|
47
|
+
news: NetworkProps,
|
|
48
|
+
olds: NetworkProps | undefined,
|
|
49
|
+
outputArg: object | null = {},
|
|
50
|
+
) => {
|
|
51
|
+
const output = outputArg ?? undefined;
|
|
52
|
+
const fake = fakeUnifi(route);
|
|
53
|
+
const notes: string[] = [];
|
|
54
|
+
const run = unifiOperations(spec)
|
|
55
|
+
.reconcile(news, {
|
|
56
|
+
olds,
|
|
57
|
+
output,
|
|
58
|
+
note: (m) => Effect.sync(() => void notes.push(m)),
|
|
59
|
+
})
|
|
60
|
+
.pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch)));
|
|
61
|
+
return { fake, notes, exit: Effect.runPromiseExit(run) };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const answering =
|
|
65
|
+
(liveBody: object, put: () => Response = () => Response.json(liveBody)) =>
|
|
66
|
+
(method: string, url: URL) =>
|
|
67
|
+
url.pathname === PATH
|
|
68
|
+
? method === 'GET'
|
|
69
|
+
? Response.json(liveBody)
|
|
70
|
+
: put()
|
|
71
|
+
: fakeFailure(400, 'unexpected');
|
|
72
|
+
|
|
73
|
+
export const putBody = (seen: ReadonlyArray<{ method: string; body?: unknown }>) =>
|
|
74
|
+
seen.filter((s) => s.method === 'PUT').map((s) => s.body as Record<string, unknown>);
|