Files
document-haness/scripts/tests/test_studio_save.py
T
DongHyeonkaandClaude Opus 5 20c8535169 feat(studio-save): 검토로 보낸다는 것은 저장이 막힌다는 뜻이어야 한다
시도 1 이 「8건이 PASS 로 나가지 않는다」를 세웠는데 코드 3건에 대해서만 성립했다. 경고는
실렸는데 그것을 읽고 막는 코드가 없었다. 검토로 라우팅한다는 것이 실제로는 경고를 붙여서
통과시키는 것이었다. 막지 않으면 라우팅이 아니라 주석이다.

경고가 있으면 항목마다 판정을 받고 전부 PASS 일 때만 저장이 나간다. 판정 파일이 없으면
거절한다 — 검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다. 판정이 안 붙은
경고가 있어도 거절한다 — 빠뜨린 것과 통과시킨 것은 다르다.

FAIL 과 UNKNOWN 은 둘 다 막되 문구가 갈린다. 근거가 모자라 판정을 못 한 것과 근거를 읽고
틀렸다고 본 것은 다음에 할 일이 다르다.

판정이 어느 경고에 붙은 것인지 정하려고 경고에 키를 붙였고, 그 키를 읽는 코드를 같은
변경에 넣었다. 내용이 바뀌면 키도 바뀌어 옛 판정이 다른 경고에 붙지 않는다.

경고가 없으면 판정 파일 없이 그대로 나간다. 정상은 이 게이트에 안 걸린다 — 채택 편집
100쌍에서 91쌍이 판정 없이 나가고 9쌍이 판정을 받아야 한다. 판정을 받아야 한다는 것은
막힌다는 뜻이 아니다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 15:53:26 +09:00

393 lines
18 KiB
Python

