Smail/backend/services/plugin/config_test.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

239 lines
9.8 KiB
Go

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