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{}); 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 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) } }) } }