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:
이정수 2026-06-24 11:15:22 +09:00
parent ccf1e42430
commit 71b6b6f32a
11 changed files with 1102 additions and 0 deletions

View File

@ -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");

40
docs/jam-judge-ddl.sql Normal file
View File

@ -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");

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
} }

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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);
}
} }
}); });
})(); })();

View File

@ -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() {
} }

View File

@ -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;
}
}

View File

@ -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;
}
}