import { describe, it, expect } from 'vitest'; import { LocationSpoofDetector } from '../../src/antispoofing/detector.js'; import { signFrame } from '../../src/utils/crypto.js'; import type { CameraFrame, Vec3 } from '../../src/types/index.js'; const TEST_SECRET = 'a'.repeat(32); function makeFrame( seq: number, ts: number, position: Vec3, poseTs?: number, withPose = true, ): CameraFrame { const rgb = new Uint8Array(10 * 10 * 3); for (let i = 0; i < rgb.length; i++) rgb[i] = (i * 17 + seq * 31) % 256; const frame: CameraFrame = { id: `frame-${seq}`, timestamp: ts, rgb, width: 10, height: 10, sequenceNumber: seq, hmac: signFrame(rgb, ts, seq, TEST_SECRET), }; if (!withPose) return frame; return { ...frame, pose: { position, orientation: { w: 1, x: 0, y: 0, z: 0 }, timestamp: poseTs ?? ts, }, }; } describe('LocationSpoofDetector', () => { it('accepts normal slow movement', () => { const d = new LocationSpoofDetector(); expect(d.check(makeFrame(1, 0, { x: 0, y: 0, z: 0 }))).toHaveLength(0); expect(d.check(makeFrame(2, 1000, { x: 1, y: 0, z: 0 }))).toHaveLength(0); // 1 m/s expect(d.check(makeFrame(3, 2000, { x: 2.5, y: 0, z: 0 }))).toHaveLength(0); // 1.5 m/s }); it('passes through frames with no pose (nothing to assess)', () => { const d = new LocationSpoofDetector(); const frame = makeFrame(1, 0, { x: 0, y: 0, z: 0 }, undefined, false); expect(frame.pose).toBeUndefined(); expect(d.check(frame)).toHaveLength(0); }); it('detects a teleport (implied speed beyond any platform)', () => { const d = new LocationSpoofDetector(); d.check(makeFrame(1, 0, { x: 0, y: 0, z: 0 })); const threats = d.check(makeFrame(2, 1000, { x: 1000, y: 0, z: 0 })); // 1000 m/s expect(threats.some(t => t.type === 'location-spoof' && t.severity === 'critical')).toBe(true); expect(threats[0]!.details).toMatch(/Teleport|exceeds max/i); }); it('detects pose timestamp regression', () => { const d = new LocationSpoofDetector(); d.check(makeFrame(1, 0, { x: 0, y: 0, z: 0 }, 1000)); const threats = d.check(makeFrame(2, 1, { x: 0, y: 0, z: 0 }, 500)); // pose time goes backwards expect(threats.some(t => t.type === 'location-spoof' && t.severity === 'critical')).toBe(true); expect(threats[0]!.details).toMatch(/regressed/i); }); it('detects a static jump (GNSS pull-off signature)', () => { const d = new LocationSpoofDetector(); d.check(makeFrame(1, 0, { x: 0, y: 0, z: 0 })); d.check(makeFrame(2, 1000, { x: 0.1, y: 0, z: 0 })); // ~0.1 m/s, stationary const threats = d.check(makeFrame(3, 2000, { x: 10, y: 0, z: 0 })); // jumps ~9.9m in 1s expect(threats.some(t => t.severity === 'high' && /pull-off|jumped/i.test(t.details))).toBe(true); }); it('detects impossible acceleration', () => { const d = new LocationSpoofDetector(); d.check(makeFrame(1, 0, { x: 0, y: 0, z: 0 })); d.check(makeFrame(2, 1000, { x: 5, y: 0, z: 0 })); // 5 m/s const threats = d.check(makeFrame(3, 2000, { x: 45, y: 0, z: 0 })); // 40 m/s -> accel 35 m/s^2 expect(threats.some(t => /acceleration/i.test(t.details))).toBe(true); }); it('flags two pose claims at the same instant in different places', () => { const d = new LocationSpoofDetector(); d.check(makeFrame(1, 1000, { x: 0, y: 0, z: 0 }, 1000)); const threats = d.check(makeFrame(2, 1000, { x: 10, y: 0, z: 0 }, 1000)); // same poseTs, 10m apart expect(threats.some(t => t.severity === 'critical' && /same timestamp/i.test(t.details))).toBe(true); }); it('reset() clears kinematic history', () => { const d = new LocationSpoofDetector(); d.check(makeFrame(1, 0, { x: 0, y: 0, z: 0 })); d.check(makeFrame(2, 1000, { x: 1000, y: 0, z: 0 })); // teleport flagged d.reset(); // After reset the next frame is treated as the first — no comparison, no threat. expect(d.check(makeFrame(3, 2000, { x: 5000, y: 0, z: 0 }))).toHaveLength(0); }); it('respects custom thresholds', () => { const d = new LocationSpoofDetector(5); // maxSpeed 5 m/s d.check(makeFrame(1, 0, { x: 0, y: 0, z: 0 })); const threats = d.check(makeFrame(2, 1000, { x: 10, y: 0, z: 0 })); // 10 m/s > 5 expect(threats.some(t => t.severity === 'critical')).toBe(true); }); });