From 4634c132f9fb057a51c685755df794dc12ab5e59 Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Fri, 4 Sep 2026 02:08:59 +0900 Subject: [PATCH 1/6] fix(db): allow repeated club manager assignments --- .github/workflows/ci.yml | 16 ++ .../club-manager-reversal.migration.test.ts | 226 ++++++++++++++++++ apps/web/sql/260904_club_manager_reversal.sql | 53 ++++ 3 files changed, 295 insertions(+) create mode 100644 apps/web/server/infra/database/club-manager-reversal.migration.test.ts create mode 100644 apps/web/sql/260904_club_manager_reversal.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78aa1006..c2af5726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,22 @@ jobs: lint-and-typecheck: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + steps: - name: Checkout uses: actions/checkout@v5.0.1 diff --git a/apps/web/server/infra/database/club-manager-reversal.migration.test.ts b/apps/web/server/infra/database/club-manager-reversal.migration.test.ts new file mode 100644 index 00000000..29fd370b --- /dev/null +++ b/apps/web/server/infra/database/club-manager-reversal.migration.test.ts @@ -0,0 +1,226 @@ +import 'reflect-metadata' +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { DataSource, QueryFailedError } from 'typeorm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('../../provider', () => ({ + InjectRepository: () => () => undefined, + Service: () => undefined, +})) + +import type { User } from '../../domain/model/User' +import { + ClubManagerTransferService, + hashManagerTransferToken, +} from '../../service/club-manager-transfer.service' +import { + ClubEntity, + ClubManagerEntity, + ClubManagerTransferInvitationEntity, + UserNotificationEntity, +} from './entities' +import { CollegeMajorEntity } from './entities/college-major.entity' + +const testDatabaseUrl = process.env.TEST_DATABASE_URL +const describeWithPostgres = testDatabaseUrl ? describe : describe.skip + +const clubUuid = '4dfcd19f-9f20-4128-8b4c-b76deab4b65d' +const firstManagerServiceUserId = 'beee3485-6f87-4db0-b69f-c300f7c47291' +const secondManagerServiceUserId = 'f293f05e-7a0f-4da5-b028-aa8ba84c26a2' +const thirdManagerServiceUserId = '04cc77e4-b65d-4df5-b4d6-3a52bdd49974' + +const createUser = (serviceUserId: string, name: string): User => ({ + id: randomUUID(), + serviceUserId, + nickname: name, + name, + phone: '', + email: '', + collegeMajor: null, + admissionClass: 23, +}) + +describeWithPostgres('club manager reversal migration (PostgreSQL)', () => { + const databaseName = `allclear_manager_transfer_${process.pid}_${randomUUID().slice(0, 8)}` + let adminDataSource: DataSource + let testDataSource: DataSource + let transferService: ClubManagerTransferService + + beforeAll(async () => { + adminDataSource = new DataSource({ type: 'postgres', url: testDatabaseUrl }) + await adminDataSource.initialize() + await adminDataSource.query(`CREATE DATABASE "${databaseName}"`) + + const databaseUrl = new URL(testDatabaseUrl as string) + databaseUrl.pathname = `/${databaseName}` + testDataSource = new DataSource({ + type: 'postgres', + url: databaseUrl.toString(), + entities: [ + ClubEntity, + ClubManagerEntity, + ClubManagerTransferInvitationEntity, + CollegeMajorEntity, + UserNotificationEntity, + ], + synchronize: true, + }) + await testDataSource.initialize() + await testDataSource.query(` + ALTER TABLE public.club_manager + ADD CONSTRAINT club_manager_clubid_serviceuserid UNIQUE (club_id, service_user_id) + `) + await testDataSource.query(` + CREATE UNIQUE INDEX uq_club_manager_active_club + ON public.club_manager (club_id) + WHERE deleted_at IS NULL + `) + + transferService = Object.create( + ClubManagerTransferService.prototype, + ) as ClubManagerTransferService + Object.defineProperty(transferService, 'invitationRepository', { + value: testDataSource.getRepository(ClubManagerTransferInvitationEntity), + }) + Object.defineProperty(transferService, 'clubRepository', { + value: testDataSource.getRepository(ClubEntity), + }) + + await testDataSource.getRepository(ClubEntity).insert({ + uuid: clubUuid, + name: '와플스튜디오', + }) + await testDataSource.getRepository(ClubManagerEntity).insert({ + clubId: clubUuid, + serviceUserId: firstManagerServiceUserId, + name: '첫 관리자', + phone: '', + studentId: '23학번', + }) + }, 60_000) + + afterAll(async () => { + if (testDataSource?.isInitialized) { + await testDataSource.destroy() + } + if (adminDataSource?.isInitialized) { + await adminDataSource.query( + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', + [databaseName], + ) + await adminDataSource.query(`DROP DATABASE IF EXISTS "${databaseName}"`) + await adminDataSource.destroy() + } + }) + + it('allows A -> B -> A while preserving one active manager and acceptance atomicity', async () => { + const invitationRepository = testDataSource.getRepository(ClubManagerTransferInvitationEntity) + const firstToken = 'a'.repeat(43) + await invitationRepository.insert({ + clubId: clubUuid, + senderServiceUserId: firstManagerServiceUserId, + tokenHash: hashManagerTransferToken(firstToken), + expiresAt: '2099-01-01T00:00:00.000Z', + revokedAt: null, + acceptedByServiceUserId: null, + acceptedAt: null, + }) + + await transferService.acceptInvitation( + firstToken, + createUser(secondManagerServiceUserId, '두 번째 관리자'), + ) + + const reversalToken = 'b'.repeat(43) + await invitationRepository.insert({ + clubId: clubUuid, + senderServiceUserId: secondManagerServiceUserId, + tokenHash: hashManagerTransferToken(reversalToken), + expiresAt: '2099-01-01T00:00:00.000Z', + revokedAt: null, + acceptedByServiceUserId: null, + acceptedAt: null, + }) + + await expect( + transferService.acceptInvitation( + reversalToken, + createUser(firstManagerServiceUserId, '첫 관리자'), + ), + ).rejects.toBeInstanceOf(QueryFailedError) + + const stateAfterFailedReversal = await testDataSource.query>( + 'SELECT service_user_id FROM public.club_manager WHERE club_id = $1 AND deleted_at IS NULL', + [clubUuid], + ) + expect(stateAfterFailedReversal).toEqual([{ service_user_id: secondManagerServiceUserId }]) + const pendingInvitation = await invitationRepository.findOneByOrFail({ + tokenHash: hashManagerTransferToken(reversalToken), + }) + expect(pendingInvitation.acceptedAt).toBeNull() + expect(pendingInvitation.acceptedByServiceUserId).toBeNull() + + const migrationSql = await readFile( + new URL('../../../sql/260904_club_manager_reversal.sql', import.meta.url), + 'utf8', + ) + await testDataSource.query(migrationSql) + + await expect( + transferService.acceptInvitation( + reversalToken, + createUser(firstManagerServiceUserId, '첫 관리자'), + ), + ).resolves.toEqual({ club_uuid: clubUuid, club_name: '와플스튜디오' }) + + const managerHistory = await testDataSource.query< + Array<{ service_user_id: string; is_active: boolean }> + >( + `SELECT service_user_id, deleted_at IS NULL AS is_active + FROM public.club_manager + WHERE club_id = $1 + ORDER BY id`, + [clubUuid], + ) + expect(managerHistory).toEqual([ + { service_user_id: firstManagerServiceUserId, is_active: false }, + { service_user_id: secondManagerServiceUserId, is_active: false }, + { service_user_id: firstManagerServiceUserId, is_active: true }, + ]) + + const notificationCountAfterReversal = await testDataSource + .getRepository(UserNotificationEntity) + .count() + await expect( + transferService.acceptInvitation( + reversalToken, + createUser(firstManagerServiceUserId, '첫 관리자'), + ), + ).resolves.toEqual({ club_uuid: clubUuid, club_name: '와플스튜디오' }) + expect(await testDataSource.getRepository(UserNotificationEntity).count()).toBe( + notificationCountAfterReversal, + ) + + let activeManagerConflict: unknown + try { + await testDataSource.getRepository(ClubManagerEntity).insert({ + clubId: clubUuid, + serviceUserId: thirdManagerServiceUserId, + name: '세 번째 관리자', + phone: '', + studentId: '23학번', + }) + } catch (error) { + activeManagerConflict = error + } + expect(activeManagerConflict).toBeInstanceOf(QueryFailedError) + expect( + ( + activeManagerConflict as QueryFailedError & { + driverError: { code: string; constraint: string } + } + ).driverError, + ).toMatchObject({ code: '23505', constraint: 'uq_club_manager_active_club' }) + }, 30_000) +}) diff --git a/apps/web/sql/260904_club_manager_reversal.sql b/apps/web/sql/260904_club_manager_reversal.sql new file mode 100644 index 00000000..f4aa5aa1 --- /dev/null +++ b/apps/web/sql/260904_club_manager_reversal.sql @@ -0,0 +1,53 @@ +/* + * 동아리 관리자 역방향 권한 이전 허용 + * + * club_manager의 soft-deleted 행은 관리자 이력으로 보존합니다. 따라서 동일한 + * 사용자가 같은 동아리의 관리자가 다시 될 수 있도록 전체 이력에 적용되던 + * (club_id, service_user_id) UNIQUE 제약을 제거합니다. + * + * 활성 관리자 한 명 제약은 deleted_at IS NULL인 행에만 적용되는 partial unique + * index로 계속 보장합니다. + */ + +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT club_id + FROM public.club_manager + WHERE deleted_at IS NULL + GROUP BY club_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'active club_manager duplicates must be resolved before migration'; + END IF; +END +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_club_manager_active_club +ON public.club_manager (club_id) +WHERE deleted_at IS NULL; + +ALTER TABLE public.club_manager + DROP CONSTRAINT IF EXISTS club_manager_clubid_serviceuserid; + +COMMIT; + +/* + * Rollback (애플리케이션 롤백 후 수동 실행) + * + * 이 migration 적용 뒤 A -> B -> A 이전이 한 번이라도 완료되면 같은 + * (club_id, service_user_id) 이력 행이 둘 이상 생기므로 아래 UNIQUE 제약은 + * 그대로 복원할 수 없습니다. 롤백 전에 중복 이력을 별도 보관·정리하고 다음을 + * 실행해야 합니다. 활성 관리자 partial unique index는 제거하지 않습니다. + * + * SELECT club_id, service_user_id, COUNT(*) + * FROM public.club_manager + * GROUP BY club_id, service_user_id + * HAVING COUNT(*) > 1; + * + * ALTER TABLE public.club_manager + * ADD CONSTRAINT club_manager_clubid_serviceuserid + * UNIQUE (club_id, service_user_id); + */ From 410d05928e0cb94cd8dfe0067a56b798e50b0d2c Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Fri, 4 Sep 2026 02:13:08 +0900 Subject: [PATCH 2/6] fix: distinguish manager transfer failures --- .../club/utils/managerTransfer.test.ts | 55 +++++++++--- .../features/club/utils/managerTransfer.ts | 29 +++++-- apps/mobile/src/shared/utils/apiError.ts | 3 + .../[token]/acceptance.test.ts | 83 +++++++++++++++++++ .../[token]/acceptance.ts | 6 +- .../club-manager-reversal.migration.test.ts | 3 +- .../club-manager-transfer.service.test.ts | 39 +++++++++ .../service/club-manager-transfer.service.ts | 29 ++++++- apps/web/server/util/safe-error.test.ts | 43 ++++++++++ apps/web/server/util/safe-error.ts | 41 +++++++++ 10 files changed, 309 insertions(+), 22 deletions(-) create mode 100644 apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts create mode 100644 apps/web/server/util/safe-error.test.ts create mode 100644 apps/web/server/util/safe-error.ts diff --git a/apps/mobile/src/features/club/utils/managerTransfer.test.ts b/apps/mobile/src/features/club/utils/managerTransfer.test.ts index 3ed21fa8..9f31c5d4 100644 --- a/apps/mobile/src/features/club/utils/managerTransfer.test.ts +++ b/apps/mobile/src/features/club/utils/managerTransfer.test.ts @@ -10,7 +10,10 @@ import { jest.mock("axios", () => ({ isAxiosError: (error: unknown) => - typeof error === "object" && error !== null && "response" in error, + typeof error === "object" && + error !== null && + "isAxiosError" in error && + error.isAxiosError === true, })); jest.mock("@react-native-clipboard/clipboard", () => ({ @@ -54,20 +57,45 @@ describe("manager transfer UI helpers", () => { [401, "로그인이 필요해요"], [403, "권한 이전 링크를 만들 수 없어요"], [404, "유효하지 않은 이전 링크예요"], - [409, "본인에게는 권한을 이전할 수 없어요"], + [409, "권한 이전 상태가 변경되었어요"], ])("API 상태 %s에 맞는 안내를 반환한다", (status, title) => { - expect(getManagerTransferErrorContent({ response: { status } }).title).toBe( - title, - ); + expect( + getManagerTransferErrorContent({ + isAxiosError: true, + response: { status }, + }).title, + ).toBe(title); }); - it("알 수 없는 오류는 재시도 가능한 안내를 반환한다", () => { - expect(getManagerTransferErrorContent(new Error("network"))).toEqual({ + it("실제 네트워크 오류는 네트워크 안내를 반환한다", () => { + expect( + getManagerTransferErrorContent({ isAxiosError: true, request: {} }), + ).toEqual({ title: "관리자 권한을 이전하지 못했어요", description: "네트워크 상태를 확인한 후 다시 시도해주세요", }); }); + it("서버 500 오류는 네트워크 문제가 아닌 서버 처리 실패로 안내한다", () => { + expect( + getManagerTransferErrorContent({ + isAxiosError: true, + response: { status: 500 }, + }), + ).toEqual({ + title: "관리자 권한을 이전하지 못했어요", + description: + "서버에서 처리 중 문제가 발생했어요. 잠시 후 다시 시도해주세요", + }); + }); + + it("앱 내부 오류를 네트워크 오류로 오인하지 않는다", () => { + expect(getManagerTransferErrorContent(new Error("unexpected"))).toEqual({ + title: "관리자 권한을 이전하지 못했어요", + description: "요청을 처리하는 중 문제가 발생했어요. 다시 시도해주세요", + }); + }); + it.each([ [401, "exit"], [403, "exit"], @@ -75,12 +103,17 @@ describe("manager transfer UI helpers", () => { [409, "exit"], [500, "retry"], ])("API 상태 %s에 맞는 후속 동작을 반환한다", (status, action) => { - expect(getManagerTransferErrorAction({ response: { status } })).toBe( - action, - ); + expect( + getManagerTransferErrorAction({ + isAxiosError: true, + response: { status }, + }), + ).toBe(action); }); it("네트워크 오류는 재시도할 수 있다", () => { - expect(getManagerTransferErrorAction(new Error("network"))).toBe("retry"); + expect( + getManagerTransferErrorAction({ isAxiosError: true, request: {} }), + ).toBe("retry"); }); }); diff --git a/apps/mobile/src/features/club/utils/managerTransfer.ts b/apps/mobile/src/features/club/utils/managerTransfer.ts index df9b3906..77e551e1 100644 --- a/apps/mobile/src/features/club/utils/managerTransfer.ts +++ b/apps/mobile/src/features/club/utils/managerTransfer.ts @@ -1,5 +1,5 @@ import Clipboard from "@react-native-clipboard/clipboard"; -import { getApiErrorStatus } from "@/shared/utils/apiError"; +import { getApiErrorStatus, isApiNetworkError } from "@/shared/utils/apiError"; import type { AppModalFlowState } from "@/shared/utils/appModalFlow"; export type ManagerTransferErrorContent = { @@ -7,11 +7,21 @@ export type ManagerTransferErrorContent = { description: string; }; -const DEFAULT_ERROR_CONTENT: ManagerTransferErrorContent = { +const NETWORK_ERROR_CONTENT: ManagerTransferErrorContent = { title: "관리자 권한을 이전하지 못했어요", description: "네트워크 상태를 확인한 후 다시 시도해주세요", }; +const SERVER_ERROR_CONTENT: ManagerTransferErrorContent = { + title: "관리자 권한을 이전하지 못했어요", + description: "서버에서 처리 중 문제가 발생했어요. 잠시 후 다시 시도해주세요", +}; + +const UNKNOWN_ERROR_CONTENT: ManagerTransferErrorContent = { + title: "관리자 권한을 이전하지 못했어요", + description: "요청을 처리하는 중 문제가 발생했어요. 다시 시도해주세요", +}; + export const MANAGER_TRANSFER_LINK_COPIED_DESCRIPTION = "관리자 권한을 이전할 상대에게\n링크를 보내주세요"; @@ -41,8 +51,8 @@ const ERROR_CONTENT_BY_STATUS: Partial< description: "링크가 만료되었거나 이미 사용되었어요", }, 409: { - title: "본인에게는 권한을 이전할 수 없어요", - description: "다른 올클 회원에게 링크를 전달해주세요", + title: "권한 이전 상태가 변경되었어요", + description: "동아리 관리자 상태를 확인한 후 다시 시도해주세요", }, }; @@ -50,7 +60,16 @@ export const getManagerTransferErrorContent = ( error: unknown, ): ManagerTransferErrorContent => { const status = getApiErrorStatus(error); - return (status && ERROR_CONTENT_BY_STATUS[status]) || DEFAULT_ERROR_CONTENT; + if (status && ERROR_CONTENT_BY_STATUS[status]) { + return ERROR_CONTENT_BY_STATUS[status]; + } + if (status && status >= 500) { + return SERVER_ERROR_CONTENT; + } + if (isApiNetworkError(error)) { + return NETWORK_ERROR_CONTENT; + } + return UNKNOWN_ERROR_CONTENT; }; export const getManagerTransferErrorAction = ( diff --git a/apps/mobile/src/shared/utils/apiError.ts b/apps/mobile/src/shared/utils/apiError.ts index 9bb945c3..d76533a2 100644 --- a/apps/mobile/src/shared/utils/apiError.ts +++ b/apps/mobile/src/shared/utils/apiError.ts @@ -7,3 +7,6 @@ export const getApiErrorStatus = (error: unknown): number | undefined => { return error.response?.status; }; + +export const isApiNetworkError = (error: unknown): boolean => + axios.isAxiosError(error) && error.response == null; diff --git a/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts b/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts new file mode 100644 index 00000000..59f899dc --- /dev/null +++ b/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts @@ -0,0 +1,83 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getService: vi.fn(), + getUserByAccountId: vi.fn(), + acceptInvitation: vi.fn(), +})) + +vi.mock('server/provider', () => ({ + InjectRepository: () => () => undefined, + Provider: { getService: mocks.getService }, + Service: () => undefined, +})) + +vi.mock('server/service/club-manager-transfer.service', () => ({ + ClubManagerTransferService: class ClubManagerTransferService {}, +})) + +vi.mock('server/service/user.service', () => ({ + UserService: class UserService {}, +})) + +import { ClubManagerTransferService } from 'server/service/club-manager-transfer.service' +import { UserService } from 'server/service/user.service' +import handler from './acceptance' + +const token = 'a'.repeat(43) + +const createResponse = () => { + const response = { + status: vi.fn(), + send: vi.fn(), + json: vi.fn(), + end: vi.fn(), + } + response.status.mockReturnValue(response) + response.send.mockReturnValue(response) + response.json.mockReturnValue(response) + response.end.mockReturnValue(response) + return response +} + +describe('manager transfer acceptance API', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getService.mockImplementation((service) => { + if (service === UserService) { + return { getUserByAccountId: mocks.getUserByAccountId } + } + if (service === ClubManagerTransferService) { + return { acceptInvitation: mocks.acceptInvitation } + } + throw new Error('unexpected service') + }) + mocks.getUserByAccountId.mockResolvedValue({ serviceUserId: 'recipient' }) + }) + + it('returns an opaque 500 and logs only safe metadata for unexpected failures', async () => { + const failure = new Error(`database failed for ${token}`) + mocks.acceptInvitation.mockRejectedValue(failure) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const response = createResponse() + + await handler( + { + method: 'PUT', + headers: { user: 'account-id' }, + query: { token }, + } as unknown as NextApiRequest, + response as unknown as NextApiResponse, + ) + + expect(response.status).toHaveBeenCalledWith(500) + expect(response.send).toHaveBeenCalledWith('Internal Server Error') + expect(consoleError).toHaveBeenCalledWith('accept manager transfer invitation failed', { + errorName: 'Error', + }) + expect(JSON.stringify(consoleError.mock.calls)).not.toContain(token) + + consoleError.mockRestore() + }) +}) diff --git a/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.ts b/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.ts index 7e28f25d..88dbff1a 100644 --- a/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.ts +++ b/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.ts @@ -3,6 +3,7 @@ import { ConflictError, NotFoundError, UserNotFoundError } from 'server/domain/e import { Provider } from 'server/provider' import { ClubManagerTransferService } from 'server/service/club-manager-transfer.service' import { UserService } from 'server/service/user.service' +import { getSafeDatabaseErrorContext } from 'server/util/safe-error' import { type ManagerTransferAcceptanceResponse, ManagerTransferTokenParamsSchema, @@ -42,10 +43,7 @@ export default async function handler( if (error instanceof z.ZodError) { return res.status(400).json(error.errors) } - console.error( - 'accept manager transfer invitation failed', - error instanceof Error ? error.name : '', - ) + console.error('accept manager transfer invitation failed', getSafeDatabaseErrorContext(error)) return res.status(500).send('Internal Server Error') } } diff --git a/apps/web/server/infra/database/club-manager-reversal.migration.test.ts b/apps/web/server/infra/database/club-manager-reversal.migration.test.ts index 29fd370b..07fb110c 100644 --- a/apps/web/server/infra/database/club-manager-reversal.migration.test.ts +++ b/apps/web/server/infra/database/club-manager-reversal.migration.test.ts @@ -9,6 +9,7 @@ vi.mock('../../provider', () => ({ Service: () => undefined, })) +import { ConflictError } from '../../domain/error' import type { User } from '../../domain/model/User' import { ClubManagerTransferService, @@ -148,7 +149,7 @@ describeWithPostgres('club manager reversal migration (PostgreSQL)', () => { reversalToken, createUser(firstManagerServiceUserId, '첫 관리자'), ), - ).rejects.toBeInstanceOf(QueryFailedError) + ).rejects.toBeInstanceOf(ConflictError) const stateAfterFailedReversal = await testDataSource.query>( 'SELECT service_user_id FROM public.club_manager WHERE club_id = $1 AND deleted_at IS NULL', diff --git a/apps/web/server/service/club-manager-transfer.service.test.ts b/apps/web/server/service/club-manager-transfer.service.test.ts index 30ea1630..3a6eded8 100644 --- a/apps/web/server/service/club-manager-transfer.service.test.ts +++ b/apps/web/server/service/club-manager-transfer.service.test.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { QueryFailedError } from 'typeorm' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../provider', () => ({ @@ -74,6 +75,7 @@ const createTestContext = () => { return { service, + transaction, invitationRepository, clubManagerRepository, clubRepository, @@ -81,6 +83,16 @@ const createTestContext = () => { } } +const createUniqueViolation = (constraint: string): QueryFailedError => + new QueryFailedError( + 'INSERT INTO club_manager ...', + ['sensitive-value'], + Object.assign(new Error('unique violation'), { + code: '23505', + constraint, + }), + ) + describe('ClubManagerTransferService', () => { beforeEach(() => { vi.useFakeTimers() @@ -233,6 +245,33 @@ describe('ClubManagerTransferService', () => { expect(notificationRepository.insert).toHaveBeenCalledTimes(1) }) + it.each(['uq_club_manager_active_club', 'club_manager_clubid_serviceuserid'])( + 'maps the %s database conflict to a safe domain conflict', + async (constraint) => { + const { service, transaction } = createTestContext() + transaction.mockRejectedValue(createUniqueViolation(constraint)) + + await expect(service.acceptInvitation(rawToken, recipient)).rejects.toBeInstanceOf( + ConflictError, + ) + }, + ) + + it('does not hide unexpected database failures', async () => { + const { service, transaction } = createTestContext() + const databaseFailure = new QueryFailedError( + 'INSERT INTO club_manager ...', + [], + Object.assign(new Error('foreign key violation'), { + code: '23503', + constraint: 'club_manager_club_id_fkey', + }), + ) + transaction.mockRejectedValue(databaseFailure) + + await expect(service.acceptInvitation(rawToken, recipient)).rejects.toBe(databaseFailure) + }) + it('rejects a self-transfer before mutating manager state', async () => { const { service, invitationRepository, clubManagerRepository } = createTestContext() invitationRepository.findOne.mockResolvedValue({ diff --git a/apps/web/server/service/club-manager-transfer.service.ts b/apps/web/server/service/club-manager-transfer.service.ts index 829ca012..0e914c6e 100644 --- a/apps/web/server/service/club-manager-transfer.service.ts +++ b/apps/web/server/service/club-manager-transfer.service.ts @@ -10,6 +10,12 @@ import { UserNotificationEntity, } from '../infra/database/entities' import { InjectRepository, Service } from '../provider' +import { getSafeDatabaseErrorContext } from '../util/safe-error' + +const MANAGER_TRANSFER_CONFLICT_CONSTRAINTS = new Set([ + 'uq_club_manager_active_club', + 'club_manager_clubid_serviceuserid', +]) const INVITATION_TTL_MS = 72 * 60 * 60 * 1000 @@ -112,7 +118,7 @@ export class ClubManagerTransferService { rawToken: string, recipient: User, ): Promise { - return this.invitationRepository.manager.transaction(async (transactionManager) => { + const acceptance = this.invitationRepository.manager.transaction(async (transactionManager) => { const invitationRepository = transactionManager.getRepository( ClubManagerTransferInvitationEntity, ) @@ -210,6 +216,27 @@ export class ClubManagerTransferService { return club }) + return this.withPersistenceConflictMapping(acceptance) + } + + private async withPersistenceConflictMapping(operation: Promise): Promise { + try { + return await operation + } catch (error) { + if (this.isManagerTransferPersistenceConflict(error)) { + throw new ConflictError('manager transfer state conflict') + } + throw error + } + } + + private isManagerTransferPersistenceConflict(error: unknown): boolean { + const { databaseCode, constraint } = getSafeDatabaseErrorContext(error) + return ( + databaseCode === '23505' && + typeof constraint === 'string' && + MANAGER_TRANSFER_CONFLICT_CONSTRAINTS.has(constraint) + ) } private isActive(invitation: ClubManagerTransferInvitationEntity): boolean { diff --git a/apps/web/server/util/safe-error.test.ts b/apps/web/server/util/safe-error.test.ts new file mode 100644 index 00000000..87123415 --- /dev/null +++ b/apps/web/server/util/safe-error.test.ts @@ -0,0 +1,43 @@ +import { QueryFailedError } from 'typeorm' +import { describe, expect, it } from 'vitest' +import { getSafeDatabaseErrorContext, getSafeErrorName } from './safe-error' + +describe('safe error logging', () => { + it('keeps an allowlisted error name without logging its message', () => { + const error = Object.assign(new Error('accessToken=secret-value'), { + name: 'AxiosError', + }) + + expect(getSafeErrorName(error)).toBe('AxiosError') + }) + + it('rejects an attacker-controlled error name', () => { + const error = Object.assign(new Error('secret-value'), { + name: 'token=secret-value', + }) + + expect(getSafeErrorName(error)).toBe('UnknownError') + }) + + it('returns only PostgreSQL code and constraint metadata', () => { + const error = new QueryFailedError( + 'INSERT accessToken=secret-value', + ['secret-value'], + Object.assign(new Error('unique violation'), { + code: '23505', + constraint: 'uq_club_manager_active_club', + detail: 'sensitive database row', + }), + ) + + const context = getSafeDatabaseErrorContext(error) + + expect(context).toEqual({ + errorName: 'QueryFailedError', + databaseCode: '23505', + constraint: 'uq_club_manager_active_club', + }) + expect(JSON.stringify(context)).not.toContain('secret-value') + expect(JSON.stringify(context)).not.toContain('sensitive database row') + }) +}) diff --git a/apps/web/server/util/safe-error.ts b/apps/web/server/util/safe-error.ts new file mode 100644 index 00000000..3b5b3721 --- /dev/null +++ b/apps/web/server/util/safe-error.ts @@ -0,0 +1,41 @@ +import { QueryFailedError } from 'typeorm' + +const SAFE_ERROR_NAME = /^[A-Za-z][A-Za-z0-9]{0,63}$/ +const SAFE_DATABASE_CODE = /^[A-Z0-9]{5}$/ +const SAFE_DATABASE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ + +export type SafeDatabaseErrorContext = { + errorName: string + databaseCode?: string + constraint?: string +} + +export const getSafeErrorName = (error: unknown): string => { + if (!(error instanceof Error) || !SAFE_ERROR_NAME.test(error.name)) { + return 'UnknownError' + } + return error.name +} + +export const getSafeDatabaseErrorContext = (error: unknown): SafeDatabaseErrorContext => { + const context: SafeDatabaseErrorContext = { errorName: getSafeErrorName(error) } + if (!(error instanceof QueryFailedError)) { + return context + } + + const driverError = ( + error as QueryFailedError & { + driverError?: { code?: unknown; constraint?: unknown } + } + ).driverError + if (typeof driverError?.code === 'string' && SAFE_DATABASE_CODE.test(driverError.code)) { + context.databaseCode = driverError.code + } + if ( + typeof driverError?.constraint === 'string' && + SAFE_DATABASE_IDENTIFIER.test(driverError.constraint) + ) { + context.constraint = driverError.constraint + } + return context +} From 1e8033fc1defdedd77aad0e32bcee2834069da30 Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Fri, 4 Sep 2026 02:13:23 +0900 Subject: [PATCH 3/6] fix(auth): remove sensitive OAuth logging --- .../pages/api/v2/auth/kakao/callback/index.ts | 9 ++- .../auth/kakao/native/callback/index.test.ts | 64 +++++++++++++++++++ .../v2/auth/kakao/native/callback/index.ts | 10 ++- apps/web/server/service/auth.service.test.ts | 52 +++++++++++++++ apps/web/server/service/auth.service.ts | 11 ++-- 5 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 apps/web/pages/api/v2/auth/kakao/native/callback/index.test.ts create mode 100644 apps/web/server/service/auth.service.test.ts diff --git a/apps/web/pages/api/v2/auth/kakao/callback/index.ts b/apps/web/pages/api/v2/auth/kakao/callback/index.ts index e2ff6a92..7edda150 100644 --- a/apps/web/pages/api/v2/auth/kakao/callback/index.ts +++ b/apps/web/pages/api/v2/auth/kakao/callback/index.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { ENV } from 'server/ENV' import { Provider } from 'server/provider' import { AuthService } from 'server/service/auth.service' +import { getSafeErrorName } from 'server/util/safe-error' import { KakaoCallbackQuerySchema } from 'src/lib/schemas/auth' import type { ZodIssue } from 'zod' import { z } from 'zod' @@ -49,7 +50,9 @@ export default async function handler( token, }) } catch (err) { - console.error('kakaoLoginCallback jwt.sign error', err) + console.error('kakaoLoginCallback jwt.sign error', { + errorName: getSafeErrorName(err), + }) return res.status(500).send('Internal Server Error') } } @@ -58,7 +61,9 @@ export default async function handler( if (err instanceof z.ZodError) { return res.status(400).json(err.errors) } - console.error('kakaoLoginCallback error: ', err) + console.error('kakaoLoginCallback error', { + errorName: getSafeErrorName(err), + }) return res.status(500).send('Internal Server Error') } } diff --git a/apps/web/pages/api/v2/auth/kakao/native/callback/index.test.ts b/apps/web/pages/api/v2/auth/kakao/native/callback/index.test.ts new file mode 100644 index 00000000..941dc98c --- /dev/null +++ b/apps/web/pages/api/v2/auth/kakao/native/callback/index.test.ts @@ -0,0 +1,64 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getService: vi.fn(), + getOrCreateKakaoUser: vi.fn(), + sign: vi.fn(), +})) + +vi.mock('server/provider', () => ({ + Provider: { getService: mocks.getService }, +})) + +vi.mock('server/service/auth.service', () => ({ + AuthService: class AuthService {}, +})) + +vi.mock('jsonwebtoken', () => ({ + default: { sign: mocks.sign }, +})) + +import handler from './index' + +const createResponse = () => { + const response = { + status: vi.fn(), + send: vi.fn(), + json: vi.fn(), + } + response.status.mockReturnValue(response) + response.send.mockReturnValue(response) + response.json.mockReturnValue(response) + return response +} + +describe('Kakao native callback logging', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getService.mockReturnValue({ + getOrCreateKakaoUser: mocks.getOrCreateKakaoUser, + }) + mocks.getOrCreateKakaoUser.mockResolvedValue('account-id') + mocks.sign.mockReturnValue('app-jwt') + }) + + it('does not log the raw Kakao access token', async () => { + const accessToken = 'raw-kakao-access-token' + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const response = createResponse() + + await handler( + { + method: 'POST', + body: { accessToken }, + } as unknown as NextApiRequest, + response as unknown as NextApiResponse, + ) + + expect(response.status).toHaveBeenCalledWith(200) + expect(consoleLog).not.toHaveBeenCalled() + expect(JSON.stringify(consoleLog.mock.calls)).not.toContain(accessToken) + consoleLog.mockRestore() + }) +}) diff --git a/apps/web/pages/api/v2/auth/kakao/native/callback/index.ts b/apps/web/pages/api/v2/auth/kakao/native/callback/index.ts index f64b392f..6f9e19f1 100644 --- a/apps/web/pages/api/v2/auth/kakao/native/callback/index.ts +++ b/apps/web/pages/api/v2/auth/kakao/native/callback/index.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { ENV } from 'server/ENV' import { Provider } from 'server/provider' import { AuthService } from 'server/service/auth.service' +import { getSafeErrorName } from 'server/util/safe-error' import { KakaoNativeCallbackPayloadSchema } from 'src/lib/schemas/auth' import type { ZodIssue } from 'zod' import { z } from 'zod' @@ -20,7 +21,6 @@ export default async function handler( if (req.method == 'POST') { const { accessToken } = KakaoNativeCallbackPayloadSchema.parse(req.body) - console.log(accessToken) if (!accessToken) { return res.status(401).send('Unauthorized') } @@ -34,7 +34,9 @@ export default async function handler( token, }) } catch (err) { - console.error('kakaoLoginNativeCallback jwt.sign error', err) + console.error('kakaoLoginNativeCallback jwt.sign error', { + errorName: getSafeErrorName(err), + }) return res.status(500).send('Internal Server Error') } } @@ -43,7 +45,9 @@ export default async function handler( if (err instanceof z.ZodError) { return res.status(400).json(err.errors) } - console.error('kakaoLoginNativeCallback error: ', err) + console.error('kakaoLoginNativeCallback error', { + errorName: getSafeErrorName(err), + }) return res.status(500).send('Internal Server Error') } } diff --git a/apps/web/server/service/auth.service.test.ts b/apps/web/server/service/auth.service.test.ts new file mode 100644 index 00000000..c5ed953e --- /dev/null +++ b/apps/web/server/service/auth.service.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../provider', () => ({ + InjectRepository: () => () => undefined, + Service: () => undefined, +})) + +vi.mock('axios', () => ({ default: vi.fn() })) + +import axios from 'axios' +import { AccountType } from '../infra/database/entities' +import { AuthService } from './auth.service' + +describe('AuthService Kakao logging', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not log the Kakao profile returned by the provider', async () => { + vi.mocked(axios).mockResolvedValue({ + data: { + id: 'kakao-user-id', + kakao_account: { + email: 'private@example.com', + phone_number: '+82 10-1234-5678', + }, + }, + }) + const accountRepository = { + findOneBy: vi.fn().mockResolvedValue({ id: 'account-id', type: AccountType.KAKAO }), + } + const accountUserRepository = { + findOneBy: vi.fn().mockResolvedValue({ accountId: 'account-id', userId: 'user-id' }), + } + const userRepository = { + findOne: vi.fn().mockResolvedValue({ id: 'user-id', deletedAt: null }), + } + const service = Object.create(AuthService.prototype) as AuthService + Object.defineProperty(service, 'accountRepository', { value: accountRepository }) + Object.defineProperty(service, 'accountUserRepository', { + value: accountUserRepository, + }) + Object.defineProperty(service, 'userRepository', { value: userRepository }) + Object.defineProperty(service, 'serviceUserRepository', { value: {} }) + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await expect(service.getOrCreateKakaoUser('raw-access-token')).resolves.toBe('account-id') + + expect(consoleLog).not.toHaveBeenCalled() + consoleLog.mockRestore() + }) +}) diff --git a/apps/web/server/service/auth.service.ts b/apps/web/server/service/auth.service.ts index dd20613b..9181747e 100644 --- a/apps/web/server/service/auth.service.ts +++ b/apps/web/server/service/auth.service.ts @@ -13,6 +13,7 @@ import { } from '../infra/database/entities' import { UserRole } from '../infra/database/entities/user-role.enum' import { InjectRepository, Service } from '../provider' +import { getSafeErrorName } from '../util/safe-error' type AccountId = string @@ -46,7 +47,7 @@ export class AuthService { } if (!accountUser.user?.serviceUser) { // 유저가 회원탈퇴를 한 경우 account는 남아있고 해당 서비스의 user만 soft delete 되어있다 - console.error(`User not found for account ${account.id}`) + console.error('active service user not found for account', { accountType: type }) throw new UserNotFoundError(`User not found`) } return { @@ -128,7 +129,6 @@ export class AuthService { }, }) const user = userResponse.data - console.log('[kakao user profile] ', user) const account = await this.findOrRestoreAccount(AccountType.KAKAO, user.id) if (account) { @@ -158,9 +158,6 @@ export class AuthService { email?: string user?: string }) { - console.info(`Apple Account ID: ${accountId}`) - console.info(`Apple Email: ${email}`) - const account = await this.findOrRestoreAccount(AccountType.APPLE, accountId) if (account) { return account.id @@ -170,7 +167,9 @@ export class AuthService { try { parsedUser = JSON.parse(user || '{}') } catch (err) { - console.error('Failed to parse Apple user payload', err) + console.error('Failed to parse Apple user payload', { + errorName: getSafeErrorName(err), + }) } // Names are only shown in the first time. From 858048c762e8e5ba3486f2629ea0b3cb36a38367 Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Fri, 4 Sep 2026 02:14:23 +0900 Subject: [PATCH 4/6] test(web): keep handler tests outside API routes --- .../api/kakao-native-callback.handler.test.ts} | 2 +- .../api/manager-transfer-acceptance.handler.test.ts} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename apps/web/{pages/api/v2/auth/kakao/native/callback/index.test.ts => server/api/kakao-native-callback.handler.test.ts} (96%) rename apps/web/{pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts => server/api/manager-transfer-acceptance.handler.test.ts} (96%) diff --git a/apps/web/pages/api/v2/auth/kakao/native/callback/index.test.ts b/apps/web/server/api/kakao-native-callback.handler.test.ts similarity index 96% rename from apps/web/pages/api/v2/auth/kakao/native/callback/index.test.ts rename to apps/web/server/api/kakao-native-callback.handler.test.ts index 941dc98c..3271f9bf 100644 --- a/apps/web/pages/api/v2/auth/kakao/native/callback/index.test.ts +++ b/apps/web/server/api/kakao-native-callback.handler.test.ts @@ -19,7 +19,7 @@ vi.mock('jsonwebtoken', () => ({ default: { sign: mocks.sign }, })) -import handler from './index' +import handler from '../../pages/api/v2/auth/kakao/native/callback' const createResponse = () => { const response = { diff --git a/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts b/apps/web/server/api/manager-transfer-acceptance.handler.test.ts similarity index 96% rename from apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts rename to apps/web/server/api/manager-transfer-acceptance.handler.test.ts index 59f899dc..b25cea40 100644 --- a/apps/web/pages/api/v2/manager-transfer-invitations/[token]/acceptance.test.ts +++ b/apps/web/server/api/manager-transfer-acceptance.handler.test.ts @@ -23,7 +23,7 @@ vi.mock('server/service/user.service', () => ({ import { ClubManagerTransferService } from 'server/service/club-manager-transfer.service' import { UserService } from 'server/service/user.service' -import handler from './acceptance' +import handler from '../../pages/api/v2/manager-transfer-invitations/[token]/acceptance' const token = 'a'.repeat(43) From 51e5b38c1fbc045b1bb8f272115b3df06eebc15d Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Fri, 4 Sep 2026 02:15:18 +0900 Subject: [PATCH 5/6] test(ci): pass PostgreSQL URL to database tests --- turbo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index cbda47b7..f2573108 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,8 @@ "dependsOn": ["^typecheck"] }, "test": { - "dependsOn": ["^test"] + "dependsOn": ["^test"], + "env": ["TEST_DATABASE_URL"] } } } From 1f20ef4a1cb523725db6ba91ee8430d86fefc91f Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Fri, 4 Sep 2026 02:18:54 +0900 Subject: [PATCH 6/6] fix(auth): redact Apple callback logs --- .../pages/api/v2/auth/apple/callback/index.ts | 13 ++- .../server/api/apple-callback.handler.test.ts | 84 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 apps/web/server/api/apple-callback.handler.test.ts diff --git a/apps/web/pages/api/v2/auth/apple/callback/index.ts b/apps/web/pages/api/v2/auth/apple/callback/index.ts index 7e3f6bd4..67bc71d9 100644 --- a/apps/web/pages/api/v2/auth/apple/callback/index.ts +++ b/apps/web/pages/api/v2/auth/apple/callback/index.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { ENV } from 'server/ENV' import { Provider } from 'server/provider' import { AuthService } from 'server/service/auth.service' +import { getSafeErrorName } from 'server/util/safe-error' import { AppleLoginCallbackPayloadSchema } from 'src/lib/schemas/auth' import type { ZodIssue } from 'zod' import { z } from 'zod' @@ -28,11 +29,11 @@ export default async function handler( email?: string } | null if (!decodedObj) { - console.error('appleLoginNativeCallback jwt.decode error', payload.id_token) + console.error('appleLoginNativeCallback jwt.decode error') return res.status(401).send('Unauthorized') } if (!decodedObj.sub) { - console.error('appleLoginNativeCallback missing sub', decodedObj) + console.error('appleLoginNativeCallback missing sub') return res.status(401).send('Unauthorized') } const accountId = await authService.createAppleUser({ @@ -46,7 +47,9 @@ export default async function handler( { algorithm: 'HS256', expiresIn: '1y' }, (err, token) => { if (err || !token) { - console.error('appleLoginNativeCallback jwt.sign error', err) + console.error('appleLoginNativeCallback jwt.sign error', { + errorName: getSafeErrorName(err), + }) return res.status(500).send('Internal Server Error') } return res.status(200).json({ @@ -60,7 +63,9 @@ export default async function handler( if (err instanceof z.ZodError) { return res.status(400).json(err.errors) } - console.error('appleLoginNativeCallback error: ', err) + console.error('appleLoginNativeCallback error', { + errorName: getSafeErrorName(err), + }) return res.status(500).send('Internal Server Error') } } diff --git a/apps/web/server/api/apple-callback.handler.test.ts b/apps/web/server/api/apple-callback.handler.test.ts new file mode 100644 index 00000000..c13e2a30 --- /dev/null +++ b/apps/web/server/api/apple-callback.handler.test.ts @@ -0,0 +1,84 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + decode: vi.fn(), + getService: vi.fn(), +})) + +vi.mock('server/provider', () => ({ + Provider: { getService: mocks.getService }, +})) + +vi.mock('server/service/auth.service', () => ({ + AuthService: class AuthService {}, +})) + +vi.mock('jsonwebtoken', () => ({ + default: { + decode: mocks.decode, + sign: vi.fn(), + }, +})) + +import handler from '../../pages/api/v2/auth/apple/callback' + +const createResponse = () => { + const response = { + status: vi.fn(), + send: vi.fn(), + json: vi.fn(), + } + response.status.mockReturnValue(response) + response.send.mockReturnValue(response) + response.json.mockReturnValue(response) + return response +} + +describe('Apple callback logging', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getService.mockReturnValue({}) + }) + + it('does not log the raw Apple identity token when decoding fails', async () => { + const identityToken = 'raw-apple-identity-token' + mocks.decode.mockReturnValue(null) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const response = createResponse() + + await handler( + { + method: 'POST', + body: { id_token: identityToken }, + } as unknown as NextApiRequest, + response as unknown as NextApiResponse, + ) + + expect(response.status).toHaveBeenCalledWith(401) + expect(response.send).toHaveBeenCalledWith('Unauthorized') + expect(consoleError).toHaveBeenCalledWith('appleLoginNativeCallback jwt.decode error') + expect(JSON.stringify(consoleError.mock.calls)).not.toContain(identityToken) + consoleError.mockRestore() + }) + + it('does not log decoded Apple profile data when the subject is missing', async () => { + const privateEmail = 'private@example.com' + mocks.decode.mockReturnValue({ email: privateEmail }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const response = createResponse() + + await handler( + { + method: 'POST', + body: { id_token: 'identity-token' }, + } as unknown as NextApiRequest, + response as unknown as NextApiResponse, + ) + + expect(response.status).toHaveBeenCalledWith(401) + expect(consoleError).toHaveBeenCalledWith('appleLoginNativeCallback missing sub') + expect(JSON.stringify(consoleError.mock.calls)).not.toContain(privateEmail) + consoleError.mockRestore() + }) +})