서버 계약은 tech-log-backend @ a000f87 의 openapi 와 컨트롤러에서 읽었다. Idempotency-Key 는 필수이고 200자 이하다. expectedVersion 은 스키마가 minimum 1 로 못 박으므로 빈 값은 「검사 안 함」이 아니라 「반드시 충돌」이다. 멱등 키를 기록 경로에서 유도한다. 프런트는 호출마다 새 uuid 를 만들어서 재시도가 문서를 둘 만든다. 만들기는 경로만으로 키를 만들어 몇 번을 다시 시도해도 문서가 하나이고, 저장은 보낼 내용을 키에 넣는다 — 경로만 쓰면 두 번째 저장이 첫 결과로 조용히 재생된다. 게시 요청을 만드는 코드가 없고, 계획이 게시 경로를 가리키면 거절한다. 지금까지 게시를 막고 있던 것은 스킬 문서의 문장 하나였다. 무인 저장은 꺼져 있다. 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. 켜는 것은 studio:publish 가 갈라진 뒤의 판단이지 이 파일의 기본값이 아니다. 저장 뒤 되읽어 견줄 때 문자열 하나로 판정하지 않는다. 정규화가 지운 차이를 통과로 세면 비교 틀이 결함을 만들어 내거나 지운다. 정규화 뒤에만 같아지는 것은 whitespaceOnly 로, 되읽은 값이 보낸 값의 앞부분인 것은 truncated 로 따로 낸다. 못 보는 칸은 사유와 함께 적는다 — 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다. 회귀의 첫 줄은 대조군이다. 손대지 않은 쌍이 「같다」로 나오지 않으면 나머지 「잡았다」가 전부 틀 탓이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
195 lines
8.5 KiB
Python
195 lines
8.5 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"])
|
|
|
|
|
|
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()
|