markTestSkipped('pdo_sqlite not available on this host'); } $this->pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $this->pdo->exec(<<<'SQL' CREATE TABLE move_audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, before_json TEXT NOT NULL, after_json TEXT NOT NULL, ts INTEGER NOT NULL, prev_hash TEXT, entry_hash TEXT ); SQL); } private function rows(): array { return $this->pdo->query('SELECT * FROM move_audit_log ORDER BY id ASC')->fetchAll(); } /** The verifier's core loop, mirrored here so the property is asserted, not just the tool. */ private function chainIsIntact(): bool { $expected = null; $started = false; foreach ($this->rows() as $r) { if ($r['entry_hash'] === null) { if ($started) return false; continue; } if ($started && $r['prev_hash'] !== $expected) return false; $started = true; $recomputed = PdoAuditRepository::hash( $r['actor'], $r['action'], $r['target'], $r['before_json'], $r['after_json'], (int)$r['ts'], $r['prev_hash'] !== null && $r['prev_hash'] !== '' ? $r['prev_hash'] : null ); if (!hash_equals((string)$r['entry_hash'], $recomputed)) return false; $expected = $r['entry_hash']; } return true; } private function appendThree(): PdoAuditRepository { $repo = new PdoAuditRepository($this->pdo); $repo->append('kim', 'acknowledge', 'event:1', [], ['ok' => true]); $repo->append('lee', 'zone_delete', 'zone:abc', ['level' => 'restricted'], []); $repo->append('kim', 'threshold_set', 'threshold:pi-1:fall_prone_s', ['value' => '3'], ['value' => '5']); return $repo; } public function testEachEntryLinksToItsPredecessor(): void { $this->appendThree(); $rows = $this->rows(); $this->assertCount(3, $rows); $this->assertNull($rows[0]['prev_hash'], '첫 행은 사슬의 시작'); $this->assertSame($rows[0]['entry_hash'], $rows[1]['prev_hash']); $this->assertSame($rows[1]['entry_hash'], $rows[2]['prev_hash']); $this->assertTrue($this->chainIsIntact()); } public function testEditingARowBreaksTheChain(): void { $this->appendThree(); // 감사 기록을 조용히 고치는 시나리오: 확인자를 바꿔치기한다. $this->pdo->exec("UPDATE move_audit_log SET actor = 'park' WHERE id = 2"); $this->assertFalse($this->chainIsIntact(), '수정이 탐지되어야 한다'); } public function testDeletingARowBreaksTheChain(): void { $this->appendThree(); $this->pdo->exec('DELETE FROM move_audit_log WHERE id = 2'); $this->assertFalse($this->chainIsIntact(), '삭제가 탐지되어야 한다'); } public function testInsertingARowWithoutTheChainIsDetected(): void { $this->appendThree(); // 애플리케이션을 우회해 직접 INSERT — 해시가 없다. $this->pdo->exec("INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts) VALUES ('ghost', 'acknowledge', 'event:99', '[]', '[]', 1)"); $this->assertFalse($this->chainIsIntact()); } public function testRowsPredatingTheChainAreNotReportedAsDamage(): void { // 마이그레이션 이전 구간: 해시 없는 행이 앞에 쌓여 있다. $this->pdo->exec("INSERT INTO move_audit_log (actor, action, target, before_json, after_json, ts) VALUES ('old', 'login', 'user:1', '[]', '[]', 1)"); $this->appendThree(); $this->assertTrue($this->chainIsIntact(), '정상 시스템이 매번 빨간불을 켜면 아무도 이 도구를 보지 않게 된다'); } public function testAppendIsSafeWhenTheCallerAlreadyOwnsATransaction(): void { $repo = new PdoAuditRepository($this->pdo); $this->pdo->beginTransaction(); $repo->append('kim', 'zone_save', 'zone:1', [], []); $this->pdo->commit(); $this->assertCount(1, $this->rows()); $this->assertTrue($this->chainIsIntact()); } public function testFieldSeparatorCannotBeForgedByAValueContainingIt(): void { // '|' 구분자였다면 actor="a|b", action="c" 와 actor="a", action="b|c" 가 같은 해시를 // 낳는다 — 감사 항목 위조가 성립한다. 0x1F 는 텍스트 필드에 나타나지 않는다. $a = PdoAuditRepository::hash('a|b', 'c', 't', '[]', '[]', 1, null); $b = PdoAuditRepository::hash('a', 'b|c', 't', '[]', '[]', 1, null); $this->assertNotSame($a, $b); } }