feat: 코드 리뷰 기반 전면 개선 — 보안, 접근성, 테스트, UX
- 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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
156
src/api/client.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user