feat: add multi-source breed encyclopedia and two-hour refresh sessions
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user