Smail/backend/config/config_security_test.go
smail 10a2733605
Some checks are pending
Security Checks / security (push) Waiting to run
补齐安全码与已验证邮箱二选一授权回归覆盖
2026-10-02 18:36:39 +08:00

257 lines
9.1 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 config
import (
"bytes"
stdcontext "context"
"encoding/json"
"os"
"path/filepath"
"testing"
"git.pcux.cn/xuans/Smail/services/access"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
func TestConfigAuthorizationAcceptsEitherRecoveryOption(t *testing.T) {
actor := AuditActor{UserID: "2", Role: string(access.RoleAdmin), RecentAuth: true, ExternalEmailConfigured: true}
if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); err != nil {
t.Fatalf("email-only admin rejected: %v", err)
}
actor.RecentAuth = false
if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); !access.IsDenied(err, access.CodeRecentAuthRequired) {
t.Fatalf("email bypassed recent auth: %v", err)
}
actor.RecentAuth = true
if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationRollback, SectionRoot); !access.IsDenied(err, access.CodeAccessDenied) {
t.Fatalf("email granted rollback: %v", err)
}
actor.ExternalEmailConfigured = false
if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); !access.IsDenied(err, access.CodeRecoveryCodeRequired) {
t.Fatalf("missing recovery option accepted: %v", err)
}
actor.RecoveryCodeConfigured = true
if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); err != nil {
t.Fatalf("code-only admin rejected: %v", err)
}
}
func TestLogFormatterHandlesMissingLogID(t *testing.T) {
entry := &log.Entry{
Context: &appcontext.Context{Context: stdcontext.Background()},
Message: "配置操作",
}
output, err := (&logFormatter{}).Format(entry)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(output, []byte("%!s(<nil>)")) {
t.Fatalf("log output contains a formatting error: %q", output)
}
if !bytes.Contains(output, []byte("配置操作")) {
t.Fatalf("log output lost the message: %q", output)
}
}
func TestLogFormatterIncludesLogID(t *testing.T) {
ctx := &appcontext.Context{Context: stdcontext.Background()}
ctx.SetValue(appcontext.LogID, "request-123")
entry := &log.Entry{Context: ctx, Message: "配置操作"}
output, err := (&logFormatter{}).Format(entry)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(output, []byte("[request-123]")) {
t.Fatalf("log output lost the log ID: %q", output)
}
}
func TestConfigCredentialsAreEncryptedAtRest(t *testing.T) {
oldRoot := ROOT_PATH
root := t.TempDir()
ROOT_PATH = root + string(os.PathSeparator)
t.Cleanup(func() { ROOT_PATH = oldRoot })
original := &Config{
WeChatPushSecret: "wechat-secret-value",
TgBotToken: "telegram-secret-value",
WebPushToken: "web-push-secret-value",
}
if err := WriteConfig(original); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{original.WeChatPushSecret, original.TgBotToken, original.WebPushToken} {
if bytes.Contains(raw, []byte(secret)) {
t.Fatal("plaintext credential was written to configuration")
}
}
loaded, err := ReadConfig()
if err != nil {
t.Fatal(err)
}
if loaded.WeChatPushSecret != original.WeChatPushSecret || loaded.TgBotToken != original.TgBotToken || loaded.WebPushToken != original.WebPushToken {
t.Fatal("configuration credentials did not round trip")
}
if original.WeChatPushSecret != "wechat-secret-value" {
t.Fatal("write mutated the caller configuration")
}
}
func TestNotificationSystemConfigurationRequiresSuperAdmin(t *testing.T) {
for _, id := range []string{"telegram_push", "wechat_push"} {
t.Run(id, func(t *testing.T) {
original := PluginInstance{Enabled: true, Settings: json.RawMessage(`{"recipient":"original","enabled":true}`)}
current := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{id: original}}}
for _, tc := range []struct {
name string
modify func(*PluginInstance)
}{
{"enabled", func(p *PluginInstance) { p.Enabled = false }},
{"settings", func(p *PluginInstance) { p.Settings = json.RawMessage(`{"recipient":"changed"}`) }},
{"secret", func(p *PluginInstance) { p.Secret.Ciphertext = "changed" }},
{"executable", func(p *PluginInstance) { p.Executable = "changed" }},
{"capabilities", func(p *PluginInstance) { p.Permissions.Capabilities = []string{"network"} }},
{"network", func(p *PluginInstance) {
p.Permissions.Network = []PluginNetworkTarget{{Scheme: "https", Host: "example.com", Port: 443}}
}},
} {
t.Run(tc.name, func(t *testing.T) {
next := original
tc.modify(&next)
candidate := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{id: next}}}
for _, role := range []string{"super_admin", "admin", "user"} {
err := AuthorizePluginChanges(AuditActor{UserID: "1", Role: role}, current, candidate)
if (err == nil) != (role == "super_admin") {
t.Fatalf("角色=%s,修改=%s,授权结果=%v", role, tc.name, err)
}
}
})
}
for _, role := range []string{"super_admin", "admin", "user"} {
actor := AuditActor{UserID: "1", Role: role}
if err := AuthorizePluginChanges(actor, RuntimeConfig{}, current); (err == nil) != (role == "super_admin") {
t.Fatalf("角色=%s,首次配置授权结果=%v", role, err)
}
if err := AuthorizePluginChanges(actor, current, RuntimeConfig{}); (err == nil) != (role == "super_admin") {
t.Fatalf("角色=%s,移除配置授权结果=%v", role, err)
}
}
})
}
}
func TestAdminCanCarryUnchangedNotificationConfiguration(t *testing.T) {
for _, id := range []string{"telegram_push", "wechat_push"} {
current := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{
id: {Enabled: true, Settings: json.RawMessage(`{"recipient":"original","enabled":true}`)},
"spam_block": {Settings: json.RawMessage(`{"score":1}`)},
}}}
candidate := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{
id: {Enabled: true, Settings: json.RawMessage(`{ "enabled": true, "recipient": "original" }`)},
"spam_block": {Settings: json.RawMessage(`{"score":2}`)},
}}}
if err := AuthorizePluginChanges(AuditActor{UserID: "2", Role: "admin"}, current, candidate); err != nil {
t.Fatalf("修改垃圾邮件参数不应误拦截未变化的渠道 %s:%v", id, err)
}
if err := AuthorizePluginChanges(AuditActor{UserID: "3", Role: "user"}, current, candidate); err == nil {
t.Fatal("普通用户不应修改系统插件配置")
}
invalid := candidate.Plugins.Plugins[id]
invalid.Settings = append(invalid.Settings, []byte(" {}")...)
candidate.Plugins.Plugins[id] = invalid
if err := AuthorizePluginChanges(AuditActor{UserID: "2", Role: "admin"}, current, candidate); err == nil {
t.Fatal("尾随 JSON 不应视为未变化的系统配置")
}
}
}
func TestConfigPrefixCredentialRoundTrip(t *testing.T) {
oldRoot := ROOT_PATH
root := t.TempDir()
ROOT_PATH = root + string(os.PathSeparator)
t.Cleanup(func() { ROOT_PATH = oldRoot })
original := &Config{WeChatPushSecret: "enc:v1:provider-secret"}
if err := WriteConfig(original); err != nil {
t.Fatal(err)
}
loaded, err := ReadConfig()
if err != nil {
t.Fatal(err)
}
if loaded.WeChatPushSecret != original.WeChatPushSecret {
t.Fatal("prefix credential did not round trip through configuration")
}
}
func TestReadConfigRejectsLegacyPlaintextCredentials(t *testing.T) {
oldRoot := ROOT_PATH
root := t.TempDir()
ROOT_PATH = root + string(os.PathSeparator)
t.Cleanup(func() { ROOT_PATH = oldRoot })
legacy := &Config{
WeChatPushSecret: "legacy-wechat-secret",
TgBotToken: "legacy-telegram-token",
WebPushToken: "legacy-web-push-token",
}
raw, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err = writeConfigFile("config.json", raw); err != nil {
t.Fatal(err)
}
if _, err := ReadConfig(); err == nil {
t.Fatal("legacy plaintext credential was accepted")
}
stored, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
if err != nil {
t.Fatal(err)
}
for _, plaintext := range []string{legacy.WeChatPushSecret, legacy.TgBotToken, legacy.WebPushToken} {
if !bytes.Contains(stored, []byte(plaintext)) {
t.Fatal("rejected configuration was modified")
}
}
}
func TestConfigRejectsTamperedCredential(t *testing.T) {
oldRoot := ROOT_PATH
root := t.TempDir()
ROOT_PATH = root + string(os.PathSeparator)
t.Cleanup(func() { ROOT_PATH = oldRoot })
if err := WriteConfig(&Config{WeChatPushSecret: "wechat-secret-value"}); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "config", "config.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
marker := []byte("enc:v1:")
index := bytes.Index(raw, marker)
if index < 0 {
t.Fatal("encrypted credential marker was not found")
}
change := index + len(marker)
if raw[change] == 'A' {
raw[change] = 'B'
} else {
raw[change] = 'A'
}
if err := os.WriteFile(path, raw, 0600); err != nil {
t.Fatal(err)
}
if _, err := ReadConfig(); err == nil {
t.Fatal("tampered credential was accepted")
}
}