587 lines
24 KiB
JavaScript
587 lines
24 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 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('미디어 카탈로그 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()
|
|
})
|