@homeflare/alchemy 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
  2. package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
  3. package/dist/kubernetes/index.d.ts +12 -0
  4. package/dist/kubernetes/index.d.ts.map +1 -0
  5. package/dist/kubernetes/index.js +585 -0
  6. package/dist/kubernetes/index.js.map +19 -0
  7. package/dist/kubernetes/ready-checks.d.ts +45 -0
  8. package/dist/kubernetes/ready-checks.d.ts.map +1 -0
  9. package/dist/kubernetes/ready-classify.d.ts +23 -0
  10. package/dist/kubernetes/ready-classify.d.ts.map +1 -0
  11. package/dist/kubernetes/ready-connect.d.ts +29 -0
  12. package/dist/kubernetes/ready-connect.d.ts.map +1 -0
  13. package/dist/kubernetes/ready-declare.d.ts +5 -0
  14. package/dist/kubernetes/ready-declare.d.ts.map +1 -0
  15. package/dist/kubernetes/ready-errors.d.ts +80 -0
  16. package/dist/kubernetes/ready-errors.d.ts.map +1 -0
  17. package/dist/kubernetes/ready-poll.d.ts +61 -0
  18. package/dist/kubernetes/ready-poll.d.ts.map +1 -0
  19. package/dist/kubernetes/ready.d.ts +74 -0
  20. package/dist/kubernetes/ready.d.ts.map +1 -0
  21. package/dist/talos/fake-apiserver.d.ts +11 -2
  22. package/dist/talos/fake-apiserver.d.ts.map +1 -1
  23. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  24. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  25. package/dist/unifi/acl-rule.d.ts +6 -1
  26. package/dist/unifi/acl-rule.d.ts.map +1 -1
  27. package/dist/unifi/dns-policy.d.ts +6 -1
  28. package/dist/unifi/dns-policy.d.ts.map +1 -1
  29. package/dist/unifi/fake-unifi.d.ts +4 -0
  30. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  31. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  32. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  33. package/dist/unifi/firewall-policy.d.ts +6 -1
  34. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  35. package/dist/unifi/firewall-zone.d.ts +6 -1
  36. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  37. package/dist/unifi/index.d.ts +5 -3
  38. package/dist/unifi/index.d.ts.map +1 -1
  39. package/dist/unifi/index.js +339 -86
  40. package/dist/unifi/index.js.map +10 -6
  41. package/dist/unifi/network-scope.d.ts +31 -0
  42. package/dist/unifi/network-scope.d.ts.map +1 -0
  43. package/dist/unifi/network-update.d.ts +44 -0
  44. package/dist/unifi/network-update.d.ts.map +1 -0
  45. package/dist/unifi/network.d.ts +32 -12
  46. package/dist/unifi/network.d.ts.map +1 -1
  47. package/dist/unifi/policy.d.ts +89 -1
  48. package/dist/unifi/policy.d.ts.map +1 -1
  49. package/dist/unifi/resource.d.ts +17 -43
  50. package/dist/unifi/resource.d.ts.map +1 -1
  51. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  52. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  53. package/dist/unifi/update-reconcile.d.ts +31 -0
  54. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  55. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  56. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  57. package/dist/unifi/wire-guard.d.ts +33 -0
  58. package/dist/unifi/wire-guard.d.ts.map +1 -0
  59. package/docs/kubernetes-ready.md +146 -0
  60. package/docs/peers.md +13 -0
  61. package/docs/provider-standard.md +7 -0
  62. package/docs/unifi-network-write.md +90 -0
  63. package/docs/unifi.md +36 -36
  64. package/package.json +7 -1
  65. package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
  66. package/src/kubernetes/helm-chart-bounded.ts +110 -0
  67. package/src/kubernetes/index.ts +28 -0
  68. package/src/kubernetes/ready-backoff.test.ts +124 -0
  69. package/src/kubernetes/ready-checks.test.ts +106 -0
  70. package/src/kubernetes/ready-checks.ts +165 -0
  71. package/src/kubernetes/ready-classify.ts +76 -0
  72. package/src/kubernetes/ready-connect.ts +52 -0
  73. package/src/kubernetes/ready-declare.test.ts +85 -0
  74. package/src/kubernetes/ready-declare.ts +50 -0
  75. package/src/kubernetes/ready-errors.ts +102 -0
  76. package/src/kubernetes/ready-first-install.test.ts +126 -0
  77. package/src/kubernetes/ready-poll-states.test.ts +92 -0
  78. package/src/kubernetes/ready-poll.test.ts +194 -0
  79. package/src/kubernetes/ready-poll.ts +215 -0
  80. package/src/kubernetes/ready-rollout.test.ts +58 -0
  81. package/src/kubernetes/ready-secrecy.test.ts +131 -0
  82. package/src/kubernetes/ready.fixtures.ts +103 -0
  83. package/src/kubernetes/ready.test.ts +205 -0
  84. package/src/kubernetes/ready.ts +188 -0
  85. package/src/talos/fake-apiserver.ts +21 -3
  86. package/src/unifi/fake-unifi.ts +15 -2
  87. package/src/unifi/handler-allow-news.test.ts +80 -0
  88. package/src/unifi/index.ts +21 -5
  89. package/src/unifi/network-scope.test.ts +203 -0
  90. package/src/unifi/network-scope.ts +75 -0
  91. package/src/unifi/network-update.test.ts +250 -0
  92. package/src/unifi/network-update.ts +95 -0
  93. package/src/unifi/network.test.ts +27 -1
  94. package/src/unifi/network.ts +56 -12
  95. package/src/unifi/policy.ts +128 -10
  96. package/src/unifi/resource.test.ts +15 -3
  97. package/src/unifi/resource.ts +64 -105
  98. package/src/unifi/update-reconcile.ts +126 -0
  99. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  100. package/src/unifi/wire-guard-round2.test.ts +120 -0
  101. package/src/unifi/wire-guard.test.ts +218 -0
  102. package/src/unifi/wire-guard.ts +192 -0
  103. package/src/unifi/write-op-reference.test.ts +38 -2
  104. package/src/unifi/get-only-guard.test.ts +0 -165
