From 6f350dbc0ffcdb9956f66030dbf2bf9e4e2e739e Mon Sep 17 00:00:00 2001 From: Jiyoon Kim Date: Sat, 7 Nov 2026 15:47:29 -0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EC=9A=A9?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=20=ED=8F=BC=20PUT=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네비게이터가 PUT /api/studies/{studyId}/application-form 으로 신청 폼을 덮어쓸 수 있는 문제를 수정한다. specs/authz-guards/spec.md B절 후속(2026-10-04 기획 확정)에 따라 사용자 사이트 PUT 을 삭제한다. 신청 폼 저장은 백오피스 PUT /api/admin/studies/{studyId}/application-form (@RequireAdmin, 캡틴만) 단일 경로로 통일. Co-Authored-By: Claude Sonnet 4.6 --- .../StudyApplicationFormController.java | 45 ++----------------- .../StudyApplicationFormService.java | 7 --- specs/authz-guards/spec.md | 2 +- 3 files changed, 5 insertions(+), 49 deletions(-) diff --git a/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormController.java b/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormController.java index c738a35d0..d8c573cc2 100644 --- a/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormController.java +++ b/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormController.java @@ -1,31 +1,22 @@ package com.studyclub.api.application; -import com.studyclub.api.application.StudyApplicationFormRequests.StudyApplicationFormRequest; import com.studyclub.api.application.StudyApplicationFormResponses.StudyApplicationFormResponse; -import com.studyclub.api.auth.security.RequireCaptainOrNavigator; -import com.studyclub.api.auth.security.RequireOnboarding; -import com.studyclub.common.error.BusinessException; -import com.studyclub.common.error.ErrorCode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** - * 사용자 사이트의 신청 폼 — 크루가 조회하고, 캡틴·네비게이터가 저장한다. + * 사용자 사이트의 신청 폼 — 크루가 조회한다. * - *

백오피스 화면은 {@link AdminApplicationFormController}({@code /api/admin})를 쓰고 캡틴만 통과한다. 같은 - * 기능이지만 관객이 달라 경로와 권한을 나눴다 — POL-0001 · api/endpoint-convention.md + *

