270 lines
9.7 KiB
Go
270 lines
9.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/sundynix/pets-be/internal/breeds"
|
|
"github.com/sundynix/pets-be/internal/config"
|
|
"github.com/sundynix/pets-be/internal/database"
|
|
"github.com/sundynix/pets-be/internal/model"
|
|
"gorm.io/gorm"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func featureDB(t *testing.T) (*gorm.DB, *config.Config) {
|
|
t.Helper()
|
|
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
|
t.Skip("PostgreSQL integration disabled")
|
|
}
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := database.Open(cfg.Postgres, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, _ := db.DB()
|
|
t.Cleanup(func() { sqlDB.Close() })
|
|
tx := db.Begin()
|
|
if tx.Error != nil {
|
|
t.Fatal(tx.Error)
|
|
}
|
|
t.Cleanup(func() { tx.Rollback() })
|
|
return tx, cfg
|
|
}
|
|
func featureCall(handler gin.HandlerFunc, method, path string, body any, actorID, id string) *httptest.ResponseRecorder {
|
|
raw, _ := json.Marshal(body)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(method, path, bytes.NewReader(raw))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
c.Set("actor_id", actorID)
|
|
c.Params = gin.Params{{Key: "id", Value: id}}
|
|
handler(c)
|
|
return rec
|
|
}
|
|
func pairFrom(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
if rec.Code != 200 && rec.Code != 201 {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
var result struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return result.Data
|
|
}
|
|
func TestAccessTokenTwoHours(t *testing.T) {
|
|
api := New(nil, nil, &config.Config{JWT: config.JWTConfig{Secret: "test", ExpireHours: 168}})
|
|
raw, err := api.token(uuid.NewString(), "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := &claims{}
|
|
_, err = jwt.ParseWithClaims(raw, c, func(*jwt.Token) (any, error) { return []byte("test"), nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.ExpiresAt.Sub(c.IssuedAt.Time) != 2*time.Hour {
|
|
t.Fatal("access token lifetime is not two hours")
|
|
}
|
|
}
|
|
func TestRefreshRotationPostgres(t *testing.T) {
|
|
tx, cfg := featureDB(t)
|
|
a := New(tx, nil, cfg)
|
|
user := model.User{OpenID: "refresh-test:" + uuid.NewString(), Nickname: "测试家长"}
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pair, err := a.newSession(user.ID, "user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := pair["refresh_token"].(string)
|
|
var session model.RefreshSession
|
|
if err := tx.Where("token_hash = ?", refreshHash(old)).First(&session).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.TokenHash == old {
|
|
t.Fatal("raw refresh stored")
|
|
}
|
|
call := func(raw, role string) *httptest.ResponseRecorder {
|
|
return featureCall(a.refreshToken(role), "POST", "/refresh", gin.H{"refresh_token": raw}, "", "")
|
|
}
|
|
if rec := call(old, "admin"); rec.Code != 401 {
|
|
t.Fatal("user credential accepted for admin")
|
|
}
|
|
next := pairFrom(t, call(old, "user"))
|
|
fresh := next["refresh_token"].(string)
|
|
if fresh == old || next["refresh_expires_at"].(float64) < float64(time.Now().Add(2*time.Hour-time.Second).Unix()) {
|
|
t.Fatal("rotation or two-hour expiry wrong")
|
|
}
|
|
if rec := call(old, "user"); rec.Code != 401 {
|
|
t.Fatal("old credential reusable")
|
|
}
|
|
revoked := featureCall(a.revokeRefresh("user"), "POST", "/logout", gin.H{"refresh_token": fresh}, "", "")
|
|
if revoked.Code != 200 {
|
|
t.Fatal(revoked.Body.String())
|
|
}
|
|
if rec := call(fresh, "user"); rec.Code != 401 {
|
|
t.Fatal("logout failed to revoke")
|
|
}
|
|
// Logout invalidates the access token tied to the same session too.
|
|
r := gin.New()
|
|
r.GET("/protected", a.requireRole("user"), func(c *gin.Context) { ok(c, true) })
|
|
req := httptest.NewRequest("GET", "/protected", nil)
|
|
req.Header.Set("Authorization", "Bearer "+next["token"].(string))
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
if rec.Code != 401 {
|
|
t.Fatal("revoked session access accepted")
|
|
}
|
|
pair, err = a.newSession(user.ID, "user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fresh = pair["refresh_token"].(string)
|
|
if err := tx.Model(&user).Update("disabled", true).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := call(fresh, "user"); rec.Code != 401 {
|
|
t.Fatal("disabled user refreshed")
|
|
}
|
|
if err := tx.Model(&user).Update("disabled", false).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tx.Model(&model.RefreshSession{}).Where("token_hash = ?", refreshHash(fresh)).Update("expires_at", time.Now().Add(-time.Minute))
|
|
if rec := call(fresh, "user"); rec.Code != 401 {
|
|
t.Fatal("expired credential refreshed")
|
|
}
|
|
admin := model.Admin{Username: "refresh-test:" + uuid.NewString(), PasswordHash: "fixture"}
|
|
if err := tx.Create(&admin).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pair, err = a.newSession(admin.ID, "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pairFrom(t, call(pair["refresh_token"].(string), "admin"))
|
|
}
|
|
func TestBreedCatalogPostgres(t *testing.T) {
|
|
tx, cfg := featureDB(t)
|
|
a := New(tx, nil, cfg)
|
|
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
|
t.Fatalf("idempotent seed: %d %v", added, err)
|
|
}
|
|
var source model.Breed
|
|
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&source).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tx.Model(&source).Updates(map[string]any{"enabled": false, "name": "改名柯基测试"})
|
|
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
|
t.Fatalf("seed restored renamed record: %d %v", added, err)
|
|
}
|
|
tx.First(&source, "id = ?", source.ID)
|
|
if source.Enabled || source.Name != "改名柯基测试" {
|
|
t.Fatal("seed overwrote edits")
|
|
}
|
|
rec := featureCall(a.listBreeds, "GET", "/breeds?q=bianmu", nil, "", "")
|
|
if rec.Code != 200 {
|
|
t.Fatal(rec.Body.String())
|
|
}
|
|
// Short initials and Chinese aliases must both work.
|
|
for _, q := range []string{"边牧", "bjm", "bianjing"} {
|
|
rec = featureCall(a.listBreeds, "GET", "/breeds?q="+q, nil, "", "")
|
|
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("边境牧羊犬")) {
|
|
t.Fatalf("search %s: %s", q, rec.Body)
|
|
}
|
|
}
|
|
name := "测试品种" + uuid.NewString()
|
|
body := gin.H{"name": name, "aliases": "testbreed", "enabled": false}
|
|
created := pairFrom(t, featureCall(a.createBreed, "POST", "/breeds", body, "", ""))
|
|
id := created["id"].(string)
|
|
if created["enabled"].(bool) {
|
|
t.Fatal("disabled creation ignored")
|
|
}
|
|
pairFrom(t, featureCall(a.setBreedEnabled, "PUT", "/breeds", gin.H{"enabled": true}, "", id))
|
|
body["enabled"] = true
|
|
body["name"] = name + "更新"
|
|
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds", body, "", id))
|
|
// Duplicate request runs in a savepoint because PostgreSQL aborts a transaction on unique violations.
|
|
tx.SavePoint("duplicate_breed")
|
|
rec = featureCall(a.createBreed, "POST", "/breeds", body, "", "")
|
|
if rec.Code != 409 {
|
|
t.Fatalf("duplicate status %d: %s", rec.Code, rec.Body)
|
|
}
|
|
tx.RollbackTo("duplicate_breed")
|
|
user := model.User{OpenID: "breed-test:" + uuid.NewString(), Nickname: "测试家长"}
|
|
tx.Create(&user)
|
|
selected, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "forged", BreedID: &id})
|
|
if !valid || selected.Breed != body["name"] {
|
|
t.Fatal("selected name not resolved by ID")
|
|
}
|
|
custom, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "未收录地方犬"})
|
|
if !valid || custom.BreedID != nil || custom.Breed != "未收录地方犬" {
|
|
t.Fatal("manual breed unavailable")
|
|
}
|
|
selected.UserID = user.ID
|
|
tx.Create(&selected)
|
|
tx.Model(&model.Breed{}).Where("id = ?", id).Update("enabled", false)
|
|
_, valid = a.buildPet(testPetContext(user.ID, ""), petInput{Name: "新宠物", BreedID: &id})
|
|
if valid {
|
|
t.Fatal("disabled breed newly selected")
|
|
}
|
|
_, valid = a.buildPet(testPetContext(user.ID, selected.ID), petInput{Name: "旧宠物", BreedID: &id})
|
|
if !valid {
|
|
t.Fatal("existing disabled breed cannot be retained")
|
|
}
|
|
}
|
|
func testPetContext(actorID, id string) *gin.Context {
|
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
c.Set("actor_id", actorID)
|
|
c.Params = gin.Params{{Key: "id", Value: id}}
|
|
return c
|
|
}
|
|
|
|
func TestBreedSourcesAndEditorialProtection(t *testing.T) {
|
|
tx, cfg := featureDB(t)
|
|
a := New(tx, nil, cfg)
|
|
var breed model.Breed
|
|
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&breed).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
detail := pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
|
if len(detail["references"].([]any)) != 4 {
|
|
t.Fatal("Corgi references missing")
|
|
}
|
|
if detail["field_sources"].(map[string]any)["exercise"] != "rkc" {
|
|
t.Fatal("exercise attribution wrong")
|
|
}
|
|
body := gin.H{"name": breed.Name, "english_name": breed.EnglishName, "enabled": true, "exercise": "编辑安排的散步活动", "summary": "新的中文介绍", "traits": gin.H{"运动活力": 2}}
|
|
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID))
|
|
if _, err := breeds.Seed(tx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
detail = pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
|
if detail["exercise"] != "编辑安排的散步活动" || detail["summary"] != "新的中文介绍" || detail["height"] != "" {
|
|
t.Fatal("import overwrote manual edits or cleared fields")
|
|
}
|
|
if detail["field_sources"].(map[string]any)["exercise"] != "editorial" || len(detail["references"].([]any)) != 4 {
|
|
t.Fatal("edited provenance or reference uniqueness lost")
|
|
}
|
|
body["traits"] = gin.H{"运动活力": 6}
|
|
if rec := featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID); rec.Code != 400 {
|
|
t.Fatalf("invalid rating accepted: %d", rec.Code)
|
|
}
|
|
tx.Model(&model.Breed{}).Where("id = ?", breed.ID).Update("enabled", false)
|
|
if rec := featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID); rec.Code != 404 {
|
|
t.Fatal("disabled encyclopedia visible")
|
|
}
|
|
}
|