@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.
Files changed (58) hide show
  1. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  2. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  3. package/dist/unifi/acl-rule.d.ts +6 -1
  4. package/dist/unifi/acl-rule.d.ts.map +1 -1
  5. package/dist/unifi/dns-policy.d.ts +6 -1
  6. package/dist/unifi/dns-policy.d.ts.map +1 -1
  7. package/dist/unifi/fake-unifi.d.ts +4 -0
  8. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  9. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  10. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  11. package/dist/unifi/firewall-policy.d.ts +6 -1
  12. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  13. package/dist/unifi/firewall-zone.d.ts +6 -1
  14. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  15. package/dist/unifi/index.d.ts +5 -3
  16. package/dist/unifi/index.d.ts.map +1 -1
  17. package/dist/unifi/index.js +339 -86
  18. package/dist/unifi/index.js.map +10 -6
  19. package/dist/unifi/network-scope.d.ts +31 -0
  20. package/dist/unifi/network-scope.d.ts.map +1 -0
  21. package/dist/unifi/network-update.d.ts +44 -0
  22. package/dist/unifi/network-update.d.ts.map +1 -0
  23. package/dist/unifi/network.d.ts +32 -12
  24. package/dist/unifi/network.d.ts.map +1 -1
  25. package/dist/unifi/policy.d.ts +89 -1
  26. package/dist/unifi/policy.d.ts.map +1 -1
  27. package/dist/unifi/resource.d.ts +17 -43
  28. package/dist/unifi/resource.d.ts.map +1 -1
  29. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  30. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  31. package/dist/unifi/update-reconcile.d.ts +31 -0
  32. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  33. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  34. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  35. package/dist/unifi/wire-guard.d.ts +33 -0
  36. package/dist/unifi/wire-guard.d.ts.map +1 -0
  37. package/docs/unifi-network-write.md +90 -0
  38. package/docs/unifi.md +36 -36
  39. package/package.json +1 -1
  40. package/src/unifi/fake-unifi.ts +15 -2
  41. package/src/unifi/handler-allow-news.test.ts +80 -0
  42. package/src/unifi/index.ts +21 -5
  43. package/src/unifi/network-scope.test.ts +203 -0
  44. package/src/unifi/network-scope.ts +75 -0
  45. package/src/unifi/network-update.test.ts +250 -0
  46. package/src/unifi/network-update.ts +95 -0
  47. package/src/unifi/network.test.ts +27 -1
  48. package/src/unifi/network.ts +56 -12
  49. package/src/unifi/policy.ts +128 -10
  50. package/src/unifi/resource.test.ts +15 -3
  51. package/src/unifi/resource.ts +64 -105
  52. package/src/unifi/update-reconcile.ts +126 -0
  53. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  54. package/src/unifi/wire-guard-round2.test.ts +120 -0
  55. package/src/unifi/wire-guard.test.ts +218 -0
  56. package/src/unifi/wire-guard.ts +192 -0
  57. package/src/unifi/write-op-reference.test.ts +38 -2
  58. package/src/unifi/get-only-guard.test.ts +0 -165
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `unifiHandlers.reconcile` builds the allowed PUT from `news`, never `olds` (red team round 1,
3
+ * mutation gap), and anchors it to the CONFIGURED base URL read from the credentials.
4
+ *
5
+ * ★ A probe spec whose `describe` is constant, so `updateReconcile`'s identity check passes and the
6
+ * ONLY thing separating `news.id` from `olds.id` is the wire guard's allow entry.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as ConfigProvider from 'effect/ConfigProvider';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Exit from 'effect/Exit';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as HttpClient from 'effect/http/HttpClient';
14
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
15
+ import { FAKE_BASE, FAKE_KEY, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
16
+ import { type UnifiSpec, unifiHandlers } from './resource.ts';
17
+
18
+ interface Probe {
19
+ id: string;
20
+ }
21
+ interface State {
22
+ written: boolean;
23
+ }
24
+
25
+ const probe: UnifiSpec<Probe, State, State, unknown, never> = {
26
+ type: 'Test.Probe',
27
+ describe: () => 'probe',
28
+ fetchLive: () =>
29
+ Effect.gen(function* () {
30
+ const client = yield* HttpClient.HttpClient;
31
+ const res = yield* client.execute(HttpClientRequest.get(`${FAKE_BASE}/probe`));
32
+ return (yield* res.json) as unknown as State;
33
+ }) as never,
34
+ attributes: (live) => live,
35
+ matches: (a) => a.written,
36
+ update: {
37
+ allowedWrite: (props) => ({ method: 'PUT', tail: `/probe/${props.id}` }),
38
+ // `id` differs between news and olds, so the patch is non-empty and the write really runs.
39
+ patchKeys: ['id'],
40
+ driftOf: () => [],
41
+ checkScope: () => Effect.void,
42
+ write: (_live, _patch, props) =>
43
+ Effect.gen(function* () {
44
+ const client = yield* HttpClient.HttpClient;
45
+ const res = yield* client.execute(HttpClientRequest.put(`${FAKE_BASE}/probe/${props.id}`));
46
+ return (yield* res.json) as unknown as State;
47
+ }) as never,
48
+ },
49
+ };
50
+
51
+ const config = ConfigProvider.layer(
52
+ ConfigProvider.fromUnknown({
53
+ UNIFI_NETWORK_API_BASE_URL: FAKE_BASE,
54
+ UNIFI_NETWORK_API_KEY: FAKE_KEY,
55
+ }),
56
+ );
57
+
58
+ const reconcile = (news: Probe, olds: Probe) => {
59
+ let written = false;
60
+ const fake = fakeUnifi((method) => {
61
+ if (method === 'PUT') written = true;
62
+ return Response.json({ written });
63
+ });
64
+ const exit = Effect.runPromiseExit(
65
+ unifiHandlers(probe)
66
+ .reconcile({ news, olds, output: { written: false }, session: { note: () => Effect.void } })
67
+ .pipe(Effect.provide(Layer.mergeAll(fakeUnifiLayer(fake.fetch), config))) as never,
68
+ );
69
+ return { fake, exit };
70
+ };
71
+
72
+ describe('unifiHandlers.reconcile allow entry', () => {
73
+ test('is built from news: a PUT to news.id passes even though olds.id differs', async () => {
74
+ const { fake, exit } = reconcile({ id: 'NEW' }, { id: 'OLD' });
75
+ expect(Exit.isSuccess(await exit)).toBe(true);
76
+ expect(fake.seen.filter((s) => s.method === 'PUT').map((s) => s.path)).toEqual([
77
+ '/proxy/network/integration/probe/NEW',
78
+ ]);
79
+ });
80
+ });
@@ -1,20 +1,36 @@
1
1
  /**
2
- * UniFi Network providers for Alchemy — READ-ONLY, by Tim's rule (2026-09-24; `policy.ts`).
2
+ * UniFi Network providers for Alchemy — ADOPT-ONLY, except `Unifi.Network` update (Tim's rule of
3
+ * 2026-09-24 lifted for that one case on 2026-10-10; `policy.ts`).
3
4
  *
4
5
  * ⛔ THIS BARREL IS THE PUBLIC API, SMALLER THAN THE DIRECTORY. `resource.ts`'s generic engine
5
6
  * and each `*-form.ts`'s wire helpers are internals a provider needs but a consumer should
6
7
  * not depend on — mirrors `../netbox/index.ts`.
7
8
  */
8
- export { UNIFI_READ_ONLY_POLICY, UnifiWriteRefused, type UnifiWriteAction } from './policy.ts';
9
+ export {
10
+ UNIFI_READ_ONLY_POLICY,
11
+ UNIFI_WRITE_POLICY,
12
+ UnifiFieldNotRemovable,
13
+ UnifiFieldNotSettable,
14
+ UnifiIdentityChanged,
15
+ UnifiImmutableFieldChanged,
16
+ UnifiLiveDriftedSinceDeploy,
17
+ UnifiManagementNetworkRefused,
18
+ UnifiUpdateDidNotConverge,
19
+ UnifiUpdateWouldBeNoop,
20
+ UnifiWriteRefused,
21
+ type UnifiWriteAction,
22
+ } from './policy.ts';
9
23
  export {
10
24
  // Barrel-exported (not just internal to `unifiHandlers`) so `homeflare-network`'s own
11
25
  // import-layer guard (plan item C0) can reuse this instead of re-implementing the same
12
26
  // `HttpClient` wrap against a second copy of the SDK's `HttpClient.HttpClient` service.
27
+ type AllowedWrite,
13
28
  GetOnlyHttpClient,
14
29
  UnifiNonGetRequest,
15
- type UnifiRequirements,
16
- type UnifiSpec,
17
- } from './resource.ts';
30
+ UnifiRefusedRequest,
31
+ guardedHttpClient,
32
+ } from './wire-guard.ts';
33
+ export { type UnifiRequirements, type UnifiSpec } from './resource.ts';
18
34
  export type { FieldDrift } from './drift.ts';
