Add read-only server endpoints for yeeke return packages (paginated list with order/tracking/shop/item/variation/claim-status/create-time filters, plus package detail with items) and extend the existing sync-runs endpoint with trigger/status/started-at filters, backward compatible. Split the admin menu into two modules per the ticket's confirmed scope adjustment: yeeke_returns (packages) and the new yeeke_sync_runs, both under the 采集采购 menu group, wired via a new sys_menu migration mirroring #237's precedent. Add the corresponding Vue pages, API wrappers and Go tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
203 lines
7.1 KiB
Go
203 lines
7.1 KiB
Go
package clientapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"github.com/gin-gonic/gin"
|
|
"go-admin/app/goauto/clientkey"
|
|
"go-admin/common/clientprincipal"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sql, _ := db.DB()
|
|
sql.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { sql.Close() })
|
|
if err = db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return db, clientkey.Service{DB: db, Modules: Catalog(Inventory())}
|
|
}
|
|
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
|
db, s := fixture(t)
|
|
routes := Inventory()
|
|
if len(s.Modules) != 15 {
|
|
t.Fatal("menu groups lost")
|
|
}
|
|
router := gin.New()
|
|
seen := map[string]bool{}
|
|
for _, e := range routes {
|
|
key := e.Method + e.Path
|
|
if seen[key] {
|
|
t.Fatal("duplicate route")
|
|
}
|
|
seen[key] = true
|
|
if strings.Contains(e.Path, "payment") || strings.Contains(e.Path, "token") || strings.Contains(e.Path, "client-keys") || e.Handle == nil {
|
|
t.Fatal("forbidden route")
|
|
}
|
|
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { c.JSON(200, gin.H{"data": "ok"}) })
|
|
if e.Method != "GET" && e.Capability == "read" {
|
|
t.Fatal("mutating read permission")
|
|
}
|
|
}
|
|
for _, e := range routes {
|
|
for _, scheme := range []string{"http", "https"} {
|
|
grant := clientkey.Grant{Module: e.Module}
|
|
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := e.Path
|
|
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
|
|
path = strings.ReplaceAll(path, param, "1")
|
|
}
|
|
req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
want := 200
|
|
if e.Capability != "read" {
|
|
want = 403
|
|
}
|
|
if rr.Code != want {
|
|
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
|
|
}
|
|
if e.Capability == "write" {
|
|
grant.Write = true
|
|
} else if e.Capability != "read" {
|
|
grant.Actions = []string{e.Capability}
|
|
}
|
|
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req.Clone(context.Background()))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
|
|
|
|
func TestOrderBackfillRequiresExplicitWritebackAction(t *testing.T) {
|
|
db, s := fixture(t)
|
|
e := Endpoint{Method: "POST", Path: "/purchase-tasks/order-backfill", Module: "purchase_tasks", Capability: "writeback"}
|
|
router := gin.New()
|
|
called := 0
|
|
router.POST("/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { called++; c.JSON(200, gin.H{"data": "ok"}) })
|
|
send := func(token string) int {
|
|
req := httptest.NewRequest("POST", "https://example.test/api/client/v1"+e.Path, strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000316","items":[]}`))
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr.Code
|
|
}
|
|
if got := send(""); got != 401 {
|
|
t.Fatalf("missing key status %d", got)
|
|
}
|
|
if (clientkey.Key{GrantsJSON: `[{"module":"purchase_tasks","write":true}]`}).Allows("purchase_tasks", "writeback") {
|
|
t.Fatal("generic write must not imply writeback")
|
|
}
|
|
_, writeOnly, err := s.Create(context.Background(), "purchase-only", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"purchase"}}}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := send(writeOnly); got != 403 {
|
|
t.Fatalf("generic write unexpectedly granted writeback: %d", got)
|
|
}
|
|
_, authorized, err := s.Create(context.Background(), "writeback", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"writeback"}}}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := send(authorized); got != 200 || called != 1 {
|
|
t.Fatalf("explicit writeback status=%d called=%d", got, called)
|
|
}
|
|
}
|
|
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
|
|
db, s := fixture(t)
|
|
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e := Endpoint{Method: "GET", Path: "/pdd-products", Module: "pdd_products", Capability: "read"}
|
|
router := gin.New()
|
|
calls := 0
|
|
router.GET("/api/client/v1/pdd-products", Gate(db, e), func(c *gin.Context) {
|
|
calls++
|
|
id, ok := clientprincipal.Get(c)
|
|
if !ok || id.KeyID != v.ID {
|
|
t.Error("client attribution missing")
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "data": gin.H{"apiKey": "sentinel-secret", "rawJson": "sentinel-raw", "title": "product", "nested": []any{gin.H{"device_token": "sentinel-token"}}}})
|
|
})
|
|
send := func(tlsOn bool, credential, path string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("GET", "http://example.test"+path, nil)
|
|
if tlsOn {
|
|
req.TLS = &tls.ConnectionState{}
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+credential)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
path := "/api/client/v1/pdd-products"
|
|
for _, tlsOn := range []bool{false, true} {
|
|
if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
|
|
t.Fatal("credential fallback accepted")
|
|
}
|
|
rr := send(tlsOn, token, path)
|
|
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
|
|
t.Fatal("redaction failed")
|
|
}
|
|
if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
|
|
t.Fatal("cache or audit headers missing")
|
|
}
|
|
}
|
|
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
|
|
t.Fatal("revocation not immediate")
|
|
}
|
|
var logs []clientkey.Audit
|
|
db.Find(&logs)
|
|
raw, _ := json.Marshal(logs)
|
|
if strings.Contains(string(raw), token) || strings.Contains(string(raw), "sentinel") {
|
|
t.Fatal("audit leaked payload")
|
|
}
|
|
}
|
|
func TestAuditUnavailableDoesNotExecute(t *testing.T) {
|
|
db, s := fixture(t)
|
|
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Migrator().DropTable(&clientkey.Audit{})
|
|
router := gin.New()
|
|
called := false
|
|
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
|
|
router.POST(e.Path, Gate(db, e), func(c *gin.Context) { called = true; c.JSON(200, gin.H{}) })
|
|
req := httptest.NewRequest("POST", "https://example.test"+e.Path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
if rr.Code != 503 || called {
|
|
t.Fatal("executed without audit")
|
|
}
|
|
}
|