181 lines
8.3 KiB
Python
181 lines
8.3 KiB
Python
"""실제 승인/ZIP을 임시 사본에서 변조해 fail-closed 릴리스 계약을 검사한다."""
|
|||
|
|
import copy
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
import shutil
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
|
||
|
|
from scripts import release_approval as gate
|
||
|
|
|
||
|
|
|
||
|
|
class ReleaseApprovalTests(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.temp = tempfile.TemporaryDirectory()
|
||
|
|
self.addCleanup(self.temp.cleanup)
|
||
|
|
self.root = Path(self.temp.name)
|
||
|
|
# 실제 증거에 쓰지 않는다. 자동 성공 fixture도 이 임시 사본에만 존재한다.
|
||
|
|
for name in (gate.APPROVAL, gate.ASR, gate.PRIOR_ASR, gate.BUNDLE,
|
||
|
|
*(f"public/ambience/{name}" for name in gate.SCOPE["files"])):
|
||
|
|
target = self.root / name
|
||
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
shutil.copyfile(gate.ROOT / name, target)
|
||
|
|
self.approval = gate.read_json(self.root / gate.APPROVAL)
|
||
|
|
|
||
|
|
def write(self, path, value):
|
||
|
|
(self.root / path).write_text(json.dumps(value, ensure_ascii=False) + "\n")
|
||
|
|
|
||
|
|
def rejected(self):
|
||
|
|
with self.assertRaises((ValueError, KeyError, TypeError, OSError)):
|
||
|
|
gate.validate_release(self.root)
|
||
|
|
|
||
|
|
def synthetic_empty_screening(self):
|
||
|
|
"""OR의 자동 분기 검사용 fixture. 실제 보고서는 절대 수정하지 않는다."""
|
||
|
|
screening = gate.read_json(self.root / gate.ASR)
|
||
|
|
for row in screening["files"]:
|
||
|
|
row["segments"] = []
|
||
|
|
row["text"] = ""
|
||
|
|
row["info"]["duration_after_vad"] = 0
|
||
|
|
screening["requiredEmptyPassed"] = True
|
||
|
|
self.write(gate.ASR, screening)
|
||
|
|
|
||
|
|
def test_current_asr_failure_passes_only_through_exact_owner_approval(self):
|
||
|
|
result = gate.validate_release(self.root)
|
||
|
|
self.assertEqual(result["acceptancePath"], "exact-byte-owner-listening-approval")
|
||
|
|
self.assertFalse(result["automatedEmptyAsrPassed"])
|
||
|
|
self.assertTrue(result["humanListeningAcceptance"])
|
||
|
|
self.assertFalse(result["legalPrivacyClearance"])
|
||
|
|
(self.root / gate.APPROVAL).unlink()
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_automated_empty_criteria_can_pass_without_owner_approval(self):
|
||
|
|
self.synthetic_empty_screening()
|
||
|
|
(self.root / gate.APPROVAL).unlink()
|
||
|
|
result = gate.validate_release(self.root)
|
||
|
|
self.assertEqual(result["acceptancePath"], "automated-empty-asr")
|
||
|
|
self.assertFalse(result["humanListeningAcceptance"])
|
||
|
|
self.assertFalse(result["legalPrivacyClearance"])
|
||
|
|
|
||
|
|
def test_every_missing_approval_field_fails(self):
|
||
|
|
for key in self.approval:
|
||
|
|
with self.subTest(key=key):
|
||
|
|
changed = copy.deepcopy(self.approval)
|
||
|
|
del changed[key]
|
||
|
|
self.write(gate.APPROVAL, changed)
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_statement_owner_scope_timestamp_and_clearance_cannot_be_reinterpreted(self):
|
||
|
|
mutations = {
|
||
|
|
"owner": ["Neo", "oz", ""],
|
||
|
|
"statement": ["승인합니다", "승인합니다. 이대로 진행하세요 ", ""],
|
||
|
|
"timestampUtc": ["", "not-a-date", "2026-10-14T11:29:50Z"],
|
||
|
|
"timestampKst": ["", "2026-10-14T11:29:51+09:00"],
|
||
|
|
"scope": [{}, {**gate.SCOPE, "version": "0.2.3"}, {**gate.SCOPE, "files": ["market.wav"]}],
|
||
|
|
"productOwnerReleaseApproval": [False, "true", 1],
|
||
|
|
"humanListeningAcceptance": [False, "true", 1],
|
||
|
|
"legalPrivacyClearance": [True, "false", 0, None],
|
||
|
|
}
|
||
|
|
for key, values in mutations.items():
|
||
|
|
for value in values:
|
||
|
|
with self.subTest(key=key, value=value):
|
||
|
|
self.write(gate.APPROVAL, {**self.approval, key: value})
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_approval_hash_mismatches_and_incomplete_asset_scope_fail(self):
|
||
|
|
for field in ("reviewBundle", "manifest", "notice"):
|
||
|
|
with self.subTest(field=field):
|
||
|
|
changed = copy.deepcopy(self.approval)
|
||
|
|
changed[field]["sha256"] = "0" * 64
|
||
|
|
self.write(gate.APPROVAL, changed)
|
||
|
|
self.rejected()
|
||
|
|
for index in range(8):
|
||
|
|
with self.subTest(wav=index):
|
||
|
|
changed = copy.deepcopy(self.approval)
|
||
|
|
changed["wavs"][index]["sha256"] = "0" * 64
|
||
|
|
self.write(gate.APPROVAL, changed)
|
||
|
|
self.rejected()
|
||
|
|
for wavs in (self.approval["wavs"][:-1], self.approval["wavs"] + self.approval["wavs"][:1]):
|
||
|
|
self.write(gate.APPROVAL, {**self.approval, "wavs": wavs})
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_current_wavs_manifest_notice_and_bundle_are_bound_to_reviewed_bytes(self):
|
||
|
|
for name in (gate.BUNDLE, *(f"public/ambience/{name}" for name in gate.SCOPE["files"])):
|
||
|
|
with self.subTest(file=name):
|
||
|
|
path = self.root / name
|
||
|
|
original = path.read_bytes()
|
||
|
|
path.write_bytes(original + b" ")
|
||
|
|
self.rejected()
|
||
|
|
path.write_bytes(original)
|
||
|
|
|
||
|
|
def test_manifest_reformat_cannot_be_self_approved_by_updating_all_recorded_hashes(self):
|
||
|
|
path = self.root / gate.MANIFEST
|
||
|
|
path.write_bytes(path.read_bytes() + b"\n")
|
||
|
|
digest = gate.sha256(path.read_bytes())
|
||
|
|
self.approval["manifest"]["sha256"] = digest
|
||
|
|
self.write(gate.APPROVAL, self.approval)
|
||
|
|
screening = gate.read_json(self.root / gate.ASR)
|
||
|
|
screening["method"]["manifestSha256"] = digest
|
||
|
|
self.write(gate.ASR, screening)
|
||
|
|
self.rejected() # 전달 ZIP의 manifest 바이트는 여전히 다르다.
|
||
|
|
|
||
|
|
def test_wav_change_cannot_be_self_approved_by_updating_manifest_asr_and_approval(self):
|
||
|
|
path = self.root / "public/ambience/market.wav"
|
||
|
|
raw = bytearray(path.read_bytes())
|
||
|
|
raw[100] ^= 1
|
||
|
|
path.write_bytes(raw)
|
||
|
|
digest = gate.sha256(raw)
|
||
|
|
manifest = gate.read_json(self.root / gate.MANIFEST)
|
||
|
|
screening = gate.read_json(self.root / gate.ASR)
|
||
|
|
for rows in (manifest["files"], screening["files"], self.approval["wavs"]):
|
||
|
|
next(row for row in rows if row["environmentId"] == "market")["sha256"] = digest
|
||
|
|
self.write(gate.MANIFEST, manifest)
|
||
|
|
manifest_hash = gate.sha256((self.root / gate.MANIFEST).read_bytes())
|
||
|
|
screening["method"]["manifestSha256"] = manifest_hash
|
||
|
|
self.approval["manifest"]["sha256"] = manifest_hash
|
||
|
|
self.write(gate.ASR, screening)
|
||
|
|
self.write(gate.APPROVAL, self.approval)
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_different_bundle_hash_cannot_be_self_approved(self):
|
||
|
|
path = self.root / gate.BUNDLE
|
||
|
|
path.write_bytes(path.read_bytes() + b" ")
|
||
|
|
self.approval["reviewBundle"]["sha256"] = gate.sha256(path.read_bytes())
|
||
|
|
self.write(gate.APPROVAL, self.approval)
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_invalid_approval_is_not_ignored_even_when_automated_criteria_pass(self):
|
||
|
|
self.synthetic_empty_screening()
|
||
|
|
for key, value in (("legalPrivacyClearance", True), ("scope", {}), ("timestampUtc", "")):
|
||
|
|
self.write(gate.APPROVAL, {**self.approval, key: value})
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_nonempty_asr_cannot_be_relabeled_pass(self):
|
||
|
|
screening = gate.read_json(self.root / gate.ASR)
|
||
|
|
screening["requiredEmptyPassed"] = True
|
||
|
|
self.write(gate.ASR, screening)
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_stale_asr_settings_and_clearance_overclaims_fail_with_valid_owner_approval(self):
|
||
|
|
original = gate.read_json(self.root / gate.ASR)
|
||
|
|
for field, key, value in (("method", "manifestSha256", "0" * 64),
|
||
|
|
("method", "parameters", {}),
|
||
|
|
("limitations", "legalPrivacyClearance", True),
|
||
|
|
("limitations", "textIsVerifiedTranscript", True)):
|
||
|
|
changed = copy.deepcopy(original)
|
||
|
|
changed[field][key] = value
|
||
|
|
self.write(gate.ASR, changed)
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
def test_unknown_clearance_claim_and_duplicate_json_keys_fail(self):
|
||
|
|
self.write(gate.APPROVAL, {**self.approval, "legalClearance": True})
|
||
|
|
self.rejected()
|
||
|
|
raw = json.dumps(self.approval)
|
||
|
|
(self.root / gate.APPROVAL).write_text(raw[:-1] + ', "legalPrivacyClearance": false}')
|
||
|
|
self.rejected()
|
||
|
|
(self.root / gate.APPROVAL).write_text('{"statement":')
|
||
|
|
self.rejected()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|