修复Linux测试目录隔离并通过HTTP全量实机复验

This commit is contained in:
xuans 2026-09-18 01:32:36 +08:00
parent 5a96623854
commit b866b135b7
4 changed files with 128 additions and 16 deletions

View File

@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -39,7 +40,7 @@ func TestSettingsAPIUpdatesSafeFieldsWithoutReturningSecrets(t *testing.T) {
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
cfg.Database.DSN = "mysql://secret-user:secret-password@localhost/scert"
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -99,7 +100,7 @@ func TestSettingsAPIWebAddressChangeDoesNotRequireRestart(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -141,7 +142,7 @@ func TestSettingsAPIWebEnabledChangeRequiresRestart(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -191,7 +192,7 @@ func TestSettingsAPIReturnsAndUpdatesRenewalPolicy(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -275,7 +276,7 @@ func TestSettingsAPIRejectsInvalidRenewalPolicy(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -316,7 +317,7 @@ func TestSettingsAPIReturnsChineseNotificationCredentialError(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -430,7 +431,7 @@ func TestSettingsAPIRollsBackCredentialsWhenValidationFails(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("save test config: %v", err)
}
@ -468,7 +469,7 @@ func TestSettingsAPICleansReplacedNotificationCredential(t *testing.T) {
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
cfg.Notification.HTTPAuthMode = "bearer"
cfg.Notification.HTTPCredentialRef = "secret://notification/old"
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("save test config: %v", err)
}
@ -512,7 +513,7 @@ func TestSettingsAPIRollsBackEarlierCredentialWhenLaterCredentialFails(t *testin
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
cfg.Notification.SMTPUsername = "smtp-user"
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("save test config: %v", err)
}
@ -552,7 +553,7 @@ func TestSettingsAPIDNSRequiresCompleteVerification(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -588,7 +589,7 @@ func TestSettingsAPIDNSProviderChangeRequiresReverification(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -621,7 +622,7 @@ func TestSettingsAPISavesVerifiedDNSCredentialWithoutReturningSecret(t *testing.
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -686,7 +687,7 @@ func TestSettingsAPIDNSCheckFailureRollsBackCredential(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
configPath := dir + "\\config\\scert.json"
configPath := filepath.Join(dir, "config", "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
@ -732,11 +733,11 @@ func TestSettingsAPIDNSSaveFailureRollsBackCredential(t *testing.T) {
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
initialConfigPath := dir + "\\config\\initial.json"
initialConfigPath := filepath.Join(dir, "config", "initial.json")
if err := cfg.Save(initialConfigPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
blockedPath := dir + "\\blocked"
blockedPath := filepath.Join(dir, "blocked")
if err := os.WriteFile(blockedPath, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("创建保存失败测试文件失败:%v", err)
}
@ -747,7 +748,7 @@ func TestSettingsAPIDNSSaveFailureRollsBackCredential(t *testing.T) {
secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{
newCredentialRef: {"api_token": "new-token"},
}}
server := New(Dependencies{Config: cfg, ConfigPath: blockedPath + "\\scert.json", SetupDNS: verifier, Secrets: secrets})
server := New(Dependencies{Config: cfg, ConfigPath: filepath.Join(blockedPath, "scert.json"), SetupDNS: verifier, Secrets: secrets})
cookie := loginForSettingsTest(t, server)
request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{

View File

@ -3,6 +3,7 @@ package httpapi
import (
"net/http"
"net/http/httptest"
"path/filepath"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
@ -10,6 +11,13 @@ import (
func readyTestConfig(directory string) config.Config {
cfg := config.Default(directory)
// Linux 默认目录属于正式安装,测试必须显式隔离所有可写路径。
cfg.Paths = config.PathsConfig{
Install: directory, Config: filepath.Join(directory, "conf"),
Data: filepath.Join(directory, "data"), Log: filepath.Join(directory, "logs"),
Certificate: filepath.Join(directory, "ssl"), Backup: filepath.Join(directory, "backups"),
}
cfg.Database.DSN = filepath.Join(cfg.Paths.Data, "scert.db")
cfg.SetupReady = true
return cfg
}

View File

@ -0,0 +1,61 @@
package httpapi
import (
"path/filepath"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestReadyTestConfigIsolatesRuntimePaths(t *testing.T) {
root := t.TempDir()
cfg := readyTestConfig(root)
for name, path := range map[string]string{
"install": cfg.Paths.Install, "config": cfg.Paths.Config,
"data": cfg.Paths.Data, "log": cfg.Paths.Log,
"certificate": cfg.Paths.Certificate, "backup": cfg.Paths.Backup,
"database": cfg.Database.DSN,
} {
relative, err := filepath.Rel(root, path)
if err != nil || !filepath.IsLocal(relative) {
t.Fatalf("测试路径越过临时目录:%s=%s,错误=%v", name, path, err)
}
}
if !cfg.SetupReady || cfg.Database.Driver != "sqlite" {
t.Fatal("隔离配置必须保留已初始化状态和 SQLite 数据库")
}
}
func TestReadyTestConfigCreatesIndependentDatabases(t *testing.T) {
for range 2 {
cfg := readyTestConfig(t.TempDir())
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.Migrate(t.Context(), db.DB, cfg.Database.Driver); err != nil {
t.Fatal(err)
}
var sequence int
var name, path string
if err := db.DB.QueryRowContext(t.Context(), "PRAGMA database_list").Scan(&sequence, &name, &path); err != nil {
t.Fatal(err)
}
if filepath.Clean(path) != filepath.Clean(cfg.Database.DSN) {
t.Fatalf("测试实际连接了错误的数据库:%s", path)
}
tasks := repository.NewSQLTaskRepository(db.DB)
items, err := tasks.ListTasks(t.Context(), "", 10)
if err != nil || len(items) != 0 {
t.Fatalf("新测试数据库不为空:%+v,错误=%v", items, err)
}
if err := tasks.CreateTask(t.Context(), repository.Task{
ID: "isolation-task", DomainID: "management-certificate",
TaskType: "management_renewal", Status: "running", Stage: "preflight",
}); err != nil {
t.Fatalf("测试数据库被其他用例复用或不可写:%v", err)
}
}
}

View File

@ -1,5 +1,47 @@
# Linux 生产流程测试记录
## 当前进展:Linux 测试隔离与跨平台路径复验
2026-09-18 本轮修改前检查点 `5a96623` 已推送。下方旧阶段的“当前进展”和待办按各自记录时间理解,以本节为最新状态;本轮没有替换正式程序,也不表示完整生产验收通过。
### 问题与修复
- [✓] 定位上一阶段管理任务测试的只读数据库错误:Linux `config.Default` 固定使用正式安装路径,HTTP 测试辅助函数仅传入临时目录并不能隔离数据库。现显式设置临时安装、配置、数据、日志、证书、备份目录及 SQLite DSN,不改变生产默认路径。
- [✓] 新增路径边界与数据库隔离测试,使用 SQLite `PRAGMA database_list` 核对实际打开的文件,并验证两个新数据库均为空且可分别写入相同任务 ID。
- [✓] 扩大全量测试后发现设置接口的保存失败用例以 Windows 反斜杠拼接路径,Linux 上未真正制造目录阻塞。该文件的路径拼接已改为 `filepath.Join`;重新验证了保存失败时凭据回退与令牌不消费。
- [✓] 已提交代码 `c8c7121` 包含独立管理证书续期任务、调度接入、审计及通知;当前仍只接通直连激活,代理模式会在申请前拒绝,不能标记 Nginx 自动续期完成。
### 本地验证
```sh
go test -p=2 ./...
go test -race -p=2 ./internal/httpapi -run 'TestReadyTestConfig|TestTaskAPI|TestManagement|TestTLSCertificate' -count=1
go test -race -p=2 ./internal/httpapi -count=1
go vet -p=2 ./internal/httpapi
```
以上均通过。后端全量测试在隔离辅助函数修改后执行;随后设置测试路径修改由 HTTP 全量竞态测试覆盖,耗时 63.647 秒。格式检查无待格式化文件。
### Linux 实机验证
测试以 `Scert` 用户运行,不清空或复用正式数据库。开启私有临时目录、私有网络、只读系统、隐藏用户目录和禁止提权;额外将 `/scert/data`、`/scert/conf`、`/scert/log`、`/scert/backups`、`/ssl`、`/ssl-scert-acceptance` 设置为不可访问,防止测试再次读取正式数据。测试只访问独立临时数据和本机隔离网络,不调用真实 DNS 或 ACME。
- 定向复验单元 `scert-http-isolation-20260918-0126.service` 成功退出,覆盖原失败管理任务、任务 API、管理激活、TLS 与新隔离用例。
- 首次全量单元 `scert-http-full-20260918-0128.service` 暴露上述 Windows 路径拼接问题,退出 1,原日志与测试程序保留。
- 修复后的全量单元 `scert-http-full-20260918-0132.service` 退出 0:169 项顶层测试通过、失败零、跳过零。运行 12.976 秒,CPU 时间 6.416 秒,峰值内存 24.1 MiB,Swap 为零;限制为内存 192 MiB、CPU 50%、64 个任务、180 秒运行上限。
最终测试程序为 `.runtime/http-isolation-20260918-0130.test`,远端路径为 `/scert/acceptance-20260917-1044/http-isolation-20260918-0130.test`。两端 SHA-256 一致:`2af3d17ba5bca2be1dd2052ddab8e5a836777caee1569a471d398f8cec8c1a5e`。使用新文件名上传,没有覆盖原失败证据。
结束时测试单元为 `inactive`、PID 为零。正式 Scert PID `588768`、Nginx `578671`、MySQL `588722` 与本轮检查起点一致;Scert 二进制摘要仍为 `f5b08bf0fb88f7b19281e91f942aa2995866f96420e422c015890b841bae6366`。这些 PID 与更早阶段不同,不能沿用旧记录声称跨阶段始终未重启。本轮没有执行正式服务重启。
`nginx -t` 通过,回环地址按管理域名严格验证 TLS 后,`/readyz` 返回 200、`status=ready`。Nginx 主配置与 Smail 站点摘要未变。入口仍为 `web.apmmk.cn` 的 80 跳转 443,443 反代本地 HTTPS 9004;本轮没有浏览器验收。
### 尚未完成的验收
- [✓] 本轮 HTTP 测试隔离和跨平台路径复审通过,不代表所有包的 Linux 全量测试已通过。
- [ ] 受限 Nginx 管理证书激活器、双层 TLS 就绪确认及失败回退。
- [ ] 包含管理存储隔离与续期调度的最新候选部署,以及真实目标机证书推送、通知送达、灾难恢复、容量和最终生产验收。
## 当前进展:管理证书存储隔离
2026-09-18 接续优化,修改前检查点 `6d7972f`、代码提交 `ec432f0` 均已推送。已修复同名业务证书更新可能清理管理入口引用目录的问题;正式程序尚未替换,本节不表示整体验收完成。