Files
sori.studio/tests/playwright/api/core-api.spec.js

682 lines
30 KiB
JavaScript

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<import('@playwright/test').APIResponse>} 로그인 응답
*/
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: '<!doctype html><html><head><base href="https://example.com/"></head><body>호환성 테스트</body></html>'
}
})
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('긴 접속 시간은 실시간에 표시하고 평균 체류 집계 상한을 유지한다', async ({ request: anonymousApi }) => {
const slug = 'realtime-duration-api-test'
const [post] = await database`
INSERT INTO posts (title, slug, content, status, author_id, published_at)
VALUES ('긴 접속 시간 검증', ${slug}, '시간 검증', 'published', ${adminUser.userId}, now())
RETURNING id
`
const heartbeat = {
path: `/post/${slug}`, postSlug: slug, clientSessionId: 'duration-api-client-session',
contentSessionId: 'duration-api-content-session', durationSeconds: 1810, maxScrollRatio: 0
}
try {
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(200)
heartbeat.durationSeconds = 7200
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(200)
const realtime = await adminApi.get('/admin/api/analytics/realtime')
expect((await realtime.json()).sessions.find((session) => session.postSlug === slug)?.durationSeconds).toBe(7200)
const sessions = await database`SELECT duration_seconds FROM analytics_content_sessions WHERE post_id = ${post.id}`
expect(sessions).toHaveLength(1)
expect(sessions[0].duration_seconds).toBe(1800)
const daily = await database`SELECT total_engaged_seconds FROM post_analytics_daily WHERE post_id = ${post.id}`
expect(daily[0].total_engaged_seconds).toBe(1800)
heartbeat.durationSeconds = 2147483648
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(400)
heartbeat.contentSessionId = 'duration-api-return-visit'
heartbeat.durationSeconds = 0
expect((await anonymousApi.post('/api/analytics/heartbeat', { data: heartbeat })).status()).toBe(200)
const restarted = await adminApi.get('/admin/api/analytics/realtime')
expect((await restarted.json()).sessions.find((session) => session.postSlug === slug)?.durationSeconds).toBe(0)
} finally {
await database`DELETE FROM analytics_active_sessions WHERE post_slug = ${slug}`
await database`DELETE FROM analytics_content_sessions WHERE post_id = ${post.id}`
await database`DELETE FROM posts WHERE id = ${post.id}`
}
})
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()
})