@homeflare/alchemy 0.39.0 → 0.41.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 +10 -8
- package/dist/caddy/index.js.map +2 -2
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1610 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +62 -39
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +7 -0
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +588 -3
- package/dist/unifi/index.js.map +24 -4
- package/dist/unifi/paginate.d.ts +0 -12
- package/dist/unifi/paginate.d.ts.map +1 -1
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +10 -5
- package/package.json +7 -3
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +18 -6
- package/src/release/index.ts +1 -0
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.WifiBroadcast`'s wire shape — the declarable subset of `WifiBroadcastOverview`, and how
|
|
3
|
+
* a live read becomes both plan attributes and a rendered declaration.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ T23, THE WHOLE REASON THIS SHAPE DIFFERS FROM EVERY OTHER FAMILY HERE. Every other family's
|
|
6
|
+
* `*-form.ts` (`network-form.ts`, `dns-policy-form.ts`, …) types its props against the vendor's
|
|
7
|
+
* CREATE/UPDATE request — the fullest declarable shape, even though this stack never calls it.
|
|
8
|
+
* `WifiBroadcastProps`/`WifiBroadcastAttributes` do NOT: `securityConfiguration` is typed
|
|
9
|
+
* `wifiBroadcasts.WifiSecurityConfigurationOverview` — the PAGE OVERVIEW's own type, `{type,
|
|
10
|
+
* presharedKeyNetworkIds}` — never `WifiSecurityConfigurationDetailObject` (the
|
|
11
|
+
* `getWifiBroadcastDetails`/create/update shape, which carries `passphrase` and
|
|
12
|
+
* `radiusConfiguration`). There is no DECLARED field on `WifiSecurityConfigurationOverview` for
|
|
13
|
+
* `attributesOf`/`declareWifiBroadcast` below to name a passphrase into. See `wifi-broadcast.ts`'s
|
|
14
|
+
* header for why `fetchLive` can only ever produce a `WifiBroadcastOverview` in the first place
|
|
15
|
+
* (no get-by-id call exists for it).
|
|
16
|
+
*
|
|
17
|
+
* ⛔ THE TYPE SYSTEM ALONE DOES NOT ENFORCE THIS (red team, IMPORTANT-1, 2026-09-26). `@distilled
|
|
18
|
+
* .cloud/core`'s wire decode does not strip a key a schema doesn't declare (this file's next
|
|
19
|
+
* header block); the TS types above describe what a CORRECTLY-shaped response looks like, not
|
|
20
|
+
* what the decoder actually returns at runtime, so a stray key nested inside `network`,
|
|
21
|
+
* `hotspotConfiguration`, `broadcastingDeviceFilter`, or an element of `presharedKeyNetworkIds`
|
|
22
|
+
* would ride along through a naive `{...live}`/pass-through copy exactly like the top-level
|
|
23
|
+
* `securityConfiguration` object does. `normalizeRef`/`normalizeHotspot`/`normalizeDeviceFilter`
|
|
24
|
+
* below rebuild EVERY nested object field-by-field for the same reason
|
|
25
|
+
* `normalizeSecurityConfiguration` does — enumeration, not the type annotation, is what actually
|
|
26
|
+
* stops it. `wifi-broadcast-secrets.test.ts` plants a sentinel inside each nested shape
|
|
27
|
+
* (including one `presharedKeyNetworkIds` element) to prove it, not just the top level.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ EVERY OTHER FIELD REUSES THE SDK'S OWN OVERVIEW TYPES DIRECTLY (`WifiNetworkReference`,
|
|
30
|
+
* `BroadcastingDeviceFilter`, `IntegrationWifiHotspotConfigurationOverviewDto`) rather than
|
|
31
|
+
* redeclaring them — same reasoning `network-form.ts` gives for reusing `NetworkDHCPGuarding`/
|
|
32
|
+
* `NetworkIPv6Configuration` verbatim: one shape to keep in sync with the SDK's own regen.
|
|
33
|
+
*
|
|
34
|
+
* ⛔ THREE FIELDS ARE SETS, NOT SEQUENCES, UNCONFIRMED AGAINST A LIVE CONSOLE (same caveat
|
|
35
|
+
* `network-form.ts`'s header gives for its own three): `broadcastingDeviceFilter.deviceIds`/
|
|
36
|
+
* `.deviceTagIds` and `securityConfiguration.presharedKeyNetworkIds` are membership sets by the
|
|
37
|
+
* vendor description's own wording ("Defines the custom scope of devices…"), not ordered
|
|
38
|
+
* sequences. `normalizeDeviceFilter`/`normalizeSecurityConfiguration`/`normalizeFrequencies`
|
|
39
|
+
* neutralize the same "spurious update, refused by a read-only reconcile" failure mode
|
|
40
|
+
* `network-form.ts`'s header describes, exactly like `firewall-zone-form.ts`'s `networkIds`.
|
|
41
|
+
*/
|
|
42
|
+
import type * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
|
|
43
|
+
|
|
44
|
+
const sortedStrings = (values: readonly string[]): string[] => [...new Set(values)].sort();
|
|
45
|
+
|
|
46
|
+
const sortedFrequencies = (values: readonly number[]): number[] =>
|
|
47
|
+
[...new Set(values)].sort((a, b) => a - b);
|
|
48
|
+
|
|
49
|
+
/** Canonical sort key for a `{type, networkId?}` reference — stable regardless of vendor order. */
|
|
50
|
+
const refKey = (ref: wifiBroadcasts.WifiNetworkReference): string =>
|
|
51
|
+
`${ref.type}:${ref.networkId ?? ''}`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Rebuilds a reference field-by-field — never returns the object as received. IMPORTANT-1: this
|
|
55
|
+
* runs on EVERY reference this file handles (`network`, and each `presharedKeyNetworkIds`
|
|
56
|
+
* element) precisely because a reference is exactly the shape (`{network: WifiNetworkReference,
|
|
57
|
+
* passphrase}`) the details call uses to carry a PPSK passphrase (`IntegrationWifiPresharedKeyDto`,
|
|
58
|
+
* SDK `wifi_broadcasts.ts`); it is the most plausible place for a stray passphrase-shaped key to
|
|
59
|
+
* appear on the wire if the vendor ever leaked one onto the overview response.
|
|
60
|
+
*/
|
|
61
|
+
export const normalizeRef = (
|
|
62
|
+
ref: wifiBroadcasts.WifiNetworkReference | undefined,
|
|
63
|
+
): wifiBroadcasts.WifiNetworkReference | undefined =>
|
|
64
|
+
ref === undefined
|
|
65
|
+
? undefined
|
|
66
|
+
: { type: ref.type, ...(ref.networkId !== undefined ? { networkId: ref.networkId } : {}) };
|
|
67
|
+
|
|
68
|
+
const sortedRefs = (
|
|
69
|
+
values: readonly wifiBroadcasts.WifiNetworkReference[],
|
|
70
|
+
): wifiBroadcasts.WifiNetworkReference[] => {
|
|
71
|
+
const byKey = new Map(
|
|
72
|
+
values.map(
|
|
73
|
+
(ref) => [refKey(ref), normalizeRef(ref) as wifiBroadcasts.WifiNetworkReference] as const,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
return [...byKey.values()].sort((a, b) => refKey(a).localeCompare(refKey(b)));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rebuilds the hotspot configuration field-by-field. `IntegrationWifiHotspotConfigurationOverviewDto`
|
|
81
|
+
* declares only `type` (SDK `wifi_broadcasts.ts:743-753`) — a PASSPOINT/RADIUS secret would live on
|
|
82
|
+
* a wire key this type doesn't name, exactly the IMPORTANT-1 risk `normalizeRef` guards against.
|
|
83
|
+
*/
|
|
84
|
+
export const normalizeHotspot = (
|
|
85
|
+
value: wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined,
|
|
86
|
+
): wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined =>
|
|
87
|
+
value === undefined ? undefined : { type: value.type };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* ★ EXPORTED: `wifi-broadcast-drift.ts` reuses these exact normalizers so `matches`/`driftOf` can
|
|
91
|
+
* never quietly disagree about what counts as a change (MEDIUM-4, `network-drift.ts`'s header).
|
|
92
|
+
*
|
|
93
|
+
* ⛔ NO `{...value}` SPREAD (red team, IMPORTANT-1) — `BroadcastingDeviceFilter` declares exactly
|
|
94
|
+
* `type`/`deviceIds`/`deviceTagIds` (SDK `wifi_broadcasts.ts:125-129`); enumerating exactly those
|
|
95
|
+
* three is what stops a stray wire key here, the same reason `normalizeSecurityConfiguration`
|
|
96
|
+
* (below) never spreads its input.
|
|
97
|
+
*/
|
|
98
|
+
export const normalizeDeviceFilter = (
|
|
99
|
+
value: wifiBroadcasts.BroadcastingDeviceFilter | undefined,
|
|
100
|
+
): wifiBroadcasts.BroadcastingDeviceFilter | undefined =>
|
|
101
|
+
value == null
|
|
102
|
+
? value
|
|
103
|
+
: {
|
|
104
|
+
type: value.type,
|
|
105
|
+
...(value.deviceIds !== undefined ? { deviceIds: sortedStrings(value.deviceIds) } : {}),
|
|
106
|
+
...(value.deviceTagIds !== undefined
|
|
107
|
+
? { deviceTagIds: sortedStrings(value.deviceTagIds) }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const normalizeFrequencies = (value: readonly number[] | undefined): number[] | undefined =>
|
|
112
|
+
value === undefined ? value : sortedFrequencies(value);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* ⛔ NO `{...value}` SPREAD, UNLIKE EVERY OTHER NORMALIZER IN THIS DIRECTORY (`network-form.ts`'s
|
|
116
|
+
* `normalizeDhcpGuarding`/`normalizeIpv6Configuration` both spread). This is the one field in
|
|
117
|
+
* the whole `Unifi.*` tree with a spec-confirmed passphrase risk (T23): nothing in
|
|
118
|
+
* `@distilled.cloud/core`'s wire key-mapping strips an object key the schema doesn't declare —
|
|
119
|
+
* it only renames declared ones — so a stray extra key on the WIRE `securityConfiguration`
|
|
120
|
+
* object (a vendor bug, or a future console version) survives `getWifiBroadcastPage`'s own
|
|
121
|
+
* decode untyped, and `{...value}` would carry it straight through into `Attributes`/`Props`/the
|
|
122
|
+
* rendered declaration. Enumerating exactly the two fields
|
|
123
|
+
* `WifiSecurityConfigurationOverview` declares is what actually stops that — proved by
|
|
124
|
+
* `wifi-broadcast-secrets.test.ts`'s sentinel test, which injects exactly this kind of stray key.
|
|
125
|
+
*/
|
|
126
|
+
export const normalizeSecurityConfiguration = (
|
|
127
|
+
value: wifiBroadcasts.WifiSecurityConfigurationOverview,
|
|
128
|
+
): wifiBroadcasts.WifiSecurityConfigurationOverview => ({
|
|
129
|
+
type: value.type,
|
|
130
|
+
...(value.presharedKeyNetworkIds !== undefined
|
|
131
|
+
? { presharedKeyNetworkIds: sortedRefs(value.presharedKeyNetworkIds) }
|
|
132
|
+
: {}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` ON PURPOSE — `exactOptionalPropertyTypes`; see
|
|
137
|
+
* `network-form.ts`'s own header. `attributesOf`/`declareWifiBroadcast` echo a live optional
|
|
138
|
+
* field structurally rather than defaulting it, so the target type must admit an explicit
|
|
139
|
+
* `undefined`.
|
|
140
|
+
*/
|
|
141
|
+
export interface WifiBroadcastProps {
|
|
142
|
+
siteId: string;
|
|
143
|
+
wifiBroadcastId: string;
|
|
144
|
+
name: string;
|
|
145
|
+
enabled: boolean;
|
|
146
|
+
type: string;
|
|
147
|
+
network?: wifiBroadcasts.WifiNetworkReference | undefined;
|
|
148
|
+
broadcastingDeviceFilter?: wifiBroadcasts.BroadcastingDeviceFilter | undefined;
|
|
149
|
+
broadcastingFrequenciesGHz?: number[] | undefined;
|
|
150
|
+
hotspotConfiguration?: wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined;
|
|
151
|
+
/** ⛔ T23: the PAGE OVERVIEW's security shape ONLY — see this file's header. */
|
|
152
|
+
securityConfiguration: wifiBroadcasts.WifiSecurityConfigurationOverview;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface WifiBroadcastAttributes {
|
|
156
|
+
siteId: string;
|
|
157
|
+
wifiBroadcastId: string;
|
|
158
|
+
name: string;
|
|
159
|
+
enabled: boolean;
|
|
160
|
+
type: string;
|
|
161
|
+
network: wifiBroadcasts.WifiNetworkReference | undefined;
|
|
162
|
+
broadcastingDeviceFilter: wifiBroadcasts.BroadcastingDeviceFilter | undefined;
|
|
163
|
+
broadcastingFrequenciesGHz: number[] | undefined;
|
|
164
|
+
hotspotConfiguration: wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined;
|
|
165
|
+
securityConfiguration: wifiBroadcasts.WifiSecurityConfigurationOverview;
|
|
166
|
+
/** `metadata.origin` — server-derived, never declared; same split as every other family. */
|
|
167
|
+
metadataOrigin: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const attributesOf = (
|
|
171
|
+
live: wifiBroadcasts.WifiBroadcastOverview,
|
|
172
|
+
props: WifiBroadcastProps,
|
|
173
|
+
): WifiBroadcastAttributes => ({
|
|
174
|
+
siteId: props.siteId,
|
|
175
|
+
wifiBroadcastId: live.id,
|
|
176
|
+
name: live.name,
|
|
177
|
+
enabled: live.enabled,
|
|
178
|
+
type: live.type,
|
|
179
|
+
network: normalizeRef(live.network),
|
|
180
|
+
broadcastingDeviceFilter: normalizeDeviceFilter(live.broadcastingDeviceFilter),
|
|
181
|
+
broadcastingFrequenciesGHz: normalizeFrequencies(live.broadcastingFrequenciesGHz),
|
|
182
|
+
hotspotConfiguration: normalizeHotspot(live.hotspotConfiguration),
|
|
183
|
+
securityConfiguration: normalizeSecurityConfiguration(live.securityConfiguration),
|
|
184
|
+
metadataOrigin: live.metadata.origin,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The declaration renderer (task spec: "given one live object, return the props a declaration
|
|
189
|
+
* needs so its plan is noop"). A later import script feeds `getWifiBroadcastPage`'s own per-row
|
|
190
|
+
* response straight in and writes the result into `alchemy.run.ts` as `wifiBroadcast('<id>',
|
|
191
|
+
* declareWifiBroadcast(live, siteId))` — no field is invented or defaulted here beyond what
|
|
192
|
+
* `attributesOf` already reports, so `matches(attributesOf(live, props), declareWifiBroadcast(live,
|
|
193
|
+
* siteId))` is `true` by construction (`wifi-broadcast.test.ts` proves it) for any live object
|
|
194
|
+
* this SDK can decode. Builds ONLY from fields `WifiBroadcastOverview` has — never a passphrase,
|
|
195
|
+
* which that type cannot express (see this file's header).
|
|
196
|
+
*/
|
|
197
|
+
export const declareWifiBroadcast = (
|
|
198
|
+
live: wifiBroadcasts.WifiBroadcastOverview,
|
|
199
|
+
siteId: string,
|
|
200
|
+
): WifiBroadcastProps => ({
|
|
201
|
+
siteId,
|
|
202
|
+
wifiBroadcastId: live.id,
|
|
203
|
+
name: live.name,
|
|
204
|
+
enabled: live.enabled,
|
|
205
|
+
type: live.type,
|
|
206
|
+
network: normalizeRef(live.network),
|
|
207
|
+
broadcastingDeviceFilter: normalizeDeviceFilter(live.broadcastingDeviceFilter),
|
|
208
|
+
broadcastingFrequenciesGHz: normalizeFrequencies(live.broadcastingFrequenciesGHz),
|
|
209
|
+
hotspotConfiguration: normalizeHotspot(live.hotspotConfiguration),
|
|
210
|
+
securityConfiguration: normalizeSecurityConfiguration(live.securityConfiguration),
|
|
211
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T23 runtime proof for `Unifi.WifiBroadcast`, split from `wifi-broadcast.test.ts` for the same
|
|
3
|
+
* reason `errors-and-secrets.test.ts` is split from `network.test.ts`: keeps the security-critical
|
|
4
|
+
* assertions in one file a reviewer can find by name, under the house line cap.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY THE SENTINEL IS INJECTED INTO `securityConfiguration` DIRECTLY ON THE WIRE, NOT VIA A
|
|
7
|
+
* `WifiBroadcastOverview` TYPE. `WifiSecurityConfigurationOverview` has no `passphrase` field —
|
|
8
|
+
* that is the whole point of `wifi-broadcast-form.ts`'s design (see its header) — so there is no
|
|
9
|
+
* TYPED way to construct a fixture that carries one. The raw JSON fixture below simulates the
|
|
10
|
+
* one thing the type system CANNOT rule out: a vendor bug or a future console version that puts
|
|
11
|
+
* a passphrase-shaped key on the overview response anyway. `errors-and-secrets.test.ts` already
|
|
12
|
+
* proves the SDK's OWN details-call schema redacts a real `passphrase` field to `Redacted`; this
|
|
13
|
+
* file proves the different, narrower claim the overview path needs: even an UNDECLARED key with
|
|
14
|
+
* that name surviving decode never reaches this family's attributes, declaration or a forced
|
|
15
|
+
* error path.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import { fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
20
|
+
import { attributesOf } from './wifi-broadcast-form.ts';
|
|
21
|
+
import { type WifiBroadcastProps, declareWifiBroadcast, spec } from './wifi-broadcast.ts';
|
|
22
|
+
|
|
23
|
+
const PAGE_PATH = '/proxy/network/integration/v1/sites/site-1/wifi/broadcasts';
|
|
24
|
+
|
|
25
|
+
// A fabricated sentinel, not a real WiFi passphrase (kit is PUBLIC — see fake-unifi.ts).
|
|
26
|
+
const SENTINEL = 'sentinel-wpa-passphrase-do-not-log-me';
|
|
27
|
+
|
|
28
|
+
const PROPS: WifiBroadcastProps = {
|
|
29
|
+
siteId: 'site-1',
|
|
30
|
+
wifiBroadcastId: 'wifi-1',
|
|
31
|
+
name: 'Guest',
|
|
32
|
+
enabled: true,
|
|
33
|
+
type: 'STANDARD',
|
|
34
|
+
securityConfiguration: { type: 'WPA_PERSONAL' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One base row per nested location a stray key could hide in — red team, IMPORTANT-1 (2026-09-26):
|
|
39
|
+
* the original version of this file only planted the sentinel on `securityConfiguration` itself
|
|
40
|
+
* and missed that `sortedRefs`/`network`/`hotspotConfiguration`/`broadcastingDeviceFilter` all
|
|
41
|
+
* passed their OWN object through unchanged (`network`/`hotspotConfiguration` assigned directly;
|
|
42
|
+
* `presharedKeyNetworkIds` elements returned as received; `broadcastingDeviceFilter` spread with
|
|
43
|
+
* `{...value}`). Each case below plants the SAME sentinel in a different one of those four spots —
|
|
44
|
+
* `IntegrationWifiPresharedKeyDto`'s own shape (`{network, passphrase}`, SDK `wifi_broadcasts.ts`)
|
|
45
|
+
* is exactly why a `presharedKeyNetworkIds` element is the most plausible leak site of the four.
|
|
46
|
+
*/
|
|
47
|
+
const baseRow = {
|
|
48
|
+
enabled: true,
|
|
49
|
+
id: 'wifi-1',
|
|
50
|
+
metadata: { origin: 'USER' },
|
|
51
|
+
name: 'Guest',
|
|
52
|
+
type: 'STANDARD',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const NESTED_SENTINEL_CASES: Record<string, unknown> = {
|
|
56
|
+
'top-level securityConfiguration': {
|
|
57
|
+
...baseRow,
|
|
58
|
+
securityConfiguration: {
|
|
59
|
+
type: 'WPA_PERSONAL',
|
|
60
|
+
presharedKeyNetworkIds: [{ type: 'VLAN' }],
|
|
61
|
+
passphrase: SENTINEL,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
'a presharedKeyNetworkIds element (IntegrationWifiPresharedKeyDto shape)': {
|
|
65
|
+
...baseRow,
|
|
66
|
+
securityConfiguration: {
|
|
67
|
+
type: 'WPA_PERSONAL',
|
|
68
|
+
presharedKeyNetworkIds: [{ type: 'VLAN', networkId: 'net-1', passphrase: SENTINEL }],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
network: {
|
|
72
|
+
...baseRow,
|
|
73
|
+
network: { type: 'STANDARD', networkId: 'net-1', passphrase: SENTINEL },
|
|
74
|
+
securityConfiguration: { type: 'WPA_PERSONAL' },
|
|
75
|
+
},
|
|
76
|
+
hotspotConfiguration: {
|
|
77
|
+
...baseRow,
|
|
78
|
+
hotspotConfiguration: { type: 'PASSPOINT', radiusSharedSecret: SENTINEL },
|
|
79
|
+
securityConfiguration: { type: 'WPA_PERSONAL' },
|
|
80
|
+
},
|
|
81
|
+
broadcastingDeviceFilter: {
|
|
82
|
+
...baseRow,
|
|
83
|
+
broadcastingDeviceFilter: {
|
|
84
|
+
type: 'CUSTOM',
|
|
85
|
+
deviceIds: ['dev-1'],
|
|
86
|
+
radiusSharedSecret: SENTINEL,
|
|
87
|
+
},
|
|
88
|
+
securityConfiguration: { type: 'WPA_PERSONAL' },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
describe('T23 — a stray passphrase-shaped key on the wire never survives fetch -> attrs -> declare -> render', () => {
|
|
93
|
+
for (const [label, rawRow] of Object.entries(NESTED_SENTINEL_CASES)) {
|
|
94
|
+
test(`sentinel-passphrase test: ${label}`, async () => {
|
|
95
|
+
const fake = fakeUnifi((method, url) =>
|
|
96
|
+
method === 'GET' && url.pathname === PAGE_PATH
|
|
97
|
+
? Response.json({ count: 1, data: [rawRow], limit: 1, offset: 0, totalCount: 1 })
|
|
98
|
+
: new Response('unexpected request', { status: 400 }),
|
|
99
|
+
);
|
|
100
|
+
const live = await Effect.runPromise(
|
|
101
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// ⚠️ FETCH: documents the real risk, rather than assuming it away — nothing in
|
|
105
|
+
// `@distilled.cloud/core`'s wire key-mapping strips a key the schema does not declare (see
|
|
106
|
+
// `wifi-broadcast-form.ts`'s header). If this assertion ever starts failing because a future
|
|
107
|
+
// core/SDK regen DOES start stripping unknown keys, that is a welcome, stronger guarantee —
|
|
108
|
+
// not a reason this test needs to keep asserting the weaker one.
|
|
109
|
+
expect(JSON.stringify(live)).toContain(SENTINEL);
|
|
110
|
+
if (live === undefined) throw new Error('expected the fake page to decode a row');
|
|
111
|
+
|
|
112
|
+
// ATTRS: `attributesOf` rebuilds every nested object field by field (never `{...live}`,
|
|
113
|
+
// never assigning a nested object through unchanged).
|
|
114
|
+
const attrs = attributesOf(live, PROPS);
|
|
115
|
+
expect(JSON.stringify(attrs)).not.toContain(SENTINEL);
|
|
116
|
+
|
|
117
|
+
// DECLARE: `declareWifiBroadcast` is the same discipline, independently.
|
|
118
|
+
const declared = declareWifiBroadcast(live, 'site-1');
|
|
119
|
+
expect(JSON.stringify(declared)).not.toContain(SENTINEL);
|
|
120
|
+
|
|
121
|
+
// RENDER: the literal text a later import script would write into `alchemy.run.ts`.
|
|
122
|
+
const rendered = `wifiBroadcast(${JSON.stringify('wifi-1')}, ${JSON.stringify(declared)})`;
|
|
123
|
+
expect(rendered).not.toContain(SENTINEL);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test('forced decode-failure test: a malformed page body fails typed, without echoing the sentinel', async () => {
|
|
128
|
+
// A non-JSON 200 body (a genuine decode failure, not a mapped HTTP error) that happens to
|
|
129
|
+
// CONTAIN a sentinel value, the way a garbled proxy/debug response might. `getWifiBroadcastPage`
|
|
130
|
+
// has no schema to reject this against (see `wifi-broadcast-form.ts`'s header) — it decodes as
|
|
131
|
+
// the raw string, which fails `pageAll`'s own OWN consistency check instead (`paginate.ts`):
|
|
132
|
+
// the fake "page" has no `.offset`, so the walk cannot proceed and refuses to trust it.
|
|
133
|
+
const fake = fakeUnifi(() => new Response(`malformed body ${SENTINEL}`, { status: 200 }));
|
|
134
|
+
const failure = await Effect.runPromise(
|
|
135
|
+
Effect.flip(spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(failure._tag).toBe('UnifiPaginationInconsistent');
|
|
139
|
+
// The error's OWN fields (`reason`/`detail`) are built entirely from offsets and counts
|
|
140
|
+
// (`paginate.ts`) — never from the raw response body — so neither they nor the `.message`
|
|
141
|
+
// getter built from them can ever echo the sentinel, regardless of what garbled text arrived.
|
|
142
|
+
expect(JSON.stringify(failure)).not.toContain(SENTINEL);
|
|
143
|
+
expect((failure as { message: string }).message).not.toContain(SENTINEL);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.WifiBroadcast`'s `spec` against a fake UniFi Network API, proving the real distilled wire
|
|
3
|
+
* path THROUGH `pageAll` (path assembly, JSON decode, `NotFound` folding, the pagination walk this
|
|
4
|
+
* family is the first resource to actually need), the declaration renderer, and — the task's own
|
|
5
|
+
* requirement — that no handler this family exposes ever sends a non-`GET` request.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ THE PASSPHRASE-SPECIFIC PROOFS (T23 sentinel + forced-decode-failure) LIVE IN
|
|
8
|
+
* `wifi-broadcast-secrets.test.ts`, SPLIT OUT — same reason `errors-and-secrets.test.ts` is its
|
|
9
|
+
* own file rather than folded into `network.test.ts`: keeps this file under the house line cap
|
|
10
|
+
* and keeps the security-critical assertions in one place a reviewer can find by name.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import type * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
|
|
14
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
17
|
+
import { attributesOf } from './wifi-broadcast-form.ts';
|
|
18
|
+
import { matches } from './wifi-broadcast-drift.ts';
|
|
19
|
+
import { type WifiBroadcastProps, declareWifiBroadcast, spec } from './wifi-broadcast.ts';
|
|
20
|
+
import { unifiOperations } from './resource.ts';
|
|
21
|
+
|
|
22
|
+
const PAGE_PATH = '/proxy/network/integration/v1/sites/site-1/wifi/broadcasts';
|
|
23
|
+
|
|
24
|
+
const liveOverview = (
|
|
25
|
+
overrides: Partial<wifiBroadcasts.WifiBroadcastOverview> = {},
|
|
26
|
+
): wifiBroadcasts.WifiBroadcastOverview => ({
|
|
27
|
+
enabled: true,
|
|
28
|
+
id: 'wifi-1',
|
|
29
|
+
metadata: { origin: 'USER' },
|
|
30
|
+
name: 'Guest',
|
|
31
|
+
type: 'STANDARD',
|
|
32
|
+
securityConfiguration: { type: 'WPA_PERSONAL', presharedKeyNetworkIds: [{ type: 'VLAN' }] },
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const PROPS: WifiBroadcastProps = {
|
|
37
|
+
siteId: 'site-1',
|
|
38
|
+
wifiBroadcastId: 'wifi-1',
|
|
39
|
+
name: 'Guest',
|
|
40
|
+
enabled: true,
|
|
41
|
+
type: 'STANDARD',
|
|
42
|
+
securityConfiguration: { type: 'WPA_PERSONAL', presharedKeyNetworkIds: [{ type: 'VLAN' }] },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** One page's worth of the shared list envelope every UniFi list op answers. `total` is the
|
|
46
|
+
* FULL row count across every page, never just this page's own `rows.length` -- pageAll stops
|
|
47
|
+
* once `offset` reaches it (`paginate.ts`), so getting this wrong silently truncates the walk. */
|
|
48
|
+
const pageResponse = (
|
|
49
|
+
rows: readonly wifiBroadcasts.WifiBroadcastOverview[],
|
|
50
|
+
offset: number,
|
|
51
|
+
total = rows.length,
|
|
52
|
+
) =>
|
|
53
|
+
Response.json({
|
|
54
|
+
count: rows.length,
|
|
55
|
+
data: rows,
|
|
56
|
+
limit: rows.length || 1,
|
|
57
|
+
offset,
|
|
58
|
+
totalCount: total,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Unifi.WifiBroadcast spec.fetchLive', () => {
|
|
62
|
+
test('a single-page GET response decodes and finds the matching row', async () => {
|
|
63
|
+
const fake = fakeUnifi((method, url) =>
|
|
64
|
+
method === 'GET' && url.pathname === PAGE_PATH
|
|
65
|
+
? pageResponse([liveOverview()], 0)
|
|
66
|
+
: fakeFailure(400, 'unexpected request'),
|
|
67
|
+
);
|
|
68
|
+
const live = await Effect.runPromise(
|
|
69
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
70
|
+
);
|
|
71
|
+
expect(live?.id).toBe('wifi-1');
|
|
72
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: `${PAGE_PATH}?offset=0` }]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the row can be on a LATER page -- pageAll actually walks (T9/B0b)', async () => {
|
|
76
|
+
const rows = [liveOverview({ id: 'wifi-0', name: 'Other' }), liveOverview({ id: 'wifi-1' })];
|
|
77
|
+
const fake = fakeUnifi((method, url) => {
|
|
78
|
+
if (method !== 'GET' || url.pathname !== PAGE_PATH) return fakeFailure(400, 'unexpected');
|
|
79
|
+
const offset = Number(url.searchParams.get('offset') ?? '0');
|
|
80
|
+
return pageResponse(rows.slice(offset, offset + 1), offset, rows.length);
|
|
81
|
+
});
|
|
82
|
+
const live = await Effect.runPromise(
|
|
83
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
84
|
+
);
|
|
85
|
+
expect(live?.id).toBe('wifi-1');
|
|
86
|
+
expect(fake.seen.length).toBeGreaterThan(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('present rows but no matching id folds to undefined -- no separate "not found" signal', async () => {
|
|
90
|
+
const fake = fakeUnifi(() => pageResponse([liveOverview({ id: 'someone-else' })], 0));
|
|
91
|
+
const live = await Effect.runPromise(
|
|
92
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
93
|
+
);
|
|
94
|
+
expect(live).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a genuine 404 (site not found) folds to undefined', async () => {
|
|
98
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
99
|
+
const live = await Effect.runPromise(
|
|
100
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
101
|
+
);
|
|
102
|
+
expect(live).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a 500 fails loudly -- only a genuine not-found may mean absent', async () => {
|
|
106
|
+
// ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
|
|
107
|
+
// asserts the FAILURE, not the retry schedule (same reason `network.test.ts`'s 500 test needs it).
|
|
108
|
+
const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
|
|
109
|
+
const failure = await Effect.runPromise(
|
|
110
|
+
Effect.flip(
|
|
111
|
+
spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
expect(failure._tag).toBe('InternalServerError');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('declareWifiBroadcast -- the declaration renderer', () => {
|
|
119
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
120
|
+
const live = liveOverview({ name: 'Employees', enabled: false });
|
|
121
|
+
const declared = declareWifiBroadcast(live, 'site-1');
|
|
122
|
+
const attrs = attributesOf(live, declared);
|
|
123
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('Unifi.WifiBroadcast write paths never reach the vendor API', () => {
|
|
128
|
+
const ops = unifiOperations(spec);
|
|
129
|
+
|
|
130
|
+
test('reconcile on an exact-match adoption (H6) sends only GETs', async () => {
|
|
131
|
+
const fake = fakeUnifi((method, url) =>
|
|
132
|
+
method === 'GET' && url.pathname === PAGE_PATH
|
|
133
|
+
? pageResponse([liveOverview()], 0)
|
|
134
|
+
: fakeFailure(400, 'unexpected request'),
|
|
135
|
+
);
|
|
136
|
+
const exit = await Effect.runPromiseExit(
|
|
137
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
138
|
+
);
|
|
139
|
+
expect(exit._tag).toBe('Success');
|
|
140
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
144
|
+
const fake = fakeUnifi((method, url) =>
|
|
145
|
+
method === 'GET' && url.pathname === PAGE_PATH
|
|
146
|
+
? pageResponse([liveOverview()], 0)
|
|
147
|
+
: fakeFailure(400, 'unexpected request'),
|
|
148
|
+
);
|
|
149
|
+
const exit = await Effect.runPromiseExit(
|
|
150
|
+
ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
151
|
+
);
|
|
152
|
+
expect(exit._tag).toBe('Failure');
|
|
153
|
+
expect(fake.seen).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.WifiBroadcast` — one WiFi network (SSID) broadcast on a UniFi Network site, READ-ONLY
|
|
3
|
+
* (`policy.ts`).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ T23, THE REASON THIS FAMILY NEVER CALLS `getWifiBroadcastDetails`. That single-object GET
|
|
6
|
+
* returns `WifiBroadcastDetails`, whose `securityConfiguration`
|
|
7
|
+
* (`WifiSecurityConfigurationDetailObject`) carries the WPA passphrase AND every PPSK entry's
|
|
8
|
+
* passphrase — the pinned spec has NO `writeOnly` flag on either (verified: 4 `passphrase`
|
|
9
|
+
* props, 0 `writeOnly`; A2 already redacts them to `Redacted.Redacted<string>` at the SDK layer,
|
|
10
|
+
* `errors-and-secrets.test.ts`). This family goes further and never fetches that shape AT ALL:
|
|
11
|
+
* the only call below is `getWifiBroadcastPage`, which answers `WifiBroadcastOverview` instead —
|
|
12
|
+
* whose own `securityConfiguration` (`WifiSecurityConfigurationOverview`) is `{type,
|
|
13
|
+
* presharedKeyNetworkIds}`. No passphrase field exists in that type, so `WifiBroadcastProps`/
|
|
14
|
+
* `Attributes` (`wifi-broadcast-form.ts`) cannot carry one even by a future mistake.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ NO GET-BY-ID CALL EXISTS FOR THE OVERVIEW SHAPE — unlike every other family in this
|
|
17
|
+
* directory (`getNetworkDetails`, `getFirewallZone`, `getDnsPolicy`, `getAclRule`),
|
|
18
|
+
* `wifi_broadcasts.ts` only has `getWifiBroadcastPage` (list) and `getWifiBroadcastDetails`
|
|
19
|
+
* (single, passphrase-bearing — forbidden above). `fetchLive` below walks every page with B0b's
|
|
20
|
+
* `pageAll` and finds the one row matching `wifiBroadcastId` — the first real resource-level
|
|
21
|
+
* consumer of that pager (previously exercised only by `paginate.test.ts` in isolation).
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ THE `NotFound` CATCH BELOW IS FOR THE WHOLE WALK, NOT JUST THE FIRST PAGE CALL (red team,
|
|
24
|
+
* MINOR-4, 2026-09-26 — corrected from an earlier claim that it only applied to the first call).
|
|
25
|
+
* `pageAll` makes its sequential page calls inside one Effect pipeline, and `catchTag` here wraps
|
|
26
|
+
* that entire pipeline; a 404 on ANY page of the walk — not only the first — folds to `undefined`
|
|
27
|
+
* and discards whatever rows the walk had already collected. In practice this means "the site
|
|
28
|
+
* vanished partway through listing its broadcasts" is indistinguishable from "the site was never
|
|
29
|
+
* there" and from "the target id just isn't among the collected rows" — none of these three cases
|
|
30
|
+
* has a separate vendor signal, and no other family's `fetchLive` distinguishes its own analogous
|
|
31
|
+
* cases either. Read-only reporting treats all three the same conservative way (absent, not an
|
|
32
|
+
* error); a write path would need to tell them apart.
|
|
33
|
+
*/
|
|
34
|
+
import { Resource } from 'alchemy';
|
|
35
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
36
|
+
import * as Provider from 'alchemy/Provider';
|
|
37
|
+
import * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
|
|
38
|
+
import * as Effect from 'effect/Effect';
|
|
39
|
+
import {
|
|
40
|
+
type WifiBroadcastAttributes,
|
|
41
|
+
type WifiBroadcastProps,
|
|
42
|
+
attributesOf,
|
|
43
|
+
} from './wifi-broadcast-form.ts';
|
|
44
|
+
import { matches } from './wifi-broadcast-drift.ts';
|
|
45
|
+
import { type UnifiPaginationInconsistent, pageAll } from './paginate.ts';
|
|
46
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
47
|
+
import { unifiHandlers } from './resource.ts';
|
|
48
|
+
|
|
49
|
+
export type { WifiBroadcastAttributes, WifiBroadcastProps } from './wifi-broadcast-form.ts';
|
|
50
|
+
export { declareWifiBroadcast } from './wifi-broadcast-form.ts';
|
|
51
|
+
export { driftOf } from './wifi-broadcast-drift.ts';
|
|
52
|
+
|
|
53
|
+
export interface UnifiWifiBroadcast extends Resource<
|
|
54
|
+
'Unifi.WifiBroadcast',
|
|
55
|
+
WifiBroadcastProps,
|
|
56
|
+
WifiBroadcastAttributes,
|
|
57
|
+
never,
|
|
58
|
+
UnifiRequirements
|
|
59
|
+
> {}
|
|
60
|
+
|
|
61
|
+
export const UnifiWifiBroadcast = Resource<UnifiWifiBroadcast>('Unifi.WifiBroadcast', {
|
|
62
|
+
defaultRemovalPolicy: 'retain',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** `wifiBroadcast('guest', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
|
|
66
|
+
export const wifiBroadcast = (id: string, props: WifiBroadcastProps) =>
|
|
67
|
+
UnifiWifiBroadcast(id, props).pipe(adopt(true));
|
|
68
|
+
|
|
69
|
+
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
70
|
+
export const spec: UnifiSpec<
|
|
71
|
+
WifiBroadcastProps,
|
|
72
|
+
wifiBroadcasts.WifiBroadcastOverview,
|
|
73
|
+
WifiBroadcastAttributes,
|
|
74
|
+
wifiBroadcasts.GetWifiBroadcastPageError | UnifiPaginationInconsistent
|
|
75
|
+
> = {
|
|
76
|
+
type: 'Unifi.WifiBroadcast',
|
|
77
|
+
describe: (props) => `sites/${props.siteId}/wifi/broadcasts/${props.wifiBroadcastId}`,
|
|
78
|
+
fetchLive: (props) =>
|
|
79
|
+
pageAll(wifiBroadcasts.getWifiBroadcastPage, { siteId: props.siteId }).pipe(
|
|
80
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
81
|
+
Effect.map((rows) => rows?.find((row) => row.id === props.wifiBroadcastId)),
|
|
82
|
+
),
|
|
83
|
+
attributes: attributesOf,
|
|
84
|
+
matches,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const handlers = unifiHandlers(spec);
|
|
88
|
+
|
|
89
|
+
export const UnifiWifiBroadcastProvider = () =>
|
|
90
|
+
Provider.effect(UnifiWifiBroadcast, Effect.succeed(UnifiWifiBroadcast.Provider.of(handlers)));
|