Files

276 lines
9.9 KiB
Go

package httpserver
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
"time"
"github.com/dujiao-next/internal/app/httpserver/middleware"
"github.com/dujiao-next/internal/authz"
contenttransport "github.com/dujiao-next/internal/modules/content/transport/http"
admincontract "github.com/dujiao-next/internal/modules/identity/admin/contract"
admindomain "github.com/dujiao-next/internal/modules/identity/admin/domain"
adminstore "github.com/dujiao-next/internal/modules/identity/admin/infrastructure/gormstore"
adminauthapp "github.com/dujiao-next/internal/modules/identity/adminauth/application"
"github.com/dujiao-next/internal/modules/identity/jwttoken"
"github.com/dujiao-next/internal/platform/http/response"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
const contentAdminJWTSecret = "content-admin-route-contract-secret"
var contentAdminRouteContract = []adminRoute{
{method: http.MethodGet, object: "/admin/posts"},
{method: http.MethodPost, object: "/admin/posts"},
{method: http.MethodPut, object: "/admin/posts/:id"},
{method: http.MethodDelete, object: "/admin/posts/:id"},
{method: http.MethodGet, object: "/admin/posts/:id/products"},
{method: http.MethodGet, object: "/admin/post-categories"},
{method: http.MethodPost, object: "/admin/post-categories"},
{method: http.MethodPut, object: "/admin/post-categories/:id"},
{method: http.MethodDelete, object: "/admin/post-categories/:id"},
{method: http.MethodPatch, object: "/admin/post-categories/:id/status"},
{method: http.MethodGet, object: "/admin/banners"},
{method: http.MethodGet, object: "/admin/banners/:id"},
{method: http.MethodPost, object: "/admin/banners"},
{method: http.MethodPut, object: "/admin/banners/:id"},
{method: http.MethodDelete, object: "/admin/banners/:id"},
{method: http.MethodGet, object: "/admin/media"},
{method: http.MethodPost, object: "/admin/media/batch-delete"},
{method: http.MethodPut, object: "/admin/media/:id"},
{method: http.MethodDelete, object: "/admin/media/:id"},
}
var contentPublicRouteContract = []adminRoute{
{method: http.MethodGet, object: "/api/v1/public/posts"},
{method: http.MethodGet, object: "/api/v1/public/posts/:slug"},
{method: http.MethodGet, object: "/api/v1/public/banners"},
{method: http.MethodGet, object: "/api/v1/public/post-categories"},
}
func TestContentRouteContractMatchesRouterSource(t *testing.T) {
adminRoutes, err := extractAdminRoutesFromSource()
if err != nil {
t.Fatalf("extract admin routes: %v", err)
}
actualAdmin := make([]adminRoute, 0, len(contentAdminRouteContract))
for _, route := range adminRoutes {
if isContentAdminRoute(route.object) {
actualAdmin = append(actualAdmin, route)
}
}
assertRouteSetsEqual(t, contentAdminRouteContract, actualAdmin)
publicRoutes, err := extractPublicContentRoutesFromSource()
if err != nil {
t.Fatalf("extract public content routes: %v", err)
}
assertRouteSetsEqual(t, contentPublicRouteContract, publicRoutes)
}
func TestContentRoutesStayInExpectedRouterTrustGroups(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve content route test filename")
}
routerDirectory := filepath.Dir(thisFile)
registrations := []struct {
file string
statement string
}{
{file: "routes_storefront.go", statement: "contenttransport.RegisterPublicRoutes(public, publicContentHandler)"},
{file: "routes_admin.go", statement: "contenttransport.RegisterAdminRoutes(authorized, adminContentHandler)"},
}
for _, registration := range registrations {
raw, err := os.ReadFile(filepath.Join(routerDirectory, registration.file))
if err != nil {
t.Fatalf("read production route file %s: %v", registration.file, err)
}
source := string(raw)
if !strings.Contains(source, registration.statement) {
t.Errorf("%s must contain %q; changing the route group can bypass the intended authentication or public boundary", registration.file, registration.statement)
}
}
}
func TestAdminContentRoutesRequireAuthenticationAndOperationsPermission(t *testing.T) {
adminRepo, authzService, noPermissionToken, operationsToken := setupContentRouteAccessTest(t)
tests := []struct {
name string
token string
wantStatus int
}{
{name: "anonymous", wantStatus: response.CodeUnauthorized},
{name: "invalid token", token: "not-a-jwt", wantStatus: response.CodeUnauthorized},
{name: "no permission", token: noPermissionToken, wantStatus: response.CodeForbidden},
{name: "operations", token: operationsToken, wantStatus: response.CodeOK},
}
for _, route := range contentAdminRouteContract {
route := route
t.Run(route.method+" "+route.object, func(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
router := newContentRouteAccessRouter(adminRepo, authzService)
path := strings.ReplaceAll("/api/v1"+route.object, ":id", "42")
request := httptest.NewRequest(route.method, path, nil)
if test.token != "" {
request.Header.Set("Authorization", "Bearer "+test.token)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
var got struct {
StatusCode int `json:"status_code"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v body=%s", err, recorder.Body.String())
}
if got.StatusCode != test.wantStatus {
t.Fatalf("business status want %d got %d body=%s", test.wantStatus, got.StatusCode, recorder.Body.String())
}
})
}
})
}
}
func extractPublicContentRoutesFromSource() ([]adminRoute, error) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return nil, fmt.Errorf("resolve content route test filename")
}
routerSource := filepath.Join(filepath.Dir(thisFile), "..", "..", "modules", "content", "transport", "http", "routes.go")
raw, err := os.ReadFile(routerSource)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`public\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`)
matches := re.FindAllStringSubmatch(string(raw), -1)
routes := make([]adminRoute, 0, len(contentPublicRouteContract))
for _, match := range matches {
path := "/api/v1/public" + match[2]
if isContentPublicRoute(path) {
routes = append(routes, adminRoute{method: match[1], object: path})
}
}
return routes, nil
}
func isContentAdminRoute(object string) bool {
return object == "/admin/posts" || strings.HasPrefix(object, "/admin/posts/") ||
object == "/admin/post-categories" || strings.HasPrefix(object, "/admin/post-categories/") ||
object == "/admin/banners" || strings.HasPrefix(object, "/admin/banners/") ||
object == "/admin/media" || strings.HasPrefix(object, "/admin/media/")
}
func isContentPublicRoute(path string) bool {
return path == "/api/v1/public/posts" || path == "/api/v1/public/posts/:slug" ||
path == "/api/v1/public/banners" || path == "/api/v1/public/post-categories"
}
func assertRouteSetsEqual(t *testing.T, want, got []adminRoute) {
t.Helper()
normalize := func(routes []adminRoute) []string {
result := make([]string, 0, len(routes))
for _, route := range routes {
result = append(result, route.method+" "+route.object)
}
sort.Strings(result)
return result
}
wantRoutes := normalize(want)
gotRoutes := normalize(got)
if strings.Join(wantRoutes, "\n") != strings.Join(gotRoutes, "\n") {
t.Fatalf("content route contract mismatch\nwant:\n%s\ngot:\n%s", strings.Join(wantRoutes, "\n"), strings.Join(gotRoutes, "\n"))
}
}
func setupContentRouteAccessTest(t *testing.T) (admincontract.Store, *authz.Service, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
dsn := fmt.Sprintf("file:content_route_access_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&admindomain.Admin{}); err != nil {
t.Fatalf("auto migrate admins: %v", err)
}
adminRepo := adminstore.New(db)
noPermission := &admindomain.Admin{Username: "content-none", PasswordHash: "hash"}
operations := &admindomain.Admin{Username: "content-operations", PasswordHash: "hash"}
if err := adminRepo.Create(noPermission); err != nil {
t.Fatalf("create no-permission admin: %v", err)
}
if err := adminRepo.Create(operations); err != nil {
t.Fatalf("create operations admin: %v", err)
}
authzService, err := authz.NewService(db)
if err != nil {
t.Fatalf("create authz service: %v", err)
}
if err := authzService.BootstrapBuiltinRoles(); err != nil {
t.Fatalf("bootstrap builtin roles: %v", err)
}
if err := authzService.SetAdminRoles(operations.ID, []string{"operations"}); err != nil {
t.Fatalf("assign operations role: %v", err)
}
return adminRepo, authzService,
signContentAdminToken(t, noPermission),
signContentAdminToken(t, operations)
}
func signContentAdminToken(t *testing.T, admin *admindomain.Admin) string {
t.Helper()
now := time.Now()
claims := adminauthapp.JWTClaims{
AdminID: admin.ID,
Username: admin.Username,
TokenVersion: admin.TokenVersion,
Typ: jwttoken.TypeAccess,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now.Add(-time.Second)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(contentAdminJWTSecret))
if err != nil {
t.Fatalf("sign admin token: %v", err)
}
return signed
}
func newContentRouteAccessRouter(adminRepo admincontract.Store, authzService *authz.Service) *gin.Engine {
router := gin.New()
admin := router.Group("/api/v1/admin")
admin.Use(middleware.JWTAuthMiddleware(contentAdminJWTSecret, adminRepo), middleware.AdminRBACMiddleware(authzService))
admin.Use(func(c *gin.Context) {
response.Success(c, nil)
c.Abort()
})
contenttransport.RegisterAdminRoutes(admin, contenttransport.NewAdminHandler(nil, nil, nil, nil))
return router
}