- Device-status panel (DeviceStatusController): site-scoped liveness over stored heartbeat telemetry. Fix config_drift false-positive by storing NULL until the device actually reports a version (was firing "설정 미적용" on every device). - Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone is never persisted against a device that will never fetch it. - Snapshot proxy sends immutable private cache headers, ending the 5s-poll re-download of every thumbnail (a Cafe24 traffic-cap hazard). - Event list: paged-in acknowledgements stick; honesty affordances for the paging gap, a stale device poll, and a filtered camera aging out; logout wired for every page via the shared layout.
112 lines
7 KiB
PHP
112 lines
7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
use App\Config;
|
|
use App\Http\Request;
|
|
use App\Security\Hmac;
|
|
use App\Api\{ConfigController, HeartbeatController};
|
|
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository, InMemoryNonceRepository, InMemoryRateLimiter};
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ApiSyncTest extends TestCase
|
|
{
|
|
private function signed(string $method, string $path, string $secret, array $q = [], array $body = [], string $nonce = 'n1', string $ts = '1000'): Request {
|
|
$raw = $body ? json_encode($body) : '';
|
|
$sig = Hmac::sign($secret, $nonce, $ts, $raw);
|
|
return new Request($method, $path, ['X-Nonce' => $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']);
|
|
$this->assertSame(7, $dev->heartbeats['pi-1']['config_version']);
|
|
}
|
|
public function testHeartbeatStoresNullConfigVersionWhenNotReported(): void {
|
|
// An older edge build sends no config_version. The server must store NULL, not a fake 0 —
|
|
// storing 0 made the dashboard's config-drift check fire on every device forever (0 != 1),
|
|
// training operators to ignore the device-warning line.
|
|
$cfg = Config::fromArray([]);
|
|
$dev = new InMemoryDeviceRepository(['pi-1' => 'sek']);
|
|
$c = new HeartbeatController($cfg, $dev, new InMemoryNonceRepository());
|
|
$body = ['device_id' => 'pi-1', 'fps' => 12.0]; // no config_version reported
|
|
$res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000);
|
|
$this->assertSame(200, $res->status());
|
|
$this->assertNull($dev->heartbeats['pi-1']['config_version']);
|
|
}
|
|
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
|
|
}
|
|
public function testHeartbeatIpFloodRateLimited(): void {
|
|
// An unauthenticated attacker flooding /api/heartbeat from one IP must be throttled
|
|
// pre-auth, before the HMAC + DB write on every hit (was: no limit at all).
|
|
$cfg = Config::fromArray(['ingest_ip_rate_limit_per_min' => 2]);
|
|
$dev = new InMemoryDeviceRepository(['pi-1' => 'sek']);
|
|
$c = new HeartbeatController($cfg, $dev, new InMemoryNonceRepository(), new InMemoryRateLimiter());
|
|
$garbage = new Request('POST', '/api/heartbeat', [], 'not-json', [], []);
|
|
$this->assertSame(401, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status());
|
|
$this->assertSame(401, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status());
|
|
$this->assertSame(429, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status()); // 3rd from same IP
|
|
}
|
|
}
|