@homeflare/alchemy 0.31.1 → 0.32.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.
Files changed (166) hide show
  1. package/README.md +44 -7
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/linux/container-form.d.ts +129 -0
  5. package/dist/linux/container-form.d.ts.map +1 -0
  6. package/dist/linux/container-generator.d.ts +74 -0
  7. package/dist/linux/container-generator.d.ts.map +1 -0
  8. package/dist/linux/container-handlers.d.ts +15 -0
  9. package/dist/linux/container-handlers.d.ts.map +1 -0
  10. package/dist/linux/container-lifecycle.d.ts +37 -0
  11. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  12. package/dist/linux/container-preflight.d.ts +34 -0
  13. package/dist/linux/container-preflight.d.ts.map +1 -0
  14. package/dist/linux/container-secrets.d.ts +37 -0
  15. package/dist/linux/container-secrets.d.ts.map +1 -0
  16. package/dist/linux/container-settle.d.ts +34 -0
  17. package/dist/linux/container-settle.d.ts.map +1 -0
  18. package/dist/linux/container.d.ts +30 -0
  19. package/dist/linux/container.d.ts.map +1 -0
  20. package/dist/linux/fake-quadlet.d.ts +25 -0
  21. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  22. package/dist/linux/index.d.ts +6 -3
  23. package/dist/linux/index.d.ts.map +1 -1
  24. package/dist/linux/index.js +641 -268
  25. package/dist/linux/index.js.map +15 -7
  26. package/dist/linux/providers.d.ts +1 -1
  27. package/dist/linux/providers.d.ts.map +1 -1
  28. package/dist/linux/systemctl.d.ts +7 -1
  29. package/dist/linux/systemctl.d.ts.map +1 -1
  30. package/dist/opnsense/alias-form.d.ts +29 -0
  31. package/dist/opnsense/alias-form.d.ts.map +1 -0
  32. package/dist/opnsense/alias.d.ts +81 -0
  33. package/dist/opnsense/alias.d.ts.map +1 -0
  34. package/dist/opnsense/category-form.d.ts +10 -0
  35. package/dist/opnsense/category-form.d.ts.map +1 -0
  36. package/dist/opnsense/category.d.ts +58 -0
  37. package/dist/opnsense/category.d.ts.map +1 -0
  38. package/dist/opnsense/credentials.d.ts +36 -0
  39. package/dist/opnsense/credentials.d.ts.map +1 -0
  40. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  41. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  42. package/dist/opnsense/group-form.d.ts +11 -0
  43. package/dist/opnsense/group-form.d.ts.map +1 -0
  44. package/dist/opnsense/group.d.ts +62 -0
  45. package/dist/opnsense/group.d.ts.map +1 -0
  46. package/dist/opnsense/index.d.ts +16 -0
  47. package/dist/opnsense/index.d.ts.map +1 -0
  48. package/dist/opnsense/index.js +211 -0
  49. package/dist/opnsense/index.js.map +20 -0
  50. package/dist/opnsense/policy.d.ts +20 -0
  51. package/dist/opnsense/policy.d.ts.map +1 -0
  52. package/dist/opnsense/providers.d.ts +13 -0
  53. package/dist/opnsense/providers.d.ts.map +1 -0
  54. package/dist/opnsense/resource.d.ts +74 -0
  55. package/dist/opnsense/resource.d.ts.map +1 -0
  56. package/dist/opnsense/wire.d.ts +39 -0
  57. package/dist/opnsense/wire.d.ts.map +1 -0
  58. package/dist/proxmox/acl.d.ts.map +1 -1
  59. package/dist/proxmox/api-token.d.ts.map +1 -1
  60. package/dist/proxmox/group-wire.d.ts +44 -5
  61. package/dist/proxmox/group-wire.d.ts.map +1 -1
  62. package/dist/proxmox/group.d.ts.map +1 -1
  63. package/dist/proxmox/index.js +326 -264
  64. package/dist/proxmox/index.js.map +13 -11
  65. package/dist/proxmox/role.d.ts.map +1 -1
  66. package/dist/proxmox/storage-form.d.ts +54 -0
  67. package/dist/proxmox/storage-form.d.ts.map +1 -0
  68. package/dist/proxmox/storage-wire.d.ts +56 -0
  69. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  70. package/dist/proxmox/storage.d.ts +55 -60
  71. package/dist/proxmox/storage.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +34 -5
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/dist/proxmox/user.d.ts +6 -6
  75. package/dist/proxmox/user.d.ts.map +1 -1
  76. package/dist/unifi/fake-unifi.d.ts +48 -0
  77. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  78. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  79. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  80. package/dist/unifi/firewall-zone.d.ts +50 -0
  81. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  82. package/dist/unifi/index.d.ts +13 -0
  83. package/dist/unifi/index.d.ts.map +1 -0
  84. package/dist/unifi/index.js +190 -0
  85. package/dist/unifi/index.js.map +16 -0
  86. package/dist/unifi/network-form.d.ts +67 -0
  87. package/dist/unifi/network-form.d.ts.map +1 -0
  88. package/dist/unifi/network.d.ts +67 -0
  89. package/dist/unifi/network.d.ts.map +1 -0
  90. package/dist/unifi/policy.d.ts +20 -0
  91. package/dist/unifi/policy.d.ts.map +1 -0
  92. package/dist/unifi/providers.d.ts +13 -0
  93. package/dist/unifi/providers.d.ts.map +1 -0
  94. package/dist/unifi/resource.d.ts +64 -0
  95. package/dist/unifi/resource.d.ts.map +1 -0
  96. package/dist/verify/fake-engine.d.ts +14 -0
  97. package/dist/verify/fake-engine.d.ts.map +1 -1
  98. package/docs/grafana.md +47 -17
  99. package/docs/opnsense.md +146 -0
  100. package/docs/provider-standard.md +1 -1
  101. package/docs/quadlet-container.md +132 -0
  102. package/docs/unifi.md +93 -0
  103. package/docs/upstream-conformance.md +14 -4
  104. package/package.json +13 -3
  105. package/src/grafana/index.ts +6 -5
  106. package/src/linux/container-fixture.test.ts +92 -0
  107. package/src/linux/container-form.ts +243 -0
  108. package/src/linux/container-generator.test.ts +195 -0
  109. package/src/linux/container-generator.ts +113 -0
  110. package/src/linux/container-handlers.ts +73 -0
  111. package/src/linux/container-lifecycle.test.ts +209 -0
  112. package/src/linux/container-lifecycle.ts +217 -0
  113. package/src/linux/container-preflight.ts +97 -0
  114. package/src/linux/container-secrets.test.ts +171 -0
  115. package/src/linux/container-secrets.ts +112 -0
  116. package/src/linux/container-settle.ts +97 -0
  117. package/src/linux/container.ts +60 -0
  118. package/src/linux/fake-quadlet.ts +127 -0
  119. package/src/linux/index.ts +11 -3
  120. package/src/linux/providers.ts +2 -0
  121. package/src/linux/systemctl.ts +9 -0
  122. package/src/opnsense/alias-form.test.ts +89 -0
  123. package/src/opnsense/alias-form.ts +53 -0
  124. package/src/opnsense/alias.test.ts +203 -0
  125. package/src/opnsense/alias.ts +117 -0
  126. package/src/opnsense/category-form.test.ts +54 -0
  127. package/src/opnsense/category-form.ts +20 -0
  128. package/src/opnsense/category.test.ts +138 -0
  129. package/src/opnsense/category.ts +82 -0
  130. package/src/opnsense/credentials.ts +41 -0
  131. package/src/opnsense/fake-opnsense.ts +79 -0
  132. package/src/opnsense/group-form.test.ts +75 -0
  133. package/src/opnsense/group-form.ts +25 -0
  134. package/src/opnsense/group.test.ts +141 -0
  135. package/src/opnsense/group.ts +78 -0
  136. package/src/opnsense/index.ts +45 -0
  137. package/src/opnsense/policy.ts +49 -0
  138. package/src/opnsense/providers.ts +21 -0
  139. package/src/opnsense/resource.ts +146 -0
  140. package/src/opnsense/wire.ts +56 -0
  141. package/src/opnsense/write-refusal.test.ts +148 -0
  142. package/src/proxmox/acl.ts +10 -4
  143. package/src/proxmox/api-token.ts +20 -12
  144. package/src/proxmox/group-wire.ts +62 -8
  145. package/src/proxmox/group.ts +23 -35
  146. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  147. package/src/proxmox/role.ts +9 -2
  148. package/src/proxmox/storage-form.ts +144 -0
  149. package/src/proxmox/storage-read-failure.test.ts +214 -0
  150. package/src/proxmox/storage-wire.ts +124 -0
  151. package/src/proxmox/storage.ts +165 -175
  152. package/src/proxmox/user-wire.ts +44 -8
  153. package/src/proxmox/user.ts +29 -27
  154. package/src/unifi/fake-unifi.ts +60 -0
  155. package/src/unifi/firewall-zone-form.ts +65 -0
  156. package/src/unifi/firewall-zone.test.ts +88 -0
  157. package/src/unifi/firewall-zone.ts +66 -0
  158. package/src/unifi/index.ts +26 -0
  159. package/src/unifi/network-form.ts +207 -0
  160. package/src/unifi/network.test.ts +209 -0
  161. package/src/unifi/network.ts +83 -0
  162. package/src/unifi/policy.ts +37 -0
  163. package/src/unifi/providers.ts +15 -0
  164. package/src/unifi/resource.test.ts +133 -0
  165. package/src/unifi/resource.ts +131 -0
  166. package/src/verify/fake-engine.ts +23 -0
@@ -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
+ });