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
|
END
|
||||||
$$;
|
$$;
|
||||||
CREATE INDEX IF NOT EXISTS "idx_jam_status_log_jam" ON "jam_status_log" ("jam_id", "created_at" DESC);
|
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);
|
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"
|
<button class="admin-btn admin-btn--danger" type="button"
|
||||||
data-action="delete"
|
data-action="delete"
|
||||||
data-jam-id="<%= jamIdAttr %>">삭제</button>
|
data-jam-id="<%= jamIdAttr %>">삭제</button>
|
||||||
|
<button class="admin-btn" type="button"
|
||||||
|
data-action="judges"
|
||||||
|
data-jam-id="<%= jamIdAttr %>">심사위원</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -341,6 +344,40 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -409,6 +446,128 @@
|
||||||
.catch(handleError);
|
.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 핸들러에 사용자 데이터 삽입 금지)
|
// data-* 속성 + 위임 핸들러 (inline 핸들러에 사용자 데이터 삽입 금지)
|
||||||
document.addEventListener('click', function (ev) {
|
document.addEventListener('click', function (ev) {
|
||||||
var btn = ev.target.closest('[data-action]');
|
var btn = ev.target.closest('[data-action]');
|
||||||
|
|
@ -416,6 +575,10 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var action = btn.getAttribute('data-action');
|
var action = btn.getAttribute('data-action');
|
||||||
|
if (action === 'judge-assign') {
|
||||||
|
assignJudge();
|
||||||
|
return;
|
||||||
|
}
|
||||||
var jamId = btn.getAttribute('data-jam-id');
|
var jamId = btn.getAttribute('data-jam-id');
|
||||||
if (!jamId) {
|
if (!jamId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -426,6 +589,13 @@
|
||||||
toggleVisibility(jamId);
|
toggleVisibility(jamId);
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
removeJam(jamId);
|
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.GameReviewsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
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.UserAuthIdentitiesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||||
|
import com.pandoli365.bibimbap.security.JamRoleGate;
|
||||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
@ -68,6 +70,9 @@ class BibimbapApplicationTests {
|
||||||
@MockBean
|
@MockBean
|
||||||
private JamEntriesMapper jamEntriesMapper;
|
private JamEntriesMapper jamEntriesMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamJudgesMapper jamJudgesMapper;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private JamTeamsMapper jamTeamsMapper;
|
private JamTeamsMapper jamTeamsMapper;
|
||||||
|
|
||||||
|
|
@ -80,6 +85,9 @@ class BibimbapApplicationTests {
|
||||||
@MockBean
|
@MockBean
|
||||||
private PermissionGate permissionGate;
|
private PermissionGate permissionGate;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamRoleGate jamRoleGate;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
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