v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.

세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 11:05:40 +09:00
parent 9bc0f0a90e
commit de767095ba
116 changed files with 8128 additions and 2933 deletions

43
tests/csp-report.test.js Normal file
View File

@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
classifyCspUserAgentFamily,
isSupportedCspReportContentType,
normalizeCspBlockedOrigin,
normalizeCspDocumentPath,
parseCspReportPayload
} from '../lib/csp-report.js'
test('CSP 보고 URL은 문서 path와 차단 origin까지만 남긴다', () => {
const [report] = parseCspReportPayload({
'csp-report': {
'document-uri': 'https://sori.studio/post/visible?token=secret#private',
'blocked-uri': 'https://cdn.example.com/private/file.js?signature=secret#part',
'effective-directive': 'script-src-elem'
}
}, 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36')
assert.deepEqual(report, {
policyVersion: 'compat-v1',
documentPath: '/post/visible',
effectiveDirective: 'script-src-elem',
blockedOrigin: 'https://cdn.example.com',
userAgentFamily: 'Chrome'
})
assert.equal(JSON.stringify(report).includes('secret'), false)
assert.equal(JSON.stringify(report).includes('/private/file.js'), false)
})
test('CSP 보고 정규화는 실행 토큰과 브라우저 계열만 보존한다', () => {
assert.equal(normalizeCspDocumentPath('/settings?email=user@example.com'), '/settings')
assert.equal(normalizeCspBlockedOrigin('inline'), 'inline')
assert.equal(normalizeCspBlockedOrigin('data:text/javascript,secret'), 'data:')
assert.equal(classifyCspUserAgentFamily('Mozilla/5.0 Version/18.0 Safari/605.1.15'), 'Safari')
})
test('CSP 수집은 지정된 JSON 호환 미디어 타입만 허용한다', () => {
assert.equal(isSupportedCspReportContentType('application/csp-report'), true)
assert.equal(isSupportedCspReportContentType('application/reports+json; charset=utf-8'), true)
assert.equal(isSupportedCspReportContentType('application/vnd.example+json'), true)
assert.equal(isSupportedCspReportContentType('text/plain'), false)
})

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { sanitizeHtmlBlock } from '../lib/markdown-html-block.js'
/**
* 실행성 HTML 우회 입력을 제거하는지 검증한다.
* @returns {void}
*/
const testDangerousHtmlRemoval = () => {
const attacks = [
'<img src="x" onerror="alert(1)">',
'<a href="jav&#x61;script:alert(1)">링크</a>',
'<svg><a xlink:href="javascript:alert(1)">위험</a></svg>',
'<script><img src=x onerror=alert(1)></script>',
'<div style="background:url(javascript:alert(1))" onclick=alert(1)>본문</div>',
'<iframe srcdoc="<script>alert(1)</script>"></iframe>'
]
for (const attack of attacks) {
const sanitized = sanitizeHtmlBlock(attack)
assert.doesNotMatch(sanitized, /onerror|onclick|javascript:|<script|<iframe|<svg|style=/i)
}
}
/**
* 허용된 본문 HTML과 새 창 링크 보정을 검증한다.
* @returns {void}
*/
const testSafeHtmlPreservation = () => {
const sanitized = sanitizeHtmlBlock(
'<table><tr><th scope="col">제목</th><td colspan="2">본문</td></tr></table>'
+ '<a href="https://example.com" target="_blank">외부 링크</a>'
)
assert.match(sanitized, /<table>/)
assert.match(sanitized, /scope="col"/)
assert.match(sanitized, /rel="noopener noreferrer"/)
}
test('HTML 블록의 실행성 태그·속성·URL을 제거한다', testDangerousHtmlRemoval)
test('HTML 블록의 안전한 표와 링크를 보존한다', testSafeHtmlPreservation)

View File

@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
detectMediaKind,
detectMediaRole,
escapePostgresLikeLiteral,
extractMediaReferences,
normalizeMediaCatalogUrl
} from '../lib/media-catalog.js'
test('SQL LIKE wildcard 검색 문자를 리터럴로 이스케이프한다', () => {
assert.equal(escapePostgresLikeLiteral('100%_done!'), '100!%!_done!!')
})
test('미디어 URL을 저장 키로 정규화하고 쿼리를 제거한다', () => {
assert.deepEqual(
normalizeMediaCatalogUrl('/uploads/posts/2026/07/%ED%95%9C%EA%B8%80.png?v=1'),
{
url: '/uploads/posts/2026/07/한글.png',
storageKey: 'posts/2026/07/한글.png'
}
)
assert.deepEqual(
normalizeMediaCatalogUrl('/uploads/posts/literal-100%.png'),
{
url: '/uploads/posts/literal-100%.png',
storageKey: 'posts/literal-100%.png'
}
)
})
test('업로드 루트 이탈과 인코딩된 traversal을 차단한다', () => {
assert.throws(() => normalizeMediaCatalogUrl('/uploads/../secret.txt'), /MEDIA_URL_TRAVERSAL/)
assert.throws(() => normalizeMediaCatalogUrl('/uploads/%2e%2e/secret.txt'), /MEDIA_URL_TRAVERSAL/)
assert.throws(() => normalizeMediaCatalogUrl('/etc/passwd'), /MEDIA_URL_OUTSIDE_UPLOADS/)
})
test('확장자 종류와 시스템 역할 및 파생 원본 후보를 판별한다', () => {
assert.equal(detectMediaKind('photo.webp'), 'image')
assert.equal(detectMediaKind('clip.mp4'), 'video')
assert.equal(detectMediaKind('voice.m4a'), 'audio')
assert.equal(detectMediaKind('document.pdf'), 'file')
assert.deepEqual(detectMediaRole('exports/posts.zip'), {
role: 'export',
parentStorageKey: null
})
assert.deepEqual(detectMediaRole('posts/2026/07/thumbs/photo-card.webp'), {
role: 'card',
parentStorageKey: 'posts/2026/07/photo.jpg'
})
assert.equal(detectMediaRole('members/avatars/2026/07/profile.webp').role, 'avatar')
})
test('본문 720·1600 파생 URL을 레거시 body와 구분한다', async () => {
const {
isPostBody1600ThumbnailUrl,
isPostBody720ThumbnailUrl,
isPostBodyThumbnailUrl
} = await import('../lib/post-card-thumbnail-url.js')
assert.equal(
isPostBodyThumbnailUrl('/uploads/posts/2026/07/thumbs/photo-body.webp'),
true
)
assert.equal(
isPostBody1600ThumbnailUrl('/uploads/posts/2026/07/thumbs/photo-body.webp'),
false
)
assert.equal(
isPostBody720ThumbnailUrl('/uploads/posts/2026/07/thumbs/photo-body.webp'),
false
)
assert.equal(
isPostBody1600ThumbnailUrl('/uploads/posts/2026/07/thumbs/photo-body-1600.webp'),
true
)
assert.equal(
isPostBody720ThumbnailUrl('/uploads/posts/2026/07/thumbs/photo-body-720.webp'),
true
)
})
test('필드별 미디어 참조를 추출하고 같은 필드 중복을 제거한다', () => {
assert.deepEqual(extractMediaReferences({
content: '![a](/uploads/posts/a.png) 다시 /uploads/posts/a.png',
featuredImage: '/uploads/posts/a.png',
external: 'https://example.com/image.png'
}), [
{
mediaUrl: '/uploads/posts/a.png',
referenceField: 'content'
},
{
mediaUrl: '/uploads/posts/a.png',
referenceField: 'featuredImage'
}
])
})

