@homeflare/alchemy 0.37.8 → 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.
- package/README.md +22 -0
- package/dist/caddy/admin.d.ts +42 -8
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +9 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +5 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.js +10 -6
- package/dist/caddy/index.js.map +5 -5
- package/dist/caddy/providers.d.ts +19 -12
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/forgejo/index.js +4 -2
- package/dist/forgejo/index.js.map +3 -3
- package/dist/forgejo/team-member.d.ts +25 -0
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +14 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +565 -133
- package/dist/proxmox/index.js.map +33 -23
- package/dist/proxmox/qemu-form.d.ts +27 -18
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts +30 -0
- package/dist/proxmox/qemu-judge.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-net.d.ts +9 -0
- package/dist/proxmox/qemu-net.d.ts.map +1 -0
- package/dist/proxmox/qemu-props.d.ts +112 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -0
- package/dist/proxmox/qemu-volume.d.ts +17 -0
- package/dist/proxmox/qemu-volume.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +14 -27
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/storage-download-errors.d.ts +5 -0
- package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
- package/dist/proxmox/storage-download-identity.d.ts +27 -0
- package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/storage-download-props.d.ts +36 -0
- package/dist/proxmox/storage-download-props.d.ts.map +1 -0
- package/dist/proxmox/storage-download-task.d.ts +17 -0
- package/dist/proxmox/storage-download-task.d.ts.map +1 -0
- package/dist/proxmox/storage-download.d.ts +44 -0
- package/dist/proxmox/storage-download.d.ts.map +1 -0
- package/dist/telemetry/fake-collector.d.ts +36 -0
- package/dist/telemetry/fake-collector.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +223 -0
- package/dist/telemetry/index.js.map +12 -0
- package/dist/telemetry/layer.d.ts +86 -0
- package/dist/telemetry/layer.d.ts.map +1 -0
- package/dist/telemetry/redact-serialization.d.ts +21 -0
- package/dist/telemetry/redact-serialization.d.ts.map +1 -0
- package/dist/telemetry/redact.d.ts +46 -0
- package/dist/telemetry/redact.d.ts.map +1 -0
- package/dist/unifi/drift.d.ts +27 -0
- package/dist/unifi/drift.d.ts.map +1 -0
- package/dist/unifi/fake-unifi.d.ts +5 -1
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-zone-form.d.ts +6 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +4 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +138 -58
- package/dist/unifi/index.js.map +10 -8
- package/dist/unifi/network-drift.d.ts +26 -0
- package/dist/unifi/network-drift.d.ts.map +1 -0
- package/dist/unifi/network-form.d.ts +40 -7
- package/dist/unifi/network-form.d.ts.map +1 -1
- package/dist/unifi/network.d.ts +1 -0
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/paginate.d.ts +36 -0
- package/dist/unifi/paginate.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +36 -1
- package/dist/unifi/resource.d.ts.map +1 -1
- package/docs/telemetry-spike.md +94 -0
- package/docs/telemetry.md +109 -0
- package/docs/unifi.md +56 -0
- package/package.json +6 -2
- package/src/caddy/admin-transport-scope.test.ts +127 -0
- package/src/caddy/admin.ts +45 -9
- package/src/caddy/config-adopt-provider.test.ts +2 -2
- package/src/caddy/config-format-warning.test.ts +9 -3
- package/src/caddy/config.ts +20 -4
- package/src/caddy/fake-caddy.ts +10 -1
- package/src/caddy/providers.test.ts +3 -2
- package/src/caddy/providers.ts +18 -11
- package/src/forgejo/team-member.test.ts +14 -0
- package/src/forgejo/team-member.ts +28 -1
- package/src/proxmox/constraints-required.test.ts +1 -0
- package/src/proxmox/generated/constraints/index.ts +3 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.test.ts +11 -6
- package/src/proxmox/index.ts +19 -3
- package/src/proxmox/qemu-form.ts +49 -13
- package/src/proxmox/qemu-identity.test.ts +30 -0
- package/src/proxmox/qemu-judge.test.ts +65 -0
- package/src/proxmox/qemu-judge.ts +84 -0
- package/src/proxmox/qemu-lifecycle.test.ts +78 -19
- package/src/proxmox/qemu-lifecycle.ts +131 -32
- package/src/proxmox/qemu-net.test.ts +48 -0
- package/src/proxmox/qemu-net.ts +78 -0
- package/src/proxmox/qemu-ownership.test.ts +44 -0
- package/src/proxmox/qemu-props.test.ts +87 -0
- package/src/proxmox/qemu-props.ts +157 -0
- package/src/proxmox/qemu-volume.test.ts +80 -0
- package/src/proxmox/qemu-volume.ts +121 -0
- package/src/proxmox/qemu.ts +15 -29
- package/src/proxmox/storage-download-errors.ts +7 -0
- package/src/proxmox/storage-download-identity.test.ts +54 -0
- package/src/proxmox/storage-download-identity.ts +50 -0
- package/src/proxmox/storage-download-lifecycle.ts +226 -0
- package/src/proxmox/storage-download-ownership.test.ts +50 -0
- package/src/proxmox/storage-download-props.ts +38 -0
- package/src/proxmox/storage-download-task.ts +72 -0
- package/src/proxmox/storage-download.test.ts +216 -0
- package/src/proxmox/storage-download.ts +58 -0
- package/src/telemetry/fake-collector.ts +59 -0
- package/src/telemetry/http-client-sealing.test.ts +82 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/layer.test.ts +204 -0
- package/src/telemetry/layer.ts +152 -0
- package/src/telemetry/redact-serialization.ts +178 -0
- package/src/telemetry/redact.test.ts +124 -0
- package/src/telemetry/redact.ts +147 -0
- package/src/unifi/drift.test.ts +78 -0
- package/src/unifi/drift.ts +56 -0
- package/src/unifi/errors-and-secrets.test.ts +127 -0
- package/src/unifi/fake-unifi.ts +5 -1
- package/src/unifi/firewall-zone-form.ts +31 -2
- package/src/unifi/firewall-zone.test.ts +21 -1
- package/src/unifi/firewall-zone.ts +1 -1
- package/src/unifi/get-only-guard.test.ts +165 -0
- package/src/unifi/index.ts +15 -1
- package/src/unifi/network-drift.test.ts +207 -0
- package/src/unifi/network-drift.ts +80 -0
- package/src/unifi/network-form.ts +11 -39
- package/src/unifi/network.test.ts +7 -82
- package/src/unifi/network.ts +6 -6
- package/src/unifi/paginate.test.ts +228 -0
- package/src/unifi/paginate.ts +129 -0
- package/src/unifi/resource.ts +73 -3
- 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
|
+
});
|
package/src/unifi/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|