@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.AclRuleOrdering`'s `spec` against a fake UniFi Network API — mirrors `acl-rule.test.ts`.
|
|
3
|
+
* The one thing this file exists to prove that no other family test does: T5's "order IS the
|
|
4
|
+
* value, never `sortedSet`" rule actually holds for `orderedAclRuleIds`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import type * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
|
|
8
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
11
|
+
import { attributesOf, driftOf, matches } from './acl-rule-ordering-form.ts';
|
|
12
|
+
import { type AclRuleOrderingProps, declareAclRuleOrdering, spec } from './acl-rule-ordering.ts';
|
|
13
|
+
import { unifiOperations } from './resource.ts';
|
|
14
|
+
|
|
15
|
+
const ORDERING_PATH = '/proxy/network/integration/v1/sites/site-1/acl-rules/ordering';
|
|
16
|
+
|
|
17
|
+
const liveOrdering = (
|
|
18
|
+
overrides: Partial<aclRules.ACLRuleOrdering> = {},
|
|
19
|
+
): aclRules.ACLRuleOrdering => ({
|
|
20
|
+
orderedAclRuleIds: ['rule-1', 'rule-2', 'rule-3'],
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const PROPS: AclRuleOrderingProps = {
|
|
25
|
+
siteId: 'site-1',
|
|
26
|
+
orderedAclRuleIds: ['rule-1', 'rule-2', 'rule-3'],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('Unifi.AclRuleOrdering spec.fetchLive', () => {
|
|
30
|
+
test('a real GET response decodes into the typed live object', async () => {
|
|
31
|
+
const fake = fakeUnifi((method, url) =>
|
|
32
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
33
|
+
? Response.json(liveOrdering())
|
|
34
|
+
: fakeFailure(400, 'unexpected request'),
|
|
35
|
+
);
|
|
36
|
+
const live = await Effect.runPromise(
|
|
37
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
38
|
+
);
|
|
39
|
+
expect(live?.orderedAclRuleIds).toEqual(['rule-1', 'rule-2', 'rule-3']);
|
|
40
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: ORDERING_PATH }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a genuine 404 folds to undefined', async () => {
|
|
44
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
45
|
+
const live = await Effect.runPromise(
|
|
46
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
47
|
+
);
|
|
48
|
+
expect(live).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
|
|
52
|
+
// ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
|
|
53
|
+
// asserts the FAILURE, not the retry schedule. Regression for red-team Important finding 2:
|
|
54
|
+
// a blanket `Effect.orElseSucceed(() => undefined)` fold would pass every test above too.
|
|
55
|
+
const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
|
|
56
|
+
const failure = await Effect.runPromise(
|
|
57
|
+
Effect.flip(
|
|
58
|
+
spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
expect(failure._tag).toBe('InternalServerError');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('declareAclRuleOrdering -- the declaration renderer', () => {
|
|
66
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
67
|
+
const live = liveOrdering({ orderedAclRuleIds: ['rule-9', 'rule-2'] });
|
|
68
|
+
const declared = declareAclRuleOrdering(live, 'site-1');
|
|
69
|
+
const attrs = attributesOf(live, declared);
|
|
70
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('T5 -- order IS the value, never sortedSet', () => {
|
|
75
|
+
test('an identical list is a noop', () => {
|
|
76
|
+
expect(driftOf(liveOrdering(), PROPS)).toEqual([]);
|
|
77
|
+
expect(matches(attributesOf(liveOrdering(), PROPS), PROPS)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('the SAME rule ids in a DIFFERENT order is real drift, not a noop', () => {
|
|
81
|
+
// The one behavior `firewall-zone-form.ts`'s `networkIds` and this file must NOT share:
|
|
82
|
+
// there, a reorder is a noop (a set); here, it is the whole point of the resource.
|
|
83
|
+
const live = liveOrdering({ orderedAclRuleIds: ['rule-2', 'rule-1', 'rule-3'] });
|
|
84
|
+
expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
|
|
85
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
86
|
+
{
|
|
87
|
+
field: 'orderedAclRuleIds',
|
|
88
|
+
live: ['rule-2', 'rule-1', 'rule-3'],
|
|
89
|
+
declared: ['rule-1', 'rule-2', 'rule-3'],
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a genuinely different rule set (not just reordered) is also drift', () => {
|
|
95
|
+
const live = liveOrdering({ orderedAclRuleIds: ['rule-1', 'rule-4'] });
|
|
96
|
+
expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('Unifi.AclRuleOrdering write paths never reach the vendor API', () => {
|
|
101
|
+
const ops = unifiOperations(spec);
|
|
102
|
+
|
|
103
|
+
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
104
|
+
const fake = fakeUnifi((method, url) =>
|
|
105
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
106
|
+
? Response.json(liveOrdering())
|
|
107
|
+
: fakeFailure(400, 'unexpected request'),
|
|
108
|
+
);
|
|
109
|
+
const exit = await Effect.runPromiseExit(
|
|
110
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
111
|
+
);
|
|
112
|
+
expect(exit._tag).toBe('Success');
|
|
113
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('reconcile on drift (a reorder) refuses instead of PUTting the whole list', async () => {
|
|
117
|
+
const fake = fakeUnifi((method, url) =>
|
|
118
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
119
|
+
? Response.json(liveOrdering({ orderedAclRuleIds: ['rule-3', 'rule-2', 'rule-1'] }))
|
|
120
|
+
: fakeFailure(400, 'unexpected request'),
|
|
121
|
+
);
|
|
122
|
+
const exit = await Effect.runPromiseExit(
|
|
123
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
124
|
+
);
|
|
125
|
+
expect(exit._tag).toBe('Failure');
|
|
126
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
130
|
+
const fake = fakeUnifi((method, url) =>
|
|
131
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
132
|
+
? Response.json(liveOrdering())
|
|
133
|
+
: fakeFailure(400, 'unexpected request'),
|
|
134
|
+
);
|
|
135
|
+
const exit = await Effect.runPromiseExit(
|
|
136
|
+
ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
137
|
+
);
|
|
138
|
+
expect(exit._tag).toBe('Failure');
|
|
139
|
+
expect(fake.seen).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.AclRuleOrdering` — the ONE ordered list of user-defined ACL rule IDs for a site
|
|
3
|
+
* (`getAclRuleOrdering`/`updateAclRuleOrdering`, `GET`/`PUT /v1/sites/{siteId}/acl-rules/ordering`),
|
|
4
|
+
* READ-ONLY (`policy.ts`). T5's per-family variant of the ordering trap `docs/unifi-api-notes.md`
|
|
5
|
+
* warns about for `Unifi.FirewallPolicyOrdering` (`firewall-policy-ordering.ts`, keyed per zone
|
|
6
|
+
* pair rather than site-wide — see its own header for why): the endpoint replaces the WHOLE list
|
|
7
|
+
* on a write, so a partial or resorted body would drop or reorder every OTHER rule too.
|
|
8
|
+
* This family never writes at all, but its `matches`/`driftOf` (`acl-rule-ordering-form.ts`) are
|
|
9
|
+
* still order-preserving, never `sortedSet`, so a future write path inherits a correct comparison
|
|
10
|
+
* rather than one that has to be fixed alongside the first `update`.
|
|
11
|
+
*
|
|
12
|
+
* ★ ONE OBJECT PER SITE, NOT PER RULE — unlike `acl-rule.ts`, there is no per-object id in the
|
|
13
|
+
* wire response at all (`ACLRuleOrdering` is just `{ orderedAclRuleIds }`); the resource's own
|
|
14
|
+
* `id` (the stack-chosen string passed to `aclRuleOrdering(id, props)`) is a label the DECLARER
|
|
15
|
+
* picks, same as `firewallZone`/`network`, not something read back from the vendor.
|
|
16
|
+
*/
|
|
17
|
+
import { Resource } from 'alchemy';
|
|
18
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
19
|
+
import * as Provider from 'alchemy/Provider';
|
|
20
|
+
import * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
|
|
21
|
+
import * as Effect from 'effect/Effect';
|
|
22
|
+
import {
|
|
23
|
+
type AclRuleOrderingAttributes,
|
|
24
|
+
type AclRuleOrderingProps,
|
|
25
|
+
attributesOf,
|
|
26
|
+
matches,
|
|
27
|
+
} from './acl-rule-ordering-form.ts';
|
|
28
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
29
|
+
import { unifiHandlers } from './resource.ts';
|
|
30
|
+
|
|
31
|
+
export type { AclRuleOrderingAttributes, AclRuleOrderingProps } from './acl-rule-ordering-form.ts';
|
|
32
|
+
export { declareAclRuleOrdering, driftOf } from './acl-rule-ordering-form.ts';
|
|
33
|
+
|
|
34
|
+
export interface UnifiAclRuleOrdering extends Resource<
|
|
35
|
+
'Unifi.AclRuleOrdering',
|
|
36
|
+
AclRuleOrderingProps,
|
|
37
|
+
AclRuleOrderingAttributes,
|
|
38
|
+
never,
|
|
39
|
+
UnifiRequirements
|
|
40
|
+
> {}
|
|
41
|
+
|
|
42
|
+
export const UnifiAclRuleOrdering = Resource<UnifiAclRuleOrdering>('Unifi.AclRuleOrdering', {
|
|
43
|
+
defaultRemovalPolicy: 'retain',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** `aclRuleOrdering('site-1', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
|
|
47
|
+
export const aclRuleOrdering = (id: string, props: AclRuleOrderingProps) =>
|
|
48
|
+
UnifiAclRuleOrdering(id, props).pipe(adopt(true));
|
|
49
|
+
|
|
50
|
+
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
51
|
+
export const spec: UnifiSpec<
|
|
52
|
+
AclRuleOrderingProps,
|
|
53
|
+
aclRules.ACLRuleOrdering,
|
|
54
|
+
AclRuleOrderingAttributes,
|
|
55
|
+
aclRules.GetAclRuleOrderingError
|
|
56
|
+
> = {
|
|
57
|
+
type: 'Unifi.AclRuleOrdering',
|
|
58
|
+
describe: (props) => `sites/${props.siteId}/acl-rules/ordering`,
|
|
59
|
+
fetchLive: (props) =>
|
|
60
|
+
aclRules
|
|
61
|
+
.getAclRuleOrdering({ siteId: props.siteId })
|
|
62
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
63
|
+
attributes: attributesOf,
|
|
64
|
+
matches,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const handlers = unifiHandlers(spec);
|
|
68
|
+
|
|
69
|
+
export const UnifiAclRuleOrderingProvider = () =>
|
|
70
|
+
Provider.effect(UnifiAclRuleOrdering, Effect.succeed(UnifiAclRuleOrdering.Provider.of(handlers)));
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.AclRule`'s `spec` against a fake UniFi Network API — mirrors `network.test.ts`/
|
|
3
|
+
* `firewall-zone.test.ts`. `driftOf`/`matches` coverage lives here too, same as
|
|
4
|
+
* `dns-policy.test.ts`, not split into its own file (see that file's own header).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import type * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
|
|
8
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
11
|
+
import { attributesOf } from './acl-rule-form.ts';
|
|
12
|
+
import { driftOf, matches } from './acl-rule-drift.ts';
|
|
13
|
+
import { type AclRuleProps, declareAclRule, spec } from './acl-rule.ts';
|
|
14
|
+
import { unifiOperations } from './resource.ts';
|
|
15
|
+
|
|
16
|
+
const RULE_PATH = '/proxy/network/integration/v1/sites/site-1/acl-rules/rule-1';
|
|
17
|
+
|
|
18
|
+
const liveAclRule = (overrides: Partial<aclRules.ACLRule> = {}): aclRules.ACLRule => ({
|
|
19
|
+
action: 'BLOCK',
|
|
20
|
+
enabled: true,
|
|
21
|
+
id: 'rule-1',
|
|
22
|
+
index: 3,
|
|
23
|
+
metadata: { origin: 'USER' },
|
|
24
|
+
name: 'Block IoT to WAN',
|
|
25
|
+
// ★ `IPV4` is the real `ACL rule` discriminator value (`type`: `IPV4`|`MAC`, `docs/unifi-acl-
|
|
26
|
+
// rule.md`) -- a prior fixture used the invented `NETWORK_TO_INTERNET`, corrected 2026-09-26.
|
|
27
|
+
type: 'IPV4',
|
|
28
|
+
protocolFilter: ['TCP', 'UDP'],
|
|
29
|
+
// ★ `DEVICES` is the real `ACL rule device filter` discriminator value; the vendor has no
|
|
30
|
+
// "ALL_SWITCHES" variant -- omitting the field entirely means "all switches" per its own
|
|
31
|
+
// description ("When null, the rule will be provisioned to all switches on the site").
|
|
32
|
+
enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-1'] },
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const PROPS: AclRuleProps = {
|
|
37
|
+
siteId: 'site-1',
|
|
38
|
+
aclRuleId: 'rule-1',
|
|
39
|
+
action: 'BLOCK',
|
|
40
|
+
enabled: true,
|
|
41
|
+
name: 'Block IoT to WAN',
|
|
42
|
+
type: 'IPV4',
|
|
43
|
+
protocolFilter: ['TCP', 'UDP'],
|
|
44
|
+
enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-1'] },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('Unifi.AclRule spec.fetchLive', () => {
|
|
48
|
+
test('a real GET response decodes into the typed live object', async () => {
|
|
49
|
+
const fake = fakeUnifi((method, url) =>
|
|
50
|
+
method === 'GET' && url.pathname === RULE_PATH
|
|
51
|
+
? Response.json(liveAclRule())
|
|
52
|
+
: fakeFailure(400, 'unexpected request'),
|
|
53
|
+
);
|
|
54
|
+
const live = await Effect.runPromise(
|
|
55
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
56
|
+
);
|
|
57
|
+
expect(live?.id).toBe('rule-1');
|
|
58
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: RULE_PATH }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a genuine 404 folds to undefined -- never a false "unreadable" on anything else', async () => {
|
|
62
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
63
|
+
const live = await Effect.runPromise(
|
|
64
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
65
|
+
);
|
|
66
|
+
expect(live).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
|
|
70
|
+
// ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
|
|
71
|
+
// asserts the FAILURE, not the retry schedule. Regression for red-team Important finding 2:
|
|
72
|
+
// a blanket `Effect.orElseSucceed(() => undefined)` fold would pass every test above too.
|
|
73
|
+
const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
|
|
74
|
+
const failure = await Effect.runPromise(
|
|
75
|
+
Effect.flip(
|
|
76
|
+
spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
expect(failure._tag).toBe('InternalServerError');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('declareAclRule -- the declaration renderer', () => {
|
|
84
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
85
|
+
const live = liveAclRule({ protocolFilter: ['UDP', 'TCP'], index: 9 });
|
|
86
|
+
const declared = declareAclRule(live, 'site-1');
|
|
87
|
+
const attrs = attributesOf(live, declared);
|
|
88
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
89
|
+
expect(driftOf(live, declared)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('driftOf -- B6 field-level drift, straight from one live read', () => {
|
|
94
|
+
test('a real name change reports its own field, live and declared', () => {
|
|
95
|
+
const live = liveAclRule({ name: 'Block cameras from WAN' });
|
|
96
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
97
|
+
{ field: 'name', live: 'Block cameras from WAN', declared: 'Block IoT to WAN' },
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('protocolFilter in a different order is a noop -- it is a SET, not a sequence', () => {
|
|
102
|
+
const live = liveAclRule({ protocolFilter: ['UDP', 'TCP'] });
|
|
103
|
+
expect(matches(attributesOf(live, PROPS), PROPS)).toBe(true);
|
|
104
|
+
expect(driftOf(live, PROPS)).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the DECLARED side of protocolFilter is also normalized, not just live (MEDIUM-4 mutant)', () => {
|
|
108
|
+
const live = liveAclRule({ protocolFilter: ['TCP', 'UDP'] });
|
|
109
|
+
const props: AclRuleProps = { ...PROPS, protocolFilter: ['UDP', 'TCP'] };
|
|
110
|
+
expect(matches(attributesOf(live, props), props)).toBe(true);
|
|
111
|
+
expect(driftOf(live, props)).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a genuine protocolFilter difference is caught', () => {
|
|
115
|
+
const live = liveAclRule({ protocolFilter: ['TCP'] });
|
|
116
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
117
|
+
{ field: 'protocolFilter', live: ['TCP'], declared: ['TCP', 'UDP'] },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('enforcingDeviceFilter.deviceIds in a different order is a noop -- also a SET', () => {
|
|
122
|
+
const live = liveAclRule({
|
|
123
|
+
enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-2', 'sw-1'] },
|
|
124
|
+
});
|
|
125
|
+
const props: AclRuleProps = {
|
|
126
|
+
...PROPS,
|
|
127
|
+
enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-1', 'sw-2'] },
|
|
128
|
+
};
|
|
129
|
+
expect(matches(attributesOf(live, props), props)).toBe(true);
|
|
130
|
+
expect(driftOf(live, props)).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('index is never compared -- it is attributes-only (deprecated for writes, owned by ordering)', () => {
|
|
134
|
+
const live = liveAclRule({ index: 41 });
|
|
135
|
+
expect(matches(attributesOf(live, PROPS), PROPS)).toBe(true);
|
|
136
|
+
expect(attributesOf(live, PROPS).index).toBe(41);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('sourceFilter/destinationFilter stay opaque -- a real difference is still caught', () => {
|
|
140
|
+
// `NETWORKS` + `networkIds` (plural, an array) is the real `IntegrationIpAclRuleNetworkEndpoint
|
|
141
|
+
// FilterDto` shape -- a prior fixture used the invented singular `NETWORK`/`networkId`,
|
|
142
|
+
// corrected 2026-09-26. `sourceFilter` is `unknown` either way, so this is a fixture-realism fix
|
|
143
|
+
// only, not a behavior change.
|
|
144
|
+
const live = liveAclRule({ sourceFilter: { type: 'NETWORKS', networkIds: ['net-1'] } });
|
|
145
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
146
|
+
{
|
|
147
|
+
field: 'sourceFilter',
|
|
148
|
+
live: { type: 'NETWORKS', networkIds: ['net-1'] },
|
|
149
|
+
declared: undefined,
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('Unifi.AclRule write paths never reach the vendor API', () => {
|
|
156
|
+
const ops = unifiOperations(spec);
|
|
157
|
+
|
|
158
|
+
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
159
|
+
const fake = fakeUnifi((method, url) =>
|
|
160
|
+
method === 'GET' && url.pathname === RULE_PATH
|
|
161
|
+
? Response.json(liveAclRule())
|
|
162
|
+
: fakeFailure(400, 'unexpected request'),
|
|
163
|
+
);
|
|
164
|
+
const exit = await Effect.runPromiseExit(
|
|
165
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
166
|
+
);
|
|
167
|
+
expect(exit._tag).toBe('Success');
|
|
168
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
172
|
+
const fake = fakeUnifi((method, url) =>
|
|
173
|
+
method === 'GET' && url.pathname === RULE_PATH
|
|
174
|
+
? Response.json(liveAclRule())
|
|
175
|
+
: fakeFailure(400, 'unexpected request'),
|
|
176
|
+
);
|
|
177
|
+
const exit = await Effect.runPromiseExit(
|
|
178
|
+
ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
179
|
+
);
|
|
180
|
+
expect(exit._tag).toBe('Failure');
|
|
181
|
+
expect(fake.seen).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.AclRule` — one user-defined ACL rule on a UniFi Network site, READ-ONLY (`policy.ts`).
|
|
3
|
+
* `Unifi.AclRuleOrdering` (`acl-rule-ordering.ts`) is the sibling resource for rule ORDER (T5) —
|
|
4
|
+
* one per site, never this file's concern.
|
|
5
|
+
*
|
|
6
|
+
* ★ T11 GATE — see `dns-policy.ts`'s own header for the full closure-diff record: `Access Control
|
|
7
|
+
* (ACL Rules)` (7 ops, including `getAclRuleOrdering`/`updateAclRuleOrdering`) is
|
|
8
|
+
* byte-identical between 10.4.57 and 10.6.97, and unreachable from any of the 14 schemas that DID
|
|
9
|
+
* change. Full breakdown: `docs/unifi-acl-rule.md`.
|
|
10
|
+
*/
|
|
11
|
+
import { Resource } from 'alchemy';
|
|
12
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import * as Provider from 'alchemy/Provider';
|
|
14
|
+
import * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import { type AclRuleAttributes, type AclRuleProps, attributesOf } from './acl-rule-form.ts';
|
|
17
|
+
import { matches } from './acl-rule-drift.ts';
|
|
18
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
19
|
+
import { unifiHandlers } from './resource.ts';
|
|
20
|
+
|
|
21
|
+
export type { AclRuleAttributes, AclRuleProps } from './acl-rule-form.ts';
|
|
22
|
+
export { declareAclRule } from './acl-rule-form.ts';
|
|
23
|
+
export { driftOf } from './acl-rule-drift.ts';
|
|
24
|
+
|
|
25
|
+
export interface UnifiAclRule extends Resource<
|
|
26
|
+
'Unifi.AclRule',
|
|
27
|
+
AclRuleProps,
|
|
28
|
+
AclRuleAttributes,
|
|
29
|
+
never,
|
|
30
|
+
UnifiRequirements
|
|
31
|
+
> {}
|
|
32
|
+
|
|
33
|
+
export const UnifiAclRule = Resource<UnifiAclRule>('Unifi.AclRule', {
|
|
34
|
+
defaultRemovalPolicy: 'retain',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** `aclRule('block-iot-wan', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
|
|
38
|
+
export const aclRule = (id: string, props: AclRuleProps) =>
|
|
39
|
+
UnifiAclRule(id, props).pipe(adopt(true));
|
|
40
|
+
|
|
41
|
+
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
42
|
+
export const spec: UnifiSpec<
|
|
43
|
+
AclRuleProps,
|
|
44
|
+
aclRules.ACLRule,
|
|
45
|
+
AclRuleAttributes,
|
|
46
|
+
aclRules.GetAclRuleError
|
|
47
|
+
> = {
|
|
48
|
+
type: 'Unifi.AclRule',
|
|
49
|
+
describe: (props) => `sites/${props.siteId}/acl-rules/${props.aclRuleId}`,
|
|
50
|
+
fetchLive: (props) =>
|
|
51
|
+
aclRules
|
|
52
|
+
.getAclRule({ siteId: props.siteId, aclRuleId: props.aclRuleId })
|
|
53
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
54
|
+
attributes: attributesOf,
|
|
55
|
+
matches,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const handlers = unifiHandlers(spec);
|
|
59
|
+
|
|
60
|
+
export const UnifiAclRuleProvider = () =>
|
|
61
|
+
Provider.effect(UnifiAclRule, Effect.succeed(UnifiAclRule.Provider.of(handlers)));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3/T26 regression: the SDK's per-discriminator-typed filters (FirewallPolicy's
|
|
3
|
+
* `protocolFilter`/`macAddressFilter`, an ACL rule's `sourceFilter`/`destinationFilter`) must
|
|
4
|
+
* decode a wire value UNCHANGED — including a key this pinned spec doesn't yet declare.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WITHOUT THIS TEST, A REGEN THAT SWITCHES `scripts/generate.ts` BACK TO
|
|
7
|
+
* `unionStyle: "opaque-cases"` STAYS GREEN. That style emits `S.Unknown.pipe(T.UnionCases(...))`,
|
|
8
|
+
* whose decode (`@distilled.cloud/core` `protocol-http.ts`'s `mapKeys`) picks the best-matching
|
|
9
|
+
* case by key set and keeps ONLY that case's keys — silently dropping a future vendor field, and
|
|
10
|
+
* in a same-key-set tie (an all-null MAC endpoint vs. the IP endpoint below) picking the WRONG
|
|
11
|
+
* case's keys outright. `scripts/generate.ts` now uses Argo CD's `union:` callback instead — a
|
|
12
|
+
* bare `S.Unknown` with no case annotation — which this repo's own `fake-unifi.ts` proves against
|
|
13
|
+
* the real wire path (path assembly + JSON decode), the same way `errors-and-secrets.test.ts`
|
|
14
|
+
* proves A1/A2. This family has no `Unifi.FirewallPolicy`/`Unifi.AclRule` provider yet (Lane B),
|
|
15
|
+
* so the SDK's own `getAclRule`/`getFirewallPolicy` operations are called directly.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
|
|
19
|
+
import * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import { fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
22
|
+
|
|
23
|
+
const ACL_PATH = '/proxy/network/integration/v1/sites/site-1/acl-rules/acl-1';
|
|
24
|
+
const FW_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/policies/fp-1';
|
|
25
|
+
|
|
26
|
+
// Fabricated fixture (RFC 5737 address block, RFC 2606-safe ids) — kit is PUBLIC (fake-unifi.ts).
|
|
27
|
+
const aclBody = () => ({
|
|
28
|
+
type: 'IPV4',
|
|
29
|
+
id: 'acl-1',
|
|
30
|
+
name: 'example-rule',
|
|
31
|
+
enabled: true,
|
|
32
|
+
action: 'BLOCK',
|
|
33
|
+
index: 0,
|
|
34
|
+
metadata: { origin: 'USER' },
|
|
35
|
+
// A future console version's field this pinned spec has never declared.
|
|
36
|
+
sourceFilter: {
|
|
37
|
+
type: 'IP_ADDRESSES_OR_SUBNETS',
|
|
38
|
+
ipAddressesOrSubnets: ['192.0.2.0/24'],
|
|
39
|
+
futureVendorField: 'unmodeled-by-this-spec-version',
|
|
40
|
+
},
|
|
41
|
+
// Every member null (a same-key-set tie with the IP case under key-set matching).
|
|
42
|
+
destinationFilter: { type: 'MAC_ADDRESSES', macAddresses: null, prefixLength: null },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('A3/T26 — ACL rule filters decode unchanged, including an unmodeled field', () => {
|
|
46
|
+
test('sourceFilter keeps a key this spec version does not declare', async () => {
|
|
47
|
+
const fake = fakeUnifi((method, url) =>
|
|
48
|
+
method === 'GET' && url.pathname === ACL_PATH
|
|
49
|
+
? Response.json(aclBody())
|
|
50
|
+
: new Response('unexpected request', { status: 400 }),
|
|
51
|
+
);
|
|
52
|
+
const live = await Effect.runPromise(
|
|
53
|
+
aclRules
|
|
54
|
+
.getAclRule({ siteId: 'site-1', aclRuleId: 'acl-1' })
|
|
55
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
56
|
+
);
|
|
57
|
+
const source = live.sourceFilter as unknown as Record<string, unknown>;
|
|
58
|
+
expect(source.futureVendorField).toBe('unmodeled-by-this-spec-version');
|
|
59
|
+
expect(source.ipAddressesOrSubnets).toEqual(['192.0.2.0/24']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('destinationFilter is not silently reassigned to the tied IP case', async () => {
|
|
63
|
+
const fake = fakeUnifi(() => Response.json(aclBody()));
|
|
64
|
+
const live = await Effect.runPromise(
|
|
65
|
+
aclRules
|
|
66
|
+
.getAclRule({ siteId: 'site-1', aclRuleId: 'acl-1' })
|
|
67
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
68
|
+
);
|
|
69
|
+
const dest = live.destinationFilter as unknown as Record<string, unknown>;
|
|
70
|
+
expect(dest.type).toBe('MAC_ADDRESSES');
|
|
71
|
+
expect('macAddresses' in dest).toBe(true);
|
|
72
|
+
// The IP case's own keys (`ipAddressesOrSubnets`, `portFilter`, `networkIds`) never appear —
|
|
73
|
+
// the case-narrowing mapKeys path this SDK no longer uses would rewrite them in on a key-set tie.
|
|
74
|
+
expect('ipAddressesOrSubnets' in dest).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const fwBody = () => ({
|
|
79
|
+
id: 'fp-1',
|
|
80
|
+
name: 'example-policy',
|
|
81
|
+
enabled: true,
|
|
82
|
+
index: 1,
|
|
83
|
+
action: { type: 'ALLOW' },
|
|
84
|
+
source: {
|
|
85
|
+
zoneId: 'zone-src',
|
|
86
|
+
trafficFilter: {
|
|
87
|
+
type: 'MAC_ADDRESS',
|
|
88
|
+
macAddressFilter: {
|
|
89
|
+
macAddresses: ['02:00:00:00:00:01'],
|
|
90
|
+
futureVendorField: 'unmodeled-by-this-spec-version',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
destination: { zoneId: 'zone-dst' },
|
|
95
|
+
ipProtocolScope: {
|
|
96
|
+
ipVersion: 'IPV6',
|
|
97
|
+
protocolFilter: {
|
|
98
|
+
type: 'NAMED_PROTOCOL',
|
|
99
|
+
protocol: { name: 'icmpv6', typenameFilter: 'ECHO_REQUEST' },
|
|
100
|
+
matchOpposite: false,
|
|
101
|
+
futureVendorField: 'unmodeled-by-this-spec-version',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
metadata: { origin: 'USER' },
|
|
105
|
+
loggingEnabled: false,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('A3/T26 — FirewallPolicy filters decode unchanged, including an unmodeled field', () => {
|
|
109
|
+
test('macAddressFilter and protocolFilter both keep a key this spec version does not declare', async () => {
|
|
110
|
+
const fake = fakeUnifi((method, url) =>
|
|
111
|
+
method === 'GET' && url.pathname === FW_PATH
|
|
112
|
+
? Response.json(fwBody())
|
|
113
|
+
: new Response('unexpected request', { status: 400 }),
|
|
114
|
+
);
|
|
115
|
+
const live = await Effect.runPromise(
|
|
116
|
+
firewall
|
|
117
|
+
.getFirewallPolicy({ siteId: 'site-1', firewallPolicyId: 'fp-1' })
|
|
118
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
119
|
+
);
|
|
120
|
+
const macFilter = live.source.trafficFilter?.macAddressFilter as unknown as Record<
|
|
121
|
+
string,
|
|
122
|
+
unknown
|
|
123
|
+
>;
|
|
124
|
+
expect(macFilter.futureVendorField).toBe('unmodeled-by-this-spec-version');
|
|
125
|
+
expect(macFilter.macAddresses).toEqual(['02:00:00:00:00:01']);
|
|
126
|
+
|
|
127
|
+
const protocolFilter = live.ipProtocolScope.protocolFilter as unknown as Record<
|
|
128
|
+
string,
|
|
129
|
+
unknown
|
|
130
|
+
>;
|
|
131
|
+
expect(protocolFilter.futureVendorField).toBe('unmodeled-by-this-spec-version');
|
|
132
|
+
expect((protocolFilter.protocol as Record<string, unknown>).name).toBe('icmpv6');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.DnsPolicy`'s `matches` AND `driftOf` (B6) — both built on the ONE field list below, the
|
|
3
|
+
* same `fieldDrift`-is-the-only-comparison shape `network-drift.ts`'s own header explains (MEDIUM-4,
|
|
4
|
+
* red team, 2026-09-26): `matches(attrs, props) === (driftOf(live, props).length === 0)` is true
|
|
5
|
+
* BY CONSTRUCTION, never two hand-kept-in-sync comparisons.
|
|
6
|
+
*
|
|
7
|
+
* ★ NO CUSTOM `equal` ANYWHERE. Every field is a scalar and `drift.ts`'s `defaultEqual`
|
|
8
|
+
* (`deepEqual` with `stripNullish: true`) already gives UniFi's `null`-for-unset-optional
|
|
9
|
+
* reality the same tolerance `network-drift.ts`/`firewall-zone-form.ts` rely on — see
|
|
10
|
+
* `dns-policy-form.ts`'s own header for why no field here needs a set/order normalizer.
|
|
11
|
+
*/
|
|
12
|
+
import type * as dnsPolicies from '@distilled.cloud/unifi-network/dns_policies';
|
|
13
|
+
import { makeDriftOf } from './drift.ts';
|
|
14
|
+
import { type DnsPolicyAttributes, type DnsPolicyProps, attributesOf } from './dns-policy-form.ts';
|
|
15
|
+
|
|
16
|
+
const fieldDrift = makeDriftOf<DnsPolicyAttributes, DnsPolicyProps>([
|
|
17
|
+
{ field: 'enabled', live: (a) => a.enabled, declared: (p) => p.enabled },
|
|
18
|
+
{ field: 'type', live: (a) => a.type, declared: (p) => p.type },
|
|
19
|
+
{ field: 'domain', live: (a) => a.domain, declared: (p) => p.domain },
|
|
20
|
+
{ field: 'ipv6Address', live: (a) => a.ipv6Address, declared: (p) => p.ipv6Address },
|
|
21
|
+
{ field: 'ttlSeconds', live: (a) => a.ttlSeconds, declared: (p) => p.ttlSeconds },
|
|
22
|
+
{ field: 'ipv4Address', live: (a) => a.ipv4Address, declared: (p) => p.ipv4Address },
|
|
23
|
+
{ field: 'targetDomain', live: (a) => a.targetDomain, declared: (p) => p.targetDomain },
|
|
24
|
+
{ field: 'ipAddress', live: (a) => a.ipAddress, declared: (p) => p.ipAddress },
|
|
25
|
+
{
|
|
26
|
+
field: 'mailServerDomain',
|
|
27
|
+
live: (a) => a.mailServerDomain,
|
|
28
|
+
declared: (p) => p.mailServerDomain,
|
|
29
|
+
},
|
|
30
|
+
{ field: 'priority', live: (a) => a.priority, declared: (p) => p.priority },
|
|
31
|
+
{ field: 'port', live: (a) => a.port, declared: (p) => p.port },
|
|
32
|
+
{ field: 'protocol', live: (a) => a.protocol, declared: (p) => p.protocol },
|
|
33
|
+
{ field: 'serverDomain', live: (a) => a.serverDomain, declared: (p) => p.serverDomain },
|
|
34
|
+
{ field: 'service', live: (a) => a.service, declared: (p) => p.service },
|
|
35
|
+
{ field: 'weight', live: (a) => a.weight, declared: (p) => p.weight },
|
|
36
|
+
{ field: 'text', live: (a) => a.text, declared: (p) => p.text },
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const matches = (attributes: DnsPolicyAttributes, props: DnsPolicyProps): boolean =>
|
|
40
|
+
fieldDrift(attributes, props).length === 0;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Per-field live-vs-declared drift straight from one live read — the task's own API
|
|
44
|
+
* (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive
|
|
45
|
+
* `attributesOf` itself just to call this.
|
|
46
|
+
*/
|
|
47
|
+
export const driftOf = (live: dnsPolicies.DNSPolicy, props: DnsPolicyProps) =>
|
|
48
|
+
fieldDrift(attributesOf(live, props), props);
|