@homeflare/alchemy 0.49.0 → 0.51.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/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
- package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
- package/dist/kubernetes/index.d.ts +12 -0
- package/dist/kubernetes/index.d.ts.map +1 -0
- package/dist/kubernetes/index.js +585 -0
- package/dist/kubernetes/index.js.map +19 -0
- package/dist/kubernetes/ready-checks.d.ts +45 -0
- package/dist/kubernetes/ready-checks.d.ts.map +1 -0
- package/dist/kubernetes/ready-classify.d.ts +23 -0
- package/dist/kubernetes/ready-classify.d.ts.map +1 -0
- package/dist/kubernetes/ready-connect.d.ts +29 -0
- package/dist/kubernetes/ready-connect.d.ts.map +1 -0
- package/dist/kubernetes/ready-declare.d.ts +5 -0
- package/dist/kubernetes/ready-declare.d.ts.map +1 -0
- package/dist/kubernetes/ready-errors.d.ts +80 -0
- package/dist/kubernetes/ready-errors.d.ts.map +1 -0
- package/dist/kubernetes/ready-poll.d.ts +61 -0
- package/dist/kubernetes/ready-poll.d.ts.map +1 -0
- package/dist/kubernetes/ready.d.ts +74 -0
- package/dist/kubernetes/ready.d.ts.map +1 -0
- package/dist/talos/fake-apiserver.d.ts +11 -2
- package/dist/talos/fake-apiserver.d.ts.map +1 -1
- package/dist/unifi/acl-rule-ordering.d.ts +6 -1
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
- package/dist/unifi/acl-rule.d.ts +6 -1
- package/dist/unifi/acl-rule.d.ts.map +1 -1
- package/dist/unifi/dns-policy.d.ts +6 -1
- package/dist/unifi/dns-policy.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +4 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
- package/dist/unifi/firewall-policy.d.ts +6 -1
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +6 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +5 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +339 -86
- package/dist/unifi/index.js.map +10 -6
- package/dist/unifi/network-scope.d.ts +31 -0
- package/dist/unifi/network-scope.d.ts.map +1 -0
- package/dist/unifi/network-update.d.ts +44 -0
- package/dist/unifi/network-update.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +32 -12
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/policy.d.ts +89 -1
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +17 -43
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +6 -1
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
- package/dist/unifi/update-reconcile.d.ts +31 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +6 -1
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
- package/dist/unifi/wire-guard.d.ts +33 -0
- package/dist/unifi/wire-guard.d.ts.map +1 -0
- package/docs/kubernetes-ready.md +146 -0
- package/docs/peers.md +13 -0
- package/docs/provider-standard.md +7 -0
- package/docs/unifi-network-write.md +90 -0
- package/docs/unifi.md +36 -36
- package/package.json +7 -1
- package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
- package/src/kubernetes/helm-chart-bounded.ts +110 -0
- package/src/kubernetes/index.ts +28 -0
- package/src/kubernetes/ready-backoff.test.ts +124 -0
- package/src/kubernetes/ready-checks.test.ts +106 -0
- package/src/kubernetes/ready-checks.ts +165 -0
- package/src/kubernetes/ready-classify.ts +76 -0
- package/src/kubernetes/ready-connect.ts +52 -0
- package/src/kubernetes/ready-declare.test.ts +85 -0
- package/src/kubernetes/ready-declare.ts +50 -0
- package/src/kubernetes/ready-errors.ts +102 -0
- package/src/kubernetes/ready-first-install.test.ts +126 -0
- package/src/kubernetes/ready-poll-states.test.ts +92 -0
- package/src/kubernetes/ready-poll.test.ts +194 -0
- package/src/kubernetes/ready-poll.ts +215 -0
- package/src/kubernetes/ready-rollout.test.ts +58 -0
- package/src/kubernetes/ready-secrecy.test.ts +131 -0
- package/src/kubernetes/ready.fixtures.ts +103 -0
- package/src/kubernetes/ready.test.ts +205 -0
- package/src/kubernetes/ready.ts +188 -0
- package/src/talos/fake-apiserver.ts +21 -3
- package/src/unifi/fake-unifi.ts +15 -2
- package/src/unifi/handler-allow-news.test.ts +80 -0
- package/src/unifi/index.ts +21 -5
- package/src/unifi/network-scope.test.ts +203 -0
- package/src/unifi/network-scope.ts +75 -0
- package/src/unifi/network-update.test.ts +250 -0
- package/src/unifi/network-update.ts +95 -0
- package/src/unifi/network.test.ts +27 -1
- package/src/unifi/network.ts +56 -12
- package/src/unifi/policy.ts +128 -10
- package/src/unifi/resource.test.ts +15 -3
- package/src/unifi/resource.ts +64 -105
- package/src/unifi/update-reconcile.ts +126 -0
- package/src/unifi/wire-guard-anchor.test.ts +198 -0
- package/src/unifi/wire-guard-round2.test.ts +120 -0
- package/src/unifi/wire-guard.test.ts +218 -0
- package/src/unifi/wire-guard.ts +192 -0
- package/src/unifi/write-op-reference.test.ts +38 -2
- package/src/unifi/get-only-guard.test.ts +0 -165
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s write SCOPE (`network-scope.ts`), identity check, no-retry PUT and message
|
|
3
|
+
* wording — red team round 1 on PR 377. Drives `unifiOperations(spec).reconcile` against `fakeUnifi`,
|
|
4
|
+
* so the real SDK `updateNetwork` assembles the request and the fake records every one.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Cause from 'effect/Cause';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Exit from 'effect/Exit';
|
|
10
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
11
|
+
import { type NetworkProps, spec } from './network.ts';
|
|
12
|
+
import { UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
|
|
13
|
+
import { unifiOperations } from './resource.ts';
|
|
14
|
+
|
|
15
|
+
const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
|
|
16
|
+
const IPV6 = {
|
|
17
|
+
interfaceType: 'STATIC',
|
|
18
|
+
prefixLength: 64,
|
|
19
|
+
hostIpAddress: '2602:f660:0:16::254',
|
|
20
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
21
|
+
routerAdvertisement: { priority: 'HIGH' },
|
|
22
|
+
};
|
|
23
|
+
const BASE: NetworkProps = {
|
|
24
|
+
siteId: 'site-1',
|
|
25
|
+
networkId: 'net-1',
|
|
26
|
+
name: 'Compute',
|
|
27
|
+
enabled: true,
|
|
28
|
+
management: 'ADVANCED',
|
|
29
|
+
vlanId: 16,
|
|
30
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
|
|
31
|
+
ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
|
|
32
|
+
};
|
|
33
|
+
const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
|
|
34
|
+
|
|
35
|
+
const live = (extra: Record<string, unknown> = {}) => ({
|
|
36
|
+
id: 'net-1',
|
|
37
|
+
default: false,
|
|
38
|
+
metadata: { origin: 'USER' },
|
|
39
|
+
...BASE,
|
|
40
|
+
siteId: undefined,
|
|
41
|
+
networkId: undefined,
|
|
42
|
+
...extra,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const run = (
|
|
46
|
+
liveBody: object,
|
|
47
|
+
news: NetworkProps,
|
|
48
|
+
olds: NetworkProps,
|
|
49
|
+
put: () => Response = () => Response.json(liveBody),
|
|
50
|
+
) => {
|
|
51
|
+
const fake = fakeUnifi((method, url) =>
|
|
52
|
+
url.pathname === PATH
|
|
53
|
+
? method === 'GET'
|
|
54
|
+
? Response.json(liveBody)
|
|
55
|
+
: put()
|
|
56
|
+
: fakeFailure(400, 'unexpected'),
|
|
57
|
+
);
|
|
58
|
+
const exit = Effect.runPromiseExit(
|
|
59
|
+
unifiOperations(spec)
|
|
60
|
+
.reconcile(news, { olds, output: {} })
|
|
61
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
62
|
+
);
|
|
63
|
+
return { fake, exit };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const tagOf = (exit: Exit.Exit<unknown, unknown>) => {
|
|
67
|
+
if (!Exit.isFailure(exit)) return 'Success';
|
|
68
|
+
const error = Cause.squash(exit.cause) as { _tag?: string; message?: string };
|
|
69
|
+
return error._tag ?? 'defect';
|
|
70
|
+
};
|
|
71
|
+
const puts = (fake: { seen: ReadonlyArray<{ method: string }> }) =>
|
|
72
|
+
fake.seen.filter((s) => s.method === 'PUT');
|
|
73
|
+
|
|
74
|
+
describe('removable-field allowlist (omission would turn the block off)', () => {
|
|
75
|
+
test.each([
|
|
76
|
+
['ipv4Configuration', 'ipv4Configuration'],
|
|
77
|
+
['dhcpGuarding', 'dhcpGuarding'],
|
|
78
|
+
['a required key (name)', 'name'],
|
|
79
|
+
])('dropping %s => UnifiFieldNotRemovable, zero PUTs', async (_n, key) => {
|
|
80
|
+
const { [key as 'name']: _gone, ...news } = { ...WITH_V6 } as NetworkProps;
|
|
81
|
+
const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news as NetworkProps, WITH_V6);
|
|
82
|
+
const result = await exit;
|
|
83
|
+
expect(tagOf(result)).toBe('UnifiFieldNotRemovable');
|
|
84
|
+
expect(JSON.stringify(result)).toContain(key);
|
|
85
|
+
expect(puts(fake)).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ⚠️ Mutation gap: an explicit `null` removes a key just like absence (`== null`, not `=== undefined`).
|
|
89
|
+
test('an explicit null on ipv6Configuration removes it from the body', async () => {
|
|
90
|
+
const news = { ...BASE, ipv6Configuration: null } as unknown as NetworkProps;
|
|
91
|
+
const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news, WITH_V6, () =>
|
|
92
|
+
Response.json(live()),
|
|
93
|
+
);
|
|
94
|
+
expect(tagOf(await exit)).toBe('Success');
|
|
95
|
+
const body = puts(fake)[0] as { body?: Record<string, unknown> };
|
|
96
|
+
expect(body.body).toBeDefined();
|
|
97
|
+
expect('ipv6Configuration' in (body.body as object)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('settable-field allowlist (only ipv6Configuration may be written)', () => {
|
|
102
|
+
// ⛔ Round-2 BLOCKING finding: the PR claimed this scope but sent these PUTs. Each case is a real
|
|
103
|
+
// change to a non-ipv6 key, with live matching the prior state, so only the allowlist stops it.
|
|
104
|
+
test.each([
|
|
105
|
+
['enabled', { enabled: false }, {}],
|
|
106
|
+
['internetAccessEnabled', { internetAccessEnabled: false }, { internetAccessEnabled: true }],
|
|
107
|
+
[
|
|
108
|
+
'ipv4Configuration',
|
|
109
|
+
{ ipv4Configuration: { dhcpConfiguration: { mode: 'NONE' }, hostIpAddress: '10.9.9.1' } },
|
|
110
|
+
{},
|
|
111
|
+
],
|
|
112
|
+
['dhcpGuarding', { dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.9'] } }, {}],
|
|
113
|
+
])('a patch that sets %s => UnifiFieldNotSettable, zero PUTs', async (field, change, olds) => {
|
|
114
|
+
const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
|
|
115
|
+
const { fake, exit } = run(
|
|
116
|
+
live({ ipv6Configuration: IPV6, ...olds }),
|
|
117
|
+
{ ...oldProps, ...change } as NetworkProps,
|
|
118
|
+
oldProps,
|
|
119
|
+
);
|
|
120
|
+
const result = await exit;
|
|
121
|
+
expect(tagOf(result)).toBe('UnifiFieldNotSettable');
|
|
122
|
+
expect(JSON.stringify(result)).toContain(field);
|
|
123
|
+
expect(puts(fake)).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('setting ipv6Configuration is still allowed', async () => {
|
|
127
|
+
const { fake, exit } = run(live(), WITH_V6, BASE, () =>
|
|
128
|
+
Response.json(live({ ipv6Configuration: IPV6 })),
|
|
129
|
+
);
|
|
130
|
+
expect(tagOf(await exit)).toBe('Success');
|
|
131
|
+
expect(puts(fake)).toHaveLength(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('write scope', () => {
|
|
136
|
+
test('a live default (management) network is refused, zero PUTs', async () => {
|
|
137
|
+
const { fake, exit } = run(live({ default: true }), WITH_V6, BASE);
|
|
138
|
+
expect(tagOf(await exit)).toBe('UnifiManagementNetworkRefused');
|
|
139
|
+
expect(puts(fake)).toHaveLength(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test.each([
|
|
143
|
+
['zoneId', { zoneId: 'zone-2' }, { zoneId: 'zone-1' }],
|
|
144
|
+
['vlanId', { vlanId: 17 }, {}],
|
|
145
|
+
['management', { management: 'GATEWAY' }, {}],
|
|
146
|
+
['deviceId', { deviceId: 'dev-2' }, { deviceId: 'dev-1' }],
|
|
147
|
+
])('a patch that changes %s is refused, zero PUTs', async (field, change, olds) => {
|
|
148
|
+
const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
|
|
149
|
+
const { fake, exit } = run(
|
|
150
|
+
live({ ipv6Configuration: IPV6, ...olds }),
|
|
151
|
+
{ ...oldProps, ...change } as NetworkProps,
|
|
152
|
+
oldProps,
|
|
153
|
+
);
|
|
154
|
+
const result = await exit;
|
|
155
|
+
expect(tagOf(result)).toBe('UnifiImmutableFieldChanged');
|
|
156
|
+
expect(JSON.stringify(result)).toContain(field);
|
|
157
|
+
expect(puts(fake)).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('olds naming another network id => UnifiIdentityChanged, zero PUTs', async () => {
|
|
161
|
+
const { fake, exit } = run(live(), WITH_V6, { ...BASE, networkId: 'net-OLD' });
|
|
162
|
+
expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
|
|
163
|
+
expect(puts(fake)).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('olds naming another site id => UnifiIdentityChanged, zero PUTs', async () => {
|
|
167
|
+
const { fake, exit } = run(live(), WITH_V6, { ...BASE, siteId: 'site-OLD' });
|
|
168
|
+
expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
|
|
169
|
+
expect(puts(fake)).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('the PUT is attempted once', () => {
|
|
174
|
+
test('a 503 on the PUT is not retried', async () => {
|
|
175
|
+
const { fake, exit } = run(live(), WITH_V6, BASE, () => fakeFailure(503, 'busy'));
|
|
176
|
+
expect((await exit)._tag).toBe('Failure');
|
|
177
|
+
expect(puts(fake)).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('error wording', () => {
|
|
182
|
+
test('did-not-converge says the PUT landed and names re-import', () => {
|
|
183
|
+
const message = new UnifiUpdateDidNotConverge({
|
|
184
|
+
type: 'Unifi.Network',
|
|
185
|
+
identity: 'sites/s/networks/n',
|
|
186
|
+
fields: ['ipv6Configuration'],
|
|
187
|
+
}).message;
|
|
188
|
+
expect(message).toContain('LANDED');
|
|
189
|
+
expect(message).toContain('differs from the declaration');
|
|
190
|
+
expect(message).toContain('re-import');
|
|
191
|
+
expect(message).toContain('[ipv6Configuration]');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('live-drifted names both causes, not only a hand edit', () => {
|
|
195
|
+
const message = new UnifiLiveDriftedSinceDeploy({
|
|
196
|
+
type: 'Unifi.Network',
|
|
197
|
+
identity: 'sites/s/networks/n',
|
|
198
|
+
fields: ['name'],
|
|
199
|
+
}).message;
|
|
200
|
+
expect(message).toContain('hand edit');
|
|
201
|
+
expect(message).toContain('UnifiUpdateDidNotConverge');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write SCOPE of `Unifi.Network`: the one intended consumer is a single VLAN's
|
|
3
|
+
* `ipv6Configuration`, so everything else a whole-object PUT could reach is refused BEFORE any
|
|
4
|
+
* request, with a typed error that carries field names only.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ SETTABLE ALLOWLIST. A key in `patch.set` is written into the PUT body, so only
|
|
7
|
+
* `ipv6Configuration` may be set: `enabled`, `internetAccessEnabled`, `ipv4Configuration`,
|
|
8
|
+
* `dhcpGuarding` and every other declared key are refused with `UnifiFieldNotSettable` (a touched
|
|
9
|
+
* immutable key gets `UnifiImmutableFieldChanged` first). Widening it is a kit change.
|
|
10
|
+
* ⛔ REMOVABLE ALLOWLIST. A key in `patch.unset` is deleted from the PUT body, and the controller
|
|
11
|
+
* reads an omitted `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration` as "off". Dropping a
|
|
12
|
+
* declaration key therefore must not silently turn a live block off: only `ipv6Configuration`
|
|
13
|
+
* (the block this write path exists for, and the revert target) may be removed. A required key
|
|
14
|
+
* (`name`, `vlanId`, …) removed from a declaration would fail the SDK schema or send a body the
|
|
15
|
+
* controller rejects, so it is refused here with the same error.
|
|
16
|
+
* ⛔ `default: true` IS THE MANAGEMENT LAN. Writing it can cut the admin off the console.
|
|
17
|
+
* ⛔ `zoneId`/`vlanId`/`management`/`deviceId` decide where the network lives and what serves it;
|
|
18
|
+
* a patch (set OR unset) touching one is refused. Non-USER `metadata.origin` is deliberately NOT
|
|
19
|
+
* handled yet: no rule exists for it.
|
|
20
|
+
*/
|
|
21
|
+
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
22
|
+
import * as Effect from 'effect/Effect';
|
|
23
|
+
import type { NetworkProps } from './network-form.ts';
|
|
24
|
+
import {
|
|
25
|
+
UnifiFieldNotRemovable,
|
|
26
|
+
UnifiFieldNotSettable,
|
|
27
|
+
UnifiImmutableFieldChanged,
|
|
28
|
+
UnifiManagementNetworkRefused,
|
|
29
|
+
} from './policy.ts';
|
|
30
|
+
import type { Patch } from './update-reconcile.ts';
|
|
31
|
+
|
|
32
|
+
export const SETTABLE_KEYS: ReadonlyArray<keyof NetworkProps> = ['ipv6Configuration'];
|
|
33
|
+
export const REMOVABLE_KEYS: ReadonlyArray<keyof NetworkProps> = ['ipv6Configuration'];
|
|
34
|
+
export const IMMUTABLE_KEYS: ReadonlyArray<keyof NetworkProps> = [
|
|
35
|
+
'zoneId',
|
|
36
|
+
'vlanId',
|
|
37
|
+
'management',
|
|
38
|
+
'deviceId',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export type ScopeError =
|
|
42
|
+
| UnifiFieldNotRemovable
|
|
43
|
+
| UnifiFieldNotSettable
|
|
44
|
+
| UnifiImmutableFieldChanged
|
|
45
|
+
| UnifiManagementNetworkRefused;
|
|
46
|
+
|
|
47
|
+
const isIn = (list: ReadonlyArray<string>) => (key: string) => list.includes(key);
|
|
48
|
+
|
|
49
|
+
export const checkNetworkWriteScope = (
|
|
50
|
+
live: networks.NetworkDetails,
|
|
51
|
+
patch: Patch<NetworkProps>,
|
|
52
|
+
props: NetworkProps,
|
|
53
|
+
): Effect.Effect<void, ScopeError> => {
|
|
54
|
+
const type = 'Unifi.Network';
|
|
55
|
+
const identity = `sites/${props.siteId}/networks/${props.networkId}`;
|
|
56
|
+
if (live.default === true) {
|
|
57
|
+
return Effect.fail(new UnifiManagementNetworkRefused({ type, identity }));
|
|
58
|
+
}
|
|
59
|
+
const touched = [...Object.keys(patch.set), ...patch.unset.map(String)];
|
|
60
|
+
const immutable = touched.filter(isIn(IMMUTABLE_KEYS as ReadonlyArray<string>));
|
|
61
|
+
if (immutable.length > 0) {
|
|
62
|
+
return Effect.fail(new UnifiImmutableFieldChanged({ type, identity, fields: immutable }));
|
|
63
|
+
}
|
|
64
|
+
const removable = isIn(REMOVABLE_KEYS as ReadonlyArray<string>);
|
|
65
|
+
const notRemovable = patch.unset.map(String).filter((k) => !removable(k));
|
|
66
|
+
if (notRemovable.length > 0) {
|
|
67
|
+
return Effect.fail(new UnifiFieldNotRemovable({ type, identity, fields: notRemovable }));
|
|
68
|
+
}
|
|
69
|
+
const settable = isIn(SETTABLE_KEYS as ReadonlyArray<string>);
|
|
70
|
+
const notSettable = Object.keys(patch.set).filter((k) => !settable(k));
|
|
71
|
+
if (notSettable.length > 0) {
|
|
72
|
+
return Effect.fail(new UnifiFieldNotSettable({ type, identity, fields: notSettable }));
|
|
73
|
+
}
|
|
74
|
+
return Effect.void;
|
|
75
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s three-way update against `fakeUnifi`, driving `unifiOperations(spec).reconcile`
|
|
3
|
+
* with `olds`/`output` set. The PUT goes through the real SDK `updateNetwork`, so path assembly,
|
|
4
|
+
* JSON encode and status→error matching are the production ones.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ These call `unifiOperations` directly, which bypasses the wire guard (see `wire-guard.test.ts`
|
|
7
|
+
* for that); here the question is WHICH BODY is sent and WHEN. The fake records every request.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
11
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
14
|
+
import { NETWORK_PATCH_KEYS, type NetworkProps, spec } from './network.ts';
|
|
15
|
+
import { SERVER_FIELDS } from './network-update.ts';
|
|
16
|
+
import {
|
|
17
|
+
UnifiLiveDriftedSinceDeploy,
|
|
18
|
+
UnifiUpdateDidNotConverge,
|
|
19
|
+
UnifiWriteRefused,
|
|
20
|
+
} from './policy.ts';
|
|
21
|
+
import { unifiOperations } from './resource.ts';
|
|
22
|
+
|
|
23
|
+
const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
|
|
24
|
+
const IPV6 = {
|
|
25
|
+
interfaceType: 'STATIC',
|
|
26
|
+
prefixLength: 64,
|
|
27
|
+
hostIpAddress: '2602:f660:0:16::254',
|
|
28
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
29
|
+
routerAdvertisement: { priority: 'HIGH' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const BASE: NetworkProps = {
|
|
33
|
+
siteId: 'site-1',
|
|
34
|
+
networkId: 'net-1',
|
|
35
|
+
name: 'Compute',
|
|
36
|
+
enabled: true,
|
|
37
|
+
management: 'ADVANCED',
|
|
38
|
+
vlanId: 16,
|
|
39
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
|
|
40
|
+
ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
|
|
41
|
+
};
|
|
42
|
+
const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
|
|
43
|
+
|
|
44
|
+
/** Raw live object, with a key the SDK schema has never heard of. */
|
|
45
|
+
const live = (extra: Record<string, unknown> = {}) => ({
|
|
46
|
+
id: 'net-1',
|
|
47
|
+
default: false,
|
|
48
|
+
metadata: { origin: 'USER' },
|
|
49
|
+
futureTopLevelKey: 'x',
|
|
50
|
+
...BASE,
|
|
51
|
+
siteId: undefined,
|
|
52
|
+
networkId: undefined,
|
|
53
|
+
...extra,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const reconcile = (
|
|
57
|
+
route: (method: string, url: URL) => Response,
|
|
58
|
+
news: NetworkProps,
|
|
59
|
+
olds: NetworkProps | undefined,
|
|
60
|
+
outputArg: object | null = {},
|
|
61
|
+
) => {
|
|
62
|
+
const output = outputArg ?? undefined;
|
|
63
|
+
const fake = fakeUnifi(route);
|
|
64
|
+
const notes: string[] = [];
|
|
65
|
+
const run = unifiOperations(spec)
|
|
66
|
+
.reconcile(news, {
|
|
67
|
+
olds,
|
|
68
|
+
output,
|
|
69
|
+
note: (m) => Effect.sync(() => void notes.push(m)),
|
|
70
|
+
})
|
|
71
|
+
.pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch)));
|
|
72
|
+
return { fake, notes, exit: Effect.runPromiseExit(run) };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const answering =
|
|
76
|
+
(liveBody: object, put: () => Response = () => Response.json(liveBody)) =>
|
|
77
|
+
(method: string, url: URL) =>
|
|
78
|
+
url.pathname === PATH
|
|
79
|
+
? method === 'GET'
|
|
80
|
+
? Response.json(liveBody)
|
|
81
|
+
: put()
|
|
82
|
+
: fakeFailure(400, 'unexpected');
|
|
83
|
+
|
|
84
|
+
const putBody = (seen: ReadonlyArray<{ method: string; body?: unknown }>) =>
|
|
85
|
+
seen.filter((s) => s.method === 'PUT').map((s) => s.body as Record<string, unknown>);
|
|
86
|
+
|
|
87
|
+
describe('Unifi.Network update', () => {
|
|
88
|
+
test('(1) one GET then one PUT: live carried through byte-equal, server fields dropped, patch added', async () => {
|
|
89
|
+
const liveBody = live();
|
|
90
|
+
const after = { ...liveBody, ipv6Configuration: IPV6 };
|
|
91
|
+
const route = answering(liveBody, () => Response.json(after));
|
|
92
|
+
const { fake, exit, notes } = reconcile(route, WITH_V6, BASE);
|
|
93
|
+
expect((await exit)._tag).toBe('Success');
|
|
94
|
+
expect(fake.seen.map((s) => s.method)).toEqual(['GET', 'PUT']);
|
|
95
|
+
const body = putBody(fake.seen)[0] as Record<string, unknown>;
|
|
96
|
+
for (const k of ['id', 'default', 'metadata']) expect(k in body).toBe(false);
|
|
97
|
+
expect(body.futureTopLevelKey).toBe('x');
|
|
98
|
+
expect(body.dhcpGuarding).toEqual(liveBody.dhcpGuarding);
|
|
99
|
+
expect(body.ipv4Configuration).toEqual(liveBody.ipv4Configuration);
|
|
100
|
+
expect(body.ipv6Configuration).toEqual(IPV6);
|
|
101
|
+
// (8) one note, field names only
|
|
102
|
+
expect(notes).toHaveLength(1);
|
|
103
|
+
expect(notes[0]).toContain('changes [ipv6Configuration]');
|
|
104
|
+
expect(notes[0]).not.toContain('2602');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('(2) an unknown NESTED key in live ipv6Configuration survives into the body (F2 alarm)', async () => {
|
|
108
|
+
const nested = { ...IPV6, futureNestedKey: 'keep' };
|
|
109
|
+
const liveBody = live({ ipv6Configuration: nested });
|
|
110
|
+
// olds mirrors live (as an import of the live object would), so there is no drift; news
|
|
111
|
+
// changes only `ipv6Configuration` (the one settable key) and keeps the unknown nested key.
|
|
112
|
+
const olds = { ...BASE, ipv6Configuration: nested } as NetworkProps;
|
|
113
|
+
const news = {
|
|
114
|
+
...olds,
|
|
115
|
+
ipv6Configuration: { ...nested, routerAdvertisement: { priority: 'LOW' } },
|
|
116
|
+
} as NetworkProps;
|
|
117
|
+
const { fake, exit } = reconcile(answering(liveBody), news, olds);
|
|
118
|
+
await exit;
|
|
119
|
+
const body = putBody(fake.seen)[0] as {
|
|
120
|
+
ipv6Configuration: Record<string, unknown>;
|
|
121
|
+
futureTopLevelKey: string;
|
|
122
|
+
};
|
|
123
|
+
expect(body.ipv6Configuration.futureNestedKey).toBe('keep');
|
|
124
|
+
expect(body.ipv6Configuration.routerAdvertisement).toEqual({ priority: 'LOW' });
|
|
125
|
+
expect(body.futureTopLevelKey).toBe('x');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('(3) revert: olds has ipv6Configuration, news omits it => PUT body has no such key', async () => {
|
|
129
|
+
const liveBody = live({ ipv6Configuration: IPV6 });
|
|
130
|
+
const { fake, exit } = reconcile(
|
|
131
|
+
answering(liveBody, () => Response.json(live())),
|
|
132
|
+
BASE,
|
|
133
|
+
WITH_V6,
|
|
134
|
+
);
|
|
135
|
+
expect((await exit)._tag).toBe('Success');
|
|
136
|
+
const bodies = putBody(fake.seen);
|
|
137
|
+
expect(bodies).toHaveLength(1);
|
|
138
|
+
expect('ipv6Configuration' in (bodies[0] as object)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test.each([401, 403])('(4) PUT %d => typed failure, exactly one PUT', async (status) => {
|
|
142
|
+
const { fake, exit } = reconcile(
|
|
143
|
+
answering(live(), () => fakeFailure(status, 'no')),
|
|
144
|
+
WITH_V6,
|
|
145
|
+
BASE,
|
|
146
|
+
);
|
|
147
|
+
const result = await exit;
|
|
148
|
+
expect(result._tag).toBe('Failure');
|
|
149
|
+
expect(fake.seen.map((s) => s.method)).toEqual(['GET', 'PUT']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('(5) PUT 200 whose body still mismatches => UnifiUpdateDidNotConverge', async () => {
|
|
153
|
+
const { fake, exit } = reconcile(answering(live()), WITH_V6, BASE);
|
|
154
|
+
const result = await exit;
|
|
155
|
+
expect(JSON.stringify(result)).toContain('UnifiUpdateDidNotConverge');
|
|
156
|
+
expect(fake.seen.filter((s) => s.method === 'PUT')).toHaveLength(1);
|
|
157
|
+
expect(UnifiUpdateDidNotConverge.name).toBe('UnifiUpdateDidNotConverge');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('(6) live drifted from olds on name => UnifiLiveDriftedSinceDeploy[name], zero PUTs', async () => {
|
|
161
|
+
const { fake, exit } = reconcile(answering(live({ name: 'Renamed' })), WITH_V6, BASE);
|
|
162
|
+
const result = await exit;
|
|
163
|
+
expect(JSON.stringify(result)).toContain('UnifiLiveDriftedSinceDeploy');
|
|
164
|
+
expect(JSON.stringify(result)).toContain('"fields":["name"]');
|
|
165
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
166
|
+
expect(UnifiLiveDriftedSinceDeploy.name).toBe('UnifiLiveDriftedSinceDeploy');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test.each([
|
|
170
|
+
['output undefined', BASE, null],
|
|
171
|
+
['olds undefined', undefined, {}],
|
|
172
|
+
])('(7) %s => refuses update, all-GET', async (_n, olds, output) => {
|
|
173
|
+
const { fake, exit } = reconcile(answering(live()), WITH_V6, olds, output);
|
|
174
|
+
const result = await exit;
|
|
175
|
+
expect(JSON.stringify(result)).toContain('UnifiWriteRefused');
|
|
176
|
+
expect(JSON.stringify(result)).toContain('"action":"update"');
|
|
177
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
178
|
+
expect(UnifiWriteRefused.name).toBe('UnifiWriteRefused');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('declared change equal to olds (pure drift driftOf missed is impossible) writes nothing', async () => {
|
|
182
|
+
// news == olds but live differs only in a field driftOf compares: that is drift, refused.
|
|
183
|
+
const { fake, exit } = reconcile(answering(live({ vlanId: 99 })), BASE, BASE);
|
|
184
|
+
expect((await exit)._tag).toBe('Failure');
|
|
185
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('an exact match writes nothing even with olds/output set (H6)', async () => {
|
|
189
|
+
const { fake, exit } = reconcile(answering(live()), BASE, BASE);
|
|
190
|
+
expect((await exit)._tag).toBe('Success');
|
|
191
|
+
expect(fake.seen.map((s) => s.method)).toEqual(['GET']);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('SERVER_FIELDS: one list drives the strip', () => {
|
|
196
|
+
// ⛔ PINNED, like NETWORK_PATCH_KEYS above: the values are load-bearing (id travels as the path
|
|
197
|
+
// param instead; default/metadata are controller-owned), so the list cannot be quietly
|
|
198
|
+
// emptied or widened. The body test below proves the list is actually USED on the wire.
|
|
199
|
+
test('is exactly the three controller-owned keys', () => {
|
|
200
|
+
expect([...SERVER_FIELDS]).toEqual(['id', 'default', 'metadata']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the PUT body omits exactly those keys from live — every one of them, no other live key', async () => {
|
|
204
|
+
const liveBody = live();
|
|
205
|
+
// Only `ipv6Configuration` may be set (network-scope.ts SETTABLE_KEYS), so the change is that.
|
|
206
|
+
const news = WITH_V6;
|
|
207
|
+
const { fake, exit } = reconcile(
|
|
208
|
+
answering(liveBody, () => Response.json({ ...liveBody, ipv6Configuration: IPV6 })),
|
|
209
|
+
news,
|
|
210
|
+
BASE,
|
|
211
|
+
);
|
|
212
|
+
expect((await exit)._tag).toBe('Success');
|
|
213
|
+
const body = putBody(fake.seen)[0] as Record<string, unknown>;
|
|
214
|
+
const server: ReadonlyArray<string> = SERVER_FIELDS;
|
|
215
|
+
// Exactly, both ways: every SERVER_FIELDS key is gone from the body ...
|
|
216
|
+
expect(Object.keys(body).filter((k) => server.includes(k))).toEqual([]);
|
|
217
|
+
// ... and no other live key was stripped on the way. The two identity ids are the one
|
|
218
|
+
// legitimate extra omission: they travel as the PUT's PATH params — `answering` serves only
|
|
219
|
+
// PATH, which embeds them — never as body fields.
|
|
220
|
+
const ids = ['siteId', 'networkId'];
|
|
221
|
+
expect(
|
|
222
|
+
Object.keys(liveBody).filter((k) => !server.includes(k) && !ids.includes(k) && !(k in body)),
|
|
223
|
+
).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('NETWORK_PATCH_KEYS', () => {
|
|
228
|
+
test('equals every NetworkProps key except the two ids', () => {
|
|
229
|
+
const full: Required<NetworkProps> = {
|
|
230
|
+
siteId: 's',
|
|
231
|
+
networkId: 'n',
|
|
232
|
+
name: 'x',
|
|
233
|
+
enabled: true,
|
|
234
|
+
management: 'm',
|
|
235
|
+
vlanId: 2,
|
|
236
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: [] },
|
|
237
|
+
cellularBackupEnabled: true,
|
|
238
|
+
internetAccessEnabled: true,
|
|
239
|
+
ipv4Configuration: {},
|
|
240
|
+
ipv6Configuration: {} as networks.NetworkIPv6Configuration,
|
|
241
|
+
isolationEnabled: true,
|
|
242
|
+
mdnsForwardingEnabled: true,
|
|
243
|
+
zoneId: 'z',
|
|
244
|
+
deviceId: 'd',
|
|
245
|
+
};
|
|
246
|
+
const ids = ['siteId', 'networkId'];
|
|
247
|
+
const expected = Object.keys(full).filter((k) => !ids.includes(k));
|
|
248
|
+
expect([...NETWORK_PATCH_KEYS].map(String).sort()).toEqual(expected.sort());
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s one write: `updateNetwork`. THE ONLY FILE UNDER `src/unifi` ALLOWED TO NAME IT
|
|
3
|
+
* (`write-op-reference.test.ts`'s `WRITE_OP_ALLOWLIST` pins that, and still refuses
|
|
4
|
+
* `createNetwork`/`deleteNetwork` here).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE WHOLE-OBJECT-PUT TRAP (packages/distilled-unifi-network/README.md). `PUT .../networks/{id}` disables
|
|
7
|
+
* `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration` outright when the body omits them. So
|
|
8
|
+
* the body is never `props`: it is the RAW live object (as `fetchLive` decoded it) minus the
|
|
9
|
+
* server fields (`SERVER_FIELDS`: `id`/`default`/`metadata`), plus ONLY the patch (HIGH-2). A
|
|
10
|
+
* hand edit on a field
|
|
11
|
+
* the declaration never touched rides through byte-for-byte; `update-reconcile.ts` has already
|
|
12
|
+
* refused any hand edit on a declared field.
|
|
13
|
+
*
|
|
14
|
+
* ★ WHY RAW LIVE, NOT THE ATTRIBUTES VIEW (LOW-6). `attributesOf` normalizes (sorted sets) and
|
|
15
|
+
* drops keys this kit does not model. Unknown keys must survive the round trip or the PUT would
|
|
16
|
+
* silently delete a setting the console added after the SDK was generated (F2). Measured by
|
|
17
|
+
* reading `@distilled.cloud/core` `protocol-http.ts`: `validateResponse` returns the ORIGINAL
|
|
18
|
+
* decoded value and `mapKeys` passes unknown keys through at every level, so top-level and
|
|
19
|
+
* nested unknown keys both survive; `network-update.test.ts` is the alarm if that ever changes.
|
|
20
|
+
*
|
|
21
|
+
* ★ WHY `unset` EXISTS (HIGH-1). Reverting a declaration (dropping `ipv6Configuration`) must plan
|
|
22
|
+
* and execute a real update back to the pre-image. A key in `patch.unset` is DELETED from the
|
|
23
|
+
* body, which is exactly "absent = off" for the optional blocks above.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ NO-OP GUARD. If the merged body equals live (ignoring the ids), the PUT would change nothing;
|
|
26
|
+
* `UnifiUpdateWouldBeNoop` refuses so a normalizer gap can never become a re-PUT loop.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ NO RETRY ON THE PUT (red team round 1, reversing the earlier Q7 decision). The SDK's default
|
|
29
|
+
* policy would replay a write whose first attempt may already have landed (a dropped response
|
|
30
|
+
* during a reprovision); a replay then runs against a controller state this reconcile never
|
|
31
|
+
* read. One attempt, and a failure surfaces for the operator. Reads keep the default policy.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ SCOPE FIRST (`network-scope.ts`): the management LAN, immutable fields and non-removable keys
|
|
34
|
+
* are refused before the body is even built, so none of them can reach the wire.
|
|
35
|
+
*/
|
|
36
|
+
import * as networks from '@distilled.cloud/unifi-network/networks';
|
|
37
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
38
|
+
import { deepEqual } from 'alchemy/Diff';
|
|
39
|
+
import * as Effect from 'effect/Effect';
|
|
40
|
+
import { type NetworkProps } from './network-form.ts';
|
|
41
|
+
import { checkNetworkWriteScope } from './network-scope.ts';
|
|
42
|
+
import { UnifiUpdateWouldBeNoop } from './policy.ts';
|
|
43
|
+
import type { Patch } from './update-reconcile.ts';
|
|
44
|
+
|
|
45
|
+
export const SERVER_FIELDS = ['id', 'default', 'metadata'] as const;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The live object minus `SERVER_FIELDS` — ONE list drives the strip. The destructure this
|
|
49
|
+
* replaced hard-coded the same three names a second time, so a key added to the export was
|
|
50
|
+
* silently NOT stripped (and a removed one still was): two lists that could drift apart. Own
|
|
51
|
+
* enumerable string keys only, exactly what object-rest destructure copies; a decoded JSON body
|
|
52
|
+
* has no symbol keys.
|
|
53
|
+
*/
|
|
54
|
+
const withoutServerFields = (live: networks.NetworkDetails): Record<string, unknown> => {
|
|
55
|
+
const server = new Set<string>(SERVER_FIELDS);
|
|
56
|
+
const rest: Record<string, unknown> = {};
|
|
57
|
+
for (const [key, value] of Object.entries(live)) {
|
|
58
|
+
if (!server.has(key)) rest[key] = value;
|
|
59
|
+
}
|
|
60
|
+
return rest;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const mergeForUpdate = (
|
|
64
|
+
live: networks.NetworkDetails,
|
|
65
|
+
patch: Patch<NetworkProps>,
|
|
66
|
+
props: NetworkProps,
|
|
67
|
+
): Effect.Effect<networks.UpdateNetworkRequest, UnifiUpdateWouldBeNoop> => {
|
|
68
|
+
// Raw copy minus the server fields: unknown top-level keys stay in `rest` (F2).
|
|
69
|
+
const rest = withoutServerFields(live);
|
|
70
|
+
const body: Record<string, unknown> = { ...rest, ...patch.set };
|
|
71
|
+
for (const key of patch.unset) delete body[key];
|
|
72
|
+
if (deepEqual(body, rest, { stripNullish: true })) {
|
|
73
|
+
return Effect.fail(
|
|
74
|
+
new UnifiUpdateWouldBeNoop({
|
|
75
|
+
type: 'Unifi.Network',
|
|
76
|
+
identity: `sites/${props.siteId}/networks/${props.networkId}`,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return Effect.succeed({
|
|
81
|
+
...body,
|
|
82
|
+
siteId: props.siteId,
|
|
83
|
+
networkId: live.id,
|
|
84
|
+
} as networks.UpdateNetworkRequest);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const writeNetwork = (
|
|
88
|
+
live: networks.NetworkDetails,
|
|
89
|
+
patch: Patch<NetworkProps>,
|
|
90
|
+
props: NetworkProps,
|
|
91
|
+
) =>
|
|
92
|
+
checkNetworkWriteScope(live, patch, props).pipe(
|
|
93
|
+
Effect.andThen(mergeForUpdate(live, patch, props)),
|
|
94
|
+
Effect.flatMap((body) => networks.updateNetwork(body).pipe(Retry.none)),
|
|
95
|
+
);
|
|
@@ -90,7 +90,7 @@ describe('declareNetwork -- the declaration renderer', () => {
|
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
describe('Unifi.Network
|
|
93
|
+
describe('Unifi.Network: only the one allowed PUT ever reaches the vendor API', () => {
|
|
94
94
|
const ops = unifiOperations(spec);
|
|
95
95
|
|
|
96
96
|
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
@@ -119,6 +119,32 @@ describe('Unifi.Network write paths never reach the vendor API', () => {
|
|
|
119
119
|
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
test('a missing network still refuses create -- nothing is ever POSTed', async () => {
|
|
123
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
124
|
+
const failure = await Effect.runPromise(
|
|
125
|
+
Effect.flip(ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
126
|
+
);
|
|
127
|
+
expect(failure).toMatchObject({ _tag: 'UnifiWriteRefused', action: 'create' });
|
|
128
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('drift with olds undefined (first deploy) refuses update, all-GET', async () => {
|
|
132
|
+
const fake = fakeUnifi((method, url) =>
|
|
133
|
+
method === 'GET' && url.pathname === NETWORK_PATH
|
|
134
|
+
? Response.json(liveNetwork({ vlanId: 999 }))
|
|
135
|
+
: fakeFailure(400, 'unexpected request'),
|
|
136
|
+
);
|
|
137
|
+
const failure = await Effect.runPromise(
|
|
138
|
+
Effect.flip(
|
|
139
|
+
ops
|
|
140
|
+
.reconcile(PROPS, { olds: undefined, output: undefined })
|
|
141
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
expect(failure).toMatchObject({ _tag: 'UnifiWriteRefused', action: 'update' });
|
|
145
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
122
148
|
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
123
149
|
const fake = fakeUnifi((method, url) =>
|
|
124
150
|
method === 'GET' && url.pathname === NETWORK_PATH
|