실측으로 찾았다. 공개 런타임은 119 paths 구버전이고 /admin/voice-runtime 이 배포돼 있지 않다. capture-g7-runtime-evidence.py 는 정확히 그 경로만 부르므로 artifact 2 를 만들 수 없다. 즉 마이크와 사람을 다 준비해도 오늘 실행하면 50분을 버리고 실패한다. 현재 소스에는 있다(admin.py:1699, 프리뷰 126 paths). 오케스트레이터가 시작 전에 이걸 검사하고 admin_voice_runtime_not_deployed 로 즉시 멈춘다. 공개 런타임 대상 --rehearse 가 exit 2 로 몇 초 만에 차단됐다. 판정 방식을 한 번 고쳤다. 처음엔 미인증 404/401 상태로 짰는데, Cloudflare 앞단이 자동화 클라이언트에게 존재하지 않는 경로까지 포함해 모든 경로를 403(error code 1010)으로 돌려주는 것을 확인했다. 그 신호로는 "배포 누락"과 "edge 차단"을 구분할 수 없어 작동하는 것처럼 보이지만 무의미한 검사였다. OpenAPI spec 의 paths 만 authoritative 하게 쓰도록 바꾸고 그 이유를 테스트로 남겼다. G7 실행 순서가 이렇게 확정된다. 1. current source 를 공개 런타임에 승격(비-secure origin crypto.randomUUID 수정 포함) 2. --rehearse 로 배관 확인 3. 동의 하 물리 마이크 50분 + human pack 으로 실제 실행 검증: 오케스트레이터 25/25, SSOT FAIL 0, dashboard E2E 10/10, ruff clean.
274 lines
11 KiB
Python
274 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
|
|
SCRIPT_PATH = Path(__file__).with_name("run-g7-external-proof-window.py")
|
|
SPEC = importlib.util.spec_from_file_location("run_g7_external_proof_window", SCRIPT_PATH)
|
|
assert SPEC is not None and SPEC.loader is not None
|
|
MODULE = importlib.util.module_from_spec(SPEC)
|
|
sys.modules[SPEC.name] = MODULE
|
|
SPEC.loader.exec_module(MODULE)
|
|
|
|
|
|
def args(**overrides):
|
|
base = {
|
|
"wss_url": "wss://api.example.test/voice/ws",
|
|
"origin": "https://api.example.test",
|
|
"admin_runtime_url": "https://api.example.test/admin/voice-runtime",
|
|
"compose_project": "vignette",
|
|
"api_container": "vignette-api-1",
|
|
"api_image_digest": "sha256:" + "a" * 64,
|
|
"caddy_container": "vignette-proxy-1",
|
|
"caddy_image_digest": "sha256:" + "b" * 64,
|
|
"expected_stt_provider": "local_whisper",
|
|
"expected_stt_model": "large-v3",
|
|
"expected_tts_provider": "melotts",
|
|
"expected_tts_model": "melotts-korean",
|
|
"microphone_device": "",
|
|
"confirm_physical_capture": False,
|
|
"duration_seconds": 3_000.0,
|
|
"runtime_interval_seconds": 100.0,
|
|
"topology_interval_seconds": 100.0,
|
|
"human_voice_gain": Path("pack.json"),
|
|
"out_dir": Path("out"),
|
|
"rehearse": False,
|
|
}
|
|
base.update(overrides)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
class FakeCompleted:
|
|
def __init__(self, returncode: int) -> None:
|
|
self.returncode = returncode
|
|
self.stdout = ""
|
|
self.stderr = ""
|
|
|
|
|
|
class HostAlignmentTest(unittest.TestCase):
|
|
def test_matching_hosts_are_accepted(self) -> None:
|
|
host = MODULE.assert_single_host(
|
|
"wss://api.example.test/voice/ws",
|
|
"https://api.example.test",
|
|
"https://api.example.test/admin/voice-runtime",
|
|
)
|
|
self.assertEqual(host, "api.example.test")
|
|
|
|
def test_mismatched_hosts_fail_closed(self) -> None:
|
|
with self.assertRaises(MODULE.WindowError):
|
|
MODULE.assert_single_host(
|
|
"wss://api.example.test/voice/ws", "https://other.example.test"
|
|
)
|
|
|
|
def test_plan_rejects_a_split_window(self) -> None:
|
|
with self.assertRaises(MODULE.WindowError):
|
|
MODULE.plan_legs(
|
|
args(admin_runtime_url="https://elsewhere.test/admin/voice-runtime",
|
|
microphone_device="mic", confirm_physical_capture=True),
|
|
Path("out"),
|
|
admin_probe=lambda origin: ["/admin/voice-runtime"],
|
|
)
|
|
|
|
|
|
class SamplePlanTest(unittest.TestCase):
|
|
def test_samples_cover_the_whole_window(self) -> None:
|
|
self.assertEqual(MODULE.sample_plan(3_000.0, 100.0), 31)
|
|
|
|
def test_invalid_plan_fails_closed(self) -> None:
|
|
for duration, interval in ((0, 100.0), (3_000.0, 0)):
|
|
with self.subTest(duration=duration, interval=interval):
|
|
with self.assertRaises(MODULE.WindowError):
|
|
MODULE.sample_plan(duration, interval)
|
|
|
|
|
|
class ConsentGateTest(unittest.TestCase):
|
|
def test_production_soak_requires_device_and_confirmation(self) -> None:
|
|
for device, confirm in (("", True), ("mic", False), ("", False)):
|
|
with self.subTest(device=device, confirm=confirm):
|
|
with self.assertRaises(MODULE.WindowError) as ctx:
|
|
MODULE.build_soak_leg(
|
|
args(microphone_device=device, confirm_physical_capture=confirm),
|
|
Path("soak.json"),
|
|
)
|
|
self.assertEqual(
|
|
str(ctx.exception), "physical_microphone_consent_required"
|
|
)
|
|
|
|
def test_confirmed_production_soak_passes_the_flags_through(self) -> None:
|
|
leg = MODULE.build_soak_leg(
|
|
args(microphone_device="mic-1", confirm_physical_capture=True),
|
|
Path("soak.json"),
|
|
)
|
|
self.assertEqual(leg.name, "voice_soak")
|
|
self.assertIn("--confirm-physical-capture", leg.argv)
|
|
self.assertEqual(leg.argv[leg.argv.index("--microphone-device") + 1], "mic-1")
|
|
self.assertNotIn("--preflight-only", leg.argv)
|
|
|
|
def test_rehearse_never_opens_a_microphone(self) -> None:
|
|
leg = MODULE.build_soak_leg(args(rehearse=True), Path("soak.json"))
|
|
self.assertEqual(leg.name, "voice_soak_preflight")
|
|
self.assertIn("--preflight-only", leg.argv)
|
|
self.assertNotIn("--confirm-physical-capture", leg.argv)
|
|
self.assertNotIn("--microphone-device", leg.argv)
|
|
|
|
def test_production_window_shorter_than_fifty_minutes_is_rejected(self) -> None:
|
|
with self.assertRaises(MODULE.WindowError) as ctx:
|
|
MODULE.validate(args(duration_seconds=600.0))
|
|
self.assertEqual(str(ctx.exception), "production_window_too_short")
|
|
|
|
def test_production_run_requires_the_human_pack(self) -> None:
|
|
with self.assertRaises(MODULE.WindowError) as ctx:
|
|
MODULE.validate(args(human_voice_gain=None))
|
|
self.assertEqual(str(ctx.exception), "human_voice_gain_pack_required")
|
|
|
|
def test_rehearse_relaxes_only_the_two_human_inputs(self) -> None:
|
|
MODULE.validate(args(rehearse=True, duration_seconds=30.0, human_voice_gain=None))
|
|
|
|
|
|
class LegCompositionTest(unittest.TestCase):
|
|
def test_expected_providers_default_to_the_decided_local_stack(self) -> None:
|
|
leg = MODULE.build_soak_leg(args(rehearse=True), Path("soak.json"))
|
|
self.assertEqual(
|
|
leg.argv[leg.argv.index("--expected-stt-provider") + 1], "local_whisper"
|
|
)
|
|
self.assertEqual(
|
|
leg.argv[leg.argv.index("--expected-tts-provider") + 1], "melotts"
|
|
)
|
|
|
|
def test_topology_leg_pins_the_public_host_from_the_wss_url(self) -> None:
|
|
leg = MODULE.build_topology_leg(args(), Path("topology.json"))
|
|
self.assertEqual(
|
|
leg.argv[leg.argv.index("--public-host") + 1], "api.example.test"
|
|
)
|
|
|
|
def test_all_three_legs_share_one_window_length(self) -> None:
|
|
legs = MODULE.plan_legs(
|
|
args(rehearse=True, duration_seconds=3_000.0), Path("out"),
|
|
admin_probe=lambda origin: ["/admin/voice-runtime"],
|
|
)
|
|
self.assertEqual([leg.name for leg in legs][1:], ["runtime", "topology"])
|
|
for leg in legs[1:]:
|
|
samples = int(leg.argv[leg.argv.index("--samples") + 1])
|
|
interval = float(leg.argv[leg.argv.index("--interval-seconds") + 1])
|
|
self.assertGreaterEqual(samples * interval, 3_000.0)
|
|
|
|
|
|
class WindowExecutionTest(unittest.TestCase):
|
|
def test_all_three_legs_start_before_any_finishes(self) -> None:
|
|
started: list[str] = []
|
|
|
|
def runner(argv, **kwargs):
|
|
started.append(argv[3])
|
|
return FakeCompleted(0)
|
|
|
|
legs = MODULE.plan_legs(args(rehearse=True), Path("out"), admin_probe=lambda origin: ["/admin/voice-runtime"])
|
|
results = MODULE.run_window(legs, timeout=60, runner=runner)
|
|
self.assertEqual(len(started), 3)
|
|
self.assertTrue(all(result.ok for result in results))
|
|
|
|
def test_one_failing_leg_does_not_cancel_the_others(self) -> None:
|
|
def runner(argv, **kwargs):
|
|
return FakeCompleted(1 if "capture-g7-runtime-evidence.py" in argv[3] else 0)
|
|
|
|
legs = MODULE.plan_legs(args(rehearse=True), Path("out"), admin_probe=lambda origin: ["/admin/voice-runtime"])
|
|
results = MODULE.run_window(legs, timeout=60, runner=runner)
|
|
self.assertEqual(len(results), 3)
|
|
failed = [r.name for r in results if not r.ok]
|
|
self.assertEqual(failed, ["runtime"])
|
|
|
|
|
|
class AdminEndpointPreflightTest(unittest.TestCase):
|
|
"""공개 런타임이 구버전이라 endpoint 가 없으면 50분을 버리기 전에 막는다."""
|
|
|
|
ADMIN_URL = "https://api.example.test/admin/voice-runtime"
|
|
|
|
def test_missing_endpoint_is_rejected_before_the_window_starts(self) -> None:
|
|
with self.assertRaises(MODULE.WindowError) as ctx:
|
|
MODULE.assert_admin_endpoint_deployed(
|
|
self.ADMIN_URL, paths_probe=lambda origin: ["/health", "/voice/speech"]
|
|
)
|
|
self.assertEqual(str(ctx.exception), "admin_voice_runtime_not_deployed")
|
|
|
|
def test_deployed_endpoint_is_accepted(self) -> None:
|
|
origin = MODULE.assert_admin_endpoint_deployed(
|
|
self.ADMIN_URL,
|
|
paths_probe=lambda origin: ["/health", "/admin/voice-runtime"],
|
|
)
|
|
self.assertEqual(origin, "https://api.example.test")
|
|
|
|
def test_unauthenticated_status_is_never_used_as_the_signal(self) -> None:
|
|
"""Cloudflare 는 자동화 클라이언트에 모든 경로를 403 으로 준다.
|
|
|
|
상태 코드로 판정하면 배포 누락을 통과시킨다. spec 만 본다.
|
|
"""
|
|
|
|
probed: list[str] = []
|
|
|
|
def probe(origin: str):
|
|
probed.append(origin)
|
|
return ["/admin/voice-runtime"]
|
|
|
|
MODULE.assert_admin_endpoint_deployed(self.ADMIN_URL, paths_probe=probe)
|
|
self.assertEqual(probed, ["https://api.example.test"])
|
|
|
|
def test_wrong_admin_url_path_fails_closed(self) -> None:
|
|
with self.assertRaises(MODULE.WindowError) as ctx:
|
|
MODULE.assert_admin_endpoint_deployed(
|
|
"https://api.example.test/admin/something-else",
|
|
paths_probe=lambda origin: ["/admin/voice-runtime"],
|
|
)
|
|
self.assertEqual(str(ctx.exception), "admin_runtime_url_unexpected_path")
|
|
|
|
def test_plan_refuses_a_deployment_without_the_endpoint(self) -> None:
|
|
with self.assertRaises(MODULE.WindowError) as ctx:
|
|
MODULE.plan_legs(
|
|
args(rehearse=True), Path("out"), admin_probe=lambda origin: ["/health"]
|
|
)
|
|
self.assertEqual(str(ctx.exception), "admin_voice_runtime_not_deployed")
|
|
|
|
|
|
class ReportTest(unittest.TestCase):
|
|
def _results(self, codes=(0, 0, 0)):
|
|
names = ("voice_soak", "runtime", "topology")
|
|
return [
|
|
MODULE.LegResult(name, code, Path(f"{name}.json"))
|
|
for name, code in zip(names, codes)
|
|
]
|
|
|
|
def test_rehearse_never_reports_the_gate_as_closed(self) -> None:
|
|
report = MODULE.summarize(
|
|
self._results(), rehearse=True, checker_returncode=0
|
|
)
|
|
self.assertFalse(report["gate_closed"])
|
|
self.assertEqual(report["mode"], "rehearse")
|
|
|
|
def test_production_gate_closes_only_on_checker_exit_zero(self) -> None:
|
|
closed = MODULE.summarize(
|
|
self._results(), rehearse=False, checker_returncode=0
|
|
)
|
|
open_gate = MODULE.summarize(
|
|
self._results(), rehearse=False, checker_returncode=1
|
|
)
|
|
self.assertTrue(closed["gate_closed"])
|
|
self.assertFalse(open_gate["gate_closed"])
|
|
|
|
def test_failed_leg_is_reported(self) -> None:
|
|
report = MODULE.summarize(
|
|
self._results((0, 1, 0)), rehearse=False, checker_returncode=None
|
|
)
|
|
self.assertFalse(report["all_legs_passed"])
|
|
self.assertFalse(report["gate_closed"])
|
|
|
|
def test_checker_argv_passes_all_four_artifacts(self) -> None:
|
|
argv = MODULE.build_checker_argv(self._results(), Path("pack.json"))
|
|
for flag in ("--voice-soak", "--runtime", "--topology", "--human-voice-gain"):
|
|
self.assertIn(flag, argv)
|
|
self.assertEqual(argv[argv.index("--human-voice-gain") + 1], "pack.json")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|