@homeflare/alchemy 0.38.0 → 0.40.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 +32 -8
- 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 +1609 -0
- package/dist/ceph/index.js.map +38 -0
- 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/index.d.ts +1 -0
- package/dist/index.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 +574 -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 +27 -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/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/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/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/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-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/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 +11 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +725 -60
- package/dist/unifi/index.js.map +31 -9
- 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 +24 -0
- package/dist/unifi/paginate.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +36 -1
- package/dist/unifi/resource.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/telemetry-spike.md +94 -0
- package/docs/telemetry.md +109 -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 +64 -3
- package/package.json +10 -2
- 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/forgejo/team-member.test.ts +14 -0
- package/src/forgejo/team-member.ts +28 -1
- package/src/index.ts +1 -0
- 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 +229 -0
- package/src/proxmox/qemu-volume.ts +178 -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/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/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/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/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-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/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 +78 -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 +252 -0
- package/src/unifi/paginate.ts +141 -0
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/resource.ts +73 -3
- 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 +190 -0
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
* `Unifi.Network`'s `spec` against a fake UniFi Network API, proving the real distilled wire
|
|
3
3
|
* path (path assembly, JSON decode, `NotFound` folding), the declaration renderer, and — the
|
|
4
4
|
* task's own requirement — that no handler this family exposes ever sends a non-`GET` request.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ `driftOf`/`matches` coverage (the DHCP/IPv6 unordered-array normalizers, the MEDIUM-4
|
|
7
|
+
* mutant-closing tests) lives in `network-drift.test.ts`, split out to keep this file under the
|
|
8
|
+
* house 250-line cap once that coverage grew — same split `network-drift.ts` itself is from
|
|
9
|
+
* `network-form.ts`, not a different family.
|
|
5
10
|
*/
|
|
6
11
|
import { describe, expect, test } from 'bun:test';
|
|
7
12
|
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
8
13
|
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
9
14
|
import * as Effect from 'effect/Effect';
|
|
10
15
|
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
11
|
-
import { attributesOf
|
|
16
|
+
import { attributesOf } from './network-form.ts';
|
|
17
|
+
import { matches } from './network-drift.ts';
|
|
12
18
|
import { type NetworkProps, declareNetwork, spec } from './network.ts';
|
|
13
19
|
import { unifiOperations } from './resource.ts';
|
|
14
20
|
|
|
@@ -84,87 +90,6 @@ describe('declareNetwork -- the declaration renderer', () => {
|
|
|
84
90
|
});
|
|
85
91
|
});
|
|
86
92
|
|
|
87
|
-
describe('matches -- unordered arrays (dhcpGuarding, ipv6Configuration)', () => {
|
|
88
|
-
// ⛔ `deepEqual` (alchemy/Diff) sorts object keys but not array elements: without normalizing
|
|
89
|
-
// these three fields (see network-form.ts's header), a console returning the same set in a
|
|
90
|
-
// different order would plan a spurious update.
|
|
91
|
-
test('the same trusted DHCP servers in a different order is a noop', () => {
|
|
92
|
-
const live = liveNetwork({
|
|
93
|
-
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.2', '10.0.0.1'] },
|
|
94
|
-
});
|
|
95
|
-
const props: NetworkProps = {
|
|
96
|
-
...PROPS,
|
|
97
|
-
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
|
|
98
|
-
};
|
|
99
|
-
expect(matches(attributesOf(live, props), props)).toBe(true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('a different set of trusted DHCP servers is an update', () => {
|
|
103
|
-
const live = liveNetwork({
|
|
104
|
-
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
|
|
105
|
-
});
|
|
106
|
-
const props: NetworkProps = {
|
|
107
|
-
...PROPS,
|
|
108
|
-
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.3'] },
|
|
109
|
-
};
|
|
110
|
-
expect(matches(attributesOf(live, props), props)).toBe(false);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('the same IPv6 host subnets and DNS overrides in a different order is a noop', () => {
|
|
114
|
-
const live = liveNetwork({
|
|
115
|
-
ipv6Configuration: {
|
|
116
|
-
clientAddressAssignment: { slaacEnabled: true },
|
|
117
|
-
interfaceType: 'ETHERNET',
|
|
118
|
-
additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
|
|
119
|
-
dnsServerIpAddressesOverride: ['2001:4860:4860::8888', '2001:4860:4860::8844'],
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
const props: NetworkProps = {
|
|
123
|
-
...PROPS,
|
|
124
|
-
ipv6Configuration: {
|
|
125
|
-
clientAddressAssignment: { slaacEnabled: true },
|
|
126
|
-
interfaceType: 'ETHERNET',
|
|
127
|
-
additionalHostIpSubnets: ['2001:db8:1::/64', '2001:db8::/64'],
|
|
128
|
-
dnsServerIpAddressesOverride: ['2001:4860:4860::8844', '2001:4860:4860::8888'],
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
expect(matches(attributesOf(live, props), props)).toBe(true);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('a different set of IPv6 host subnets is an update', () => {
|
|
135
|
-
const live = liveNetwork({
|
|
136
|
-
ipv6Configuration: {
|
|
137
|
-
clientAddressAssignment: { slaacEnabled: true },
|
|
138
|
-
interfaceType: 'ETHERNET',
|
|
139
|
-
additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
|
|
140
|
-
},
|
|
141
|
-
});
|
|
142
|
-
const props: NetworkProps = {
|
|
143
|
-
...PROPS,
|
|
144
|
-
ipv6Configuration: {
|
|
145
|
-
clientAddressAssignment: { slaacEnabled: true },
|
|
146
|
-
interfaceType: 'ETHERNET',
|
|
147
|
-
additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:2::/64'],
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
expect(matches(attributesOf(live, props), props)).toBe(false);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('a live `null` (not `undefined`) for either field does not throw', () => {
|
|
154
|
-
// UniFi's JSON answers `null` for an unset optional field (see network-form.ts's header) —
|
|
155
|
-
// a reality the SDK's `T | undefined` types don't model. The double cast lands `null` at
|
|
156
|
-
// runtime the same way a real JSON decode would, past what the type system alone allows.
|
|
157
|
-
const live = {
|
|
158
|
-
...liveNetwork(),
|
|
159
|
-
dhcpGuarding: null,
|
|
160
|
-
ipv6Configuration: null,
|
|
161
|
-
} as unknown as networks.NetworkDetails;
|
|
162
|
-
const declared = declareNetwork(live, 'site-1');
|
|
163
|
-
expect(() => attributesOf(live, declared)).not.toThrow();
|
|
164
|
-
expect(matches(attributesOf(live, declared), declared)).toBe(true);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
93
|
describe('Unifi.Network write paths never reach the vendor API', () => {
|
|
169
94
|
const ops = unifiOperations(spec);
|
|
170
95
|
|
package/src/unifi/network.ts
CHANGED
|
@@ -23,17 +23,17 @@ import { adopt } from 'alchemy/AdoptPolicy';
|
|
|
23
23
|
import * as Provider from 'alchemy/Provider';
|
|
24
24
|
import * as networks from '@distilled.cloud/unifi-network/networks';
|
|
25
25
|
import * as Effect from 'effect/Effect';
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
type NetworkProps,
|
|
29
|
-
attributesOf,
|
|
30
|
-
matches,
|
|
31
|
-
} from './network-form.ts';
|
|
26
|
+
import { type NetworkAttributes, type NetworkProps, attributesOf } from './network-form.ts';
|
|
27
|
+
import { matches } from './network-drift.ts';
|
|
32
28
|
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
33
29
|
import { unifiHandlers } from './resource.ts';
|
|
34
30
|
|
|
35
31
|
export type { NetworkAttributes, NetworkProps } from './network-form.ts';
|
|
36
32
|
export { declareNetwork } from './network-form.ts';
|
|
33
|
+
// MEDIUM-4: `matches` moved here from `network-form.ts` — see network-drift.ts's own header. Not
|
|
34
|
+
// re-exported from this barrel file either, same as before the move: `spec.matches` below is the
|
|
35
|
+
// only consumer a provider needs.
|
|
36
|
+
export { driftOf } from './network-drift.ts';
|
|
37
37
|
|
|
38
38
|
export interface UnifiNetwork extends Resource<
|
|
39
39
|
'Unifi.Network',
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pageAll` (B0b/T9) — pure walk-logic tests against a synthetic fetcher, the three IMPORTANT-3
|
|
3
|
+
* consistency checks (red team, 2026-09-26) against fetchers that lie the exact ways a real vendor
|
|
4
|
+
* response could, and one integration test proving it type-checks and decodes against a REAL SDK
|
|
5
|
+
* list operation (`getNetworksOverviewPage`) through the fake UniFi server. `M2`: this file does
|
|
6
|
+
* not repeat the 404/500 decode tests `network.test.ts` already covers — those are about ONE
|
|
7
|
+
* page's failure, not the walk this file exists to prove.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ MINOR-3 (red team, 2026-09-26): `page.offset`/`page.totalCount` are wire-controlled values
|
|
10
|
+
* `@distilled.cloud/core` never validates (`wifi-broadcast.ts`'s header documents why), so the
|
|
11
|
+
* "malformed response" test below plants a non-numeric value in each and proves `paginate.ts`'s
|
|
12
|
+
* `numOrPlaceholder` keeps it out of the error text, instead of assuming a vendor response is
|
|
13
|
+
* always well-typed just because the SDK's TS types say so.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import * as networks from '@distilled.cloud/unifi-network/networks';
|
|
18
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
19
|
+
import { type OffsetPage, UnifiPaginationInconsistent, pageAll } from './paginate.ts';
|
|
20
|
+
|
|
21
|
+
interface Row {
|
|
22
|
+
readonly id: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A fake dataset served `pageSize` rows at a time, the same envelope every real list op uses —
|
|
26
|
+
* including `offset` echoed back exactly as requested, the honest case every OTHER test in this
|
|
27
|
+
* file deviates from on purpose. */
|
|
28
|
+
const fakePages = (rows: readonly Row[], pageSize: number) => {
|
|
29
|
+
const calls: { offset: number; limit: number | undefined }[] = [];
|
|
30
|
+
const fetchPage = (request: { offset?: number; limit?: number }) => {
|
|
31
|
+
const offset = request.offset ?? 0;
|
|
32
|
+
calls.push({ offset, limit: request.limit });
|
|
33
|
+
return Effect.succeed<OffsetPage<Row>>({
|
|
34
|
+
data: rows.slice(offset, offset + pageSize),
|
|
35
|
+
offset,
|
|
36
|
+
totalCount: rows.length,
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
return { calls, fetchPage };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const flip = <A>(effect: Effect.Effect<A, UnifiPaginationInconsistent>) =>
|
|
43
|
+
Effect.runPromise(Effect.flip(effect));
|
|
44
|
+
|
|
45
|
+
describe('pageAll -- walk logic', () => {
|
|
46
|
+
test('walks every page in order and returns every row exactly once', async () => {
|
|
47
|
+
const rows = Array.from({ length: 7 }, (_, id) => ({ id }));
|
|
48
|
+
const { calls, fetchPage } = fakePages(rows, 3);
|
|
49
|
+
|
|
50
|
+
const result = await Effect.runPromise(pageAll(fetchPage, { limit: 3 }));
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual(rows);
|
|
53
|
+
expect(calls).toEqual([
|
|
54
|
+
{ offset: 0, limit: 3 },
|
|
55
|
+
{ offset: 3, limit: 3 },
|
|
56
|
+
{ offset: 6, limit: 3 },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a single page under the limit stops after one call', async () => {
|
|
61
|
+
const rows = [{ id: 1 }, { id: 2 }];
|
|
62
|
+
const { calls, fetchPage } = fakePages(rows, 10);
|
|
63
|
+
|
|
64
|
+
const result = await Effect.runPromise(pageAll(fetchPage, { limit: 10 }));
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual(rows);
|
|
67
|
+
expect(calls).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('zero rows total makes exactly one call and returns empty', async () => {
|
|
71
|
+
const { calls, fetchPage } = fakePages([], 5);
|
|
72
|
+
|
|
73
|
+
const result = await Effect.runPromise(pageAll(fetchPage, { limit: 5 }));
|
|
74
|
+
|
|
75
|
+
expect(result).toEqual([]);
|
|
76
|
+
expect(calls).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('T9: keeps paging on totalCount even when every page is short of the limit', async () => {
|
|
80
|
+
// A page shorter than `limit` is NOT by itself "last page" -- only `totalCount` is. This
|
|
81
|
+
// fetcher returns exactly one row per call (as if capped server-side, independent of `limit`)
|
|
82
|
+
// while `totalCount` says three exist; a pager that stopped on `data.length < limit` would
|
|
83
|
+
// truncate after the FIRST call and silently drop rows 2 and 3 -- the exact T9 failure mode.
|
|
84
|
+
const offsetsSeen: number[] = [];
|
|
85
|
+
const fetchPage = (request: { offset?: number; limit?: number }) => {
|
|
86
|
+
const offset = request.offset ?? 0;
|
|
87
|
+
offsetsSeen.push(offset);
|
|
88
|
+
return Effect.succeed<OffsetPage<Row>>({ data: [{ id: offset }], offset, totalCount: 3 });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const result = await Effect.runPromise(pageAll(fetchPage, { limit: 10 }));
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual([{ id: 0 }, { id: 1 }, { id: 2 }]);
|
|
94
|
+
expect(offsetsSeen).toEqual([0, 1, 2]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('resumes from a caller-given offset instead of always starting at 0', async () => {
|
|
98
|
+
const rows = Array.from({ length: 5 }, (_, id) => ({ id }));
|
|
99
|
+
const { calls, fetchPage } = fakePages(rows, 2);
|
|
100
|
+
|
|
101
|
+
const result = await Effect.runPromise(pageAll(fetchPage, { offset: 2, limit: 2 }));
|
|
102
|
+
|
|
103
|
+
expect(result).toEqual(rows.slice(2));
|
|
104
|
+
expect(calls[0]).toEqual({ offset: 2, limit: 2 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a failure from one page propagates instead of being swallowed', async () => {
|
|
108
|
+
const boom = new Error('boom');
|
|
109
|
+
const fetchPage = () => Effect.fail(boom);
|
|
110
|
+
|
|
111
|
+
const result = await Effect.runPromise(Effect.flip(pageAll(fetchPage, { limit: 5 })));
|
|
112
|
+
|
|
113
|
+
expect(result).toBe(boom);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('pageAll -- IMPORTANT-3: typed failures on a self-contradicting response', () => {
|
|
118
|
+
test('the server ignoring offset (always echoing 0) fails as offset-mismatch, not an infinite loop', async () => {
|
|
119
|
+
let calls = 0;
|
|
120
|
+
const fetchPage = () => {
|
|
121
|
+
calls += 1;
|
|
122
|
+
// Always answers page 1, regardless of the requested offset -- the "offset ignored" trap.
|
|
123
|
+
return Effect.succeed<OffsetPage<Row>>({
|
|
124
|
+
data: [{ id: 1 }, { id: 2 }],
|
|
125
|
+
offset: 0,
|
|
126
|
+
totalCount: 5,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const failure = await flip(pageAll(fetchPage, { limit: 2 }));
|
|
131
|
+
|
|
132
|
+
expect(failure).toBeInstanceOf(UnifiPaginationInconsistent);
|
|
133
|
+
expect(failure.reason).toBe('offset-mismatch');
|
|
134
|
+
// The FIRST page (offset 0 requested, 0 echoed) is fine; the SECOND (offset 2 requested, 0
|
|
135
|
+
// echoed) is what trips it -- proving this stops the walk instead of cycling forever.
|
|
136
|
+
expect(calls).toBe(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('totalCount drifting between calls fails as total-count-changed', async () => {
|
|
140
|
+
let call = 0;
|
|
141
|
+
const fetchPage = (request: { offset?: number; limit?: number }) => {
|
|
142
|
+
call += 1;
|
|
143
|
+
const offset = request.offset ?? 0;
|
|
144
|
+
// A row is removed after the first page is read -- totalCount drops from 3 to 2.
|
|
145
|
+
return Effect.succeed<OffsetPage<Row>>({
|
|
146
|
+
data: [{ id: offset }],
|
|
147
|
+
offset,
|
|
148
|
+
totalCount: call === 1 ? 3 : 2,
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const failure = await flip(pageAll(fetchPage, { limit: 1 }));
|
|
153
|
+
|
|
154
|
+
expect(failure.reason).toBe('total-count-changed');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('an undercounted totalCount fails loudly instead of silently truncating (T9)', async () => {
|
|
158
|
+
// The exact scenario named in review: totalCount says 0 but the vendor still returns real
|
|
159
|
+
// rows on the one page fetched before the loop (correctly) stops on totalCount -- the OLD
|
|
160
|
+
// behavior returned those 2 rows as if they were the complete, correct answer.
|
|
161
|
+
const fetchPage = () =>
|
|
162
|
+
Effect.succeed<OffsetPage<Row>>({ data: [{ id: 1 }, { id: 2 }], offset: 0, totalCount: 0 });
|
|
163
|
+
|
|
164
|
+
const failure = await flip(pageAll(fetchPage, { limit: 10 }));
|
|
165
|
+
|
|
166
|
+
expect(failure.reason).toBe('row-count-mismatch');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a walk that never reaches totalCount fails at the page ceiling instead of looping forever', async () => {
|
|
170
|
+
// Offset IS honored (so this never trips offset-mismatch first) but totalCount is inflated far
|
|
171
|
+
// past what MAX_PAGES worth of single-row pages could ever reach -- the walk is internally
|
|
172
|
+
// consistent page-to-page, just never converges, which only the ceiling can catch.
|
|
173
|
+
const fetchPage = (request: { offset?: number; limit?: number }) =>
|
|
174
|
+
Effect.succeed<OffsetPage<Row>>({
|
|
175
|
+
data: [{ id: request.offset ?? 0 }],
|
|
176
|
+
offset: request.offset ?? 0,
|
|
177
|
+
totalCount: 1_000_000,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const failure = await flip(pageAll(fetchPage, { limit: 1 }));
|
|
181
|
+
|
|
182
|
+
expect(failure.reason).toBe('page-ceiling');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('a non-numeric offset/totalCount from a malformed response never appears in the error text (MINOR-3)', async () => {
|
|
186
|
+
const SENTINEL = 'sentinel-do-not-log-me';
|
|
187
|
+
// Cast, not a typo: the SDK's TS types say `offset`/`totalCount` are numbers, but nothing
|
|
188
|
+
// validates that at runtime -- this is exactly the shape a genuinely malformed page could take.
|
|
189
|
+
const fetchPage = () =>
|
|
190
|
+
Effect.succeed({
|
|
191
|
+
data: [],
|
|
192
|
+
offset: SENTINEL,
|
|
193
|
+
totalCount: SENTINEL,
|
|
194
|
+
} as unknown as OffsetPage<Row>);
|
|
195
|
+
|
|
196
|
+
const failure = await flip(pageAll(fetchPage, {}));
|
|
197
|
+
|
|
198
|
+
expect(failure.reason).toBe('offset-mismatch');
|
|
199
|
+
expect(failure.detail).not.toContain(SENTINEL);
|
|
200
|
+
expect(failure.message).not.toContain(SENTINEL);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('an honest, well-behaved server never trips any of the three checks', async () => {
|
|
204
|
+
const rows = Array.from({ length: 9 }, (_, id) => ({ id }));
|
|
205
|
+
const { fetchPage } = fakePages(rows, 4);
|
|
206
|
+
|
|
207
|
+
const result = await Effect.runPromise(pageAll(fetchPage, { limit: 4 }));
|
|
208
|
+
|
|
209
|
+
expect(result).toEqual(rows);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('pageAll -- against the real SDK (decode proof)', () => {
|
|
214
|
+
test('walks two real pages of getNetworksOverviewPage through the fake server', async () => {
|
|
215
|
+
const allNetworks = Array.from({ length: 3 }, (_, i) => ({
|
|
216
|
+
default: i === 0,
|
|
217
|
+
enabled: true,
|
|
218
|
+
id: `net-${i}`,
|
|
219
|
+
management: 'ADVANCED',
|
|
220
|
+
metadata: { origin: 'USER' as const },
|
|
221
|
+
name: `Network ${i}`,
|
|
222
|
+
vlanId: i + 1,
|
|
223
|
+
}));
|
|
224
|
+
const fake = fakeUnifi((method, url) => {
|
|
225
|
+
if (
|
|
226
|
+
method !== 'GET' ||
|
|
227
|
+
url.pathname !== '/proxy/network/integration/v1/sites/site-1/networks'
|
|
228
|
+
) {
|
|
229
|
+
return fakeFailure(400, 'unexpected request');
|
|
230
|
+
}
|
|
231
|
+
const offset = Number(url.searchParams.get('offset') ?? '0');
|
|
232
|
+
const limit = Number(url.searchParams.get('limit') ?? '2');
|
|
233
|
+
return Response.json({
|
|
234
|
+
count: allNetworks.slice(offset, offset + limit).length,
|
|
235
|
+
data: allNetworks.slice(offset, offset + limit),
|
|
236
|
+
limit,
|
|
237
|
+
offset,
|
|
238
|
+
totalCount: allNetworks.length,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = await Effect.runPromise(
|
|
243
|
+
pageAll(networks.getNetworksOverviewPage, { siteId: 'site-1', limit: 2 }).pipe(
|
|
244
|
+
Effect.provide(fakeUnifiLayer(fake.fetch)),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(result.map((n) => n.id)).toEqual(['net-0', 'net-1', 'net-2']);
|
|
249
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
250
|
+
expect(fake.seen).toHaveLength(2);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B0b / T9: a CONSUMER-SIDE sequential offset-pager for the SDK's list operations. None of them
|
|
3
|
+
* paginate on their own — `getNetworksOverviewPage`, `getFirewallZones`, `getAclRules`, … all take
|
|
4
|
+
* `{ ..., offset?, limit? }` and answer `{ count, data, limit, offset, totalCount }` (the shape
|
|
5
|
+
* `protocol.ts`'s own header names); there is no `smithy.api#paginated` trait in the pinned spec
|
|
6
|
+
* to generate a helper from.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ NO SDK OR `@distilled.cloud/core` CHANGE (M1 — upstream's own convention: pagination stays
|
|
9
|
+
* out of the 59 packages walked down for `distilled-doctrine`). This is a plain function over
|
|
10
|
+
* whatever `Effect.Effect<OffsetPage<T>, E, R>` a resource file's own `fetchPage` already is.
|
|
11
|
+
* ⛔ STOPS ON `totalCount`, NEVER ON `data.length < limit` (T9's own wording). A page can come back
|
|
12
|
+
* short of the requested `limit` for reasons unrelated to "this was the last page" (a vendor-side
|
|
13
|
+
* per-call cap, a narrow filter) — `totalCount` is the one number the response commits to, so it
|
|
14
|
+
* is the only thing this loop trusts to stop early. An empty page (`data.length === 0`) is the
|
|
15
|
+
* other legitimate stop, and the only one checked before `totalCount` is read.
|
|
16
|
+
* ⚠️ NOT SEQUENCED AGAINST A LIVE CONSOLE THAT MUTATES BETWEEN CALLS. `totalCount` is whatever the
|
|
17
|
+
* MOST RECENT page reported; a page walk racing a live edit could see a row twice, miss one, or
|
|
18
|
+
* just see a moving target. `homeflare-network`'s own import already treats one family's fetch
|
|
19
|
+
* as one sequenced run for this reason (T27's neighboring trap) — nothing here can fix a
|
|
20
|
+
* concurrent mutation, only DETECT the common ways one shows up (below) instead of returning a
|
|
21
|
+
* silently wrong row set.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ RED TEAM (2026-09-26, IMPORTANT-3): the first version of this file trusted the vendor's
|
|
24
|
+
* paged response completely — a lying or drifting `totalCount`, an ignored `offset`, or a row
|
|
25
|
+
* added/removed mid-walk all produced a WRONG row set with no signal at all (silent truncation,
|
|
26
|
+
* an infinite loop, or a subtly incomplete list an importer would commit as if it were correct).
|
|
27
|
+
* Every list op's response already carries `offset` alongside `totalCount` (`networks.ts`'s
|
|
28
|
+
* `NetworkOverviewPage`, and `protocol.ts`'s header for every OTHER list op too); the three
|
|
29
|
+
* checks below are consistency proofs built entirely from fields the vendor already sends, plus
|
|
30
|
+
* a hard ceiling as a backstop against a walk that can never converge. These are typed failures,
|
|
31
|
+
* not `Effect.die`: an inconsistent paged response is a VENDOR-DATA condition a caller may
|
|
32
|
+
* legitimately want to retry or report on (distilled-doctrine: a defect is for "structurally
|
|
33
|
+
* impossible" code paths, not for data the wire actually sent).
|
|
34
|
+
*/
|
|
35
|
+
import * as Data from 'effect/Data';
|
|
36
|
+
import * as Effect from 'effect/Effect';
|
|
37
|
+
|
|
38
|
+
/** The one response shape every UniFi Network list operation already has (`protocol.ts`). */
|
|
39
|
+
export interface OffsetPage<T> {
|
|
40
|
+
readonly data: readonly T[];
|
|
41
|
+
readonly offset: number;
|
|
42
|
+
readonly totalCount: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The one request shape every UniFi Network list operation already accepts, plus its own fields. */
|
|
46
|
+
export type OffsetPageRequest = { readonly offset?: number; readonly limit?: number };
|
|
47
|
+
|
|
48
|
+
/** Well past any family's real row count, even at a one-row page size (`docs/unifi.md`: a real
|
|
49
|
+
* console's largest family is in the hundreds, not thousands) — a walk that reaches this many
|
|
50
|
+
* pages is not looking at a real UniFi site, it is looking at a response that never lets `offset`
|
|
51
|
+
* converge on `totalCount`. */
|
|
52
|
+
const MAX_PAGES = 1000;
|
|
53
|
+
|
|
54
|
+
export class UnifiPaginationInconsistent extends Data.TaggedError('UnifiPaginationInconsistent')<{
|
|
55
|
+
readonly reason:
|
|
56
|
+
| 'offset-mismatch'
|
|
57
|
+
| 'total-count-changed'
|
|
58
|
+
| 'row-count-mismatch'
|
|
59
|
+
| 'page-ceiling';
|
|
60
|
+
readonly detail: string;
|
|
61
|
+
}> {
|
|
62
|
+
override get message(): string {
|
|
63
|
+
return (
|
|
64
|
+
`UniFi pager: ${this.reason} -- ${this.detail}. The paged response contradicted itself (or ` +
|
|
65
|
+
'the walk ran implausibly long); the rows collected so far cannot be trusted and are not returned.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Walks every page of `fetchPage`, starting at `request.offset ?? 0`, and returns every row in
|
|
72
|
+
* order. `request.limit`, if given, is kept as the page size for every call; the vendor's own
|
|
73
|
+
* default applies otherwise — this loop never invents one (T18: "pass ops only their own fields").
|
|
74
|
+
*
|
|
75
|
+
* Three self-consistency checks run against fields the vendor already sends with every page —
|
|
76
|
+
* detection, not a fix, for the "live console mutates mid-walk" case the header above accepts as
|
|
77
|
+
* unfixable from the client alone: a row added AND removed in the same window can still net out to
|
|
78
|
+
* a `totalCount` and final row count that agree by coincidence. What this closes is the SILENT
|
|
79
|
+
* version of every case actually observed in review: an ignored `offset`, a `totalCount` that
|
|
80
|
+
* drifts between calls, and an undercounted `totalCount` that truncates the walk early.
|
|
81
|
+
*
|
|
82
|
+
* ⛔ `numOrPlaceholder` (red team, MINOR-3, 2026-09-26): nothing in `@distilled.cloud/core` decodes
|
|
83
|
+
* or validates a page response's shape (this file's own `wifi-broadcast.ts` neighbor documents
|
|
84
|
+
* why), so `page.offset`/`page.totalCount` can be ANY JSON value, not just a number, if the wire
|
|
85
|
+
* ever sends something malformed. Every `detail` string below prints these through
|
|
86
|
+
* `numOrPlaceholder` rather than raw template interpolation, so a non-numeric value — however it
|
|
87
|
+
* got there — can never inject arbitrary response text into an error message a caller might log.
|
|
88
|
+
*/
|
|
89
|
+
/** A finite number renders as itself; anything else (a malformed wire value) renders as a fixed
|
|
90
|
+
* placeholder, never as the value itself — see this file's header (MINOR-3). */
|
|
91
|
+
const numOrPlaceholder = (value: unknown): string =>
|
|
92
|
+
typeof value === 'number' && Number.isFinite(value) ? String(value) : '<non-numeric>';
|
|
93
|
+
|
|
94
|
+
export const pageAll = <Req extends OffsetPageRequest, T, E, R>(
|
|
95
|
+
fetchPage: (request: Req) => Effect.Effect<OffsetPage<T>, E, R>,
|
|
96
|
+
request: Req,
|
|
97
|
+
): Effect.Effect<T[], E | UnifiPaginationInconsistent, R> =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const rows: T[] = [];
|
|
100
|
+
const startOffset = request.offset ?? 0;
|
|
101
|
+
let offset = startOffset;
|
|
102
|
+
let totalCount: number | undefined;
|
|
103
|
+
for (let pageCount = 1; ; pageCount++) {
|
|
104
|
+
if (pageCount > MAX_PAGES) {
|
|
105
|
+
return yield* new UnifiPaginationInconsistent({
|
|
106
|
+
reason: 'page-ceiling',
|
|
107
|
+
detail: `stopped after ${MAX_PAGES} pages at offset ${offset} without reaching totalCount`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// ⚠️ THE CAST IS SOUND, NOT A SHORTCUT: `Req` is a generic parameter, so TS cannot see that
|
|
111
|
+
// spreading a value already typed `Req` and overriding one of `OffsetPageRequest`'s own
|
|
112
|
+
// fields reconstructs a `Req` — but every field except `offset` is untouched from the
|
|
113
|
+
// caller's own `request`, so it does.
|
|
114
|
+
const page = yield* fetchPage({ ...request, offset } as Req);
|
|
115
|
+
if (page.offset !== offset) {
|
|
116
|
+
return yield* new UnifiPaginationInconsistent({
|
|
117
|
+
reason: 'offset-mismatch',
|
|
118
|
+
detail: `requested offset ${offset}, page echoed ${numOrPlaceholder(page.offset)} -- rows may be duplicated or skipped`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (totalCount !== undefined && page.totalCount !== totalCount) {
|
|
122
|
+
return yield* new UnifiPaginationInconsistent({
|
|
123
|
+
reason: 'total-count-changed',
|
|
124
|
+
detail: `totalCount changed from ${numOrPlaceholder(totalCount)} to ${numOrPlaceholder(page.totalCount)} between page calls`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
totalCount = page.totalCount;
|
|
128
|
+
rows.push(...page.data);
|
|
129
|
+
if (page.data.length === 0) break;
|
|
130
|
+
offset += page.data.length;
|
|
131
|
+
if (offset >= totalCount) break;
|
|
132
|
+
}
|
|
133
|
+
const expected = totalCount - startOffset;
|
|
134
|
+
if (rows.length !== expected) {
|
|
135
|
+
return yield* new UnifiPaginationInconsistent({
|
|
136
|
+
reason: 'row-count-mismatch',
|
|
137
|
+
detail: `collected ${rows.length} rows but totalCount(${numOrPlaceholder(totalCount)}) - startOffset(${startOffset}) = ${numOrPlaceholder(expected)}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return rows;
|
|
141
|
+
});
|
package/src/unifi/providers.ts
CHANGED
|
@@ -9,7 +9,25 @@
|
|
|
9
9
|
* mirroring `../discord/providers.ts`.
|
|
10
10
|
*/
|
|
11
11
|
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { UnifiAclRuleProvider } from './acl-rule.ts';
|
|
13
|
+
import { UnifiAclRuleOrderingProvider } from './acl-rule-ordering.ts';
|
|
14
|
+
import { UnifiDnsPolicyProvider } from './dns-policy.ts';
|
|
15
|
+
import { UnifiFirewallPolicyProvider } from './firewall-policy.ts';
|
|
16
|
+
import { UnifiFirewallPolicyOrderingProvider } from './firewall-policy-ordering.ts';
|
|
12
17
|
import { UnifiFirewallZoneProvider } from './firewall-zone.ts';
|
|
13
18
|
import { UnifiNetworkProvider } from './network.ts';
|
|
19
|
+
import { UnifiTrafficMatchingListProvider } from './traffic-matching-list.ts';
|
|
20
|
+
import { UnifiWifiBroadcastProvider } from './wifi-broadcast.ts';
|
|
14
21
|
|
|
15
|
-
export const providers = () =>
|
|
22
|
+
export const providers = () =>
|
|
23
|
+
Layer.mergeAll(
|
|
24
|
+
UnifiNetworkProvider(),
|
|
25
|
+
UnifiFirewallZoneProvider(),
|
|
26
|
+
UnifiDnsPolicyProvider(),
|
|
27
|
+
UnifiAclRuleProvider(),
|
|
28
|
+
UnifiAclRuleOrderingProvider(),
|
|
29
|
+
UnifiWifiBroadcastProvider(),
|
|
30
|
+
UnifiFirewallPolicyProvider(),
|
|
31
|
+
UnifiFirewallPolicyOrderingProvider(),
|
|
32
|
+
UnifiTrafficMatchingListProvider(),
|
|
33
|
+
);
|
package/src/unifi/resource.ts
CHANGED
|
@@ -37,8 +37,8 @@ import type { Input } from 'alchemy/Input';
|
|
|
37
37
|
import { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
|
|
38
38
|
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
39
39
|
import * as Effect from 'effect/Effect';
|
|
40
|
-
import
|
|
41
|
-
import { refuseWrite } from './policy.ts';
|
|
40
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
41
|
+
import { UNIFI_READ_ONLY_POLICY, refuseWrite } from './policy.ts';
|
|
42
42
|
|
|
43
43
|
/** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
|
|
44
44
|
export type UnifiRequirements = HttpClient.HttpClient;
|
|
@@ -107,6 +107,76 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
|
|
|
107
107
|
};
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* B0a / T12 DEFENSE IN DEPTH. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at
|
|
112
|
+
* `reconcile`/`destroy` — every path this engine exposes already returns that refusal instead of
|
|
113
|
+
* calling an SDK write op. This guard stops the same thing one layer lower, AT THE WIRE: it wraps
|
|
114
|
+
* whatever `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in
|
|
115
|
+
* production, `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same
|
|
116
|
+
* "wrap, don't replace" seam) so a future resource file that, by mistake, called an SDK write
|
|
117
|
+
* operation directly — bypassing `unifiOperations` entirely — would still never reach the vendor
|
|
118
|
+
* API. `write-op-reference.test.ts` proves no such call exists today; this is what stops one from
|
|
119
|
+
* ever taking effect if one is ever added.
|
|
120
|
+
*
|
|
121
|
+
* ⚠️ `Effect.die`, NOT A TYPED FAILURE — classified per distilled-doctrine's "classify every
|
|
122
|
+
* die/orDie". Every generated SDK operation (`networks.getNetworkDetails`, …) declares its OWN
|
|
123
|
+
* closed error union from the pinned OpenAPI spec, and generated files are never hand-edited to
|
|
124
|
+
* add a member to it. A typed `Effect.fail` here would be a failure mode absent from every op's
|
|
125
|
+
* declared type the moment it occurred — a type-system lie. A defect is the sound way to add a
|
|
126
|
+
* NEW "this must be structurally impossible" failure underneath types this package does not
|
|
127
|
+
* own, exactly like `openbao`'s `refuse()` helpers' `Effect.die(new Error(...))` for a
|
|
128
|
+
* configuration move that should never be reachable.
|
|
129
|
+
*/
|
|
130
|
+
export class UnifiNonGetRequest extends Error {
|
|
131
|
+
constructor(
|
|
132
|
+
readonly method: string,
|
|
133
|
+
/** PATH ONLY, never the full URL — see `requestPath` below. */
|
|
134
|
+
readonly path: string,
|
|
135
|
+
) {
|
|
136
|
+
super(
|
|
137
|
+
`UniFi HttpClient guard: refused ${method} ${path} -- ${UNIFI_READ_ONLY_POLICY}. This is a ` +
|
|
138
|
+
'defect, not a recoverable condition: something under src/unifi tried to send a non-GET ' +
|
|
139
|
+
"request, which policy.ts's per-operation refusal should already have made impossible. " +
|
|
140
|
+
'Fix the kit code that produced this request.',
|
|
141
|
+
);
|
|
142
|
+
this.name = 'UnifiNonGetRequest';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID
|
|
148
|
+
* (`docs/unifi.md`'s Credentials section, T3) — a defect message is still a message that can
|
|
149
|
+
* reach a log, a CI failure body or a pasted error report, so it gets the same treatment as
|
|
150
|
+
* `redactUrls` gives the import layer's own error text. Falls back to a fixed placeholder rather
|
|
151
|
+
* than the raw string on a parse failure, so a malformed URL can never leak through unredacted.
|
|
152
|
+
*/
|
|
153
|
+
const requestPath = (url: string): string => {
|
|
154
|
+
try {
|
|
155
|
+
return new URL(url).pathname;
|
|
156
|
+
} catch {
|
|
157
|
+
return '<unparseable request url>';
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const guardGetOnly = (client: HttpClient.HttpClient): HttpClient.HttpClient =>
|
|
162
|
+
client.pipe(
|
|
163
|
+
HttpClient.mapRequestEffect((request) =>
|
|
164
|
+
request.method === 'GET'
|
|
165
|
+
? Effect.succeed(request)
|
|
166
|
+
: Effect.die(new UnifiNonGetRequest(request.method, requestPath(request.url))),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
|
|
172
|
+
* for everything downstream, with `guardGetOnly`'s wrapped version (`HttpClient.layerMergedContext`
|
|
173
|
+
* — see the guard above). Exported so `get-only-guard.test.ts` can prove the mechanism directly,
|
|
174
|
+
* against a bare `HttpClient` service, without needing `CredentialsFromEnv` or any env var at all.
|
|
175
|
+
*/
|
|
176
|
+
export const GetOnlyHttpClient = HttpClient.layerMergedContext(
|
|
177
|
+
Effect.map(HttpClient.HttpClient, guardGetOnly),
|
|
178
|
+
);
|
|
179
|
+
|
|
110
180
|
/**
|
|
111
181
|
* The four provider handlers for a spec'd read-only UniFi object, wired once.
|
|
112
182
|
*
|
|
@@ -118,7 +188,7 @@ export const unifiHandlers = <Props extends object, Live, Attributes extends obj
|
|
|
118
188
|
) => {
|
|
119
189
|
const ops = unifiOperations(spec);
|
|
120
190
|
const withCredentials = <A, Err>(effect: Effect.Effect<A, Err, UnifiNetworkOpContext>) =>
|
|
121
|
-
Effect.provide(
|
|
191
|
+
effect.pipe(Effect.provide(GetOnlyHttpClient), Effect.provide(CredentialsFromEnv));
|
|
122
192
|
return {
|
|
123
193
|
list: () => Effect.succeed([]),
|
|
124
194
|
read: (args: { olds: Props; output: Attributes | undefined }) =>
|