@homeflare/alchemy 0.39.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 +10 -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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +15 -6
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- 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/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 +6 -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/index.ts +1 -0
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallPolicyOrdering`'s `spec` against a fake UniFi Network API — mirrors
|
|
3
|
+
* `acl-rule-ordering.test.ts`. The two things this file exists to prove that no other family test
|
|
4
|
+
* does: the zone-pair QUERY identity actually reaches the wire (unlike `acl-rule-ordering.ts`'s
|
|
5
|
+
* one-per-site path with no query at all), and T5's "order IS the value, never `sortedSet`" rule
|
|
6
|
+
* holds independently for BOTH `beforeSystemDefined` and `afterSystemDefined`.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import type * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
10
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
13
|
+
import { attributesOf, driftOf, matches } from './firewall-policy-ordering-form.ts';
|
|
14
|
+
import {
|
|
15
|
+
type FirewallPolicyOrderingProps,
|
|
16
|
+
declareFirewallPolicyOrdering,
|
|
17
|
+
spec,
|
|
18
|
+
} from './firewall-policy-ordering.ts';
|
|
19
|
+
import { unifiOperations } from './resource.ts';
|
|
20
|
+
|
|
21
|
+
const ORDERING_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/policies/ordering';
|
|
22
|
+
|
|
23
|
+
const liveOrdering = (
|
|
24
|
+
overrides: Partial<firewall.IntegrationFirewallPolicyOrderingDto> = {},
|
|
25
|
+
): firewall.IntegrationFirewallPolicyOrderingDto => ({
|
|
26
|
+
orderedFirewallPolicyIds: {
|
|
27
|
+
beforeSystemDefined: ['fp-1', 'fp-2'],
|
|
28
|
+
afterSystemDefined: ['fp-9'],
|
|
29
|
+
},
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const PROPS: FirewallPolicyOrderingProps = {
|
|
34
|
+
siteId: 'site-1',
|
|
35
|
+
sourceFirewallZoneId: 'zone-iot',
|
|
36
|
+
destinationFirewallZoneId: 'zone-external',
|
|
37
|
+
beforeSystemDefined: ['fp-1', 'fp-2'],
|
|
38
|
+
afterSystemDefined: ['fp-9'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('Unifi.FirewallPolicyOrdering spec.fetchLive', () => {
|
|
42
|
+
test('a real GET response decodes into the typed live object, with the zone pair as a query', async () => {
|
|
43
|
+
const fake = fakeUnifi((method, url) =>
|
|
44
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
45
|
+
? Response.json(liveOrdering())
|
|
46
|
+
: fakeFailure(400, 'unexpected request'),
|
|
47
|
+
);
|
|
48
|
+
const live = await Effect.runPromise(
|
|
49
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
50
|
+
);
|
|
51
|
+
expect(live?.orderedFirewallPolicyIds.beforeSystemDefined).toEqual(['fp-1', 'fp-2']);
|
|
52
|
+
expect(fake.seen).toHaveLength(1);
|
|
53
|
+
const sentUrl = new URL(`http://x${fake.seen[0]?.path}`);
|
|
54
|
+
expect(sentUrl.pathname).toBe(ORDERING_PATH);
|
|
55
|
+
expect(sentUrl.searchParams.get('sourceFirewallZoneId')).toBe('zone-iot');
|
|
56
|
+
expect(sentUrl.searchParams.get('destinationFirewallZoneId')).toBe('zone-external');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a genuine 404 folds to undefined', async () => {
|
|
60
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
61
|
+
const live = await Effect.runPromise(
|
|
62
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
63
|
+
);
|
|
64
|
+
expect(live).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
|
|
68
|
+
const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
|
|
69
|
+
const failure = await Effect.runPromise(
|
|
70
|
+
Effect.flip(
|
|
71
|
+
spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
expect(failure._tag).toBe('InternalServerError');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('declareFirewallPolicyOrdering -- the declaration renderer', () => {
|
|
79
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
80
|
+
const live = liveOrdering({
|
|
81
|
+
orderedFirewallPolicyIds: { beforeSystemDefined: ['fp-5'], afterSystemDefined: [] },
|
|
82
|
+
});
|
|
83
|
+
const declared = declareFirewallPolicyOrdering(live, 'site-1', 'zone-iot', 'zone-external');
|
|
84
|
+
const attrs = attributesOf(live, declared);
|
|
85
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('T5 -- order IS the value, never sortedSet, independently on each half', () => {
|
|
90
|
+
test('an identical pair of lists is a noop', () => {
|
|
91
|
+
expect(driftOf(liveOrdering(), PROPS)).toEqual([]);
|
|
92
|
+
expect(matches(attributesOf(liveOrdering(), PROPS), PROPS)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the SAME ids in a DIFFERENT order within beforeSystemDefined is real drift', () => {
|
|
96
|
+
const live = liveOrdering({
|
|
97
|
+
orderedFirewallPolicyIds: {
|
|
98
|
+
beforeSystemDefined: ['fp-2', 'fp-1'],
|
|
99
|
+
afterSystemDefined: ['fp-9'],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
|
|
103
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
104
|
+
{ field: 'beforeSystemDefined', live: ['fp-2', 'fp-1'], declared: ['fp-1', 'fp-2'] },
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a reorder within afterSystemDefined alone is caught, beforeSystemDefined untouched', () => {
|
|
109
|
+
const live = liveOrdering({
|
|
110
|
+
orderedFirewallPolicyIds: {
|
|
111
|
+
beforeSystemDefined: ['fp-1', 'fp-2'],
|
|
112
|
+
afterSystemDefined: ['fp-9', 'fp-10'],
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const props: FirewallPolicyOrderingProps = { ...PROPS, afterSystemDefined: ['fp-10', 'fp-9'] };
|
|
116
|
+
expect(driftOf(live, props)).toEqual([
|
|
117
|
+
{ field: 'afterSystemDefined', live: ['fp-9', 'fp-10'], declared: ['fp-10', 'fp-9'] },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a policy moving from beforeSystemDefined to afterSystemDefined drifts on both fields', () => {
|
|
122
|
+
// The two halves can never swap through this endpoint by "reordering" alone -- membership moving
|
|
123
|
+
// across them is a genuine drift on each side independently, not one combined-list reorder.
|
|
124
|
+
const live = liveOrdering({
|
|
125
|
+
orderedFirewallPolicyIds: {
|
|
126
|
+
beforeSystemDefined: ['fp-1'],
|
|
127
|
+
afterSystemDefined: ['fp-2', 'fp-9'],
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
expect(driftOf(live, PROPS)).toEqual([
|
|
131
|
+
{ field: 'beforeSystemDefined', live: ['fp-1'], declared: ['fp-1', 'fp-2'] },
|
|
132
|
+
{ field: 'afterSystemDefined', live: ['fp-2', 'fp-9'], declared: ['fp-9'] },
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('Unifi.FirewallPolicyOrdering write paths never reach the vendor API', () => {
|
|
138
|
+
const ops = unifiOperations(spec);
|
|
139
|
+
|
|
140
|
+
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
141
|
+
const fake = fakeUnifi((method, url) =>
|
|
142
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
143
|
+
? Response.json(liveOrdering())
|
|
144
|
+
: fakeFailure(400, 'unexpected request'),
|
|
145
|
+
);
|
|
146
|
+
const exit = await Effect.runPromiseExit(
|
|
147
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
148
|
+
);
|
|
149
|
+
expect(exit._tag).toBe('Success');
|
|
150
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('reconcile on drift (a reorder) refuses instead of PUTting the whole pair', async () => {
|
|
154
|
+
const fake = fakeUnifi((method, url) =>
|
|
155
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
156
|
+
? Response.json(
|
|
157
|
+
liveOrdering({
|
|
158
|
+
orderedFirewallPolicyIds: {
|
|
159
|
+
beforeSystemDefined: ['fp-2', 'fp-1'],
|
|
160
|
+
afterSystemDefined: [],
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
: fakeFailure(400, 'unexpected request'),
|
|
165
|
+
);
|
|
166
|
+
const exit = await Effect.runPromiseExit(
|
|
167
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
168
|
+
);
|
|
169
|
+
expect(exit._tag).toBe('Failure');
|
|
170
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
174
|
+
const fake = fakeUnifi((method, url) =>
|
|
175
|
+
method === 'GET' && url.pathname === ORDERING_PATH
|
|
176
|
+
? Response.json(liveOrdering())
|
|
177
|
+
: fakeFailure(400, 'unexpected request'),
|
|
178
|
+
);
|
|
179
|
+
const exit = await Effect.runPromiseExit(
|
|
180
|
+
ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
181
|
+
);
|
|
182
|
+
expect(exit._tag).toBe('Failure');
|
|
183
|
+
expect(fake.seen).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallPolicyOrdering` — the ordered `before`/`afterSystemDefined` policy-id lists for
|
|
3
|
+
* ONE `(sourceFirewallZoneId, destinationFirewallZoneId)` zone pair (`getFirewallPolicyOrdering`,
|
|
4
|
+
* `GET /v1/sites/{siteId}/firewall/policies/ordering`), READ-ONLY (`policy.ts`). T5/I4's per-family
|
|
5
|
+
* variant: `docs/unifi.md`'s own header named this "a future `Unifi.FirewallPolicyOrdering`" as a
|
|
6
|
+
* separate, bigger PR (see `acl-rule-ordering.ts`'s header for the same forward reference, now
|
|
7
|
+
* resolved) — this is that resource.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ NOT ONE OBJECT PER SITE, UNLIKE `acl-rule-ordering.ts` — the identity is the TRIPLE
|
|
10
|
+
* `(siteId, sourceFirewallZoneId, destinationFirewallZoneId)`, both zone ids passed as QUERY
|
|
11
|
+
* parameters on the same path every time (`firewall.ts`'s `GetFirewallPolicyOrderingRequest`).
|
|
12
|
+
* There is no "list every zone pair with an ordering" endpoint — a caller (a future Lane C import)
|
|
13
|
+
* has to already know which pairs exist, typically by walking `firewall.getFirewallPolicies`
|
|
14
|
+
* (T9's own 424-live-policy scale — B0b's `pageAll`, see `firewall-policy.test.ts`'s own decode
|
|
15
|
+
* proof) and grouping by each policy's `source.zoneId`/`destination.zoneId`. Nothing here performs
|
|
16
|
+
* that discovery — this resource is one already-known pair's ordering, same as
|
|
17
|
+
* `firewall-policy.ts`/`acl-rule.ts` are one already-known object's own fields.
|
|
18
|
+
*/
|
|
19
|
+
import { Resource } from 'alchemy';
|
|
20
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
21
|
+
import * as Provider from 'alchemy/Provider';
|
|
22
|
+
import * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
import {
|
|
25
|
+
type FirewallPolicyOrderingAttributes,
|
|
26
|
+
type FirewallPolicyOrderingProps,
|
|
27
|
+
attributesOf,
|
|
28
|
+
matches,
|
|
29
|
+
} from './firewall-policy-ordering-form.ts';
|
|
30
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
31
|
+
import { unifiHandlers } from './resource.ts';
|
|
32
|
+
|
|
33
|
+
export type {
|
|
34
|
+
FirewallPolicyOrderingAttributes,
|
|
35
|
+
FirewallPolicyOrderingProps,
|
|
36
|
+
} from './firewall-policy-ordering-form.ts';
|
|
37
|
+
export { declareFirewallPolicyOrdering, driftOf } from './firewall-policy-ordering-form.ts';
|
|
38
|
+
|
|
39
|
+
export interface UnifiFirewallPolicyOrdering extends Resource<
|
|
40
|
+
'Unifi.FirewallPolicyOrdering',
|
|
41
|
+
FirewallPolicyOrderingProps,
|
|
42
|
+
FirewallPolicyOrderingAttributes,
|
|
43
|
+
never,
|
|
44
|
+
UnifiRequirements
|
|
45
|
+
> {}
|
|
46
|
+
|
|
47
|
+
export const UnifiFirewallPolicyOrdering = Resource<UnifiFirewallPolicyOrdering>(
|
|
48
|
+
'Unifi.FirewallPolicyOrdering',
|
|
49
|
+
{ defaultRemovalPolicy: 'retain' },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** `firewallPolicyOrdering('iot-to-external', props)` — `adopt(true)` piped on by default (H5); see
|
|
53
|
+
* `network.ts`. */
|
|
54
|
+
export const firewallPolicyOrdering = (id: string, props: FirewallPolicyOrderingProps) =>
|
|
55
|
+
UnifiFirewallPolicyOrdering(id, props).pipe(adopt(true));
|
|
56
|
+
|
|
57
|
+
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
58
|
+
export const spec: UnifiSpec<
|
|
59
|
+
FirewallPolicyOrderingProps,
|
|
60
|
+
firewall.IntegrationFirewallPolicyOrderingDto,
|
|
61
|
+
FirewallPolicyOrderingAttributes,
|
|
62
|
+
firewall.GetFirewallPolicyOrderingError
|
|
63
|
+
> = {
|
|
64
|
+
type: 'Unifi.FirewallPolicyOrdering',
|
|
65
|
+
describe: (props) =>
|
|
66
|
+
`sites/${props.siteId}/firewall/policies/ordering` +
|
|
67
|
+
`?sourceFirewallZoneId=${props.sourceFirewallZoneId}` +
|
|
68
|
+
`&destinationFirewallZoneId=${props.destinationFirewallZoneId}`,
|
|
69
|
+
fetchLive: (props) =>
|
|
70
|
+
firewall
|
|
71
|
+
.getFirewallPolicyOrdering({
|
|
72
|
+
siteId: props.siteId,
|
|
73
|
+
sourceFirewallZoneId: props.sourceFirewallZoneId,
|
|
74
|
+
destinationFirewallZoneId: props.destinationFirewallZoneId,
|
|
75
|
+
})
|
|
76
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
77
|
+
attributes: attributesOf,
|
|
78
|
+
matches,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const handlers = unifiHandlers(spec);
|
|
82
|
+
|
|
83
|
+
export const UnifiFirewallPolicyOrderingProvider = () =>
|
|
84
|
+
Provider.effect(
|
|
85
|
+
UnifiFirewallPolicyOrdering,
|
|
86
|
+
Effect.succeed(UnifiFirewallPolicyOrdering.Provider.of(handlers)),
|
|
87
|
+
);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallPolicy`'s `spec` against a fake UniFi Network API — mirrors `acl-rule.test.ts`/
|
|
3
|
+
* `dns-policy.test.ts`.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ `driftOf`/`matches` coverage lives in `firewall-policy-drift.test.ts` (the field-level table
|
|
6
|
+
* plus I2's per-field completeness check) and `firewall-policy-filter-roundtrip.test.ts` (I1's
|
|
7
|
+
* filter-bearing round trip) — both split out to keep this file under the house 250-line cap once
|
|
8
|
+
* that coverage grew, same split `network-drift.test.ts` is from `network.test.ts`, not a
|
|
9
|
+
* different family. Red team, 2026-09-26.
|
|
10
|
+
*
|
|
11
|
+
* The last `describe` block is this family's own decode-proof of B0b's `pageAll` (T9) — not used by
|
|
12
|
+
* `spec.fetchLive` itself (`getFirewallPolicy` reads one policy by id directly, same as
|
|
13
|
+
* `acl-rule.ts`), but a real console's FirewallPolicy list is the one T9 names by scale (424 live
|
|
14
|
+
* policies, `alchemy-ledger-network.md:13`) — `paginate.test.ts`'s own "against the real SDK" section
|
|
15
|
+
* proves the pager's decode path against `networks.getNetworksOverviewPage`; this proves it against
|
|
16
|
+
* `firewall.getFirewallPolicies`'s own page shape too, so a future Lane C import walking this
|
|
17
|
+
* family's full list is walking a shape this suite has already exercised end to end.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, test } from 'bun:test';
|
|
20
|
+
import * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
21
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
22
|
+
import * as Effect from 'effect/Effect';
|
|
23
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
24
|
+
import { attributesOf } from './firewall-policy-form.ts';
|
|
25
|
+
import { driftOf, matches } from './firewall-policy-drift.ts';
|
|
26
|
+
import { type FirewallPolicyProps, declareFirewallPolicy, spec } from './firewall-policy.ts';
|
|
27
|
+
import { pageAll } from './paginate.ts';
|
|
28
|
+
import { unifiOperations } from './resource.ts';
|
|
29
|
+
|
|
30
|
+
const POLICY_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/policies/fp-1';
|
|
31
|
+
|
|
32
|
+
const liveFirewallPolicy = (
|
|
33
|
+
overrides: Partial<firewall.FirewallPolicy> = {},
|
|
34
|
+
): firewall.FirewallPolicy => ({
|
|
35
|
+
action: { type: 'ALLOW' },
|
|
36
|
+
connectionStateFilter: ['ESTABLISHED', 'RELATED'],
|
|
37
|
+
description: 'Allow IoT devices outbound to the internet',
|
|
38
|
+
destination: { zoneId: 'zone-external' },
|
|
39
|
+
enabled: true,
|
|
40
|
+
id: 'fp-1',
|
|
41
|
+
index: 3,
|
|
42
|
+
ipProtocolScope: { ipVersion: 'IPV4' },
|
|
43
|
+
loggingEnabled: false,
|
|
44
|
+
metadata: { origin: 'USER' },
|
|
45
|
+
name: 'Allow IoT to Internet',
|
|
46
|
+
source: { zoneId: 'zone-iot' },
|
|
47
|
+
...overrides,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const PROPS: FirewallPolicyProps = {
|
|
51
|
+
siteId: 'site-1',
|
|
52
|
+
firewallPolicyId: 'fp-1',
|
|
53
|
+
action: { type: 'ALLOW' },
|
|
54
|
+
connectionStateFilter: ['ESTABLISHED', 'RELATED'],
|
|
55
|
+
description: 'Allow IoT devices outbound to the internet',
|
|
56
|
+
destination: { zoneId: 'zone-external' },
|
|
57
|
+
enabled: true,
|
|
58
|
+
ipProtocolScope: { ipVersion: 'IPV4' },
|
|
59
|
+
loggingEnabled: false,
|
|
60
|
+
name: 'Allow IoT to Internet',
|
|
61
|
+
source: { zoneId: 'zone-iot' },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('Unifi.FirewallPolicy spec.fetchLive', () => {
|
|
65
|
+
test('a real GET response decodes into the typed live object', async () => {
|
|
66
|
+
const fake = fakeUnifi((method, url) =>
|
|
67
|
+
method === 'GET' && url.pathname === POLICY_PATH
|
|
68
|
+
? Response.json(liveFirewallPolicy())
|
|
69
|
+
: fakeFailure(400, 'unexpected request'),
|
|
70
|
+
);
|
|
71
|
+
const live = await Effect.runPromise(
|
|
72
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
73
|
+
);
|
|
74
|
+
expect(live?.id).toBe('fp-1');
|
|
75
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: POLICY_PATH }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a genuine 404 folds to undefined -- never a false "unreadable" on anything else', async () => {
|
|
79
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
80
|
+
const live = await Effect.runPromise(
|
|
81
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
82
|
+
);
|
|
83
|
+
expect(live).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
|
|
87
|
+
// ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
|
|
88
|
+
// asserts the FAILURE, not the retry schedule.
|
|
89
|
+
const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
|
|
90
|
+
const failure = await Effect.runPromise(
|
|
91
|
+
Effect.flip(
|
|
92
|
+
spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
expect(failure._tag).toBe('InternalServerError');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('declareFirewallPolicy -- the declaration renderer', () => {
|
|
100
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
101
|
+
const live = liveFirewallPolicy({
|
|
102
|
+
connectionStateFilter: ['RELATED', 'ESTABLISHED'],
|
|
103
|
+
index: 9,
|
|
104
|
+
});
|
|
105
|
+
const declared = declareFirewallPolicy(live, 'site-1');
|
|
106
|
+
const attrs = attributesOf(live, declared);
|
|
107
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
108
|
+
expect(driftOf(live, declared)).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('Unifi.FirewallPolicy write paths never reach the vendor API', () => {
|
|
113
|
+
const ops = unifiOperations(spec);
|
|
114
|
+
|
|
115
|
+
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
116
|
+
const fake = fakeUnifi((method, url) =>
|
|
117
|
+
method === 'GET' && url.pathname === POLICY_PATH
|
|
118
|
+
? Response.json(liveFirewallPolicy())
|
|
119
|
+
: fakeFailure(400, 'unexpected request'),
|
|
120
|
+
);
|
|
121
|
+
const exit = await Effect.runPromiseExit(
|
|
122
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
123
|
+
);
|
|
124
|
+
expect(exit._tag).toBe('Success');
|
|
125
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('reconcile on drift refuses instead of PATCHing/PUTting a merged body', async () => {
|
|
129
|
+
const fake = fakeUnifi((method, url) =>
|
|
130
|
+
method === 'GET' && url.pathname === POLICY_PATH
|
|
131
|
+
? Response.json(liveFirewallPolicy({ enabled: false }))
|
|
132
|
+
: fakeFailure(400, 'unexpected request'),
|
|
133
|
+
);
|
|
134
|
+
const exit = await Effect.runPromiseExit(
|
|
135
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
136
|
+
);
|
|
137
|
+
expect(exit._tag).toBe('Failure');
|
|
138
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
142
|
+
const fake = fakeUnifi((method, url) =>
|
|
143
|
+
method === 'GET' && url.pathname === POLICY_PATH
|
|
144
|
+
? Response.json(liveFirewallPolicy())
|
|
145
|
+
: fakeFailure(400, 'unexpected request'),
|
|
146
|
+
);
|
|
147
|
+
const exit = await Effect.runPromiseExit(
|
|
148
|
+
ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
149
|
+
);
|
|
150
|
+
expect(exit._tag).toBe('Failure');
|
|
151
|
+
expect(fake.seen).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("T9/B0b -- pageAll walks this family's own page shape (decode proof)", () => {
|
|
156
|
+
test('every row across several pages of getFirewallPolicies decodes, in order', async () => {
|
|
157
|
+
const rows = Array.from({ length: 7 }, (_, i) =>
|
|
158
|
+
liveFirewallPolicy({ id: `fp-${i}`, index: i }),
|
|
159
|
+
);
|
|
160
|
+
const fake = fakeUnifi((method, url) => {
|
|
161
|
+
if (
|
|
162
|
+
method !== 'GET' ||
|
|
163
|
+
url.pathname !== '/proxy/network/integration/v1/sites/site-1/firewall/policies'
|
|
164
|
+
) {
|
|
165
|
+
return fakeFailure(400, 'unexpected request');
|
|
166
|
+
}
|
|
167
|
+
const offset = Number(url.searchParams.get('offset') ?? '0');
|
|
168
|
+
const limit = Number(url.searchParams.get('limit') ?? '3');
|
|
169
|
+
const data = rows.slice(offset, offset + limit);
|
|
170
|
+
return Response.json({ count: data.length, data, limit, offset, totalCount: rows.length });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const result = await Effect.runPromise(
|
|
174
|
+
pageAll(firewall.getFirewallPolicies, { siteId: 'site-1', limit: 3 }).pipe(
|
|
175
|
+
Effect.provide(fakeUnifiLayer(fake.fetch)),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(result.map((p) => p.id)).toEqual(rows.map((r) => r.id));
|
|
180
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
181
|
+
expect(fake.seen).toHaveLength(3);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallPolicy` — one firewall policy on a UniFi Network site, READ-ONLY (`policy.ts`).
|
|
3
|
+
* `Unifi.FirewallPolicyOrdering` (`firewall-policy-ordering.ts`) is the sibling resource for policy
|
|
4
|
+
* ORDER within a zone pair (T5) — one per `(sourceFirewallZoneId, destinationFirewallZoneId)`, never
|
|
5
|
+
* this file's concern. `docs/unifi.md`'s own header names this pair as "a bigger, separate PR";
|
|
6
|
+
* this is that PR — see `docs/unifi-firewall-policy.md` for the full breakdown.
|
|
7
|
+
*
|
|
8
|
+
* ★ T11 GATE, CHECKED 2026-09-26: the `Firewall` tag's 13 operations (5 already covered for
|
|
9
|
+
* `Unifi.FirewallZone` in `docs/unifi.md`, 8 new here for `FirewallPolicy` — `getFirewallPolicies`,
|
|
10
|
+
* `createFirewallPolicy`, `getFirewallPolicyOrdering`, `updateFirewallPolicyOrdering`,
|
|
11
|
+
* `deleteFirewallPolicy`, `getFirewallPolicy`, `patchFirewallPolicy`, `updateFirewallPolicy`) are
|
|
12
|
+
* byte-identical between 10.4.57 and 10.6.97, full raw operation objects; the 106-schema closure
|
|
13
|
+
* reachable from all 13 (`$ref` + `discriminator.mapping`) shares zero names with the 14 schemas
|
|
14
|
+
* that DID change elsewhere in the document. Corroborated independently by the A3 changeset's own
|
|
15
|
+
* broader 25-operation/139-schema re-check (`.changeset/unifi-network-a3-typed-discriminator-filters.md`).
|
|
16
|
+
* Full breakdown: `docs/unifi-firewall-policy.md`.
|
|
17
|
+
*/
|
|
18
|
+
import { Resource } from 'alchemy';
|
|
19
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
20
|
+
import * as Provider from 'alchemy/Provider';
|
|
21
|
+
import * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
22
|
+
import * as Effect from 'effect/Effect';
|
|
23
|
+
import {
|
|
24
|
+
type FirewallPolicyAttributes,
|
|
25
|
+
type FirewallPolicyProps,
|
|
26
|
+
attributesOf,
|
|
27
|
+
} from './firewall-policy-form.ts';
|
|
28
|
+
import { matches } from './firewall-policy-drift.ts';
|
|
29
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
30
|
+
import { unifiHandlers } from './resource.ts';
|
|
31
|
+
|
|
32
|
+
export type { FirewallPolicyAttributes, FirewallPolicyProps } from './firewall-policy-form.ts';
|
|
33
|
+
export { declareFirewallPolicy } from './firewall-policy-form.ts';
|
|
34
|
+
export { driftOf } from './firewall-policy-drift.ts';
|
|
35
|
+
|
|
36
|
+
export interface UnifiFirewallPolicy extends Resource<
|
|
37
|
+
'Unifi.FirewallPolicy',
|
|
38
|
+
FirewallPolicyProps,
|
|
39
|
+
FirewallPolicyAttributes,
|
|
40
|
+
never,
|
|
41
|
+
UnifiRequirements
|
|
42
|
+
> {}
|
|
43
|
+
|
|
44
|
+
export const UnifiFirewallPolicy = Resource<UnifiFirewallPolicy>('Unifi.FirewallPolicy', {
|
|
45
|
+
defaultRemovalPolicy: 'retain',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** `firewallPolicy('block-iot-wan', props)` — `adopt(true)` piped on by default (H5); see
|
|
49
|
+
* `network.ts`. */
|
|
50
|
+
export const firewallPolicy = (id: string, props: FirewallPolicyProps) =>
|
|
51
|
+
UnifiFirewallPolicy(id, props).pipe(adopt(true));
|
|
52
|
+
|
|
53
|
+
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
54
|
+
export const spec: UnifiSpec<
|
|
55
|
+
FirewallPolicyProps,
|
|
56
|
+
firewall.FirewallPolicy,
|
|
57
|
+
FirewallPolicyAttributes,
|
|
58
|
+
firewall.GetFirewallPolicyError
|
|
59
|
+
> = {
|
|
60
|
+
type: 'Unifi.FirewallPolicy',
|
|
61
|
+
describe: (props) => `sites/${props.siteId}/firewall/policies/${props.firewallPolicyId}`,
|
|
62
|
+
fetchLive: (props) =>
|
|
63
|
+
firewall
|
|
64
|
+
.getFirewallPolicy({ siteId: props.siteId, firewallPolicyId: props.firewallPolicyId })
|
|
65
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
66
|
+
attributes: attributesOf,
|
|
67
|
+
matches,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const handlers = unifiHandlers(spec);
|
|
71
|
+
|
|
72
|
+
export const UnifiFirewallPolicyProvider = () =>
|
|
73
|
+
Provider.effect(UnifiFirewallPolicy, Effect.succeed(UnifiFirewallPolicy.Provider.of(handlers)));
|
package/src/unifi/index.ts
CHANGED
|
@@ -37,4 +37,67 @@ export {
|
|
|
37
37
|
type FirewallZoneProps,
|
|
38
38
|
driftOf as firewallZoneDriftOf,
|
|
39
39
|
} from './firewall-zone.ts';
|
|
40
|
+
export {
|
|
41
|
+
UnifiDnsPolicy,
|
|
42
|
+
UnifiDnsPolicyProvider,
|
|
43
|
+
declareDnsPolicy,
|
|
44
|
+
dnsPolicy,
|
|
45
|
+
type DnsPolicyAttributes,
|
|
46
|
+
type DnsPolicyProps,
|
|
47
|
+
driftOf as dnsPolicyDriftOf,
|
|
48
|
+
} from './dns-policy.ts';
|
|
49
|
+
export {
|
|
50
|
+
UnifiAclRule,
|
|
51
|
+
UnifiAclRuleProvider,
|
|
52
|
+
aclRule,
|
|
53
|
+
declareAclRule,
|
|
54
|
+
type AclRuleAttributes,
|
|
55
|
+
type AclRuleProps,
|
|
56
|
+
driftOf as aclRuleDriftOf,
|
|
57
|
+
} from './acl-rule.ts';
|
|
58
|
+
export {
|
|
59
|
+
UnifiAclRuleOrdering,
|
|
60
|
+
UnifiAclRuleOrderingProvider,
|
|
61
|
+
aclRuleOrdering,
|
|
62
|
+
declareAclRuleOrdering,
|
|
63
|
+
type AclRuleOrderingAttributes,
|
|
64
|
+
type AclRuleOrderingProps,
|
|
65
|
+
driftOf as aclRuleOrderingDriftOf,
|
|
66
|
+
} from './acl-rule-ordering.ts';
|
|
67
|
+
export {
|
|
68
|
+
UnifiWifiBroadcast,
|
|
69
|
+
UnifiWifiBroadcastProvider,
|
|
70
|
+
declareWifiBroadcast,
|
|
71
|
+
wifiBroadcast,
|
|
72
|
+
type WifiBroadcastAttributes,
|
|
73
|
+
type WifiBroadcastProps,
|
|
74
|
+
driftOf as wifiBroadcastDriftOf,
|
|
75
|
+
} from './wifi-broadcast.ts';
|
|
76
|
+
export {
|
|
77
|
+
UnifiFirewallPolicy,
|
|
78
|
+
UnifiFirewallPolicyProvider,
|
|
79
|
+
declareFirewallPolicy,
|
|
80
|
+
firewallPolicy,
|
|
81
|
+
type FirewallPolicyAttributes,
|
|
82
|
+
type FirewallPolicyProps,
|
|
83
|
+
driftOf as firewallPolicyDriftOf,
|
|
84
|
+
} from './firewall-policy.ts';
|
|
85
|
+
export {
|
|
86
|
+
UnifiFirewallPolicyOrdering,
|
|
87
|
+
UnifiFirewallPolicyOrderingProvider,
|
|
88
|
+
declareFirewallPolicyOrdering,
|
|
89
|
+
firewallPolicyOrdering,
|
|
90
|
+
type FirewallPolicyOrderingAttributes,
|
|
91
|
+
type FirewallPolicyOrderingProps,
|
|
92
|
+
driftOf as firewallPolicyOrderingDriftOf,
|
|
93
|
+
} from './firewall-policy-ordering.ts';
|
|
94
|
+
export {
|
|
95
|
+
UnifiTrafficMatchingList,
|
|
96
|
+
UnifiTrafficMatchingListProvider,
|
|
97
|
+
declareTrafficMatchingList,
|
|
98
|
+
trafficMatchingList,
|
|
99
|
+
type TrafficMatchingListAttributes,
|
|
100
|
+
type TrafficMatchingListProps,
|
|
101
|
+
driftOf as trafficMatchingListDriftOf,
|
|
102
|
+
} from './traffic-matching-list.ts';
|
|
40
103
|
export { providers } from './providers.ts';
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* list operation (`getNetworksOverviewPage`) through the fake UniFi server. `M2`: this file does
|
|
6
6
|
* not repeat the 404/500 decode tests `network.test.ts` already covers — those are about ONE
|
|
7
7
|
* page's failure, not the walk this file exists to prove.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ MINOR-3 (red team, 2026-09-26): `page.offset`/`page.totalCount` are wire-controlled values
|
|
10
|
+
* `@distilled.cloud/core` never validates (`wifi-broadcast.ts`'s header documents why), so the
|
|
11
|
+
* "malformed response" test below plants a non-numeric value in each and proves `paginate.ts`'s
|
|
12
|
+
* `numOrPlaceholder` keeps it out of the error text, instead of assuming a vendor response is
|
|
13
|
+
* always well-typed just because the SDK's TS types say so.
|
|
8
14
|
*/
|
|
9
15
|
import { describe, expect, test } from 'bun:test';
|
|
10
16
|
import * as Effect from 'effect/Effect';
|
|
@@ -176,6 +182,24 @@ describe('pageAll -- IMPORTANT-3: typed failures on a self-contradicting respons
|
|
|
176
182
|
expect(failure.reason).toBe('page-ceiling');
|
|
177
183
|
});
|
|
178
184
|
|
|
185
|
+
test('a non-numeric offset/totalCount from a malformed response never appears in the error text (MINOR-3)', async () => {
|
|
186
|
+
const SENTINEL = 'sentinel-do-not-log-me';
|
|
187
|
+
// Cast, not a typo: the SDK's TS types say `offset`/`totalCount` are numbers, but nothing
|
|
188
|
+
// validates that at runtime -- this is exactly the shape a genuinely malformed page could take.
|
|
189
|
+
const fetchPage = () =>
|
|
190
|
+
Effect.succeed({
|
|
191
|
+
data: [],
|
|
192
|
+
offset: SENTINEL,
|
|
193
|
+
totalCount: SENTINEL,
|
|
194
|
+
} as unknown as OffsetPage<Row>);
|
|
195
|
+
|
|
196
|
+
const failure = await flip(pageAll(fetchPage, {}));
|
|
197
|
+
|
|
198
|
+
expect(failure.reason).toBe('offset-mismatch');
|
|
199
|
+
expect(failure.detail).not.toContain(SENTINEL);
|
|
200
|
+
expect(failure.message).not.toContain(SENTINEL);
|
|
201
|
+
});
|
|
202
|
+
|
|
179
203
|
test('an honest, well-behaved server never trips any of the three checks', async () => {
|
|
180
204
|
const rows = Array.from({ length: 9 }, (_, id) => ({ id }));
|
|
181
205
|
const { fetchPage } = fakePages(rows, 4);
|
package/src/unifi/paginate.ts
CHANGED
|
@@ -78,7 +78,19 @@ export class UnifiPaginationInconsistent extends Data.TaggedError('UnifiPaginati
|
|
|
78
78
|
* a `totalCount` and final row count that agree by coincidence. What this closes is the SILENT
|
|
79
79
|
* version of every case actually observed in review: an ignored `offset`, a `totalCount` that
|
|
80
80
|
* drifts between calls, and an undercounted `totalCount` that truncates the walk early.
|
|
81
|
+
*
|
|
82
|
+
* ⛔ `numOrPlaceholder` (red team, MINOR-3, 2026-09-26): nothing in `@distilled.cloud/core` decodes
|
|
83
|
+
* or validates a page response's shape (this file's own `wifi-broadcast.ts` neighbor documents
|
|
84
|
+
* why), so `page.offset`/`page.totalCount` can be ANY JSON value, not just a number, if the wire
|
|
85
|
+
* ever sends something malformed. Every `detail` string below prints these through
|
|
86
|
+
* `numOrPlaceholder` rather than raw template interpolation, so a non-numeric value — however it
|
|
87
|
+
* got there — can never inject arbitrary response text into an error message a caller might log.
|
|
81
88
|
*/
|
|
89
|
+
/** A finite number renders as itself; anything else (a malformed wire value) renders as a fixed
|
|
90
|
+
* placeholder, never as the value itself — see this file's header (MINOR-3). */
|
|
91
|
+
const numOrPlaceholder = (value: unknown): string =>
|
|
92
|
+
typeof value === 'number' && Number.isFinite(value) ? String(value) : '<non-numeric>';
|
|
93
|
+
|
|
82
94
|
export const pageAll = <Req extends OffsetPageRequest, T, E, R>(
|
|
83
95
|
fetchPage: (request: Req) => Effect.Effect<OffsetPage<T>, E, R>,
|
|
84
96
|
request: Req,
|
|
@@ -103,13 +115,13 @@ export const pageAll = <Req extends OffsetPageRequest, T, E, R>(
|
|
|
103
115
|
if (page.offset !== offset) {
|
|
104
116
|
return yield* new UnifiPaginationInconsistent({
|
|
105
117
|
reason: 'offset-mismatch',
|
|
106
|
-
detail: `requested offset ${offset}, page echoed ${page.offset} -- rows may be duplicated or skipped`,
|
|
118
|
+
detail: `requested offset ${offset}, page echoed ${numOrPlaceholder(page.offset)} -- rows may be duplicated or skipped`,
|
|
107
119
|
});
|
|
108
120
|
}
|
|
109
121
|
if (totalCount !== undefined && page.totalCount !== totalCount) {
|
|
110
122
|
return yield* new UnifiPaginationInconsistent({
|
|
111
123
|
reason: 'total-count-changed',
|
|
112
|
-
detail: `totalCount changed from ${totalCount} to ${page.totalCount} between page calls`,
|
|
124
|
+
detail: `totalCount changed from ${numOrPlaceholder(totalCount)} to ${numOrPlaceholder(page.totalCount)} between page calls`,
|
|
113
125
|
});
|
|
114
126
|
}
|
|
115
127
|
totalCount = page.totalCount;
|
|
@@ -122,7 +134,7 @@ export const pageAll = <Req extends OffsetPageRequest, T, E, R>(
|
|
|
122
134
|
if (rows.length !== expected) {
|
|
123
135
|
return yield* new UnifiPaginationInconsistent({
|
|
124
136
|
reason: 'row-count-mismatch',
|
|
125
|
-
detail: `collected ${rows.length} rows but totalCount(${totalCount}) - startOffset(${startOffset}) = ${expected}`,
|
|
137
|
+
detail: `collected ${rows.length} rows but totalCount(${numOrPlaceholder(totalCount)}) - startOffset(${startOffset}) = ${numOrPlaceholder(expected)}`,
|
|
126
138
|
});
|
|
127
139
|
}
|
|
128
140
|
return rows;
|