@homeflare/alchemy 0.38.0 → 0.39.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 (165) hide show
  1. package/README.md +22 -0
  2. package/dist/forgejo/index.js +4 -2
  3. package/dist/forgejo/index.js.map +3 -3
  4. package/dist/forgejo/team-member.d.ts +25 -0
  5. package/dist/forgejo/team-member.d.ts.map +1 -1
  6. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  7. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  8. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  9. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  10. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  11. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  12. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  13. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  14. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  15. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  16. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  17. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  18. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  19. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  20. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  21. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  22. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  23. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  24. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  25. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  26. package/dist/proxmox/index.d.ts +14 -3
  27. package/dist/proxmox/index.d.ts.map +1 -1
  28. package/dist/proxmox/index.js +565 -133
  29. package/dist/proxmox/index.js.map +33 -23
  30. package/dist/proxmox/qemu-form.d.ts +27 -18
  31. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  32. package/dist/proxmox/qemu-judge.d.ts +30 -0
  33. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  34. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  35. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  36. package/dist/proxmox/qemu-net.d.ts +9 -0
  37. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  38. package/dist/proxmox/qemu-props.d.ts +112 -0
  39. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  40. package/dist/proxmox/qemu-volume.d.ts +17 -0
  41. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  42. package/dist/proxmox/qemu.d.ts +14 -27
  43. package/dist/proxmox/qemu.d.ts.map +1 -1
  44. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  45. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  46. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  47. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  48. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  49. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  50. package/dist/proxmox/storage-download-props.d.ts +36 -0
  51. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  52. package/dist/proxmox/storage-download-task.d.ts +17 -0
  53. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  54. package/dist/proxmox/storage-download.d.ts +44 -0
  55. package/dist/proxmox/storage-download.d.ts.map +1 -0
  56. package/dist/telemetry/fake-collector.d.ts +36 -0
  57. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  58. package/dist/telemetry/index.d.ts +11 -0
  59. package/dist/telemetry/index.d.ts.map +1 -0
  60. package/dist/telemetry/index.js +223 -0
  61. package/dist/telemetry/index.js.map +12 -0
  62. package/dist/telemetry/layer.d.ts +86 -0
  63. package/dist/telemetry/layer.d.ts.map +1 -0
  64. package/dist/telemetry/redact-serialization.d.ts +21 -0
  65. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  66. package/dist/telemetry/redact.d.ts +46 -0
  67. package/dist/telemetry/redact.d.ts.map +1 -0
  68. package/dist/unifi/drift.d.ts +27 -0
  69. package/dist/unifi/drift.d.ts.map +1 -0
  70. package/dist/unifi/fake-unifi.d.ts +5 -1
  71. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  72. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  73. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  74. package/dist/unifi/firewall-zone.d.ts +1 -1
  75. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  76. package/dist/unifi/index.d.ts +4 -3
  77. package/dist/unifi/index.d.ts.map +1 -1
  78. package/dist/unifi/index.js +138 -58
  79. package/dist/unifi/index.js.map +10 -8
  80. package/dist/unifi/network-drift.d.ts +26 -0
  81. package/dist/unifi/network-drift.d.ts.map +1 -0
  82. package/dist/unifi/network-form.d.ts +40 -7
  83. package/dist/unifi/network-form.d.ts.map +1 -1
  84. package/dist/unifi/network.d.ts +1 -0
  85. package/dist/unifi/network.d.ts.map +1 -1
  86. package/dist/unifi/paginate.d.ts +36 -0
  87. package/dist/unifi/paginate.d.ts.map +1 -0
  88. package/dist/unifi/resource.d.ts +36 -1
  89. package/dist/unifi/resource.d.ts.map +1 -1
  90. package/docs/telemetry-spike.md +94 -0
  91. package/docs/telemetry.md +109 -0
  92. package/docs/unifi.md +56 -0
  93. package/package.json +6 -2
  94. package/src/forgejo/team-member.test.ts +14 -0
  95. package/src/forgejo/team-member.ts +28 -1
  96. package/src/proxmox/constraints-required.test.ts +1 -0
  97. package/src/proxmox/generated/constraints/index.ts +3 -1
  98. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/index.test.ts +11 -6
  116. package/src/proxmox/index.ts +19 -3
  117. package/src/proxmox/qemu-form.ts +49 -13
  118. package/src/proxmox/qemu-identity.test.ts +30 -0
  119. package/src/proxmox/qemu-judge.test.ts +65 -0
  120. package/src/proxmox/qemu-judge.ts +84 -0
  121. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  122. package/src/proxmox/qemu-lifecycle.ts +131 -32
  123. package/src/proxmox/qemu-net.test.ts +48 -0
  124. package/src/proxmox/qemu-net.ts +78 -0
  125. package/src/proxmox/qemu-ownership.test.ts +44 -0
  126. package/src/proxmox/qemu-props.test.ts +87 -0
  127. package/src/proxmox/qemu-props.ts +157 -0
  128. package/src/proxmox/qemu-volume.test.ts +80 -0
  129. package/src/proxmox/qemu-volume.ts +121 -0
  130. package/src/proxmox/qemu.ts +15 -29
  131. package/src/proxmox/storage-download-errors.ts +7 -0
  132. package/src/proxmox/storage-download-identity.test.ts +54 -0
  133. package/src/proxmox/storage-download-identity.ts +50 -0
  134. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  135. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  136. package/src/proxmox/storage-download-props.ts +38 -0
  137. package/src/proxmox/storage-download-task.ts +72 -0
  138. package/src/proxmox/storage-download.test.ts +216 -0
  139. package/src/proxmox/storage-download.ts +58 -0
  140. package/src/telemetry/fake-collector.ts +59 -0
  141. package/src/telemetry/http-client-sealing.test.ts +82 -0
  142. package/src/telemetry/index.ts +10 -0
  143. package/src/telemetry/layer.test.ts +204 -0
  144. package/src/telemetry/layer.ts +152 -0
  145. package/src/telemetry/redact-serialization.ts +178 -0
  146. package/src/telemetry/redact.test.ts +124 -0
  147. package/src/telemetry/redact.ts +147 -0
  148. package/src/unifi/drift.test.ts +78 -0
  149. package/src/unifi/drift.ts +56 -0
  150. package/src/unifi/errors-and-secrets.test.ts +127 -0
  151. package/src/unifi/fake-unifi.ts +5 -1
  152. package/src/unifi/firewall-zone-form.ts +31 -2
  153. package/src/unifi/firewall-zone.test.ts +21 -1
  154. package/src/unifi/firewall-zone.ts +1 -1
  155. package/src/unifi/get-only-guard.test.ts +165 -0
  156. package/src/unifi/index.ts +15 -1
  157. package/src/unifi/network-drift.test.ts +207 -0
  158. package/src/unifi/network-drift.ts +80 -0
  159. package/src/unifi/network-form.ts +11 -39
  160. package/src/unifi/network.test.ts +7 -82
  161. package/src/unifi/network.ts +6 -6
  162. package/src/unifi/paginate.test.ts +228 -0
  163. package/src/unifi/paginate.ts +129 -0
  164. package/src/unifi/resource.ts +73 -3
  165. package/src/unifi/write-op-reference.test.ts +236 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `GetOnlyHttpClient` (`resource.ts`, B0a/T12) in isolation — against a bare `HttpClient` service,
