"""실제 승인/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()