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{"team: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 TestMyTeamRouteRequiresTeamViewPermissionBeforeOwnerCall(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:list"}) 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 team: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{"team: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 }