156 lines
5.9 KiB
Go

package externaladmin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
adminmiddleware "hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
"google.golang.org/grpc"
userv1 "hyapp.local/api/proto/user/v1"
)
type managerTeamHostClient struct {
userv1.UserHostServiceClient
request *userv1.ListManagerTeamAgenciesRequest
calls int
}
func (client *managerTeamHostClient) ListManagerTeamAgencies(_ context.Context, request *userv1.ListManagerTeamAgenciesRequest, _ ...grpc.CallOption) (*userv1.ListManagerTeamAgenciesResponse, error) {
client.calls++
client.request = request
return &userv1.ListManagerTeamAgenciesResponse{
Agencies: []*userv1.ManagerTeamAgency{
{AgencyId: 101, OwnerUserId: 1001, ParentBdUserId: 2001},
{AgencyId: 102, OwnerUserId: 1002, ParentBdUserId: 2002},
{AgencyId: 103, OwnerUserId: 1003, ParentBdUserId: 2003},
},
TotalBdLeaders: 4,
TotalBds: 9,
Truncated: true,
}, nil
}
func TestMyTeamRouteUsesOnlySessionLinkedUserAndPaginates(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
// This fixture models fields populated by AuthRequired. Query parameters below
// intentionally carry attacker-selected IDs and must never override the principal.
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?page=2&page_size=2&manager_user_id=999&user_id=888", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if client.calls != 1 || client.request == nil {
t.Fatalf("owner service calls = %d request=%v", client.calls, client.request)
}
if client.request.GetManagerUserId() != 42 {
t.Fatalf("manager user id = %d, want session linked user 42", client.request.GetManagerUserId())
}
if client.request.GetMeta().GetAppCode() != "fami" || client.request.GetPageSize() != myTeamAgencyRPCPageSize {
t.Fatalf("owner request is not session scoped: %+v", client.request)
}
var body struct {
Data MyTeamAgencyPage `json:"data"`
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Data.Page != 2 || body.Data.PageSize != 2 || body.Data.Total != 3 || len(body.Data.Items) != 1 {
t.Fatalf("unexpected page: %+v", body.Data)
}
item := body.Data.Items[0]
if item.AgencyID != 103 || item.OwnerUserID != 1003 || item.ParentBDUserID != 2003 || item.Status != "active" {
t.Fatalf("unexpected agency projection: %+v", item)
}
if body.Data.TotalBDLeaders != 4 || body.Data.TotalBDs != 9 || !body.Data.Truncated {
t.Fatalf("owner totals not preserved: %+v", body.Data)
}
if got := responseRecorder.Body.String(); !containsAll(got, `"agencyId":"103"`, `"ownerUserId":"1003"`, `"parentBdUserId":"2003"`) {
t.Fatalf("int64 ids must be JSON strings: %s", got)
}
}
func TestMyTeamRouteRequiresBDViewPermissionBeforeOwnerCall(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"agency:view"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies", nil))
if responseRecorder.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if client.calls != 0 {
t.Fatalf("owner service called %d times without bd:view", client.calls)
}
}
func TestMyTeamRouteFiltersScopedIDProjectionBeforeTotalAndPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"bd:view"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
// "002" matches owner 1002 and parent BD 2002. Filtering happens only over
// the already session-scoped RPC projection and must precede total/pagination.
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?keyword=002&page=1&page_size=1", nil))
if responseRecorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
var body struct {
Data MyTeamAgencyPage `json:"data"`
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Data.Total != 1 || len(body.Data.Items) != 1 || body.Data.Items[0].AgencyID != 102 {
t.Fatalf("keyword filter/page = %+v", body.Data)
}
if client.request.GetManagerUserId() != 42 {
t.Fatalf("keyword must not change team scope: manager=%d", client.request.GetManagerUserId())
}
}
func containsAll(value string, targets ...string) bool {
for _, target := range targets {
if !strings.Contains(value, target) {
return false
}
}
return true
}