Support revoking admin role via setAdminRole

- appointAdmin을 setAdminRole로 교체 — isAdmin boolean 인자로 부여와 해제를 모두 처리
- 자기 자신의 관리자 권한 해제는 409(CANNOT_REMOVE_SELF_ADMIN)로 차단
- 비활성 유저 검사는 권한 부여 시에만 적용, 응답 형태를 {isAdmin, changed}로 변경
- 어드민 핸들러 라우트와 테스트를 새 시그니처로 갱신
This commit is contained in:
윤정민 2026-07-23 14:13:29 +09:00
parent ffafca1338
commit da8ecf6d4f
3 changed files with 45 additions and 22 deletions

View File

@ -12,7 +12,7 @@ import { Timestamp } from "firebase-admin/firestore";
import type { OrderStatus, ProductDoc } from "../types/reward"; import type { OrderStatus, ProductDoc } from "../types/reward";
import { transitionOrder, updateOrderShipment } from "../services/orderService"; import { transitionOrder, updateOrderShipment } from "../services/orderService";
import { invalidateRewardCatalog } from "../services/rewardCatalogService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService";
import { appointAdmin, listAdminUsers, searchAdminUsers } from "../services/adminUserService"; import { listAdminUsers, searchAdminUsers, setAdminRole } from "../services/adminUserService";
import { import {
toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto, toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto,
type AdminProductDto, type LedgerPageDto, type OrderPageDto, type AdminProductDto, type LedgerPageDto, type OrderPageDto,
@ -133,7 +133,7 @@ export const admin = onRequest(async (req, res) => {
} }
if (tail === "users/admin" && req.method === "POST") { 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; return;
} }

View File

@ -27,13 +27,14 @@ async function attachAdminStatus(users: AdminUserDto[]): Promise<AdminUserDto[]>
export interface AdminRoleUpdateResult { export interface AdminRoleUpdateResult {
uid: string; uid: string;
isAdmin: true; isAdmin: boolean;
alreadyAdmin: boolean; changed: boolean;
} }
/** 기존 custom claims를 보존하면서 활성 유저에게 관리자 권한을 부여한다. */ /** 기존 custom claims를 보존하면서 관리자 권한을 부여하거나 해제한다. */
export async function appointAdmin( export async function setAdminRole(
uidRaw: unknown, uidRaw: unknown,
isAdminRaw: unknown,
actorUid: string actorUid: string
): Promise<AdminRoleUpdateResult> { ): Promise<AdminRoleUpdateResult> {
if ( if (
@ -45,23 +46,30 @@ export async function appointAdmin(
) { ) {
throw new HttpError(400, "invalid uid", "INVALID_INPUT"); 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); const user = await getUser(uidRaw);
if (!user) throw new HttpError(404, "user not found", "USER_NOT_FOUND"); 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"); throw new HttpError(409, "inactive user cannot be admin", "INACTIVE_USER");
} }
const record = await auth.getUser(uidRaw).catch(() => { const record = await auth.getUser(uidRaw).catch(() => {
throw new HttpError(404, "auth user not found", "USER_NOT_FOUND"); throw new HttpError(404, "auth user not found", "USER_NOT_FOUND");
}); });
if (record.customClaims?.admin === true) { const currentIsAdmin = record.customClaims?.admin === true;
return { uid: uidRaw, isAdmin: true, alreadyAdmin: true }; if (currentIsAdmin === isAdminRaw) {
return { uid: uidRaw, isAdmin: isAdminRaw, changed: false };
} }
await auth.setCustomUserClaims(uidRaw, { const nextClaims: Record<string, unknown> = { ...(record.customClaims ?? {}) };
...(record.customClaims ?? {}), if (isAdminRaw) nextClaims.admin = true;
admin: true, else delete nextClaims.admin;
}); await auth.setCustomUserClaims(uidRaw, nextClaims);
return { uid: uidRaw, isAdmin: true, alreadyAdmin: false }; return { uid: uidRaw, isAdmin: isAdminRaw, changed: true };
} }
/** /**

View File

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { auth, firestore } from "../../src/firebase"; import { auth, firestore } from "../../src/firebase";
import { appointAdmin, listAdminUsers, searchAdminUsers } from "../../src/services/adminUserService"; import { listAdminUsers, searchAdminUsers, setAdminRole } from "../../src/services/adminUserService";
// 다른 테스트가 만드는 users 문서와 섞이지 않도록 고유 접두어를 쓴다. // 다른 테스트가 만드는 users 문서와 섞이지 않도록 고유 접두어를 쓴다.
const PREFIX = "검색전용"; const PREFIX = "검색전용";
@ -71,34 +71,49 @@ describe("searchAdminUsers", () => {
}); });
}); });
describe("appointAdmin", () => { describe("setAdminRole", () => {
it("기존 custom claims를 보존하면서 관리자 권한을 부여하고 재호출은 멱등이다", async () => { it("기존 custom claims를 보존하면서 관리자 권한을 부여·해제하고 재호출은 멱등이다", async () => {
const uid = "admin-user-search-test-1"; const uid = "admin-user-search-test-1";
await auth.setCustomUserClaims(uid, { betaTester: true }); await auth.setCustomUserClaims(uid, { betaTester: true });
await expect(appointAdmin(uid, "granting-admin")).resolves.toEqual({ await expect(setAdminRole(uid, true, "granting-admin")).resolves.toEqual({
uid, uid,
isAdmin: true, isAdmin: true,
alreadyAdmin: false, changed: true,
}); });
const record = await auth.getUser(uid); const record = await auth.getUser(uid);
expect(record.customClaims).toMatchObject({ expect(record.customClaims).toMatchObject({
betaTester: true, betaTester: true,
admin: true, admin: true,
}); });
await expect(appointAdmin(uid, "granting-admin")).resolves.toMatchObject({ await expect(setAdminRole(uid, true, "granting-admin")).resolves.toMatchObject({
isAdmin: true, isAdmin: true,
alreadyAdmin: true, changed: false,
}); });
const [dto] = await searchAdminUsers(uid, 10); const [dto] = await searchAdminUsers(uid, 10);
expect(dto.isAdmin).toBe(true); 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 () => { it("비활성 유저는 관리자로 임명할 수 없다", async () => {
await expect( 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" }); ).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", () => { describe("listAdminUsers", () => {