3
+ * no `CredentialsFromEnv`, no real SDK operation. Proves the guard's own mechanism: a `GET`
4
+ * reaches the wrapped transport unchanged, and any other method dies with `UnifiNonGetRequest`
5
+ * BEFORE the fake server ever sees it (`fake.seen` stays empty for the refused call).
6
+ *
7
+ * ⚠️ NOT ROUTED THROUGH `unifiHandlers`/`CredentialsFromEnv` ON PURPOSE — see `fake-unifi.ts`'s
8
+ * own warning: `CredentialsFromEnv` resolves `UNIFI_NETWORK_*` through Effect's `Config`, which
9
+ * this directory's existing tests all avoid depending on for the same reason. Since the guard
10
+ * only touches the `HttpClient` service, testing it directly here is both simpler and a more
11
+ * precise proof of the mechanism than routing real credentials through it would be.
12
+ * ⚠️ CORRECTION (2026-09-26, red team): the line this replaces claimed `network.test.ts`/
13
+ * `firewall-zone.test.ts` prove `withCredentials` lets a real GET through. They don't —
14
+ * both call `unifiOperations(spec)` directly, which bypasses `unifiHandlers`/`withCredentials`
15
+ * (and therefore this guard) entirely. Proof: deleting `Effect.provide(GetOnlyHttpClient)` from
16
+ * `withCredentials` (`resource.ts`) left all 56 tests green before this file's own
17
+ * `describe('GetOnlyHttpClient installed in unifiHandlers', …)` block below existed. THAT block
18
+ * is what closes the gap: it drives `unifiHandlers(...).read`/`.reconcile` — the actual exported
19
+ * entry points a real deploy calls — through a `ConfigProvider` override (the same pattern
20
+ * `opnsense/write-refusal.test.ts` uses for `CredentialsFromEnv`), so it fails if the guard is
21
+ * ever removed from `withCredentials` again.
22
+ * ★ REUSES `fakeUnifi` (`fake-unifi.ts`), NOT AN AD HOC MOCK. `fakeUnifi`'s wrapper records
23
+ * `seen` BEFORE calling `route`, so even a `route` that would throw still proves whether the
24
+ * guard let a request through — the assertion is on `seen`, never on catching that throw.
25
+ */
26
+ import { describe, expect, test } from 'bun:test';
27
+ import * as Cause from 'effect/Cause';
28
+ import * as ConfigProvider from 'effect/ConfigProvider';
29
+ import * as Effect from 'effect/Effect';
30
+ import * as Exit from 'effect/Exit';
31
+ import * as Layer from 'effect/Layer';
32
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
33
+ import type * as HttpClientError from 'effect/unstable/http/HttpClientError';
34
+ import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
35
+ import { FAKE_BASE, FAKE_KEY, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
36
+ import {
37
+ GetOnlyHttpClient,
38
+ UnifiNonGetRequest,
39
+ type UnifiSpec,
40
+ unifiHandlers,
41
+ } from './resource.ts';
42
+
43
+ const FAKE_PATH = '/proxy/network/integration/v1/sites/site-1/networks';
44
+ const FAKE_URL = `https://unifi.example.com${FAKE_PATH}`;
45
+
46
+ /** One request through `GetOnlyHttpClient`, over a fake transport built by `fakeUnifi`. */
47
+ const sendGuarded = (method: string, fetchFn: typeof globalThis.fetch) =>
48
+ Effect.runPromiseExit(
49
+ Effect.gen(function* () {
50
+ const client = yield* HttpClient.HttpClient;
51
+ return yield* client.execute(HttpClientRequest.make(method as never)(FAKE_URL));
52
+ }).pipe(Effect.provide(GetOnlyHttpClient), Effect.provide(fakeUnifiLayer(fetchFn))),
53
+ );
54
+
55
+ describe('GetOnlyHttpClient', () => {
56
+ test('a GET request passes through unchanged and reaches the transport', async () => {
57
+ const fake = fakeUnifi(() => Response.json({ ok: true }));
58
+
59
+ const exit = await sendGuarded('GET', fake.fetch);
60
+
61
+ expect(Exit.isSuccess(exit)).toBe(true);
62
+ expect(fake.seen).toEqual([{ method: 'GET', path: FAKE_PATH }]);
63
+ });
64
+
65
+ test.each(['POST', 'PUT', 'PATCH', 'DELETE'])(
66
+ '%s dies with UnifiNonGetRequest and never reaches the transport',
67
+ async (method) => {
68
+ const fake = fakeUnifi(() => {
69
+ throw new Error(`get-only-guard.test.ts: unexpected ${method} reached the fake transport`);
70
+ });
71
+
72
+ const exit = await sendGuarded(method, fake.fetch);
73
+
74
+ expect(Exit.isFailure(exit)).toBe(true);
75
+ if (!Exit.isFailure(exit)) return;
76
+ expect(Cause.hasDies(exit.cause)).toBe(true);
77
+ expect(Cause.hasFails(exit.cause)).toBe(false);
78
+ const defect = Cause.squash(exit.cause);
79
+ expect(defect).toBeInstanceOf(UnifiNonGetRequest);
80
+ expect((defect as UnifiNonGetRequest).method).toBe(method);
81
+ // ⛔ THE PROOF: the fake transport never even recorded the call -- the guard refused the
82
+ // request in `mapRequestEffect`, before dispatch, not after a failed response.
83
+ expect(fake.seen).toEqual([]);
84
+ },
85
+ );
86
+ });
87
+
88
+ interface ProbeProps {
89
+ id: string;
90
+ }
91
+
92
+ /**
93
+ * A `fetchLive` that sends exactly one raw request over whatever `method` the test wants to
94
+ * probe — not a real SDK operation, on purpose: this block proves the GUARD'S INSTALLATION in
95
+ * `unifiHandlers`, not any one family's own all-GET behavior (`network.test.ts`/
96
+ * `firewall-zone.test.ts` already prove that, for calls that bypass `unifiHandlers` entirely).
97
+ */
98
+ const probeSpec = (
99
+ method: string,
100
+ ): UnifiSpec<ProbeProps, ProbeProps, ProbeProps, HttpClientError.HttpClientError> => ({
101
+ type: 'Test.Probe',
102
+ describe: (props) => `probe/${props.id}`,
103
+ fetchLive: (props) =>
104
+ Effect.gen(function* () {
105
+ const client = yield* HttpClient.HttpClient;
106
+ yield* client.execute(HttpClientRequest.make(method as never)(`${FAKE_BASE}/probe`));
107
+ return props;
108
+ }),
109
+ attributes: (live) => live,
110
+ matches: () => true,
111
+ });
112
+
113
+ /**
114
+ * Overrides the `ConfigProvider` `CredentialsFromEnv` resolves `UNIFI_NETWORK_*` against — the
115
+ * SAME pattern `opnsense/write-refusal.test.ts` uses for its own `CredentialsFromEnv` — WITHOUT
116
+ * touching real `process.env` (a parallel test file could race on that). `unifiHandlers`'s
117
+ * `withCredentials` bakes in `CredentialsFromEnv` itself, so providing a `Credentials` layer from
118
+ * OUTSIDE (the way `fake-unifi.ts`'s own `fakeUnifiLayer` does for tests that call `spec.fetchLive`
119
+ * directly) would be shadowed and do nothing here — this ConfigProvider override is what actually
120
+ * reaches it.
121
+ */
122
+ const fakeCredentialsConfigProvider = ConfigProvider.layer(
123
+ ConfigProvider.fromUnknown({
124
+ UNIFI_NETWORK_API_BASE_URL: FAKE_BASE,
125
+ UNIFI_NETWORK_API_KEY: FAKE_KEY,
126
+ }),
127
+ );
128
+
129
+ describe('GetOnlyHttpClient is installed in unifiHandlers (IMPORTANT-1, red team 2026-09-26)', () => {
130
+ test('a GET through the real handler entry point still reaches the transport', async () => {
131
+ const fake = fakeUnifi(() => Response.json({ id: 'w1' }));
132
+ const attrs = await Effect.runPromise(
133
+ unifiHandlers(probeSpec('GET'))
134
+ .read({ olds: { id: 'w1' }, output: undefined })
135
+ .pipe(
136
+ Effect.provide(Layer.mergeAll(fakeUnifiLayer(fake.fetch), fakeCredentialsConfigProvider)),
137
+ ),
138
+ );
139
+ expect(attrs).toEqual({ id: 'w1' });
140
+ expect(fake.seen).toEqual([{ method: 'GET', path: '/proxy/network/integration/probe' }]);
141
+ });
142
+
143
+ test('a non-GET through the real handler entry point dies, never reaching the transport', async () => {
144
+ const fake = fakeUnifi(() => {
145
+ throw new Error('get-only-guard.test.ts: unexpected POST reached the fake transport');
146
+ });
147
+ const exit = await Effect.runPromiseExit(
148
+ unifiHandlers(probeSpec('POST'))
149
+ .read({ olds: { id: 'w1' }, output: undefined })
150
+ .pipe(
151
+ Effect.provide(Layer.mergeAll(fakeUnifiLayer(fake.fetch), fakeCredentialsConfigProvider)),
152
+ ),
153
+ );
154
+ expect(Exit.isFailure(exit)).toBe(true);
155
+ if (!Exit.isFailure(exit)) return;
156
+ expect(Cause.hasDies(exit.cause)).toBe(true);
157
+ expect(Cause.squash(exit.cause)).toBeInstanceOf(UnifiNonGetRequest);
158
+ // ⛔ THIS IS THE PROOF THE FILE HEADER PROMISES. Verified by hand (2026-09-26): deleting
159
+ // `Effect.provide(GetOnlyHttpClient)` from `withCredentials` (`resource.ts`) turns this
160
+ // exact test into a passing `Response.json` round-trip with a non-empty `fake.seen` --
161
+ // i.e. it FAILS the instant the guard installation regresses, which no test in this
162
+ // directory could do before this block existed (IMPORTANT-1).
163
+ expect(fake.seen).toEqual([]);
164
+ });
165
+ });
@@ -6,7 +6,16 @@
6
6
  * not depend on — mirrors `../netbox/index.ts`.
7
7
  */
8
8
  export { UNIFI_READ_ONLY_POLICY, UnifiWriteRefused, type UnifiWriteAction } from './policy.ts';
9
- export type { UnifiRequirements, UnifiSpec } from './resource.ts';
9
+ export {
10
+ // Barrel-exported (not just internal to `unifiHandlers`) so `homeflare-network`'s own
11
+ // import-layer guard (plan item C0) can reuse this instead of re-implementing the same
12
+ // `HttpClient` wrap against a second copy of the SDK's `HttpClient.HttpClient` service.
13
+ GetOnlyHttpClient,
14
+ UnifiNonGetRequest,
15
+ type UnifiRequirements,
16
+ type UnifiSpec,
17
+ } from './resource.ts';
18
+ export type { FieldDrift } from './drift.ts';
10
19
  export {
11
20
  UnifiNetwork,
12
21
  UnifiNetworkProvider,
@@ -14,6 +23,10 @@ export {
14
23
  network,
15
24
  type NetworkAttributes,
16
25
  type NetworkProps,
26
+ // B6: renamed here, not in `network.ts` — `network-form.ts`'s `matches`/`attributesOf` name the
27
+ // same function identically per family (never barrel-exported); `driftOf` IS barrel-exported, so
28
+ // it needs a name unique across both families the moment it leaves `network.ts`.
29
+ driftOf as networkDriftOf,
17
30
  } from './network.ts';
18
31
  export {
19
32
  UnifiFirewallZone,
@@ -22,5 +35,6 @@ export {
22
35
  firewallZone,
23
36
  type FirewallZoneAttributes,
24
37
  type FirewallZoneProps,
38
+ driftOf as firewallZoneDriftOf,
25
39
  } from './firewall-zone.ts';
26
40
  export { providers } from './providers.ts';
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `Unifi.Network`'s `driftOf`/`matches` (`network-drift.ts`, B6) — split out of `network.test.ts`
3
+ * to keep that file under the house 250-line cap once this coverage grew, not because this is a
4
+ * different family (same split `network-drift.ts` itself is from `network-form.ts`).
5
+ *
6
+ * Since MEDIUM-4 (red team, 2026-09-26), `matches` IS `fieldDrift(...).length === 0` — there is no
7
+ * second, hand-written comparison — so every test below exercises both functions together on
8
+ * purpose, proving they cannot disagree rather than merely asserting each looks right alone.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import type * as networks from '@distilled.cloud/unifi-network/networks';
12
+ import { attributesOf } from './network-form.ts';
13
+ import { driftOf, matches } from './network-drift.ts';
14
+ import { type NetworkProps, declareNetwork } from './network.ts';
15
+
16
+ const liveNetwork = (
17
+ overrides: Partial<networks.NetworkDetails> = {},
18
+ ): networks.NetworkDetails => ({
19
+ default: false,
20
+ enabled: true,
21
+ id: 'net-1',
22
+ management: 'ADVANCED',
23
+ metadata: { origin: 'USER' },
24
+ name: 'Cameras',
25
+ vlanId: 40,
26
+ isolationEnabled: true,
27
+ zoneId: 'zone-1',
28
+ ...overrides,
29
+ });
30
+
31
+ const PROPS: NetworkProps = {
32
+ siteId: 'site-1',
33
+ networkId: 'net-1',
34
+ name: 'Cameras',
35
+ enabled: true,
36
+ management: 'ADVANCED',
37
+ vlanId: 40,
38
+ isolationEnabled: true,
39
+ zoneId: 'zone-1',
40
+ };
41
+
42
+ describe('driftOf -- B6 field-level drift, straight from one live read', () => {
43
+ test('a live object that matches its declaration drifts on nothing', () => {
44
+ const live = liveNetwork();
45
+ expect(driftOf(live, PROPS)).toEqual([]);
46
+ });
47
+
48
+ test('a drifted field reports its own live and declared values, by name', () => {
49
+ const live = liveNetwork({ vlanId: 999 });
50
+ expect(driftOf(live, PROPS)).toEqual([{ field: 'vlanId', live: 999, declared: 40 }]);
51
+ });
52
+
53
+ test('agrees with matches() on every case matches() already covers', () => {
54
+ const live = liveNetwork({ name: 'Guest', vlanId: 7 });
55
+ expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
56
+ expect(driftOf(live, PROPS).length > 0).toBe(true);
57
+ });
58
+
59
+ // MEDIUM-4 (red team, 2026-09-26) Mutant B: deleting the `zoneId`/`deviceId` entries from
60
+ // `fieldDrift` entirely left 56/56 green, because nothing exercised a live/declared difference
61
+ // on either field. `matches` now IS `fieldDrift(...).length === 0` (no second comparison to fall
62
+ // back on), so this closes the gap for both functions at once, not just `driftOf`. Verified by
63
+ // hand: reverting `fieldDrift` to drop these two entries fails both tests below.
64
+ test('a zoneId difference is caught, not silently dropped from either comparison', () => {
65
+ const live = liveNetwork({ zoneId: 'zone-9' });
66
+ expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
67
+ expect(driftOf(live, PROPS)).toEqual([{ field: 'zoneId', live: 'zone-9', declared: 'zone-1' }]);
68
+ });
69
+
70
+ test('a deviceId difference is caught, not silently dropped from either comparison', () => {
71
+ const props: NetworkProps = { ...PROPS, deviceId: 'device-1' };
72
+ const live = liveNetwork({ deviceId: 'device-2' });
73
+ expect(matches(attributesOf(live, props), props)).toBe(false);
74
+ expect(driftOf(live, props)).toEqual([
75
+ { field: 'deviceId', live: 'device-2', declared: 'device-1' },
76
+ ]);
77
+ });
78
+ });
79
+
80
+ describe('matches -- unordered arrays (dhcpGuarding, ipv6Configuration)', () => {
81
+ // ⛔ `deepEqual` (alchemy/Diff) sorts object keys but not array elements: without normalizing
82
+ // these three fields (see network-form.ts's header), a console returning the same set in a
83
+ // different order would plan a spurious update.
84
+ test('the same trusted DHCP servers in a different order is a noop', () => {
85
+ const live = liveNetwork({
86
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.2', '10.0.0.1'] },
87
+ });
88
+ const props: NetworkProps = {
89
+ ...PROPS,
90
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
91
+ };
92
+ expect(matches(attributesOf(live, props), props)).toBe(true);
93
+ // MEDIUM-4: `driftOf` shares `matches`' own field list now, so a normalizer this test relies
94
+ // on being applied to the DECLARED side (T15) getting dropped would show up here too.
95
+ expect(driftOf(live, props)).toEqual([]);
96
+ });
97
+
98
+ test('the DECLARED side is also normalized, not just live (MEDIUM-4 mutant)', () => {
99
+ // The previous test's PROPS happens to already be sorted, so it can't tell a real normalizer
100
+ // from one silently applied to only the live side -- a red-team mutant that dropped
101
+ // `normalizeDhcpGuarding` from `network-drift.ts`'s DECLARED closure left every test (this
102
+ // file included, before this one) green. Reversing PROPS here, with live already sorted,
103
+ // exercises the declared-side call directly. Verified by hand: this test fails against that
104
+ // exact mutant; every other test in this file still passes against it.
105
+ const live = liveNetwork({
106
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
107
+ });
108
+ const props: NetworkProps = {
109
+ ...PROPS,
110
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.2', '10.0.0.1'] },
111
+ };
112
+ expect(matches(attributesOf(live, props), props)).toBe(true);
113
+ expect(driftOf(live, props)).toEqual([]);
114
+ });
115
+
116
+ test('a different set of trusted DHCP servers is an update', () => {
117
+ const live = liveNetwork({
118
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
119
+ });
120
+ const props: NetworkProps = {
121
+ ...PROPS,
122
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.3'] },
123
+ };
124
+ expect(matches(attributesOf(live, props), props)).toBe(false);
125
+ expect(driftOf(live, props).some((d) => d.field === 'dhcpGuarding')).toBe(true);
126
+ });
127
+
128
+ test('the same IPv6 host subnets and DNS overrides in a different order is a noop', () => {
129
+ const live = liveNetwork({
130
+ ipv6Configuration: {
131
+ clientAddressAssignment: { slaacEnabled: true },
132
+ interfaceType: 'ETHERNET',
133
+ additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
134
+ dnsServerIpAddressesOverride: ['2001:4860:4860::8888', '2001:4860:4860::8844'],
135
+ },
136
+ });
137
+ const props: NetworkProps = {
138
+ ...PROPS,
139
+ ipv6Configuration: {
140
+ clientAddressAssignment: { slaacEnabled: true },
141
+ interfaceType: 'ETHERNET',
142
+ additionalHostIpSubnets: ['2001:db8:1::/64', '2001:db8::/64'],
143
+ dnsServerIpAddressesOverride: ['2001:4860:4860::8844', '2001:4860:4860::8888'],
144
+ },
145
+ };
146
+ expect(matches(attributesOf(live, props), props)).toBe(true);
147
+ expect(driftOf(live, props)).toEqual([]);
148
+ });
149
+
150
+ test('the DECLARED side of IPv6 subnets/DNS overrides is also normalized (MEDIUM-4 mutant)', () => {
151
+ // Same blind spot as the DHCP test above, for both ipv6Configuration override lists: live is
152
+ // already sorted here, so only reversing PROPS exercises the DECLARED side's normalizer.
153
+ const live = liveNetwork({
154
+ ipv6Configuration: {
155
+ clientAddressAssignment: { slaacEnabled: true },
156
+ interfaceType: 'ETHERNET',
157
+ additionalHostIpSubnets: ['2001:db8:1::/64', '2001:db8::/64'],
158
+ dnsServerIpAddressesOverride: ['2001:4860:4860::8844', '2001:4860:4860::8888'],
159
+ },
160
+ });
161
+ const props: NetworkProps = {
162
+ ...PROPS,
163
+ ipv6Configuration: {
164
+ clientAddressAssignment: { slaacEnabled: true },
165
+ interfaceType: 'ETHERNET',
166
+ additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
167
+ dnsServerIpAddressesOverride: ['2001:4860:4860::8888', '2001:4860:4860::8844'],
168
+ },
169
+ };
170
+ expect(matches(attributesOf(live, props), props)).toBe(true);
171
+ expect(driftOf(live, props)).toEqual([]);
172
+ });
173
+
174
+ test('a different set of IPv6 host subnets is an update', () => {
175
+ const live = liveNetwork({
176
+ ipv6Configuration: {
177
+ clientAddressAssignment: { slaacEnabled: true },
178
+ interfaceType: 'ETHERNET',
179
+ additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
180
+ },
181
+ });
182
+ const props: NetworkProps = {
183
+ ...PROPS,
184
+ ipv6Configuration: {
185
+ clientAddressAssignment: { slaacEnabled: true },
186
+ interfaceType: 'ETHERNET',
187
+ additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:2::/64'],
188
+ },
189
+ };
190
+ expect(matches(attributesOf(live, props), props)).toBe(false);
191
+ expect(driftOf(live, props).some((d) => d.field === 'ipv6Configuration')).toBe(true);
192
+ });
193
+
194
+ test('a live `null` (not `undefined`) for either field does not throw', () => {
195
+ // UniFi's JSON answers `null` for an unset optional field (see network-form.ts's header) —
196
+ // a reality the SDK's `T | undefined` types don't model. The double cast lands `null` at
197
+ // runtime the same way a real JSON decode would, past what the type system alone allows.
198
+ const live = {
199
+ ...liveNetwork(),
200
+ dhcpGuarding: null,
201
+ ipv6Configuration: null,
202
+ } as unknown as networks.NetworkDetails;
203
+ const declared = declareNetwork(live, 'site-1');
204
+ expect(() => attributesOf(live, declared)).not.toThrow();
205
+ expect(matches(attributesOf(live, declared), declared)).toBe(true);
206
+ });
207
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `Unifi.Network`'s `matches` AND `driftOf` (B6) — both built on the ONE field list below, not two
3
+ * hand-kept-in-sync comparisons. `matches` used to be a separate boolean expression in
4
+ * `network-form.ts`; MEDIUM-4 (red team, 2026-09-26) found that a `fieldDrift` entry could be
5
+ * deleted entirely and every test still passed, because `matches` never consulted `fieldDrift` at
6
+ * all. `matches(attrs, props) === (driftOf(live, props).length === 0)` is now true BY
7
+ * CONSTRUCTION — there is exactly one comparison — and every `fieldDrift` entry names ONE field
8
+ * plus its own live/declared value instead of collapsing straight to a boolean.
9
+ *
10
+ * ⚠️ `stripNullish: true` ON EVERY FIELD BY DEFAULT (`drift.ts`'s `defaultEqual`). UniFi's JSON
11
+ * answers `null` for an unset optional field; a declaration that omits the same prop carries
12
+ * `undefined`. Without this, every plan against an object with one unset optional field would
13
+ * report `update` forever — the exact "read and write are different shapes" trap
14
+ * `netbox/values.ts` and `proxmox/user-wire.ts` both work around, here solved once by
15
+ * `makeDriftOf`'s own default instead of a per-field coercion.
16
+ */
17
+ import type * as networks from '@distilled.cloud/unifi-network/networks';
18
+ import { makeDriftOf } from './drift.ts';
19
+ import {
20
+ type NetworkAttributes,
21
+ type NetworkProps,
22
+ attributesOf,
23
+ normalizeDhcpGuarding,
24
+ normalizeIpv6Configuration,
25
+ } from './network-form.ts';
26
+
27
+ const fieldDrift = makeDriftOf<NetworkAttributes, NetworkProps>([
28
+ { field: 'name', live: (a) => a.name, declared: (p) => p.name },
29
+ { field: 'enabled', live: (a) => a.enabled, declared: (p) => p.enabled },
30
+ { field: 'management', live: (a) => a.management, declared: (p) => p.management },
31
+ { field: 'vlanId', live: (a) => a.vlanId, declared: (p) => p.vlanId },
32
+ {
33
+ field: 'dhcpGuarding',
34
+ live: (a) => normalizeDhcpGuarding(a.dhcpGuarding),
35
+ declared: (p) => normalizeDhcpGuarding(p.dhcpGuarding),
36
+ },
37
+ {
38
+ field: 'cellularBackupEnabled',
39
+ live: (a) => a.cellularBackupEnabled,
40
+ declared: (p) => p.cellularBackupEnabled,
41
+ },
42
+ {
43
+ field: 'internetAccessEnabled',
44
+ live: (a) => a.internetAccessEnabled,
45
+ declared: (p) => p.internetAccessEnabled,
46
+ },
47
+ {
48
+ field: 'ipv4Configuration',
49
+ live: (a) => a.ipv4Configuration,
50
+ declared: (p) => p.ipv4Configuration,
51
+ },
52
+ {
53
+ field: 'ipv6Configuration',
54
+ live: (a) => normalizeIpv6Configuration(a.ipv6Configuration),
55
+ declared: (p) => normalizeIpv6Configuration(p.ipv6Configuration),
56
+ },
57
+ {
58
+ field: 'isolationEnabled',
59
+ live: (a) => a.isolationEnabled,
60
+ declared: (p) => p.isolationEnabled,
61
+ },
62
+ {
63
+ field: 'mdnsForwardingEnabled',
64
+ live: (a) => a.mdnsForwardingEnabled,
65
+ declared: (p) => p.mdnsForwardingEnabled,
66
+ },
67
+ { field: 'zoneId', live: (a) => a.zoneId, declared: (p) => p.zoneId },
68
+ { field: 'deviceId', live: (a) => a.deviceId, declared: (p) => p.deviceId },
69
+ ]);
70
+
71
+ export const matches = (attributes: NetworkAttributes, props: NetworkProps): boolean =>
72
+ fieldDrift(attributes, props).length === 0;
73
+
74
+ /**
75
+ * Per-field live-vs-declared drift straight from one live read — the task's own API
76
+ * (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive
77
+ * `attributesOf` itself just to call this.
78
+ */
79
+ export const driftOf = (live: networks.NetworkDetails, props: NetworkProps) =>
80
+ fieldDrift(attributesOf(live, props), props);
@@ -30,7 +30,6 @@
30
30
  * before any of the three is attributed, declared, or compared. Every OTHER array field in this
31
31
  * family (there are none today besides these three) would stay declaration-order as written.
32
32
  */
33
- import { deepEqual } from 'alchemy/Diff';
34
33
  import type * as networks from '@distilled.cloud/unifi-network/networks';
35
34
 
36
35
  const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();
@@ -44,7 +43,10 @@ const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].
44
43
  // `TypeError: Cannot read properties of null` the first time a console omits either field —
45
44
  // `== null` catches both and returns the value through untouched, matching what the rest of
46
45
  // this file already does for every other optional field.
47
- const normalizeDhcpGuarding = (
46
+ // ★ EXPORTED (not just used below): `network-drift.ts`'s `driftOf` reuses these exact two
47
+ // normalizers so it can never quietly disagree with `matches` about what counts as a change —
48
+ // see that file's own header.
49
+ export const normalizeDhcpGuarding = (
48
50
  value: networks.NetworkDHCPGuarding | undefined,
49
51
  ): networks.NetworkDHCPGuarding | undefined =>
50
52
  value == null
@@ -56,7 +58,7 @@ const normalizeDhcpGuarding = (
56
58
  // `NetworkIPv6Configuration` types them `field?: T`, not `field?: T | undefined`. Conditional
57
59
  // spread (rather than `field: value.field === undefined ? undefined : sortedSet(...)`) is what
58
60
  // keeps an absent key absent.
59
- const normalizeIpv6Configuration = (
61
+ export const normalizeIpv6Configuration = (
60
62
  value: networks.NetworkIPv6Configuration | undefined,
61
63
  ): networks.NetworkIPv6Configuration | undefined =>
62
64
  value == null
@@ -142,43 +144,13 @@ export const attributesOf = (
142
144
  });
143
145
 
144
146
  /**
145
- * ⚠️ `stripNullish: true` ON EVERY COMPARISON. UniFi's JSON answers `null` for an unset optional
146
- * field; a declaration that omits the same prop carries `undefined`. Without this option every
147
- * plan against an object with one unset optional field would report `update` forever — the
148
- * exact "read and write are different shapes" trap `netbox/values.ts` and `proxmox/user-wire.ts`
149
- * both work around, here solved once with the engine's own `deepEqual` instead of a per-field
150
- * coercion, because nothing here needs PVE's or NetBox's extra wire-format translation.
147
+ * MEDIUM-4 (red team, 2026-09-26): `matches` used to be a hand-written boolean expression here,
148
+ * kept in sync BY HAND with `network-drift.ts`'s separately-maintained field list — two comparisons
149
+ * that could (and, per a red-team mutant, silently did) drift apart. `matches` now lives in
150
+ * `network-drift.ts`, defined as `fieldDrift(...).length === 0` — the SAME comparison `driftOf`
151
+ * runs, not a second one. See that file's own header for the `stripNullish`/normalizer reasoning
152
+ * this expression used to carry.
151
153
  */
152
- export const matches = (attributes: NetworkAttributes, props: NetworkProps): boolean =>
153
- attributes.name === props.name &&
154
- attributes.enabled === props.enabled &&
155
- attributes.management === props.management &&
156
- attributes.vlanId === props.vlanId &&
157
- deepEqual(
158
- normalizeDhcpGuarding(attributes.dhcpGuarding),
159
- normalizeDhcpGuarding(props.dhcpGuarding),
160
- {
161
- stripNullish: true,
162
- },
163
- ) &&
164
- deepEqual(attributes.cellularBackupEnabled, props.cellularBackupEnabled, {
165
- stripNullish: true,
166
- }) &&
167
- deepEqual(attributes.internetAccessEnabled, props.internetAccessEnabled, {
168
- stripNullish: true,
169
- }) &&
170
- deepEqual(attributes.ipv4Configuration, props.ipv4Configuration, { stripNullish: true }) &&
171
- deepEqual(
172
- normalizeIpv6Configuration(attributes.ipv6Configuration),
173
- normalizeIpv6Configuration(props.ipv6Configuration),
174
- { stripNullish: true },
175
- ) &&
176
- deepEqual(attributes.isolationEnabled, props.isolationEnabled, { stripNullish: true }) &&
177
- deepEqual(attributes.mdnsForwardingEnabled, props.mdnsForwardingEnabled, {
178
- stripNullish: true,
179
- }) &&
180
- deepEqual(attributes.zoneId, props.zoneId, { stripNullish: true }) &&
181
- deepEqual(attributes.deviceId, props.deviceId, { stripNullish: true });
182
154
 
183
155
  /**
184
156
  * The declaration renderer (task spec: "given one live object, return the props a declaration