fix(studio-save): 정리 단계가 본문 없이 나가고 종류를 안 봤다

DELETE 가 본문을 요구한다. requestBody: required: true 이고 컨트롤러가 @RequestBody
ExpectedVersionRequest 를 받는다. 본문 없이 보내면 400 이고 초안이 남는다. expectedVersion
은 verify 가 읽은 값이어야 한다 — 만든 뒤 한 번 더 저장하므로 만들 때 version 이 아니다.

경로가 종류를 안 보고 무조건 cases 로 나갔다. 서버는 종류를 조회 조건에 넣고 그 이유를
코드에 적어 두었다 — 그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기
때문이다. 경로가 종류와 어긋나면 DOCUMENT_NOT_FOUND 가 나고 초안이 남는다.

시험 초안을 CASE 로 쓴 판단은 맞았지만 코드가 그 판단에 기대고 있었다. 종류별 경로를
표로 만들고, Decision 처럼 프로젝트 id 가 필요한 것과 모르는 종류는 거절한다 — 조용히
받으면 못 지우는 초안이 운영에 남는다.

CSRF 헤더 이름과 404 의 뜻도 계획에 적었다. X-XSRF-TOKEN 이면 403 이다.

이 고침은 계약에 맞춘 것이지 걸어 본 것이 아니다. 계획대로 보내 본 사람은 아직 없다.

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-11 00:36:29 +09:00
co-authored by Claude Opus 5
parent e1404f8a3d
commit 96d89ec6da
2 changed files with 83 additions and 2 deletions
+34 -2
View File
@@ -256,6 +256,20 @@ VERDICTS = ("PASS", "FAIL", "UNKNOWN")
# 사람이 눈으로 가릴 수 있게 시험 초안에 붙이는 접두사
HARNESS_PREFIX = "[HARNESS-TEST]"
# 종류마다 삭제 경로가 다르다. **서버가 종류를 조회 조건에 넣는다** —
# 「그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기 때문이다」
# (`DeleteDocumentDraftUseCase` · `tech-log-backend` @ `a000f87`).
# 경로를 종류와 안 맞추면 `DOCUMENT_NOT_FOUND` 가 나고 **초안이 남는다.**
DELETE_PATHS = {
"CASE": "/api/v1/studio/cases/{id}",
"REFERENCE": "/api/v1/studio/references/{id}",
"CONCEPT": "/api/v1/studio/concepts/{id}",
"SETUP": "/api/v1/studio/setups/{id}",
"QUESTION": "/api/v1/studio/questions/{id}",
# Decision 만 프로젝트 아래에 있다. 지우려면 프로젝트 id 도 있어야 한다
"PROJECT_DECISION": "/api/v1/studio/projects/{projectId}/decisions/{id}",
}
def published_marks(record_path: str) -> list[str]:
"""이 기록이 게시된 것으로 보이는 표시. 비면 저장 대상이다.
@@ -500,16 +514,34 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
# 계약에 삭제 경로가 없다」고 적었는데 틀렸다 — `ManagementDocumentController.java:123`
# 에 `DELETE /v1/studio/projects/{id}/decisions/{decisionId}` 가 있다. 스킬의 문장을
# 확인 없이 옮겼다. **고른 이유는 그 기록이 이 배치가 만든 것이라 남의 것이 아니어서다.**
kind = (doc.get("kind") or "").upper()
template = DELETE_PATHS.get(kind)
if template is None:
raise Refused(
f"이 종류의 삭제 경로를 모른다: {kind!r}. 지울 수 없는 것을 만들지 않는다\n"
f" 아는 종류: {' · '.join(sorted(DELETE_PATHS))}")
if "{projectId}" in template:
raise Refused(
f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다. "
"이 어댑터는 그 값을 모른다 — 시험 초안으로 만들지 않는다")
steps.append({
"op": "cleanup", "method": "DELETE",
"path": f"/api/v1/studio/cases/{document_id or '<생성된 id>'}",
"path": template.format(id=document_id or "<생성된 id>"),
"headers": {"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
"body": None,
# **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가
# `@RequestBody ExpectedVersionRequest` 를 받는다. 안 보내면 400 이고 초안이 남는다.
# 값은 **`verify` 가 읽은 version** 이다 — 만들 때 version 이 아니다.
# 만든 뒤 한 번 더 저장하므로 그 사이에 올라가 있다
"body": {"expectedVersion": "<verify 가 읽은 document.version>"},
"expect": {"status": 204,
"onConflict": "409 DOCUMENT_PUBLISHED(공개된 기록은 삭제할 수 없습니다) "
"또는 DOCUMENT_IN_USE(참조하는 곳이 있어 삭제할 수 없습니다) "
"— 둘 다 사람이 화면에서 처리한다",
"onNotFound": "404 DOCUMENT_NOT_FOUND — 경로의 종류가 문서의 종류와 "
"다르면 이렇게 나오고 초안이 남는다",
"csrfHeader": "X-CSRF-TOKEN 이다. X-XSRF-TOKEN 이면 403 이 난다",
"then": "GET 으로 404 를 확인한다 — 지워졌다는 것은 그것이다",
"why": "시험 초안을 남기지 않는다"},
})
_reject_forbidden_paths(steps)
+49
View File
@@ -502,6 +502,55 @@ class HarnessTestPlanTest(unittest.TestCase):
harness_test=True)
self.assertIn("cleanup", [s["op"] for s in harness])
def test_the_cleanup_path_follows_the_kind(self):
"""서버가 종류를 조회 조건에 넣는다.
「그렇게 하지 않으면 Case 경로로 Reference 를 지울 수 있게 되기 때문이다」
(`DeleteDocumentDraftUseCase`). 경로를 종류와 안 맞추면 `DOCUMENT_NOT_FOUND` 가
나고 **초안이 남는다.** 시험 초안을 CASE 로만 쓰면 이 결함이 안 드러난다.
"""
for kind, want in (("CASE", "/cases/"), ("CONCEPT", "/concepts/"),
("REFERENCE", "/references/"), ("QUESTION", "/questions/"),
("SETUP", "/setups/")):
with self.subTest(kind=kind):
steps = ss.plan_requests("docs/p/t/case/x.md", {**SENT, "kind": kind},
None, None, harness_test=True)
cleanup = next(s for s in steps if s["op"] == "cleanup")
self.assertIn(want, cleanup["path"])
def test_the_cleanup_body_is_not_empty(self):
"""`requestBody: required: true` 다. 안 보내면 400 이고 초안이 남는다.
값은 **`verify` 가 읽은 version** 이다 — 만든 뒤 한 번 더 저장하므로 만들 때
version 이 아니다.
"""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
cleanup = next(s for s in steps if s["op"] == "cleanup")
self.assertIsNotNone(cleanup["body"])
self.assertIn("expectedVersion", cleanup["body"])
self.assertIn("verify", str(cleanup["body"]["expectedVersion"]))
def test_a_kind_we_cannot_delete_is_refused(self):
"""지울 수 없는 것을 만들지 않는다.
Decision 은 프로젝트 아래에 있어 프로젝트 id 가 있어야 지운다. 이 어댑터는
그 값을 모른다 — 조용히 받으면 못 지우는 초안이 운영에 남는다.
"""
for kind in ("PROJECT_DECISION", "MYSTERY"):
with self.subTest(kind=kind):
with self.assertRaises(ss.Refused):
ss.plan_requests("docs/p/t/case/x.md", {**SENT, "kind": kind},
None, None, harness_test=True)
def test_the_cleanup_names_the_csrf_header_and_the_not_found_shape(self):
"""`X-XSRF-TOKEN` 이면 403 이 난다. 그리고 404 는 「종류가 어긋났다」의 모양이다."""
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)
e = next(s for s in steps if s["op"] == "cleanup")["expect"]
self.assertIn("X-CSRF-TOKEN", e["csrfHeader"])
self.assertIn("DOCUMENT_NOT_FOUND", e["onNotFound"])
def test_the_cleanup_path_is_the_case_delete_endpoint(self):
"""`DELETE /api/v1/studio/cases/{id}` 는 실재한다
(`ManagementDocumentController.java:72`).