v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
43
tests/csp-report.test.js
Normal file
43
tests/csp-report.test.js
Normal 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)
|
||||
})
|
||||
41
tests/markdown-html-block.test.js
Normal file
41
tests/markdown-html-block.test.js
Normal 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="javascript: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)
|
||||
98
tests/media-catalog.test.js
Normal file
98
tests/media-catalog.test.js
Normal 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: ' 다시 /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'
|
||||
}
|
||||
])
|
||||
})
|
||||
158
tests/multipart-file-upload.test.js
Normal file
158
tests/multipart-file-upload.test.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
473
tests/playwright/api/core-api.spec.js
Normal file
473
tests/playwright/api/core-api.spec.js
Normal 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()
|
||||
})
|
||||
100
tests/playwright/e2e/admin-guard-and-settings.spec.js
Normal file
100
tests/playwright/e2e/admin-guard-and-settings.spec.js
Normal 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)
|
||||
})
|
||||
38
tests/playwright/e2e/admin-publish.spec.js
Normal file
38
tests/playwright/e2e/admin-publish.spec.js
Normal 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()
|
||||
})
|
||||
Reference in New Issue
Block a user