@homeflare/alchemy 0.57.0 → 0.59.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 +17 -0
- package/dist/caddy/config.d.ts +6 -0
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +128 -61
- package/dist/caddy/index.js.map +6 -4
- package/dist/scope/duplicate-id.d.ts +32 -0
- package/dist/scope/duplicate-id.d.ts.map +1 -0
- package/dist/scope/guard.d.ts +28 -0
- package/dist/scope/guard.d.ts.map +1 -0
- package/dist/scope/index.d.ts +21 -0
- package/dist/scope/index.d.ts.map +1 -0
- package/dist/scope/index.js +71 -0
- package/dist/scope/index.js.map +11 -0
- package/dist/unifi/firewall-policy-drift.d.ts +3 -3
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-form.d.ts +18 -15
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
- package/dist/unifi/index.js +64 -8
- package/dist/unifi/index.js.map +5 -5
- package/dist/unifi/network-update.fixtures.d.ts +53 -0
- package/dist/unifi/network-update.fixtures.d.ts.map +1 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -1
- package/docs/duplicate-resource-id.md +91 -0
- package/docs/provider-standard.md +9 -0
- package/package.json +5 -1
- package/src/caddy/config.ts +12 -3
- package/src/scope/duplicate-id.test.ts +95 -0
- package/src/scope/duplicate-id.ts +113 -0
- package/src/scope/guard.test.ts +128 -0
- package/src/scope/guard.ts +49 -0
- package/src/scope/index.ts +20 -0
- package/src/unifi/firewall-policy-drift.test.ts +64 -0
- package/src/unifi/firewall-policy-drift.ts +24 -7
- package/src/unifi/firewall-policy-form.ts +61 -15
- package/src/unifi/network-update-accept.test.ts +59 -0
- package/src/unifi/network-update.fixtures.ts +74 -0
- package/src/unifi/network-update.test.ts +11 -69
- package/src/unifi/update-reconcile.ts +37 -3
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* before comparing — never applied to only `live` or only `declared` (the specific gap MEDIUM-4's
|
|
11
11
|
* own mutant found in `network-drift.ts`; see its header, and `acl-rule-drift.ts`'s own repeat of
|
|
12
12
|
* this same rule for `protocolFilter`/`enforcingDeviceFilter`).
|
|
13
|
-
* ★ `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* ★ `destination`/`ipProtocolScope`/`schedule`/`source` run through `normalizeSetArrays` on BOTH
|
|
14
|
+
* sides (set-like nested arrays compare as sets; `firewall-policy-form.ts`'s header), then the
|
|
15
|
+
* default `deepEqual`. `action` compares wholesale.
|
|
16
16
|
*/
|
|
17
17
|
import type * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
18
18
|
import { makeDriftOf } from './drift.ts';
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type FirewallPolicyProps,
|
|
22
22
|
attributesOf,
|
|
23
23
|
normalizeConnectionStateFilter,
|
|
24
|
+
normalizeSetArrays,
|
|
24
25
|
} from './firewall-policy-form.ts';
|
|
25
26
|
|
|
26
27
|
const fieldDrift = makeDriftOf<FirewallPolicyAttributes, FirewallPolicyProps>([
|
|
@@ -31,14 +32,30 @@ const fieldDrift = makeDriftOf<FirewallPolicyAttributes, FirewallPolicyProps>([
|
|
|
31
32
|
declared: (p) => normalizeConnectionStateFilter(p.connectionStateFilter),
|
|
32
33
|
},
|
|
33
34
|
{ field: 'description', live: (a) => a.description, declared: (p) => p.description },
|
|
34
|
-
{
|
|
35
|
+
{
|
|
36
|
+
field: 'destination',
|
|
37
|
+
live: (a) => normalizeSetArrays(a.destination),
|
|
38
|
+
declared: (p) => normalizeSetArrays(p.destination),
|
|
39
|
+
},
|
|
35
40
|
{ field: 'enabled', live: (a) => a.enabled, declared: (p) => p.enabled },
|
|
36
|
-
{
|
|
41
|
+
{
|
|
42
|
+
field: 'ipProtocolScope',
|
|
43
|
+
live: (a) => normalizeSetArrays(a.ipProtocolScope),
|
|
44
|
+
declared: (p) => normalizeSetArrays(p.ipProtocolScope),
|
|
45
|
+
},
|
|
37
46
|
{ field: 'ipsecFilter', live: (a) => a.ipsecFilter, declared: (p) => p.ipsecFilter },
|
|
38
47
|
{ field: 'loggingEnabled', live: (a) => a.loggingEnabled, declared: (p) => p.loggingEnabled },
|
|
39
48
|
{ field: 'name', live: (a) => a.name, declared: (p) => p.name },
|
|
40
|
-
{
|
|
41
|
-
|
|
49
|
+
{
|
|
50
|
+
field: 'schedule',
|
|
51
|
+
live: (a) => normalizeSetArrays(a.schedule),
|
|
52
|
+
declared: (p) => normalizeSetArrays(p.schedule),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
field: 'source',
|
|
56
|
+
live: (a) => normalizeSetArrays(a.source),
|
|
57
|
+
declared: (p) => normalizeSetArrays(p.source),
|
|
58
|
+
},
|
|
42
59
|
]);
|
|
43
60
|
|
|
44
61
|
export const matches = (
|
|
@@ -15,17 +15,18 @@
|
|
|
15
15
|
* `deepEqual` (`stripNullish`) as ONE value, same proportion `acl-rule-form.ts`'s
|
|
16
16
|
* `enforcingDeviceFilter` gets (one level of structure, not exploded into scalars).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
|
|
22
|
-
* `FirewallPolicyApplicationFilter.applicationIds`, `
|
|
23
|
-
* `
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
18
|
+
* ★ SET-LIKE NESTED ARRAYS COMPARE AS SETS (closed 2026-10-19; was T15's accepted gap). The
|
|
19
|
+
* vendor's filter variants inside `source`/`destination`/`ipProtocolScope`/`schedule` carry
|
|
20
|
+
* arrays with no documented order: `FirewallPolicyIPAddressFilter.items`, the port filter's
|
|
21
|
+
* `items`, `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
|
|
22
|
+
* `FirewallPolicyApplicationFilter.applicationIds`, `FirewallPolicyRegionFilter.regions`,
|
|
23
|
+
* `FirewallPolicyVPNServerFilter.vpnServerIds`, `FirewallPolicyDomainFilter.domains`,
|
|
24
|
+
* `applicationCategoryIds` and `FirewallSchedule.repeatOnDays`. Measured:
|
|
25
|
+
* the controller (Network 11.0.83) stored a declared port list [443, 9100] as [9100, 443], so a
|
|
26
|
+
* correct create failed its read-back (`UnifiCreateDidNotConverge`, homeflare-network PR 79).
|
|
27
|
+
* `normalizeSetArrays` sorts every array under one of those keys (by canonical JSON) on BOTH
|
|
28
|
+
* sides before `deepEqual`. Membership still has to match exactly, and duplicates are kept, so a
|
|
29
|
+
* real change is never hidden.
|
|
29
30
|
*
|
|
30
31
|
* ⛔ `connectionStateFilter` IS THE ONE TOP-LEVEL PROPS ARRAY, AND IS A SET — "Match on firewall
|
|
31
32
|
* connection state. If null, matches all connection states" describes membership, not a sequence
|
|
@@ -54,6 +55,51 @@ export const normalizeConnectionStateFilter = (
|
|
|
54
55
|
value: readonly string[] | undefined,
|
|
55
56
|
): string[] | undefined => (value == null ? undefined : sortedSet(value));
|
|
56
57
|
|
|
58
|
+
/** Keys whose array value is a set in the vendor's firewall filter schemas (header). */
|
|
59
|
+
const SET_ARRAY_KEYS: ReadonlySet<string> = new Set([
|
|
60
|
+
'items',
|
|
61
|
+
'macAddresses',
|
|
62
|
+
'networkIds',
|
|
63
|
+
'applicationIds',
|
|
64
|
+
'regions',
|
|
65
|
+
'vpnServerIds',
|
|
66
|
+
'repeatOnDays',
|
|
67
|
+
'domains',
|
|
68
|
+
'applicationCategoryIds',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** Key-order-independent JSON: a hand-written `{start, stop, type}` and a decoded `{type, start,
|
|
72
|
+
* stop}` must sort to the same place (Opus red team, kit PR 466). */
|
|
73
|
+
const sortKeys = (value: unknown): unknown =>
|
|
74
|
+
Array.isArray(value)
|
|
75
|
+
? value.map(sortKeys)
|
|
76
|
+
: value !== null && typeof value === 'object'
|
|
77
|
+
? Object.fromEntries(
|
|
78
|
+
Object.keys(value as object)
|
|
79
|
+
.sort()
|
|
80
|
+
.map((k) => [k, sortKeys((value as Record<string, unknown>)[k])]),
|
|
81
|
+
)
|
|
82
|
+
: value;
|
|
83
|
+
const canonical = (value: unknown): string => JSON.stringify(sortKeys(normalizeSetArrays(value)));
|
|
84
|
+
|
|
85
|
+
/** A deep copy with every SET_ARRAY_KEYS array sorted by canonical JSON (duplicates kept). */
|
|
86
|
+
export const normalizeSetArrays = <T>(value: T): T => {
|
|
87
|
+
if (Array.isArray(value)) return value.map((v) => normalizeSetArrays(v)) as T;
|
|
88
|
+
if (value === null || typeof value !== 'object') return value;
|
|
89
|
+
const out: Record<string, unknown> = {};
|
|
90
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
91
|
+
out[key] =
|
|
92
|
+
SET_ARRAY_KEYS.has(key) && Array.isArray(inner)
|
|
93
|
+
? [...inner]
|
|
94
|
+
.map((v) => normalizeSetArrays(v))
|
|
95
|
+
.sort((x, y) =>
|
|
96
|
+
canonical(x) < canonical(y) ? -1 : canonical(x) > canonical(y) ? 1 : 0,
|
|
97
|
+
)
|
|
98
|
+
: normalizeSetArrays(inner);
|
|
99
|
+
}
|
|
100
|
+
return out as T;
|
|
101
|
+
};
|
|
102
|
+
|
|
57
103
|
/** ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` — `exactOptionalPropertyTypes`; see
|
|
58
104
|
* `network-form.ts`'s own header.
|
|
59
105
|
*
|
|
@@ -70,17 +116,17 @@ export interface FirewallPolicyProps {
|
|
|
70
116
|
/** Connection states this policy matches — compared as a SET, see the header. */
|
|
71
117
|
connectionStateFilter?: string[] | undefined;
|
|
72
118
|
description?: string | undefined;
|
|
73
|
-
/** Traffic destination — compound, compared wholesale; see the header
|
|
119
|
+
/** Traffic destination — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
74
120
|
destination: firewall.FirewallPolicyDestination;
|
|
75
121
|
enabled: boolean;
|
|
76
|
-
/** IP version + protocol scope — compound, compared wholesale; see the header
|
|
122
|
+
/** IP version + protocol scope — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
77
123
|
ipProtocolScope: firewall.FirewallPolicyIPProtocolScope;
|
|
78
124
|
ipsecFilter?: string | undefined;
|
|
79
125
|
loggingEnabled: boolean;
|
|
80
126
|
name: string;
|
|
81
|
-
/** Schedule this policy is active on — compound, compared wholesale; see the header
|
|
127
|
+
/** Schedule this policy is active on — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
82
128
|
schedule?: firewall.FirewallSchedule | undefined;
|
|
83
|
-
/** Traffic source — compound, compared wholesale; see the header
|
|
129
|
+
/** Traffic source — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
84
130
|
source: firewall.FirewallPolicySource;
|
|
85
131
|
}
|
|
86
132
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A live edit made since the last deploy that the declaration ALREADY matches (a re-import
|
|
3
|
+
* recorded it) is accepted, not refused: it is folded into the baseline, so it is neither a
|
|
4
|
+
* conflict nor part of the PUT patch. A live edit the declaration disagrees with is still
|
|
5
|
+
* refused (network-update.test.ts case 6). Measured case: homeflare-network VLAN 16,
|
|
6
|
+
* 2026-10-19 (mdnsForwardingEnabled edited on the console, re-imported, refused forever).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import type { NetworkProps } from './network.ts';
|
|
10
|
+
import {
|
|
11
|
+
BASE,
|
|
12
|
+
IPV6,
|
|
13
|
+
WITH_V6,
|
|
14
|
+
answering,
|
|
15
|
+
live,
|
|
16
|
+
putBody,
|
|
17
|
+
reconcile,
|
|
18
|
+
} from './network-update.fixtures.ts';
|
|
19
|
+
|
|
20
|
+
describe('Unifi.Network update: accepted live drift', () => {
|
|
21
|
+
test('a console edit the re-import recorded is accepted; the declared change still PUTs', async () => {
|
|
22
|
+
const liveBody = live({ mdnsForwardingEnabled: true });
|
|
23
|
+
const news = { ...WITH_V6, mdnsForwardingEnabled: true } as NetworkProps;
|
|
24
|
+
const route = answering(liveBody, () =>
|
|
25
|
+
Response.json({ ...liveBody, ipv6Configuration: IPV6 }),
|
|
26
|
+
);
|
|
27
|
+
const { fake, exit, notes } = reconcile(route, news, BASE);
|
|
28
|
+
expect((await exit)._tag).toBe('Success');
|
|
29
|
+
expect(fake.seen.map((s) => s.method)).toEqual(['GET', 'PUT']);
|
|
30
|
+
const body = putBody(fake.seen)[0] as Record<string, unknown>;
|
|
31
|
+
expect(body.ipv6Configuration).toEqual(IPV6);
|
|
32
|
+
expect(body.mdnsForwardingEnabled).toBe(true);
|
|
33
|
+
expect(notes.some((n) => n.includes('accepted: [mdnsForwardingEnabled]'))).toBe(true);
|
|
34
|
+
expect(notes.some((n) => /PUT changes \[ipv6Configuration\]/.test(n))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('accepted drift never widens the patch past the declared change', async () => {
|
|
38
|
+
const liveBody = live({ name: 'Renamed' });
|
|
39
|
+
const news = { ...WITH_V6, name: 'Renamed' } as NetworkProps;
|
|
40
|
+
const route = answering(liveBody, () =>
|
|
41
|
+
Response.json({ ...liveBody, ipv6Configuration: IPV6 }),
|
|
42
|
+
);
|
|
43
|
+
const { exit, notes } = reconcile(route, news, BASE);
|
|
44
|
+
expect((await exit)._tag).toBe('Success');
|
|
45
|
+
expect(notes.find((n) => n.includes('PUT changes'))).toContain(
|
|
46
|
+
'PUT changes [ipv6Configuration]',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('drift the declaration disagrees with stays refused, even beside an accepted field', async () => {
|
|
51
|
+
const liveBody = live({ mdnsForwardingEnabled: true, name: 'Renamed' });
|
|
52
|
+
const news = { ...WITH_V6, mdnsForwardingEnabled: true } as NetworkProps;
|
|
53
|
+
const { fake, exit } = reconcile(answering(liveBody), news, BASE);
|
|
54
|
+
const result = JSON.stringify(await exit);
|
|
55
|
+
expect(result).toContain('UnifiLiveDriftedSinceDeploy');
|
|
56
|
+
expect(result).toContain('"fields":["name"]');
|
|
57
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the `Unifi.Network` update tests (network-update.test.ts,
|
|
3
|
+
* network-update-accept.test.ts): the fake controller route, the base declaration and the
|
|
4
|
+
* reconcile driver. Extracted so neither test file passes the 250-line cap.
|
|
5
|
+
*/
|
|
6
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
9
|
+
import { type NetworkProps, spec } from './network.ts';
|
|
10
|
+
import { unifiOperations } from './resource.ts';
|
|
11
|
+
|
|
12
|
+
export const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
|
|
13
|
+
export const IPV6 = {
|
|
14
|
+
interfaceType: 'STATIC',
|
|
15
|
+
prefixLength: 64,
|
|
16
|
+
hostIpAddress: '2602:f660:0:16::254',
|
|
17
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
18
|
+
routerAdvertisement: { priority: 'HIGH' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const BASE: NetworkProps = {
|
|
22
|
+
siteId: 'site-1',
|
|
23
|
+
networkId: 'net-1',
|
|
24
|
+
name: 'Compute',
|
|
25
|
+
enabled: true,
|
|
26
|
+
management: 'ADVANCED',
|
|
27
|
+
vlanId: 16,
|
|
28
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
|
|
29
|
+
ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
|
|
30
|
+
};
|
|
31
|
+
export const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
|
|
32
|
+
|
|
33
|
+
/** Raw live object, with a key the SDK schema has never heard of. */
|
|
34
|
+
export const live = (extra: Record<string, unknown> = {}) => ({
|
|
35
|
+
id: 'net-1',
|
|
36
|
+
default: false,
|
|
37
|
+
metadata: { origin: 'USER' },
|
|
38
|
+
futureTopLevelKey: 'x',
|
|
39
|
+
...BASE,
|
|
40
|
+
siteId: undefined,
|
|
41
|
+
networkId: undefined,
|
|
42
|
+
...extra,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const reconcile = (
|
|
46
|
+
route: (method: string, url: URL) => Response,
|
|
47
|
+
news: NetworkProps,
|
|
48
|
+
olds: NetworkProps | undefined,
|
|
49
|
+
outputArg: object | null = {},
|
|
50
|
+
) => {
|
|
51
|
+
const output = outputArg ?? undefined;
|
|
52
|
+
const fake = fakeUnifi(route);
|
|
53
|
+
const notes: string[] = [];
|
|
54
|
+
const run = unifiOperations(spec)
|
|
55
|
+
.reconcile(news, {
|
|
56
|
+
olds,
|
|
57
|
+
output,
|
|
58
|
+
note: (m) => Effect.sync(() => void notes.push(m)),
|
|
59
|
+
})
|
|
60
|
+
.pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch)));
|
|
61
|
+
return { fake, notes, exit: Effect.runPromiseExit(run) };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const answering =
|
|
65
|
+
(liveBody: object, put: () => Response = () => Response.json(liveBody)) =>
|
|
66
|
+
(method: string, url: URL) =>
|
|
67
|
+
url.pathname === PATH
|
|
68
|
+
? method === 'GET'
|
|
69
|
+
? Response.json(liveBody)
|
|
70
|
+
: put()
|
|
71
|
+
: fakeFailure(400, 'unexpected');
|
|
72
|
+
|
|
73
|
+
export const putBody = (seen: ReadonlyArray<{ method: string; body?: unknown }>) =>
|
|
74
|
+
seen.filter((s) => s.method === 'PUT').map((s) => s.body as Record<string, unknown>);
|
|
@@ -8,81 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { describe, expect, test } from 'bun:test';
|
|
10
10
|
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
|
|
11
|
+
import { fakeFailure } from './fake-unifi.ts';
|
|
12
|
+
import { NETWORK_PATCH_KEYS, type NetworkProps } from './network.ts';
|
|
13
|
+
import {
|
|
14
|
+
BASE,
|
|
15
|
+
IPV6,
|
|
16
|
+
WITH_V6,
|
|
17
|
+
answering,
|
|
18
|
+
live,
|
|
19
|
+
putBody,
|
|
20
|
+
reconcile,
|
|
21
|
+
} from './network-update.fixtures.ts';
|
|
15
22
|
import { SERVER_FIELDS } from './network-update.ts';
|
|
16
23
|
import {
|
|
17
24
|
UnifiLiveDriftedSinceDeploy,
|
|
18
25
|
UnifiUpdateDidNotConverge,
|
|
19
26
|
UnifiWriteRefused,
|
|
20
27
|
} 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
28
|
|
|
87
29
|
describe('Unifi.Network update', () => {
|
|
88
30
|
test('(1) one GET then one PUT: live carried through byte-equal, server fields dropped, patch added', async () => {
|
|
@@ -72,6 +72,21 @@ export const computePatch = <Props extends object>(
|
|
|
72
72
|
return { set, unset };
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
/** `olds` with each accepted field taken from `news` (which equals live there), so the patch
|
|
76
|
+
* skips it. A `FieldDrift.field` names a props key; one that is not is left alone. */
|
|
77
|
+
const acceptInto = <Props extends object>(
|
|
78
|
+
olds: Props,
|
|
79
|
+
news: Props,
|
|
80
|
+
accepted: ReadonlyArray<unknown>,
|
|
81
|
+
): Props => {
|
|
82
|
+
const next: Record<string, unknown> = { ...(olds as Record<string, unknown>) };
|
|
83
|
+
for (const field of accepted) {
|
|
84
|
+
const key = String(field);
|
|
85
|
+
if (key in (news as object) || key in next) next[key] = (news as Record<string, unknown>)[key];
|
|
86
|
+
}
|
|
87
|
+
return next as Props;
|
|
88
|
+
};
|
|
89
|
+
|
|
75
90
|
export type ReconcileArgs<Props extends object> = {
|
|
76
91
|
readonly news: Props;
|
|
77
92
|
readonly olds: Props | undefined;
|
|
@@ -99,15 +114,34 @@ export const updateReconcile = <Props extends object, Live, Attributes extends o
|
|
|
99
114
|
if (previous !== identity) {
|
|
100
115
|
return yield* new UnifiIdentityChanged({ type: spec.type, identity, previous });
|
|
101
116
|
}
|
|
117
|
+
// ★ A FIELD THAT DRIFTED SINCE THE LAST DEPLOY IS A CONFLICT ONLY IF THE DECLARATION STILL
|
|
118
|
+
// DISAGREES WITH LIVE ON IT. When the declaration already equals live there (a re-import
|
|
119
|
+
// recorded the console edit, the documented recovery for UnifiLiveDriftedSinceDeploy),
|
|
120
|
+
// the edit is ACCEPTED: folded into the baseline, so it is neither refused nor patched. The
|
|
121
|
+
// PUT is live plus the patch, so live's value survives untouched. Without this, `olds`
|
|
122
|
+
// (the last deployed state) only moves on a successful deploy, so a re-import could never
|
|
123
|
+
// clear the refusal (homeflare-network VLAN 16, 2026-10-19: mdnsForwardingEnabled edited on
|
|
124
|
+
// the console, re-imported, and still refused on every deploy).
|
|
125
|
+
// ⛔ A drifted field the declaration DISAGREES with stays a refusal: the PUT would overwrite
|
|
126
|
+
// a hand edit nobody re-imported.
|
|
127
|
+
const disagrees = new Set(update.driftOf(live, news).map((d) => String(d.field)));
|
|
102
128
|
const drifted = update.driftOf(live, olds).map((d) => d.field);
|
|
103
|
-
|
|
129
|
+
const conflicts = drifted.filter((field) => disagrees.has(String(field)));
|
|
130
|
+
if (conflicts.length > 0) {
|
|
104
131
|
return yield* new UnifiLiveDriftedSinceDeploy({
|
|
105
132
|
type: spec.type,
|
|
106
133
|
identity,
|
|
107
|
-
fields:
|
|
134
|
+
fields: conflicts,
|
|
108
135
|
});
|
|
109
136
|
}
|
|
110
|
-
const
|
|
137
|
+
const accepted = drifted.filter((field) => !disagrees.has(String(field)));
|
|
138
|
+
const baseline = acceptInto(olds, news, accepted);
|
|
139
|
+
if (accepted.length > 0) {
|
|
140
|
+
yield* note(
|
|
141
|
+
`${spec.type} ${identity}: live edits since the last deploy already match the declaration, accepted: [${accepted.map(String).join(', ')}]`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const patch = computePatch(news, baseline, update.patchKeys);
|
|
111
145
|
if (Object.keys(patch.set).length === 0 && patch.unset.length === 0) {
|
|
112
146
|
return yield* refuseWrite(spec.type, identity, 'update');
|
|
113
147
|
}
|