diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index f5e9f699e..45fef0a8d 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -91,6 +91,24 @@ describe("device bootstrap tokens", () => { expect(raw).toContain(issued.token); }); + it("accepts trimmed bootstrap tokens and still consumes them once", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + + await expect( + verifyDeviceBootstrapToken({ + token: ` ${issued.token} `, + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + }), + ).resolves.toEqual({ ok: true }); + + await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); + }); + it("rejects blank or unknown tokens", async () => { const baseDir = await createTempDir(); await issueDeviceBootstrapToken({ baseDir }); @@ -118,6 +136,23 @@ describe("device bootstrap tokens", () => { ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); }); + it("repairs malformed persisted state when issuing a new token", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-14T12:00:00Z")); + + const baseDir = await createTempDir(); + const bootstrapPath = resolveBootstrapPath(baseDir); + await fs.mkdir(path.dirname(bootstrapPath), { recursive: true }); + await fs.writeFile(bootstrapPath, "[1,2,3]\n", "utf8"); + + const issued = await issueDeviceBootstrapToken({ baseDir }); + const raw = await fs.readFile(bootstrapPath, "utf8"); + const parsed = JSON.parse(raw) as Record; + + expect(Object.keys(parsed)).toEqual([issued.token]); + expect(parsed[issued.token]?.token).toBe(issued.token); + }); + it("accepts legacy records that only stored issuedAtMs and prunes expired tokens", async () => { vi.useFakeTimers(); const baseDir = await createTempDir(); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 50a4e53ff..d4d2d6ed5 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -31,11 +31,25 @@ function resolveBootstrapPath(baseDir?: string): string { async function loadState(baseDir?: string): Promise { const bootstrapPath = resolveBootstrapPath(baseDir); - const state = (await readJsonFile(bootstrapPath)) ?? {}; - for (const entry of Object.values(state)) { - if (typeof entry.ts !== "number") { - entry.ts = entry.issuedAtMs; + const rawState = (await readJsonFile(bootstrapPath)) ?? {}; + const state: DeviceBootstrapStateFile = {}; + if (!rawState || typeof rawState !== "object" || Array.isArray(rawState)) { + return state; + } + for (const [tokenKey, entry] of Object.entries(rawState)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + continue; } + const record = entry as Partial; + const token = + typeof record.token === "string" && record.token.trim().length > 0 ? record.token : tokenKey; + const issuedAtMs = typeof record.issuedAtMs === "number" ? record.issuedAtMs : 0; + state[tokenKey] = { + ...record, + token, + issuedAtMs, + ts: typeof record.ts === "number" ? record.ts : issuedAtMs, + }; } pruneExpiredPending(state, Date.now(), DEVICE_BOOTSTRAP_TOKEN_TTL_MS); return state; diff --git a/src/infra/transport-ready.test.ts b/src/infra/transport-ready.test.ts index 68a5406d2..a4703ba51 100644 --- a/src/infra/transport-ready.test.ts +++ b/src/infra/transport-ready.test.ts @@ -1,10 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { waitForTransportReady } from "./transport-ready.js"; +let injectedSleepError: Error | null = null; + // Perf: `sleepWithAbort` uses `node:timers/promises` which isn't controlled by fake timers. // Route sleeps through global `setTimeout` so tests can advance time deterministically. vi.mock("./backoff.js", () => ({ sleepWithAbort: async (ms: number, signal?: AbortSignal) => { + if (injectedSleepError) { + throw injectedSleepError; + } if (signal?.aborted) { throw new Error("aborted"); } @@ -37,6 +42,7 @@ describe("waitForTransportReady", () => { afterEach(() => { vi.useRealTimers(); + injectedSleepError = null; }); it("returns when the check succeeds and logs after the delay", async () => { @@ -143,4 +149,21 @@ describe("waitForTransportReady", () => { expect(runtime.error.mock.calls.at(0)?.[0]).toContain("unknown error"); expect(runtime.error.mock.calls.at(-1)?.[0]).toContain("not ready after 120ms"); }); + + it("rethrows non-abort sleep failures", async () => { + const runtime = createRuntime(); + injectedSleepError = new Error("sleep exploded"); + + await expect( + waitForTransportReady({ + label: "test transport", + timeoutMs: 500, + pollIntervalMs: 50, + runtime, + check: async () => ({ ok: false, error: "still down" }), + }), + ).rejects.toThrow("sleep exploded"); + + expect(runtime.error).not.toHaveBeenCalled(); + }); });