From da8ecf6d4fc7fd8400a3b4aafb665bbe90589022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 23 Jul 2026 14:13:29 +0900 Subject: [PATCH] Support revoking admin role via setAdminRole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - appointAdmin을 setAdminRole로 교체 — isAdmin boolean 인자로 부여와 해제를 모두 처리 - 자기 자신의 관리자 권한 해제는 409(CANNOT_REMOVE_SELF_ADMIN)로 차단 - 비활성 유저 검사는 권한 부여 시에만 적용, 응답 형태를 {isAdmin, changed}로 변경 - 어드민 핸들러 라우트와 테스트를 새 시그니처로 갱신 --- src/handlers/adminHandlers.ts | 4 ++-- src/services/adminUserService.ts | 32 +++++++++++++++---------- tests/services/adminUserService.test.ts | 31 +++++++++++++++++------- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/handlers/adminHandlers.ts b/src/handlers/adminHandlers.ts index 901e910..abe7b00 100644 --- a/src/handlers/adminHandlers.ts +++ b/src/handlers/adminHandlers.ts @@ -12,7 +12,7 @@ import { Timestamp } from "firebase-admin/firestore"; import type { OrderStatus, ProductDoc } from "../types/reward"; import { transitionOrder, updateOrderShipment } from "../services/orderService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService"; -import { appointAdmin, listAdminUsers, searchAdminUsers } from "../services/adminUserService"; +import { listAdminUsers, searchAdminUsers, setAdminRole } from "../services/adminUserService"; import { toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto, type AdminProductDto, type LedgerPageDto, type OrderPageDto, @@ -133,7 +133,7 @@ export const admin = onRequest(async (req, res) => { } if (tail === "users/admin" && req.method === "POST") { - res.json(await appointAdmin(req.body?.uid, adminUid)); + res.json(await setAdminRole(req.body?.uid, req.body?.isAdmin, adminUid)); return; } diff --git a/src/services/adminUserService.ts b/src/services/adminUserService.ts index 370898e..558c41a 100644 --- a/src/services/adminUserService.ts +++ b/src/services/adminUserService.ts @@ -27,13 +27,14 @@ async function attachAdminStatus(users: AdminUserDto[]): Promise export interface AdminRoleUpdateResult { uid: string; - isAdmin: true; - alreadyAdmin: boolean; + isAdmin: boolean; + changed: boolean; } -/** 기존 custom claims를 보존하면서 활성 유저에게 관리자 권한을 부여한다. */ -export async function appointAdmin( +/** 기존 custom claims를 보존하면서 관리자 권한을 부여하거나 해제한다. */ +export async function setAdminRole( uidRaw: unknown, + isAdminRaw: unknown, actorUid: string ): Promise { if ( @@ -45,23 +46,30 @@ export async function appointAdmin( ) { throw new HttpError(400, "invalid uid", "INVALID_INPUT"); } + if (typeof isAdminRaw !== "boolean") { + throw new HttpError(400, "isAdmin must be boolean", "INVALID_INPUT"); + } + if (!isAdminRaw && uidRaw === actorUid) { + throw new HttpError(409, "cannot remove own admin role", "CANNOT_REMOVE_SELF_ADMIN"); + } const user = await getUser(uidRaw); if (!user) throw new HttpError(404, "user not found", "USER_NOT_FOUND"); - if (user.active === false) { + if (isAdminRaw && user.active === false) { throw new HttpError(409, "inactive user cannot be admin", "INACTIVE_USER"); } const record = await auth.getUser(uidRaw).catch(() => { throw new HttpError(404, "auth user not found", "USER_NOT_FOUND"); }); - if (record.customClaims?.admin === true) { - return { uid: uidRaw, isAdmin: true, alreadyAdmin: true }; + const currentIsAdmin = record.customClaims?.admin === true; + if (currentIsAdmin === isAdminRaw) { + return { uid: uidRaw, isAdmin: isAdminRaw, changed: false }; } - await auth.setCustomUserClaims(uidRaw, { - ...(record.customClaims ?? {}), - admin: true, - }); - return { uid: uidRaw, isAdmin: true, alreadyAdmin: false }; + const nextClaims: Record = { ...(record.customClaims ?? {}) }; + if (isAdminRaw) nextClaims.admin = true; + else delete nextClaims.admin; + await auth.setCustomUserClaims(uidRaw, nextClaims); + return { uid: uidRaw, isAdmin: isAdminRaw, changed: true }; } /** diff --git a/tests/services/adminUserService.test.ts b/tests/services/adminUserService.test.ts index 7d4c123..433badc 100644 --- a/tests/services/adminUserService.test.ts +++ b/tests/services/adminUserService.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { Timestamp } from "firebase-admin/firestore"; import { auth, firestore } from "../../src/firebase"; -import { appointAdmin, listAdminUsers, searchAdminUsers } from "../../src/services/adminUserService"; +import { listAdminUsers, searchAdminUsers, setAdminRole } from "../../src/services/adminUserService"; // 다른 테스트가 만드는 users 문서와 섞이지 않도록 고유 접두어를 쓴다. const PREFIX = "검색전용"; @@ -71,34 +71,49 @@ describe("searchAdminUsers", () => { }); }); -describe("appointAdmin", () => { - it("기존 custom claims를 보존하면서 관리자 권한을 부여하고 재호출은 멱등이다", async () => { +describe("setAdminRole", () => { + it("기존 custom claims를 보존하면서 관리자 권한을 부여·해제하고 재호출은 멱등이다", async () => { const uid = "admin-user-search-test-1"; await auth.setCustomUserClaims(uid, { betaTester: true }); - await expect(appointAdmin(uid, "granting-admin")).resolves.toEqual({ + await expect(setAdminRole(uid, true, "granting-admin")).resolves.toEqual({ uid, isAdmin: true, - alreadyAdmin: false, + changed: true, }); const record = await auth.getUser(uid); expect(record.customClaims).toMatchObject({ betaTester: true, admin: true, }); - await expect(appointAdmin(uid, "granting-admin")).resolves.toMatchObject({ + await expect(setAdminRole(uid, true, "granting-admin")).resolves.toMatchObject({ isAdmin: true, - alreadyAdmin: true, + changed: false, }); const [dto] = await searchAdminUsers(uid, 10); expect(dto.isAdmin).toBe(true); + + await expect(setAdminRole(uid, false, "other-admin")).resolves.toEqual({ + uid, + isAdmin: false, + changed: true, + }); + const revoked = await auth.getUser(uid); + expect(revoked.customClaims).toMatchObject({ betaTester: true }); + expect(revoked.customClaims).not.toHaveProperty("admin"); }); it("비활성 유저는 관리자로 임명할 수 없다", async () => { await expect( - appointAdmin("admin-user-search-test-2", "granting-admin") + setAdminRole("admin-user-search-test-2", true, "granting-admin") ).rejects.toMatchObject({ status: 409, code: "INACTIVE_USER" }); }); + + it("현재 관리자는 자신의 권한을 해제할 수 없다", async () => { + await expect( + setAdminRole("admin-user-search-test-1", false, "admin-user-search-test-1") + ).rejects.toMatchObject({ status: 409, code: "CANNOT_REMOVE_SELF_ADMIN" }); + }); }); describe("listAdminUsers", () => {