feat: 코드 리뷰 기반 전면 개선 — 보안, 접근성, 테스트, UX
Some checks failed
Client CI/CD / test (push) Failing after 15m27s
Client CI/CD / deploy (push) Has been cancelled

- HttpOnly 쿠키 refresh token (localStorage 제거)
- 런치 티켓 방식 (JWT URL 노출 방지)
- JWT 디코드로 role 결정 (localStorage 신뢰 제거)
- apiUpload withCredentials 추가
- ErrorBoundary 컴포넌트 추가
- 404 catch-all 라우트 추가
- ARIA 접근성 (tab pattern, aria-label, aria-live)
- Toast CSS 추출 + toastId useRef
- UploadForm 별도 파일 분리 + apiUpload 함수
- UserAdmin fetchError 상태 + retry 버튼
- AuthRedirect 일관성 (모든 경로 → /login)
- DownloadSection localStorage 중복 제거
- CI lint + test + build 검증 단계 추가
- Vitest 테스트 (client 8, Register 10, Login 8)
- AuthPage.css 공유 의도 명확화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:07:32 +09:00
parent 254617530c
commit 96f5381d1c
27 changed files with 2154 additions and 401 deletions

View File

@@ -32,3 +32,9 @@ export async function ssafyCallback(code) {
// 토큰을 리프레시하고 새 access token을 반환 (동시 호출 방지 포함)
export { tryRefresh as refreshToken } from './client';
// 게임 런처용 일회용 티켓 발급 (JWT를 URL에 노출하지 않기 위해 사용)
export async function createLaunchTicket() {
const data = await apiFetch('/api/auth/launch-ticket', { method: 'POST' });
return data.ticket;
}

View File

@@ -7,20 +7,16 @@ export async function tryRefresh() {
if (refreshingPromise) return refreshingPromise;
refreshingPromise = (async () => {
const rt = localStorage.getItem('refreshToken');
if (!rt) throw new Error('no_refresh_token');
const res = await fetch(BASE + '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: rt }),
credentials: 'include',
});
if (!res.ok) throw new Error('refresh_failed');
const data = await res.json();
localStorage.setItem('token', data.token);
localStorage.setItem('refreshToken', data.refreshToken);
return data.token;
})().finally(() => {
refreshingPromise = null;
@@ -32,7 +28,7 @@ export async function tryRefresh() {
async function doFetch(path, options, token) {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (token) headers['Authorization'] = `Bearer ${token}`;
return fetch(BASE + path, { ...options, headers });
return fetch(BASE + path, { ...options, headers, credentials: 'include' });
}
async function parseError(res) {
@@ -103,3 +99,56 @@ export async function apiFetch(path, options = {}, _retryCount = 0) {
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* XHR 기반 파일 업로드 (progress 지원 + 401 자동 갱신)
* @param {string} path - API 경로 (예: '/api/download/upload/game?filename=...')
* @param {File} file - 업로드할 파일
* @param {function} onProgress - progress 콜백 (percent: number)
* @returns {Promise<{status: number, body: object}>}
*/
export function apiUpload(path, file, onProgress) {
function send(token) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', BASE + path);
xhr.withCredentials = true;
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => resolve(xhr);
xhr.onerror = () => reject(new Error('네트워크 오류가 발생했습니다.'));
xhr.send(file);
});
}
return (async () => {
const token = localStorage.getItem('token');
const xhr = await send(token);
if (xhr.status === 401) {
try {
const newToken = await tryRefresh();
const retryXhr = await send(newToken);
if (retryXhr.status === 401) {
window.dispatchEvent(new Event('auth:unauthorized'));
throw new Error('인증이 필요합니다');
}
return { status: retryXhr.status, body: JSON.parse(retryXhr.responseText || '{}') };
} catch (e) {
if (e.message === 'no_refresh_token' || e.message === 'refresh_failed') {
window.dispatchEvent(new Event('auth:unauthorized'));
}
throw e;
}
}
return { status: xhr.status, body: JSON.parse(xhr.responseText || '{}') };
})();
}

156
src/api/client.test.js Normal file
View File

@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { apiFetch, tryRefresh } from './client';
// Stub import.meta.env
// Vitest handles import.meta.env automatically via vite config
describe('apiFetch', () => {
let fetchSpy;
beforeEach(() => {
localStorage.clear();
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('adds Authorization header when token exists in localStorage', async () => {
localStorage.setItem('token', 'test-token-123');
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
await apiFetch('/api/test');
expect(fetchSpy).toHaveBeenCalledOnce();
const [, options] = fetchSpy.mock.calls[0];
expect(options.headers['Authorization']).toBe('Bearer test-token-123');
});
it('does not add Authorization header when no token exists', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ data: 1 }), { status: 200 }),
);
await apiFetch('/api/test');
const [, options] = fetchSpy.mock.calls[0];
expect(options.headers['Authorization']).toBeUndefined();
});
it('attempts token refresh on 401 response', async () => {
localStorage.setItem('token', 'expired-token');
// First call: 401
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }),
);
// Refresh call: success (refresh token sent via HttpOnly cookie by browser)
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ token: 'new-token' }),
{ status: 200 },
),
);
// Retry with new token: success
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ data: 'ok' }), { status: 200 }),
);
const result = await apiFetch('/api/protected');
expect(result).toEqual({ data: 'ok' });
// After refresh, localStorage should have new access token
expect(localStorage.getItem('token')).toBe('new-token');
});
it('dispatches auth:unauthorized when refresh fails', async () => {
localStorage.setItem('token', 'expired-token');
// Refresh will fail (no HttpOnly cookie set in test env)
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }),
);
// Refresh call: fail
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'refreshToken이 필요합니다' }), { status: 400 }),
);
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
await expect(apiFetch('/api/protected')).rejects.toThrow();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'auth:unauthorized' }),
);
});
it('retries on 5xx errors up to 2 times', async () => {
// Mock delay to be instant for faster tests
const originalSetTimeout = globalThis.setTimeout;
vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn) => originalSetTimeout(fn, 0));
fetchSpy
.mockResolvedValueOnce(
new Response('', { status: 500, statusText: 'Internal Server Error' }),
)
.mockResolvedValueOnce(
new Response('', { status: 502, statusText: 'Bad Gateway' }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: 'recovered' }), { status: 200 }),
);
const result = await apiFetch('/api/flaky');
expect(result).toEqual({ data: 'recovered' });
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it('throws after exhausting 5xx retries', async () => {
const originalSetTimeout = globalThis.setTimeout;
vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn) => originalSetTimeout(fn, 0));
fetchSpy
.mockResolvedValue(
new Response('', { status: 500, statusText: 'Internal Server Error' }),
);
await expect(apiFetch('/api/broken')).rejects.toThrow();
// 1 original + 2 retries = 3
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
});
describe('tryRefresh', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it('throws when refresh request fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'refreshToken이 필요합니다' }), { status: 400 }),
);
await expect(tryRefresh()).rejects.toThrow('refresh_failed');
});
it('stores new access token on successful refresh', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(
JSON.stringify({ token: 'fresh-token' }),
{ status: 200 },
),
);
const token = await tryRefresh();
expect(token).toBe('fresh-token');
expect(localStorage.getItem('token')).toBe('fresh-token');
});
});