package middleware import ( "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "affiliate_dash/internal/model" "affiliate_dash/internal/service" "affiliate_dash/internal/testdb" "github.com/gin-gonic/gin" ) func newOpenAuthTestServer(t *testing.T, signatureVersion string) (*gin.Engine, string, string) { t.Helper() gin.SetMode(gin.TestMode) db := testdb.New(t, &model.Merchant{}, &model.APIClient{}, &model.APIRequestNonce{}) codec, err := service.NewSecretCodec("test-master-key") if err != nil { t.Fatalf("codec: %v", err) } merchant := model.Merchant{Code: "merchant-open", Name: "开放测试商户", Status: model.MerchantStatusActive} if err := db.Create(&merchant).Error; err != nil { t.Fatalf("create merchant: %v", err) } secret := "client-secret" ciphertext, err := codec.Encrypt(secret) if err != nil { t.Fatalf("encrypt secret: %v", err) } client := model.APIClient{ MerchantID: merchant.ID, Name: "测试客户端", AppKey: "ak_test", SecretCiphertext: ciphertext, SignatureVersion: signatureVersion, Scopes: "*", Status: model.APIClientStatusActive, } if err := db.Create(&client).Error; err != nil { t.Fatalf("create client: %v", err) } r := gin.New() r.Use(OpenAuth(OpenAuthConfig{ DB: db, Codec: codec, SkewSeconds: 300, })) r.POST("/api/client/v1/orders", RequireAPIScope("orders:write"), func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "merchant_id": GetMerchantID(c), "client_id": GetAPIClient(c).ID, }) }) return r, client.AppKey, secret } func TestOpenAuthV1AcceptsSignedRequestAndRejectsReplay(t *testing.T) { r, appKey, secret := newOpenAuthTestServer(t, "v1") body := `{"client_order_no":"client-001","sku":"sku-basic"}` ts := strconv.FormatInt(time.Now().Unix(), 10) nonce := "nonce-123456" path := "/api/client/v1/orders" sign := BuildOpenV1Sign(secret, appKey, ts, nonce, http.MethodPost, path, []byte(body)) req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("X-App-Key", appKey) req.Header.Set("X-Timestamp", ts) req.Header.Set("X-Nonce", nonce) req.Header.Set("X-Sign", sign) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("signed request should pass, code=%d body=%s", w.Code, w.Body.String()) } replay := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) replay.Header = req.Header.Clone() w = httptest.NewRecorder() r.ServeHTTP(w, replay) if w.Code != http.StatusUnauthorized { t.Fatalf("replay should be rejected, code=%d body=%s", w.Code, w.Body.String()) } } func TestOpenAuthRejectsSignatureMismatch(t *testing.T) { r, appKey, _ := newOpenAuthTestServer(t, "v1") req := httptest.NewRequest(http.MethodPost, "/api/client/v1/orders", strings.NewReader(`{"a":1}`)) req.Header.Set("X-App-Key", appKey) req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) req.Header.Set("X-Nonce", "nonce-bad-sign") req.Header.Set("X-Sign", "bad-sign") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("bad signature should be rejected, code=%d body=%s", w.Code, w.Body.String()) } } func TestSourceOpenAuthKeepsLegacyUpstreamSignature(t *testing.T) { gin.SetMode(gin.TestMode) db := testdb.New(t) appKey := "source-key" secret := "source-secret" r := gin.New() r.Use(SourceOpenAuth(SourceOpenAuthConfig{ DB: db, APIKey: appKey, APISecret: secret, SkewSeconds: 300, })) r.POST("/api/open/v1/orders/ship-notify", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) body := `{"client_order_no":"client-legacy","sku":"sku-basic"}` ts := strconv.FormatInt(time.Now().Unix(), 10) nonce := "legacy-nonce-123" path := "/api/open/v1/orders/ship-notify" sign := BuildOpenSign(appKey, secret, ts, nonce, http.MethodPost, path, body) req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("X-Api-Key", appKey) req.Header.Set("X-Timestamp", ts) req.Header.Set("X-Nonce", nonce) req.Header.Set("X-Sign", sign) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("legacy signed request should pass, code=%d body=%s", w.Code, w.Body.String()) } replay := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) replay.Header = req.Header.Clone() w = httptest.NewRecorder() r.ServeHTTP(w, replay) if w.Code != http.StatusUnauthorized { t.Fatalf("legacy replay should be rejected, code=%d body=%s", w.Code, w.Body.String()) } } func TestSourceOpenAuthAcceptsUppercaseLegacySignature(t *testing.T) { gin.SetMode(gin.TestMode) db := testdb.New(t) appKey := "source-key" secret := "source-secret" r := gin.New() r.Use(SourceOpenAuth(SourceOpenAuthConfig{ DB: db, APIKey: appKey, APISecret: secret, SkewSeconds: 300, })) r.GET("/api/open/v1/orders/O123", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) ts := strconv.FormatInt(time.Now().Unix(), 10) nonce := "legacy-nonce-upper" path := "/api/open/v1/orders/O123" sign := strings.ToUpper(BuildOpenSign(appKey, secret, ts, nonce, http.MethodGet, path, "")) req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-Api-Key", appKey) req.Header.Set("X-Timestamp", ts) req.Header.Set("X-Nonce", nonce) req.Header.Set("X-Sign", sign) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("uppercase legacy signature should pass, code=%d body=%s", w.Code, w.Body.String()) } }