Scert/backend/internal/httpapi/notification_settings_scope_test.go

130 lines
5.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.config.DNS.VerificationState = "verified"
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) {
read := serveUserRequest(f.server, f.cookies[actor], http.MethodGet, "/api/v1/settings", "")
response := serveUserRequest(f.server, f.cookies[actor], http.MethodPut, "/api/v1/settings", notificationScopeBody)
want := http.StatusForbidden
if actor == "" {
want = http.StatusUnauthorized
}
if read.Code != want || read.Header().Get("Cache-Control") != "private, no-store" || read.Header().Get("Vary") != "Cookie" {
t.Fatalf("通知配置读权限或缓存隔离错误:%s,状态 %d", actor, read.Code)
}
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.MethodPut, http.MethodGet} {
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("失效会话仍修改了配置或访问秘密仓库")
}
})
}
}