import { expect, request, test } from '@playwright/test' import postgres from 'postgres' import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' test.describe.configure({ mode: 'serial' }) const adminEmail = 'playwright-admin@example.com' const initialPassword = 'playwright-admin-password' const changedPassword = 'playwright-new-password' let adminApi let adminUser let database /** * 실제 관리자 로그인 API를 호출한다. * @param {import('@playwright/test').APIRequestContext} api - API 요청 컨텍스트 * @param {string} password - 로그인 비밀번호 * @returns {Promise} 로그인 응답 */ const loginAdmin = (api, password = initialPassword) => api.post('/admin/api/auth/login', { data: { email: adminEmail, password } }) test.beforeAll(async ({ baseURL }) => { database = postgres(process.env.DATABASE_URL, { max: 1 }) adminApi = await request.newContext({ baseURL }) const response = await loginAdmin(adminApi) expect(response.status()).toBe(200) adminUser = await response.json() }) test.afterAll(async () => { await adminApi?.dispose() await database?.end() }) test('관리자 미인증 설정 PATCH는 401을 반환한다', async ({ request: anonymousApi }) => { const response = await anonymousApi.patch('/admin/api/settings', { data: { title: '허용되지 않아야 하는 변경' } }) expect(response.status()).toBe(401) }) test('정상 관리자 로그인은 서명된 세션 쿠키를 설정한다', async ({ baseURL }) => { const api = await request.newContext({ baseURL }) const response = await loginAdmin(api) expect(response.status()).toBe(200) expect(response.headers()['set-cookie']).toContain('sori_admin_session=') const meResponse = await api.get('/admin/api/auth/me') expect(meResponse.status()).toBe(200) expect((await meResponse.json()).email).toBe(adminEmail) await api.dispose() }) test('설정 일부 PATCH는 전달하지 않은 필드를 보존한다', async () => { const beforeResponse = await adminApi.get('/admin/api/settings') expect(beforeResponse.status()).toBe(200) const before = await beforeResponse.json() const changedTitle = 'Playwright API 테스트 사이트' const patchResponse = await adminApi.patch('/admin/api/settings', { data: { title: changedTitle } }) expect(patchResponse.status()).toBe(200) const afterResponse = await adminApi.get('/admin/api/settings') const after = await afterResponse.json() expect(after.title).toBe(changedTitle) expect(after.description).toBe(before.description) expect(after.copyrightText).toBe(before.copyrightText) }) test('공개 게시물 목록은 limit을 지키고 content를 제외한다', async () => { for (let index = 1; index <= 3; index += 1) { const response = await adminApi.post('/admin/api/posts', { data: { title: `API 목록 테스트 ${index}`, slug: `api-list-test-${index}`, content: `목록에 포함되면 안 되는 본문 ${index}`, excerpt: `요약 ${index}`, status: 'published', publishedAt: new Date(Date.now() - index * 1000).toISOString(), tags: [], tagNames: {} } }) expect(response.status()).toBe(200) } const response = await adminApi.get('/api/posts?limit=2') expect(response.status()).toBe(200) const posts = await response.json() expect(posts).toHaveLength(2) for (const post of posts) { expect(post).not.toHaveProperty('content') } }) test('CSP 헤더는 호환 기능을 차단하지 않고 관찰 정책을 보고한다', async ({ request: anonymousApi }) => { const response = await anonymousApi.get('/') expect(response.status()).toBe(200) const headers = response.headers() expect(headers['content-security-policy']).toBe("base-uri 'self'; object-src 'none'; frame-ancestors 'self'") expect(headers['content-security-policy']).not.toContain('script-src') expect(headers['content-security-policy']).not.toContain('style-src') expect(headers['content-security-policy']).not.toContain('img-src') expect(headers['content-security-policy']).not.toContain('frame-src') const reportOnly = headers['content-security-policy-report-only'] expect(reportOnly).toContain("script-src 'self' 'unsafe-inline' 'unsafe-eval' https:") expect(reportOnly).toContain("img-src 'self' data: blob: https:") expect(reportOnly).toContain('frame-src https:') expect(reportOnly).toContain('report-uri /api/csp-report') expect(reportOnly).toContain('report-to csp-endpoint') expect(headers['reporting-endpoints']).toBe('csp-endpoint="http://127.0.0.1:43100/api/csp-report"') expect(JSON.parse(headers['report-to']).endpoints[0].url).toBe('http://127.0.0.1:43100/api/csp-report') const createPageResponse = await adminApi.post('/admin/api/pages', { data: { title: 'CSP HTML 문서 테스트', slug: 'csp-policy-test', renderMode: 'html_document', status: 'published', content: '호환성 테스트' } }) expect(createPageResponse.status()).toBe(200) const htmlPageResponse = await anonymousApi.get('/pages/csp-policy-test') expect(htmlPageResponse.status()).toBe(200) expect(htmlPageResponse.headers()).not.toHaveProperty('content-security-policy') expect(htmlPageResponse.headers()['content-security-policy-report-only']).toContain('report-to csp-endpoint') }) test('CSP 보고는 민감 URL을 제거해 같은 차원으로 집계한다', async ({ request: anonymousApi }) => { const userAgent = 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36' const reports = [ { 'document-uri': 'http://127.0.0.1:43100/post/csp-test?token=first#private', 'blocked-uri': 'https://cdn.example.com/private/one.js?signature=first', 'effective-directive': 'script-src-elem' }, { 'document-uri': 'http://127.0.0.1:43100/post/csp-test?token=second#other', 'blocked-uri': 'https://cdn.example.com/secret/two.js?signature=second', 'effective-directive': 'script-src-elem' } ] for (const report of reports) { const response = await anonymousApi.post('/api/csp-report', { headers: { 'content-type': 'application/csp-report', 'user-agent': userAgent }, data: { 'csp-report': report } }) expect(response.status()).toBe(204) } const rows = await database` SELECT policy_version, document_path, effective_directive, blocked_origin, user_agent_family, violation_count FROM csp_violation_aggregates WHERE document_path = '/post/csp-test' ` expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ policy_version: 'compat-v1', document_path: '/post/csp-test', effective_directive: 'script-src-elem', blocked_origin: 'https://cdn.example.com', user_agent_family: 'Chrome' }) expect(Number(rows[0].violation_count)).toBe(2) expect(JSON.stringify(rows[0])).not.toContain('token') expect(JSON.stringify(rows[0])).not.toContain('/private/one.js') }) test('CSP 수집은 잘못된 형식과 32KB 초과 본문을 거부한다', async ({ request: anonymousApi }) => { const unsupportedResponse = await anonymousApi.post('/api/csp-report', { headers: { 'content-type': 'text/plain' }, data: '{}' }) expect(unsupportedResponse.status()).toBe(415) const invalidResponse = await anonymousApi.post('/api/csp-report', { headers: { 'content-type': 'application/csp-report' }, data: { invalid: true } }) expect(invalidResponse.status()).toBe(400) const oversizedResponse = await anonymousApi.post('/api/csp-report', { headers: { 'content-type': 'application/csp-report' }, data: JSON.stringify({ 'csp-report': { 'document-uri': `https://sori.studio/${'x'.repeat(33 * 1024)}` } }) }) expect(oversizedResponse.status()).toBe(413) }) test('CSP 수집은 IP 해시 기준 분당 요청 상한을 적용한다', async ({ request: anonymousApi }) => { for (let index = 0; index < 60; index += 1) { const response = await anonymousApi.post('/api/csp-report', { headers: { 'content-type': 'text/plain', 'x-forwarded-for': '198.51.100.77' }, data: '{}' }) expect(response.status()).toBe(415) } const limitedResponse = await anonymousApi.post('/api/csp-report', { headers: { 'content-type': 'text/plain', 'x-forwarded-for': '198.51.100.77' }, data: '{}' }) expect(limitedResponse.status()).toBe(429) expect(Number(limitedResponse.headers()['retry-after'])).toBeGreaterThan(0) }) test('미디어 카탈로그 reconcile과 DB 목록 계약 및 fallback을 유지한다', async () => { const uploadRoot = process.env.SORI_TEST_UPLOAD_ROOT const mediaDirectory = join(uploadRoot, 'posts', 'catalog-api-test') const avatarDirectory = join(uploadRoot, 'members', 'avatars', 'catalog-api-test') await mkdir(mediaDirectory, { recursive: true }) await mkdir(avatarDirectory, { recursive: true }) for (let index = 1; index <= 51; index += 1) { await writeFile(join(mediaDirectory, `catalog-${String(index).padStart(2, '0')}.png`), Buffer.from(`media-${index}`)) } await writeFile(join(mediaDirectory, 'literal-100%.png'), Buffer.from('literal-percent')) await writeFile(join(mediaDirectory, 'literal-100X.png'), Buffer.from('wildcard-percent-control')) await writeFile(join(mediaDirectory, 'literal_under_score.png'), Buffer.from('literal-underscore')) await writeFile(join(mediaDirectory, 'literalXunderXscore.png'), Buffer.from('wildcard-underscore-control')) await writeFile(join(avatarDirectory, 'catalog-owner.webp'), Buffer.from('avatar-owner')) const avatarUrl = '/uploads/members/avatars/catalog-api-test/catalog-owner.webp' await database` UPDATE users SET avatar_url = ${avatarUrl} WHERE id = ${adminUser.userId} ` const navigationRows = await database` SELECT id FROM navigation_items ORDER BY sort_order ASC LIMIT 1 ` await database` UPDATE navigation_items SET cached_favicon_url = '/uploads/posts/catalog-api-test/catalog-02.png' WHERE id = ${navigationRows[0].id} ` const unreadyStateRows = await database` SELECT ready, reconciled_at FROM media_catalog_state WHERE singleton = true ` expect(unreadyStateRows[0].ready).toBe(false) expect(unreadyStateRows[0].reconciled_at).toBeNull() const unreadyFallbackResponse = await adminApi.get('/admin/api/media?paged=1&search=catalog-01&pageSize=50') expect(unreadyFallbackResponse.status()).toBe(200) expect((await unreadyFallbackResponse.json()).items[0].name).toBe('catalog-01.png') const reconcileResponse = await adminApi.post('/admin/api/media/reconcile') if (!reconcileResponse.ok()) { throw new Error(`미디어 reconcile 실패: ${await reconcileResponse.text()}`) } expect(reconcileResponse.status()).toBe(200) const reconcileReport = await reconcileResponse.json() expect(reconcileReport.scanned.count).toBeGreaterThanOrEqual(56) expect(reconcileReport.catalog.markedMissing).toBe(0) expect(reconcileReport.catalog.ready).toBe(true) const readyStateRows = await database` SELECT ready, filesystem_count, catalog_count, filesystem_bytes, catalog_bytes FROM media_catalog_state WHERE singleton = true ` expect(readyStateRows[0].ready).toBe(true) expect(readyStateRows[0].filesystem_count).toBe(readyStateRows[0].catalog_count) expect(readyStateRows[0].filesystem_bytes).toBe(readyStateRows[0].catalog_bytes) const firstPageResponse = await adminApi.get('/admin/api/media?paged=1&page=1&pageSize=50&tab=library') expect(firstPageResponse.status()).toBe(200) const firstPage = await firstPageResponse.json() expect(firstPage.items).toHaveLength(50) expect(firstPage.pagination.total).toBeGreaterThanOrEqual(51) expect(firstPage.pagination.pageCount).toBeGreaterThanOrEqual(2) const secondPageResponse = await adminApi.get('/admin/api/media?paged=1&page=2&pageSize=50&tab=library') const secondPage = await secondPageResponse.json() expect(secondPage.pagination.page).toBe(2) expect(secondPage.items.length).toBeGreaterThan(0) const searchResponse = await adminApi.get('/admin/api/media?paged=1&search=catalog-51&pageSize=50') const searchResult = await searchResponse.json() expect(searchResult.pagination.total).toBe(1) expect(searchResult.items[0].name).toBe('catalog-51.png') const literalPercentResponse = await adminApi.get('/admin/api/media?paged=1&search=100%25&pageSize=50') const literalPercentResult = await literalPercentResponse.json() expect(literalPercentResult.pagination.total).toBe(1) expect(literalPercentResult.items[0].name).toBe('literal-100%.png') const literalUnderscoreResponse = await adminApi.get('/admin/api/media?paged=1&search=_under_&pageSize=50') const literalUnderscoreResult = await literalUnderscoreResponse.json() expect(literalUnderscoreResult.pagination.total).toBe(1) expect(literalUnderscoreResult.items[0].name).toBe('literal_under_score.png') const avatarResponse = await adminApi.get('/admin/api/media?paged=1&tab=thumbnails&search=catalog-owner&pageSize=50') const avatarResult = await avatarResponse.json() expect(avatarResult.items[0].avatarOwner.id).toBe(adminUser.userId) expect(avatarResult.items[0].avatarOwner.username).toBeTruthy() const userUsage = avatarResult.items[0].usage.find((usage) => usage.type === 'user') expect(userUsage.typeLabel).toBe('회원') expect(userUsage.adminUrl).toBe(`/admin/members/${adminUser.userId}`) const navigationResponse = await adminApi.get('/admin/api/media?paged=1&search=catalog-02&pageSize=50') const navigationResult = await navigationResponse.json() const navigationUsage = navigationResult.items[0].usage.find((usage) => usage.type === 'navigation') expect(navigationUsage.typeLabel).toBe('네비게이션') expect(navigationUsage.adminUrl).toBe('/admin/navigation') const managedUploadResponses = await Promise.all([ adminApi.post('/admin/api/uploads', { multipart: { files: { name: 'managed-state-a.txt', mimeType: 'text/plain', buffer: Buffer.from('managed-state-a') } } }), adminApi.post('/admin/api/uploads', { multipart: { files: { name: 'managed-state-b.txt', mimeType: 'text/plain', buffer: Buffer.from('managed-state-b') } } }) ]) expect(managedUploadResponses[0].status()).toBe(200) expect(managedUploadResponses[1].status()).toBe(200) const managedAUrl = (await managedUploadResponses[0].json()).files[0].url const managedBUrl = (await managedUploadResponses[1].json()).files[0].url const stateAfterUploads = await database` SELECT ready, filesystem_count, catalog_count, filesystem_bytes, catalog_bytes FROM media_catalog_state WHERE singleton = true ` expect(stateAfterUploads[0].ready).toBe(true) expect(stateAfterUploads[0].filesystem_count).toBe(stateAfterUploads[0].catalog_count) expect(stateAfterUploads[0].filesystem_bytes).toBe(stateAfterUploads[0].catalog_bytes) expect(Number(stateAfterUploads[0].catalog_count)).toBe(Number(readyStateRows[0].catalog_count) + 2) const renameResponse = await adminApi.put('/admin/api/media', { data: { url: managedAUrl, name: 'managed-state-a-renamed' } }) expect(renameResponse.status()).toBe(200) const renamedItem = await renameResponse.json() const renamedSearchResponse = await adminApi.get('/admin/api/media?paged=1&search=managed-state-a-renamed&pageSize=50') expect((await renamedSearchResponse.json()).pagination.total).toBe(1) for (const url of [renamedItem.url, managedBUrl]) { const deleteResponse = await adminApi.delete('/admin/api/media', { data: { url } }) expect(deleteResponse.status()).toBe(200) } const stateAfterDeletes = await database` SELECT ready, filesystem_count, catalog_count, filesystem_bytes, catalog_bytes FROM media_catalog_state WHERE singleton = true ` expect(stateAfterDeletes[0].ready).toBe(true) expect(stateAfterDeletes[0].filesystem_count).toBe(stateAfterDeletes[0].catalog_count) expect(stateAfterDeletes[0].filesystem_bytes).toBe(stateAfterDeletes[0].catalog_bytes) expect(Number(stateAfterDeletes[0].catalog_count)).toBe(Number(readyStateRows[0].catalog_count)) await database` UPDATE media_metadata SET status = 'staging' WHERE url = '/uploads/posts/catalog-api-test/catalog-03.png' ` const stagingFallbackResponse = await adminApi.get('/admin/api/media?paged=1&search=catalog-03&pageSize=50') expect(stagingFallbackResponse.status()).toBe(200) expect((await stagingFallbackResponse.json()).items[0].name).toBe('catalog-03.png') await database` UPDATE media_metadata SET status = 'active' WHERE url = '/uploads/posts/catalog-api-test/catalog-03.png' ` await rm(join(mediaDirectory, 'catalog-51.png')) const missingResponse = await adminApi.post('/admin/api/media/reconcile') expect(missingResponse.status()).toBe(200) const missingRows = await database` SELECT status, missing_since FROM media_metadata WHERE url = '/uploads/posts/catalog-api-test/catalog-51.png' ` expect(missingRows[0].status).toBe('missing') expect(missingRows[0].missing_since).toBeTruthy() await database`ALTER TABLE media_references RENAME TO media_references_fallback_test` try { const fallbackResponse = await adminApi.get('/admin/api/media?paged=1&search=catalog-50&pageSize=50') expect(fallbackResponse.status()).toBe(200) const fallback = await fallbackResponse.json() expect(fallback.items[0].name).toBe('catalog-50.png') } finally { await database`ALTER TABLE media_references_fallback_test RENAME TO media_references` } }) test('비밀번호 변경은 기존 관리자 세션을 무효화한다', async ({ baseURL }) => { const response = await adminApi.put(`/admin/api/members/${adminUser.userId}/password`, { data: { password: changedPassword } }) expect(response.status()).toBe(200) const expiredSessionResponse = await adminApi.get('/admin/api/auth/me') expect(expiredSessionResponse.status()).toBe(401) const newSessionApi = await request.newContext({ baseURL }) const loginResponse = await loginAdmin(newSessionApi, changedPassword) expect(loginResponse.status()).toBe(200) await newSessionApi.dispose() })