v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -49,6 +49,11 @@ const envValues = readEnvFile()
|
||||
const databaseName = process.env.POSTGRES_DB || envValues.POSTGRES_DB || 'sori_studio'
|
||||
const databaseUser = process.env.POSTGRES_USER || envValues.POSTGRES_USER || 'sori_studio'
|
||||
|
||||
if (migrationEnvironment === 'test' && !databaseName.endsWith('_test')) {
|
||||
console.error(`테스트 DB 이름은 _test로 끝나야 합니다: ${databaseName}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 문자열 리터럴 이스케이프
|
||||
* @param {string} value - SQL 문자열 값
|
||||
@@ -96,7 +101,11 @@ const runCommand = (command, args, options = {}) => {
|
||||
* @param {string[]} args - compose 하위 명령 인자
|
||||
* @returns {string[]} 전체 docker compose 인자
|
||||
*/
|
||||
const getComposeArgs = (args) => ['compose', '--env-file', envFile, ...args]
|
||||
const getComposeArgs = (args) => [
|
||||
'compose',
|
||||
...(existsSync(join(rootDir, envFile)) ? ['--env-file', envFile] : []),
|
||||
...args
|
||||
]
|
||||
|
||||
/**
|
||||
* psql 실행 인자 생성
|
||||
@@ -234,8 +243,14 @@ const getAppliedMigrationFiles = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const runMigrationFile = (fileName) => {
|
||||
runCommand('docker', getPsqlArgs(['-f', `${containerMigrationsDir}/${fileName}`]))
|
||||
runSql(`INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`)
|
||||
const recordSql = `INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`
|
||||
runCommand('docker', getPsqlArgs([
|
||||
'--single-transaction',
|
||||
'-f',
|
||||
`${containerMigrationsDir}/${fileName}`,
|
||||
'-c',
|
||||
recordSql
|
||||
]))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
90
scripts/playwright-test-environment.js
Normal file
90
scripts/playwright-test-environment.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const composeProjectName = 'sori-studio-playwright'
|
||||
const databaseName = 'sori_studio_test'
|
||||
const databasePort = process.env.TEST_DB_PORT || '43091'
|
||||
const uploadRoot = join(tmpdir(), 'sori-studio-playwright-uploads')
|
||||
|
||||
if (!databaseName.endsWith('_test')) {
|
||||
throw new Error(`테스트 DB 이름은 _test로 끝나야 합니다: ${databaseName}`)
|
||||
}
|
||||
|
||||
export const playwrightTestEnv = {
|
||||
...process.env,
|
||||
PLAYWRIGHT_TEST: '1',
|
||||
DATABASE_URL: `postgres://sori_studio_test:sori_studio_test_password@127.0.0.1:${databasePort}/${databaseName}`,
|
||||
DATABASE_NAME: databaseName,
|
||||
POSTGRES_DB: databaseName,
|
||||
POSTGRES_USER: 'sori_studio_test',
|
||||
POSTGRES_PASSWORD: 'sori_studio_test_password',
|
||||
ADMIN_EMAIL: 'playwright-admin@example.com',
|
||||
ADMIN_PASSWORD: 'playwright-admin-password',
|
||||
MEMBER_SESSION_SECRET: 'playwright-member-session-secret',
|
||||
ANALYTICS_HASH_SECRET: 'playwright-analytics-secret',
|
||||
NUXT_PUBLIC_SITE_URL: 'http://127.0.0.1:43100',
|
||||
NUXT_POST_EXPORT_WORKER: '0',
|
||||
NUXT_RECOMMENDED_FAVICON_CRON: '0',
|
||||
MEDIA_CATALOG_MODE: 'database',
|
||||
SORI_TEST_UPLOAD_ROOT: uploadRoot,
|
||||
TEST_DB_PORT: databasePort
|
||||
}
|
||||
|
||||
const composeArgs = [
|
||||
'compose',
|
||||
'--project-name',
|
||||
composeProjectName,
|
||||
'-f',
|
||||
'docker-compose.test.yml'
|
||||
]
|
||||
|
||||
/**
|
||||
* 명령을 실행하고 실패 시 즉시 중단한다.
|
||||
* @param {string} command - 실행 파일
|
||||
* @param {string[]} args - 실행 인자
|
||||
* @returns {void}
|
||||
*/
|
||||
const run = (command, args) => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: rootDir,
|
||||
env: playwrightTestEnv,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} 실행 실패 (${result.status || 1})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright 전용 DB와 업로드 디렉터리를 새로 준비한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setupPlaywrightTestEnvironment = () => {
|
||||
teardownPlaywrightTestEnvironment()
|
||||
rmSync(uploadRoot, { recursive: true, force: true })
|
||||
mkdirSync(uploadRoot, { recursive: true })
|
||||
|
||||
try {
|
||||
run('docker', [...composeArgs, 'up', '-d', '--wait'])
|
||||
} catch (error) {
|
||||
teardownPlaywrightTestEnvironment()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright 전용 자원만 정리한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const teardownPlaywrightTestEnvironment = () => {
|
||||
spawnSync('docker', [...composeArgs, 'down', '--volumes'], {
|
||||
cwd: rootDir,
|
||||
env: playwrightTestEnv,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
rmSync(uploadRoot, { recursive: true, force: true })
|
||||
}
|
||||
40
scripts/reconcile-media-catalog.js
Normal file
40
scripts/reconcile-media-catalog.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import postgres from 'postgres'
|
||||
|
||||
const databaseUrl = String(process.env.DATABASE_URL || '').trim()
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL_REQUIRED')
|
||||
}
|
||||
|
||||
const parsedDatabaseUrl = new URL(databaseUrl)
|
||||
const allowedHosts = new Set(['127.0.0.1', 'localhost', '::1'])
|
||||
const expectedDatabase = decodeURIComponent(parsedDatabaseUrl.pathname.replace(/^\/+/, ''))
|
||||
|
||||
if (process.env.NODE_ENV === 'production' || !allowedHosts.has(parsedDatabaseUrl.hostname)) {
|
||||
throw new Error('MEDIA_CATALOG_RECONCILE_LOCAL_DATABASE_ONLY')
|
||||
}
|
||||
|
||||
const database = postgres(databaseUrl, {
|
||||
max: 1
|
||||
})
|
||||
|
||||
try {
|
||||
const identityRows = await database`
|
||||
SELECT current_database() AS database_name
|
||||
`
|
||||
const databaseName = identityRows[0]?.database_name
|
||||
|
||||
if (!databaseName || databaseName !== expectedDatabase || /prod|production/i.test(databaseName)) {
|
||||
throw new Error('MEDIA_CATALOG_DATABASE_IDENTITY_MISMATCH')
|
||||
}
|
||||
|
||||
const { reconcileMediaCatalog } = await import('../server/repositories/media-catalog-repository.js')
|
||||
const report = await reconcileMediaCatalog()
|
||||
console.info(JSON.stringify({
|
||||
database: databaseName,
|
||||
destructiveFileChanges: false,
|
||||
report
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await database.end()
|
||||
}
|
||||
37
scripts/run-playwright-tests.js
Normal file
37
scripts/run-playwright-tests.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
playwrightTestEnv,
|
||||
setupPlaywrightTestEnvironment,
|
||||
teardownPlaywrightTestEnvironment
|
||||
} from './playwright-test-environment.js'
|
||||
|
||||
const project = process.argv[2]
|
||||
|
||||
if (!['api', 'e2e'].includes(project)) {
|
||||
console.error('Playwright 프로젝트는 api 또는 e2e여야 합니다.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let exitCode = 1
|
||||
|
||||
try {
|
||||
setupPlaywrightTestEnvironment()
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['./node_modules/@playwright/test/cli.js', 'test', `--project=${project}`],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: playwrightTestEnv,
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
exitCode = result.status || 0
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
} finally {
|
||||
teardownPlaywrightTestEnvironment()
|
||||
}
|
||||
|
||||
process.exit(exitCode)
|
||||
Reference in New Issue
Block a user