Files
2026-07-30 12:23:05 +08:00

303 lines
9.1 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
// newSettingEnv 建一个只含 Setting 表的内存库环境。
func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open in-memory sqlite: %v", err)
}
// :memory: 库每个连接彼此独立,SendAsync 等后台 goroutine 读库需复用同一连接
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.Setting{}, &model.User{}, &model.UserIdentity{}, &model.UserPasskey{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-data-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
return NewSettingService(db, cipher), db
}
func strPtr(s string) *string { return &s }
func intPtr(n int) *int { return &n }
func boolPtr(b bool) *bool { return &b }
func TestUpdateTelegramTokenHandling(t *testing.T) {
tests := []struct {
name string
initial *string // 先保存一次的 token;nil 表示未配置过
update *string // 第二次更新携带的 botToken
wantToken string // 最终 TelegramConfig 应解出的 token
}{
{name: "nil 沿用旧 token", initial: strPtr("tok-1234567890"), update: nil, wantToken: "tok-1234567890"},
{name: "非 nil 覆盖旧 token", initial: strPtr("tok-1234567890"), update: strPtr("tok-abcdefghij"), wantToken: "tok-abcdefghij"},
{name: "空串清除已存 token", initial: strPtr("tok-1234567890"), update: strPtr(""), wantToken: ""},
{name: "首次设置", initial: nil, update: strPtr("tok-first99999"), wantToken: "tok-first99999"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
ctx := context.Background()
if tt.initial != nil {
in := UpdateTelegramInput{Enabled: true, BotToken: tt.initial, ChatID: "42"}
if err := svc.UpdateTelegram(ctx, in); err != nil {
t.Fatalf("save initial: %v", err)
}
}
in := UpdateTelegramInput{Enabled: true, BotToken: tt.update, ChatID: "42"}
if err := svc.UpdateTelegram(ctx, in); err != nil {
t.Fatalf("update: %v", err)
}
cfg, err := svc.TelegramConfig(ctx)
if err != nil {
t.Fatalf("TelegramConfig: %v", err)
}
if cfg.BotToken != tt.wantToken {
t.Errorf("token = %q, want %q", cfg.BotToken, tt.wantToken)
}
})
}
}
func TestTelegramViewNeverReturnsPlaintext(t *testing.T) {
svc, db := newSettingEnv(t)
ctx := context.Background()
token := "123456789:AAtestTokenValue"
in := UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "-100200"}
if err := svc.UpdateTelegram(ctx, in); err != nil {
t.Fatalf("UpdateTelegram: %v", err)
}
view, err := svc.TelegramView(ctx)
if err != nil {
t.Fatalf("TelegramView: %v", err)
}
want := TelegramView{Enabled: true, ChatID: "-100200", TokenSet: true, TokenTail: "alue"}
if view != want {
t.Errorf("view = %+v, want %+v", view, want)
}
// 落库必须是密文:存储值不含 token 原文,但能解密还原
var st model.Setting
if err := db.First(&st, "key = ?", settingTelegramBotToken).Error; err != nil {
t.Fatalf("load setting row: %v", err)
}
if strings.Contains(st.Value, token) {
t.Errorf("token stored in plaintext: %q", st.Value)
}
cfg, err := svc.TelegramConfig(ctx)
if err != nil || cfg.BotToken != token {
t.Errorf("decrypted token = %q (%v), want %q", cfg.BotToken, err, token)
}
}
func TestTokenTail(t *testing.T) {
tests := []struct {
name string
token string
want string
}{
{name: "常规 token 取尾 4 位", token: "123456789:AAtestTokenValue", want: "alue"},
{name: "过短不展示", token: "abc", want: ""},
{name: "空串", token: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tokenTail(tt.token); got != tt.want {
t.Errorf("tokenTail(%q) = %q, want %q", tt.token, got, tt.want)
}
})
}
}
func TestNotifyEventsReadWrite(t *testing.T) {
allOn := NotifyEventsView{
TaskFail: true, TaskRecover: true, SnatchSuccess: true, TenantDead: true,
TaskStop: true, LoginLock: true, ModelDeprecated: true,
LogEventInstance: true, LogEventIdentity: true, LogEventPolicy: true,
LogEventRegion: true, LogEventLogin: true,
}
offTaskFailStop := allOn
offTaskFailStop.TaskFail, offTaskFailStop.TaskStop = false, false
offTenantDead := allOn
offTenantDead.TenantDead = false
tests := []struct {
name string
setup func(t *testing.T, svc *SettingService)
want NotifyEventsView
}{
{
name: "键全部缺省视为全开(兼容存量部署)",
setup: func(t *testing.T, svc *SettingService) {},
want: allOn,
},
{
name: "写 0 后读回 false",
setup: func(t *testing.T, svc *SettingService) {
in := allOn
in.TaskFail = false
in.TaskStop = false
if err := svc.UpdateNotifyEvents(context.Background(), in); err != nil {
t.Fatalf("UpdateNotifyEvents: %v", err)
}
},
want: offTaskFailStop,
},
{
name: "部分键缺失时只关显式为 0 的项",
setup: func(t *testing.T, svc *SettingService) {
if err := svc.set(context.Background(), settingNotifyEventTenantDead, "0"); err != nil {
t.Fatalf("set: %v", err)
}
},
want: offTenantDead,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
tt.setup(t, svc)
got, err := svc.NotifyEvents(context.Background())
if err != nil {
t.Fatalf("NotifyEvents: %v", err)
}
if got != tt.want {
t.Errorf("view = %+v, want %+v", got, tt.want)
}
})
}
}
func TestNotifyEventEnabled(t *testing.T) {
tests := []struct {
name string
value *string // 键值;nil 表示键不存在
want bool
}{
{name: "键缺失视为开启", value: nil, want: true},
{name: "显式 0 关闭", value: strPtr("0"), want: false},
{name: "显式 1 开启", value: strPtr("1"), want: true},
{name: "脏数据非 0 视为开启", value: strPtr("maybe"), want: true},
{name: "空值视为开启", value: strPtr(""), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
ctx := context.Background()
if tt.value != nil {
if err := svc.set(ctx, settingNotifyEventTaskFail, *tt.value); err != nil {
t.Fatalf("set: %v", err)
}
}
if got := svc.NotifyEventEnabled(ctx, "task_fail"); got != tt.want {
t.Errorf("NotifyEventEnabled = %v, want %v", got, tt.want)
}
})
}
}
// saveTaskLimit 返回把抢机熔断阈值写入设置的 setup 步骤,供 TestTaskSettings 复用。
func saveTaskLimit(limit int) func(t *testing.T, svc *SettingService) {
return func(t *testing.T, svc *SettingService) {
t.Helper()
in := TaskSettingsView{SnatchAuthFailLimit: limit}
if err := svc.UpdateTaskSettings(context.Background(), in); err != nil {
t.Fatalf("UpdateTaskSettings(%d): %v", limit, err)
}
}
}
func TestTaskSettings(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, svc *SettingService)
want int
}{
{name: "键缺省回退默认 3", setup: func(t *testing.T, svc *SettingService) {}, want: 3},
{name: "写 5 读回 5", setup: saveTaskLimit(5), want: 5},
{name: "合法下界写 1 读回 1", setup: saveTaskLimit(1), want: 1},
{name: "合法上界写 10 读回 10", setup: saveTaskLimit(10), want: 10},
{
name: "脏数据非整数回退 3",
setup: func(t *testing.T, svc *SettingService) {
if err := svc.set(context.Background(), settingSnatchAuthFailLimit, "abc"); err != nil {
t.Fatalf("set: %v", err)
}
},
want: 3,
},
{
name: "脏数据越界回退 3",
setup: func(t *testing.T, svc *SettingService) {
if err := svc.set(context.Background(), settingSnatchAuthFailLimit, "99"); err != nil {
t.Fatalf("set: %v", err)
}
},
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
tt.setup(t, svc)
got, err := svc.TaskSettings(context.Background())
if err != nil {
t.Fatalf("TaskSettings: %v", err)
}
if got.SnatchAuthFailLimit != tt.want {
t.Errorf("limit = %d, want %d", got.SnatchAuthFailLimit, tt.want)
}
})
}
}
func TestUpdateTaskSettingsRejectsOutOfRange(t *testing.T) {
tests := []struct {
name string
limit int
}{
{name: "低于下界 0", limit: 0},
{name: "高于上界 11", limit: 11},
{name: "负值", limit: -3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
err := svc.UpdateTaskSettings(context.Background(), TaskSettingsView{SnatchAuthFailLimit: tt.limit})
if !errors.Is(err, ErrInvalidAuthFailLimit) {
t.Fatalf("err = %v, want ErrInvalidAuthFailLimit", err)
}
got, err := svc.TaskSettings(context.Background())
if err != nil {
t.Fatalf("TaskSettings: %v", err)
}
if got.SnatchAuthFailLimit != 3 {
t.Errorf("拒绝后读回 = %d, want 保持默认 3", got.SnatchAuthFailLimit)
}
})
}
}