move_detect/server/tests/RotateCredentialsTest.php
유창욱 6b6f29c447 feat: 납품 잔여 3건 해소 (Pi 번들 · MoveNet 폴백 · 자격증명 회전)
Pi 번들 — arm64 빌드 머신이 필요하다고 적어 둔 것은 과한 가정이었다.
--cross 모드에서 pip 이 태그로 aarch64/cp311 휠을 받으므로 네트워크만
있으면 된다. 휠 11개 전부 aarch64. "Pi 번들 미빌드"로 스킵되던 테스트가
이제 실제로 돈다.

MoveNet — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체하고
sha256+size 로 고정했다. 고정 전에 어댑터 계약과 대조했다: 입력
[1,192,192,3] int32 NHWC, 출력 [1,1,17,3]. 레이아웃이 다른 변환본은
onnxruntime 에 그냥 로드되고 좌표만 틀리는 실패라, 파일이 바뀌었는지가
아니라 계약이 맞는지를 테스트로 박았다.

자격증명 회전 — 손으로 적던 SQL 절차를 명령 하나로 바꿨다. DB 에 접속하지
않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로 접속 정보 없이
어느 머신에서나 돌아야 한다). 비밀번호는 STDIN 으로만 받는다 — 명령행은
ps 와 셸 히스토리에 남는다. 테스트가 잠그는 것은 서버 키와 Pi 키의 일치,
그리고 앱이 그 해시를 검증하는지 두 가지다. 회전 실행 자체는 운영 계정
조작이라 저장소에서 수행하지 않는다.

검증: phpunit 276 passed(스킵 0, 신규 7) · pytest 스킵 2(환경)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-21 10:47:16 +09:00

111 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Security\Password;
use PHPUnit\Framework\TestCase;
/**
* 자격증명 회전 도구.
*
* 이 도구의 실패는 조용하다. 회전은 사고 없이 몇 달에 한 번 하는 일이라, 출력이 틀려도
* 알아차릴 기회가 없다. 특히 두 가지가 치명적이다:
*
* 서버 SQL 의 키와 Pi 에 넣으라는 키가 다르면 — 그 장치의 이벤트가 전부 거부된다.
* 사람이 쓰러져도 서버까지 오지 않는데, 대시보드는 device_fault 하나만 보여준다.
* 해시가 앱의 검증과 안 맞으면 — 운영자가 대시보드에서 잠긴다.
*
* 둘 다 "실행했고 SQL 도 돌았는데 안 된다" 로 나타나므로, 출력 그대로를 검사한다.
*/
final class RotateCredentialsTest extends TestCase
{
private const TOOL = __DIR__ . '/../tools/rotate_credentials.php';
/** @return array{0:int,1:string,2:string} [exit, stdout, stderr] */
private function tool(array $args, string $stdin = ''): array
{
$cmd = array_merge([PHP_BINARY, self::TOOL], $args);
$spec = [0 => ['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);
}
}
}