Files
parking_solution/apps/server/src/subscription-channel.test.ts
T
julian 43c1f45e29
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 42s
feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).

1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
   channel-blind (one opaque cardid from either engine) and SubscriptionFlow
   matched by value only, so printing an RF card's UID (often written on the
   card face, e.g. 86A158) as a barcode cloned the card. Now:
   - Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
     overrides READER_QR_PREFIX / READER_CARD_PREFIX).
   - routes/qr-reader.ts strips the prefix and tags the read's confirmed
     channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
     capture stores the BARE value. READ log lines carry ch=… (permanent
     phantom attribution).
   - SubscriptionFlow.match requires channel agreement: an optical decode may
     not claim an rf credential (and vice versa) — refused + signed
     sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
   - Unprefixed reads keep the legacy untagged shape and match as before, so
     enforcement only bites where prefixes are deployed. Deploy server FIRST,
     then set prefixes in the vendor tool.

2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
   requested, reverses the earlier "record every probe" position — red
   "who is exiting?" rows for NOBODY train the operator to ignore the feed).
   read-dispatch.ts drops a no-match reader value that cannot possibly be a
   credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
   confirmed-RF, not a plate) to UNSIGNED device_events telemetry
   (unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
   shapes, unknown physical cards, unknown SUB- codes all still sign the
   normal refusal anomaly; enrolled credentials match before the filter and
   can never be hidden. Works for legacy unprefixed reads too — the feed
   cleans up on deploy, before any vendor-tool change.

Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).

Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 19:34:48 +02:00

89 lines
3.8 KiB
TypeScript

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);
});
});