@@ -0,0 +1,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
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `Unifi.Network`'s one write: `updateNetwork`. THE ONLY FILE UNDER `src/unifi` ALLOWED TO NAME IT
3
+ * (`write-op-reference.test.ts`'s `WRITE_OP_ALLOWLIST` pins that, and still refuses
4
+ * `createNetwork`/`deleteNetwork` here).
5
+ *
6
+ * ⛔ THE WHOLE-OBJECT-PUT TRAP (packages/distilled-unifi-network/README.md). `PUT .../networks/{id}` disables
7
+ * `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration` outright when the body omits them. So
8
+ * the body is never `props`: it is the RAW live object (as `fetchLive` decoded it) minus the
9
+ * server fields (`SERVER_FIELDS`: `id`/`default`/`metadata`), plus ONLY the patch (HIGH-2). A
10
+ * hand edit on a field
11
+ * the declaration never touched rides through byte-for-byte; `update-reconcile.ts` has already
12
+ * refused any hand edit on a declared field.
13
+ *
14
+ * ★ WHY RAW LIVE, NOT THE ATTRIBUTES VIEW (LOW-6). `attributesOf` normalizes (sorted sets) and
15
+ * drops keys this kit does not model. Unknown keys must survive the round trip or the PUT would
16
+ * silently delete a setting the console added after the SDK was generated (F2). Measured by
17
+ * reading `@distilled.cloud/core` `protocol-http.ts`: `validateResponse` returns the ORIGINAL
18
+ * decoded value and `mapKeys` passes unknown keys through at every level, so top-level and
19
+ * nested unknown keys both survive; `network-update.test.ts` is the alarm if that ever changes.
20
+ *
21
+ * ★ WHY `unset` EXISTS (HIGH-1). Reverting a declaration (dropping `ipv6Configuration`) must plan
22
+ * and execute a real update back to the pre-image. A key in `patch.unset` is DELETED from the
23
+ * body, which is exactly "absent = off" for the optional blocks above.
24
+ *
25
+ * ⛔ NO-OP GUARD. If the merged body equals live (ignoring the ids), the PUT would change nothing;
26
+ * `UnifiUpdateWouldBeNoop` refuses so a normalizer gap can never become a re-PUT loop.
27
+ *
28
+ * ⛔ NO RETRY ON THE PUT (red team round 1, reversing the earlier Q7 decision). The SDK's default
29
+ * policy would replay a write whose first attempt may already have landed (a dropped response
30
+ * during a reprovision); a replay then runs against a controller state this reconcile never
31
+ * read. One attempt, and a failure surfaces for the operator. Reads keep the default policy.
32
+ *
33
+ * ⛔ SCOPE FIRST (`network-scope.ts`): the management LAN, immutable fields and non-removable keys
34
+ * are refused before the body is even built, so none of them can reach the wire.
35
+ */
36
+ import * as networks from '@distilled.cloud/unifi-network/networks';
37
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
38
+ import { deepEqual } from 'alchemy/Diff';
39
+ import * as Effect from 'effect/Effect';
40
+ import { type NetworkProps } from './network-form.ts';
41
+ import { checkNetworkWriteScope } from './network-scope.ts';
42
+ import { UnifiUpdateWouldBeNoop } from './policy.ts';
43
+ import type { Patch } from './update-reconcile.ts';
44
+
45
+ export const SERVER_FIELDS = ['id', 'default', 'metadata'] as const;
46
+
47
+ /**
48
+ * The live object minus `SERVER_FIELDS` — ONE list drives the strip. The destructure this
49
+ * replaced hard-coded the same three names a second time, so a key added to the export was
50
+ * silently NOT stripped (and a removed one still was): two lists that could drift apart. Own
51
+ * enumerable string keys only, exactly what object-rest destructure copies; a decoded JSON body
52
+ * has no symbol keys.
53
+ */
54
+ const withoutServerFields = (live: networks.NetworkDetails): Record<string, unknown> => {
55
+ const server = new Set<string>(SERVER_FIELDS);
56
+ const rest: Record<string, unknown> = {};
57
+ for (const [key, value] of Object.entries(live)) {
58
+ if (!server.has(key)) rest[key] = value;
59
+ }
60
+ return rest;
61
+ };
62
+
63
+ export const mergeForUpdate = (
64
+ live: networks.NetworkDetails,
65
+ patch: Patch<NetworkProps>,
66
+ props: NetworkProps,
67
+ ): Effect.Effect<networks.UpdateNetworkRequest, UnifiUpdateWouldBeNoop> => {
68
+ // Raw copy minus the server fields: unknown top-level keys stay in `rest` (F2).
69
+ const rest = withoutServerFields(live);
70
+ const body: Record<string, unknown> = { ...rest, ...patch.set };
71
+ for (const key of patch.unset) delete body[key];
72
+ if (deepEqual(body, rest, { stripNullish: true })) {
73
+ return Effect.fail(
74
+ new UnifiUpdateWouldBeNoop({
75
+ type: 'Unifi.Network',
76
+ identity: `sites/${props.siteId}/networks/${props.networkId}`,
77
+ }),
78
+ );
79
+ }
80
+ return Effect.succeed({
81
+ ...body,
82
+ siteId: props.siteId,
83
+ networkId: live.id,
84
+ } as networks.UpdateNetworkRequest);
85
+ };
86
+
87
+ export const writeNetwork = (
88
+ live: networks.NetworkDetails,
89
+ patch: Patch<NetworkProps>,
90
+ props: NetworkProps,
91
+ ) =>
92
+ checkNetworkWriteScope(live, patch, props).pipe(
93
+ Effect.andThen(mergeForUpdate(live, patch, props)),
94
+ Effect.flatMap((body) => networks.updateNetwork(body).pipe(Retry.none)),
95
+ );
@@ -90,7 +90,7 @@ describe('declareNetwork -- the declaration renderer', () => {
90
90
  });
