@ceralive/modem-control 0.1.0 → 1.0.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.
@@ -0,0 +1,377 @@
1
+ // The `usb-hub-port-cycle` power hook — recovery ladder rung 4, backed by `uhubctl`.
2
+ //
3
+ // This is the FIRST real `PowerHook` implementation. It cuts VBUS on one port of a
4
+ // per-port-power-switching (PPPS) USB hub, waits for the modem to come back on the
5
+ // SAME physical topology path, and reports `applied` only when it actually did.
6
+ //
7
+ // FOUR safety properties, in the order they bite:
8
+ //
9
+ // 1. CONFIG-MAPPED, NEVER DISCOVERED. A stable key is power-cyclable only if an
10
+ // operator wrote it into an explicitly-pathed config file (`readUhubctlPowerConfig`
11
+ // takes the path as an argument — there is no default path, no search, no probe).
12
+ // An unmapped key returns `unsupported` and touches nothing. Guessing which hub
13
+ // port a modem is on and then cutting its power is exactly the failure mode that
14
+ // would black out an unrelated device.
15
+ // 2. ARGV ONLY, ALLOWLISTED. The command is built as an argv array and handed to an
16
+ // injected runner — there is no shell, no string interpolation, no `sh -c`. Every
17
+ // generated token is re-checked against `ALLOWED_ARGV` before the runner is
18
+ // called, so even a config that somehow evaded the schema cannot smuggle a flag.
19
+ // 3. BOUNDED + CANCELLABLE. The runner call is bounded by `commandTimeoutMs`, the
20
+ // re-enumeration wait by `enumerationTimeoutMs`, and both observe an optional
21
+ // `AbortSignal`. The worst case is `commandTimeoutMs + enumerationTimeoutMs`;
22
+ // there is no path that waits forever.
23
+ // 4. SERIALISED PER MODEM. Like every other disruptive op in this backend (see
24
+ // `mm-mutations.ts`), a cycle runs through the shared per-modem `ModemActor`,
25
+ // keyed on the STABLE key. Two overlapping cycles on one port would otherwise
26
+ // interleave a power-on with a power-off and leave the port dark.
27
+ //
28
+ // PROOF OF SUCCESS IS RE-ENUMERATION, NOT EXIT CODE 0. `uhubctl` exiting 0 only means
29
+ // the hub accepted the request. The hook records the modem's `ID_PATH` BEFORE the cut
30
+ // and only reports `applied` once that same `ID_PATH` is observed again — a port that
31
+ // powers back up with nothing on it is a `failed`, reported with expected-vs-observed.
32
+ //
33
+ // -----------------------------------------------------------------------------------
34
+ // CAVEAT — STALE DEVICE FILES ON LINUX KERNELS BEFORE 6.0.
35
+ //
36
+ // uhubctl README, FAQ section `_USB devices are not removed after port power down on
37
+ // Linux_` (github.com/mvp/uhubctl, README.md), verbatim:
38
+ //
39
+ // "After powering down USB port, udev does not get any event, so it keeps the device
40
+ // files around. However, trying to access the device files will lead to an IO error.
41
+ // This is Linux kernel issue and is fixed since uhubctl 2.5.0 for systems with Linux
42
+ // kernel 6.0 or later. If you are still using Linux 5.x or older, you can use this
43
+ // workaround for this issue:
44
+ //
45
+ // sudo uhubctl -a off -l ${location} -p ${port}
46
+ // sudo udevadm trigger --action=remove /sys/bus/usb/devices/${location}.${port}/
47
+ //
48
+ // Device file will be removed by udev, but USB device will be still visible in
49
+ // `lsusb`. Note that path /sys/bus/usb/devices/${location}.${port} will only exist if
50
+ // device was detected on that port. When you turn power back on, device should
51
+ // re-enumerate properly (no need to call `udevadm` again)."
52
+ //
53
+ // Why it matters HERE: during the dark window of a cycle, a pre-6.0 kernel leaves the
54
+ // device files in place, so a presence check that asks "does the node still exist?"
55
+ // reports the modem as present when it is electrically gone — and would let this hook
56
+ // declare `applied` off a stale artefact rather than a real re-enumeration.
57
+ //
58
+ // THIS HOOK DOES NOT RUN `udevadm trigger --action=remove` ITSELF, deliberately: it is
59
+ // a privileged host-wide udev mutation whose sysfs path only exists if a device was
60
+ // detected there, and firing it from a recovery rung would make rung 4 mutate state
61
+ // well outside the port it was mapped to. Instead the hook is built so the caveat
62
+ // cannot corrupt its verdict — presence is resolved by the INJECTED
63
+ // `UsbEnumerationPoller`, whose production implementation re-reads udev every call and
64
+ // never caches (see `usb-enumerator.ts`, which re-runs `udevadm info --export-db` per
65
+ // `enumerate()`), and the postcondition compares `ID_PATH`, not a device-node path. A
66
+ // deployment pinned to a pre-6.0 kernel wires the `udevadm trigger --action=remove`
67
+ // step into that poller or into a udev rule — one explicit, auditable place.
68
+ //
69
+ // Hardware note: the README's compatible-hub table lists `0BDA:0411` (Rosonway RSH-A10
70
+ // / RSH-A16, Juiced Systems 6HUB-01) as per-port-power-switching capable — that is the
71
+ // Realtek chipset on this project's bench board. `0bda:5411` is NOT on that list, so a
72
+ // hub reporting that id may need `-f`, which this hook never passes.
73
+ // -----------------------------------------------------------------------------------
74
+
75
+ import { z } from 'zod';
76
+ import { ModemActor } from './modem-actor';
77
+ import type {
78
+ PowerCapability,
79
+ PowerCycleContext,
80
+ PowerCycleResult,
81
+ PowerHook,
82
+ PreferredUsbMode,
83
+ } from './power-contract';
84
+
85
+ /**
86
+ * A uhubctl hub location: `<bus>-<port>[.<port>…]` (e.g. `1-1`, `2-1.4`), or a bare
87
+ * bus number for a root hub. This mirrors the Linux sysfs USB path and is the ONLY
88
+ * shape accepted — a VID:PID selector or a `--` flag can never parse as one.
89
+ */
90
+ const HUB_LOCATION = /^[0-9]{1,3}(-[0-9]{1,3}(\.[0-9]{1,3})*)?$/;
91
+
92
+ /** One mapped modem: which PPPS hub it hangs off, and which port on that hub. */
93
+ export const uhubctlPortMappingSchema = z.strictObject({
94
+ /** The hub's uhubctl location (`-l`), e.g. `1-1` or `2-1.4`. */
95
+ hubLocation: z.string().regex(HUB_LOCATION, 'hubLocation must look like `1-1` or `2-1.4`'),
96
+ /** The 1-based port number on that hub (`-p`). */
97
+ port: z.number().int().min(1).max(255),
98
+ });
99
+ export type UhubctlPortMapping = z.infer<typeof uhubctlPortMappingSchema>;
100
+
101
+ /**
102
+ * The whole config file: a map from STABLE KEY to its hub/port mapping. `.strictObject`
103
+ * on each entry means a typo'd or smuggled extra field is rejected rather than ignored.
104
+ */
105
+ export const uhubctlPortMapSchema = z.record(z.string().min(1), uhubctlPortMappingSchema);
106
+ export type UhubctlPortMap = z.infer<typeof uhubctlPortMapSchema>;
107
+
108
+ /**
109
+ * Parse config text (JSON) into a validated port map. `path` is used only for the
110
+ * error message, so a malformed file fails visibly with a named field.
111
+ */
112
+ export function parseUhubctlPortMap(text: string, path: string): UhubctlPortMap {
113
+ let raw: unknown;
114
+ try {
115
+ raw = JSON.parse(text) as unknown;
116
+ } catch (error) {
117
+ throw new Error(`invalid uhubctl port map ${path}: ${describe(error)}`);
118
+ }
119
+ const result = uhubctlPortMapSchema.safeParse(raw);
120
+ if (!result.success) {
121
+ const issue = result.error.issues[0];
122
+ const where = issue?.path.join('.') || '(root)';
123
+ throw new Error(
124
+ `invalid uhubctl port map ${path}: ${where}: ${issue?.message ?? 'schema mismatch'}`,
125
+ );
126
+ }
127
+ return result.data;
128
+ }
129
+
130
+ /**
131
+ * Read + validate a port map from an EXPLICIT path. There is intentionally no default
132
+ * and no discovery: a caller that cannot name the file gets no power control.
133
+ */
134
+ export async function readUhubctlPortMap(path: string): Promise<UhubctlPortMap> {
135
+ return parseUhubctlPortMap(await Bun.file(path).text(), path);
136
+ }
137
+
138
+ /** The result of one `uhubctl` invocation. */
139
+ export interface UhubctlResult {
140
+ readonly stdout: string;
141
+ readonly stderr: string;
142
+ readonly exitCode: number;
143
+ }
144
+
145
+ /**
146
+ * A runner over `uhubctl` argv — structurally the same seam as `NmcliRunner`. Tests
147
+ * inject a fake; the device injects `SpawnUhubctlRunner`. The hook NEVER spawns
148
+ * directly, so the argv the tests assert against is byte-for-byte what runs on-device.
149
+ */
150
+ export interface UhubctlRunner {
151
+ run(argv: readonly string[]): UhubctlResult | Promise<UhubctlResult>;
152
+ }
153
+
154
+ /** The device-exact runner: spawns the real `uhubctl` with the argv array verbatim. */
155
+ export class SpawnUhubctlRunner implements UhubctlRunner {
156
+ async run(argv: readonly string[]): Promise<UhubctlResult> {
157
+ const proc = Bun.spawn(['uhubctl', ...argv], { stdout: 'pipe', stderr: 'pipe' });
158
+ const [stdout, stderr, exitCode] = await Promise.all([
159
+ new Response(proc.stdout).text(),
160
+ new Response(proc.stderr).text(),
161
+ proc.exited,
162
+ ]);
163
+ return { stdout, stderr, exitCode };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Resolves the modem's current udev `ID_PATH` (its physical topology UID) for a stable
169
+ * key, or `undefined` when nothing is enumerated there. Injected so tests need no
170
+ * hardware; the production implementation MUST re-read udev/sysfs every call (see the
171
+ * pre-6.0 stale-devfile caveat at the top of this file).
172
+ */
173
+ export interface UsbEnumerationPoller {
174
+ idPathFor(stableKey: string): string | undefined | Promise<string | undefined>;
175
+ }
176
+
177
+ const DEFAULT_ENUMERATION_TIMEOUT_MS = 30_000;
178
+ const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
179
+ const DEFAULT_POLL_INTERVAL_MS = 250;
180
+ /** `uhubctl -d` — seconds the port stays dark before it is powered back on. */
181
+ const DEFAULT_POWER_OFF_DELAY_SECONDS = 3;
182
+
183
+ /** Construction dependencies. Everything that touches the system is injectable. */
184
+ export interface UhubctlPowerHookDeps {
185
+ /** The validated stable-key → hub/port map (see `readUhubctlPortMap`). */
186
+ readonly ports: UhubctlPortMap;
187
+ readonly runner: UhubctlRunner;
188
+ readonly poller: UsbEnumerationPoller;
189
+ /** Shared per-modem serialisation. Defaults to a private actor. */
190
+ readonly actor?: ModemActor;
191
+ readonly enumerationTimeoutMs?: number;
192
+ readonly commandTimeoutMs?: number;
193
+ readonly pollIntervalMs?: number;
194
+ readonly powerOffDelaySeconds?: number;
195
+ readonly preferredUsbMode?: PreferredUsbMode;
196
+ /** Cancels an in-flight cycle — the hook resolves `failed`, it never hangs. */
197
+ readonly signal?: AbortSignal;
198
+ readonly sleep?: (ms: number) => Promise<void>;
199
+ readonly now?: () => number;
200
+ }
201
+
202
+ /**
203
+ * Every argv token this hook is permitted to emit. The flags are literals; the two
204
+ * value slots are re-validated against the same shapes the schema enforced. Anything
205
+ * else is a bug in this file and fails closed before the runner is called.
206
+ */
207
+ const ALLOWED_ARGV: readonly RegExp[] = [
208
+ /^-l$/,
209
+ /^-p$/,
210
+ /^-a$/,
211
+ /^-d$/,
212
+ /^cycle$/,
213
+ HUB_LOCATION,
214
+ /^[0-9]{1,3}$/,
215
+ ];
216
+
217
+ /** Build the `uhubctl` argv for one mapping. Pure + exported so tests can assert it. */
218
+ export function uhubctlCycleArgv(
219
+ mapping: UhubctlPortMapping,
220
+ powerOffDelaySeconds: number,
221
+ ): readonly string[] {
222
+ const argv = [
223
+ '-l',
224
+ mapping.hubLocation,
225
+ '-p',
226
+ String(mapping.port),
227
+ '-a',
228
+ 'cycle',
229
+ '-d',
230
+ String(powerOffDelaySeconds),
231
+ ];
232
+ for (const token of argv) {
233
+ if (!ALLOWED_ARGV.some((allowed) => allowed.test(token))) {
234
+ throw new Error(`refusing to run uhubctl: argv token '${token}' is not allowlisted`);
235
+ }
236
+ }
237
+ return argv;
238
+ }
239
+
240
+ /** The `usb-hub-port-cycle` power hook. One instance serves every mapped modem. */
241
+ export function createUhubctlPowerHook(deps: UhubctlPowerHookDeps): PowerHook {
242
+ const enumerationTimeoutMs = deps.enumerationTimeoutMs ?? DEFAULT_ENUMERATION_TIMEOUT_MS;
243
+ const commandTimeoutMs = deps.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
244
+ const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
245
+ const powerOffDelaySeconds = deps.powerOffDelaySeconds ?? DEFAULT_POWER_OFF_DELAY_SECONDS;
246
+ const actor = deps.actor ?? new ModemActor();
247
+ const now = deps.now ?? Date.now;
248
+ const sleep = deps.sleep ?? defaultSleep;
249
+
250
+ const capability: PowerCapability = {
251
+ power: 'usb-hub-port-cycle',
252
+ usbReset: true,
253
+ enumerationTimeoutMs,
254
+ ...(deps.preferredUsbMode !== undefined ? { preferredUsbMode: deps.preferredUsbMode } : {}),
255
+ };
256
+
257
+ const cancelled = (): PowerCycleResult | undefined =>
258
+ deps.signal?.aborted === true
259
+ ? { status: 'failed', reason: 'power cycle cancelled by the caller' }
260
+ : undefined;
261
+
262
+ async function awaitReenumeration(
263
+ stableKey: string,
264
+ expected: string | undefined,
265
+ ): Promise<PowerCycleResult> {
266
+ const deadline = now() + enumerationTimeoutMs;
267
+ let observed: string | undefined;
268
+ while (now() < deadline) {
269
+ const abort = cancelled();
270
+ if (abort !== undefined) {
271
+ return abort;
272
+ }
273
+ observed = await deps.poller.idPathFor(stableKey);
274
+ // A port cycle preserves the physical topology, so the SAME ID_PATH must
275
+ // come back. If nothing was enumerated before the cut there is no path to
276
+ // compare against — any device re-appearing at that key is the recovery.
277
+ if (observed !== undefined && (expected === undefined || observed === expected)) {
278
+ return {
279
+ status: 'applied',
280
+ reason: `port cycled; modem re-enumerated at ID_PATH '${observed}'`,
281
+ };
282
+ }
283
+ await sleep(pollIntervalMs);
284
+ }
285
+ return {
286
+ status: 'failed',
287
+ reason:
288
+ `modem did not re-enumerate within ${enumerationTimeoutMs}ms — expected ID_PATH ` +
289
+ `${expected === undefined ? '(any device)' : `'${expected}'`}, observed ` +
290
+ `${observed === undefined ? 'no device' : `'${observed}'`}`,
291
+ };
292
+ }
293
+
294
+ async function cycleMapped(
295
+ stableKey: string,
296
+ mapping: UhubctlPortMapping,
297
+ ): Promise<PowerCycleResult> {
298
+ // Record the pre-cut topology path — the postcondition compares against it.
299
+ const expected = await deps.poller.idPathFor(stableKey);
300
+
301
+ let argv: readonly string[];
302
+ try {
303
+ argv = uhubctlCycleArgv(mapping, powerOffDelaySeconds);
304
+ } catch (error) {
305
+ return { status: 'failed', reason: describe(error) };
306
+ }
307
+
308
+ let result: UhubctlResult;
309
+ try {
310
+ result = await withTimeout(
311
+ Promise.resolve(deps.runner.run(argv)),
312
+ commandTimeoutMs,
313
+ `uhubctl did not return within ${commandTimeoutMs}ms`,
314
+ );
315
+ } catch (error) {
316
+ return { status: 'failed', reason: `uhubctl ${argv.join(' ')} failed: ${describe(error)}` };
317
+ }
318
+ if (result.exitCode !== 0) {
319
+ return {
320
+ status: 'failed',
321
+ reason:
322
+ `uhubctl ${argv.join(' ')} exited ${result.exitCode}: ` +
323
+ `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
324
+ };
325
+ }
326
+
327
+ // Exit 0 only means the hub accepted the request — re-enumeration is the proof.
328
+ return awaitReenumeration(stableKey, expected);
329
+ }
330
+
331
+ return {
332
+ capability,
333
+ cycle(context: PowerCycleContext): Promise<PowerCycleResult> {
334
+ const { stableKey } = context;
335
+ const mapping = deps.ports[stableKey];
336
+ if (mapping === undefined) {
337
+ // Refuse BEFORE the actor and before any I/O: an unmapped key must never
338
+ // cut power to a port that was never declared to belong to it.
339
+ return Promise.resolve({
340
+ status: 'unsupported',
341
+ reason: `no uhubctl hub/port mapping is configured for stable key '${stableKey}'`,
342
+ });
343
+ }
344
+ const abort = cancelled();
345
+ if (abort !== undefined) {
346
+ return Promise.resolve(abort);
347
+ }
348
+ // Serialised on the STABLE key, like every other disruptive op (mm-mutations).
349
+ return actor.run(stableKey, () => cycleMapped(stableKey, mapping));
350
+ },
351
+ };
352
+ }
353
+
354
+ function defaultSleep(ms: number): Promise<void> {
355
+ return new Promise((resolve) => setTimeout(resolve, ms));
356
+ }
357
+
358
+ /** Bound a promise; rejects with `message` if it has not settled in `ms`. */
359
+ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
360
+ let timer: ReturnType<typeof setTimeout> | undefined;
361
+ try {
362
+ return await Promise.race([
363
+ promise,
364
+ new Promise<never>((_resolve, reject) => {
365
+ timer = setTimeout(() => reject(new Error(message)), ms);
366
+ }),
367
+ ]);
368
+ } finally {
369
+ if (timer !== undefined) {
370
+ clearTimeout(timer);
371
+ }
372
+ }
373
+ }
374
+
375
+ function describe(error: unknown): string {
376
+ return error instanceof Error ? error.message : String(error);
377
+ }
@@ -12,6 +12,29 @@ export {
12
12
  } from './accounting';
13
13
  export { clampCycleDay, cycleStart, daysInMonth } from './billing-cycle';
14
14
  export { readBootId } from './boot-id';
15
+ export {
16
+ createUsagePolicyFileStore,
17
+ isValidCycleDay,
18
+ isValidThresholdBytes,
19
+ type PersistedUsagePolicy,
20
+ type PersistedUsagePolicySlot,
21
+ selectUsagePolicy,
22
+ USAGE_POLICY_SCHEMA_VERSION,
23
+ type UsagePolicyFileStoreOptions,
24
+ type UsagePolicyLogEvent,
25
+ type UsagePolicyLogger,
26
+ type UsagePolicyStore,
27
+ } from './policy-store';
28
+ export {
29
+ getUsagePolicy,
30
+ type SetUsagePolicyDeps,
31
+ type SetUsagePolicyRejection,
32
+ type SetUsagePolicyRequest,
33
+ type SetUsagePolicyResult,
34
+ setUsagePolicy,
35
+ type UsagePolicyApplication,
36
+ type UsagePolicyTarget,
37
+ } from './policy-write';
15
38
  export {
16
39
  type CounterSource,
17
40
  parseProcNetDev,
@@ -0,0 +1,164 @@
1
+ // Usage-policy persistence contract — mode 0600 (real fs.stat), fail-soft
2
+ // corruption recovery with METADATA-ONLY logging, and the per-slot selector.
3
+
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import {
9
+ createUsagePolicyFileStore,
10
+ isValidCycleDay,
11
+ isValidThresholdBytes,
12
+ type PersistedUsagePolicy,
13
+ selectUsagePolicy,
14
+ USAGE_POLICY_SCHEMA_VERSION,
15
+ type UsagePolicyLogEvent,
16
+ } from './policy-store';
17
+
18
+ let dir: string;
19
+ let path: string;
20
+
21
+ beforeEach(async () => {
22
+ dir = await mkdtemp(join(tmpdir(), 'usage-policy-'));
23
+ path = join(dir, 'policy.json');
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await rm(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ const sample: PersistedUsagePolicy = {
31
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
32
+ savedAtMs: 1_700_000_000_000,
33
+ slots: [
34
+ { logicalSlotId: 'slot-a', cycleDay: 15, thresholdBytes: 5_000_000_000 },
35
+ { logicalSlotId: 'slot-b', cycleDay: 1 },
36
+ { logicalSlotId: 'slot-c', thresholdBytes: 0 },
37
+ ],
38
+ };
39
+
40
+ describe('UsagePolicyStore — versioned round-trip', () => {
41
+ test('save then load returns identical, schema-versioned state', async () => {
42
+ const store = createUsagePolicyFileStore({ path });
43
+ await store.save(sample);
44
+ expect(await store.load(1)).toEqual(sample);
45
+ });
46
+
47
+ test('an absent file loads as a fresh empty document and writes nothing', async () => {
48
+ const store = createUsagePolicyFileStore({ path });
49
+ const loaded = await store.load(4242);
50
+ expect(loaded).toEqual({
51
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
52
+ savedAtMs: 4242,
53
+ slots: [],
54
+ });
55
+ expect(readFile(path, 'utf8')).rejects.toThrow();
56
+ });
57
+
58
+ test('the written file is mode 0600 regardless of umask', async () => {
59
+ const store = createUsagePolicyFileStore({ path });
60
+ await store.save(sample);
61
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
62
+ });
63
+ });
64
+
65
+ describe('UsagePolicyStore — fail-soft corruption', () => {
66
+ test('invalid JSON is replaced by a fresh 0600 file and logged as METADATA ONLY', async () => {
67
+ const events: UsagePolicyLogEvent[] = [];
68
+ await writeFile(path, '{"schemaVersion":1,"slots":');
69
+ const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) });
70
+
71
+ const loaded = await store.load(7);
72
+
73
+ expect(loaded.slots).toEqual([]);
74
+ expect(events).toHaveLength(1);
75
+ expect(events[0]?.kind).toBe('corrupt-policy');
76
+ expect(events[0]?.reason).toContain('invalid-json');
77
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
78
+ });
79
+
80
+ test.each([
81
+ [
82
+ 'schemaVersion',
83
+ JSON.stringify({ ...sample, schemaVersion: 99 }),
84
+ 'schema-mismatch: schemaVersion',
85
+ ],
86
+ ['savedAtMs', JSON.stringify({ ...sample, savedAtMs: 'soon' }), 'schema-mismatch: savedAtMs'],
87
+ ['slots', JSON.stringify({ ...sample, slots: {} }), 'schema-mismatch: slots'],
88
+ [
89
+ 'logicalSlotId',
90
+ JSON.stringify({ ...sample, slots: [{ cycleDay: 3 }] }),
91
+ 'schema-mismatch: logicalSlotId',
92
+ ],
93
+ [
94
+ 'cycleDay',
95
+ JSON.stringify({ ...sample, slots: [{ logicalSlotId: 'a', cycleDay: 32 }] }),
96
+ 'schema-mismatch: cycleDay',
97
+ ],
98
+ [
99
+ 'thresholdBytes',
100
+ JSON.stringify({ ...sample, slots: [{ logicalSlotId: 'a', thresholdBytes: -1 }] }),
101
+ 'schema-mismatch: thresholdBytes',
102
+ ],
103
+ ])('a bad %s is rejected by name and never throws', async (_field, text, reason) => {
104
+ const events: UsagePolicyLogEvent[] = [];
105
+ await writeFile(path, text);
106
+ const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) });
107
+
108
+ expect((await store.load(9)).slots).toEqual([]);
109
+ expect(events[0]?.reason).toBe(reason);
110
+ });
111
+
112
+ test('the corruption log carries a byte count and NEVER the file content', async () => {
113
+ const secretish = JSON.stringify({ schemaVersion: 1, slots: 'iccid-8991101200003204514' });
114
+ const events: UsagePolicyLogEvent[] = [];
115
+ await writeFile(path, secretish);
116
+ const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) });
117
+
118
+ await store.load(11);
119
+
120
+ expect(events[0]?.bytes).toBe(Buffer.byteLength(secretish, 'utf8'));
121
+ expect(JSON.stringify(events[0])).not.toContain('8991101200003204514');
122
+ });
123
+ });
124
+
125
+ describe('selectUsagePolicy', () => {
126
+ test('returns the slot policy, omitting fields the slot never set', () => {
127
+ expect(selectUsagePolicy(sample, 'slot-a')).toEqual({
128
+ cycleDay: 15,
129
+ thresholdBytes: 5_000_000_000,
130
+ });
131
+ expect(selectUsagePolicy(sample, 'slot-b')).toEqual({ cycleDay: 1 });
132
+ expect(selectUsagePolicy(sample, 'slot-c')).toEqual({ thresholdBytes: 0 });
133
+ });
134
+
135
+ test('an unknown slot answers "no policy set", not a default', () => {
136
+ expect(selectUsagePolicy(sample, 'nope')).toEqual({});
137
+ });
138
+ });
139
+
140
+ describe('validators', () => {
141
+ test.each([
142
+ [1, true],
143
+ [31, true],
144
+ [15, true],
145
+ [0, false],
146
+ [32, false],
147
+ [1.5, false],
148
+ [Number.NaN, false],
149
+ ['3', false],
150
+ ])('isValidCycleDay(%p) === %p', (value, expected) => {
151
+ expect(isValidCycleDay(value)).toBe(expected);
152
+ });
153
+
154
+ test.each([
155
+ [0, true],
156
+ [5_000_000_000, true],
157
+ [-1, false],
158
+ [1.5, false],
159
+ [Number.POSITIVE_INFINITY, false],
160
+ ['5', false],
161
+ ])('isValidThresholdBytes(%p) === %p', (value, expected) => {
162
+ expect(isValidThresholdBytes(value)).toBe(expected);
163
+ });
164
+ });