Files
goauto/server/app/goauto/clientapi/gateway_test.go
T

166 lines
5.6 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) != 12 {
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 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")
}
}