feat(scripts): Studio 저장 어댑터 — 게시를 코드가 막고 무인 저장은 꺼 둔다

서버 계약은 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
This commit is contained in:
DongHyeonka
2026-09-10 11:13:47 +09:00
co-authored by Claude Opus 5
parent 5472751894
commit 32a369f335
2 changed files with 569 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""Studio 저장 어댑터. 승인된 최종본만 넣고, 저장한 뒤 되읽어 대조한다.
**게시하지 않는다.** 이 파일에 게시 요청을 만드는 코드가 없고, 만들어 낸 요청이 게시 경로를
가리키면 `_reject_forbidden_paths` 가 막는다. 스킬 문서의 「게시 버튼을 누르지 않는다」는
문장 하나로 막던 것을 코드로 옮긴 것이다.
**무인 저장은 꺼져 있다.** `UNATTENDED_SAVE_ENABLED` 가 `False` 다. 서버 권한이
`studio:read`·`studio:write` 둘뿐이라 저장할 수 있는 계정은 게시도 할 수 있고, 그래서
사람이 안 보는 사이에 저장을 돌리지 않는다. 이 값을 `True` 로 바꾸는 것은 서버에
`studio:publish` 가 갈라져 들어온 뒤의 일이다.
**자격증명을 이 저장소에 적지 않는다.** 세션 쿠키와 CSRF 토큰은 환경 변수로만 받는다.
서버 계약 (tech-log-backend @ a000f87 의 src/config/openapi/studio-v1.yaml 과 소스에서 읽었다)
| 무엇 | 값 |
|---|---|
| 만들기 | `POST /api/v1/studio/documents` → 201 |
| 읽기 | `GET /api/v1/studio/documents/{id}` |
| 저장 | `PUT /api/v1/studio/documents/{id}` → 200 |
| 멱등 키 | `Idempotency-Key` 헤더 **필수**, 200자 이하 (`StudioIdempotency.java:34,77-88`) |
| 재생 여부 | 응답 헤더 `Idempotency-Replayed` |
| 낙관적 락 | `SaveDocumentCommand.expectedVersion` **필수 · minimum 1** |
| 충돌 | `VERSION_CONFLICT` 409, `details.latestDocument` 에 현재 문서 전체 |
| 인증 | 쿠키 `TECHLOG_SESSION` + 변경 요청에 `X-CSRF-TOKEN` |
python3 scripts/studio-save.py --record <기록.md> --package <review-package.json>
python3 scripts/studio-save.py --record <기록.md> --package <pkg.json> -o <plan.json>
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 서버에 studio:publish 가 갈라져 들어오기 전에는 무인 저장을 켜지 않는다.
# 켜는 조건은 사람의 판단이지 이 파일의 기본값이 아니다 (A-studio-change-requests.md CR-001).
UNATTENDED_SAVE_ENABLED = False
UNATTENDED_SAVE_BLOCKED_BY = (
"CR-001 — 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. "
"studio:publish 가 갈라지기 전에는 사람이 보는 앞에서만 저장한다"
)
BASE_PATH = "/api/v1/studio"
FORBIDDEN_PATH = re.compile(r"/(publish|unpublish)(/|$)")
MAX_KEY_LENGTH = 200 # StudioIdempotency.java:34
IDEMPOTENCY_HEADER = "Idempotency-Key"
REPLAYED_HEADER = "Idempotency-Replayed"
# 기록의 `##` 이름 → Studio 입력 칸. studio-form-map.md 가 정본이다
FIELD_MAP = {
"CASE": {"문제": "problem", "결론": "conclusion", "검증 환경": "environment",
"재현 조건": "reproduction", "본문": "bodyMarkdown"},
"CONCEPT": {"본문": "bodyMarkdown"},
"REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "appliesWhen",
"예외": "exceptions", "예시": "examples"},
"QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns",
"제약": "constraints", "선택지": "options", "다음 검증": "nextVerification"},
"PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale",
"영향": "impact"},
}
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
class Refused(Exception):
"""어댑터가 스스로 거절한 것. 서버 오류가 아니다."""
def _sha256_file(path: str) -> str:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
def _sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def idempotency_key(op: str, record_rel: str, payload: dict | None = None) -> str:
"""기록 경로에서 유도한 안정 키.
프런트는 호출마다 `crypto.randomUUID()` 로 새 키를 만든다(`local-id.ts:8`). 그래서
「만들다 타임아웃 → 다시 시도」가 문서를 둘 만든다. 서버의 멱등 기능이 정확히 막으라고
만들어진 상황을 키 정책이 빠져나간다.
- **만들기**는 경로만으로 키를 만든다. 몇 번을 다시 시도해도 같은 키라 문서가 하나다.
- **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를
재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로
조용히 재생돼 버린다.
"""
base = _sha256_text(record_rel)[:32]
key = f"studio-{op}-{base}"
if payload is not None:
key += "-" + _sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True))[:32]
if len(key) > MAX_KEY_LENGTH: # 서버가 200자 초과를 거절한다
raise Refused(f"멱등 키가 {len(key)}자다. 서버 상한은 {MAX_KEY_LENGTH}")
return key
def _front_matter(text: str) -> dict:
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
out: dict[str, str] = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out
def _sections(text: str) -> dict[str, str]:
a, b = text.find(BODY_START), text.find(BODY_END)
marks = []
for m in re.finditer(r"^##\s+(.+)$", text, re.M):
if a >= 0 <= b and a < m.start() < b:
continue
marks.append((m.group(1).strip(), m.end()))
out: dict[str, str] = {}
for i, (name, start) in enumerate(marks):
stop = marks[i + 1][1] - len(f"## {marks[i + 1][0]}") if i + 1 < len(marks) else len(text)
chunk = text[start:stop]
if name == "본문":
chunk = chunk.replace(BODY_START, "").replace(BODY_END, "")
out[name] = chunk.strip()
return out
def _summary(text: str) -> str:
m = re.search(r"^#\s+.+$", text, re.M)
if not m:
return ""
after = re.split(r"^##\s", text[m.end():], maxsplit=1, flags=re.M)[0]
for para in (p.strip() for p in after.split("\n\n")):
if para and not para.startswith("<!--"):
return para
return ""
def build_input(record_path: str) -> dict:
"""기록 `.md` 를 `WorkingCopyInput` 으로. 없는 칸을 지어내지 않는다."""
text = open(record_path, encoding="utf-8").read()
fm = _front_matter(text)
kind = (fm.get("kind") or "").upper()
if kind not in FIELD_MAP:
raise Refused(f"모르는 kind: {fm.get('kind')!r}")
found = _sections(text)
doc: dict = {
"kind": kind,
"title": fm.get("title", ""),
"slug": fm.get("slug", ""),
"summary": _summary(text),
"topicId": None, # 이름→uuid 해석은 catalog 조회가 필요하다. 지어내지 않는다
"projectId": None,
"relations": [],
}
for section, field in FIELD_MAP[kind].items():
if section in found:
doc[field] = found[section]
if kind == "CASE":
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
return doc
def approved(record_path: str, package_path: str) -> dict:
"""검토를 지난 최종본만 통과시킨다.
묶음의 `target.sha256` 은 검토가 본 파일의 해시다. 지금 디스크의 파일이 그것과 다르면
**검토 뒤에 바뀐 것**이라 그 판정을 이 파일에 붙일 수 없다.
"""
pkg = json.load(open(package_path, encoding="utf-8"))
now = _sha256_file(record_path)
want = (pkg.get("target") or {}).get("sha256")
if not want:
raise Refused(f"묶음에 target.sha256 이 없다: {package_path}")
if want != now:
raise Refused(
"검토가 본 파일과 지금 파일이 다르다 — 그 판정을 이 파일에 붙일 수 없다\n"
f" 검토 시점 {want}\n 지금 {now}")
failed = [g["cmd"] for g in pkg.get("gates", []) if g.get("exit") not in (0, "0")]
if failed:
raise Refused("관문이 통과하지 못한 묶음이다:\n " + "\n ".join(failed))
return pkg
def _reject_forbidden_paths(plan: list[dict]) -> None:
"""게시 경로가 계획에 들어 있으면 멈춘다.
한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다. 되돌릴 수 없는
동작을 프롬프트가 아니라 코드가 막는다.
"""
for step in plan:
if FORBIDDEN_PATH.search(step["path"]):
raise Refused(f"게시 경로는 이 어댑터가 만들지 않는다: {step['path']}")
def plan_requests(record_path: str, doc: dict, document_id: str | None,
expected_version: int | None) -> list[dict]:
"""보낼 요청을 그대로 적어 낸다. 보내지 않는다."""
rel = os.path.relpath(os.path.abspath(record_path), ROOT)
steps: list[dict] = []
if not document_id:
steps.append({
"op": "create", "method": "POST", "path": f"{BASE_PATH}/documents",
"headers": {IDEMPOTENCY_HEADER: idempotency_key("create", rel),
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": doc,
"expect": {"status": 201, "readHeader": REPLAYED_HEADER},
})
else:
if expected_version is None:
raise Refused(
"expectedVersion 을 모른 채 저장하지 않는다. 먼저 GET 으로 현재 version 을 "
"읽는다 — 안 보내면 서버가 0 으로 채워 사실상 항상 충돌한다"
" (StudioDocumentController.java:196)")
if expected_version < 1:
raise Refused(f"expectedVersion 은 1 이상이어야 한다 (스키마 minimum 1): {expected_version}")
body = {"expectedVersion": expected_version, "document": doc}
steps.append({
"op": "save", "method": "PUT", "path": f"{BASE_PATH}/documents/{document_id}",
"headers": {IDEMPOTENCY_HEADER: idempotency_key("save", rel, body),
"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": body,
"expect": {"status": 200, "readHeader": REPLAYED_HEADER,
"onConflict": "VERSION_CONFLICT 409 — 덮어쓰지 않고 멈춘다"},
})
steps.append({
"op": "verify", "method": "GET",
"path": f"{BASE_PATH}/documents/{document_id or '<생성된 id>'}",
"headers": {}, "cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": None,
"expect": {"status": 200,
"compare": "정규화한 본문·칸·자료를 보낸 것과 견준다",
"private": "currentPublication 이 null 이거나 status != PUBLISHED"},
})
_reject_forbidden_paths(steps)
return steps
# 이 어댑터가 값을 대조하지 않는 칸. 서버가 이름을 uuid 로 바꿔 돌려주거나 목록을 다시
# 정렬하는 자리라 문자열로 견줄 수 없다. **빼먹은 것이 아니라 못 보는 것**이므로
# 보고서에 그대로 적는다. 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다
NOT_COMPARED = {
"topicId": "이름→uuid 해석을 이 어댑터가 하지 않는다",
"projectId": "같음",
"relations": "서버가 순서를 다시 매긴다. 목록 비교는 따로 만들어야 한다",
}
def normalize(value) -> str:
"""되읽어 견주기 전에 줄 끝 공백과 줄바꿈 표기만 맞춘다.
**정규화가 차이를 지울 수 있다.** 그래서 이 함수는 뜻이 바뀌지 않는 것만 건드리고,
`compare_saved` 는 정규화한 값과 **원문 둘 다** 견준다. 정규화 뒤에만 같아지는 차이는
통과가 아니라 `whitespace-only` 로 보고한다.
"""
if value is None:
return ""
text = str(value).replace("\r\n", "\n")
return "\n".join(line.rstrip() for line in text.split("\n")).strip()
def compare_saved(sent: dict, fetched: dict) -> dict:
"""보낸 것과 되읽은 것을 견준다.
문자열 비교 하나로 「같다」를 판정하지 않는다. 세 가지를 나눠 낸다.
- `differences` — 정규화한 뒤에도 다르다. 값이 잘렸거나 바뀐 것이다
- `whitespaceOnly` — 정규화 뒤에만 같아진다. 통과로 세지 않는다
- `notCompared` · `unexpected` — 이 어댑터가 못 본 칸과 서버가 더 준 칸
**잘림은 길이로 한 번 더 본다.** Studio 는 제목 30/31자·본문 4157/4187자로 조용히 자른
적이 있다. 앞이 같고 뒤가 없는 모양은 바뀐 것과 다르게 읽힌다.
"""
differences, whitespace_only, truncated = [], [], []
for field, want in sent.items():
if field in NOT_COMPARED:
continue
got = fetched.get(field)
nw, ng = normalize(want), normalize(got)
raw_same = str(want or "") == str(got or "")
if nw != ng:
entry = {"field": field, "sentChars": len(nw), "gotChars": len(ng),
"sent": nw[:120], "got": ng[:120]}
if ng and nw.startswith(ng):
entry["shape"] = "잘림 — 되읽은 값이 보낸 값의 앞부분이다"
truncated.append(entry)
differences.append(entry)
elif not raw_same:
whitespace_only.append({"field": field,
"note": "정규화 뒤에만 같다. 공백이나 줄바꿈이 달라졌다"})
unexpected = [k for k in fetched
if k not in sent and k not in NOT_COMPARED and k not in ("id", "version")]
return {
"same": not differences and not whitespace_only,
"differences": differences,
"truncated": truncated,
"whitespaceOnly": whitespace_only,
"notCompared": NOT_COMPARED,
"unexpected": unexpected,
"comparedFields": [f for f in sent if f not in NOT_COMPARED],
}
def is_private(detail: dict) -> tuple[bool, str]:
pub = detail.get("currentPublication")
if pub is None:
return True, "currentPublication 이 없다"
status = pub.get("status")
if status == "PUBLISHED":
return False, f"이미 게시된 문서다 — publicPath={pub.get('publicPath')}"
return True, f"게시 상태가 {status}"
def main() -> int:
ap = argparse.ArgumentParser(description="Studio 저장 어댑터. 게시하지 않는다.")
ap.add_argument("--record", required=True)
ap.add_argument("--package", required=True, help="review-package.py 가 만든 묶음")
ap.add_argument("--document-id", help="이미 있는 문서. 없으면 만들기 계획을 낸다")
ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version")
ap.add_argument("-o", "--out", help="계획을 적을 파일")
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
args = ap.parse_args()
try:
for p in (args.record, args.package):
if not os.path.isfile(p):
raise Refused(f"그런 파일이 없다: {p}")
pkg = approved(args.record, args.package)
doc = build_input(args.record)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version)
if args.send and not UNATTENDED_SAVE_ENABLED:
raise Refused("무인 저장이 꺼져 있다 — " + UNATTENDED_SAVE_BLOCKED_BY)
except Refused as e:
print(f"거절: {e}", file=sys.stderr)
return 3
plan = {
"schemaVersion": 1,
"mode": "plan",
"unattendedSaveEnabled": UNATTENDED_SAVE_ENABLED,
"unattendedSaveBlockedBy": UNATTENDED_SAVE_BLOCKED_BY,
"record": os.path.relpath(os.path.abspath(args.record), ROOT),
"recordSha256": _sha256_file(args.record),
"packageSha256": _sha256_file(args.package),
"approvedAgainst": pkg["target"]["sha256"],
"checkerVersions": pkg.get("checkerVersions"),
"requests": steps,
"notes": [
"이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.",
"게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.",
"자격증명은 환경 변수 자리표시자로만 적혀 있다. 값은 이 저장소에 없다.",
],
}
text = json.dumps(plan, ensure_ascii=False, indent=2) + "\n"
if args.out:
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
open(args.out, "w", encoding="utf-8").write(text)
ops = " · ".join(f"{s['op']} {s['method']}" for s in steps)
print(f"{args.out}{ops} · 무인 저장 {'' if UNATTENDED_SAVE_ENABLED else ''}")
else:
print(text, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+194
View File
@@ -0,0 +1,194 @@
"""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()