"""Studio 저장 어댑터.
이 묶음의 절반은 **대조군**이다. 손대지 않은 쌍이 「같다」로 나오는 것을 먼저 확인하지 않으면,
결함을 잡았다는 결과가 비교 틀이 만들어 낸 것인지 알 수 없다.
"""
import importlib.util
import json
import os
import subprocess
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_spec = importlib.util.spec_from_file_location(
"studio_save", os.path.join(ROOT, "scripts", "studio-save.py"))
ss = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ss)
SENT = {
"kind": "CASE",
"title": "파이프 뒤의 종료 코드",
"slug": "exit-code-read-behind-a-pipe",
"summary": "열넷 중 둘이 exit 1 이었다.",
"problem": "이 기록의 증거 다섯 개도 그렇게 수집했다.",
"bodyMarkdown": "본문이 길다. " * 40,
"topicId": None,
"projectId": None,
"relations": [],
}
class ComparatorControlTest(unittest.TestCase):
"""비교 틀이 한쪽으로 굳어 있지 않은지."""
def test_an_untouched_pair_compares_equal(self):
"""대조군. 이것이 실패하면 아래의 모든 「잡았다」가 틀 탓이다."""
r = ss.compare_saved(SENT, dict(SENT))
self.assertTrue(r["same"], r)
self.assertEqual([], r["differences"])
self.assertEqual([], r["whitespaceOnly"])
def test_a_korean_numeral_change_is_caught(self):
"""「여섯 → 다섯」. check-preservation 은 아라비아 숫자만 봐서 이걸 못 잡는다.
저장 대조는 칸 전체를 견주므로 잡힌다. 두 검사기가 보는 것이 다르다.
"""
r = ss.compare_saved(SENT, {**SENT, "problem": "이 기록의 증거 여섯 개도 그렇게 수집했다."})
self.assertFalse(r["same"])
self.assertEqual(["problem"], [d["field"] for d in r["differences"]])
def test_a_silently_truncated_value_is_named_as_truncation(self):
"""Studio 는 제목·본문을 조용히 자른 적이 있다. 바뀐 것과 잘린 것은 다르게 읽힌다."""
short = SENT["bodyMarkdown"][:60]
r = ss.compare_saved(SENT, {**SENT, "bodyMarkdown": short})
self.assertFalse(r["same"])
self.assertEqual(1, len(r["truncated"]))
self.assertGreater(r["truncated"][0]["sentChars"], r["truncated"][0]["gotChars"])
def test_a_whitespace_only_difference_is_not_counted_as_same(self):
"""정규화가 지운 차이를 통과로 세지 않는다."""
r = ss.compare_saved(SENT, {**SENT, "title": SENT["title"] + " "})
self.assertFalse(r["same"])
self.assertEqual(["title"], [w["field"] for w in r["whitespaceOnly"]])
self.assertEqual([], r["differences"])
def test_fields_it_cannot_compare_are_named_not_skipped(self):
"""못 본 칸을 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다."""
r = ss.compare_saved(SENT, dict(SENT))
self.assertEqual({"topicId", "projectId", "relations"}, set(r["notCompared"]))
self.assertNotIn("topicId", r["comparedFields"])
def test_a_field_the_server_added_is_reported(self):
r = ss.compare_saved(SENT, {**SENT, "publishedAt": "2026-09-10"})
self.assertIn("publishedAt", r["unexpected"])
SVG_HEAD = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"'
def _svg(tmp, body):
f = os.path.join(tmp, "f.svg")
open(f, "w", encoding="utf-8").write(body)
return f
class SvgPolicyTest(unittest.TestCase):
"""`plan/01` §8 의 「저장 단계의 허용 정책」.
화살표 방향은 사람이 봐야 하지만 스크립트가 들어 있는지는 문자열로 판정된다.
**그림 쪽에서 기계가 할 수 있는 유일한 일이라 error 로 막는다.**
"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
def test_an_ordinary_figure_passes(self):
"""대조군. 이것이 막히면 아래의 모든 「막았다」가 정상을 막는 것이다."""
hits, kind = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + '><rect/><text>Guest</text></svg>'))
self.assertEqual([], hits)
self.assertIsNone(kind)
def test_a_figure_declared_illustrative_passes(self):
"""설명용이라 밝힌 그림을 막지 않는다.
막으면 V-004 에서 채택된 편집 9건을 막은 것과 같은 실패다. 밝힌 대로 읽히는지는
검토가 본다.
"""
hits, kind = ss.inspect_svg(
_svg(self.tmp, SVG_HEAD + ' data-figure-kind="illustrative"><rect/></svg>'))
self.assertEqual([], hits)
self.assertEqual("illustrative", kind)
def test_a_figure_declared_evidence_is_read_as_evidence(self):
_, kind = ss.inspect_svg(
_svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence"><rect/></svg>'))
self.assertEqual("evidence", kind)
def test_dangerous_shapes_are_blocked(self):
cases = {
"script": "><script>alert(1)</script></svg>",
"foreignObject": "><foreignObject><b>x</b></foreignObject></svg>",
"event handler": ' onload="x()"><rect/></svg>',
"external href": '><a href="https://evil.test/x">t</a></svg>',
"protocol relative": '><a xlink:href="//evil.test/x">t</a></svg>',
"external image": '><image href="https://evil.test/a.png"/></svg>',
"css import": "><style>@import url(x.css);</style></svg>",
}
for name, tail in cases.items():
with self.subTest(name=name):
hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail))
self.assertTrue(hits, f"{name} 이 통과했다")
def test_internal_and_data_references_are_not_blocked(self):
"""`#fragment` 와 `data:` 는 바깥으로 안 나간다. 막으면 정상을 막는 것이다."""
for tail in ('><use href="#box"/></svg>',
'><image href="data:image/png;base64,AA"/></svg>'):
with self.subTest(tail=tail):
hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail))
self.assertEqual([], hits)
def test_every_figure_in_this_repository_passes(self):
"""지금 저장소의 그림 전부가 통과해야 한다. 하나라도 막히면 정책이 과하다."""
import glob
files = glob.glob(os.path.join(ROOT, "docs/*/final/assets/**/*.svg"), recursive=True)
self.assertGreater(len(files), 100, "그림을 못 찾았다 — 이 대조가 무의미해진다")
blocked = [f for f in files if ss.inspect_svg(f)[0]]
self.assertEqual([], blocked)
class AssetWarningTest(unittest.TestCase):
"""바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다."""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.svg = _svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence"><rect/></svg>')
self.rel = os.path.relpath(self.svg, ROOT)
def _pkg(self, sha):
return {"assets": [{"path": self.rel, "exists": True, "sha256": sha}]}
def test_an_unchanged_figure_raises_nothing(self):
"""대조군."""
e, w = ss.inspect_assets(self._pkg(ss._sha256_file(self.svg)))
self.assertEqual([], e)
self.assertEqual([], w)
def test_a_figure_changed_after_review_is_a_warning_not_an_error(self):
"""화살표를 뒤집어도 관문은 전부 0 이다. 그것이 보이지 않던 자리다."""
e, w = ss.inspect_assets(self._pkg("0" * 64))
self.assertEqual([], e)
self.assertEqual(["그림이 검토 뒤에 바뀌었다"], [x["id"] for x in w])
def test_a_figure_without_a_kind_marker_is_a_warning(self):
plain = _svg(self.tmp, SVG_HEAD + "><rect/></svg>")
rel = os.path.relpath(plain, ROOT)
e, w = ss.inspect_assets(
{"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(plain)}]})
self.assertEqual([], e)
self.assertIn("그림 종류가 표시돼 있지 않다", [x["id"] for x in w])
def test_a_dangerous_figure_is_an_error(self):
bad = _svg(self.tmp, SVG_HEAD + "><script>x</script></svg>")
rel = os.path.relpath(bad, ROOT)
e, _ = ss.inspect_assets(
{"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(bad)}]})
self.assertEqual(["그림 허용 정책"], [x["id"] for x in e])
class IdempotencyKeyTest(unittest.TestCase):
def test_create_key_is_stable_across_retries(self):
"""재시도가 문서를 둘 만들지 않게 하는 것이 이 키의 목적이다."""
a = ss.idempotency_key("create", "docs/p/tech-log-studio/t/case/x.md")
b = ss.idempotency_key("create", "docs/p/tech-log-studio/t/case/x.md")
self.assertEqual(a, b)
def test_create_keys_differ_per_record(self):
self.assertNotEqual(
ss.idempotency_key("create", "docs/p/t/case/x.md"),
ss.idempotency_key("create", "docs/p/t/case/y.md"))
def test_save_key_follows_the_payload(self):
"""같은 내용의 재시도는 재생되고, 내용이 바뀌면 새 저장이 된다.
경로만으로 키를 만들면 두 번째 저장이 첫 결과로 조용히 재생돼 버린다.
"""
rel = "docs/p/t/case/x.md"
one = {"expectedVersion": 3, "document": {"title": "가"}}
two = {"expectedVersion": 3, "document": {"title": "나"}}
self.assertEqual(ss.idempotency_key("save", rel, one),
ss.idempotency_key("save", rel, one))
self.assertNotEqual(ss.idempotency_key("save", rel, one),
ss.idempotency_key("save", rel, two))
def test_key_stays_within_the_server_limit(self):
rel = "docs/" + "a" * 300 + "/x.md"
key = ss.idempotency_key("save", rel, {"expectedVersion": 1, "document": SENT})
self.assertLessEqual(len(key), ss.MAX_KEY_LENGTH)
class SafetyTest(unittest.TestCase):
def test_no_plan_may_contain_a_publish_path(self):
"""게시는 되돌릴 수 없다. 프롬프트가 아니라 코드가 막는다."""
with self.assertRaises(ss.Refused):
ss._reject_forbidden_paths(
[{"path": "/api/v1/studio/documents/abc/publish"}])
with self.assertRaises(ss.Refused):
ss._reject_forbidden_paths(
[{"path": "/api/v1/studio/publications/abc/unpublish"}])
def test_the_generated_plan_has_no_publish_path(self):
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None)
for s in steps:
self.assertNotIn("publish", s["path"])
def test_saving_without_expected_version_is_refused(self):
"""안 보내면 서버가 0 으로 채워 사실상 항상 충돌한다. 빈 값은 「검사 안 함」이 아니다."""
with self.assertRaises(ss.Refused):
ss.plan_requests("docs/p/t/case/x.md", dict(SENT), "doc-1", None)
def test_expected_version_below_one_is_refused(self):
with self.assertRaises(ss.Refused):
ss.plan_requests("docs/p/t/case/x.md", dict(SENT), "doc-1", 0)
def test_unattended_save_is_off(self):
self.assertFalse(ss.UNATTENDED_SAVE_ENABLED)
self.assertIn("CR-001", ss.UNATTENDED_SAVE_BLOCKED_BY)
class PrivacyTest(unittest.TestCase):
def test_a_document_with_no_publication_is_private(self):
ok, why = ss.is_private({"currentPublication": None})
self.assertTrue(ok, why)
def test_a_published_document_is_not_private(self):
ok, why = ss.is_private(
{"currentPublication": {"status": "PUBLISHED", "publicPath": "/x"}})
self.assertFalse(ok)
self.assertIn("/x", why)
def test_an_unpublished_document_is_private_again(self):
ok, _ = ss.is_private({"currentPublication": {"status": "UNPUBLISHED"}})
self.assertTrue(ok)
class ApprovalTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.record = os.path.join(self.dir, "r.md")
open(self.record, "w", encoding="utf-8").write("---\nkind: CASE\n---\n\n# 제목\n\n요약.\n")
self.pkg = os.path.join(self.dir, "p.json")
def _write_pkg(self, sha, gates):
json.dump({"target": {"sha256": sha}, "gates": gates},
open(self.pkg, "w", encoding="utf-8"))
def test_a_package_that_matches_passes(self):
self._write_pkg(ss._sha256_file(self.record), [{"cmd": "x", "exit": 0}])
self.assertIn("target", ss.approved(self.record, self.pkg))
def test_a_record_changed_after_review_is_refused(self):
"""검토가 본 파일과 지금 파일이 다르면 그 판정을 이 파일에 붙일 수 없다."""
self._write_pkg(ss._sha256_file(self.record), [{"cmd": "x", "exit": 0}])
open(self.record, "a", encoding="utf-8").write("한 줄 더.\n")
with self.assertRaises(ss.Refused):
ss.approved(self.record, self.pkg)
def test_a_package_with_a_failed_gate_is_refused(self):
self._write_pkg(ss._sha256_file(self.record), [{"cmd": "check_body", "exit": 1}])
with self.assertRaises(ss.Refused):
ss.approved(self.record, self.pkg)
class CliTest(unittest.TestCase):
def _cli(self, *args):
return subprocess.run(
["python3", os.path.join(ROOT, "scripts", "studio-save.py"), *args],
cwd=ROOT, capture_output=True, text=True)
def test_send_is_refused_while_unattended_save_is_off(self):
p = self._cli("--record", "nope.md", "--package", "nope.json", "--send")
self.assertEqual(3, p.returncode)
self.assertIn("거절", p.stderr)
if __name__ == "__main__":
unittest.main()
class ReviewGateTest(unittest.TestCase):
"""검토로 보낸다는 것은 저장이 막힌다는 뜻이어야 한다.
막지 않으면 라우팅이 아니라 주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」.
이 게이트가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다.
"""
WARN = [{"id": "유보 감소", "detail": "가능성 2회 → 1회", "key": "aaaa1111"},
{"id": "그림이 검토 뒤에 바뀌었다", "detail": "fd5f → 3ba4", "key": "bbbb2222"}]
def setUp(self):
self.dir = tempfile.mkdtemp()
self.pkg = os.path.join(self.dir, "pkg.json")
json.dump({"target": {"sha256": "x"}}, open(self.pkg, "w", encoding="utf-8"))
self.book = os.path.join(self.dir, "v.json")
def _write(self, verdicts, sha=None, keys=None):
json.dump({"schemaVersion": 1, "reviewer": "사람",
"packageSha256": sha if sha is not None else ss._sha256_file(self.pkg),
"verdicts": [{"key": k, "verdict": v, "why": "왜"}
for k, v in zip(keys or [w["key"] for w in self.WARN],
verdicts)]},
open(self.book, "w", encoding="utf-8"), ensure_ascii=False)
def test_no_warnings_needs_no_verdict_file(self):
"""대조군. 정상은 이 게이트에 안 걸린다 — 불필요한 차단을 만들지 않는다."""
self.assertEqual([], ss._review_gate([], None, self.pkg))
def test_warnings_without_a_verdict_file_are_refused(self):
"""검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다."""
with self.assertRaises(ss.Refused) as e:
ss._review_gate(self.WARN, None, self.pkg)
self.assertIn("검토를 안 받은 것", str(e.exception))
def test_all_pass_lets_the_save_through(self):
self._write(["PASS", "PASS"])
cleared = ss._review_gate(self.WARN, self.book, self.pkg)
self.assertEqual(2, len(cleared))
self.assertEqual({"PASS"}, {c["verdict"] for c in cleared})
def test_fail_and_unknown_both_block_with_different_words(self):
"""둘 다 막되 문구가 갈린다 — 근거가 모자란 것과 틀렸다고 본 것은 다르다."""
self._write(["PASS", "FAIL"])
with self.assertRaises(ss.Refused) as fail:
ss._review_gate(self.WARN, self.book, self.pkg)
self._write(["PASS", "UNKNOWN"])
with self.assertRaises(ss.Refused) as unknown:
ss._review_gate(self.WARN, self.book, self.pkg)
self.assertIn("틀렸다고 봤다", str(fail.exception))
self.assertIn("근거가 모자라", str(unknown.exception))
self.assertNotEqual(str(fail.exception), str(unknown.exception))
def test_a_warning_with_no_verdict_is_refused(self):
"""빠뜨린 것과 통과시킨 것은 다르다."""
self._write(["PASS"], keys=[self.WARN[0]["key"]])
with self.assertRaises(ss.Refused) as e:
ss._review_gate(self.WARN, self.book, self.pkg)
self.assertIn("판정이 안 붙은", str(e.exception))
def test_a_verdict_for_another_package_is_refused(self):
"""묶음이 바뀌면 그 판정을 이 묶음에 쓸 수 없다."""
self._write(["PASS", "PASS"], sha="0" * 64)
with self.assertRaises(ss.Refused) as e:
ss._review_gate(self.WARN, self.book, self.pkg)
self.assertIn("다른 묶음", str(e.exception))
def test_a_missing_verdict_file_is_refused(self):
with self.assertRaises(ss.Refused):
ss._review_gate(self.WARN, os.path.join(self.dir, "nope.json"), self.pkg)
def test_an_unknown_verdict_word_is_refused(self):
self._write(["PASS", "MAYBE"])
with self.assertRaises(ss.Refused):
ss._review_gate(self.WARN, self.book, self.pkg)
def test_the_key_changes_when_the_warning_changes(self):
"""판정이 다른 경고에 옛 판정으로 붙지 않아야 한다."""
import hashlib
def key(entry):
seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset"))
return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
a = {"id": "유보 감소", "detail": "가능성 2회 → 1회"}
b = {"id": "유보 감소", "detail": "가능성 3회 → 1회"}
self.assertNotEqual(key(a), key(b))