/** * 集成测试(真库)安全边界工具 —— 单一真源。 * * 背景:验收报告 ACCEPTANCE_REVIEW_2026-09-21.md A-8 指出 jest.setup.js:12-50 全局 mock 了 * `@/generated/prisma/client` 与 `@/lib/cms/data-server`,因此整个单测套件从未触碰真实数据层。 * 本目录(config/test/itest/)提供一套独立的 Jest project,跑在**真实但一次性**的 SQLite 文件上。 * * 硬安全约束(共享开发机):本文件的存在意义就是保证 * prisma/dev.db / dev.db / data.db / 任何 .env* 永远不可能被集成测试打开或写入。 * 实现方式:只允许 `file:<绝对路径>` 且绝对路径必须位于 realpath(os.tmpdir()) 之下。 * 任何不满足条件的 URL 立刻 throw —— globalSetup / 每个 worker 的 setupFiles / teardown 三处分别断言。 */ // @ts-nocheck 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); /** 仓库根(config/test/itest/env.js → 上三级) */ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); /** * 临时目录选择:优先 /tmp(任务书要求的落点;macOS 上 realpath 后为 /private/tmp, * 与 os.tmpdir() 的 per-user var/folders 相比更可预测),不可写时回落 os.tmpdir()。 * 可用 ITEST_TMP_DIR 显式覆盖(CI 里指向专用挂载点)。 * 无论选哪个,后续所有断言都基于 realpath 后的结果。 */ function pickTmpDir() { const { constants } = fs; const candidates = [process.env.ITEST_TMP_DIR, '/tmp', os.tmpdir()].filter(Boolean); for (const dir of candidates) { try { fs.accessSync(dir, constants.W_OK | constants.R_OK); return fs.realpathSync(dir); } catch { /* 下一个候选 */ } } throw new Error('[itest-guard] 找不到可写的临时目录'); } const TMP_REALPATH = pickTmpDir(); /** 一次性集成库的固定路径(任务书建议的 /tmp/novalon-itest.db) */ const DB_PATH = path.join(TMP_REALPATH, 'novalon-itest.db'); /** globalSetup 写入、teardown 读取的运行期清单(含 dev.db 基线指纹) */ const MANIFEST_PATH = path.join(TMP_REALPATH, 'novalon-itest-manifest.json'); /** migrate diff 输出的 DDL 脚本(只写 /tmp,不写仓库) */ const DDL_PATH = path.join(TMP_REALPATH, 'novalon-itest-schema.sql'); /** 项目自身的数据源候选 —— 任何一个被集成测试打开都属安全事故 */ const FORBIDDEN_DB_BASENAMES = ['dev.db', 'data.db', 'test.db', 'prod.db', 'novalon.db']; const FORBIDDEN_DB_PATHS = [ path.join(REPO_ROOT, 'prisma', 'dev.db'), path.join(REPO_ROOT, 'dev.db'), path.join(REPO_ROOT, 'data.db'), ]; /** 记录一个库文件的指纹(不存在时返回 null,同样可作为「未被创建」的证据) */ function fingerprint(file) { try { const st = fs.statSync(file); return { path: file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, exists: true }; } catch { return { path: file, ino: null, size: null, mtimeMs: null, exists: false }; } } function devDbBaselines() { return { prismaDevDb: fingerprint(path.join(REPO_ROOT, 'prisma', 'dev.db')), rootDevDb: fingerprint(path.join(REPO_ROOT, 'dev.db')), rootDataDb: fingerprint(path.join(REPO_ROOT, 'data.db')), }; } function sameFingerprint(a, b) { if (!a || !b) return false; if (a.exists !== b.exists) return false; if (!a.exists) return true; return a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs; } /** * 唯一允许的 URL 形态:`file:` + 绝对路径 + 位于真实临时目录之下。 * 注意 `file:./dev.db` 这类相对写法在 Prisma 里按 schema 目录解析,会命中 prisma/dev.db, * 因此这里要求绝对路径,不做任何相对解析。 */ function assertSafeDatasourceUrl(url, where) { const label = `[itest-guard:${where}]`; if (typeof url !== 'string' || url.length === 0) { throw new Error(`${label} DATABASE_URL 未设置:集成测试拒绝在无显式数据源的情况下运行`); } if (!url.startsWith('file:')) { throw new Error(`${label} 集成测试只允许 file: 协议,收到 ${url}`); } const raw = url.slice('file:'.length).split('?')[0]; if (!path.isAbsolute(raw)) { throw new Error(`${label} file: URL 必须是绝对路径(相对路径会被 Prisma 按 prisma/ 目录解析,可能命中 dev.db),收到 ${url}`); } const resolved = fs.existsSync(raw) ? fs.realpathSync(raw) : path.resolve(raw); if (!resolved.startsWith(TMP_REALPATH + path.sep)) { throw new Error(`${label} 集成库必须位于临时目录 ${TMP_REALPATH} 之下,解析结果为 ${resolved}`); } if (FORBIDDEN_DB_BASENAMES.includes(path.basename(resolved))) { throw new Error(`${label} 命中禁用库名 ${path.basename(resolved)}`); } if (FORBIDDEN_DB_PATHS.includes(resolved)) { throw new Error(`${label} 命中项目数据源 ${resolved}`); } // 仓库内的任何路径都不允许作为集成库(防止误把 DATABASE_URL 指回 prisma/dev.db) if (resolved.startsWith(REPO_ROOT + path.sep)) { throw new Error(`${label} 集成库不得位于仓库内,解析结果为 ${resolved}`); } return resolved; } const DATASOURCE_URL = `file:${DB_PATH}`; /** 把 migrate diff 的 --script 输出切成可逐条执行的语句 */ function splitStatements(sql) { return sql .split('\n') .filter((line) => !line.startsWith('--') && line.trim().length > 0) .join('\n') .split(';') .map((s) => s.trim()) .filter((s) => s.length > 0); } module.exports = { REPO_ROOT, TMP_REALPATH, DB_PATH, DDL_PATH, MANIFEST_PATH, DATASOURCE_URL, FORBIDDEN_DB_PATHS, assertSafeDatasourceUrl, fingerprint, devDbBaselines, sameFingerprint, splitStatements, };