P4-8: AuthController::login now verifies the CSRF token the login form already emits (Csrf::check over session vs posted field), returning 403 before the user lookup. Closes a login-CSRF / session-fixation hole where a forged cross-site POST could silently log a victim into an attacker's account. Tests seed the session+body token; adds testLoginRejectsMissingOrWrongCsrf. P4-6: ZoneConfigController::save wraps the repository write in try/catch(\RuntimeException) so an ownership/device mismatch is a 400 (invalid_zone) client error, not an uncaught 500. Adds testSaveOwnershipMismatchIs400NotError with a throwing repo double. phpunit: 88 passing (4 skipped: PDO-backed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
83 lines
5 KiB
PHP
83 lines
5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
use App\Http\Request;
|
|
use App\Auth\ArraySessionStore;
|
|
use App\Geometry\Polygon;
|
|
use App\Dashboard\ZoneConfigController;
|
|
use App\Tests\Support\{InMemoryZoneRepository, InMemoryAuditRepository};
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ZoneConfigTest extends TestCase
|
|
{
|
|
private function authed(string $role = 'operator'): ArraySessionStore {
|
|
$s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s;
|
|
}
|
|
public function testPolygonValidation(): void {
|
|
$this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
|
|
$this->assertFalse(Polygon::validate([[0,0],[10,0]])); // <3
|
|
$this->assertFalse(Polygon::validate([[0,0],[10,0],['x',10]])); // non-numeric
|
|
}
|
|
private function saveReq(array $body, array $headers = ['X-Csrf-Token' => 'tok']): Request {
|
|
return new Request('POST', '/api/zones', $headers, json_encode($body), [], []);
|
|
}
|
|
public function testSaveRequiresPermission(): void {
|
|
$c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
|
|
$res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
|
|
$this->assertSame(403, $res->status());
|
|
}
|
|
public function testSaveValidZone(): void {
|
|
$repo = new InMemoryZoneRepository();
|
|
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator'));
|
|
$res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
|
|
$this->assertSame(201, $res->status());
|
|
$this->assertCount(1, $repo->forCamera('cam-1'));
|
|
}
|
|
public function testSaveOwnershipMismatchIs400NotError(): void {
|
|
// The repo throws RuntimeException when the zone isn't owned by the given device/camera.
|
|
// The controller must turn that client error into 400, not let it become a 500.
|
|
$throwing = new class implements \App\Repository\ZoneRepository {
|
|
public function forDevice(string $d): array { return []; }
|
|
public function forCamera(string $c): array { return []; }
|
|
public function save(array $z): string { throw new \RuntimeException('not owned'); }
|
|
public function delete(string $id): bool { return true; }
|
|
};
|
|
$c = new ZoneConfigController($throwing, new InMemoryAuditRepository(), $this->authed('operator'));
|
|
$res = $c->save($this->saveReq(['zone_id' => 'z1', 'device_id' => 'pi-2', 'camera_id' => 'cam-1',
|
|
'level' => 'restricted', 'polygon' => [[0, 0], [9, 0], [9, 9]]]));
|
|
$this->assertSame(400, $res->status());
|
|
}
|
|
public function testSaveRejectsBadLevelOrPolygon(): void {
|
|
$c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
|
|
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());
|
|
$this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status());
|
|
}
|
|
public function testSaveRejectsMissingOrWrongCsrf(): void {
|
|
$repo = new InMemoryZoneRepository();
|
|
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator'));
|
|
$body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]];
|
|
// missing header
|
|
$this->assertSame(403, $c->save($this->saveReq($body, []))->status());
|
|
// wrong token
|
|
$this->assertSame(403, $c->save($this->saveReq($body, ['X-Csrf-Token' => 'nope']))->status());
|
|
// save did not proceed
|
|
$this->assertCount(0, $repo->forCamera('cam-1'));
|
|
}
|
|
public function testDeleteRejectsMissingOrWrongCsrf(): void {
|
|
$repo = new InMemoryZoneRepository();
|
|
$repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']);
|
|
$c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator'));
|
|
// missing header
|
|
$missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []);
|
|
$this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status());
|
|
// wrong token
|
|
$wrong = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'nope'], '', [], []);
|
|
$this->assertSame(403, $c->delete($wrong, ['id' => 'z1'])->status());
|
|
// delete did not proceed
|
|
$this->assertCount(1, $repo->forCamera('cam-1'));
|
|
// valid token proceeds
|
|
$ok = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'tok'], '', [], []);
|
|
$this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status());
|
|
$this->assertCount(0, $repo->forCamera('cam-1'));
|
|
}
|
|
}
|