View File

@@ -0,0 +1,158 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import test from 'node:test'
import {
decodeMultipartFilename,
saveMultipartFilesToDisk
} from '../server/utils/multipart-file-upload.js'
const boundary = '----sori-stream-test'
/**
* 테스트용 multipart 요청 본문을 생성한다.
* @param {Array<{ name: string, type: string, content: string|Buffer }>} files - 파일 목록
* @returns {Buffer} multipart 본문
*/
const createMultipartBody = (files) => {
const chunks = []
for (const file of files) {
chunks.push(Buffer.from(
`--${boundary}\r\n`
+ 'Content-Disposition: form-data; name="files"; filename="'
))
chunks.push(Buffer.isBuffer(file.name) ? file.name : Buffer.from(String(file.name), 'utf8'))
chunks.push(Buffer.from(
`"\r\nContent-Type: ${file.type}\r\n\r\n`
))
chunks.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content), 'utf8'))
chunks.push(Buffer.from('\r\n'))
}
chunks.push(Buffer.from(`--${boundary}--\r\n`))
return Buffer.concat(chunks)
}
/**
* 테스트용 H3 이벤트 요청 객체를 생성한다.
* @param {Buffer} body - 요청 본문
* @returns {{ node: { req: Readable & { headers: Record<string, string> } } }} 요청 이벤트 대체 객체
*/
const createMultipartEvent = (body) => {
const request = Readable.from([body])
request.headers = {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(body.length)
}
return {
node: {
req: request
}
}
}
/**
* 여러 파일이 메모리 Buffer 변환 없이 디스크에 저장되는지 검증한다.
* @returns {Promise<void>}
*/
const testMultipleFileStreaming = async () => {
const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-test-'))
try {
const body = createMultipartBody([
{ name: 'one.txt', type: 'text/plain', content: '첫 번째' },
{ name: 'two.txt', type: 'text/plain', content: '두 번째 파일' }
])
const files = await saveMultipartFilesToDisk(createMultipartEvent(body), {
fieldName: 'files',
maxFiles: 2,
maxTotalBytes: 1024,
createFileTarget: async (_file, index) => ({
filePath: join(directory, `${index}.txt`),
maxBytes: 128
})
})
const contents = await Promise.all(files.map((file) => readFile(file.filePath, 'utf8')))
assert.deepEqual(contents, ['첫 번째', '두 번째 파일'])
} finally {
await rm(directory, { recursive: true, force: true })
}
}
/**
* 파일별 스트리밍 크기 제한을 검증한다.
* @returns {Promise<void>}
*/
const testFileSizeLimit = async () => {
const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-limit-test-'))
try {
const body = createMultipartBody([
{ name: 'large.txt', type: 'text/plain', content: '1234567890' }
])
await assert.rejects(
saveMultipartFilesToDisk(createMultipartEvent(body), {
fieldName: 'files',
maxFiles: 1,
maxTotalBytes: 1024,
createFileTarget: async () => ({
filePath: join(directory, 'large.txt'),
maxBytes: 5
})
}),
(error) => error?.code === 'MULTIPART_FILE_TOO_LARGE'
)
} finally {
await rm(directory, { recursive: true, force: true })
}
}
test('다중 multipart 파일을 디스크로 스트리밍한다', testMultipleFileStreaming)
test('multipart 파일별 크기 제한을 적용한다', testFileSizeLimit)
test('busboy Latin-1 오해로 깨진 한글 파일명을 복구한다', () => {
const nfdName = '스크린샷 2026-06-25 오후 5.13.27'.normalize('NFD')
const latin1Mojibake = Buffer.from(nfdName, 'utf8').toString('latin1')
assert.equal(
decodeMultipartFilename(latin1Mojibake),
'스크린샷 2026-06-25 오후 5.13.27'
)
assert.equal(
decodeMultipartFilename('스크린샷-2026-06-25-오후-5.13.27.png'),
'스크린샷-2026-06-25-오후-5.13.27.png'
)
assert.equal(decodeMultipartFilename('café.png'), 'café.png')
})
test('UTF-8 한글 파일명 multipart를 올바르게 읽는다', async () => {
const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-korean-'))
try {
const koreanName = Buffer.from('스크린샷 2026-06-25 오후 5.13.27.png', 'utf8')
const body = createMultipartBody([
{ name: koreanName, type: 'image/png', content: 'png-bytes' }
])
const files = await saveMultipartFilesToDisk(createMultipartEvent(body), {
fieldName: 'files',
maxFiles: 1,
maxTotalBytes: 1024,
createFileTarget: async (file) => ({
filePath: join(directory, 'upload.bin'),
maxBytes: 128,
metadata: { filename: file.filename }
})
})
assert.equal(files[0].filename, '스크린샷 2026-06-25 오후 5.13.27.png')
assert.equal(files[0].metadata.filename, '스크린샷 2026-06-25 오후 5.13.27.png')
} finally {
await rm(directory, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,473 @@
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('설정 일부 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('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()
})

View File

@@ -0,0 +1,100 @@
import { expect, test } from '@playwright/test'
const adminEmail = 'playwright-admin@example.com'
const adminPassword = 'playwright-admin-password'
/**
* 관리자 화면에서 실제 로그인 API를 거쳐 로그인한다.
* @param {import('@playwright/test').Page} page - 브라우저 페이지
* @returns {Promise<void>}
*/
const loginAdmin = async (page) => {
await page.goto('/admin/login')
await page.getByLabel('이메일').fill(adminEmail)
await page.locator('input[type="password"]').fill(adminPassword)
await Promise.all([
page.waitForResponse((response) =>
response.url().endsWith('/admin/api/auth/login') && response.status() === 200
),
page.getByRole('button', { name: '로그인' }).click()
])
await page.waitForURL(/\/admin(?:\/)?$/)
}
test('설정 카드 하나만 저장하면 다른 카드의 미저장 입력을 보존한다', async ({ page }) => {
const savedTitle = 'Playwright 독립 저장 사이트'
const unsavedCopyright = 'Playwright 미저장 저작권'
await loginAdmin(page)
await page.goto('/admin/settings')
await page.waitForLoadState('networkidle')
const titleCard = page.getByTestId('title-and-description')
const siteInfoCard = page.locator('#admin-settings-section-misc')
await titleCard.getByRole('button', { name: '편집' }).click()
await titleCard.getByLabel('사이트 이름').fill(savedTitle)
await siteInfoCard.getByRole('button', { name: '편집' }).click()
const copyrightInput = siteInfoCard.getByLabel('저작권 문구')
const originalCopyright = await copyrightInput.inputValue()
await copyrightInput.fill(unsavedCopyright)
const saveResponsePromise = page.waitForResponse((response) =>
response.url().endsWith('/admin/api/settings')
&& response.request().method() === 'PATCH'
&& response.status() === 200
)
await titleCard.getByRole('button', { name: '저장', exact: true }).click()
const saveResponse = await saveResponsePromise
expect(saveResponse.request().postDataJSON()).toEqual({
title: savedTitle,
description: expect.any(String)
})
await expect(copyrightInput).toHaveValue(unsavedCopyright)
await page.reload()
await page.waitForLoadState('networkidle')
await expect(titleCard).toContainText(savedTitle)
await siteInfoCard.getByRole('button', { name: '편집' }).click()
await expect(siteInfoCard.getByLabel('저작권 문구')).toHaveValue(originalCopyright)
})
test('발행 글의 미저장 변경에서 이탈을 취소하면 입력과 경로를 유지한다', async ({ page }) => {
const originalTitle = 'Playwright Unsaved Guard'
const changedTitle = 'Playwright Unsaved Guard Changed'
await loginAdmin(page)
const createResponse = await page.request.post('/admin/api/posts', {
data: {
title: originalTitle,
slug: 'playwright-unsaved-guard',
content: '미저장 이탈 보호 E2E 본문',
excerpt: '',
status: 'published',
publishedAt: new Date().toISOString(),
tags: [],
tagNames: {}
}
})
expect(createResponse.status()).toBe(200)
const post = await createResponse.json()
await page.goto(`/admin/posts/${post.id}`)
await page.waitForLoadState('networkidle')
const titleInput = page.getByPlaceholder('제목')
await titleInput.fill(changedTitle)
await expect(titleInput).toHaveValue(changedTitle)
await page.getByRole('link', { name: 'Posts', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '이 페이지를 떠날까요?' })
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: '머무르기' }).click()
await expect(dialog).toBeHidden()
await expect(page).toHaveURL(new RegExp(`/admin/posts/${post.id}$`))
await expect(titleInput).toHaveValue(changedTitle)
})

View File

@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test'
test('관리자가 로그인해 게시물을 발행하고 공개 화면에서 확인한다', async ({ page }) => {
const title = 'Playwright E2E Post'
const slug = 'playwright-e2e-post'
await page.goto('/admin/login')
await page.getByLabel('이메일').fill('playwright-admin@example.com')
await page.locator('input[type="password"]').fill('playwright-admin-password')
await Promise.all([
page.waitForResponse((response) =>
response.url().endsWith('/admin/api/auth/login') && response.status() === 200
),
page.getByRole('button', { name: '로그인' }).click()
])
await page.waitForURL(/\/admin(?:\/)?$/)
await page.goto('/admin/posts/new')
await page.waitForLoadState('networkidle')
await page.getByPlaceholder('제목').fill(title)
await expect(page.getByPlaceholder('제목')).toHaveValue(title)
const publishResponsePromise = page.waitForResponse((response) =>
response.url().endsWith('/admin/api/posts')
&& response.request().method() === 'POST'
&& response.status() === 200
)
await expect(page.getByRole('button', { name: 'Publish', exact: true })).toBeEnabled()
await page.getByRole('button', { name: 'Publish', exact: true }).click()
await expect(page.getByRole('heading', { name: '발행' })).toBeVisible()
await page.getByRole('button', { name: '최종 확인하고 저장 →' }).click()
await publishResponsePromise
await page.waitForURL(/\/admin\/posts\/[0-9a-f-]+$/)
await page.goto(`/post/${slug}`)
await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible()
})