19
35
  export {
20
36
  UnifiNetwork,
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `Unifi.Network`'s write SCOPE (`network-scope.ts`), identity check, no-retry PUT and message
3
+ * wording — red team round 1 on PR 377. Drives `unifiOperations(spec).reconcile` against `fakeUnifi`,
4
+ * so the real SDK `updateNetwork` assembles the request and the fake records every one.
5
+ */
6
+ import { 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 { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
11
+ import { type NetworkProps, spec } from './network.ts';
12
+ import { UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
13
+ import { unifiOperations } from './resource.ts';
14
+
15
+ const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
16
+ const IPV6 = {
17
+ interfaceType: 'STATIC',
18
+ prefixLength: 64,
19
+ hostIpAddress: '2602:f660:0:16::254',
20
+ clientAddressAssignment: { slaacEnabled: true },
21
+ routerAdvertisement: { priority: 'HIGH' },
22
+ };
23
+ const BASE: NetworkProps = {
24
+ siteId: 'site-1',
25
+ networkId: 'net-1',
26
+ name: 'Compute',
27
+ enabled: true,
28
+ management: 'ADVANCED',
29
+ vlanId: 16,
30
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
31
+ ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
32
+ };
33
+ const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
34
+
35
+ const live = (extra: Record<string, unknown> = {}) => ({
36
+ id: 'net-1',
37
+ default: false,
38
+ metadata: { origin: 'USER' },
39
+ ...BASE,
40
+ siteId: undefined,
41
+ networkId: undefined,
42
+ ...extra,
43
+ });
44
+
45
+ const run = (
46
+ liveBody: object,
47
+ news: NetworkProps,
48
+ olds: NetworkProps,
49
+ put: () => Response = () => Response.json(liveBody),
50
+ ) => {
51
+ const fake = fakeUnifi((method, url) =>
52
+ url.pathname === PATH
53
+ ? method === 'GET'
54
+ ? Response.json(liveBody)
55
+ : put()
56
+ : fakeFailure(400, 'unexpected'),
57
+ );
58
+ const exit = Effect.runPromiseExit(
59
+ unifiOperations(spec)
60
+ .reconcile(news, { olds, output: {} })
61
+ .pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
62
+ );
63
+ return { fake, exit };
64
+ };
65
+
66
+ const tagOf = (exit: Exit.Exit<unknown, unknown>) => {
67
+ if (!Exit.isFailure(exit)) return 'Success';
68
+ const error = Cause.squash(exit.cause) as { _tag?: string; message?: string };
69
+ return error._tag ?? 'defect';
70
+ };
71
+ const puts = (fake: { seen: ReadonlyArray<{ method: string }> }) =>
72
+ fake.seen.filter((s) => s.method === 'PUT');
73
+
74
+ describe('removable-field allowlist (omission would turn the block off)', () => {
75
+ test.each([
76
+ ['ipv4Configuration', 'ipv4Configuration'],
77
+ ['dhcpGuarding', 'dhcpGuarding'],
78
+ ['a required key (name)', 'name'],
79
+ ])('dropping %s => UnifiFieldNotRemovable, zero PUTs', async (_n, key) => {
80
+ const { [key as 'name']: _gone, ...news } = { ...WITH_V6 } as NetworkProps;
81
+ const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news as NetworkProps, WITH_V6);
82
+ const result = await exit;
83
+ expect(tagOf(result)).toBe('UnifiFieldNotRemovable');
84
+ expect(JSON.stringify(result)).toContain(key);
85
+ expect(puts(fake)).toHaveLength(0);
86
+ });
87
+
88
+ // ⚠️ Mutation gap: an explicit `null` removes a key just like absence (`== null`, not `=== undefined`).
89
+ test('an explicit null on ipv6Configuration removes it from the body', async () => {
90
+ const news = { ...BASE, ipv6Configuration: null } as unknown as NetworkProps;
91
+ const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news, WITH_V6, () =>
92
+ Response.json(live()),
93
+ );
94
+ expect(tagOf(await exit)).toBe('Success');
95
+ const body = puts(fake)[0] as { body?: Record<string, unknown> };
96
+ expect(body.body).toBeDefined();
97
+ expect('ipv6Configuration' in (body.body as object)).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('settable-field allowlist (only ipv6Configuration may be written)', () => {
102
+ // ⛔ Round-2 BLOCKING finding: the PR claimed this scope but sent these PUTs. Each case is a real
103
+ // change to a non-ipv6 key, with live matching the prior state, so only the allowlist stops it.
104
+ test.each([
105
+ ['enabled', { enabled: false }, {}],
106
+ ['internetAccessEnabled', { internetAccessEnabled: false }, { internetAccessEnabled: true }],
107
+ [
108
+ 'ipv4Configuration',
109
+ { ipv4Configuration: { dhcpConfiguration: { mode: 'NONE' }, hostIpAddress: '10.9.9.1' } },
110
+ {},
111
+ ],
112
+ ['dhcpGuarding', { dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.9'] } }, {}],
113
+ ])('a patch that sets %s => UnifiFieldNotSettable, zero PUTs', async (field, change, olds) => {
114
+ const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
115
+ const { fake, exit } = run(
116
+ live({ ipv6Configuration: IPV6, ...olds }),
117
+ { ...oldProps, ...change } as NetworkProps,
118
+ oldProps,
119
+ );
120
+ const result = await exit;
121
+ expect(tagOf(result)).toBe('UnifiFieldNotSettable');
122
+ expect(JSON.stringify(result)).toContain(field);
123
+ expect(puts(fake)).toHaveLength(0);
124
+ });
125
+
126
+ test('setting ipv6Configuration is still allowed', async () => {
127
+ const { fake, exit } = run(live(), WITH_V6, BASE, () =>
128
+ Response.json(live({ ipv6Configuration: IPV6 })),
129
+ );
130
+ expect(tagOf(await exit)).toBe('Success');
131
+ expect(puts(fake)).toHaveLength(1);
132
+ });
133
+ });
134
+
135
+ describe('write scope', () => {
136
+ test('a live default (management) network is refused, zero PUTs', async () => {
137
+ const { fake, exit } = run(live({ default: true }), WITH_V6, BASE);
138
+ expect(tagOf(await exit)).toBe('UnifiManagementNetworkRefused');
139
+ expect(puts(fake)).toHaveLength(0);
140
+ });
141
+
142
+ test.each([
143
+ ['zoneId', { zoneId: 'zone-2' }, { zoneId: 'zone-1' }],
144
+ ['vlanId', { vlanId: 17 }, {}],
145
+ ['management', { management: 'GATEWAY' }, {}],
146
+ ['deviceId', { deviceId: 'dev-2' }, { deviceId: 'dev-1' }],
147
+ ])('a patch that changes %s is refused, zero PUTs', async (field, change, olds) => {
148
+ const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
149
+ const { fake, exit } = run(
150
+ live({ ipv6Configuration: IPV6, ...olds }),
151
+ { ...oldProps, ...change } as NetworkProps,
152
+ oldProps,
153
+ );
154
+ const result = await exit;
155
+ expect(tagOf(result)).toBe('UnifiImmutableFieldChanged');
156
+ expect(JSON.stringify(result)).toContain(field);
157
+ expect(puts(fake)).toHaveLength(0);
158
+ });
159
+
160
+ test('olds naming another network id => UnifiIdentityChanged, zero PUTs', async () => {
161
+ const { fake, exit } = run(live(), WITH_V6, { ...BASE, networkId: 'net-OLD' });
162
+ expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
163
+ expect(puts(fake)).toHaveLength(0);
164
+ });
165
+
166
+ test('olds naming another site id => UnifiIdentityChanged, zero PUTs', async () => {
167
+ const { fake, exit } = run(live(), WITH_V6, { ...BASE, siteId: 'site-OLD' });
168
+ expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
169
+ expect(puts(fake)).toHaveLength(0);
170
+ });
171
+ });
172
+
173
+ describe('the PUT is attempted once', () => {
174
+ test('a 503 on the PUT is not retried', async () => {
175
+ const { fake, exit } = run(live(), WITH_V6, BASE, () => fakeFailure(503, 'busy'));
176
+ expect((await exit)._tag).toBe('Failure');
177
+ expect(puts(fake)).toHaveLength(1);
178
+ });
179
+ });
180
+
181
+ describe('error wording', () => {
182
+ test('did-not-converge says the PUT landed and names re-import', () => {
183
+ const message = new UnifiUpdateDidNotConverge({
184
+ type: 'Unifi.Network',
185
+ identity: 'sites/s/networks/n',
186
+ fields: ['ipv6Configuration'],
187
+ }).message;
188
+ expect(message).toContain('LANDED');
189
+ expect(message).toContain('differs from the declaration');
190
+ expect(message).toContain('re-import');
191
+ expect(message).toContain('[ipv6Configuration]');
192
+ });
193
+
194
+ test('live-drifted names both causes, not only a hand edit', () => {
195
+ const message = new UnifiLiveDriftedSinceDeploy({
196
+ type: 'Unifi.Network',
197
+ identity: 'sites/s/networks/n',
198
+ fields: ['name'],
199
+ }).message;
200
+ expect(message).toContain('hand edit');
201
+ expect(message).toContain('UnifiUpdateDidNotConverge');
202
+ });
203
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The write SCOPE of `Unifi.Network`: the one intended consumer is a single VLAN's
3
+ * `ipv6Configuration`, so everything else a whole-object PUT could reach is refused BEFORE any
4
+ * request, with a typed error that carries field names only.
5
+ *
6
+ * ⛔ SETTABLE ALLOWLIST. A key in `patch.set` is written into the PUT body, so only
7
+ * `ipv6Configuration` may be set: `enabled`, `internetAccessEnabled`, `ipv4Configuration`,
8
+ * `dhcpGuarding` and every other declared key are refused with `UnifiFieldNotSettable` (a touched
9
+ * immutable key gets `UnifiImmutableFieldChanged` first). Widening it is a kit change.
10
+ * ⛔ REMOVABLE ALLOWLIST. A key in `patch.unset` is deleted from the PUT body, and the controller
11
+ * reads an omitted `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration` as "off". Dropping a
12
+ * declaration key therefore must not silently turn a live block off: only `ipv6Configuration`
13
+ * (the block this write path exists for, and the revert target) may be removed. A required key
14
+ * (`name`, `vlanId`, …) removed from a declaration would fail the SDK schema or send a body the
15
+ * controller rejects, so it is refused here with the same error.
16
+ * ⛔ `default: true` IS THE MANAGEMENT LAN. Writing it can cut the admin off the console.
17
+ * ⛔ `zoneId`/`vlanId`/`management`/`deviceId` decide where the network lives and what serves it;
18
+ * a patch (set OR unset) touching one is refused. Non-USER `metadata.origin` is deliberately NOT
19
+ * handled yet: no rule exists for it.
20
+ */
21
+ import type * as networks from '@distilled.cloud/unifi-network/networks';
22
+ import * as Effect from 'effect/Effect';
23
+ import type { NetworkProps } from './network-form.ts';
24
+ import {
25
+ UnifiFieldNotRemovable,
26
+ UnifiFieldNotSettable,
27
+ UnifiImmutableFieldChanged,
28
+ UnifiManagementNetworkRefused,
29
+ } from './policy.ts';
30
+ import type { Patch } from './update-reconcile.ts';
31
+
32
+ export const SETTABLE_KEYS: ReadonlyArray<keyof NetworkProps> = ['ipv6Configuration'];
33
+ export const REMOVABLE_KEYS: ReadonlyArray<keyof NetworkProps> = ['ipv6Configuration'];
34
+ export const IMMUTABLE_KEYS: ReadonlyArray<keyof NetworkProps> = [
35
+ 'zoneId',
36
+ 'vlanId',
37
+ 'management',
38
+ 'deviceId',
39
+ ];
40
+
41
+ export type ScopeError =
42
+ | UnifiFieldNotRemovable
43
+ | UnifiFieldNotSettable
44
+ | UnifiImmutableFieldChanged
45
+ | UnifiManagementNetworkRefused;
46
+
47
+ const isIn = (list: ReadonlyArray<string>) => (key: string) => list.includes(key);
48
+
49
+ export const checkNetworkWriteScope = (
50
+ live: networks.NetworkDetails,
51
+ patch: Patch<NetworkProps>,
52
+ props: NetworkProps,
53
+ ): Effect.Effect<void, ScopeError> => {
54
+ const type = 'Unifi.Network';
55
+ const identity = `sites/${props.siteId}/networks/${props.networkId}`;
56
+ if (live.default === true) {
57
+ return Effect.fail(new UnifiManagementNetworkRefused({ type, identity }));
58
+ }
59
+ const touched = [...Object.keys(patch.set), ...patch.unset.map(String)];
60
+ const immutable = touched.filter(isIn(IMMUTABLE_KEYS as ReadonlyArray<string>));
61
+ if (immutable.length > 0) {
62
+ return Effect.fail(new UnifiImmutableFieldChanged({ type, identity, fields: immutable }));
63
+ }
64
+ const removable = isIn(REMOVABLE_KEYS as ReadonlyArray<string>);
65
+ const notRemovable = patch.unset.map(String).filter((k) => !removable(k));
66
+ if (notRemovable.length > 0) {
67
+ return Effect.fail(new UnifiFieldNotRemovable({ type, identity, fields: notRemovable }));
68
+ }
69
+ const settable = isIn(SETTABLE_KEYS as ReadonlyArray<string>);
70
+ const notSettable = Object.keys(patch.set).filter((k) => !settable(k));
71
+ if (notSettable.length > 0) {
72
+ return Effect.fail(new UnifiFieldNotSettable({ type, identity, fields: notSettable }));
73
+ }
74
+ return Effect.void;
75
+ };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `Unifi.Network`'s three-way update against `fakeUnifi`, driving `unifiOperations(spec).reconcile`
3
+ * with `olds`/`output` set. The PUT goes through the real SDK `updateNetwork`, so path assembly,
4
+ * JSON encode and status→error matching are the production ones.
5
+ *
6
+ * ⚠️ These call `unifiOperations` directly, which bypasses the wire guard (see `wire-guard.test.ts`
7
+ * for that); here the question is WHICH BODY is sent and WHEN. The fake records every request.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import type * as networks from '@distilled.cloud/unifi-network/networks';
11
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
12
+ import * as Effect from 'effect/Effect';
13
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
14
+ import { NETWORK_PATCH_KEYS, type NetworkProps, spec } from './network.ts';
15
+ import { SERVER_FIELDS } from './network-update.ts';
16
+ import {
17
+ UnifiLiveDriftedSinceDeploy,
18
+ UnifiUpdateDidNotConverge,
19
+ UnifiWriteRefused,
20
+ } from './policy.ts';
21
+ import { unifiOperations } from './resource.ts';
22
+
23
+ const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
24
+ const IPV6 = {
25
+ interfaceType: 'STATIC',
26
+ prefixLength: 64,
27
+ hostIpAddress: '2602:f660:0:16::254',
28
+ clientAddressAssignment: { slaacEnabled: true },
29
+ routerAdvertisement: { priority: 'HIGH' },
30
+ };
31
+
32
+ const BASE: NetworkProps = {
33
+ siteId: 'site-1',
34
+ networkId: 'net-1',
35
+ name: 'Compute',
36
+ enabled: true,
37
+ management: 'ADVANCED',
38
+ vlanId: 16,
39
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
40
+ ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
41
+ };
42
+ const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
43
+
44
+ /** Raw live object, with a key the SDK schema has never heard of. */
45
+ const live = (extra: Record<string, unknown> = {}) => ({
46
+ id: 'net-1',
47
+ default: false,
48
+ metadata: { origin: 'USER' },
49
+ futureTopLevelKey: 'x',
50
+ ...BASE,
51
+ siteId: undefined,
52
+ networkId: undefined,
53
+ ...extra,
54
+ });
55
+
56
+ const reconcile = (
57
+ route: (method: string, url: URL) => Response,
58
+ news: NetworkProps,
59
+ olds: NetworkProps | undefined,
60
+ outputArg: object | null = {},
61
+ ) => {
62
+ const output = outputArg ?? undefined;
63
+ const fake = fakeUnifi(route);
64
+ const notes: string[] = [];
65
+ const run = unifiOperations(spec)
66
+ .reconcile(news, {
67
+ olds,
68
+ output,
69
+ note: (m) => Effect.sync(() => void notes.push(m)),
70
+ })
71
+ .pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch)));
72
+ return { fake, notes, exit: Effect.runPromiseExit(run) };
73
+ };
74
+
75
+ const answering =
76
+ (liveBody: object, put: () => Response = () => Response.json(liveBody)) =>
77
+ (method: string, url: URL) =>
78
+ url.pathname === PATH
79
+ ? method === 'GET'
80
+ ? Response.json(liveBody)
81
+ : put()
82
+ : fakeFailure(400, 'unexpected');
83
+
84
+ const putBody = (seen: ReadonlyArray<{ method: string; body?: unknown }>) =>
85
+ seen.filter((s) => s.method === 'PUT').map((s) => s.body as Record<string, unknown>);
86
+
87
+ describe('Unifi.Network update', () => {
88
+ test('(1) one GET then one PUT: live carried through byte-equal, server fields dropped, patch added', async () => {
89
+ const liveBody = live();
90
+ const after = { ...liveBody, ipv6Configuration: IPV6 };
91
+ const route = answering(liveBody, () => Response.json(after));
92
+ const { fake, exit, notes } = reconcile(route, WITH_V6, BASE);
93
+ expect((await exit)._tag).toBe('Success');
94
+ expect(fake.seen.map((s) => s.method)).toEqual(['GET', 'PUT']);
95
+ const body = putBody(fake.seen)[0] as Record<string, unknown>;
96
+ for (const k of ['id', 'default', 'metadata']) expect(k in body).toBe(false);
97
+ expect(body.futureTopLevelKey).toBe('x');
98
+ expect(body.dhcpGuarding).toEqual(liveBody.dhcpGuarding);
99
+ expect(body.ipv4Configuration).toEqual(liveBody.ipv4Configuration);
100
+ expect(body.ipv6Configuration).toEqual(IPV6);
101
+ // (8) one note, field names only
102
+ expect(notes).toHaveLength(1);
103
+ expect(notes[0]).toContain('changes [ipv6Configuration]');
104
+ expect(notes[0]).not.toContain('2602');
105
+ });
106
+
107
+ test('(2) an unknown NESTED key in live ipv6Configuration survives into the body (F2 alarm)', async () => {
108
+ const nested = { ...IPV6, futureNestedKey: 'keep' };
109
+ const liveBody = live({ ipv6Configuration: nested });
110
+ // olds mirrors live (as an import of the live object would), so there is no drift; news
111
+ // changes only `ipv6Configuration` (the one settable key) and keeps the unknown nested key.
112
+ const olds = { ...BASE, ipv6Configuration: nested } as NetworkProps;
113
+ const news = {
114
+ ...olds,
115
+ ipv6Configuration: { ...nested, routerAdvertisement: { priority: 'LOW' } },
116
+ } as NetworkProps;
117
+ const { fake, exit } = reconcile(answering(liveBody), news, olds);
118
+ await exit;
119
+ const body = putBody(fake.seen)[0] as {
120
+ ipv6Configuration: Record<string, unknown>;
121
+ futureTopLevelKey: string;
122
+ };
123
+ expect(body.ipv6Configuration.futureNestedKey).toBe('keep');
124
+ expect(body.ipv6Configuration.routerAdvertisement).toEqual({ priority: 'LOW' });
125
+ expect(body.futureTopLevelKey).toBe('x');
126
+ });
127
+
128
+ test('(3) revert: olds has ipv6Configuration, news omits it => PUT body has no such key', async () => {
129
+ const liveBody = live({ ipv6Configuration: IPV6 });
130
+ const { fake, exit } = reconcile(
131
+ answering(liveBody, () => Response.json(live())),
132
+ BASE,
133
+ WITH_V6,
134
+ );
135
+ expect((await exit)._tag).toBe('Success');
136
+ const bodies = putBody(fake.seen);
137
+ expect(bodies).toHaveLength(1);
138
+ expect('ipv6Configuration' in (bodies[0] as object)).toBe(false);
139
+ });
140
+
141
+ test.each([401, 403])('(4) PUT %d => typed failure, exactly one PUT', async (status) => {
142
+ const { fake, exit } = reconcile(
143
+ answering(live(), () => fakeFailure(status, 'no')),
144
+ WITH_V6,
145
+ BASE,
146
+ );
147
+ const result = await exit;
148
+ expect(result._tag).toBe('Failure');
149
+ expect(fake.seen.map((s) => s.method)).toEqual(['GET', 'PUT']);
150
+ });
151
+
152
+ test('(5) PUT 200 whose body still mismatches => UnifiUpdateDidNotConverge', async () => {
153
+ const { fake, exit } = reconcile(answering(live()), WITH_V6, BASE);
154
+ const result = await exit;
155
+ expect(JSON.stringify(result)).toContain('UnifiUpdateDidNotConverge');
156
+ expect(fake.seen.filter((s) => s.method === 'PUT')).toHaveLength(1);
157
+ expect(UnifiUpdateDidNotConverge.name).toBe('UnifiUpdateDidNotConverge');
158
+ });
159
+
160
+ test('(6) live drifted from olds on name => UnifiLiveDriftedSinceDeploy[name], zero PUTs', async () => {
161
+ const { fake, exit } = reconcile(answering(live({ name: 'Renamed' })), WITH_V6, BASE);
162
+ const result = await exit;
163
+ expect(JSON.stringify(result)).toContain('UnifiLiveDriftedSinceDeploy');
164
+ expect(JSON.stringify(result)).toContain('"fields":["name"]');
165
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
166
+ expect(UnifiLiveDriftedSinceDeploy.name).toBe('UnifiLiveDriftedSinceDeploy');
167
+ });
168
+
169
+ test.each([
170
+ ['output undefined', BASE, null],
171
+ ['olds undefined', undefined, {}],
172
+ ])('(7) %s => refuses update, all-GET', async (_n, olds, output) => {
173
+ const { fake, exit } = reconcile(answering(live()), WITH_V6, olds, output);
174
+ const result = await exit;
175
+ expect(JSON.stringify(result)).toContain('UnifiWriteRefused');
176
+ expect(JSON.stringify(result)).toContain('"action":"update"');
177
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
178
+ expect(UnifiWriteRefused.name).toBe('UnifiWriteRefused');
179
+ });
180
+
181
+ test('declared change equal to olds (pure drift driftOf missed is impossible) writes nothing', async () => {
182
+ // news == olds but live differs only in a field driftOf compares: that is drift, refused.
183
+ const { fake, exit } = reconcile(answering(live({ vlanId: 99 })), BASE, BASE);
184
+ expect((await exit)._tag).toBe('Failure');
185
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
186
+ });
187
+
188
+ test('an exact match writes nothing even with olds/output set (H6)', async () => {
189
+ const { fake, exit } = reconcile(answering(live()), BASE, BASE);
190
+ expect((await exit)._tag).toBe('Success');
191
+ expect(fake.seen.map((s) => s.method)).toEqual(['GET']);
192
+ });
193
+ });
194
+
195
+ describe('SERVER_FIELDS: one list drives the strip', () => {
196
+ // ⛔ PINNED, like NETWORK_PATCH_KEYS above: the values are load-bearing (id travels as the path
197
+ // param instead; default/metadata are controller-owned), so the list cannot be quietly
198
+ // emptied or widened. The body test below proves the list is actually USED on the wire.
199
+ test('is exactly the three controller-owned keys', () => {
200
+ expect([...SERVER_FIELDS]).toEqual(['id', 'default', 'metadata']);
201
+ });
202
+
203
+ test('the PUT body omits exactly those keys from live — every one of them, no other live key', async () => {
204
+ const liveBody = live();
205
+ // Only `ipv6Configuration` may be set (network-scope.ts SETTABLE_KEYS), so the change is that.
206
+ const news = WITH_V6;
207
+ const { fake, exit } = reconcile(
208
+ answering(liveBody, () => Response.json({ ...liveBody, ipv6Configuration: IPV6 })),
209
+ news,
210
+ BASE,
211
+ );
212
+ expect((await exit)._tag).toBe('Success');
213
+ const body = putBody(fake.seen)[0] as Record<string, unknown>;
214
+ const server: ReadonlyArray<string> = SERVER_FIELDS;
215
+ // Exactly, both ways: every SERVER_FIELDS key is gone from the body ...
216
+ expect(Object.keys(body).filter((k) => server.includes(k))).toEqual([]);
217
+ // ... and no other live key was stripped on the way. The two identity ids are the one
218
+ // legitimate extra omission: they travel as the PUT's PATH params — `answering` serves only
219
+ // PATH, which embeds them — never as body fields.
220
+ const ids = ['siteId', 'networkId'];
221
+ expect(
222
+ Object.keys(liveBody).filter((k) => !server.includes(k) && !ids.includes(k) && !(k in body)),
223
+ ).toEqual([]);
224
+ });
225
+ });
226
+
227
+ describe('NETWORK_PATCH_KEYS', () => {
228
+ test('equals every NetworkProps key except the two ids', () => {
229
+ const full: Required<NetworkProps> = {
230
+ siteId: 's',
231
+ networkId: 'n',
232
+ name: 'x',
233
+ enabled: true,
234
+ management: 'm',
235
+ vlanId: 2,
236
+ dhcpGuarding: { trustedDhcpServerIpAddresses: [] },
237
+ cellularBackupEnabled: true,
238
+ internetAccessEnabled: true,
239
+ ipv4Configuration: {},
240
+ ipv6Configuration: {} as networks.NetworkIPv6Configuration,
241
+ isolationEnabled: true,
242
+ mdnsForwardingEnabled: true,
243
+ zoneId: 'z',
244
+ deviceId: 'd',
245
+ };
246
+ const ids = ['siteId', 'networkId'];
247
+ const expected = Object.keys(full).filter((k) => !ids.includes(k));
248
+ expect([...NETWORK_PATCH_KEYS].map(String).sort()).toEqual(expected.sort());
249
+ });
250
+ });