From bbd5376a6602c17f5c45574e0914738994ffdbdc Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 4 Oct 2026 11:04:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E9=82=AE=E4=BB=B6=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=9D=83=E9=99=90=E5=9B=9E=E5=BD=92=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=89=8D=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification_settings_scope_test.go | 124 ++++++++++++++++++ doc/Scert整改合并优化清单.md | 10 ++ frontend/src/views/Dashboard/Dashboard.js | 6 + .../views/Dashboard/SettingsAccess.test.js | 20 +++ 4 files changed, 160 insertions(+) create mode 100644 backend/internal/httpapi/notification_settings_scope_test.go diff --git a/backend/internal/httpapi/notification_settings_scope_test.go b/backend/internal/httpapi/notification_settings_scope_test.go new file mode 100644 index 0000000..ea2c6f3 --- /dev/null +++ b/backend/internal/httpapi/notification_settings_scope_test.go @@ -0,0 +1,124 @@ +package httpapi + +import ( + "bytes" + "context" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +type notificationScopeSecrets struct { + fakeSecretReaderStore + puts int + deletes int +} + +func (s *notificationScopeSecrets) Put(ctx context.Context, ref string, value map[string]string) error { + s.puts++ + return s.fakeSecretReaderStore.Put(ctx, ref, value) +} + +func (s *notificationScopeSecrets) Delete(ctx context.Context, ref string) error { + s.deletes++ + return s.fakeSecretReaderStore.Delete(ctx, ref) +} + +const notificationScopeBody = `{"notification":{"smtp_enabled":true,"smtp_host":"smtp.example.test","smtp_port":587,"smtp_mode":"starttls","smtp_username":"notify:system@example.test","smtp_password":"fixture-notification-password","recipients":["recipient@example.test"]}}` + +func TestNotificationSettingsOnlySuperAdminCanConfigure(t *testing.T) { + f := newProjectMemberAPIFixture(t) + f.server.config.Web.Hostname = "scert.example.test" + f.server.configPath = filepath.Join(t.TempDir(), "scert.json") + if err := f.server.config.Save(f.server.configPath); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(f.server.configPath) + if err != nil { + t.Fatal(err) + } + previous := f.server.config.Notification + secrets := ¬ificationScopeSecrets{} + f.server.secrets = secrets + for _, actor := range []string{"", "global-admin", "manager", "viewer", "target"} { + t.Run(actor, func(t *testing.T) { + response := serveUserRequest(f.server, f.cookies[actor], http.MethodPut, "/api/v1/settings", notificationScopeBody) + want := http.StatusForbidden + if actor == "" { + want = http.StatusUnauthorized + } + if response.Code != want || response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" { + t.Fatalf("通知配置写权限或缓存隔离错误:%s,状态 %d", actor, response.Code) + } + after, err := os.ReadFile(f.server.configPath) + if err != nil || !bytes.Equal(before, after) || !reflect.DeepEqual(previous, f.server.config.Notification) { + t.Fatal("被拒绝的请求修改了通知配置", err) + } + if secrets.puts != 0 || secrets.deletes != 0 || secrets.getCalls != 0 { + t.Fatal("被拒绝的请求仍访问了通知秘密仓库") + } + if strings.Contains(response.Body.String(), "fixture-notification-password") { + t.Fatal("拒绝响应回显了提交的密码") + } + }) + } + response := serveUserRequest(f.server, f.cookies["primary-super-admin"], http.MethodPut, "/api/v1/settings", notificationScopeBody) + if response.Code != http.StatusOK { + t.Fatalf("超级管理员无法保存通知配置:%d %s", response.Code, response.Body.String()) + } + saved, err := config.Load(f.server.configPath) + if err != nil || !saved.Notification.SMTPEnabled || saved.Notification.SMTPHost != "smtp.example.test" || saved.Notification.SMTPUsername != "notify:system@example.test" { + t.Fatal("超级管理员通知配置未正确持久化", err) + } + if secrets.puts != 1 || saved.Notification.SMTPCredentialRef == "" { + t.Fatal("通知密码没有独立保存到秘密仓库") + } + for _, method := range []string{http.MethodGet, http.MethodPut} { + if method == http.MethodGet { + response = serveUserRequest(f.server, f.cookies["primary-super-admin"], method, "/api/v1/settings", "") + } + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "fixture-notification-password") || strings.Contains(response.Body.String(), "secret://") { + t.Fatal("超级管理员设置响应不应包含密码或秘密引用") + } + } +} + +func TestNotificationSettingsRejectsRevokedSuperAdminSession(t *testing.T) { + for _, change := range []string{"demoted", "disabled", "deleted"} { + t.Run(change, func(t *testing.T) { + f := newProjectMemberAPIFixture(t) + cookie := httpUserSessionCookie(f.server, "notification-owner", repository.UserRoleSuperAdmin) + response := serveUserRequest(f.server, cookie, http.MethodGet, "/api/v1/settings", "") + if response.Code != http.StatusOK { + t.Fatal("初始超级管理员会话不可用") + } + statement := "UPDATE users SET role = 'admin' WHERE id = ?" + if change == "disabled" { + statement = "UPDATE users SET status = 'disabled' WHERE id = ?" + } else if change == "deleted" { + statement = "DELETE FROM users WHERE id = ?" + } + if _, err := f.db.ExecContext(t.Context(), statement, "notification-owner"); err != nil { + t.Fatal(err) + } + secrets := ¬ificationScopeSecrets{} + f.server.secrets = secrets + previous := f.server.config.Notification + for _, method := range []string{http.MethodPut, http.MethodGet} { + response = serveUserRequest(f.server, cookie, method, "/api/v1/settings", notificationScopeBody) + if response.Code != http.StatusUnauthorized || response.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("权限失效后旧会话仍可操作通知配置:%s %d", method, response.Code) + } + } + if !reflect.DeepEqual(previous, f.server.config.Notification) || secrets.puts != 0 || secrets.deletes != 0 || secrets.getCalls != 0 { + t.Fatal("失效会话仍修改了配置或访问秘密仓库") + } + }) + } +} diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 66203d2..0e7adf0 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,15 @@ # Scert 整改、合并与优化清单 +## 2026年10月4日10:55 邮件通知权限确认及复审任务 + +用户已明确:邮件通知配置仅超级管理员可管理,普通管理员和普通用户只能通过获授权业务调用系统发信,不提供任意收件人或任意内容的公开邮件中继接口。继续使用系统发信账户,不要求其他用户配置自己的 SMTP 凭据。 + +- [✓] 已通过域名登录超级管理员并打开邮件通知页,交用户填写;不刷新、不覆盖输入、不清数据库。当前域名入口仍不是第一台最新候选版本验收。 +- [~] 增补邮件配置读写角色矩阵、角色撤销与停用会话、秘密仓库零副作用、配置持久化和前端入口防绕过回归。 +- [✓] 第一台固定 `37765ff` 的 `0757` 任务于 08:04:50 结束:Linux 全量、vet、三数据库租约及七种 MySQL 迁移故障专项、授权探测与接口竞态通过。10:52 再次读取终态为 inactive、success、退出零。 +- [ ] 用户完成配置后的真实 Smail 系统发信及本机安全重置仍待实测;不得把普通用户密码重置现有的管理员协助流程描述成已具备公开邮件验证码自助重置。 +- [~] P1-03 旧账户导入、遗留租约核对、安全引用回收和完整签发续期仍未完成;第二台仅推送测试、Linux 与 Web 范围不变。 + ## 2026年10月4日07:40 执行租约与原子创建进度 07:55 实库结果:`f17ea6c` 第一台 Linux 全量、vet 和三库租约/原子创建竞态通过;整轮仍失败,MySQL 排序规则故障夹具未实际改变默认值。现已修正故障注入并增加列级冲突验证,待与授权探测增量一起复审,不修改产品拒绝规则来迁就测试。 diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 91f4914..8f80e92 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -793,6 +793,12 @@ export function useDashboard() { function openSettings() { settingsError.value = ""; dnsTestError.value = ""; + if (!canManageSystemSettings.value) { + settingsForm.value = normalizeSettings(null); + showSettings.value = false; + settingsError.value = "只有超级管理员可以配置邮件通知和系统设置"; + return; + } settingsForm.value = normalizeSettings(settings.value); showSettings.value = true; } diff --git a/frontend/src/views/Dashboard/SettingsAccess.test.js b/frontend/src/views/Dashboard/SettingsAccess.test.js index 509ac73..88218b4 100644 --- a/frontend/src/views/Dashboard/SettingsAccess.test.js +++ b/frontend/src/views/Dashboard/SettingsAccess.test.js @@ -3,6 +3,26 @@ import test from "node:test"; import { useDashboard } from "./Dashboard.js"; import { deferred, mountComposable } from "../../test-support/mount.js"; +for (const role of ["admin", "user"]) { + test(`邮件配置仅超管可用,禁止绕过入口提交:${role}`, async (t) => { + const writes = []; + const { state } = await mountComposable(t, useDashboard, async (path, init = {}) => { + if (init.method) writes.push({ path, ...init }); + if (path.endsWith("/auth/session")) return Response.json({ role }); + return Response.json({ items: [] }); + }); + state.settingsForm.value.notification.smtp_password = "stale-unsaved-secret"; + state.showSettings.value = true; + state.openSettings(); + assert.equal(state.showSettings.value, false); + assert.equal(state.settingsForm.value.notification.smtp_password, ""); + assert.match(state.settingsError.value, /只有超级管理员/); + await state.saveSettings(); + assert.deepEqual(writes, []); + assert.match(state.settingsError.value, /只有超级管理员/); + }); +} + for (const role of ["admin", "user", "super_admin"]) { test(`工作台按实时会话加载系统设置:${role}`, async (t) => { const requests = [];