Files
hackerspace.sh/__tests__/door-log-logic.test.ts
T
Moheeb ZaraandClaude Opus 4.8 90c68faa25 fix(door): correct parseHeatSyncLog stale time + token reuse (L5)
(a) Reset h/mi/e after each event so a later event with no H:M:E of its own no
longer inherits the previous event's time-of-day (fabricated audit timestamp +
dedupeKey). (b) Track consumed high-half token indices so one g/d token can no
longer be paired with two adjacent grants (wrong reconstructed card numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 21:31:34 -07:00

190 lines
7.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
parseHeatSyncLog,
normalizeWebhookEvents,
cardMatchesEvent,
type DoorIngestEvent,
} from '@/lib/door-log-logic'
function ev(p: Partial<DoorIngestEvent>): DoorIngestEvent {
return { result: 'granted', cardNumber: null, cardUid: null, occurredAt: null, detail: '', dedupeKey: '', ...p }
}
describe('parseHeatSyncLog', () => {
it('parses a granted event and reconstructs a small card number', () => {
const log = '<pre>\nG: 12345\ng: 0\n</pre>'
const ev = parseHeatSyncLog(log)
expect(ev).toHaveLength(1)
expect(ev[0].result).toBe('granted')
expect(ev[0].cardNumber).toBe('12345')
expect(ev[0].cardUid).toBeNull()
})
it('reconstructs a card number split across the divisor (high*32767 + low)', () => {
// 40000 = 1*32767 + 7233
const log = 'G: 7233\ng: 1'
const ev = parseHeatSyncLog(log)
expect(ev[0].cardNumber).toBe('40000')
})
it('parses a denied event from D/d', () => {
const log = 'D: 100\nd: 0'
const ev = parseHeatSyncLog(log)
expect(ev[0].result).toBe('denied')
expect(ev[0].cardNumber).toBe('100')
})
it('stamps best-effort H:M:E time-of-day when present', () => {
const log = 'H: 9\nM: 5\nE: 3\nG: 50\ng: 0'
const ev = parseHeatSyncLog(log)
expect(ev[0].detail).toContain('09:05:03')
expect(ev[0].dedupeKey).toContain('09:05:03')
})
it('ignores empty ring slots (NUL keys) and unrelated keys', () => {
const log = ': 0\nU: 1\nG: 7\ng: 0\n: 0'
const ev = parseHeatSyncLog(log)
expect(ev).toHaveLength(1)
expect(ev[0].cardNumber).toBe('7')
})
it('does not ingest raw reads (R/r)', () => {
const log = 'R: 999\nr: 0'
expect(parseHeatSyncLog(log)).toHaveLength(0)
})
it('gives distinct dedupe keys to events in different slots, stable per slot', () => {
const log = 'G: 1\ng: 0\nU: 1\nG: 1\ng: 0'
const ev = parseHeatSyncLog(log)
expect(ev).toHaveLength(2)
expect(ev[0].dedupeKey).not.toBe(ev[1].dedupeKey)
// Re-parsing the identical dump yields identical keys (re-poll dedupes).
const again = parseHeatSyncLog(log)
expect(again.map(e => e.dedupeKey)).toEqual(ev.map(e => e.dedupeKey))
})
it('caps the number of emitted events', () => {
const log = Array.from({ length: 50 }, () => 'G: 1\ng: 0').join('\n')
expect(parseHeatSyncLog(log, { maxEvents: 10 })).toHaveLength(10)
})
it('returns nothing for an empty or junk body', () => {
expect(parseHeatSyncLog('<pre>\n</pre>')).toHaveLength(0)
expect(parseHeatSyncLog('not a log at all')).toHaveLength(0)
})
it('ignores pathological over-long digit runs without throwing (hostile body)', () => {
// Number('9'.repeat(400)) === Infinity and BigInt(Infinity) throws; the
// 15-digit cap means this line never matches and is skipped.
const log = `G: ${'9'.repeat(400)}\ng: 0\nG: 42\ng: 0`
expect(() => parseHeatSyncLog(log)).not.toThrow()
const ev = parseHeatSyncLog(log)
expect(ev).toHaveLength(1)
expect(ev[0].cardNumber).toBe('42')
})
it('treats a high-half as 0 when missing within the window', () => {
const log = 'G: 7'
const ev = parseHeatSyncLog(log)
expect(ev[0].cardNumber).toBe('7')
})
it('does not pair a G with a distant unrelated g (window bound)', () => {
// A g far past the window must not be borrowed as this G's high half.
const log = 'G: 5\nU: 1\nU: 1\nU: 1\nU: 1\ng: 9'
const ev = parseHeatSyncLog(log)
expect(ev[0].cardNumber).toBe('5')
})
it('does not inherit a previous event time-of-day (resets H/M/E per event)', () => {
// H/M/E stamp only the event that immediately follows them. A later grant
// with no time of its own must NOT reuse the first event's 09:05:03.
const log = 'H: 9\nM: 5\nE: 3\nG: 50\ng: 0\nG: 60\ng: 0'
const ev = parseHeatSyncLog(log)
expect(ev).toHaveLength(2)
expect(ev[0].detail).toContain('09:05:03')
expect(ev[0].dedupeKey).toContain('09:05:03')
expect(ev[1].detail).not.toContain('09:05:03')
expect(ev[1].dedupeKey).not.toContain('09:05:03')
// The stale time must not leak into the second event's dedupe key either.
expect(ev[1].dedupeKey).toBe('hs:5:G:60:')
})
it('pairs each high-half token at most once (no high-half reuse)', () => {
// Two adjacent grants each own a distinct g; the first g must not be
// borrowed by the second grant. G:5<->g:1 and G:6<->g:2.
const log = 'G: 5\nG: 6\ng: 1\ng: 2'
const ev = parseHeatSyncLog(log)
expect(ev).toHaveLength(2)
// 5 + 1*32767 = 32772
expect(ev[0].cardNumber).toBe('32772')
// 6 + 2*32767 = 65540 (reusing g:1 would wrongly give 6 + 1*32767 = 32773)
expect(ev[1].cardNumber).toBe('65540')
})
})
describe('normalizeWebhookEvents', () => {
it('keys on the caller-supplied event id', () => {
const ev = normalizeWebhookEvents([{ id: 'abc', card_uid: '9c40', result: 'granted' }])
expect(ev[0].dedupeKey).toBe('wh:abc')
expect(ev[0].cardUid).toBe('9c40')
expect(ev[0].result).toBe('granted')
})
it('defaults a missing result to unknown', () => {
const ev = normalizeWebhookEvents([{ id: '1', card_number: '40000' }])
expect(ev[0].result).toBe('unknown')
expect(ev[0].cardNumber).toBe('40000')
})
it('passes through an ISO occurred_at', () => {
const ev = normalizeWebhookEvents([{ id: '1', card_uid: 'x', occurred_at: '2026-05-20T10:00:00Z' }])
expect(ev[0].occurredAt).toBe('2026-05-20T10:00:00Z')
})
})
describe('cardMatchesEvent', () => {
it('matches a hex-stored uid to a decimal poll number', () => {
// 0x9c40 === 40000
expect(cardMatchesEvent('9c40', ev({ cardNumber: '40000' }))).toBe(true)
expect(cardMatchesEvent('9C40', ev({ cardNumber: '40000' }))).toBe(true)
expect(cardMatchesEvent('0x9c40', ev({ cardNumber: '40000' }))).toBe(true)
})
it('does NOT match a different card by coincidental decimal string (the fixed false positive)', () => {
// stored "16" is hex 0x16 = 22; a poll of decimal card 16 must NOT match it.
expect(cardMatchesEvent('16', ev({ cardNumber: '16' }))).toBe(false)
// stored "16" DOES match a poll of decimal 22 (its real number).
expect(cardMatchesEvent('16', ev({ cardNumber: '22' }))).toBe(true)
})
it('matches an all-digit hex uid to its decimal number', () => {
// stored "10" is hex 0x10 = 16; a poll of 16 is the same card.
expect(cardMatchesEvent('10', ev({ cardNumber: '16' }))).toBe(true)
})
it('matches a webhook card_uid by exact (case-insensitive) string', () => {
expect(cardMatchesEvent('9c40', ev({ cardUid: '9C40' }))).toBe(true)
})
it('matches a webhook all-digit card_uid as a decimal number (hex-stored)', () => {
expect(cardMatchesEvent('9c40', ev({ cardUid: '40000' }))).toBe(true)
})
it('matches a webhook card_number field', () => {
expect(cardMatchesEvent('9c40', ev({ cardUid: null, cardNumber: '40000' }))).toBe(true)
})
it('does not match different cards or empty input', () => {
expect(cardMatchesEvent('9c40', ev({ cardNumber: '40001' }))).toBe(false)
expect(cardMatchesEvent('', ev({ cardNumber: '40000' }))).toBe(false)
expect(cardMatchesEvent('9c40', ev({}))).toBe(false)
})
it('does not match a non-hex stored uid against a number', () => {
// 'xyz' is not a HeatSync hex uid; only an exact webhook uid string matches.
expect(cardMatchesEvent('xyz', ev({ cardNumber: '123' }))).toBe(false)
expect(cardMatchesEvent('xyz', ev({ cardUid: 'xyz' }))).toBe(true)
})
})