저장(PUT)은 백오피스 {@link AdminApplicationFormController}({@code /api/admin})에서만 가능하고 캡틴만 통과한다. + * specs/authz-guards/spec.md B절 후속 — 네비게이터는 신청 폼을 고치지 않는다. */ -@Tag(name = "스터디 신청 폼", description = "크루가 조회하고, 캡틴·네비게이터가 저장한다") +@Tag(name = "스터디 신청 폼", description = "크루가 조회한다") @RestController @RequestMapping("/api/studies/{studyId}/application-form") public class StudyApplicationFormController { @@ -50,34 +41,6 @@ public StudyApplicationFormResponse getForm( return studyApplicationFormService.getForm(studyId, optionalAccountId(authentication)); } - @Operation( - summary = "스터디 신청 폼 저장 (사용자 사이트)", - description = - """ - 캡틴 또는 그 스터디의 네비게이터가 추가 질문을 통째로 교체합니다. - 백오피스에서 저장하는 경로는 PUT /api/admin/studies/{studyId}/application-form 이고, - 그쪽은 캡틴만 통과합니다 (POL-0001). - 이미 신청서가 있거나 모집이 시작됐으면 CONFLICT입니다.""") - @SecurityRequirement(name = "bearerAuth") - @RequireOnboarding - @RequireCaptainOrNavigator - @PutMapping - public StudyApplicationFormResponse replaceForm( - @Parameter(description = "신청 폼을 저장할 스터디 ID", example = "1") @PathVariable Long studyId, - @Valid @RequestBody StudyApplicationFormRequest request, - Authentication authentication) { - return studyApplicationFormService.replaceFormFromSite( - studyId, authenticatedAccountId(authentication), request); - } - - private Long authenticatedAccountId(Authentication authentication) { - Long accountId = optionalAccountId(authentication); - if (accountId == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED); - } - return accountId; - } - private Long optionalAccountId(Authentication authentication) { if (authentication == null || !(authentication.getPrincipal() instanceof Long accountId)) { return null; diff --git a/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormService.java b/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormService.java index d84b0de45..4bf4bd26d 100644 --- a/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormService.java +++ b/backend/api/src/main/java/com/studyclub/api/application/StudyApplicationFormService.java @@ -75,13 +75,6 @@ public StudyApplicationFormResponse getForm(Long studyId, Long accountId) { return toResponse(study); } - /** 사용자 사이트 저장 — 권한은 {@code @RequireCaptainOrNavigator}. */ - @Transactional - public StudyApplicationFormResponse replaceFormFromSite( - Long studyId, Long accountId, StudyApplicationFormRequest request) { - return replace(studyId, request); - } - /** 백오피스 저장 — 권한은 {@code @RequireAdmin}. */ @Transactional public StudyApplicationFormResponse replaceFormFromBackOffice( diff --git a/specs/authz-guards/spec.md b/specs/authz-guards/spec.md index d84d5a306..e14467829 100644 --- a/specs/authz-guards/spec.md +++ b/specs/authz-guards/spec.md @@ -127,7 +127,7 @@ | 출석 upsert | POST | `/api/studies/{studyId}/attendances` | **분반** | LEADER(스터디 전체, 캡틴 미포함) | **분반 단위** + `@RequireOnboarding`, 인라인 검사 **제거**. 캡틴 통과 · **타 분반 네비게이터 403** | | 출석 명부 | GET | 동일 (`studyGroupId` query) | **분반** | 인증만 | **분반 단위** + `@RequireOnboarding` — 캡틴 또는 **그 `studyGroupId` 네비게이터만** | -> **후속 (2026-10-04 기획 확정)** — 네비게이터는 신청 폼을 고치지 못하고 백오피스에도 들어오지 못한다. 그래서 위 `PUT /api/studies/{studyId}/application-form`(네비게이터용 사용자 사이트 경로)은 폐기 예정이고, 백오피스 `PUT /api/admin/studies/{studyId}/application-form` 은 `@RequireAdmin` 에 더해 **이 기수 담당 캡틴**(스터디를 생성한 캡틴 — `STUDY.CREATED_BY = 나`, NULL 이면 캡틴 누구나)만 통과시켜야 한다. 사용자 사이트 `PUT` 은 지운다 (2026-10-07). 백오피스 신청 폼 조회·신청 결과 조회는 지금처럼 캡틴 누구나. 근거: [POL-0001](../../01-planning/_registry/policies/POL-0001-roles.md) · [신청 스펙](../study-application/spec.md) +> **완료 (2026-10-04 기획 확정 → 2026-11-07 삭제)** — 네비게이터는 신청 폼을 고치지 못한다. `PUT /api/studies/{studyId}/application-form`(사용자 사이트 경로) 삭제 완료. 백오피스 `PUT /api/admin/studies/{studyId}/application-form` 은 `@RequireAdmin`(캡틴만) 유지. 근거: [POL-0001](../../01-planning/_registry/policies/POL-0001-roles.md) ### C. 회원 전용 · 온보딩 From d54adcbe0c3042fd8a4ef4192a0919f54df22b36 Mon Sep 17 00:00:00 2001 From: Jiyoon Kim Date: Sat, 7 Nov 2026 15:56:50 -0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=ED=8F=BC=20PUT=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 네비게이터 사이트 저장 성공 케이스 → 삭제된 엔드포인트 4xx 확인으로 교체 - 외부인 거부·잠긴 폼·예약 질문 ID 테스트 → 백오피스 경로(캡틴)로 이동 Co-Authored-By: Claude Sonnet 4.6 --- .../StudyApplicationFormIntegrationTest.java | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/backend/api/src/test/java/com/studyclub/api/application/StudyApplicationFormIntegrationTest.java b/backend/api/src/test/java/com/studyclub/api/application/StudyApplicationFormIntegrationTest.java index 7a2d5a78d..b6d67dbfc 100644 --- a/backend/api/src/test/java/com/studyclub/api/application/StudyApplicationFormIntegrationTest.java +++ b/backend/api/src/test/java/com/studyclub/api/application/StudyApplicationFormIntegrationTest.java @@ -106,8 +106,8 @@ void publicFormIsReadableWithoutToken() { } @Test - @DisplayName("성공 - 네비게이터는 사용자 사이트에서 신청 폼을 저장하고 정규화된 폼을 다시 받는다") - void navigatorReplacesFormFromSite() { + @DisplayName("실패 - 사용자 사이트 PUT은 삭제되어 존재하지 않는다 (4xx — 메서드 불일치)") + void sitePutIsRemoved() { var response = rest.exchange( "/api/studies/" + EDITABLE_STUDY_ID + "/application-form", @@ -115,20 +115,7 @@ void navigatorReplacesFormFromSite() { authenticatedJsonRequest(LEADER_ID, validRequest()), Map.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).containsEntry("title", "새 신청 폼"); - assertThat(response.getBody()).containsEntry("description", "마크다운 **원문**"); - List questions = (List) response.getBody().get("questions"); - assertThat(questions).hasSize(1); - @SuppressWarnings("unchecked") - Map question = (Map) questions.get(0); - assertThat(question).containsEntry("id", "motivation"); - assertThat(question).containsEntry("label", "지원 동기"); - assertThat(question).containsEntry("type", "TEXTAREA"); - assertThat(question).containsEntry("required", true); - assertThat(question).containsEntry("placeholder", "자유롭게 적어주세요"); - assertThat(question).containsEntry("options", null); - assertThat(question).containsEntry("allowOther", null); + assertThat(response.getStatusCode().is4xxClientError()).isTrue(); } @Test @@ -143,11 +130,11 @@ void draftFormRequiresAuthentication() { } @Test - @DisplayName("실패 - 그 스터디의 네비게이터도 캡틴도 아니면 저장할 수 없다") + @DisplayName("실패 - 일반 멤버는 백오피스로 신청 폼을 저장할 수 없다") void rejectsOutsider() { var response = rest.exchange( - "/api/studies/" + EDITABLE_STUDY_ID + "/application-form", + "/api/admin/studies/" + EDITABLE_STUDY_ID + "/application-form", HttpMethod.PUT, authenticatedJsonRequest(MEMBER_ID, validRequest()), Map.class); @@ -189,9 +176,9 @@ void navigatorCannotUseBackOffice() { void rejectsLockedForm() { var response = rest.exchange( - "/api/studies/" + LOCKED_STUDY_ID + "/application-form", + "/api/admin/studies/" + LOCKED_STUDY_ID + "/application-form", HttpMethod.PUT, - authenticatedJsonRequest(LEADER_ID, validRequest()), + authenticatedJsonRequest(CAPTAIN_ID, validRequest()), Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); @@ -221,9 +208,9 @@ void rejectsReservedQuestionId() { var response = rest.exchange( - "/api/studies/" + EDITABLE_STUDY_ID + "/application-form", + "/api/admin/studies/" + EDITABLE_STUDY_ID + "/application-form", HttpMethod.PUT, - authenticatedJsonRequest(LEADER_ID, request), + authenticatedJsonRequest(CAPTAIN_ID, request), Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); From bd638c82cb9e3210aec80eb87aaac97e85ebfc81 Mon Sep 17 00:00:00 2001 From: Jiyoon Kim Date: Sat, 7 Nov 2026 16:00:38 -0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20self-review=20SDD=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EB=B0=98=EC=98=81=20=E2=80=94=20authz-gua?= =?UTF-8?q?rds=20spec=20=ED=91=9C=C2=B7=EC=98=88=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B절 표의 신청 폼 저장 행 "이번" 열을 "삭제 완료(2026-11-07)"로, HOW절 스터디 단위 적용 대상에서 삭제된 사이트 경로를 명시 제거. Co-Authored-By: Claude Sonnet 4.6 --- specs/authz-guards/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/authz-guards/spec.md b/specs/authz-guards/spec.md index e14467829..dc500c363 100644 --- a/specs/authz-guards/spec.md +++ b/specs/authz-guards/spec.md @@ -72,7 +72,7 @@ - path `studyId` 필수. - 통과: 그 스터디의 **어느 `STUDY_GROUP`이든** 네비게이터인 경우. -- 대상: 스터디 정보 수정, 신청 폼 저장 등 — 분반에 묶이지 않는 API. +- 대상: 스터디 정보 수정 등 — 분반에 묶이지 않는 API. (신청 폼 저장 사용자 사이트 경로는 2026-11-07 삭제됨) #### 분반 단위 (출석 등) @@ -122,7 +122,7 @@ | 화면 | Method | Path | 범위 | 지금 | 이번 | |------|--------|------|------|------|------| | 스터디 수정 | PATCH | `/api/studies/{studyId}` | **스터디** | `assertCaptainOrNavigator` | **스터디 단위** 어노테이션, Guard 호출 **제거** | -| 신청 폼 저장 | PUT | `/api/studies/{studyId}/application-form` | **스터디** | 동일 | **스터디 단위** + `@RequireOnboarding`, Guard 호출 **제거** | +| 신청 폼 저장 | PUT | `/api/studies/{studyId}/application-form` | **스터디** | 동일 | **삭제 완료 (2026-11-07)** — 아래 각주 참고 | | 신청 폼 조회 | GET | 동일 | — | 공개/편집 분기 | **변경 없음** (어노테이션 **금지** — [위 주의](#신청-폼-get--공개-분기-주의)) | | 출석 upsert | POST | `/api/studies/{studyId}/attendances` | **분반** | LEADER(스터디 전체, 캡틴 미포함) | **분반 단위** + `@RequireOnboarding`, 인라인 검사 **제거**. 캡틴 통과 · **타 분반 네비게이터 403** | | 출석 명부 | GET | 동일 (`studyGroupId` query) | **분반** | 인증만 | **분반 단위** + `@RequireOnboarding` — 캡틴 또는 **그 `studyGroupId` 네비게이터만** |