$nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $raw, $q, []); } public function testConfigReturnsZonesAndThresholds(): void { $dev = new InMemoryDeviceRepository(['pi-1' => 'sek'], ['pi-1' => 7]); $zones = new InMemoryZoneRepository(); $zones->save(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); $thr = new InMemoryThresholdRepository(['pi-1' => ['fall_prone_s' => 3.0]]); $nonces = new InMemoryNonceRepository(); $cfg = Config::fromArray(['timestamp_window' => 300]); $c = new ConfigController($dev, $zones, $thr, $nonces, $cfg); $res = $c->get($this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1']), now: 1000); $this->assertSame(200, $res->status()); $data = json_decode($res->body(), true); $this->assertSame(7, $data['version']); $this->assertCount(1, $data['zones']); $this->assertEquals(3.0, $data['thresholds']['fall_prone_s']); } public function testConfigRejectsBadSignature(): void { $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); $c = new ConfigController($dev, new InMemoryZoneRepository(), new InMemoryThresholdRepository(), new InMemoryNonceRepository(), Config::fromArray([])); $res = $c->get($this->signed('GET', '/api/config', 'WRONG', ['device_id' => 'pi-1']), now: 1000); $this->assertSame(401, $res->status()); } public function testConfigRejectsReplayedNonce(): void { $dev = new InMemoryDeviceRepository(['pi-1' => 'sek'], ['pi-1' => 7]); $zones = new InMemoryZoneRepository(); $thr = new InMemoryThresholdRepository(); $nonces = new InMemoryNonceRepository(); $cfg = Config::fromArray(['timestamp_window' => 300]); $c = new ConfigController($dev, $zones, $thr, $nonces, $cfg); $req = $this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'], [], 'replay-nonce', '1000'); $this->assertSame(200, $c->get($req, now: 1000)->status()); $this->assertSame(401, $c->get($req, now: 1000)->status()); // same nonce replayed } public function testConfigRejectsStaleTimestamp(): void { $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); $zones = new InMemoryZoneRepository(); $thr = new InMemoryThresholdRepository(); $nonces = new InMemoryNonceRepository(); $cfg = Config::fromArray(['timestamp_window' => 300]); $c = new ConfigController($dev, $zones, $thr, $nonces, $cfg); $req = $this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'], [], 'stale-nonce', '1000'); $this->assertSame(401, $c->get($req, now: 5000)->status()); // 4000s > 300 window } public function testHeartbeatStored(): void { $cfg = Config::fromArray([]); $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); $nonces = new InMemoryNonceRepository(); $c = new HeartbeatController($cfg, $dev, $nonces); $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000); $this->assertSame(200, $res->status()); $this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']); } public function testHeartbeatRejectsReplayedNonce(): void { $cfg = Config::fromArray([]); $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); $nonces = new InMemoryNonceRepository(); $c = new HeartbeatController($cfg, $dev, $nonces); $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; $req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-replay', '1000'); $this->assertSame(200, $c->post($req, now: 1000)->status()); $this->assertSame(401, $c->post($req, now: 1000)->status()); // same nonce replayed } public function testHeartbeatRejectsStaleTimestamp(): void { $cfg = Config::fromArray([]); $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); $nonces = new InMemoryNonceRepository(); $c = new HeartbeatController($cfg, $dev, $nonces); $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; $req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-stale', '1000'); $this->assertSame(401, $c->post($req, now: 5000)->status()); // 4000s > 300 default window } }