['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $p = proc_open($cmd, $spec, $pipes); $this->assertIsResource($p, 'rotate_credentials.php 를 실행하지 못했습니다'); fwrite($pipes[0], $stdin); fclose($pipes[0]); $out = (string)stream_get_contents($pipes[1]); $err = (string)stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); return [proc_close($p), $out, $err]; } public function test_device_rotation_puts_the_same_key_on_both_sides(): void { [$code, $out] = $this->tool(['--device=pi-01']); $this->assertSame(0, $code); $this->assertSame(1, preg_match( "/UPDATE move_devices SET secret = '([0-9a-f]{64})' WHERE device_id = 'pi-01';/", $out, $sql)); $this->assertSame(1, preg_match('/DEVICE_SECRET=([0-9a-f]{64})\|/', $out, $env)); $this->assertSame($sql[1], $env[1], '서버에 넣는 키와 Pi 에 넣는 키가 다릅니다 — 그 장치의 이벤트는 전부 거부됩니다.'); } public function test_each_run_mints_a_fresh_key(): void { [, $a] = $this->tool(['--device=pi-01']); [, $b] = $this->tool(['--device=pi-01']); $this->assertNotSame($a, $b, '회전인데 같은 값이 나오면 회전이 아닙니다.'); } public function test_device_id_must_be_an_identifier(): void { // device_id 는 운영자가 타이핑하는 값이고 SQL 문자열로 들어간다. foreach (["pi-01'; DROP TABLE move_devices; --", "pi 01", str_repeat('a', 65), ""] as $bad) { [$code, $out] = $this->tool(["--device={$bad}"]); $this->assertNotSame(0, $code, "거부해야 할 device_id 를 통과시켰습니다: {$bad}"); $this->assertStringNotContainsString('UPDATE', $out); } } public function test_admin_hash_is_what_the_app_will_verify(): void { $plain = 'correct-horse-battery-staple'; [$code, $out] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n"); $this->assertSame(0, $code); $this->assertSame(1, preg_match( "/SET password_hash = '([^']+)', failed_attempts = 0, locked_until = 0/", $out, $m)); $this->assertTrue(Password::verify($plain, $m[1]), '앱이 검증하지 못하는 해시입니다 — 운영자가 대시보드에서 잠깁니다.'); // 잠금 해제까지 같이 하지 않으면, 비번을 잊어 잠긴 계정은 새 비번으로도 못 들어온다. $this->assertStringContainsString('failed_attempts = 0', $out); } public function test_the_plaintext_password_is_never_printed(): void { $plain = 'correct-horse-battery-staple'; [, $out, $err] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n"); $this->assertStringNotContainsString($plain, $out); $this->assertStringNotContainsString($plain, $err); } public function test_mismatch_and_short_passwords_are_refused(): void { foreach ([["aaaaaaaaaaaa\nbbbbbbbbbbbb\n", '다릅니다'], ["short\nshort\n", '미만']] as [$in, $why]) { [$code, $out, $err] = $this->tool(['--admin=admin'], $in); $this->assertNotSame(0, $code, "거부해야 할 입력을 통과시켰습니다: {$why}"); $this->assertStringNotContainsString('UPDATE', $out); $this->assertStringContainsString($why, $err); } } public function test_it_refuses_to_guess_which_credential_to_rotate(): void { foreach ([[], ['--device=pi-01', '--admin=admin']] as $args) { [$code, $out] = $this->tool($args); $this->assertNotSame(0, $code); $this->assertStringNotContainsString('UPDATE', $out); } } }