91
91
  });
92
92
 
93
- describe('Unifi.Network write paths never reach the vendor API', () => {
93
+ describe('Unifi.Network: only the one allowed PUT ever reaches the vendor API', () => {
94
94
  const ops = unifiOperations(spec);
95
95
 
96
96
  test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
@@ -119,6 +119,32 @@ describe('Unifi.Network write paths never reach the vendor API', () => {
119
119
  expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
120
120
  });
121
121
 
122
+ test('a missing network still refuses create -- nothing is ever POSTed', async () => {
123
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
124
+ const failure = await Effect.runPromise(
125
+ Effect.flip(ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
126
+ );
127
+ expect(failure).toMatchObject({ _tag: 'UnifiWriteRefused', action: 'create' });
128
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
129
+ });
130
+
131
+ test('drift with olds undefined (first deploy) refuses update, all-GET', async () => {
132
+ const fake = fakeUnifi((method, url) =>
133
+ method === 'GET' && url.pathname === NETWORK_PATH
134
+ ? Response.json(liveNetwork({ vlanId: 999 }))
135
+ : fakeFailure(400, 'unexpected request'),
136
+ );
137
+ const failure = await Effect.runPromise(
138
+ Effect.flip(
139
+ ops
140
+ .reconcile(PROPS, { olds: undefined, output: undefined })
141
+ .pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
142
+ ),
143
+ );
144
+ expect(failure).toMatchObject({ _tag: 'UnifiWriteRefused', action: 'update' });
145
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
146
+ });
147
+
122
148
  test('delete always refuses -- no DELETE is ever sent', async () => {
123
149
  const fake = fakeUnifi((method, url) =>
124
150
  method === 'GET' && url.pathname === NETWORK_PATH