feat(jam): W2-2 심사위원 역할 — jam_judges + JamRoleGate(잼 스코프 게이트) + 자기출품 충돌
- 신규 jam_judges 테이블(docs/jam-judge-ddl.sql 권위 + db/schema.sql 동기, 멱등). 전역 RBAC(user_permissions) 무변경 — 잼 스코프 권한은 별도 조인
- JamRoleGate.isJudge(잼별 지정 조회) + isOwnEntry(개인 entrant OR 팀멤버 OR-EXISTS) — 자기출품 충돌 판정 헬퍼/계약 제공(enforce=W2-4)
- JamJudgeAdminController: 지정/해제/조회 + requireJamManage 게이트 + CSRF. W2-1 /admin/jams/** exclude 가 judges 트리 커버(인터셉터 무수정)
- JamEntriesMapper.isOwnEntry 추가(#{} only). admin-jam-list.jsp 심사위원 섹션
- BibimbapApplicationTests @MockBean 2건(JamJudgesMapper/JamRoleGate)
검증: ./mvnw -o test 119/119 GREEN(신규 22: JamRoleGateTest 8·JamJudgeAdminControllerTest 14, 회귀 0). L2 contract PASS(ux_jam_judges UNIQUE 거부·isOwnEntry OR-EXISTS 6/6 정합). 집합전수 AC-T1~7 PASS(전역 RBAC 무변경 단언 포함).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ccf1e42430
commit
71b6b6f32a
|
|
@ -555,3 +555,42 @@ BEGIN
|
|||
END
|
||||
$$;
|
||||
CREATE INDEX IF NOT EXISTS "idx_jam_status_log_jam" ON "jam_status_log" ("jam_id", "created_at" DESC);
|
||||
|
||||
-- ===========================================================================
|
||||
-- 심사위원 역할 W2-2 (권위 DDL — docs/jam-judge-ddl.sql 와 동일. 잼 스코프 역할)
|
||||
-- ===========================================================================
|
||||
-- ===========================================================================
|
||||
-- 1) jam_judges (잼별 심사위원. 잼 스코프 역할. 전역 권한과 별도 축)
|
||||
-- ===========================================================================
|
||||
CREATE SEQUENCE IF NOT EXISTS "jam_judges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "jam_judges" (
|
||||
"id" bigint DEFAULT nextval('jam_judges_id_seq'::regclass) NOT NULL,
|
||||
"jam_id" bigint NOT NULL, -- 잼 회차(FK jams)
|
||||
"user_id" bigint NOT NULL, -- 심사위원(FK users; 누구나 가능)
|
||||
"assigned_by" bigint, -- 지정 관리자(FK users; 감사 보조, nullable)
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "jam_judges_id_seq" OWNED BY "jam_judges"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_jam_fkey') THEN
|
||||
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_jam_fkey"
|
||||
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_user_fkey') THEN
|
||||
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_user_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_assigned_by_fkey') THEN
|
||||
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_assigned_by_fkey"
|
||||
FOREIGN KEY ("assigned_by") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- 같은 잼에 같은 유저 중복 지정 방지(멱등 지정). 잼 종료 후 잔존(J5) — soft delete 없음(이력=행 존재).
|
||||
-- 해제는 hard DELETE(역할 회수). 재지정은 다시 INSERT.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_judges_jam_user"
|
||||
ON "jam_judges" ("jam_id", "user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_jam_judges_jam"
|
||||
ON "jam_judges" ("jam_id");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
-- W2-2 심사위원 역할 권한(잼 스코프). 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||
-- 선행: docs/jam-ddl.sql(jams — 알파벳 글롭 순 jam-ddl 먼저 적용).
|
||||
-- 전역 user_permissions(RBAC) 변경 없음 — 잼 회차별 역할은 잼 스코프 조인이 정석.
|
||||
-- 추가만, 파괴 없음.
|
||||
|
||||
-- ===========================================================================
|
||||
-- 1) jam_judges (잼별 심사위원. 잼 스코프 역할. 전역 권한과 별도 축)
|
||||
-- ===========================================================================
|
||||
CREATE SEQUENCE IF NOT EXISTS "jam_judges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "jam_judges" (
|
||||
"id" bigint DEFAULT nextval('jam_judges_id_seq'::regclass) NOT NULL,
|
||||
"jam_id" bigint NOT NULL, -- 잼 회차(FK jams)
|
||||
"user_id" bigint NOT NULL, -- 심사위원(FK users; 누구나 가능)
|
||||
"assigned_by" bigint, -- 지정 관리자(FK users; 감사 보조, nullable)
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "jam_judges_id_seq" OWNED BY "jam_judges"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_jam_fkey') THEN
|
||||
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_jam_fkey"
|
||||
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_user_fkey') THEN
|
||||
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_user_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_assigned_by_fkey') THEN
|
||||
ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_assigned_by_fkey"
|
||||
FOREIGN KEY ("assigned_by") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- 같은 잼에 같은 유저 중복 지정 방지(멱등 지정). 잼 종료 후 잔존(J5) — soft delete 없음(이력=행 존재).
|
||||
-- 해제는 hard DELETE(역할 회수). 재지정은 다시 INSERT.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_judges_jam_user"
|
||||
ON "jam_judges" ("jam_id", "user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_jam_judges_jam"
|
||||
ON "jam_judges" ("jam_id");
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.JamJudgeData;
|
||||
import com.pandoli365.bibimbap.data.UserData;
|
||||
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class JamJudgeAdminController {
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final UsersMapper usersMapper;
|
||||
private final JamJudgesMapper jamJudgesMapper;
|
||||
private final PermissionGate gate;
|
||||
|
||||
public JamJudgeAdminController(JamsMapper jamsMapper,
|
||||
UsersMapper usersMapper,
|
||||
JamJudgesMapper jamJudgesMapper,
|
||||
PermissionGate gate) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.usersMapper = usersMapper;
|
||||
this.jamJudgesMapper = jamJudgesMapper;
|
||||
this.gate = gate;
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/judges")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> assign(
|
||||
@PathVariable("jamId") long jamId,
|
||||
@RequestParam("userId") long userId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다.");
|
||||
}
|
||||
UserData target = usersMapper.getUser(userId);
|
||||
if (target == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (jamJudgesMapper.exists(jamId, userId)) {
|
||||
return response(HttpStatus.CONFLICT, "이미 심사위원입니다.");
|
||||
}
|
||||
|
||||
long actorId = sessionUserId(session);
|
||||
try {
|
||||
jamJudgesMapper.insert(jamId, userId, actorId);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// ux_jam_judges_jam_user 경합 — exists 통과 후 동시 insert 시 409로 수렴
|
||||
return response(HttpStatus.CONFLICT, "이미 심사위원입니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "심사위원을 지정했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
body.put("userId", userId);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/judges/{userId}/remove")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> remove(
|
||||
@PathVariable("jamId") long jamId,
|
||||
@PathVariable("userId") long userId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
int affected = jamJudgesMapper.delete(jamId, userId);
|
||||
if (affected == 0) {
|
||||
return response(HttpStatus.NOT_FOUND, "지정된 심사위원이 아닙니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "심사위원을 해제했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
body.put("userId", userId);
|
||||
body.put("removed", true);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/admin/jams/{jamId}/judges")
|
||||
public ResponseEntity<Map<String, Object>> list(
|
||||
@PathVariable("jamId") long jamId,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
|
||||
List<JamJudgeData> judges = jamJudgesMapper.listByJam(jamId);
|
||||
List<Map<String, Object>> items = new ArrayList<>(judges.size());
|
||||
for (JamJudgeData judge : judges) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("userId", judge.getUserId());
|
||||
item.put("displayName", judge.getDisplayName());
|
||||
item.put("assignedBy", judge.getAssignedBy());
|
||||
item.put("createdAt", judge.getCreatedAt());
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("judges", items);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> requireJamManage(HttpSession session) {
|
||||
if (!gate.isAuthenticated(session)) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.pandoli365.bibimbap.data;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class JamJudgeData {
|
||||
|
||||
private Long id;
|
||||
private Long jamId;
|
||||
private Long userId;
|
||||
private Long assignedBy;
|
||||
private OffsetDateTime createdAt;
|
||||
private String displayName;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getJamId() {
|
||||
return jamId;
|
||||
}
|
||||
|
||||
public void setJamId(Long jamId) {
|
||||
this.jamId = jamId;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public Long getAssignedBy() {
|
||||
return assignedBy;
|
||||
}
|
||||
|
||||
public void setAssignedBy(Long assignedBy) {
|
||||
this.assignedBy = assignedBy;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,4 +63,20 @@ public interface JamEntriesMapper {
|
|||
)
|
||||
""")
|
||||
boolean exists(@Param("jamId") long jamId, @Param("gameId") long gameId);
|
||||
|
||||
@Select("""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM jam_entries e
|
||||
WHERE e.jam_id = #{jamId} AND e.game_id = #{gameId} AND e.is_delete IS NOT TRUE
|
||||
AND (
|
||||
(e.entrant_type = 'USER' AND e.entrant_user_id = #{userId})
|
||||
OR
|
||||
(e.entrant_type = 'TEAM' AND EXISTS(
|
||||
SELECT 1 FROM jam_team_members m
|
||||
WHERE m.jam_team_id = e.jam_team_id AND m.user_id = #{userId}
|
||||
))
|
||||
)
|
||||
)
|
||||
""")
|
||||
boolean isOwnEntry(@Param("jamId") long jamId, @Param("gameId") long gameId, @Param("userId") long userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.pandoli365.bibimbap.mapper;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamJudgeData;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface JamJudgesMapper {
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO jam_judges (
|
||||
jam_id,
|
||||
user_id,
|
||||
assigned_by
|
||||
) VALUES (
|
||||
#{jamId},
|
||||
#{userId},
|
||||
#{assignedBy}
|
||||
)
|
||||
""")
|
||||
int insert(@Param("jamId") long jamId, @Param("userId") long userId, @Param("assignedBy") long assignedBy);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM jam_judges
|
||||
WHERE jam_id = #{jamId}
|
||||
AND user_id = #{userId}
|
||||
""")
|
||||
int delete(@Param("jamId") long jamId, @Param("userId") long userId);
|
||||
|
||||
@Select("""
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM jam_judges
|
||||
WHERE jam_id = #{jamId}
|
||||
AND user_id = #{userId}
|
||||
)
|
||||
""")
|
||||
boolean exists(@Param("jamId") long jamId, @Param("userId") long userId);
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
jj.id,
|
||||
jj.jam_id AS jamId,
|
||||
jj.user_id AS userId,
|
||||
jj.assigned_by AS assignedBy,
|
||||
jj.created_at AS createdAt,
|
||||
u.display_name AS displayName
|
||||
FROM jam_judges jj
|
||||
JOIN users u ON u.id = jj.user_id AND u.is_delete IS NOT TRUE
|
||||
WHERE jj.jam_id = #{jamId}
|
||||
ORDER BY jj.created_at, jj.id
|
||||
""")
|
||||
List<JamJudgeData> listByJam(long jamId);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.pandoli365.bibimbap.security;
|
||||
|
||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 잼 스코프 게이트 — 전역 PermissionGate(권한 키 축)와 별도 축(잼 리소스 역할 축).
|
||||
* 잼 역할은 요청당 jam_judges 직접 조회로 판정한다(캐시·epoch 없음 — 지정/해제 다음 요청 즉시 반영).
|
||||
*/
|
||||
@Component
|
||||
public class JamRoleGate {
|
||||
|
||||
private final JamJudgesMapper jamJudgesMapper;
|
||||
private final JamEntriesMapper jamEntriesMapper;
|
||||
|
||||
public JamRoleGate(JamJudgesMapper jamJudgesMapper, JamEntriesMapper jamEntriesMapper) {
|
||||
this.jamJudgesMapper = jamJudgesMapper;
|
||||
this.jamEntriesMapper = jamEntriesMapper;
|
||||
}
|
||||
|
||||
public boolean isJudge(HttpSession session, long jamId) {
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return false;
|
||||
}
|
||||
return jamJudgesMapper.exists(jamId, userId);
|
||||
}
|
||||
|
||||
public boolean isOwnEntry(long jamId, long gameId, long judgeUserId) {
|
||||
return jamEntriesMapper.isOwnEntry(jamId, gameId, judgeUserId);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object attr = session.getAttribute("userId");
|
||||
if (attr instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (attr instanceof String s) {
|
||||
try {
|
||||
return Long.parseLong(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -330,6 +330,9 @@
|
|||
<button class="admin-btn admin-btn--danger" type="button"
|
||||
data-action="delete"
|
||||
data-jam-id="<%= jamIdAttr %>">삭제</button>
|
||||
<button class="admin-btn" type="button"
|
||||
data-action="judges"
|
||||
data-jam-id="<%= jamIdAttr %>">심사위원</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -341,6 +344,40 @@
|
|||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section" id="jam-judges-section" aria-labelledby="jam-judges-title" style="display:none">
|
||||
<h2 id="jam-judges-title">심사위원 관리 — 잼 #<span id="jam-judges-jam-id"></span></h2>
|
||||
<form id="jam-judge-assign-form" autocomplete="off">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>" />
|
||||
<div class="admin-form-grid">
|
||||
<div class="admin-field">
|
||||
<label for="jam-judge-user-id">심사위원 사용자 ID (필수)</label>
|
||||
<input type="number" id="jam-judge-user-id" name="userId" min="1" step="1" placeholder="사용자 ID" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<button class="admin-btn admin-btn--primary" type="button" data-action="judge-assign">심사위원 지정</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">사용자 ID</th>
|
||||
<th scope="col">표시 이름</th>
|
||||
<th scope="col">지정자</th>
|
||||
<th scope="col">지정일</th>
|
||||
<th scope="col">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jam-judge-list">
|
||||
<tr>
|
||||
<td colspan="5"><span class="admin-muted">잼을 선택하면 심사위원이 표시됩니다.</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
<script>
|
||||
|
|
@ -409,6 +446,128 @@
|
|||
.catch(handleError);
|
||||
}
|
||||
|
||||
var selectedJudgeJamId = null;
|
||||
|
||||
function cell(text) {
|
||||
var td = document.createElement('td');
|
||||
td.textContent = String(text == null ? '-' : text);
|
||||
return td;
|
||||
}
|
||||
|
||||
function renderJudges(jamId, judges) {
|
||||
var tbody = document.getElementById('jam-judge-list');
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
while (tbody.firstChild) {
|
||||
tbody.removeChild(tbody.firstChild);
|
||||
}
|
||||
if (!Array.isArray(judges) || judges.length === 0) {
|
||||
var emptyRow = document.createElement('tr');
|
||||
var emptyCell = document.createElement('td');
|
||||
emptyCell.setAttribute('colspan', '5');
|
||||
var emptySpan = document.createElement('span');
|
||||
emptySpan.className = 'admin-muted';
|
||||
emptySpan.textContent = '지정된 심사위원이 없습니다.';
|
||||
emptyCell.appendChild(emptySpan);
|
||||
emptyRow.appendChild(emptyCell);
|
||||
tbody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
judges.forEach(function (judge) {
|
||||
var userId = judge ? judge.userId : null;
|
||||
var tr = document.createElement('tr');
|
||||
tr.appendChild(cell(userId));
|
||||
tr.appendChild(cell(judge ? judge.displayName : null));
|
||||
tr.appendChild(cell(judge ? judge.assignedBy : null));
|
||||
tr.appendChild(cell(judge ? judge.createdAt : null));
|
||||
|
||||
var actionTd = document.createElement('td');
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'admin-btn admin-btn--danger';
|
||||
removeBtn.textContent = '해제';
|
||||
removeBtn.setAttribute('data-action', 'judge-remove');
|
||||
removeBtn.setAttribute('data-jam-id', String(jamId));
|
||||
removeBtn.setAttribute('data-user-id', String(userId == null ? '' : userId));
|
||||
actionTd.appendChild(removeBtn);
|
||||
tr.appendChild(actionTd);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function loadJudges(jamId) {
|
||||
fetch(ctx + '/admin/jams/' + encodeURIComponent(jamId) + '/judges', {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
throw new Error('status ' + res.status);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
renderJudges(jamId, data ? data.judges : []);
|
||||
})
|
||||
.catch(function () {
|
||||
notify('심사위원 목록을 불러오지 못했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
function openJudges(jamId) {
|
||||
selectedJudgeJamId = jamId;
|
||||
var section = document.getElementById('jam-judges-section');
|
||||
if (section) {
|
||||
section.style.display = '';
|
||||
}
|
||||
var label = document.getElementById('jam-judges-jam-id');
|
||||
if (label) {
|
||||
label.textContent = String(jamId);
|
||||
}
|
||||
loadJudges(jamId);
|
||||
}
|
||||
|
||||
function assignJudge() {
|
||||
if (!selectedJudgeJamId) {
|
||||
notify('먼저 잼 목록에서 심사위원을 누르세요.');
|
||||
return;
|
||||
}
|
||||
var input = document.getElementById('jam-judge-user-id');
|
||||
var userId = input ? input.value.trim() : '';
|
||||
if (!userId) {
|
||||
notify('지정할 사용자 ID를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
var jamId = selectedJudgeJamId;
|
||||
var params = new URLSearchParams();
|
||||
params.set('userId', userId);
|
||||
post(ctx + '/admin/jams/' + encodeURIComponent(jamId) + '/judges', params)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
notify('심사위원 지정에 실패했습니다. (상태 ' + res.status + ')');
|
||||
return;
|
||||
}
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
loadJudges(jamId);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
function removeJudge(jamId, userId) {
|
||||
post(ctx + '/admin/jams/' + encodeURIComponent(jamId) + '/judges/' + encodeURIComponent(userId) + '/remove')
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
notify('심사위원 해제에 실패했습니다. (상태 ' + res.status + ')');
|
||||
return;
|
||||
}
|
||||
loadJudges(jamId);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
// data-* 속성 + 위임 핸들러 (inline 핸들러에 사용자 데이터 삽입 금지)
|
||||
document.addEventListener('click', function (ev) {
|
||||
var btn = ev.target.closest('[data-action]');
|
||||
|
|
@ -416,6 +575,10 @@
|
|||
return;
|
||||
}
|
||||
var action = btn.getAttribute('data-action');
|
||||
if (action === 'judge-assign') {
|
||||
assignJudge();
|
||||
return;
|
||||
}
|
||||
var jamId = btn.getAttribute('data-jam-id');
|
||||
if (!jamId) {
|
||||
return;
|
||||
|
|
@ -426,6 +589,13 @@
|
|||
toggleVisibility(jamId);
|
||||
} else if (action === 'delete') {
|
||||
removeJam(jamId);
|
||||
} else if (action === 'judges') {
|
||||
openJudges(jamId);
|
||||
} else if (action === 'judge-remove') {
|
||||
var userId = btn.getAttribute('data-user-id');
|
||||
if (userId) {
|
||||
removeJudge(jamId, userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
|
|||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
||||
|
|
@ -16,6 +17,7 @@ import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
|||
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
import com.pandoli365.bibimbap.security.JamRoleGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
|
@ -68,6 +70,9 @@ class BibimbapApplicationTests {
|
|||
@MockBean
|
||||
private JamEntriesMapper jamEntriesMapper;
|
||||
|
||||
@MockBean
|
||||
private JamJudgesMapper jamJudgesMapper;
|
||||
|
||||
@MockBean
|
||||
private JamTeamsMapper jamTeamsMapper;
|
||||
|
||||
|
|
@ -80,6 +85,9 @@ class BibimbapApplicationTests {
|
|||
@MockBean
|
||||
private PermissionGate permissionGate;
|
||||
|
||||
@MockBean
|
||||
private JamRoleGate jamRoleGate;
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.JamJudgeData;
|
||||
import com.pandoli365.bibimbap.data.UserData;
|
||||
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* W2-2 심사위원 지정/해제/조회 컨트롤러 테스트.
|
||||
* 컨트롤러는 게이트(PermissionGate)만 소비하므로 ADMIN / SUBADMIN+키 통과는
|
||||
* gate.isAuthenticated + gate.has(GAME_JAM_MANAGE) 반환값으로 표현한다(JamAdminControllerTest 동형).
|
||||
*
|
||||
* VP-1 (지정 게이트): 통과(ADMIN/SUBADMIN+키) / 무키 403 / 미인증 401.
|
||||
* VP-5 (CSRF): 지정/해제 CSRF 누락 → 403 + mapper 미호출.
|
||||
* VP-6 (멱등): 이미 지정 재지정 → 409.
|
||||
* + 404(잼/대상유저 없음), 누구나 지정(VP-4), 해제, 조회.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JamJudgeAdminControllerTest {
|
||||
|
||||
private static final String GAME_JAM_MANAGE = PermissionKeys.GAME_JAM_MANAGE.name();
|
||||
private static final long ACTOR_ID = 99L;
|
||||
private static final long JAM_ID = 42L;
|
||||
private static final long TARGET_ID = 7L;
|
||||
|
||||
@Mock
|
||||
private JamsMapper jamsMapper;
|
||||
|
||||
@Mock
|
||||
private UsersMapper usersMapper;
|
||||
|
||||
@Mock
|
||||
private JamJudgesMapper jamJudgesMapper;
|
||||
|
||||
@Mock
|
||||
private PermissionGate gate;
|
||||
|
||||
// ---- VP-1: 지정 게이트 (미인증 401 / 무키 403) ----
|
||||
|
||||
@Test
|
||||
void assignReturns401WhenUnauthenticated() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(false);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.assign(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(response.getBody()).containsEntry("status", HttpStatus.UNAUTHORIZED.value());
|
||||
verify(jamJudgesMapper, never()).insert(anyLong(), anyLong(), anyLong());
|
||||
verifyNoInteractions(jamsMapper);
|
||||
verifyNoInteractions(usersMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignReturns403WhenLacksPermission() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(false);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.assign(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(response.getBody()).containsEntry("status", HttpStatus.FORBIDDEN.value());
|
||||
verify(jamJudgesMapper, never()).insert(anyLong(), anyLong(), anyLong());
|
||||
verifyNoInteractions(jamsMapper);
|
||||
verifyNoInteractions(usersMapper);
|
||||
}
|
||||
|
||||
// ---- VP-5: CSRF 누락 → 403, 게이트 통과 후 매퍼 접근 전 차단 ----
|
||||
|
||||
@Test
|
||||
void assignRejectsMissingCsrfBeforeMapperAccess() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = noCsrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.assign(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(response.getBody()).containsEntry("status", 403);
|
||||
verifyNoInteractions(jamsMapper);
|
||||
verifyNoInteractions(usersMapper);
|
||||
verifyNoInteractions(jamJudgesMapper);
|
||||
}
|
||||
|
||||
// ---- 지정 404: 잼 없음 / 대상 유저 없음 ----
|
||||
|
||||
@Test
|
||||
void assignReturns404WhenJamMissing() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
when(jamsMapper.getById(JAM_ID)).thenReturn(null);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.assign(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
verify(jamJudgesMapper, never()).insert(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignReturns404WhenTargetUserMissing() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
when(jamsMapper.getById(JAM_ID)).thenReturn(jam(JAM_ID));
|
||||
when(usersMapper.getUser(TARGET_ID)).thenReturn(null);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.assign(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
verify(jamJudgesMapper, never()).insert(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
// ---- VP-6: 이미 지정 재지정 → 409 (멱등) ----
|
||||
|
||||
@Test
|
||||
void assignReturns409WhenAlreadyJudge() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
when(jamsMapper.getById(JAM_ID)).thenReturn(jam(JAM_ID));
|
||||
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID));
|
||||
when(jamJudgesMapper.exists(JAM_ID, TARGET_ID)).thenReturn(true);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.assign(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
verify(jamJudgesMapper, never()).insert(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
// ---- VP-1/VP-4: 지정 성공 (누구나 지정 — 대상 전역 role 검사 없음) ----
|
||||
|
||||
@Test
|
||||
void assignInsertsJudgeWhenAuthorized() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
when(jamsMapper.getById(JAM_ID)).thenReturn(jam(JAM_ID));
|
||||
when(usersMapper.getUser(TARGET_ID)).thenReturn(user(TARGET_ID));
|
||||
when(jamJudgesMapper.exists(JAM_ID, TARGET_ID)).thenReturn(false);
|
||||
when(jamJudgesMapper.insert(JAM_ID, TARGET_ID, ACTOR_ID)).thenReturn(1);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.assign(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("status", HttpStatus.OK.value());
|
||||
assertThat(response.getBody()).containsEntry("jamId", JAM_ID);
|
||||
assertThat(response.getBody()).containsEntry("userId", TARGET_ID);
|
||||
verify(jamJudgesMapper).insert(JAM_ID, TARGET_ID, ACTOR_ID);
|
||||
}
|
||||
|
||||
// ---- 해제 게이트 / CSRF / 404 / 성공 ----
|
||||
|
||||
@Test
|
||||
void removeReturns403WhenLacksPermission() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(false);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.remove(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verify(jamJudgesMapper, never()).delete(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRejectsMissingCsrfBeforeMapperAccess() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = noCsrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.remove(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(response.getBody()).containsEntry("status", 403);
|
||||
verifyNoInteractions(jamJudgesMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeReturns404WhenNotAssigned() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
when(jamJudgesMapper.delete(JAM_ID, TARGET_ID)).thenReturn(0);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.remove(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeDeletesJudgeWhenAuthorized() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
when(jamJudgesMapper.delete(JAM_ID, TARGET_ID)).thenReturn(1);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.remove(JAM_ID, TARGET_ID, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("removed", true);
|
||||
assertThat(response.getBody()).containsEntry("jamId", JAM_ID);
|
||||
assertThat(response.getBody()).containsEntry("userId", TARGET_ID);
|
||||
verify(jamJudgesMapper).delete(JAM_ID, TARGET_ID);
|
||||
}
|
||||
|
||||
// ---- 조회 게이트 / 성공 ----
|
||||
|
||||
@Test
|
||||
void listReturns401WhenUnauthenticated() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
when(gate.isAuthenticated(session)).thenReturn(false);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = controller.list(JAM_ID, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
verify(jamJudgesMapper, never()).listByJam(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReturns403WhenLacksPermission() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(false);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = controller.list(JAM_ID, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verify(jamJudgesMapper, never()).listByJam(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReturnsJudgesWhenAuthorized() {
|
||||
JamJudgeAdminController controller = controller();
|
||||
MockHttpSession session = managerSession(ACTOR_ID);
|
||||
when(gate.isAuthenticated(session)).thenReturn(true);
|
||||
when(gate.has(session, GAME_JAM_MANAGE)).thenReturn(true);
|
||||
when(jamJudgesMapper.listByJam(JAM_ID)).thenReturn(List.of(judge(TARGET_ID, "심사위원")));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = controller.list(JAM_ID, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("status", HttpStatus.OK.value());
|
||||
assertThat(response.getBody().get("judges")).isInstanceOf(List.class);
|
||||
assertThat((List<?>) response.getBody().get("judges")).hasSize(1);
|
||||
verify(jamJudgesMapper).listByJam(JAM_ID);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private JamJudgeAdminController controller() {
|
||||
return new JamJudgeAdminController(jamsMapper, usersMapper, jamJudgesMapper, gate);
|
||||
}
|
||||
|
||||
private JamData jam(long id) {
|
||||
JamData data = new JamData();
|
||||
data.setId(id);
|
||||
data.setStatus("RECRUIT");
|
||||
data.setTitle("게임잼");
|
||||
return data;
|
||||
}
|
||||
|
||||
private UserData user(long id) {
|
||||
UserData data = new UserData();
|
||||
data.setId(id);
|
||||
return data;
|
||||
}
|
||||
|
||||
private JamJudgeData judge(long userId, String displayName) {
|
||||
JamJudgeData data = new JamJudgeData();
|
||||
data.setUserId(userId);
|
||||
data.setDisplayName(displayName);
|
||||
data.setAssignedBy(ACTOR_ID);
|
||||
return data;
|
||||
}
|
||||
|
||||
private MockHttpSession managerSession(long actorId) {
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
session.setAttribute("userId", actorId);
|
||||
CsrfTokens.getOrCreate(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private MockHttpServletRequest csrfPost(MockHttpSession session) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setSession(session);
|
||||
request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
|
||||
return request;
|
||||
}
|
||||
|
||||
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setSession(session);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.pandoli365.bibimbap.security;
|
||||
|
||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* W2-2 잼 스코프 게이트 단위 테스트.
|
||||
* VP-2 (isJudge): 지정 유저 true / 미지정 false / 미인증 false.
|
||||
* VP-3 (isOwnEntry): 개인출품 / 팀멤버출품 / 타인출품 / 비활성 충돌 판정.
|
||||
* 전역 PermissionGate(권한 키 축)와 별도 축 — jam_judges / jam_entries 직접 조회 판정.
|
||||
*/
|
||||
class JamRoleGateTest {
|
||||
|
||||
private static final long JAM_ID = 42L;
|
||||
private static final long GAME_ID = 88L;
|
||||
private static final long JUDGE_ID = 7L;
|
||||
|
||||
private final JamJudgesMapper jamJudgesMapper = mock(JamJudgesMapper.class);
|
||||
private final JamEntriesMapper jamEntriesMapper = mock(JamEntriesMapper.class);
|
||||
private final JamRoleGate gate = new JamRoleGate(jamJudgesMapper, jamEntriesMapper);
|
||||
|
||||
// ---- VP-2: isJudge(session, jamId) ----
|
||||
|
||||
@Test
|
||||
void isJudgeReturnsTrueForAssignedUser() {
|
||||
// given: 세션 userId 보유 + jam_judges 에 (jamId, userId) 존재
|
||||
MockHttpSession session = sessionWithUser(JUDGE_ID);
|
||||
when(jamJudgesMapper.exists(JAM_ID, JUDGE_ID)).thenReturn(true);
|
||||
|
||||
// when / then
|
||||
assertThat(gate.isJudge(session, JAM_ID)).isTrue();
|
||||
verify(jamJudgesMapper).exists(JAM_ID, JUDGE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isJudgeReturnsFalseForUnassignedUser() {
|
||||
// given: 세션 userId 보유하나 jam_judges 미등록
|
||||
MockHttpSession session = sessionWithUser(JUDGE_ID);
|
||||
when(jamJudgesMapper.exists(JAM_ID, JUDGE_ID)).thenReturn(false);
|
||||
|
||||
// when / then
|
||||
assertThat(gate.isJudge(session, JAM_ID)).isFalse();
|
||||
verify(jamJudgesMapper).exists(JAM_ID, JUDGE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isJudgeReturnsFalseWhenUnauthenticated() {
|
||||
// given: userId 없는 빈 세션 — 미인증은 심사위원 아님
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
|
||||
// when / then: 미인증이면 jam_judges 조회조차 하지 않는다
|
||||
assertThat(gate.isJudge(session, JAM_ID)).isFalse();
|
||||
verify(jamJudgesMapper, never()).exists(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isJudgeReturnsFalseForNullSession() {
|
||||
// given: 세션 자체가 null
|
||||
// when / then
|
||||
assertThat(gate.isJudge(null, JAM_ID)).isFalse();
|
||||
verify(jamJudgesMapper, never()).exists(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
// ---- VP-3: isOwnEntry(jamId, gameId, judgeUserId) ----
|
||||
// 게이트는 jam_entries 매퍼의 OR-EXISTS(개인 OR 팀멤버) 판정 결과를 그대로 전달한다.
|
||||
// 개인/팀/타인/비활성 SQL 분기 자체는 L2(dev DB contract) 소관 — 단위는 위임 정합을 본다.
|
||||
|
||||
@Test
|
||||
void isOwnEntryTrueForPersonalEntry() {
|
||||
// given: 개인 출품(entrant_user_id == judge) → 매퍼 true
|
||||
when(jamEntriesMapper.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).thenReturn(true);
|
||||
|
||||
// when / then
|
||||
assertThat(gate.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).isTrue();
|
||||
verify(jamEntriesMapper).isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOwnEntryTrueForTeamMemberEntry() {
|
||||
// given: 팀 출품 + judge 가 그 팀 멤버(jam_team_members) → 매퍼 true
|
||||
when(jamEntriesMapper.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).thenReturn(true);
|
||||
|
||||
// when / then
|
||||
assertThat(gate.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).isTrue();
|
||||
verify(jamEntriesMapper).isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOwnEntryFalseForOthersEntry() {
|
||||
// given: 타인/타팀 출품 → 매퍼 false (충돌 아님 — 심사 가능)
|
||||
when(jamEntriesMapper.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).thenReturn(false);
|
||||
|
||||
// when / then
|
||||
assertThat(gate.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).isFalse();
|
||||
verify(jamEntriesMapper).isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOwnEntryFalseForInactiveEntry() {
|
||||
// given: 비활성(is_delete) 출품 → 매퍼 SQL 의 is_delete IS NOT TRUE 필터로 false
|
||||
when(jamEntriesMapper.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).thenReturn(false);
|
||||
|
||||
// when / then
|
||||
assertThat(gate.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).isFalse();
|
||||
verify(jamEntriesMapper).isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private MockHttpSession sessionWithUser(long userId) {
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
session.setAttribute("userId", userId);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue