@homeflare/alchemy 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/caddy/index.js.map +2 -2
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1610 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +62 -39
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +7 -0
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +588 -3
- package/dist/unifi/index.js.map +24 -4
- package/dist/unifi/paginate.d.ts +0 -12
- package/dist/unifi/paginate.d.ts.map +1 -1
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +10 -5
- package/package.json +7 -3
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +18 -6
- package/src/release/index.ts +1 -0
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
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
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared comment/string-aware source scanner for `src/unifi`'s static banned-token tests:
|
|
3
|
+
* `write-op-reference.test.ts` (T12 — no SDK write-op reference anywhere in this directory) and
|
|
4
|
+
* `wifi-broadcast-details-call.test.ts` (T23 — no reference to the passphrase-bearing
|
|
5
|
+
* `getWifiBroadcastDetails` GET). Extracted (red team, IMPORTANT-2, 2026-09-26) so both share the
|
|
6
|
+
* ONE left-to-right, single-state comment stripper instead of each maintaining its own — see the
|
|
7
|
+
* history below for why a naive or duplicated implementation is a real trap here, not a
|
|
8
|
+
* hypothetical one.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ COMMENTS ARE STRIPPED WITH A SINGLE LEFT-TO-RIGHT SCAN, NOT TWO INDEPENDENT REGEXES. An
|
|
11
|
+
* earlier "mask strings, then regex the comments" attempt had its own bug: an apostrophe in
|
|
12
|
+
* ordinary prose ("doesn't", "it's") reads as an unterminated string open to any regex that does
|
|
13
|
+
* not already know it is inside a comment, and can swallow real code up to the next quote
|
|
14
|
+
* anywhere later in the file. `stripComments` tracks ONE state (code / line comment / block
|
|
15
|
+
* comment / string) char by char left to right, so a comment can't be misread as a string and a
|
|
16
|
+
* string's contents (a banned token hidden in one, e.g. `alias['deleteNetwork']`, is still an
|
|
17
|
+
* offense) are never mistaken for a comment either.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One left-to-right scan tracking a single state (code / `//` / `/* *\/` / string). Comments are
|
|
24
|
+
* blanked to spaces (newlines kept, so a caller's own line-number diagnostics still line up);
|
|
25
|
+
* string and template literal CONTENTS are left untouched, because a banned token hidden inside
|
|
26
|
+
* one is still an offense a caller needs to see.
|
|
27
|
+
*/
|
|
28
|
+
export const stripComments = (src: string): string => {
|
|
29
|
+
let out = '';
|
|
30
|
+
for (let i = 0; i < src.length;) {
|
|
31
|
+
const two = src.slice(i, i + 2);
|
|
32
|
+
if (two === '//') {
|
|
33
|
+
const end = src.indexOf('\n', i);
|
|
34
|
+
const stop = end === -1 ? src.length : end;
|
|
35
|
+
out += ' '.repeat(stop - i);
|
|
36
|
+
i = stop;
|
|
37
|
+
} else if (two === '/*') {
|
|
38
|
+
const end = src.indexOf('*/', i + 2);
|
|
39
|
+
const stop = end === -1 ? src.length : end + 2;
|
|
40
|
+
out += src.slice(i, stop).replace(/[^\n]/g, ' ');
|
|
41
|
+
i = stop;
|
|
42
|
+
} else if (src[i] === '"' || src[i] === "'" || src[i] === '`') {
|
|
43
|
+
const quote = src[i];
|
|
44
|
+
let j = i + 1;
|
|
45
|
+
while (j < src.length && src[j] !== quote) j += src[j] === '\\' ? 2 : 1;
|
|
46
|
+
const stop = Math.min(j + 1, src.length);
|
|
47
|
+
out += src.slice(i, stop);
|
|
48
|
+
i = stop;
|
|
49
|
+
} else {
|
|
50
|
+
out += src[i];
|
|
51
|
+
i += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Every `.ts` file under `dir`, recursively — a future `src/unifi/<subdir>/*.ts` is walked too. */
|
|
58
|
+
export const walk = (dir: string): string[] =>
|
|
59
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
60
|
+
const full = join(dir, entry.name);
|
|
61
|
+
return entry.isDirectory() ? walk(full) : entry.name.endsWith('.ts') ? [full] : [];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const readSource = (path: string): string => readFileSync(path, 'utf8');
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.TrafficMatchingList`'s `matches` AND `driftOf` (B6) — both built on the ONE field list
|
|
3
|
+
* below, the same `fieldDrift`-is-the-only-comparison shape `network-drift.ts`'s header explains
|
|
4
|
+
* (MEDIUM-4, red team, 2026-09-26): `matches(attrs, props) === (driftOf(live, props).length === 0)`
|
|
5
|
+
* is true BY CONSTRUCTION, never two hand-kept-in-sync comparisons.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ `stripNullish: true` ON EVERY FIELD BY DEFAULT (`drift.ts`'s `defaultEqual`) — same "UniFi's
|
|
8
|
+
* JSON answers `null`, a declaration omits" tolerance every other family here relies on.
|
|
9
|
+
* ★ `items` compares with the plain default `equal` (whole-value `deepEqual`) — see
|
|
10
|
+
* `traffic-matching-list-form.ts`'s header for the accepted known gap this leaves for a
|
|
11
|
+
* membership-preserving reorder.
|
|
12
|
+
*/
|
|
13
|
+
import type * as trafficMatchingLists from '@distilled.cloud/unifi-network/traffic_matching_lists';
|
|
14
|
+
import { makeDriftOf } from './drift.ts';
|
|
15
|
+
import {
|
|
16
|
+
type TrafficMatchingListAttributes,
|
|
17
|
+
type TrafficMatchingListProps,
|
|
18
|
+
attributesOf,
|
|
19
|
+
} from './traffic-matching-list-form.ts';
|
|
20
|
+
|
|
21
|
+
const fieldDrift = makeDriftOf<TrafficMatchingListAttributes, TrafficMatchingListProps>([
|
|
22
|
+
{ field: 'name', live: (a) => a.name, declared: (p) => p.name },
|
|
23
|
+
{ field: 'type', live: (a) => a.type, declared: (p) => p.type },
|
|
24
|
+
{ field: 'items', live: (a) => a.items, declared: (p) => p.items },
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const matches = (
|
|
28
|
+
attributes: TrafficMatchingListAttributes,
|
|
29
|
+
props: TrafficMatchingListProps,
|
|
30
|
+
): boolean => fieldDrift(attributes, props).length === 0;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-field live-vs-declared drift straight from one live read — the task's own API
|
|
34
|
+
* (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive
|
|
35
|
+
* `attributesOf` itself just to call this.
|
|
36
|
+
*/
|
|
37
|
+
export const driftOf = (
|
|
38
|
+
live: trafficMatchingLists.TrafficMatchingList,
|
|
39
|
+
props: TrafficMatchingListProps,
|
|
40
|
+
) => fieldDrift(attributesOf(live, props), props);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.TrafficMatchingList`'s wire shape — the declarable subset of `TrafficMatchingList`
|
|
3
|
+
* (`getTrafficMatchingList`'s own decode target), and how a live read becomes both plan attributes
|
|
4
|
+
* and a rendered declaration.
|
|
5
|
+
*
|
|
6
|
+
* ★ NO `metadata` FIELD AT ALL — the simplest object shape in this directory. Unlike every other
|
|
7
|
+
* family here (`FirewallZone`/`DNSPolicy`/`ACLRule`/`FirewallPolicy` all carry a server-derived
|
|
8
|
+
* `metadata.origin`), `TrafficMatchingList` is just `{id, name, type, items?}` — nothing here is
|
|
9
|
+
* attributes-only the way `metadataOrigin`/`index` are elsewhere.
|
|
10
|
+
*
|
|
11
|
+
* ★ `items` IS A REAL TS UNION POST-A3, COMPARED WHOLESALE — `Array<IPv4Matching> |
|
|
12
|
+
* Array<IPv6Matching> | Array<PortMatching>`, keyed by the sibling `type` field
|
|
13
|
+
* (`traffic_matching_lists.ts`'s own comment: "Shape depends on the sibling discriminator value").
|
|
14
|
+
* The runtime schema is still a passthrough `S.Unknown` (A3's own changeset: narrower for a reader
|
|
15
|
+
* at the type level only, so decode keeps every key including one this pinned spec doesn't yet
|
|
16
|
+
* declare — same proof `discriminated-filter-decode.test.ts` runs for `FirewallPolicy`/`AclRule`).
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ KNOWN GAP (T15, same shape `acl-rule-form.ts`/`firewall-policy-form.ts` both carry): a traffic
|
|
19
|
+
* matching list's `items` is semantically a SET of match entries (matching is "is this traffic
|
|
20
|
+
* covered by ANY entry," not order-dependent, unlike `firewall-policy-ordering-form.ts`'s own
|
|
21
|
+
* sequences), but each entry is an OBJECT (`{type, value?, start?, stop?}`), not a bare string --
|
|
22
|
+
* there is no cheap canonical sort key the way `sortedSet` gives a `string[]`. `deepEqual` compares
|
|
23
|
+
* `items` order-sensitively, so a console-side reorder with no membership change would report a
|
|
24
|
+
* spurious drift this read-only family's `reconcile` then refuses forever. Accepted, not fixed,
|
|
25
|
+
* same proportion as ACL rule's own nested-filter gap: a generic object-set normalizer is bigger,
|
|
26
|
+
* separate modelling work, not a mechanical extension of typing `items` per-discriminator (A3).
|
|
27
|
+
*/
|
|
28
|
+
import type * as trafficMatchingLists from '@distilled.cloud/unifi-network/traffic_matching_lists';
|
|
29
|
+
|
|
30
|
+
export interface TrafficMatchingListProps {
|
|
31
|
+
siteId: string;
|
|
32
|
+
trafficMatchingListId: string;
|
|
33
|
+
name: string;
|
|
34
|
+
/** Discriminator for `items`' own shape (e.g. an IPv4/IPv6/port list) — the spec enumerates no
|
|
35
|
+
* fixed value set for it (also accepts a future vendor value it did not enumerate). */
|
|
36
|
+
type: string;
|
|
37
|
+
/** Match entries — a SET semantically, compared wholesale; see the header's known gap. */
|
|
38
|
+
items?: trafficMatchingLists.TrafficMatchingListItems | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TrafficMatchingListAttributes {
|
|
42
|
+
siteId: string;
|
|
43
|
+
trafficMatchingListId: string;
|
|
44
|
+
name: string;
|
|
45
|
+
type: string;
|
|
46
|
+
items: trafficMatchingLists.TrafficMatchingListItems | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const attributesOf = (
|
|
50
|
+
live: trafficMatchingLists.TrafficMatchingList,
|
|
51
|
+
props: TrafficMatchingListProps,
|
|
52
|
+
): TrafficMatchingListAttributes => ({
|
|
53
|
+
siteId: props.siteId,
|
|
54
|
+
trafficMatchingListId: live.id,
|
|
55
|
+
name: live.name,
|
|
56
|
+
type: live.type,
|
|
57
|
+
items: live.items,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The declaration renderer (task spec: "given one live object, return the props a declaration
|
|
62
|
+
* needs so its plan is noop"). A later import script feeds `getTrafficMatchingList`'s own response
|
|
63
|
+
* straight in and writes the result as `trafficMatchingList('<id>',
|
|
64
|
+
* declareTrafficMatchingList(live, siteId))` — no field is invented or defaulted beyond what
|
|
65
|
+
* `attributesOf` already reports, so `matches(attributesOf(live, props), declareTrafficMatchingList(
|
|
66
|
+
* live, siteId))` is `true` by construction (`traffic-matching-list.test.ts` proves it).
|
|
67
|
+
*/
|
|
68
|
+
export const declareTrafficMatchingList = (
|
|
69
|
+
live: trafficMatchingLists.TrafficMatchingList,
|
|
70
|
+
siteId: string,
|
|
71
|
+
): TrafficMatchingListProps => ({
|
|
72
|
+
siteId,
|
|
73
|
+
trafficMatchingListId: live.id,
|
|
74
|
+
name: live.name,
|
|
75
|
+
type: live.type,
|
|
76
|
+
items: live.items,
|
|
77
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.TrafficMatchingList`'s `spec` against a fake UniFi Network API — mirrors
|
|
3
|
+
* `dns-policy.test.ts`. `driftOf`/`matches` coverage lives here too, same reasoning as that file's
|
|
4
|
+
* own header (a short field list, no separate need to split under the house cap).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ I2 (red team, 2026-09-26, PR 309 LAND): `traffic-matching-list-drift.ts`'s field list is
|
|
7
|
+
* untyped strings (`makeDriftOf`'s own contract) — dropping `type` from it left every test above
|
|
8
|
+
* green. The last `describe` below closes that the same way `firewall-policy-drift.test.ts` does:
|
|
9
|
+
* `DECLARABLE_FIELDS` is a `Record<..., true>` keyed by `Exclude<keyof TrafficMatchingListProps,
|
|
10
|
+
* 'siteId' | 'trafficMatchingListId'>` — a field added to or removed from
|
|
11
|
+
* `TrafficMatchingListProps` without a matching edit there is a TYPE ERROR, so the list is
|
|
12
|
+
* complete BY CONSTRUCTION; the per-field test then proves each one is actually wired into
|
|
13
|
+
* `fieldDrift`, not merely present in the type.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import type * as trafficMatchingLists from '@distilled.cloud/unifi-network/traffic_matching_lists';
|
|
17
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
20
|
+
import { attributesOf } from './traffic-matching-list-form.ts';
|
|
21
|
+
import { driftOf, matches } from './traffic-matching-list-drift.ts';
|
|
22
|
+
import {
|
|
23
|
+
type TrafficMatchingListProps,
|
|
24
|
+
declareTrafficMatchingList,
|
|
25
|
+
spec,
|
|
26
|
+
} from './traffic-matching-list.ts';
|
|
27
|
+
import { unifiOperations } from './resource.ts';
|
|
28
|
+
|
|
29
|
+
const LIST_PATH = '/proxy/network/integration/v1/sites/site-1/traffic-matching-lists/tml-1';
|
|
30
|
+
|
|
31
|
+
const liveTrafficMatchingList = (
|
|
32
|
+
overrides: Partial<trafficMatchingLists.TrafficMatchingList> = {},
|
|
33
|
+
): trafficMatchingLists.TrafficMatchingList => ({
|
|
34
|
+
id: 'tml-1',
|
|
35
|
+
name: 'Office subnets',
|
|
36
|
+
type: 'IPV4',
|
|
37
|
+
items: [{ type: 'SUBNET', value: '192.0.2.0/24' }],
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const PROPS: TrafficMatchingListProps = {
|
|
42
|
+
siteId: 'site-1',
|
|
43
|
+
trafficMatchingListId: 'tml-1',
|
|
44
|
+
name: 'Office subnets',
|
|
45
|
+
type: 'IPV4',
|
|
46
|
+
items: [{ type: 'SUBNET', value: '192.0.2.0/24' }],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe('Unifi.TrafficMatchingList spec.fetchLive', () => {
|
|
50
|
+
test('a real GET response decodes into the typed live object', async () => {
|
|
51
|
+
const fake = fakeUnifi((method, url) =>
|
|
52
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
53
|
+
? Response.json(liveTrafficMatchingList())
|
|
54
|
+
: fakeFailure(400, 'unexpected request'),
|
|
55
|
+
);
|
|
56
|
+
const live = await Effect.runPromise(
|
|
57
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
58
|
+
);
|
|
59
|
+
expect(live?.id).toBe('tml-1');
|
|
60
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: LIST_PATH }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a genuine 404 folds to undefined -- never a false "unreadable" on anything else', async () => {
|
|
64
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
65
|
+
const live = await Effect.runPromise(
|
|
66
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
67
|
+
);
|
|
68
|
+
expect(live).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
|
|
72
|
+
const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
|
|
73
|
+
const failure = await Effect.runPromise(
|
|
74
|
+
Effect.flip(
|
|
75
|
+
spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
expect(failure._tag).toBe('InternalServerError');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('declareTrafficMatchingList -- the declaration renderer', () => {
|
|
83
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
84
|
+
// A different discriminator case than the default fixture (`PortMatching[]`, not
|
|
85
|
+
// `IPv4Matching[]`) -- a fresh literal, not the SUBNET fixture's overrides.
|
|
86
|
+
const live: trafficMatchingLists.TrafficMatchingList = {
|
|
87
|
+
id: 'tml-2',
|
|
88
|
+
name: 'Management ports',
|
|
89
|
+
type: 'PORT',
|
|
90
|
+
items: [{ type: 'RANGE', start: 8000, stop: 8100 }],
|
|
91
|
+
};
|
|
92
|
+
const declared = declareTrafficMatchingList(live, 'site-1');
|
|
93
|
+
const attrs = attributesOf(live, declared);
|
|
94
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
95
|
+
expect(driftOf(live, declared)).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('driftOf -- B6 field-level drift, straight from one live read', () => {
|
|
100
|
+
test('a real name change reports its own field, live and declared', () => {
|
|
101
|
+
const live = liveTrafficMatchingList({ name: 'Office /24s' });
|
|
102
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
103
|
+
{ field: 'name', live: 'Office /24s', declared: 'Office subnets' },
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('items compares wholesale -- a real membership change is caught', () => {
|
|
108
|
+
const live = liveTrafficMatchingList({ items: [{ type: 'SUBNET', value: '203.0.113.0/24' }] });
|
|
109
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
110
|
+
{
|
|
111
|
+
field: 'items',
|
|
112
|
+
live: [{ type: 'SUBNET', value: '203.0.113.0/24' }],
|
|
113
|
+
declared: [{ type: 'SUBNET', value: '192.0.2.0/24' }],
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a live object with no items key at all is a noop against declared undefined', () => {
|
|
119
|
+
// `exactOptionalPropertyTypes`: the SDK's own `items?` type has no explicit `| undefined`, so
|
|
120
|
+
// this omits the key entirely rather than setting it to `undefined` -- the real wire shape for
|
|
121
|
+
// a list with no match entries yet.
|
|
122
|
+
const live: trafficMatchingLists.TrafficMatchingList = {
|
|
123
|
+
id: 'tml-1',
|
|
124
|
+
name: 'Office subnets',
|
|
125
|
+
type: 'IPV4',
|
|
126
|
+
};
|
|
127
|
+
const props: TrafficMatchingListProps = { ...PROPS, items: undefined };
|
|
128
|
+
expect(matches(attributesOf(live, props), props)).toBe(true);
|
|
129
|
+
expect(driftOf(live, props)).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('Unifi.TrafficMatchingList write paths never reach the vendor API', () => {
|
|
134
|
+
const ops = unifiOperations(spec);
|
|
135
|
+
|
|
136
|
+
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
137
|
+
const fake = fakeUnifi((method, url) =>
|
|
138
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
139
|
+
? Response.json(liveTrafficMatchingList())
|
|
140
|
+
: fakeFailure(400, 'unexpected request'),
|
|
141
|
+
);
|
|
142
|
+
const exit = await Effect.runPromiseExit(
|
|
143
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
144
|
+
);
|
|
145
|
+
expect(exit._tag).toBe('Success');
|
|
146
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('reconcile on drift refuses instead of PUTting a merged body', async () => {
|
|
150
|
+
const fake = fakeUnifi((method, url) =>
|
|
151
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
152
|
+
? Response.json(liveTrafficMatchingList({ name: 'Renamed elsewhere' }))
|
|
153
|
+
: fakeFailure(400, 'unexpected request'),
|
|
154
|
+
);
|
|
155
|
+
const exit = await Effect.runPromiseExit(
|
|
156
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
157
|
+
);
|
|
158
|
+
expect(exit._tag).toBe('Failure');
|
|
159
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
163
|
+
const fake = fakeUnifi((method, url) =>
|
|
164
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
165
|
+
? Response.json(liveTrafficMatchingList())
|
|
166
|
+
: fakeFailure(400, 'unexpected request'),
|
|
167
|
+
);
|
|
168
|
+
const exit = await Effect.runPromiseExit(
|
|
169
|
+
ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
170
|
+
);
|
|
171
|
+
expect(exit._tag).toBe('Failure');
|
|
172
|
+
expect(fake.seen).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
type DeclarableField = Exclude<keyof TrafficMatchingListProps, 'siteId' | 'trafficMatchingListId'>;
|
|
177
|
+
|
|
178
|
+
const DECLARABLE_FIELDS: Record<DeclarableField, true> = {
|
|
179
|
+
name: true,
|
|
180
|
+
type: true,
|
|
181
|
+
items: true,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** One changed value per field -- distinct enough from PROPS' own that `deepEqual` calls it drift. */
|
|
185
|
+
const CHANGED_VALUE: { [K in DeclarableField]: TrafficMatchingListProps[K] } = {
|
|
186
|
+
name: 'a different name',
|
|
187
|
+
type: 'PORT',
|
|
188
|
+
items: [{ type: 'RANGE', start: 8000, stop: 8100 }],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
describe('I2 -- every declarable field is wired into the drift field list', () => {
|
|
192
|
+
// `liveTrafficMatchingList()` (no overrides) matches PROPS field-for-field, so changing exactly
|
|
193
|
+
// one PROPS field per case isolates that field's own wire-up.
|
|
194
|
+
test.each(Object.keys(DECLARABLE_FIELDS) as DeclarableField[])(
|
|
195
|
+
'changing only %s reports exactly that field',
|
|
196
|
+
(field) => {
|
|
197
|
+
const live = liveTrafficMatchingList();
|
|
198
|
+
const changedProps: TrafficMatchingListProps = { ...PROPS, [field]: CHANGED_VALUE[field] };
|
|
199
|
+
expect(driftOf(live, changedProps).map((d) => d.field)).toEqual([field]);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.TrafficMatchingList` — one traffic matching list (IPv4/IPv6/port match entries) on a UniFi
|
|
3
|
+
* Network site, READ-ONLY (`policy.ts`).
|
|
4
|
+
*
|
|
5
|
+
* ★ T11 GATE, CHECKED 2026-09-26: `Traffic Matching Lists`' 5 operations are byte-identical between
|
|
6
|
+
* 10.4.57 and 10.6.97, full raw operation objects; the 19-schema closure reachable from all 5
|
|
7
|
+
* (`$ref` + `discriminator.mapping`) shares zero names with the 14 schemas that DID change
|
|
8
|
+
* elsewhere in the document. Corroborated independently by the A3 changeset's own broader
|
|
9
|
+
* 25-operation/139-schema re-check (`.changeset/unifi-network-a3-typed-discriminator-filters.md`).
|
|
10
|
+
* Full breakdown: `docs/unifi-traffic-matching-list.md`.
|
|
11
|
+
*/
|
|
12
|
+
import { Resource } from 'alchemy';
|
|
13
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
14
|
+
import * as Provider from 'alchemy/Provider';
|
|
15
|
+
import * as trafficMatchingLists from '@distilled.cloud/unifi-network/traffic_matching_lists';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import {
|
|
18
|
+
type TrafficMatchingListAttributes,
|
|
19
|
+
type TrafficMatchingListProps,
|
|
20
|
+
attributesOf,
|
|
21
|
+
} from './traffic-matching-list-form.ts';
|
|
22
|
+
import { matches } from './traffic-matching-list-drift.ts';
|
|
23
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
24
|
+
import { unifiHandlers } from './resource.ts';
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
TrafficMatchingListAttributes,
|
|
28
|
+
TrafficMatchingListProps,
|
|
29
|
+
} from './traffic-matching-list-form.ts';
|
|
30
|
+
export { declareTrafficMatchingList } from './traffic-matching-list-form.ts';
|
|
31
|
+
export { driftOf } from './traffic-matching-list-drift.ts';
|
|
32
|
+
|
|
33
|
+
export interface UnifiTrafficMatchingList extends Resource<
|
|
34
|
+
'Unifi.TrafficMatchingList',
|
|
35
|
+
TrafficMatchingListProps,
|
|
36
|
+
TrafficMatchingListAttributes,
|
|
37
|
+
never,
|
|
38
|
+
UnifiRequirements
|
|
39
|
+
> {}
|
|
40
|
+
|
|
41
|
+
export const UnifiTrafficMatchingList = Resource<UnifiTrafficMatchingList>(
|
|
42
|
+
'Unifi.TrafficMatchingList',
|
|
43
|
+
{ defaultRemovalPolicy: 'retain' },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** `trafficMatchingList('office-subnets', props)` — `adopt(true)` piped on by default (H5); see
|
|
47
|
+
* `network.ts`. */
|
|
48
|
+
export const trafficMatchingList = (id: string, props: TrafficMatchingListProps) =>
|
|
49
|
+
UnifiTrafficMatchingList(id, props).pipe(adopt(true));
|
|
50
|
+
|
|
51
|
+
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
52
|
+
export const spec: UnifiSpec<
|
|
53
|
+
TrafficMatchingListProps,
|
|
54
|
+
trafficMatchingLists.TrafficMatchingList,
|
|
55
|
+
TrafficMatchingListAttributes,
|
|
56
|
+
trafficMatchingLists.GetTrafficMatchingListError
|
|
57
|
+
> = {
|
|
58
|
+
type: 'Unifi.TrafficMatchingList',
|
|
59
|
+
describe: (props) =>
|
|
60
|
+
`sites/${props.siteId}/traffic-matching-lists/${props.trafficMatchingListId}`,
|
|
61
|
+
fetchLive: (props) =>
|
|
62
|
+
trafficMatchingLists
|
|
63
|
+
.getTrafficMatchingList({
|
|
64
|
+
siteId: props.siteId,
|
|
65
|
+
trafficMatchingListId: props.trafficMatchingListId,
|
|
66
|
+
})
|
|
67
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
68
|
+
attributes: attributesOf,
|
|
69
|
+
matches,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const handlers = unifiHandlers(spec);
|
|
73
|
+
|
|
74
|
+
export const UnifiTrafficMatchingListProvider = () =>
|
|
75
|
+
Provider.effect(
|
|
76
|
+
UnifiTrafficMatchingList,
|
|
77
|
+
Effect.succeed(UnifiTrafficMatchingList.Provider.of(handlers)),
|
|
78
|
+
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T23 static guard (red team, IMPORTANT-2, 2026-09-26): no file under `src/unifi` may reference
|
|
3
|
+
* `getWifiBroadcastDetails` — the single-object WiFi Broadcast GET whose `securityConfiguration`
|
|
4
|
+
* carries the WPA passphrase and every PPSK entry's own passphrase (`wifi-broadcast.ts`'s header;
|
|
5
|
+
* SDK `wifi_broadcasts.ts`). Confirmed missing before this file existed: `write-op-reference.test.ts`
|
|
6
|
+
* only bans WRITE verbs (create/update/delete/patch/execute/remove/adopt) — nothing stopped a
|
|
7
|
+
* future edit from calling this GET, even though it is exactly as forbidden here as a write op is.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ NOT MERGED INTO `write-op-reference.test.ts`'s OWN SCAN. That scan's `WRITE_OP_NAME` regex and
|
|
10
|
+
* its `sdkWriteOpNames` harvest are BOTH scoped to `BANNED_VERBS` — `getWifiBroadcastDetails`
|
|
11
|
+
* starts with `get`, so it can never match there, and widening that regex to catch it would also
|
|
12
|
+
* start matching ordinary read helpers this family and every other one legitimately call
|
|
13
|
+
* (`getNetworkDetails`, `getWifiBroadcastPage`, …). This is a single hand-named token, not a verb
|
|
14
|
+
* class, so it gets its own small scan built on the SAME comment/string-aware scanner
|
|
15
|
+
* (`scan-source.ts`) rather than a real design bent to fit inside the other one.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ NO `SDK_MARKER` GATE, UNLIKE `write-op-reference.test.ts`. That gate exists there because
|
|
18
|
+
* `create`/`delete`/… are common English words that need the "and this file imports the SDK" ANDed
|
|
19
|
+
* check to avoid false positives; `getWifiBroadcastDetails` is specific enough that its bare
|
|
20
|
+
* appearance anywhere in code (not a comment) already IS the offense, with no plausible unrelated
|
|
21
|
+
* source for that exact identifier.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ ONE NAMED EXCEPTION, NOT A PATTERN. `errors-and-secrets.test.ts` (pre-dates this file — A2,
|
|
24
|
+
* kit PR #300) calls `getWifiBroadcastDetails` directly ON PURPOSE: it is the runtime proof that
|
|
25
|
+
* the SDK's OWN regen redacts the WPA/PPSK passphrase to `Redacted.Redacted<string>` at the wire
|
|
26
|
+
* (T23's `sensitivePatterns` fix), which is impossible to prove without actually calling the
|
|
27
|
+
* details endpoint at the SDK layer. That is a DIFFERENT claim from this ban's actual target —
|
|
28
|
+
* "no `Unifi.WifiBroadcast` RESOURCE/family file ever calls it" — so it is named here explicitly,
|
|
29
|
+
* not folded into a looser rule that would also excuse a future family file. A new exception
|
|
30
|
+
* needs the same caliber of justification (proving a lower-layer guarantee that cannot be proven
|
|
31
|
+
* without the call) and its own line here, not a widened pattern.
|
|
32
|
+
*/
|
|
33
|
+
import { describe, expect, test } from 'bun:test';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { readSource, stripComments, walk } from './scan-source.ts';
|
|
37
|
+
|
|
38
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
39
|
+
const DIR = dirname(SELF);
|
|
40
|
+
const BANNED_TOKEN = 'getWifiBroadcastDetails';
|
|
41
|
+
const TOKEN_PATTERN = new RegExp(`\\b${BANNED_TOKEN}\\b`);
|
|
42
|
+
|
|
43
|
+
/** The one file allowed to reference the banned token, and why — see this file's header. */
|
|
44
|
+
const ALLOWED_REFERENCES: ReadonlySet<string> = new Set([join(DIR, 'errors-and-secrets.test.ts')]);
|
|
45
|
+
|
|
46
|
+
const sourceFiles = () =>
|
|
47
|
+
walk(DIR).filter((path) => path !== SELF && !ALLOWED_REFERENCES.has(path));
|
|
48
|
+
|
|
49
|
+
const referencesBannedToken = (rawSrc: string): boolean =>
|
|
50
|
+
TOKEN_PATTERN.test(stripComments(rawSrc));
|
|
51
|
+
|
|
52
|
+
describe('src/unifi never calls getWifiBroadcastDetails (T23, IMPORTANT-2)', () => {
|
|
53
|
+
test('every source file, except the one named exception, is clean of the details call', () => {
|
|
54
|
+
const offenders = sourceFiles().filter((path) => referencesBannedToken(readSource(path)));
|
|
55
|
+
expect(offenders).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the named exception still exists and still is the one making the call (guards a silent rename)', () => {
|
|
59
|
+
const [exceptionPath] = [...ALLOWED_REFERENCES];
|
|
60
|
+
if (exceptionPath === undefined) throw new Error('ALLOWED_REFERENCES must not be empty');
|
|
61
|
+
expect(referencesBannedToken(readSource(exceptionPath))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a comment or prose mention of the banned name never trips the scan', () => {
|
|
65
|
+
// This file's own header, and wifi-broadcast.ts's, both name the token in prose -- proving the
|
|
66
|
+
// scan is comment-aware IS the point: a naive substring scan would flag the very files that
|
|
67
|
+
// document why the call is forbidden.
|
|
68
|
+
const documented = `
|
|
69
|
+
/** Never call getWifiBroadcastDetails directly -- see T23. */
|
|
70
|
+
// getWifiBroadcastDetails is refused by policy.ts too.
|
|
71
|
+
export const read = () => 1;
|
|
72
|
+
`;
|
|
73
|
+
expect(referencesBannedToken(documented)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a real reference to the banned call is caught regardless of syntax shape', () => {
|
|
77
|
+
const marker = '@distilled.cloud/unifi-network/wifi_broadcasts';
|
|
78
|
+
const cases = [
|
|
79
|
+
`import { getWifiBroadcastDetails } from '${marker}';`,
|
|
80
|
+
`import { getWifiBroadcastDetails as readOne } from '${marker}'; readOne;`,
|
|
81
|
+
`export { getWifiBroadcastDetails } from '${marker}';`,
|
|
82
|
+
`const fn = (m: Record<string, unknown>) => m['getWifiBroadcastDetails'];`,
|
|
83
|
+
`const fn = async () => (await import('${marker}')).getWifiBroadcastDetails;`,
|
|
84
|
+
];
|
|
85
|
+
for (const src of cases) expect(referencesBannedToken(src)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the walk is recursive, matching write-op-reference.test.ts', () => {
|
|
89
|
+
expect(sourceFiles().length).toBeGreaterThan(0);
|
|
90
|
+
expect(sourceFiles()).not.toContain(SELF);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.WifiBroadcast`'s `matches` AND `driftOf` (B6) — built on the ONE field list below, the
|
|
3
|
+
* same `fieldDrift`-is-the-only-comparison shape `dns-policy-drift.ts`'s header explains
|
|
4
|
+
* (MEDIUM-4): `matches(attrs, props) === (driftOf(live, props).length === 0)` is true BY
|
|
5
|
+
* CONSTRUCTION, never two hand-kept-in-sync comparisons.
|
|
6
|
+
*
|
|
7
|
+
* ★ THREE FIELDS REUSE `wifi-broadcast-form.ts`'s OWN NORMALIZERS ON THE `declared` SIDE TOO, not
|
|
8
|
+
* just the `live` side — `firewall-zone-form.ts`'s `networkIds` gives the same reason: comparing
|
|
9
|
+
* a freshly-normalized live value against an un-normalized declared value would report spurious
|
|
10
|
+
* drift the moment a committed declaration's array happens to be in a different (still valid)
|
|
11
|
+
* order than what `declareWifiBroadcast` would itself produce today.
|
|
12
|
+
*/
|
|
13
|
+
import type * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
|
|
14
|
+
import { makeDriftOf } from './drift.ts';
|
|
15
|
+
import {
|
|
16
|
+
type WifiBroadcastAttributes,
|
|
17
|
+
type WifiBroadcastProps,
|
|
18
|
+
attributesOf,
|
|
19
|
+
normalizeDeviceFilter,
|
|
20
|
+
normalizeFrequencies,
|
|
21
|
+
normalizeHotspot,
|
|
22
|
+
normalizeRef,
|
|
23
|
+
normalizeSecurityConfiguration,
|
|
24
|
+
} from './wifi-broadcast-form.ts';
|
|
25
|
+
|
|
26
|
+
const fieldDrift = makeDriftOf<WifiBroadcastAttributes, WifiBroadcastProps>([
|
|
27
|
+
{ field: 'name', live: (a) => a.name, declared: (p) => p.name },
|
|
28
|
+
{ field: 'enabled', live: (a) => a.enabled, declared: (p) => p.enabled },
|
|
29
|
+
{ field: 'type', live: (a) => a.type, declared: (p) => p.type },
|
|
30
|
+
{
|
|
31
|
+
// IMPORTANT-1: `a.network` is already rebuilt field-by-field inside `attributesOf`; the
|
|
32
|
+
// declared side gets the same treatment so a hand-written declaration carrying a stray key
|
|
33
|
+
// cannot report phantom drift against a live side that never could.
|
|
34
|
+
field: 'network',
|
|
35
|
+
live: (a) => a.network,
|
|
36
|
+
declared: (p) => normalizeRef(p.network),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
field: 'broadcastingDeviceFilter',
|
|
40
|
+
live: (a) => a.broadcastingDeviceFilter,
|
|
41
|
+
declared: (p) => normalizeDeviceFilter(p.broadcastingDeviceFilter),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
field: 'broadcastingFrequenciesGHz',
|
|
45
|
+
live: (a) => a.broadcastingFrequenciesGHz,
|
|
46
|
+
declared: (p) => normalizeFrequencies(p.broadcastingFrequenciesGHz),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
field: 'hotspotConfiguration',
|
|
50
|
+
live: (a) => a.hotspotConfiguration,
|
|
51
|
+
declared: (p) => normalizeHotspot(p.hotspotConfiguration),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
field: 'securityConfiguration',
|
|
55
|
+
live: (a) => a.securityConfiguration,
|
|
56
|
+
declared: (p) => normalizeSecurityConfiguration(p.securityConfiguration),
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
export const matches = (attributes: WifiBroadcastAttributes, props: WifiBroadcastProps): boolean =>
|
|
61
|
+
fieldDrift(attributes, props).length === 0;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Per-field live-vs-declared drift straight from one live read — the task's own API
|
|
65
|
+
* (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive
|
|
66
|
+
* `attributesOf` itself just to call this.
|
|
67
|
+
*/
|
|
68
|
+
export const driftOf = (live: wifiBroadcasts.WifiBroadcastOverview, props: WifiBroadcastProps) =>
|
|
69
|
+
fieldDrift(attributesOf(live, props), props);
|