set('user_id', 1); $s->set('username', '관리자'); $s->set('role', $role); $s->set('csrf', 'tok'); if ($site !== null) $s->set('site_scope', $site); return $s; } private function post(array $body): Request { return new Request('POST', '/x', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []); } private function get(array $query = []): Request { return new Request('GET', '/x', [], '', $query, []); } // ================================================================ thresholds private function admin(string $role = 'admin', ?string $site = null, ?InMemoryThresholdRepository $thr = null, ?InMemoryDeviceRepository $dev = null, ?InMemoryAuditRepository $audit = null, ?InMemoryAlertRepository $alerts = null): AdminConfigController { return new AdminConfigController( $thr ?? new InMemoryThresholdRepository(), $alerts ?? new InMemoryAlertRepository(), $dev ?? new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1]), $audit ?? new InMemoryAuditRepository(), $this->sess($role, $site) ); } public function testThresholdEditingIsAdminOnly(): void { $this->assertSame(403, $this->admin('operator')->thresholds($this->get())->status()); $this->assertSame(403, $this->admin('viewer')->thresholds($this->get())->status()); $this->assertSame(200, $this->admin('admin')->thresholds($this->get())->status()); } public function testCatalogTravelsWithTheValuesSoTheEditorCanBoundItsInputs(): void { $res = $this->admin()->thresholds($this->get()); $data = json_decode($res->body(), true); $this->assertArrayHasKey('fall_prone_s', $data['catalog']); // assertEquals, not assertSame: a whole float survives the JSON round trip as an int. $this->assertEquals(30.0, $data['catalog']['fall_prone_s']['max']); $this->assertEquals(1.0, $data['catalog']['fall_prone_s']['min']); } public function testOutOfRangeValueIsRejected(): void { $c = $this->admin(); $res = $c->saveThreshold($this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 900])); $this->assertSame(400, $res->status()); $this->assertSame('out_of_range', json_decode($res->body(), true)['error']); } public function testNonFiniteValueIsRejected(): void { // edge/config.py 가 기동 시 거부하는 바로 그 값들 — 그때는 이미 공장 안 장비가 // 재시작 루프에 빠진 뒤다. 키보드 앞에서 막는다. foreach (['nan', 'inf', '-inf'] as $bad) { $res = $this->admin()->saveThreshold( $this->post(['device_id' => 'pi-1', 'key' => 'cooldown_s', 'value' => $bad])); $this->assertSame(400, $res->status(), "rejects $bad"); } } public function testUnknownKeyIsRejectedRatherThanDeliveredAndIgnored(): void { $res = $this->admin()->saveThreshold( $this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_seconds', 'value' => 3])); $this->assertSame(400, $res->status()); $this->assertSame('unknown_key', json_decode($res->body(), true)['error']); } public function testSaveBumpsConfigVersionSoTheChangeReachesTheDevice(): void { $dev = new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 4]); $c = $this->admin('admin', null, null, $dev); $this->assertSame(200, $c->saveThreshold( $this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]))->status()); $this->assertSame(5, $dev->configVersion('pi-1'), '버전이 오르지 않으면 값은 DB 에만 남고 장비는 옛 설정으로 계속 돈다'); } public function testSaveIsAudited(): void { $audit = new InMemoryAuditRepository(); $c = $this->admin('admin', null, null, null, $audit); $c->saveThreshold($this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5])); $this->assertCount(1, $audit->log); $this->assertSame('threshold_set', $audit->log[0]['act']); } public function testScopedAdminCannotRetuneAnotherSitesDevice(): void { $dev = new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1], [], ['pi-1' => 'factory-a']); $c = $this->admin('admin', 'factory-b', null, $dev); $this->assertSame(404, $c->saveThreshold( $this->post(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]))->status()); } public function testCsrfIsRequired(): void { $c = $this->admin(); $noToken = new Request('POST', '/x', [], json_encode(['device_id' => 'pi-1', 'key' => 'fall_prone_s', 'value' => 5]), [], []); $this->assertSame(403, $c->saveThreshold($noToken)->status()); } public function testIntegerKnobRejectsAFraction(): void { [$ok, $err] = ThresholdCatalog::normalize('infer_every', 1.5); $this->assertFalse($ok); $this->assertSame('not_integer', $err); } public function testNormalizationIsStableSoOneValueHasOneStoredForm(): void { [, $a] = ThresholdCatalog::normalize('fall_prone_s', '3'); [, $b] = ThresholdCatalog::normalize('fall_prone_s', 3.0); [, $c] = ThresholdCatalog::normalize('fall_prone_s', '3.000'); $this->assertSame($a, $b); $this->assertSame($b, $c); } // ================================================================ alert channels public function testWebhookTokenIsNeverEchoedBack(): void { $alerts = new InMemoryAlertRepository(); $c = $this->admin('admin', null, null, null, null, $alerts); $c->saveChannel($this->post(['kind' => 'webhook', 'name' => 'groupware', 'enabled' => 1, 'min_severity' => 'high', 'config' => ['url' => 'https://in.house/hook', 'token' => 'SECRET']])); $body = json_decode($c->channels($this->get())->body(), true); $this->assertSame('••••••••', $body['channels'][0]['config']['token'], '설정 화면을 화면공유해도 연동 키가 새지 않아야 한다'); } public function testEscalationStepRequiresADelay(): void { $c = $this->admin(); $res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'manager', 'enabled' => 1, 'min_severity' => 'critical', 'step' => 1, 'escalate_after_s' => 0, 'config' => ['to' => 'a@b.c']])); $this->assertSame(400, $res->status(), '지연 0 짜리 에스컬레이션은 즉시 채널과 같이 울린다'); } public function testWebhookRejectsNonHttpUrl(): void { $c = $this->admin(); $res = $c->saveChannel($this->post(['kind' => 'webhook', 'name' => 'x', 'enabled' => 1, 'min_severity' => 'high', 'config' => ['url' => 'file:///etc/passwd']])); $this->assertSame(400, $res->status()); } public function testEmailChannelNeedsAValidRecipient(): void { $c = $this->admin(); $res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'x', 'enabled' => 1, 'min_severity' => 'high', 'config' => ['to' => 'not-an-email']])); $this->assertSame(400, $res->status()); } // ================================================================ suppression private function suppression(string $role = 'operator', ?string $site = null, ?InMemorySuppressionRepository $repo = null, ?InMemoryEventRepository $events = null): SuppressionController { $ev = $events ?? new InMemoryEventRepository(); return new SuppressionController( $repo ?? new InMemorySuppressionRepository(), $ev, new InMemoryDeviceRepository(['pi-1' => 's'], ['pi-1' => 1]), new InMemoryAuditRepository(), $this->sess($role, $site) ); } public function testSuppressionRequiresAReason(): void { $res = $this->suppression()->create($this->post( ['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 30, 'reason' => ''])); $this->assertSame(400, $res->status()); $this->assertSame('reason_required', json_decode($res->body(), true)['error']); } public function testSuppressionCannotBeOpenEnded(): void { $c = $this->suppression(); // 8시간(한 교대) 상한. 그 이상은 의도적인 재발급을 강제한다. $this->assertSame(400, $c->create($this->post(['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 60 * 24, 'reason' => '정비']))->status()); $this->assertSame(201, $c->create($this->post(['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 60 * 4, 'reason' => '프레스 금형 교체']))->status()); } public function testViewerCannotSuppress(): void { $this->assertSame(403, $this->suppression('viewer')->create($this->post( ['scope_type' => 'device', 'scope_id' => 'pi-1', 'minutes' => 30, 'reason' => '정비']))->status()); } public function testUnknownScopeTargetIsRejected(): void { $res = $this->suppression()->create($this->post( ['scope_type' => 'device', 'scope_id' => 'pi-nope', 'minutes' => 30, 'reason' => '정비'])); $this->assertSame(400, $res->status()); } public function testRevokeIsScoped(): void { $repo = new InMemorySuppressionRepository(); $id = $repo->create(['scope_type' => 'device', 'scope_id' => 'pi-1', 'site' => 'factory-a', 'reason' => 'x', 'starts_at' => time() - 10, 'ends_at' => time() + 600, 'created_by' => 'a', 'created_at' => time()]); $c = $this->suppression('operator', 'factory-b', $repo); $this->assertSame(404, $c->revoke($this->post([]), ['id' => $id])->status()); } // ================================================================ compliance private function compliance(string $role, ?string $site, InMemoryEventRepository $events, ?InMemoryRiskControlRepository $risks = null, ?InMemoryHandoverRepository $handovers = null): ComplianceController { return new ComplianceController( $events, new InMemorySnapshotRepository(), new InMemoryActionRepository(), new InMemoryAlertRepository(), $risks ?? new InMemoryRiskControlRepository(), $handovers ?? new InMemoryHandoverRepository(), new InMemoryAuditRepository(), $this->sess($role, $site) ); } public function testIncidentPackageCarriesTheSurroundingTimeline(): void { $events = new InMemoryEventRepository(); $at = (float)time(); $before = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'warning', 'occurred_at' => $at - 300, 'metrics' => []]); $main = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => $at, 'metrics' => []]); $after = $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'no_motion', 'severity' => 'critical', 'occurred_at' => $at + 30, 'metrics' => []]); // 다른 카메라의 동시 사건은 들어오면 안 된다. $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-2', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => $at, 'metrics' => []]); $res = $this->compliance('operator', null, $events)->package($this->get(), ['id' => $main]); $this->assertSame(200, $res->status()); $data = json_decode($res->body(), true); $ids = array_column($data['timeline'], 'event_id'); $this->assertSame([$before, $main, $after], $ids, '같은 카메라의 전후 맥락만, 시간순으로'); } public function testPackageHidesAnotherSitesIncidentAs404(): void { $events = new InMemoryEventRepository(); $id = $events->insert(['device_id' => 'pi-1', 'site' => 'factory-a', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => (float)time(), 'metrics' => []]); $res = $this->compliance('operator', 'factory-b', $events)->package($this->get(), ['id' => $id]); $this->assertSame(404, $res->status()); } public function testRiskControlNeedsComplianceRole(): void { $events = new InMemoryEventRepository(); $body = $this->post(['event_type' => 'fall', 'hazard' => '단차 전도', 'control' => '매트 설치', 'severity_rating' => 5, 'likelihood_rating' => 3]); $this->assertSame(403, $this->compliance('operator', null, $events)->saveRisk($body)->status()); $this->assertSame(200, $this->compliance('admin', null, $events)->saveRisk($body)->status()); } public function testRiskRatingIsBounded(): void { $events = new InMemoryEventRepository(); $res = $this->compliance('admin', null, $events)->saveRisk($this->post( ['event_type' => 'fall', 'hazard' => 'h', 'control' => 'c', 'severity_rating' => 9, 'likelihood_rating' => 3])); $this->assertSame(400, $res->status()); } public function testHandoverCountsAreComputedServerSideNotTakenFromTheRequest(): void { $events = new InMemoryEventRepository(); $now = (float)time(); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'occurred_at' => $now - 60, 'metrics' => []]); $events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'zone_warning', 'severity' => 'warning', 'occurred_at' => $now - 30, 'metrics' => []]); $handovers = new InMemoryHandoverRepository(); $c = $this->compliance('operator', null, $events, null, $handovers); // 브라우저가 0 을 주장해도 서버가 다시 센다. $res = $c->acceptHandover($this->post(['note' => '인수', 'unacked_count' => 0, 'severe_count' => 0])); $this->assertSame(201, $res->status()); $row = $handovers->recent(null, 1)[0]; $this->assertSame(2, $row['unacked_count']); $this->assertSame(1, $row['severe_count']); $this->assertSame('관리자', $row['to_user']); } }