import { randomUUID } from "node:crypto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { SubscriptionFlow } from "./subscription-flow.js"; import type { DeviceReadEvent } from "./device-events.js"; import { makeLog, silentLogger } from "./test-helpers.js"; // CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED // the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the // credential kind must agree. An OPTICAL decode claiming an RF credential is the // cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly. // Legacy untagged reads (channel undefined) match as before, so readers without // prefixes keep working. let db: Db; let flow: SubscriptionFlow; const SUB = "sub-1"; const CARD_UID = "86A158"; const QR_CODE = "SUB-TESTQR"; beforeEach(() => { ({ db } = createTestDb()); db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run(); db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run(); flow = new SubscriptionFlow(db, makeLog(db), silentLogger()); }); function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent { return { driverId: "dingtian-qr-reader", deviceId: "reader-1", value, kind: opts.kind ?? "qr", ...(opts.channel ? { channel: opts.channel } : {}), at: new Date().toISOString(), }; } const anomalies = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly"); describe("subscription match — credential channel agreement", () => { it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => { const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" })); expect(m).toBeNull(); await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget expect(anomalies()[0].identity).toBe(SUB); expect(anomalies()[0].payload).toMatchObject({ reasonCode: "sub.refused.channelMismatch", channelMismatch: true, credentialKind: "rf", channel: "optical", value: CARD_UID, }); }); it("RF read of the same card → matches (via card), nothing signed", () => { const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" })); expect(m).toMatchObject({ subscriptionId: SUB, via: "card" }); expect(anomalies()).toHaveLength(0); }); it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => { const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape expect(m).toMatchObject({ subscriptionId: SUB, via: "card" }); expect(anomalies()).toHaveLength(0); }); it("OPTICAL read of a QR credential → matches (the legit path)", () => { const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" })); expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" }); }); it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => { const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" })); expect(m).toBeNull(); await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" }); }); it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => { const m = flow.match(read("999459", { kind: "qr", channel: "optical" })); expect(m).toBeNull(); expect(anomalies()).toHaveLength(0); }); });