@homeflare/alchemy 0.31.2 → 0.33.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 +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `alias-form.ts`'s pure functions against literal `AliasItem` fixtures — no network, no fake
|
|
3
|
+
* server. Mirrors `../discord/command-form.ts`'s own test file in spirit.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
7
|
+
import { attributesOf, matches } from './alias-form.ts';
|
|
8
|
+
import type { AliasProps } from './alias.ts';
|
|
9
|
+
|
|
10
|
+
const UUID = '11111111-1111-1111-1111-111111111111';
|
|
11
|
+
|
|
12
|
+
const liveHost = (overrides: Partial<alias.AliasItem> = {}): alias.AliasItem => ({
|
|
13
|
+
content: '10.20.10.1',
|
|
14
|
+
enabled: '1',
|
|
15
|
+
name: 'homeflare_edge',
|
|
16
|
+
type: 'host',
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('alias-form attributesOf', () => {
|
|
21
|
+
test('settles OPNsense wire spellings to plain values', () => {
|
|
22
|
+
expect(attributesOf(UUID, liveHost())).toEqual({
|
|
23
|
+
categories: [],
|
|
24
|
+
content: '10.20.10.1',
|
|
25
|
+
counters: false,
|
|
26
|
+
description: '',
|
|
27
|
+
enabled: true,
|
|
28
|
+
interface: '',
|
|
29
|
+
name: 'homeflare_edge',
|
|
30
|
+
proto: '',
|
|
31
|
+
type: 'host',
|
|
32
|
+
updatefreq: 0,
|
|
33
|
+
uuid: UUID,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('"0" is disabled, not absent', () => {
|
|
38
|
+
expect(attributesOf(UUID, liveHost({ enabled: '0' })).enabled).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('categories are split, sorted and deduplicated', () => {
|
|
42
|
+
const live = liveHost({ categories: 'c-two,c-one,c-one' });
|
|
43
|
+
expect(attributesOf(UUID, live).categories).toEqual(['c-one', 'c-two']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('alias-form matches', () => {
|
|
48
|
+
const props: AliasProps = {
|
|
49
|
+
content: '10.20.10.1',
|
|
50
|
+
name: 'homeflare_edge',
|
|
51
|
+
type: 'host',
|
|
52
|
+
uuid: UUID,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
test('a live item decoded through attributesOf matches its own declaration', () => {
|
|
56
|
+
expect(matches(attributesOf(UUID, liveHost()), props)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('content drift is caught', () => {
|
|
60
|
+
const drifted = attributesOf(UUID, liveHost({ content: '10.20.10.2' }));
|
|
61
|
+
expect(matches(drifted, props)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('category order alone is not drift — both sides normalise through the same set', () => {
|
|
65
|
+
const live = attributesOf(UUID, liveHost({ categories: 'b,a' }));
|
|
66
|
+
const declared: AliasProps = { ...props, categories: ['a', 'b'] };
|
|
67
|
+
expect(matches(live, declared)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an undeclared optional field takes its settled default, not a mismatch', () => {
|
|
71
|
+
// `proto`/`interface`/`description` etc. are absent from `props` here; `liveHost()` also
|
|
72
|
+
// never sets them, so both sides settle to the same empty defaults.
|
|
73
|
+
expect(matches(attributesOf(UUID, liveHost()), props)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('propsFromLive round-trip — the declaration renderer whole contract', () => {
|
|
78
|
+
test('the props it returns plan noop against the exact live object they came from', () => {
|
|
79
|
+
// alias.ts re-exports `attributesOf` itself as `propsFromLive`; tested directly here since
|
|
80
|
+
// alias.ts also wires up the Resource machinery this file does not need to import.
|
|
81
|
+
const live = liveHost({
|
|
82
|
+
categories: 'c-two,c-one',
|
|
83
|
+
content: '203.0.113.0/24',
|
|
84
|
+
type: 'network',
|
|
85
|
+
});
|
|
86
|
+
const rendered = attributesOf(UUID, live);
|
|
87
|
+
expect(matches(attributesOf(UUID, live), rendered)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Opnsense.Firewall.Alias`'s wire shape: how a live `AliasItem` becomes attributes, and the
|
|
3
|
+
* comparison that decides whether a declaration already matches it. Split out of alias.ts to
|
|
4
|
+
* keep that file under the house's 250-line cap, mirroring `../proxmox/user-wire.ts`.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ SCOPE, STATED ONCE. `AliasItem` (firewall_alias.ts) carries far more fields than this props
|
|
7
|
+
* type declares — three groups are left out on purpose:
|
|
8
|
+
* 1. Runtime counters (`current_items`, `last_updated`, `eval_nomatch`, `eval_match`,
|
|
9
|
+
* `in_block_p`/`_b`, `in_pass_p`/`_b`, `out_block_p`/`_b`, `out_pass_p`/`_b`) — these are
|
|
10
|
+
* the alias's hit/block statistics, not configuration, exactly the "stats" the task that
|
|
11
|
+
* opened this family says to exclude.
|
|
12
|
+
* 2. `path_expression` — only meaningful for the `urljson` alias type; a niche field this
|
|
13
|
+
* first import pass leaves for a later one rather than modelling one type's one field.
|
|
14
|
+
* 3. `password`/`username`/`authtype`/`expire` — the URL-fetch credential fields for the
|
|
15
|
+
* `url`/`urltable`/`urljson` alias types. S25 (secrets stay out of props/attributes):
|
|
16
|
+
* `password` is a literal stored credential and Alchemy persists Attributes unencrypted,
|
|
17
|
+
* the same reasoning `../proxmox/user.ts` gives for shipping no `password` prop at all.
|
|
18
|
+
*/
|
|
19
|
+
import type { AliasAttributes, AliasProps } from './alias.ts';
|
|
20
|
+
import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
21
|
+
import { bool01, csvSet, csvSetOf, numField } from './wire.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ★ ATTRIBUTES AND THE DECLARATION RENDERER ARE ONE FUNCTION. Attributes ARE exactly the props a
|
|
25
|
+
* noop declaration needs — see the exported `propsFromLive` alias in alias.ts's barrel and
|
|
26
|
+
* `alias-form.test.ts`'s round-trip test (`matches(attributesOf(uuid, live), attributesOf(uuid,
|
|
27
|
+
* live))` is always true by construction, and so is the general case).
|
|
28
|
+
*/
|
|
29
|
+
export const attributesOf = (uuid: string, live: alias.AliasItem): AliasAttributes => ({
|
|
30
|
+
categories: csvSet(live.categories),
|
|
31
|
+
content: live.content ?? '',
|
|
32
|
+
counters: bool01(live.counters, false),
|
|
33
|
+
description: live.description ?? '',
|
|
34
|
+
enabled: bool01(live.enabled, true),
|
|
35
|
+
interface: live.interface ?? '',
|
|
36
|
+
name: live.name,
|
|
37
|
+
proto: live.proto ?? '',
|
|
38
|
+
type: live.type,
|
|
39
|
+
updatefreq: numField(live.updatefreq, 0),
|
|
40
|
+
uuid,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const matches = (attributes: AliasAttributes, props: AliasProps): boolean =>
|
|
44
|
+
attributes.name === props.name &&
|
|
45
|
+
attributes.type === props.type &&
|
|
46
|
+
attributes.enabled === (props.enabled ?? true) &&
|
|
47
|
+
attributes.content === (props.content ?? '') &&
|
|
48
|
+
attributes.interface === (props.interface ?? '') &&
|
|
49
|
+
attributes.proto === (props.proto ?? '') &&
|
|
50
|
+
attributes.counters === (props.counters ?? false) &&
|
|
51
|
+
attributes.updatefreq === (props.updatefreq ?? 0) &&
|
|
52
|
+
attributes.description === (props.description ?? '') &&
|
|
53
|
+
attributes.categories.join(',') === csvSetOf(props.categories).join(',');
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Opnsense.Firewall.Alias`'s `spec` against a fake OPNsense, proving the real distilled wire
|
|
3
|
+
* path — path assembly, JSON decode and typed-error propagation — exercised through
|
|
4
|
+
* `@distilled.cloud/opnsense/firewall_alias`'s real `get`, never re-implemented here. Also proves
|
|
5
|
+
* `opnsenseOperations(spec)`'s read/diff/reconcile/destroy flow (resource.ts), the exact object
|
|
6
|
+
* `opnsenseHandlers` wraps into `OpnsenseFirewallAliasProvider`. Mirrors
|
|
7
|
+
* `../discord/guild-application-command.test.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
11
|
+
import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import { attributesOf } from './alias-form.ts';
|
|
14
|
+
import type { AliasProps } from './alias.ts';
|
|
15
|
+
import { spec } from './alias.ts';
|
|
16
|
+
import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
|
|
17
|
+
import type { OpnsenseWriteRefused } from './policy.ts';
|
|
18
|
+
import { opnsenseOperations } from './resource.ts';
|
|
19
|
+
|
|
20
|
+
/** `Effect.flip`'s promise resolves to the union of every error this spec can raise; every
|
|
21
|
+
* refusal test below is asserting specifically on the `OpnsenseWriteRefused` member. */
|
|
22
|
+
const asRefusal = (value: unknown) => value as OpnsenseWriteRefused;
|
|
23
|
+
|
|
24
|
+
const UUID = '11111111-1111-1111-1111-111111111111';
|
|
25
|
+
const GET_PATH = '/api/firewall/alias/get';
|
|
26
|
+
const PROPS: AliasProps = {
|
|
27
|
+
content: '10.20.10.1',
|
|
28
|
+
name: 'homeflare_edge',
|
|
29
|
+
type: 'host',
|
|
30
|
+
uuid: UUID,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const liveItem = (overrides: Partial<alias.AliasItem> = {}): alias.AliasItem => ({
|
|
34
|
+
content: '10.20.10.1',
|
|
35
|
+
enabled: '1',
|
|
36
|
+
name: 'homeflare_edge',
|
|
37
|
+
type: 'host',
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const getResponse = (items: Record<string, alias.AliasItem>) =>
|
|
42
|
+
Response.json({ alias: { aliases: { alias: items } } });
|
|
43
|
+
|
|
44
|
+
describe('Opnsense.Firewall.Alias spec.fetchLive', () => {
|
|
45
|
+
test('finds the item whose uuid is the map key, among others', async () => {
|
|
46
|
+
const fake = getOnlyOrFail((url) =>
|
|
47
|
+
url.pathname === GET_PATH
|
|
48
|
+
? getResponse({ 'other-uuid': liveItem({ name: 'other' }), [UUID]: liveItem() })
|
|
49
|
+
: fakeFailure(400, { errorMessage: 'unexpected request' }),
|
|
50
|
+
);
|
|
51
|
+
const live = await Effect.runPromise(
|
|
52
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
53
|
+
);
|
|
54
|
+
expect(live?.name).toBe('homeflare_edge');
|
|
55
|
+
expect(fake.seen).toHaveLength(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('no matching uuid key is absent, not an error', async () => {
|
|
59
|
+
const fake = getOnlyOrFail(() => getResponse({ 'other-uuid': liveItem() }));
|
|
60
|
+
const live = await Effect.runPromise(
|
|
61
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
62
|
+
);
|
|
63
|
+
expect(live).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an empty model (no aliases section at all) is absent, not a crash', async () => {
|
|
67
|
+
const fake = getOnlyOrFail(() => Response.json({ alias: {} }));
|
|
68
|
+
const live = await Effect.runPromise(
|
|
69
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
70
|
+
);
|
|
71
|
+
expect(live).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a 401 propagates — never folded to absent (the live bug this family avoids)', async () => {
|
|
75
|
+
const fake = fakeOpnsense(() => fakeFailure(401, { message: 'Unauthorized' }));
|
|
76
|
+
const failure = await Effect.runPromise(
|
|
77
|
+
Effect.flip(spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch)))),
|
|
78
|
+
);
|
|
79
|
+
expect(failure._tag).toBe('Unauthorized');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('opnsenseOperations(spec).read — S7/S8, marker-less API', () => {
|
|
84
|
+
test('cold read (no output) answers Unowned', async () => {
|
|
85
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
86
|
+
const attrs = await Effect.runPromise(
|
|
87
|
+
opnsenseOperations(spec)
|
|
88
|
+
.read({ olds: PROPS, output: undefined })
|
|
89
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
90
|
+
);
|
|
91
|
+
expect(attrs).toBeDefined();
|
|
92
|
+
expect(Unowned.is(attrs)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('warm read (output already ours) answers plain attributes', async () => {
|
|
96
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
97
|
+
const priorOutput = attributesOf(UUID, liveItem());
|
|
98
|
+
const attrs = await Effect.runPromise(
|
|
99
|
+
opnsenseOperations(spec)
|
|
100
|
+
.read({ olds: PROPS, output: priorOutput })
|
|
101
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
102
|
+
);
|
|
103
|
+
expect(Unowned.is(attrs)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('absent is undefined either way', async () => {
|
|
107
|
+
const fake = getOnlyOrFail(() => getResponse({}));
|
|
108
|
+
const attrs = await Effect.runPromise(
|
|
109
|
+
opnsenseOperations(spec)
|
|
110
|
+
.read({ olds: PROPS, output: undefined })
|
|
111
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
112
|
+
);
|
|
113
|
+
expect(attrs).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('opnsenseOperations(spec).diff', () => {
|
|
118
|
+
test('noop when the declaration already matches live', async () => {
|
|
119
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
120
|
+
const result = await Effect.runPromise(
|
|
121
|
+
opnsenseOperations(spec)
|
|
122
|
+
.diff(PROPS, attributesOf(UUID, liveItem()))
|
|
123
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
124
|
+
);
|
|
125
|
+
expect(result).toEqual({ action: 'noop' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('update when live content drifted from the declaration — never from a read error', async () => {
|
|
129
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ content: '10.20.10.9' }) }));
|
|
130
|
+
const result = await Effect.runPromise(
|
|
131
|
+
opnsenseOperations(spec)
|
|
132
|
+
.diff(PROPS, attributesOf(UUID, liveItem()))
|
|
133
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
134
|
+
);
|
|
135
|
+
expect(result).toEqual({ action: 'update' });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
|
|
140
|
+
test('reconcile observes with one GET, then refuses — never a write', async () => {
|
|
141
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ content: 'drifted' }) }));
|
|
142
|
+
const failure = asRefusal(
|
|
143
|
+
await Effect.runPromise(
|
|
144
|
+
Effect.flip(
|
|
145
|
+
opnsenseOperations(spec)
|
|
146
|
+
.reconcile(PROPS)
|
|
147
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
expect(failure._tag).toBe('OpnsenseWriteRefused');
|
|
152
|
+
expect(failure.action).toBe('update');
|
|
153
|
+
expect(fake.seen).toHaveLength(1);
|
|
154
|
+
expect(fake.seen[0]?.method).toBe('GET');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('reconcile against an absent uuid reports it would have created', async () => {
|
|
158
|
+
const fake = getOnlyOrFail(() => getResponse({}));
|
|
159
|
+
const failure = asRefusal(
|
|
160
|
+
await Effect.runPromise(
|
|
161
|
+
Effect.flip(
|
|
162
|
+
opnsenseOperations(spec)
|
|
163
|
+
.reconcile(PROPS)
|
|
164
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
expect(failure.action).toBe('create');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ⛔ THE ONE CASE THAT MATTERS MOST: a genuine failure of the observe step (auth, network,
|
|
172
|
+
// a vendor error) must propagate AS ITSELF, never get swallowed and re-reported as the
|
|
173
|
+
// write-refusal — the same "only a genuine not-found may mean absent; every other error
|
|
174
|
+
// fails loudly" rule this family exists to honour, extended to reconcile's own observe.
|
|
175
|
+
test('reconcile propagates a genuine read failure — never masks it as the write-refusal', async () => {
|
|
176
|
+
const fake = fakeOpnsense(() => fakeFailure(401, { message: 'Unauthorized' }));
|
|
177
|
+
const failure = await Effect.runPromise(
|
|
178
|
+
Effect.flip(
|
|
179
|
+
opnsenseOperations(spec)
|
|
180
|
+
.reconcile(PROPS)
|
|
181
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
expect(failure._tag).toBe('Unauthorized');
|
|
185
|
+
expect(failure._tag).not.toBe('OpnsenseWriteRefused');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('delete refuses unconditionally, with zero requests', async () => {
|
|
189
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
190
|
+
const failure = asRefusal(
|
|
191
|
+
await Effect.runPromise(
|
|
192
|
+
Effect.flip(
|
|
193
|
+
opnsenseOperations(spec)
|
|
194
|
+
.destroy(PROPS)
|
|
195
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
expect(failure._tag).toBe('OpnsenseWriteRefused');
|
|
200
|
+
expect(failure.action).toBe('delete');
|
|
201
|
+
expect(fake.seen).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Opnsense.Firewall.Alias` — a read-only declaration of one `firewall/alias` item on the edge.
|
|
3
|
+
* READ-ONLY BY DESIGN — see policy.ts's header. `reconcile`/`delete` always refuse.
|
|
4
|
+
*
|
|
5
|
+
* ★ READ GOES THROUGH `get()`, NEVER `getItem`/`searchItem`. `get()` (`firewall_alias.ts#get`)
|
|
6
|
+
* returns the WHOLE model tree — `{ alias: { aliases: { alias: { <uuid>: AliasItem, ... } } } }`
|
|
7
|
+
* — so finding one item by uuid is an object-key lookup, not a request the vendor could answer
|
|
8
|
+
* ambiguously. That sidesteps two real gaps in this early SDK, both measured against the
|
|
9
|
+
* generated `firewall_alias.ts`: (1) there is no per-item `getAlias` operation at all — only
|
|
10
|
+
* `get` (whole model) and `searchAlias` (paginated, see below) exist; (2) `firewall_category.ts`
|
|
11
|
+
* and `firewall_group.ts` DO export a `getCategory`/`getGroup`, but their generated request
|
|
12
|
+
* schema carries no `uuid` path parameter (`GetCategoryRequest`/`GetGroupRequest` are both
|
|
13
|
+
* `S.Struct({})` against a `uri` with no `{uuid}` placeholder) — a generation gap, not a design
|
|
14
|
+
* choice, so this family does not rely on them for any of the three resources, uniformly.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ NOT `searchAlias`. It is POST-shaped (a read, per OPNsense's own MVC convention — see the
|
|
17
|
+
* family's docs page) and paginated with no typed limit/offset the generated request schema
|
|
18
|
+
* exposes, so a large alias set could silently page-truncate through it. `get()` has neither
|
|
19
|
+
* problem: GET, and the WHOLE model in one call, no pagination to fall short of.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ `uuid` IS THE ONLY STABLE IDENTITY THIS FAMILY DECLARES BY. A name is close to stable
|
|
22
|
+
* (`AliasNameField` is unique) but is not what `get()`'s map is keyed on — obtain the live
|
|
23
|
+
* uuid once (a future import script does this from a live read, via `propsFromLive` below)
|
|
24
|
+
* and declare it from then on, the same "adoption is explicit, one identity at a time" posture
|
|
25
|
+
* `../proxmox/user.ts` and `../netbox/resource.ts` document for their own families.
|
|
26
|
+
*/
|
|
27
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
28
|
+
import { Resource } from 'alchemy';
|
|
29
|
+
import * as Provider from 'alchemy/Provider';
|
|
30
|
+
import * as Alias from '@distilled.cloud/opnsense/firewall_alias';
|
|
31
|
+
import type { OpnsenseOpError } from '@distilled.cloud/opnsense/Protocol';
|
|
32
|
+
import * as Effect from 'effect/Effect';
|
|
33
|
+
import { attributesOf, matches } from './alias-form.ts';
|
|
34
|
+
import type { OpnsenseRequirements, OpnsenseSpec } from './resource.ts';
|
|
35
|
+
import { opnsenseHandlers } from './resource.ts';
|
|
36
|
+
|
|
37
|
+
export interface AliasProps {
|
|
38
|
+
/** OPNsense-assigned uuid — see the file header on why this is the only identity to declare by. */
|
|
39
|
+
readonly uuid: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
/** e.g. `host`, `network`, `port`, `url`, `geoip`, `networkgroup` — OPNsense's own alias types. */
|
|
42
|
+
readonly type: string;
|
|
43
|
+
/** Default true, matching OPNsense's own `enabled=1`. */
|
|
44
|
+
readonly enabled?: boolean;
|
|
45
|
+
/** The alias's members — a host list, a CIDR, a URL, depending on `type`. */
|
|
46
|
+
readonly content?: string;
|
|
47
|
+
readonly interface?: string;
|
|
48
|
+
/** `IPv4` or `IPv6`, for a `geoip`/`networkgroup` alias; comma-joined on the wire (wire.ts). */
|
|
49
|
+
readonly proto?: string;
|
|
50
|
+
readonly counters?: boolean;
|
|
51
|
+
/** Minutes between refreshes, for a `url`/`urltable` alias. */
|
|
52
|
+
readonly updatefreq?: number;
|
|
53
|
+
readonly categories?: readonly string[];
|
|
54
|
+
readonly description?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AliasAttributes {
|
|
58
|
+
readonly uuid: string;
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly type: string;
|
|
61
|
+
readonly enabled: boolean;
|
|
62
|
+
readonly content: string;
|
|
63
|
+
readonly interface: string;
|
|
64
|
+
readonly proto: string;
|
|
65
|
+
readonly counters: boolean;
|
|
66
|
+
readonly updatefreq: number;
|
|
67
|
+
readonly categories: readonly string[];
|
|
68
|
+
readonly description: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface OpnsenseFirewallAlias extends Resource<
|
|
72
|
+
'Opnsense.Firewall.Alias',
|
|
73
|
+
AliasProps,
|
|
74
|
+
AliasAttributes,
|
|
75
|
+
never,
|
|
76
|
+
OpnsenseRequirements
|
|
77
|
+
> {}
|
|
78
|
+
|
|
79
|
+
export const OpnsenseFirewallAlias = Resource<OpnsenseFirewallAlias>('Opnsense.Firewall.Alias', {
|
|
80
|
+
// The edge's own config, not this stack's to reclaim — see policy.ts.
|
|
81
|
+
defaultRemovalPolicy: 'retain',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const isOpnsenseFirewallAlias = (value: unknown): value is OpnsenseFirewallAlias =>
|
|
85
|
+
typeof value === 'object' &&
|
|
86
|
+
value !== null &&
|
|
87
|
+
(value as { Type?: unknown }).Type === 'Opnsense.Firewall.Alias';
|
|
88
|
+
|
|
89
|
+
/** Exported for direct testing against `fake-opnsense.ts` — the same seam `../discord/*.ts` uses. */
|
|
90
|
+
export const spec: OpnsenseSpec<AliasProps, Alias.AliasItem, AliasAttributes, OpnsenseOpError> = {
|
|
91
|
+
attributes: attributesOf,
|
|
92
|
+
fetchLive: (props) =>
|
|
93
|
+
Alias.get({}).pipe(Effect.map((res) => res.alias?.aliases?.alias?.[props.uuid])),
|
|
94
|
+
matches,
|
|
95
|
+
resourceType: 'Opnsense.Firewall.Alias',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The pure declaration renderer: given the uuid `get()`'s map key names and the `AliasItem`
|
|
100
|
+
* decoded at that key, return the props a declaration needs so `firewallAlias.spec.matches`
|
|
101
|
+
* reports noop against it. A later import script iterates `Object.entries(map)` and calls this
|
|
102
|
+
* once per row to generate an `alchemy.run.ts` row — see `alias-form.test.ts`'s round-trip test.
|
|
103
|
+
*/
|
|
104
|
+
export const propsFromLive = attributesOf;
|
|
105
|
+
|
|
106
|
+
export const handlers = opnsenseHandlers(spec);
|
|
107
|
+
|
|
108
|
+
export const OpnsenseFirewallAliasProvider = () =>
|
|
109
|
+
Provider.effect(
|
|
110
|
+
OpnsenseFirewallAlias,
|
|
111
|
+
Effect.succeed(OpnsenseFirewallAlias.Provider.of(handlers)),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/** `firewallAlias('name-alias', { uuid, name, type })` — `adopt(true)` piped by default (H5), so
|
|
115
|
+
* declaring a live alias's own uuid takes it over silently instead of failing `OwnedBySomeoneElse`. */
|
|
116
|
+
export const firewallAlias = (id: string, props: AliasProps) =>
|
|
117
|
+
OpnsenseFirewallAlias(id, props).pipe(adopt(true));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `category-form.ts`'s pure functions against literal `CategoryItem` fixtures — no network.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import type * as category from '@distilled.cloud/opnsense/firewall_category';
|
|
6
|
+
import { attributesOf, matches } from './category-form.ts';
|
|
7
|
+
import type { CategoryProps } from './category.ts';
|
|
8
|
+
|
|
9
|
+
const UUID = '22222222-2222-2222-2222-222222222222';
|
|
10
|
+
|
|
11
|
+
const liveCategory = (overrides: Partial<category.CategoryItem> = {}): category.CategoryItem => ({
|
|
12
|
+
name: 'vpn-clients',
|
|
13
|
+
...overrides,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('category-form attributesOf', () => {
|
|
17
|
+
test('settles OPNsense wire spellings to plain values', () => {
|
|
18
|
+
expect(attributesOf(UUID, liveCategory())).toEqual({
|
|
19
|
+
auto: false,
|
|
20
|
+
color: '',
|
|
21
|
+
name: 'vpn-clients',
|
|
22
|
+
uuid: UUID,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('auto="1" is the OPNsense-created flag, not absent', () => {
|
|
27
|
+
expect(attributesOf(UUID, liveCategory({ auto: '1' })).auto).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a declared color is passed through', () => {
|
|
31
|
+
expect(attributesOf(UUID, liveCategory({ color: '0000FF' })).color).toBe('0000FF');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('category-form matches', () => {
|
|
36
|
+
const props: CategoryProps = { name: 'vpn-clients', uuid: UUID };
|
|
37
|
+
|
|
38
|
+
test('a live item decoded through attributesOf matches its own declaration', () => {
|
|
39
|
+
expect(matches(attributesOf(UUID, liveCategory()), props)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a renamed category is drift', () => {
|
|
43
|
+
const drifted = attributesOf(UUID, liveCategory({ name: 'renamed' }));
|
|
44
|
+
expect(matches(drifted, props)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('propsFromLive round-trip', () => {
|
|
49
|
+
test('the props it returns plan noop against the exact live object they came from', () => {
|
|
50
|
+
const live = liveCategory({ auto: '1', color: 'FF0000' });
|
|
51
|
+
const rendered = attributesOf(UUID, live);
|
|
52
|
+
expect(matches(attributesOf(UUID, live), rendered)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Opnsense.Firewall.Category`'s wire shape: how a live `CategoryItem` becomes attributes, and
|
|
3
|
+
* the comparison that decides whether a declaration already matches it. Mirrors alias-form.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { CategoryAttributes, CategoryProps } from './category.ts';
|
|
6
|
+
import type * as category from '@distilled.cloud/opnsense/firewall_category';
|
|
7
|
+
import { bool01 } from './wire.ts';
|
|
8
|
+
|
|
9
|
+
/** ★ Attributes and the declaration renderer are one function — see alias-form.ts's header note. */
|
|
10
|
+
export const attributesOf = (uuid: string, live: category.CategoryItem): CategoryAttributes => ({
|
|
11
|
+
auto: bool01(live.auto, false),
|
|
12
|
+
color: live.color ?? '',
|
|
13
|
+
name: live.name,
|
|
14
|
+
uuid,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const matches = (attributes: CategoryAttributes, props: CategoryProps): boolean =>
|
|
18
|
+
attributes.name === props.name &&
|
|
19
|
+
attributes.auto === (props.auto ?? false) &&
|
|
20
|
+
attributes.color === (props.color ?? '');
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Opnsense.Firewall.Category`'s `spec` against a fake OPNsense — mirrors `alias.test.ts` for the
|
|
3
|
+
* category family. See that file's header for what each block proves.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
7
|
+
import type * as category from '@distilled.cloud/opnsense/firewall_category';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { attributesOf } from './category-form.ts';
|
|
10
|
+
import type { CategoryProps } from './category.ts';
|
|
11
|
+
import { spec } from './category.ts';
|
|
12
|
+
import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
|
|
13
|
+
import type { OpnsenseWriteRefused } from './policy.ts';
|
|
14
|
+
import { opnsenseOperations } from './resource.ts';
|
|
15
|
+
|
|
16
|
+
const asRefusal = (value: unknown) => value as OpnsenseWriteRefused;
|
|
17
|
+
|
|
18
|
+
const UUID = '22222222-2222-2222-2222-222222222222';
|
|
19
|
+
const GET_PATH = '/api/firewall/category/get';
|
|
20
|
+
const PROPS: CategoryProps = { name: 'vpn-clients', uuid: UUID };
|
|
21
|
+
|
|
22
|
+
const liveItem = (overrides: Partial<category.CategoryItem> = {}): category.CategoryItem => ({
|
|
23
|
+
name: 'vpn-clients',
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const getResponse = (items: Record<string, category.CategoryItem>) =>
|
|
28
|
+
Response.json({ category: { categories: { category: items } } });
|
|
29
|
+
|
|
30
|
+
describe('Opnsense.Firewall.Category spec.fetchLive', () => {
|
|
31
|
+
test('finds the item whose uuid is the map key, among others', async () => {
|
|
32
|
+
const fake = getOnlyOrFail((url) =>
|
|
33
|
+
url.pathname === GET_PATH
|
|
34
|
+
? getResponse({ 'other-uuid': liveItem({ name: 'other' }), [UUID]: liveItem() })
|
|
35
|
+
: fakeFailure(400, { errorMessage: 'unexpected request' }),
|
|
36
|
+
);
|
|
37
|
+
const live = await Effect.runPromise(
|
|
38
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
39
|
+
);
|
|
40
|
+
expect(live?.name).toBe('vpn-clients');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('no matching uuid key is absent, not an error', async () => {
|
|
44
|
+
const fake = getOnlyOrFail(() => getResponse({ 'other-uuid': liveItem() }));
|
|
45
|
+
const live = await Effect.runPromise(
|
|
46
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
47
|
+
);
|
|
48
|
+
expect(live).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ⚠️ 403, NOT 500: a 5xx is a transient-error tag the SDK's default retry policy (retry.ts)
|
|
52
|
+
// retries with backoff (S26) — a real, useful behaviour, but one that would make this test
|
|
53
|
+
// itself slow. 403 (`Forbidden`, OPNsense's own auth-gate shape — protocol.ts) is not
|
|
54
|
+
// retried, so the propagation is provable in one request.
|
|
55
|
+
test('a 403 propagates — never folded to absent (the live bug this family avoids)', async () => {
|
|
56
|
+
const fake = fakeOpnsense(() => fakeFailure(403, { message: 'Forbidden' }));
|
|
57
|
+
const failure = await Effect.runPromise(
|
|
58
|
+
Effect.flip(spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch)))),
|
|
59
|
+
);
|
|
60
|
+
expect(failure._tag).toBe('Forbidden');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('opnsenseOperations(spec).read — S7/S8, marker-less API', () => {
|
|
65
|
+
test('cold read answers Unowned; warm read answers plain attributes', async () => {
|
|
66
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
67
|
+
const cold = await Effect.runPromise(
|
|
68
|
+
opnsenseOperations(spec)
|
|
69
|
+
.read({ olds: PROPS, output: undefined })
|
|
70
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
71
|
+
);
|
|
72
|
+
expect(Unowned.is(cold)).toBe(true);
|
|
73
|
+
|
|
74
|
+
const fakeWarm = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
75
|
+
const warm = await Effect.runPromise(
|
|
76
|
+
opnsenseOperations(spec)
|
|
77
|
+
.read({ olds: PROPS, output: attributesOf(UUID, liveItem()) })
|
|
78
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fakeWarm.fetch))),
|
|
79
|
+
);
|
|
80
|
+
expect(Unowned.is(warm)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('opnsenseOperations(spec).diff', () => {
|
|
85
|
+
test('noop when the declaration already matches live', async () => {
|
|
86
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
87
|
+
const result = await Effect.runPromise(
|
|
88
|
+
opnsenseOperations(spec)
|
|
89
|
+
.diff(PROPS, attributesOf(UUID, liveItem()))
|
|
90
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
91
|
+
);
|
|
92
|
+
expect(result).toEqual({ action: 'noop' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('update when the live color drifted', async () => {
|
|
96
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ color: 'FF0000' }) }));
|
|
97
|
+
const result = await Effect.runPromise(
|
|
98
|
+
opnsenseOperations(spec)
|
|
99
|
+
.diff({ ...PROPS, color: '0000FF' }, attributesOf(UUID, liveItem({ color: '0000FF' })))
|
|
100
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
101
|
+
);
|
|
102
|
+
expect(result).toEqual({ action: 'update' });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
|
|
107
|
+
test('reconcile observes with one GET, then refuses — never a write', async () => {
|
|
108
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ name: 'drifted' }) }));
|
|
109
|
+
const failure = asRefusal(
|
|
110
|
+
await Effect.runPromise(
|
|
111
|
+
Effect.flip(
|
|
112
|
+
opnsenseOperations(spec)
|
|
113
|
+
.reconcile(PROPS)
|
|
114
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
expect(failure._tag).toBe('OpnsenseWriteRefused');
|
|
119
|
+
expect(failure.action).toBe('update');
|
|
120
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
121
|
+
expect(fake.seen).toHaveLength(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('delete refuses unconditionally, with zero requests', async () => {
|
|
125
|
+
const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
|
|
126
|
+
const failure = asRefusal(
|
|
127
|
+
await Effect.runPromise(
|
|
128
|
+
Effect.flip(
|
|
129
|
+
opnsenseOperations(spec)
|
|
130
|
+
.destroy(PROPS)
|
|
131
|
+
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
expect(failure.action).toBe('delete');
|
|
136
|
+
expect(fake.seen).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
});
|