From 96d89ec6da522f97e5d51c9bd978a8be82f0a068 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 11 Sep 2026 00:36:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(studio-save):=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=EA=B0=80=20=EB=B3=B8=EB=AC=B8=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=82=98=EA=B0=80=EA=B3=A0=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?=EB=A5=BC=20=EC=95=88=20=EB=B4=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- scripts/studio-save.py | 36 +++++++++++++++++++++-- scripts/tests/test_studio_save.py | 49 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/scripts/studio-save.py b/scripts/studio-save.py index 5bc4bb9..87b5daf 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -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": ""}, "cookies": {"TECHLOG_SESSION": ""}, - "body": None, + # **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가 + # `@RequestBody ExpectedVersionRequest` 를 받는다. 안 보내면 400 이고 초안이 남는다. + # 값은 **`verify` 가 읽은 version** 이다 — 만들 때 version 이 아니다. + # 만든 뒤 한 번 더 저장하므로 그 사이에 올라가 있다 + "body": {"expectedVersion": ""}, "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) diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index 30f0b52..f93d103 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -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`).