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('공개 관리자 계정 로그인은 관리자 세션도 설정한다', async ({ baseURL }) => { const api = await request.newContext({ baseURL }) const response = await api.post('/api/auth/login', { data: { email: adminEmail, password: initialPassword } }) expect(response.status()).toBe(200) expect(await response.json()).toMatchObject({ email: adminEmail, isAdmin: true }) 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) const logoutResponse = await api.post('/api/auth/logout') expect(logoutResponse.status()).toBe(200) const loggedOutMeResponse = await api.get('/admin/api/auth/me') expect(loggedOutMeResponse.status()).toBe(401) 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('관리자 댓글 조회는 인증·입력 검증과 멤버별 페이지·답글·댓글 수를 제공한다', async ({ request: anonymousApi }) => { expect((await anonymousApi.get('/admin/api/comments')).status()).toBe(401) for (const query of ['page=0', 'pageSize=101', 'memberId=invalid']) { expect((await adminApi.get(`/admin/api/comments?${query}`)).status()).toBe(400) } const [post] = await database`SELECT id, slug, title FROM posts LIMIT 1` const [other] = await database` INSERT INTO users (username, email, password_hash) VALUES ('댓글 테스트', 'comment-test@example.com', 'unused') RETURNING id ` try { const [parent] = await database` INSERT INTO comments (post_id, user_id, body) VALUES (${post.id}, ${other.id}, '다른 회원 댓글') RETURNING id ` await database` INSERT INTO comments (post_id, user_id, parent_id, body, created_at) VALUES (${post.id}, ${adminUser.userId}, ${parent.id}, '회원 답글', now() + interval '1 second'), (${post.id}, ${adminUser.userId}, NULL, '회원 댓글', now()) ` const first = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1`)).json() expect(first.pagination).toMatchObject({ page: 1, total: 2, pageCount: 2 }) expect(first.items[0]).toMatchObject({ body: '회원 답글', parentId: parent.id, postId: post.id, postTitle: post.title, postSlug: post.slug }) const second = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1&page=2`)).json() expect(second.items[0].body).toBe('회원 댓글') const searched = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&q=${encodeURIComponent('회원 답글')}`)).json() expect(searched.pagination.total).toBe(1) expect(searched.items[0].body).toBe('회원 답글') const literal = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&q=%25`)).json() expect(literal.items).toEqual([]) const last = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1&page=999`)).json() expect(last.pagination.page).toBe(2) const recent = await (await adminApi.get('/admin/api/comments?pageSize=5')).json() expect(recent.items[0].body).toBe('회원 답글') const posts = await (await adminApi.get('/admin/api/posts?paged=true')).json() expect(posts.items.find((item) => item.id === post.id).commentCount).toBe(3) await database`DELETE FROM comments WHERE post_id = ${post.id}` const empty = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}`)).json() expect(empty.pagination.total).toBe(0) expect(empty.items).toEqual([]) } finally { await database`DELETE FROM comments WHERE post_id = ${post.id}` await database`DELETE FROM users WHERE id = ${other.id}` } }) test('멤버 작성 글 조회는 작성자를 분리하고 전체 이력을 검색·페이지 조회한다', async ({ request: anonymousApi }) => { const url = `/admin/api/members/${adminUser.userId}/posts` expect((await anonymousApi.get(url)).status()).toBe(401) for (const query of ['page=0', 'pageSize=101']) { expect((await adminApi.get(`${url}?${query}`)).status()).toBe(400) } expect((await adminApi.get('/admin/api/members/invalid/posts')).status()).toBe(400) const [author] = await database` INSERT INTO users (username, email, password_hash) VALUES ('작성 글 검증', 'author-history@example.com', 'unused') RETURNING id ` try { await database` INSERT INTO posts (title, slug, content, status, author_id, created_at) SELECT '작성 이력 ' || index, 'author-history-' || index, '검색 본문 ' || index, 'draft', ${author.id}, now() + index * interval '1 second' FROM generate_series(1, 11) AS index ` const [targetPost] = await database`SELECT id FROM posts WHERE author_id = ${author.id} ORDER BY created_at DESC LIMIT 1` await database` INSERT INTO comments (post_id, user_id, body, created_at) VALUES (${targetPost.id}, ${author.id}, '통합 댓글 최신', now() + interval '20 second'), (${targetPost.id}, ${author.id}, '통합 댓글 과거', now() - interval '1 day'), (${targetPost.id}, ${adminUser.userId}, '다른 회원 댓글', now() + interval '30 second') ` const activityUrl = `/admin/api/members/${author.id}/activity` expect((await anonymousApi.get(activityUrl)).status()).toBe(401) for (const query of ['type=invalid', 'page=0', 'pageSize=101']) { expect((await adminApi.get(`${activityUrl}?${query}`)).status()).toBe(400) } expect((await adminApi.get('/admin/api/members/invalid/activity')).status()).toBe(400) const mixed = await (await adminApi.get(activityUrl)).json() expect(mixed.pagination).toMatchObject({ total: 13, pageCount: 2, page: 1 }) expect(mixed.items).toHaveLength(10) expect(mixed.items[0]).toMatchObject({ kind: 'comment', body: '통합 댓글 최신', postId: targetPost.id, publicUrl: null }) expect(mixed.items[1]).toMatchObject({ kind: 'post', postTitle: '작성 이력 11', body: '검색 본문 11' }) const mixedLast = await (await adminApi.get(`${activityUrl}?page=999`)).json() expect(mixedLast.pagination.page).toBe(2) expect(mixedLast.items).toHaveLength(3) expect(mixedLast.items[2].body).toBe('통합 댓글 과거') const onlyComments = await (await adminApi.get(`${activityUrl}?type=comment`)).json() expect(onlyComments.pagination.total).toBe(2) expect(onlyComments.items.every((item) => item.kind === 'comment')).toBe(true) const onlyPosts = await (await adminApi.get(`${activityUrl}?type=post`)).json() expect(onlyPosts.pagination.total).toBe(11) expect(onlyPosts.items.every((item) => item.kind === 'post')).toBe(true) const activitySearch = await (await adminApi.get(`${activityUrl}?q=${encodeURIComponent('통합 댓글 최신')}`)).json() expect(activitySearch.pagination.total).toBe(1) const activityEmpty = await (await adminApi.get(`${activityUrl}?type=post&q=%25`)).json() expect(activityEmpty.items).toEqual([]) const authorUrl = `/admin/api/members/${author.id}/posts` const first = await (await adminApi.get(authorUrl)).json() expect(first.pagination).toMatchObject({ total: 11, pageCount: 2, page: 1 }) expect(first.items).toHaveLength(10) expect(first.items[0].title).toBe('작성 이력 11') const last = await (await adminApi.get(`${authorUrl}?page=999`)).json() expect(last.pagination.page).toBe(2) expect(last.items[0].title).toBe('작성 이력 1') const search = await (await adminApi.get(`${authorUrl}?q=${encodeURIComponent('검색 본문 11')}`)).json() expect(search.pagination.total).toBe(1) expect(search.items[0].title).toBe('작성 이력 11') const empty = await (await adminApi.get(`${authorUrl}?q=%25`)).json() expect(empty.items).toEqual([]) const others = await (await adminApi.get(url)).json() expect(others.items.some((item) => item.slug.startsWith('author-history-'))).toBe(false) } finally { await database`DELETE FROM posts WHERE author_id = ${author.id}` await database`DELETE FROM users WHERE id = ${author.id}` } }) 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('실시간 접속 요약은 표시 제한 밖의 활성 탭까지 집계하고 만료 탭을 제외한다', async ({ request: anonymousApi }) => { expect((await anonymousApi.get('/admin/api/analytics/realtime')).status()).toBe(401) try { await database` INSERT INTO analytics_active_sessions (session_hash, path, ip_address) SELECT 'realtime-api-test-' || value, '/post/shared', '198.51.100.' || value FROM generate_series(1, 21) AS value ` await database` INSERT INTO analytics_active_sessions (session_hash, path, ip_address, last_seen_at) VALUES ('realtime-api-test-extra-1', '/post/shared', '198.51.100.1', now()), ('realtime-api-test-extra-2', '/post/another', '198.51.100.1', now()), ('realtime-api-test-expired', '/post/expired', '198.51.100.99', now() - interval '2 minutes') ` const response = await adminApi.get('/admin/api/analytics/realtime?limit=20') expect(response.status()).toBe(200) const result = await response.json() expect(result.summary).toMatchObject({ onlineNow: 21, anonymousNow: 21, loggedInNow: 0, openPageCount: 23 }) expect(result.sessions).toHaveLength(20) expect(result.sessions.find((session) => session.ipAddress === '198.51.100.1')).toMatchObject({ pageCount: 3 }) expect(result.sessions.some((session) => session.ipAddress === '198.51.100.99')).toBe(false) } finally { await database`DELETE FROM analytics_active_sessions WHERE session_hash LIKE 'realtime-api-test-%'` } }) 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() })