保存邮件通知权限回归修复前检查点

This commit is contained in:
xuans 2026-10-04 11:04:41 +08:00
parent 5ed080e171
commit bbd5376a66
4 changed files with 160 additions and 0 deletions

View File

@ -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 := &notificationScopeSecrets{}
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 := &notificationScopeSecrets{}
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("失效会话仍修改了配置或访问秘密仓库")
}
})
}
}

View File

@ -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 排序规则故障夹具未实际改变默认值。现已修正故障注入并增加列级冲突验证,待与授权探测增量一起复审,不修改产品拒绝规则来迁就测试。

View File

@ -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;
}

View File

@ -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 = [];