feat: 관리자가 사유를 남겨 탈퇴 예약을 취소하도록 추가한다

This commit is contained in:
Hide_D committed 2026-11-07 20:48:19 +09:00
1 parent dcecb32ef4
commit d1cdf8a85f
5 files changed
+366 -14

No files matched your search

+25
View File
@@ -1161,6 +1161,31 @@ export const adminRouter = router({
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-scheduled-withdrawal');
return { ok: true, deleteAfter: deleteAfter.toISOString() };
}),
cancelDeletion: userAdminProcedure
.input(z.object({ userId: z.string().min(1), reason: z.string().trim().min(3).max(200) }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.users.findById(input.userId);
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
assertTargetUserManageable(requireAdminAuth(ctx), user);
if (!user.deleteAfter) {
throw new TRPCError({ code: 'CONFLICT', message: '탈퇴 예약이 없는 계정입니다.' });
}
// 관리자 복구는 보존 기한과 무관하게 허용하되, 조회한 예약만 해제한다.
// revision 증가로 이전 session과 사용자 복구 증명의 재사용을 막는다.
if (!(await ctx.users.cancelDeletion(user.id, user.deleteAfter, user.authRevision ?? 0))) {
throw new TRPCError({
code: 'CONFLICT',
message: '계정 상태가 변경되었습니다. 다시 조회해 주세요.',
});
}
let flushPublished = true;
try {
await ctx.flushPublisher.publishUserFlush(user.id, 'admin-cancelled-withdrawal');
} catch {
flushPublished = false;
}
return { ok: true, deleteAfter: null, flushPublished };
}),
forceDelete: userAdminProcedure
.input(
z.object({
+91 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
@@ -25,6 +25,7 @@ const buildCaller = async (
createOperation: GatewayProfileRepository['createOperation'],
options: {
adminRoles?: string[];
flushError?: Error;
firstUserIsAdmin?: boolean;
runtimeActionCreateError?: unknown;
initialNotice?: string;
@@ -242,6 +243,7 @@ const buildCaller = async (
sessions,
flushPublisher: {
publishUserFlush: async (userId, reason, metadata) => {
if (options.flushError) throw options.flushError;
flushes.push({
userId,
reason,
@@ -2216,6 +2218,94 @@ describe('Gateway administrator account controls', () => {
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it.each([new Date('2020-01-01T00:00:00Z'), new Date('2099-01-01T00:00:00Z')])(
'lets a user administrator cancel deletion dated %s with an audited reason',
async (deadline) => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage'],
firstUserIsAdmin: false,
});
const target = await harness.users.createUser({ username: 'restore-target', password: 'secretpass' });
await harness.users.updateSanctions(target.id, { bannedUntil: '2099-01-01T00:00:00Z' });
await harness.users.scheduleDeletion(target.id, deadline);
const before = structuredClone(await harness.users.findById(target.id));
expect(
await harness.caller.admin.users.cancelDeletion({
userId: target.id,
reason: '고객 본인 확인 후 탈퇴 취소',
})
).toEqual({ ok: true, deleteAfter: null, flushPublished: true });
const after = await harness.users.findById(target.id);
expect(after?.deleteAfter).toBeUndefined();
expect(after?.authRevision).toBe((before?.authRevision ?? 0) + 1);
expect(after?.sanctions).toEqual(before?.sanctions);
expect(after?.passwordHash).toBe(before?.passwordHash);
expect(after?.oauthId).toBe(before?.oauthId);
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-cancelled-withdrawal' });
expect(harness.auditEvents.map((event) => event.outcome)).toEqual(['STARTED', 'SUCCEEDED']);
expect(harness.auditEvents[1]).toMatchObject({
action: 'admin.users.cancelDeletion',
capability: 'admin.users.manage',
actorUserId: harness.admin.id,
targetId: target.id,
reason: '고객 본인 확인 후 탈퇴 취소',
});
await expect(
harness.caller.admin.users.cancelDeletion({ userId: target.id, reason: '중복 취소 요청' })
).rejects.toMatchObject({ code: 'CONFLICT' });
expect(harness.auditEvents.at(-1)).toMatchObject({ outcome: 'FAILED', errorCode: 'CONFLICT' });
}
);
it('denies missing scope, protected root targets, bad reasons and missing targets without mutation', async () => {
for (const roles of [['admin.notice.manage'], ['admin.users.manage']]) {
const h = await buildCaller(unusedCreateOperation, { adminRoles: roles, firstUserIsAdmin: false });
const target = await h.users.createUser({ username: 'protected-restore', password: 'secretpass' });
await h.users.updateRoles(target.id, ['superuser']);
await h.users.scheduleDeletion(target.id, new Date('2020-01-01'));
const cancel = vi.spyOn(h.users, 'cancelDeletion');
await expect(
h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '권한 없는 복구 시도' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(cancel).not.toHaveBeenCalled();
expect(h.flushes).toHaveLength(0);
expect(h.auditEvents.at(-1)).toMatchObject({ outcome: 'FAILED', errorCode: 'FORBIDDEN' });
}
const h = await buildCaller(unusedCreateOperation);
await expect(h.caller.admin.users.cancelDeletion({ userId: h.admin.id, reason: ' ' })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(
h.caller.admin.users.cancelDeletion({ userId: 'missing-user', reason: '없는 계정 복구' })
).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('keeps a newer deletion reservation when compare-and-swap fails', async () => {
const h = await buildCaller(unusedCreateOperation);
const target = await h.users.createUser({ username: 'rescheduled-restore', password: 'secretpass' });
await h.users.scheduleDeletion(target.id, new Date('2020-01-01'));
vi.spyOn(h.users, 'cancelDeletion').mockImplementation(async () => {
await h.users.scheduleDeletion(target.id, new Date('2099-01-01'));
return false;
});
await expect(
h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '재예약 충돌 확인' })
).rejects.toMatchObject({ code: 'CONFLICT' });
expect((await h.users.findById(target.id))?.deleteAfter).toBe('2099-01-01T00:00:00.000Z');
expect(h.flushes).toHaveLength(0);
});
it('reports a committed cancellation as success when flush delivery fails', async () => {
const h = await buildCaller(unusedCreateOperation, { flushError: new Error('synthetic flush failure') });
const target = await h.users.createUser({ username: 'flush-restore', password: 'secretpass' });
await h.users.scheduleDeletion(target.id, new Date('2020-01-01'));
expect(
await h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '고객 요청 복구' })
).toMatchObject({ ok: true, flushPublished: false });
expect((await h.users.findById(target.id))?.deleteAfter).toBeUndefined();
expect(h.auditEvents.at(-1)).toMatchObject({ outcome: 'SUCCEEDED' });
});
it('prevents a delegated administrator from changing a root administrator', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage'],
@@ -505,4 +505,46 @@ describe('admin security over HTTP transport', () => {
},
});
});
it('requires a valid authorized administrator to cancel deletion over HTTP and invalidates prior sessions', async () => {
const h = await createHarness();
await h.users.scheduleDeletion(h.target.id, new Date('2020-01-01'));
const input = { userId: h.target.id, reason: '본인 확인 후 관리자 복구' };
expect((await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input)).response.status).toBe(401);
expect(
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.targetSessionToken)).response.status
).toBe(403);
expect(
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', { ...input, reason: ' ' }, h.adminSessionToken))
.response.status
).toBe(400);
await h.users.updateRoles(h.target.id, ['superuser']);
expect(
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status
).toBe(403);
expect(h.flushes).toHaveLength(0);
expect((await h.users.findById(h.target.id))?.deleteAfter).toBeDefined();
await h.users.updateRoles(h.target.id, ['user']);
expect(
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status
).toBe(200);
expect((await h.users.findById(h.target.id))?.deleteAfter).toBeUndefined();
const me = await fetch(`${h.baseUrl}/trpc/me`, { headers: { 'x-session-token': h.targetSessionToken } });
expect(await me.json()).toMatchObject({ result: { data: null } });
expect(
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status
).toBe(409);
const noScope = await createHarness(['user', 'admin.notice.manage']);
await noScope.users.scheduleDeletion(noScope.target.id, new Date('2020-01-01'));
expect(
(
await postTrpc(
noScope.baseUrl,
'admin.users.cancelDeletion',
{ userId: noScope.target.id, reason: '사용자 관리 권한 없음' },
noScope.adminSessionToken
)
).response.status
).toBe(403);
expect((await noScope.users.findById(noScope.target.id))?.deleteAfter).toBeDefined();
});
});