diff --git a/backend/internal/httpapi/settings_api_test.go b/backend/internal/httpapi/settings_api_test.go index 7a1e4c7..cfe289f 100644 --- a/backend/internal/httpapi/settings_api_test.go +++ b/backend/internal/httpapi/settings_api_test.go @@ -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(`{ diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go index 8abb669..863d1f7 100644 --- a/backend/internal/httpapi/test_helpers_test.go +++ b/backend/internal/httpapi/test_helpers_test.go @@ -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 } diff --git a/backend/internal/httpapi/test_isolation_test.go b/backend/internal/httpapi/test_isolation_test.go new file mode 100644 index 0000000..494f2d3 --- /dev/null +++ b/backend/internal/httpapi/test_isolation_test.go @@ -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) + } + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 9c58203..11126e8 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -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` 均已推送。已修复同名业务证书更新可能清理管理入口引用目录的问题;正式程序尚未替换,本节不表示整体验收完成。