Smail/backend/services/plugin/config_test.go
smail 8104998417 隔离系统通知配置权限并保留各角色个人设置
个人扫码绑定实现前检查点;补充权限与账户隔离回归用例,业务实机验收尚未执行。
2026-09-28 20:57:56 +08:00

269 lines
11 KiB
Go
Raw 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 plugin
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/credential"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
func TestConfigServiceStoresPluginSecretsAsCiphertext(t *testing.T) {
service, engine := newPluginConfigService(t)
status, err := service.ChangeSecret("telegram_push", "botToken", config.SecretChange{
Operation: config.SecretReplace,
Value: &config.SecretInput{Plaintext: "telegram-token-for-test"},
}, 0)
if err != nil {
t.Fatal(err)
}
if !status.Configured || status.Version != 1 {
t.Fatalf("status = %+v", status)
}
if _, err = service.ChangeSecret("telegram_push", "unknown", config.SecretChange{}, 0); !errors.Is(err, ErrUnknownPluginField) {
t.Fatalf("unknown field error = %v", err)
}
record := models.PluginSecret{}
found, err := engine.Where("plugin_id = ? AND field_name = ?", "telegram_push", "botToken").Get(&record)
if err != nil || !found {
t.Fatalf("load secret record: found=%v err=%v", found, err)
}
if record.Ciphertext == "telegram-token-for-test" || record.Ciphertext == "" || record.KeyVersion == 0 {
t.Fatalf("secret is not safely stored: %+v", record)
}
visible, err := service.GetSecretStatus("telegram_push", "botToken")
if err != nil || !visible.Configured || visible.Version != record.RecordVersion {
t.Fatalf("visible status = %+v, err=%v", visible, err)
}
if _, err = engine.ID(record.ID).Cols("ciphertext").Update(&models.PluginSecret{Ciphertext: "smail:v1:tampered"}); err != nil {
t.Fatal(err)
}
if _, err = service.openSecretForPlugin("telegram_push", "botToken"); err == nil {
t.Fatal("tampered ciphertext was accepted")
}
}
func TestConfigServiceEncryptsTelegramUserSettingsAndRejectsTampering(t *testing.T) {
service, engine := newPluginConfigService(t)
user := insertPluginTestUser(t, engine, "user", access.RoleUser)
status, err := service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: "-100123456"}, 0)
if err != nil || status.Version != 1 {
t.Fatalf("update status = %+v, err=%v", status, err)
}
record := models.PluginUserSetting{}
found, err := engine.Where("plugin_id = ? AND user_id = ?", telegramPluginID, user.ID).Get(&record)
if err != nil || !found {
t.Fatalf("load user setting: found=%v err=%v", found, err)
}
if record.SettingsCiphertext == `{"chatId":"-100123456"}` || record.SettingsCiphertext == "" || record.SettingsHash == "" || !record.Enabled || record.SchemaVersion != userSettingsSchemaVersion {
t.Fatalf("user setting is not safely stored: %+v", record)
}
if _, err = engine.ID(record.ID).Cols("settings_hash").Update(&models.PluginUserSetting{SettingsHash: "tampered"}); err != nil {
t.Fatal(err)
}
if _, err = service.GetOwnTelegramSettings(user.ID); !errors.Is(err, ErrPluginStoreUnavailable) {
t.Fatalf("tampered user setting error = %v", err)
}
}
func TestConfigServiceAllowsEmptyTelegramChatIDToDisablePush(t *testing.T) {
service, engine := newPluginConfigService(t)
user := insertPluginTestUser(t, engine, "disabled-push", access.RoleUser)
status, err := service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{}, 0)
if err != nil {
t.Fatalf("empty chat ID update failed: %v", err)
}
if status.Settings.ChatID != "" || status.Version != 1 {
t.Fatalf("unexpected empty chat ID status: %+v", status)
}
entries, err := service.ListTelegramUserSettings()
if err != nil || len(entries) != 1 || entries[0].Settings.ChatID != "" {
t.Fatalf("stored empty chat ID settings = %+v, err=%v", entries, err)
}
}
func TestEveryRoleKeepsIndependentOwnTelegramSettings(t *testing.T) {
service, engine := newPluginConfigService(t)
var users []models.User
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
user := insertPluginTestUser(t, engine, "own-"+string(role), role)
users = append(users, user)
status, err := service.GetOwnTelegramSettings(user.ID)
if err != nil || status.Settings.ChatID != "" || status.Version != 0 {
t.Fatalf("角色=%s,不应自动绑定系统接收身份:%v", role, err)
}
}
chatIDs := []string{"10101", "20202", "30303"}
for index, user := range users {
status, err := service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: chatIDs[index]}, 0)
if err != nil || status.Settings.ChatID != chatIDs[index] || status.Version != 1 {
t.Fatalf("角色=%s,本人通知设置保存失败:%v", user.EffectiveRole(), err)
}
}
if _, err := service.UpdateOwnTelegramSettings(users[0].ID, TelegramUserSettings{ChatID: "40404"}, 1); err != nil {
t.Fatalf("超级管理员不能修改本人的通知设置:%v", err)
}
chatIDs[0] = "40404"
for index, user := range users {
status, err := service.GetOwnTelegramSettings(user.ID)
if err != nil || status.Settings.ChatID != chatIDs[index] {
t.Fatalf("角色=%s,个人通知设置发生跨账户串用:%v", user.EffectiveRole(), err)
}
}
}
func TestConfigServiceRejectsTrailingTelegramSettingsJSON(t *testing.T) {
service, engine := newPluginConfigService(t)
user := insertPluginTestUser(t, engine, "trailing-json", access.RoleUser)
if _, err := service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: "123"}, 0); err != nil {
t.Fatal(err)
}
record := models.PluginUserSetting{}
found, err := engine.Where("plugin_id = ? AND user_id = ?", telegramPluginID, user.ID).Get(&record)
if err != nil || !found {
t.Fatalf("load setting record: found=%v err=%v", found, err)
}
plaintext, err := service.vault.Decrypt(credential.CurrentVersion, record.SettingsCiphertext)
if err != nil {
t.Fatal(err)
}
badPlaintext := plaintext + " {}"
ciphertext, err := service.vault.Encrypt(badPlaintext)
if err != nil {
t.Fatal(err)
}
fingerprint, err := service.vault.Fingerprint(badPlaintext)
if err != nil {
t.Fatal(err)
}
if _, err = engine.ID(record.ID).Cols("settings_ciphertext", "settings_hash").Update(&models.PluginUserSetting{
SettingsCiphertext: ciphertext,
SettingsHash: fingerprint,
}); err != nil {
t.Fatal(err)
}
if _, err = service.GetOwnTelegramSettings(user.ID); !errors.Is(err, ErrPluginStoreUnavailable) {
t.Fatalf("trailing JSON was accepted: %v", err)
}
}
func TestConfigServiceEnforcesUserSettingRolesAndVersions(t *testing.T) {
service, engine := newPluginConfigService(t)
super := insertPluginTestUser(t, engine, "root", access.RoleSuperAdmin)
admin := insertPluginTestUser(t, engine, "admin", access.RoleAdmin)
secondAdmin := insertPluginTestUser(t, engine, "admin2", access.RoleAdmin)
user := insertPluginTestUser(t, engine, "user", access.RoleUser)
own, err := service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: "123456"}, 0)
if err != nil || own.Version != 1 {
t.Fatalf("own update = %+v, err=%v", own, err)
}
updated, err := service.UpdateTelegramSettingsForUser(admin, user.ID, TelegramUserSettings{ChatID: "-100123456"}, own.Version)
if err != nil || updated.Version != 2 {
t.Fatalf("admin update = %+v, err=%v", updated, err)
}
if _, err = service.UpdateTelegramSettingsForUser(admin, secondAdmin.ID, TelegramUserSettings{ChatID: "1"}, 0); !access.IsDenied(err, access.CodeTargetRoleDenied) {
t.Fatalf("admin writes admin error = %v", err)
}
if _, err = service.UpdateTelegramSettingsForUser(user, admin.ID, TelegramUserSettings{ChatID: "1"}, 0); !access.IsDenied(err, access.CodeAccessDenied) {
t.Fatalf("user cross-account error = %v", err)
}
if _, err = service.UpdateTelegramSettingsForUser(super, secondAdmin.ID, TelegramUserSettings{ChatID: "2"}, 0); err != nil {
t.Fatalf("super update admin: %v", err)
}
if _, err = service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: "777"}, own.Version); !errors.Is(err, ErrPluginSettingConflict) {
t.Fatalf("stale version error = %v", err)
}
if _, err = service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: "001"}, updated.Version); !errors.Is(err, ErrInvalidTelegramChatID) {
t.Fatalf("invalid chat ID error = %v", err)
}
}
func TestConfigServiceBulkTicketIsBoundOneTimeAndVersioned(t *testing.T) {
service, engine := newPluginConfigService(t)
super := insertPluginTestUser(t, engine, "root", access.RoleSuperAdmin)
first := insertPluginTestUser(t, engine, "first", access.RoleUser)
second := insertPluginTestUser(t, engine, "second", access.RoleUser)
preview, err := service.PreviewBulkTelegramSettings(super, []int{second.ID, first.ID}, TelegramUserSettings{ChatID: "123"}, false)
if err != nil || len(preview.Targets) != 2 || preview.Targets[0].UserID != first.ID {
t.Fatalf("preview = %+v, err=%v", preview, err)
}
if err = service.ExecuteBulkTelegramSettings(super.ID, preview.Ticket); err != nil {
t.Fatal(err)
}
if err = service.ExecuteBulkTelegramSettings(super.ID, preview.Ticket); !errors.Is(err, ErrPluginBulkTicketInvalid) {
t.Fatalf("ticket replay error = %v", err)
}
firstStatus, err := service.GetOwnTelegramSettings(first.ID)
if err != nil || firstStatus.Version != 1 || firstStatus.Settings.ChatID != "123" {
t.Fatalf("first setting = %+v, err=%v", firstStatus, err)
}
conflicting, err := service.PreviewBulkTelegramSettings(super, []int{first.ID}, TelegramUserSettings{ChatID: "456"}, true)
if err != nil {
t.Fatal(err)
}
if _, err = service.UpdateOwnTelegramSettings(first.ID, TelegramUserSettings{ChatID: "789"}, firstStatus.Version); err != nil {
t.Fatal(err)
}
if err = service.ExecuteBulkTelegramSettings(super.ID, conflicting.Ticket); !errors.Is(err, ErrPluginSettingConflict) {
t.Fatalf("bulk version conflict error = %v", err)
}
if err = service.ExecuteBulkTelegramSettings(first.ID, conflicting.Ticket); !errors.Is(err, ErrPluginBulkTicketInvalid) {
t.Fatalf("actor-bound ticket error = %v", err)
}
base := time.Date(2026, time.August, 28, 8, 0, 0, 0, time.UTC)
service.now = func() time.Time { return base }
expiring, err := service.PreviewBulkTelegramSettings(super, []int{second.ID}, TelegramUserSettings{ChatID: "999"}, true)
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return base.Add(6 * time.Minute) }
if err = service.ExecuteBulkTelegramSettings(super.ID, expiring.Ticket); !errors.Is(err, ErrPluginBulkTicketInvalid) {
t.Fatalf("expired ticket error = %v", err)
}
}
func newPluginConfigService(t *testing.T) (*ConfigService, *xorm.Engine) {
t.Helper()
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "plugin-config.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
runner := migration.Runner{DB: engine.DB().DB, Dialect: migration.DialectSQLite, Migrations: migration.DefaultMigrations()}
if _, err = runner.Run(context.Background()); err != nil {
t.Fatal(err)
}
vault, err := credential.NewVault(t.TempDir())
if err != nil {
t.Fatal(err)
}
service, err := NewConfigService(engine, vault)
if err != nil {
t.Fatal(err)
}
return service, engine
}
func insertPluginTestUser(t *testing.T, engine *xorm.Engine, account string, role access.Role) models.User {
t.Helper()
user := models.User{Account: account, Name: account, Password: "hash"}
user.ApplyRole(role)
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
return user
}