Initial sanitized code sync
This commit is contained in:
518
internal/handler/handler_test.go
Normal file
518
internal/handler/handler_test.go
Normal file
@@ -0,0 +1,518 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gpt-plus/internal/db"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
if err := d.AutoMigrate(
|
||||
&db.SystemConfig{}, &db.EmailRecord{}, &db.Task{}, &db.TaskLog{},
|
||||
&db.CardCode{}, &db.Card{}, &db.Account{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
db.DB = d
|
||||
|
||||
SetJWTSecret("test-secret-key")
|
||||
os.Setenv("ADMIN_PASSWORD", "testpass")
|
||||
if err := InitAdminPassword(); err != nil {
|
||||
t.Fatalf("init password: %v", err)
|
||||
}
|
||||
|
||||
// Reset rate limiter between tests
|
||||
loginLimiter.mu.Lock()
|
||||
loginLimiter.attempts = make(map[string][]time.Time)
|
||||
loginLimiter.mu.Unlock()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func jsonReq(method, path string, body interface{}) *http.Request {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func doRequest(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func loginAndGetCookie(t *testing.T, r *gin.Engine) *http.Cookie {
|
||||
t.Helper()
|
||||
req := jsonReq("POST", "/api/login", map[string]string{"password": "testpass"})
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "token" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("no token cookie in login response")
|
||||
return nil
|
||||
}
|
||||
|
||||
func authedReq(method, path string, body interface{}, cookie *http.Cookie) *http.Request {
|
||||
req := jsonReq(method, path, body)
|
||||
req.AddCookie(cookie)
|
||||
return req
|
||||
}
|
||||
|
||||
// --- Auth tests ---
|
||||
|
||||
func TestLoginSuccess(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
r := SetupRouter(false)
|
||||
|
||||
req := jsonReq("POST", "/api/login", map[string]string{"password": "testpass"})
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["message"] != "登录成功" {
|
||||
t.Fatalf("message = %q", resp["message"])
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "token" && c.HttpOnly {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no httpOnly token cookie set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
r := SetupRouter(false)
|
||||
|
||||
req := jsonReq("POST", "/api/login", map[string]string{"password": "wrong"})
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEmptyPassword(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
r := SetupRouter(false)
|
||||
|
||||
req := jsonReq("POST", "/api/login", map[string]string{})
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
r := SetupRouter(false)
|
||||
|
||||
req := jsonReq("POST", "/api/logout", nil)
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "token" && c.MaxAge < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("token cookie not cleared")
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareRejectsUnauthenticated(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
r := SetupRouter(false)
|
||||
|
||||
req := jsonReq("GET", "/api/config", nil)
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAcceptsValidToken(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
req := authedReq("GET", "/api/auth/check", nil, cookie)
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config tests ---
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
req := authedReq("GET", "/api/config", nil, cookie)
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Groups map[string][]db.SystemConfig `json:"groups"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp.Groups) == 0 {
|
||||
t.Fatal("expected non-empty config groups")
|
||||
}
|
||||
if _, ok := resp.Groups["proxy"]; !ok {
|
||||
t.Fatal("expected proxy group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfig(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"items": []map[string]string{
|
||||
{"key": "proxy.url", "value": "socks5://1.2.3.4:1080"},
|
||||
},
|
||||
}
|
||||
req := authedReq("PUT", "/api/config", body, cookie)
|
||||
w := doRequest(r, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var cfg db.SystemConfig
|
||||
db.GetDB().Where("key = ?", "proxy.url").First(&cfg)
|
||||
if cfg.Value != "socks5://1.2.3.4:1080" {
|
||||
t.Fatalf("config value = %q, want socks5://1.2.3.4:1080", cfg.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Card tests ---
|
||||
|
||||
func TestCardCRUD(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
// Add card
|
||||
body := map[string]string{
|
||||
"number": "4242424242424242", "cvc": "123",
|
||||
"exp_month": "12", "exp_year": "2030", "country": "US",
|
||||
}
|
||||
req := authedReq("POST", "/api/cards", body, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("add card: status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List cards
|
||||
req = authedReq("GET", "/api/cards", nil, cookie)
|
||||
w = doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list cards: status = %d", w.Code)
|
||||
}
|
||||
var listResp struct {
|
||||
Items []db.Card `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
if listResp.Total != 1 {
|
||||
t.Fatalf("total = %d, want 1", listResp.Total)
|
||||
}
|
||||
if listResp.Items[0].NumberLast4 != "4242" {
|
||||
t.Fatalf("last4 = %q, want 4242", listResp.Items[0].NumberLast4)
|
||||
}
|
||||
|
||||
// Stats
|
||||
req = authedReq("GET", "/api/cards/stats", nil, cookie)
|
||||
w = doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("card stats: status = %d", w.Code)
|
||||
}
|
||||
|
||||
// Delete
|
||||
cardID := listResp.Items[0].ID
|
||||
req = authedReq("DELETE", "/api/cards/"+itoa(cardID), nil, cookie)
|
||||
w = doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete card: status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardDuplicate(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
body := map[string]string{
|
||||
"number": "5555555555554444", "cvc": "321",
|
||||
"exp_month": "06", "exp_year": "2029", "country": "JP",
|
||||
}
|
||||
req := authedReq("POST", "/api/cards", body, cookie)
|
||||
doRequest(r, req) // first
|
||||
|
||||
req = authedReq("POST", "/api/cards", body, cookie)
|
||||
w := doRequest(r, req) // duplicate
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate card: status = %d, want 409", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Card Code tests ---
|
||||
|
||||
func TestCardCodeImport(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"codes": []string{"CODE-001", "CODE-002", "CODE-003"},
|
||||
}
|
||||
req := authedReq("POST", "/api/card-codes/import", body, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("import: status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]int
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["imported"] != 3 {
|
||||
t.Fatalf("imported = %d, want 3", resp["imported"])
|
||||
}
|
||||
|
||||
// Stats
|
||||
req = authedReq("GET", "/api/card-codes/stats", nil, cookie)
|
||||
w = doRequest(r, req)
|
||||
var stats db.CardCodeStats
|
||||
json.Unmarshal(w.Body.Bytes(), &stats)
|
||||
if stats.Unused != 3 {
|
||||
t.Fatalf("unused = %d, want 3", stats.Unused)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dashboard test ---
|
||||
|
||||
func TestDashboard(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
req := authedReq("GET", "/api/dashboard", nil, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard: status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email record tests ---
|
||||
|
||||
func TestEmailRecordStats(t *testing.T) {
|
||||
d := setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
d.Create(&db.EmailRecord{Email: "a@test.com", Status: "used"})
|
||||
d.Create(&db.EmailRecord{Email: "b@test.com", Status: "used_failed"})
|
||||
|
||||
req := authedReq("GET", "/api/email-records/stats", nil, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var stats db.EmailRecordStats
|
||||
json.Unmarshal(w.Body.Bytes(), &stats)
|
||||
if stats.Total != 2 || stats.Used != 1 || stats.UsedFailed != 1 {
|
||||
t.Fatalf("stats = %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account tests ---
|
||||
|
||||
func TestAccountListAndDetail(t *testing.T) {
|
||||
d := setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
d.Create(&db.Account{Email: "plus@test.com", Plan: "plus", Status: "active", AccountID: "acc-1"})
|
||||
owner := db.Account{Email: "team@test.com", Plan: "team_owner", Status: "active", AccountID: "acc-2"}
|
||||
d.Create(&owner)
|
||||
d.Create(&db.Account{Email: "m1@test.com", Plan: "team_member", Status: "active", ParentID: &owner.ID})
|
||||
|
||||
// List (should only show plus + team_owner)
|
||||
req := authedReq("GET", "/api/accounts", nil, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var listResp struct {
|
||||
Items []db.Account `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
if listResp.Total != 2 {
|
||||
t.Fatalf("total = %d, want 2 (team_member hidden)", listResp.Total)
|
||||
}
|
||||
|
||||
// Detail with sub-accounts
|
||||
req = authedReq("GET", "/api/accounts/"+itoa(owner.ID), nil, cookie)
|
||||
w = doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d", w.Code)
|
||||
}
|
||||
var detail db.Account
|
||||
json.Unmarshal(w.Body.Bytes(), &detail)
|
||||
if len(detail.SubAccounts) != 1 {
|
||||
t.Fatalf("sub_accounts = %d, want 1", len(detail.SubAccounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountUpdateNote(t *testing.T) {
|
||||
d := setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
d.Create(&db.Account{Email: "note@test.com", Plan: "plus", Status: "active"})
|
||||
|
||||
req := authedReq("PUT", "/api/accounts/1/note", map[string]string{"note": "VIP"}, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
|
||||
var acct db.Account
|
||||
d.First(&acct, 1)
|
||||
if acct.Note != "VIP" {
|
||||
t.Fatalf("note = %q, want VIP", acct.Note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSearch(t *testing.T) {
|
||||
d := setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
d.Create(&db.Account{Email: "alice@test.com", Plan: "plus", Status: "active"})
|
||||
d.Create(&db.Account{Email: "bob@test.com", Plan: "plus", Status: "active"})
|
||||
|
||||
req := authedReq("GET", "/api/accounts?search=alice", nil, cookie)
|
||||
w := doRequest(r, req)
|
||||
var resp struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("search total = %d, want 1", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task tests ---
|
||||
|
||||
func TestTaskCreateAndList(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
body := map[string]interface{}{"type": "plus", "count": 5}
|
||||
req := authedReq("POST", "/api/tasks", body, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create task: status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created db.Task
|
||||
json.Unmarshal(w.Body.Bytes(), &created)
|
||||
if created.ID == "" || created.Status != "pending" || created.TotalCount != 5 {
|
||||
t.Fatalf("created = %+v", created)
|
||||
}
|
||||
|
||||
// List
|
||||
req = authedReq("GET", "/api/tasks", nil, cookie)
|
||||
w = doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: status = %d", w.Code)
|
||||
}
|
||||
|
||||
// Delete
|
||||
req = authedReq("DELETE", "/api/tasks/"+created.ID, nil, cookie)
|
||||
w = doRequest(r, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete: status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreateInvalidType(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
SeedDefaultConfigs()
|
||||
r := SetupRouter(false)
|
||||
cookie := loginAndGetCookie(t, r)
|
||||
|
||||
body := map[string]interface{}{"type": "invalid", "count": 1}
|
||||
req := authedReq("POST", "/api/tasks", body, cookie)
|
||||
w := doRequest(r, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n uint) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
Reference in New Issue
Block a user