This commit is contained in:
parent
b7d4805402
commit
c33b8bbc2f
@ -2,6 +2,9 @@
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
. (Join-Path $PSScriptRoot 'workspace-lock.ps1')
|
||||
$workspaceLock = Enter-SmailWorkspaceLock -Root $root
|
||||
try {
|
||||
$fingerprint = & node (Join-Path $PSScriptRoot 'build-state.mjs') fingerprint
|
||||
if ($LASTEXITCODE -ne 0) { throw '无法计算源码指纹' }
|
||||
$output = Join-Path $root 'out\backend'
|
||||
@ -62,3 +65,7 @@ if (-not $SkipFrontend) {
|
||||
if ($LASTEXITCODE -ne 0) { throw '编译产物封存失败,禁止部署或启动旧包' }
|
||||
}
|
||||
Write-Output '构建完成:out/frontend、out/backend 和 out/plugins,包含三个内置插件。正式发行请使用 Makefile 的跨平台构建目标。'
|
||||
} finally {
|
||||
$workspaceLock.ReleaseMutex()
|
||||
$workspaceLock.Dispose()
|
||||
}
|
||||
|
||||
31
deploy/workspace-lock.ps1
Normal file
31
deploy/workspace-lock.ps1
Normal file
@ -0,0 +1,31 @@
|
||||
function Enter-SmailWorkspaceLock {
|
||||
param([Parameter(Mandatory = $true)][string]$Root)
|
||||
|
||||
# 构建会归档 Go 嵌入资源,必须与同一源码目录的后端测试互斥。
|
||||
# 使用系统互斥量,进程异常退出后也不会留下需要手工删除的锁文件。
|
||||
$resolved = (Resolve-Path -LiteralPath $Root -ErrorAction Stop).ProviderPath
|
||||
$identity = [IO.Path]::GetFullPath($resolved).TrimEnd('\').ToUpperInvariant()
|
||||
$hash = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$digest = [BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($identity))).Replace('-', '')
|
||||
} finally {
|
||||
$hash.Dispose()
|
||||
}
|
||||
$mutex = [Threading.Mutex]::new($false, "Local\SmailWorkspace-$digest")
|
||||
try {
|
||||
$acquired = $false
|
||||
try {
|
||||
$acquired = $mutex.WaitOne(0)
|
||||
} catch [Threading.AbandonedMutexException] {
|
||||
# 上一个持有者异常退出时,系统已把互斥量交给当前线程。
|
||||
$acquired = $true
|
||||
}
|
||||
if (-not $acquired) {
|
||||
throw '同一工作区正在构建或执行后端测试,请等待结束后重试,避免嵌入资源被并发移动。'
|
||||
}
|
||||
return $mutex
|
||||
} catch {
|
||||
$mutex.Dispose()
|
||||
throw
|
||||
}
|
||||
}
|
||||
@ -13,14 +13,21 @@
|
||||
|
||||
- `[✓]` 修改前的文档基线已提交并推送。
|
||||
- `[✓]` 源码指纹和产物 SHA-256 校验已加入构建、部署入口。
|
||||
- `[x]` 开发启动入口固定使用 `yunx-test`,移除跳过编译选项,等待迁移后运行复审。
|
||||
- `[x]` Vite 仅在服务端从 `yunx-test/config` 读取域名、证书引用,等待真实页面复审。
|
||||
- `[~]` 完整构建、最新产物部署和回归验证。
|
||||
- `[ ]` 配置事务与历史备份改存状态数据库。
|
||||
- `[ ]` 密钥、TLS、DKIM、ACME 和审计日志迁到最终目录。
|
||||
- `[ ]` 内置插件合并进发布产物并保持隔离机制。
|
||||
- `[ ]` 停服备份、无损迁移到 `yunx-test`。
|
||||
- `[ ]` 新目录开发、生产模拟运行和浏览器验收。
|
||||
- `[✓]` 开发启动入口固定使用 `yunx-test`,没有沿用旧后端;日志与进程路径已核对。
|
||||
- `[✓]` Vite 从原 `frontend` 源码启动,使用模拟部署目录的域名和证书;浏览器已确认加载开发客户端。
|
||||
- `[✓]` 停服备份、无损迁移到 `yunx-test`,原配置、证书、账号库和备份全部保留。
|
||||
- `[✓]` 配置事务与历史备份迁入 `data/smail-state.db`,五个 JSON 仍是生效配置文件。
|
||||
- `[✓]` 密钥、TLS、DKIM、ACME 和审计日志迁到最终目录,目录检查通过。
|
||||
- `[✓]` 三个内置插件已编译并嵌入主程序;运行解包目录位于操作系统临时目录,不混入部署根目录。
|
||||
- `[✓]` 迁移后的完整构建、产物哈希校验、前端 131 项测试及全量 Go 测试通过。
|
||||
- `[✓]` Windows 标准构建与后端测试入口共用工作区互斥量,4 项部署回归通过。
|
||||
- `[✓]` 新目录开发运行、登录后刷新保持会话、配置读取和配置审计 CSV Blob 验收通过。
|
||||
- `[✓]` HTTPS Cookie 属性及 CSRF 缺失、错误、正确令牌分支已实际验证。
|
||||
- `[~]` 构建互斥修复后的完整部署及重启复查。
|
||||
- `[!]` Windows 生产模拟启动受管理员恢复 Named Pipe 权限限制,未降级或绕过保护。
|
||||
- `[!]` Windows 生产插件隔离尚不可用,不能把插件打包成功视为真实插件运行验收。
|
||||
- `[ ]` 配置保存后文件与历史状态数据库的浏览器写入验收。
|
||||
- `[ ]` 全新初始化、忘记密码、二次验证、强制改密与普通用户权限的独立完整流程验收。
|
||||
|
||||
## 已实现入口
|
||||
|
||||
@ -40,6 +47,38 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Se
|
||||
|
||||
构建收据保存在 `out/build-state.json`,不混入部署根目录。已有程序被覆盖前备份至 `out/deployment-history/`。历史前端编译目录归档至 `out/build-history/`,不删除生成物,也不将历次散列资源重复嵌入新程序。
|
||||
|
||||
## 构建与测试互斥
|
||||
|
||||
Windows 下,`deploy/build.ps1` 与 `node test/run.mjs backend` 共用工作区命名互斥量。构建计算指纹、归档嵌入资源之前就获取锁,测试从调用 Go 到退出始终持锁。遇到冲突立即拒绝执行并给出中文提示,不能继续移动资源或使用历史产物冒充成功。
|
||||
|
||||
锁标识由源码目录生成:同一登录会话内,不同工作区可并行;同工作区必须串行。正常退出和进程异常结束均不需要删除锁文件。不同 Windows 登录会话、直接调用 `go test`、直接调用 Vite 或单独插件构建不在该锁的保证范围内,不能与完整构建并发执行。
|
||||
|
||||
```powershell
|
||||
# 前端、部署回归和后端依次执行,记录统一进入 test-log。
|
||||
node test/run.mjs all -p 2 ./...
|
||||
|
||||
# 只检查构建互斥及后端参数转发。
|
||||
node test/run.mjs deployment
|
||||
```
|
||||
|
||||
新增的 PowerShell 文件使用 UTF-8 BOM,以便 Windows PowerShell 5.1 正确解析中文提示;JavaScript、Go 和 JSON 不因此添加 BOM。
|
||||
|
||||
前端热更新只更新源码开发页面,不会自动重编嵌入前端的主程序。源码修改完成后,仍须通过标准完整部署/重启入口刷新 `yunx-test`,不能只刷新浏览器就宣称运行包已更新。
|
||||
|
||||
## 2026-09-08 实测记录
|
||||
|
||||
- 迁移备份与核对收据:`test-log/deployment-migration-20260908-183213/receipt.json`。
|
||||
- 互斥修复后的完整自动化测试:`test-log/run-2026-09-08T10-57-18-160Z-42364/`,前端 131 项、部署 4 项、Go 全量测试均通过。
|
||||
- 浏览器页面为 `https://xuans.com/#/`,前端 80 跳转 HTTPS 443,后端开发接口监听 5201。
|
||||
- 浏览器真实登录后强制忽略缓存刷新,收件箱与用户信息接口仍正常。配置状态、基础、邮件、证书、历史备份及插件列表读取成功。
|
||||
- 配置审计导出返回 `text/csv` Blob;仅确认响应,不代表下载文件落盘验收。
|
||||
- 缺失或错误 CSRF 请求头均返回业务错误码 `100`,提示“请求来源校验失败”;当前接口使用 HTTP 200 包装业务错误,不能只看 HTTP 状态判断成功。
|
||||
- 正确 CSRF 请求返回 CSV;HTTPS 登录响应中会话 Cookie 为 `Secure/HttpOnly/SameSite=Lax`,CSRF Cookie 为 `Secure/SameSite=Strict`。后者允许前端读取用于请求头,不应加 `HttpOnly`。
|
||||
- Cookie 响应验证使用独立临时登录会话,结束后已退出;未将密码、Cookie 值或令牌写入测试记录。
|
||||
- 生产模拟失败记录:`test-log/production-runtime-20260908.log`。开发运行明确停用管理员恢复通道,生产不能复用这一豁免。
|
||||
|
||||
## 验收边界
|
||||
|
||||
构建通过不代表目录迁移、登录持久化、插件、TLS 和管理员恢复通道均已验收。迁移完成前,不能使用旧布局的测试结果证明新布局正确。
|
||||
此次是保留现有业务数据的迁移验收,未清空数据库,不是生产首次初始化验收。旧根目录的 `config/`、`data/`、`ssl/` 是保留的迁移来源,不是当前服务的生效目录。
|
||||
|
||||
生产管理员恢复、Windows 插件隔离、其他完整账号流程仍有未通过或未验证项目。编辑器异步包仍有约 809 kB 的 Vite 体积告警;不能宣称全部功能或全部优化均已完成。
|
||||
|
||||
28
test/deployment/run-go.ps1
Normal file
28
test/deployment/run-go.ps1
Normal file
@ -0,0 +1,28 @@
|
||||
param([Parameter(Mandatory = $true)][string]$InvocationFile)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
. (Join-Path $root 'deploy\workspace-lock.ps1')
|
||||
$workspaceLock = Enter-SmailWorkspaceLock -Root $root
|
||||
try {
|
||||
# 参数从测试记录读取为字符串数组,不拼接或执行 Shell 命令。
|
||||
$invocation = Get-Content -LiteralPath $InvocationFile -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($invocation.arguments -isnot [Array] -or $invocation.arguments.Count -eq 0) {
|
||||
throw 'Go 测试参数必须是非空数组'
|
||||
}
|
||||
foreach ($argument in $invocation.arguments) {
|
||||
if ($argument -isnot [string]) { throw 'Go 测试参数必须是字符串' }
|
||||
}
|
||||
$arguments = @($invocation.arguments)
|
||||
Push-Location (Join-Path $root 'backend')
|
||||
try {
|
||||
& go @arguments
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} finally {
|
||||
$workspaceLock.ReleaseMutex()
|
||||
$workspaceLock.Dispose()
|
||||
}
|
||||
exit $code
|
||||
96
test/deployment/workspace-lock.test.mjs
Normal file
96
test/deployment/workspace-lock.test.mjs
Normal file
@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const lockScript = path.join(root, 'deploy', 'workspace-lock.ps1')
|
||||
const shellArguments = ['-NoProfile', '-ExecutionPolicy', 'Bypass']
|
||||
const windowsOnly = { skip: process.platform !== 'win32', timeout: 30000 }
|
||||
const quote = value => `'${value.replaceAll("'", "''")}'`
|
||||
|
||||
function acquire(directory) {
|
||||
return `$ErrorActionPreference = 'Stop'; . ${quote(lockScript)}; $lock = Enter-SmailWorkspaceLock -Root ${quote(directory)};`
|
||||
}
|
||||
|
||||
function probe(directory) {
|
||||
return spawnSync('powershell.exe', [...shellArguments, '-Command',
|
||||
`${acquire(directory)} try { Write-Output 'acquired' } finally { $lock.ReleaseMutex(); $lock.Dispose() }`,
|
||||
], { encoding: 'utf8', windowsHide: true, timeout: 10000 })
|
||||
}
|
||||
|
||||
async function hold(directory, context) {
|
||||
const child = spawn('powershell.exe', [...shellArguments, '-Command',
|
||||
`${acquire(directory)} try { Write-Output 'ready'; [Console]::In.ReadLine() | Out-Null } finally { $lock.ReleaseMutex(); $lock.Dispose() }`,
|
||||
], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
context.after(() => {
|
||||
if (child.exitCode === null) child.kill()
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
let output = ''
|
||||
let errors = ''
|
||||
child.stderr.on('data', data => { errors += data })
|
||||
child.stdout.on('data', data => {
|
||||
output += data
|
||||
if (output.includes('ready')) resolve()
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', code => reject(new Error(`锁持有进程提前退出:${code};${errors}`)))
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
async function release(child) {
|
||||
const exited = once(child, 'exit')
|
||||
child.stdin.end('\n')
|
||||
await exited
|
||||
}
|
||||
|
||||
test('构建入口在计算指纹和移动资源之前获取互斥量', async () => {
|
||||
const source = await readFile(path.join(root, 'deploy', 'build.ps1'), 'utf8')
|
||||
const acquireIndex = source.indexOf('$workspaceLock = Enter-SmailWorkspaceLock')
|
||||
assert.ok(acquireIndex > 0)
|
||||
assert.ok(acquireIndex < source.indexOf('fingerprint'))
|
||||
assert.ok(acquireIndex < source.indexOf('Move-Item'))
|
||||
assert.match(source, /finally\s*\{\s*\$workspaceLock\.ReleaseMutex\(\)/)
|
||||
})
|
||||
|
||||
test('同工作区互斥,不同工作区可以并行,正常退出后可重试', windowsOnly, async context => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'smail-lock-'))
|
||||
const other = await mkdtemp(path.join(os.tmpdir(), 'smail-lock-other-'))
|
||||
const child = await hold(directory, context)
|
||||
assert.notEqual(probe(directory).status, 0)
|
||||
assert.equal(probe(other).status, 0)
|
||||
await release(child)
|
||||
assert.equal(probe(directory).status, 0)
|
||||
})
|
||||
|
||||
test('持有进程异常退出后无需删除文件即可重新获取锁', windowsOnly, async context => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'smail-lock-crash-'))
|
||||
const child = await hold(directory, context)
|
||||
const exited = once(child, 'exit')
|
||||
child.kill()
|
||||
await exited
|
||||
assert.equal(probe(directory).status, 0)
|
||||
})
|
||||
|
||||
test('Go 入口与构建共用工作区锁,并保留 Go 退出码', windowsOnly, async context => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'smail-go-lock-'))
|
||||
const invocation = path.join(directory, 'invocation.json')
|
||||
const run = () => spawnSync('powershell.exe', [...shellArguments, '-File',
|
||||
path.join(root, 'test', 'deployment', 'run-go.ps1'), '-InvocationFile', invocation,
|
||||
], { encoding: 'utf8', windowsHide: true, timeout: 10000 })
|
||||
await writeFile(invocation, JSON.stringify({ arguments: ['version'] }))
|
||||
const child = await hold(root, context)
|
||||
assert.notEqual(run().status, 0)
|
||||
await release(child)
|
||||
const success = run()
|
||||
assert.equal(success.status, 0, success.stderr)
|
||||
assert.match(success.stdout, /go version/)
|
||||
await writeFile(invocation, JSON.stringify({ arguments: ['smail-invalid-test-command'] }))
|
||||
assert.notEqual(run().status, 0)
|
||||
})
|
||||
27
test/run.mjs
27
test/run.mjs
@ -6,8 +6,8 @@ import path from 'node:path'
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const [suite = 'all', ...extra] = process.argv.slice(2)
|
||||
if (!['all', 'frontend', 'backend'].includes(suite)) {
|
||||
console.error('用法:node test/run.mjs [all|frontend|backend] [Go 测试参数]')
|
||||
if (!['all', 'frontend', 'backend', 'deployment'].includes(suite)) {
|
||||
console.error('用法:node test/run.mjs [all|frontend|backend|deployment] [Go 测试参数]')
|
||||
process.exit(2)
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
@ -31,14 +31,21 @@ async function run(name, command, args, cwd) {
|
||||
}
|
||||
|
||||
let result = 0
|
||||
if (suite !== 'backend') {
|
||||
if (suite === 'all' || suite === 'frontend') {
|
||||
const tests = (await readdir(path.join(root, 'test', 'frontend')))
|
||||
.filter(name => name.endsWith('.test.mjs'))
|
||||
.map(name => path.join(root, 'test', 'frontend', name))
|
||||
if (!tests.length) throw new Error('没有找到前端测试,拒绝空测试通过')
|
||||
result = await run('frontend', process.execPath, ['--test', ...tests], path.join(root, 'frontend'))
|
||||
}
|
||||
if (suite !== 'frontend' && result === 0) {
|
||||
if ((suite === 'all' || suite === 'deployment') && result === 0) {
|
||||
const tests = (await readdir(path.join(root, 'test', 'deployment')))
|
||||
.filter(name => name.endsWith('.test.mjs'))
|
||||
.map(name => path.join(root, 'test', 'deployment', name))
|
||||
if (!tests.length) throw new Error('没有找到部署测试,拒绝空测试通过')
|
||||
result = await run('deployment', process.execPath, ['--test', ...tests], root)
|
||||
}
|
||||
if ((suite === 'all' || suite === 'backend') && result === 0) {
|
||||
const args = extra[0] === '--' ? extra.slice(1) : extra
|
||||
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
|
||||
const replacements = {
|
||||
@ -59,7 +66,17 @@ if (suite !== 'frontend' && result === 0) {
|
||||
await writeFile(overlay, JSON.stringify({ Replace }, null, 2) + '\n')
|
||||
// 显式传入的审查 overlay 保持原有行为,默认入口自动包含本轮回归。
|
||||
const overlayArgs = args.some(arg => arg === '-overlay' || arg.startsWith('-overlay=')) ? [] : [`-overlay=${overlay}`]
|
||||
result = await run('backend', 'go', ['test', ...overlayArgs, ...(args.length ? args : ['./...'])], path.join(root, 'backend'))
|
||||
const goArguments = ['test', ...overlayArgs, ...(args.length ? args : ['./...'])]
|
||||
if (process.platform === 'win32') {
|
||||
const invocation = path.join(directory, 'go-invocation.json')
|
||||
await writeFile(invocation, JSON.stringify({ arguments: goArguments }, null, 2) + '\n')
|
||||
result = await run('backend', 'powershell.exe', [
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
path.join(root, 'test', 'deployment', 'run-go.ps1'), '-InvocationFile', invocation,
|
||||
], root)
|
||||
} else {
|
||||
result = await run('backend', 'go', goArguments, path.join(root, 'backend'))
|
||||
}
|
||||
}
|
||||
console.log(`测试记录:${path.relative(root, directory)}`)
|
||||
process.exitCode = result
|
||||
|
||||
Loading…
Reference in New Issue
Block a user