Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 44 additions & 11 deletions apps/mobile/src/features/club/utils/managerTransfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -54,33 +57,63 @@ 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"],
[404, "exit"],
[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");
});
});
29 changes: 24 additions & 5 deletions apps/mobile/src/features/club/utils/managerTransfer.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
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 = {
title: string;
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링크를 보내주세요";

Expand Down Expand Up @@ -41,16 +51,25 @@ const ERROR_CONTENT_BY_STATUS: Partial<
description: "링크가 만료되었거나 이미 사용되었어요",
},
409: {
title: "본인에게는 권한을 이전할 수 없어요",
description: "다른 올클 회원에게 링크를 전달해주세요",
title: "권한 이전 상태가 변경되었어요",
description: "동아리 관리자 상태를 확인한 후 다시 시도해주세요",
},
};

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 = (
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/shared/utils/apiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
13 changes: 9 additions & 4 deletions apps/web/pages/api/v2/auth/apple/callback/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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')
}
}
9 changes: 7 additions & 2 deletions apps/web/pages/api/v2/auth/kakao/callback/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
}
}
Expand All @@ -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')
}
}
10 changes: 7 additions & 3 deletions apps/web/pages/api/v2/auth/kakao/native/callback/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
}
Expand All @@ -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')
}
}
Expand All @@ -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')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
}
}
84 changes: 84 additions & 0 deletions apps/web/server/api/apple-callback.handler.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading