package service import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "affiliate_dash/internal/model" ) func TestEnsureTokenValid_RefreshesExpiredToken(t *testing.T) { db := newRockTestDB(t) codec, err := NewSecretCodec("test-master-key") if err != nil { t.Fatalf("创建 codec 失败: %v", err) } var loginCalls int32 var badBody int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/auth/login": atomic.AddInt32(&loginCalls, 1) var body map[string]interface{} _ = json.NewDecoder(r.Body).Decode(&body) if body["publicShape"] != true || body["username"] != "tester" { atomic.AddInt32(&badBody, 1) } _, _ = w.Write([]byte(`{"code":0,"token":"fresh-token","user":{"username":"tester","role":"user"}}`)) case "/api/me": if r.Header.Get("Authorization") == "Bearer fresh-token" { _, _ = w.Write([]byte(`{"user":{"username":"tester","points":"123.5"}}`)) return } w.WriteHeader(http.StatusUnauthorized) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL), codec) ctx := context.Background() cfg, err := svc.GetConfig(ctx) if err != nil { t.Fatalf("初始化配置失败: %v", err) } ciphertext, err := codec.Encrypt("secret-pass") if err != nil { t.Fatalf("加密密码失败: %v", err) } if err := db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ "bearer_token": "expired-token", "login_username": "tester", "login_password_ciphertext": ciphertext, }).Error; err != nil { t.Fatalf("写入测试配置失败: %v", err) } if err := svc.EnsureTokenValid(ctx); err != nil { t.Fatalf("Token 自动刷新失败: %v", err) } if atomic.LoadInt32(&badBody) != 0 { t.Fatal("登录请求体缺少 publicShape 或 username") } if calls := atomic.LoadInt32(&loginCalls); calls != 1 { t.Fatalf("应只登录一次,实际 %d", calls) } updated, err := svc.GetConfig(ctx) if err != nil { t.Fatalf("读取配置失败: %v", err) } if updated.BearerToken != "fresh-token" { t.Fatalf("token 未刷新: %q", updated.BearerToken) } if updated.LastTokenRefreshAt == nil { t.Fatal("last_token_refresh_at 未记录") } if updated.LastTokenError != "" { t.Fatalf("刷新成功后不应保留错误: %q", updated.LastTokenError) } if updated.Points != 123.5 { t.Fatalf("积分未同步: %v", updated.Points) } } func TestEnsureTokenValid_LoginFailureRecordsError(t *testing.T) { db := newRockTestDB(t) codec, _ := NewSecretCodec("test-master-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/auth/login": _, _ = w.Write([]byte(`{"code":1,"message":"用户名或密码错误"}`)) default: w.WriteHeader(http.StatusUnauthorized) } })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL), codec) ctx := context.Background() cfg, _ := svc.GetConfig(ctx) ciphertext, _ := codec.Encrypt("wrong-pass") _ = db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ "bearer_token": "expired-token", "login_username": "tester", "login_password_ciphertext": ciphertext, }).Error err := svc.EnsureTokenValid(ctx) if err == nil || !strings.Contains(err.Error(), "用户名或密码错误") { t.Fatalf("应返回登录失败原因,实际 %v", err) } updated, _ := svc.GetConfig(ctx) if !strings.Contains(updated.LastTokenError, "用户名或密码错误") { t.Fatalf("last_token_error 未记录: %q", updated.LastTokenError) } if updated.BearerToken != "expired-token" { t.Fatalf("登录失败不应覆盖 token: %q", updated.BearerToken) } } func TestEnsureTokenValid_NoCredentialsSkipsSilently(t *testing.T) { db := newRockTestDB(t) svc := NewRockService(db, NewRockClient("https://example.com")) if err := svc.EnsureTokenValid(context.Background()); err != nil { t.Fatalf("未配置 token 与账号密码时应静默返回,实际 %v", err) } } func TestUpdateConfig_EncryptsPasswordAndHidesIt(t *testing.T) { db := newRockTestDB(t) codec, _ := NewSecretCodec("test-master-key") svc := NewRockService(db, NewRockClient("https://example.com"), codec) ctx := context.Background() username := "tester" password := "p@ss-word" updated, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{ LoginUsername: &username, LoginPassword: &password, }) if err != nil { t.Fatalf("保存账号密码失败: %v", err) } if updated.LoginPasswordCiphertext == "" || updated.LoginPasswordCiphertext == password { t.Fatalf("密码未加密落库: %q", updated.LoginPasswordCiphertext) } if !updated.LoginPasswordSet { t.Fatal("login_password_set 应为 true") } plain, err := codec.Decrypt(updated.LoginPasswordCiphertext) if err != nil || plain != password { t.Fatalf("密码解密不一致: %q err=%v", plain, err) } raw, _ := json.Marshal(updated) if strings.Contains(string(raw), password) || strings.Contains(string(raw), updated.LoginPasswordCiphertext) { t.Fatalf("配置响应泄露了密码: %s", string(raw)) } if !strings.Contains(string(raw), `"login_password_set":true`) { t.Fatalf("配置响应缺少 login_password_set: %s", string(raw)) } // 不传密码时应保持已保存的密文不变。 same, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{}) if err != nil { t.Fatalf("再次保存失败: %v", err) } if same.LoginPasswordCiphertext != updated.LoginPasswordCiphertext { t.Fatal("未传密码时不应改动已保存密文") } empty := "" cleared, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{LoginPassword: &empty}) if err != nil { t.Fatalf("清空密码失败: %v", err) } if cleared.LoginPasswordCiphertext != "" || cleared.LoginPasswordSet { t.Fatal("空密码应清空已保存密文") } } func TestTestLogin_ReturnsUserInfoWithoutSavingToken(t *testing.T) { db := newRockTestDB(t) codec, _ := NewSecretCodec("test-master-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/auth/login": _, _ = w.Write([]byte(`{"code":0,"token":"test-token","role":"user","username":"tester"}`)) case "/api/me": if r.Header.Get("Authorization") != "Bearer test-token" { w.WriteHeader(http.StatusUnauthorized) return } _, _ = w.Write([]byte(`{"user":{"username":"tester","points":88.5}}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL), codec) ctx := context.Background() cfg, _ := svc.GetConfig(ctx) ciphertext, _ := codec.Encrypt("secret-pass") _ = db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ "login_username": "tester", "login_password_ciphertext": ciphertext, }).Error result, err := svc.TestLogin(ctx, "", "") if err != nil { t.Fatalf("测试登录失败: %v", err) } if result.Username != "tester" || result.Role != "user" || result.Points != 88.5 { t.Fatalf("返回信息不符: %+v", result) } after, _ := svc.GetConfig(ctx) if after.BearerToken != "" { t.Fatalf("测试登录不应保存 token: %q", after.BearerToken) } } func TestEnsureTokenValid_RefreshesWhenBearerEmpty(t *testing.T) { db := newRockTestDB(t) codec, _ := NewSecretCodec("test-master-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/auth/login": _, _ = w.Write([]byte(`{"code":0,"token":"first-token","user":{"username":"tester","role":"user"}}`)) case "/api/me": _, _ = w.Write([]byte(`{"user":{"username":"tester","points":10}}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL), codec) ctx := context.Background() cfg, _ := svc.GetConfig(ctx) ciphertext, _ := codec.Encrypt("secret-pass") _ = db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ "login_username": "tester", "login_password_ciphertext": ciphertext, }).Error if err := svc.EnsureTokenValid(ctx); err != nil { t.Fatalf("空 token 时应自动登录: %v", err) } after, _ := svc.GetConfig(ctx) if after.BearerToken != "first-token" { t.Fatalf("token 未写入: %q", after.BearerToken) } } func TestRefreshTokenNow_SavesNewToken(t *testing.T) { db := newRockTestDB(t) codec, _ := NewSecretCodec("test-master-key") var loginCalls int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/auth/login": atomic.AddInt32(&loginCalls, 1) _, _ = w.Write([]byte(`{"code":0,"token":"manual-fresh-token","user":{"username":"tester","role":"user"}}`)) case "/api/me": if r.Header.Get("Authorization") != "Bearer manual-fresh-token" { w.WriteHeader(http.StatusUnauthorized) return } _, _ = w.Write([]byte(`{"user":{"username":"tester","points":66.5}}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL), codec) ctx := context.Background() cfg, _ := svc.GetConfig(ctx) ciphertext, _ := codec.Encrypt("secret-pass") _ = db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ "bearer_token": "still-valid-token", "login_username": "tester", "login_password_ciphertext": ciphertext, "last_token_refresh_at": nil, }).Error result, err := svc.RefreshTokenNow(ctx) if err != nil { t.Fatalf("手动刷新 Token 失败: %v", err) } if result.Username != "tester" || result.Role != "user" || result.Points != 66.5 { t.Fatalf("返回信息不符: %+v", result) } if result.RefreshedAt == "" { t.Fatal("refreshed_at 未返回") } if calls := atomic.LoadInt32(&loginCalls); calls != 1 { t.Fatalf("应强制登录一次,实际 %d", calls) } after, _ := svc.GetConfig(ctx) if after.BearerToken != "manual-fresh-token" { t.Fatalf("token 未更新: %q", after.BearerToken) } if after.LastTokenRefreshAt == nil { t.Fatal("last_token_refresh_at 未更新") } } func TestRefreshTokenNow_RequiresCredentials(t *testing.T) { db := newRockTestDB(t) svc := NewRockService(db, NewRockClient("https://example.com")) if _, err := svc.RefreshTokenNow(context.Background()); err == nil || !strings.Contains(err.Error(), "登录账号") { t.Fatalf("未配置账号密码时应提示,实际 %v", err) } }