diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index b2774d7232..5ec4892e16 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -178,7 +178,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService) channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db) channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService) - channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service) + channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service, apiKeyService) dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) diff --git a/backend/internal/handler/channel_monitor_v2_handler.go b/backend/internal/handler/channel_monitor_v2_handler.go index ea661d1a76..a5acc2bc0c 100644 --- a/backend/internal/handler/channel_monitor_v2_handler.go +++ b/backend/internal/handler/channel_monitor_v2_handler.go @@ -1,6 +1,7 @@ package handler import ( + "context" "errors" "net/http" "strconv" @@ -13,11 +14,16 @@ import ( ) type ChannelMonitorV2Handler struct { - service *service.ChannelMonitorV2Service + service *service.ChannelMonitorV2Service + apiKeyService channelMonitorV2GroupAuthorizer } -func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler { - return &ChannelMonitorV2Handler{service: svc} +type channelMonitorV2GroupAuthorizer interface { + GetAvailableGroups(ctx context.Context, userID int64) ([]service.Group, error) +} + +func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service, apiKeyService *service.APIKeyService) *ChannelMonitorV2Handler { + return &ChannelMonitorV2Handler{service: svc, apiKeyService: apiKeyService} } // channelMonitorV2IsAdmin is true when the request already passed admin auth @@ -68,13 +74,17 @@ func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) { if !ok { return } + admin := channelMonitorV2IsAdmin(c) + if !h.scopeFilter(c, &filter, admin) { + return + } result, err := h.service.Dimensions(c.Request.Context(), filter) if err != nil { response.ErrorFrom(c, err) return } // Admin and user share this handler; only non-admin responses strip volume. - if !channelMonitorV2IsAdmin(c) { + if !admin { service.RedactChannelMonitorV2Dimensions(result) } response.Success(c, result) @@ -94,6 +104,9 @@ func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) { if !ok { return } + if !h.scopeFilter(c, &filter, admin) { + return + } result, err := h.service.Snapshot(c.Request.Context(), filter, admin) if err != nil { response.ErrorFrom(c, err) @@ -107,6 +120,9 @@ func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) { if !ok { return } + if !h.scopeFilter(c, &filter, admin) { + return + } result, err := h.service.Models(c.Request.Context(), filter, admin) if err != nil { response.ErrorFrom(c, err) @@ -125,6 +141,9 @@ func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) { response.BadRequest(c, err.Error()) return } + if !h.scopeFilter(c, &filter, admin) { + return + } result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin) if err != nil { response.ErrorFrom(c, err) @@ -138,7 +157,11 @@ func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) { if !ok { return } - result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c)) + admin := channelMonitorV2IsAdmin(c) + if !h.scopeFilter(c, &filter, admin) { + return + } + result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, admin) if err != nil { response.ErrorFrom(c, err) return @@ -156,6 +179,9 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) { response.Error(c, http.StatusUnauthorized, "user not found in context") return } + if !h.scopeFilter(c, &filter, admin) { + return + } result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin) if err != nil { response.ErrorFrom(c, err) @@ -164,6 +190,32 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) { response.Success(c, result) } +func (h *ChannelMonitorV2Handler) scopeFilter(c *gin.Context, filter *service.ChannelMonitorV2Filter, admin bool) bool { + if admin { + return true + } + if h.apiKeyService == nil { + response.Error(c, http.StatusInternalServerError, "channel monitor group authorization unavailable") + return false + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + response.Unauthorized(c, "user not found in context") + return false + } + groups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return false + } + filter.RestrictGroups = true + filter.AllowedGroupIDs = make([]int64, 0, len(groups)) + for i := range groups { + filter.AllowedGroupIDs = append(filter.AllowedGroupIDs, groups[i].ID) + } + return true +} + func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) { groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id")) if err != nil { diff --git a/backend/internal/handler/channel_monitor_v2_handler_test.go b/backend/internal/handler/channel_monitor_v2_handler_test.go index b61c582f4e..cb822290fc 100644 --- a/backend/internal/handler/channel_monitor_v2_handler_test.go +++ b/backend/internal/handler/channel_monitor_v2_handler_test.go @@ -1,15 +1,28 @@ package handler import ( + "context" "net/http" "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type channelMonitorV2GroupAuthorizerStub struct { + groups []service.Group + err error + calls []int64 +} + +func (s *channelMonitorV2GroupAuthorizerStub) GetAvailableGroups(_ context.Context, userID int64) ([]service.Group, error) { + s.calls = append(s.calls, userID) + return s.groups, s.err +} + func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) { c, _ := gin.CreateTestContext(nil) c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil) @@ -29,7 +42,46 @@ func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) { recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil) - h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil)) + h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil), nil) h.Matrix(c) require.Equal(t, http.StatusBadRequest, recorder.Code) } + +func TestChannelMonitorV2ScopeFilterUsesAvailableGroupsForOrdinaryUser(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42}) + authorizer := &channelMonitorV2GroupAuthorizerStub{groups: []service.Group{{ID: 3}, {ID: 7}}} + h := &ChannelMonitorV2Handler{apiKeyService: authorizer} + filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{7, 9}} + + require.True(t, h.scopeFilter(c, &filter, false)) + require.True(t, filter.RestrictGroups) + require.Equal(t, []int64{3, 7}, filter.AllowedGroupIDs) + require.Equal(t, []int64{42}, authorizer.calls) +} + +func TestChannelMonitorV2ScopeFilterPreservesEmptyOrdinaryUserScope(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42}) + h := &ChannelMonitorV2Handler{apiKeyService: &channelMonitorV2GroupAuthorizerStub{}} + filter := service.ChannelMonitorV2Filter{} + + require.True(t, h.scopeFilter(c, &filter, false)) + require.True(t, filter.RestrictGroups) + require.Empty(t, filter.AllowedGroupIDs) +} + +func TestChannelMonitorV2ScopeFilterLeavesAdminUnrestricted(t *testing.T) { + authorizer := &channelMonitorV2GroupAuthorizerStub{} + h := &ChannelMonitorV2Handler{apiKeyService: authorizer} + filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{9}} + + require.True(t, h.scopeFilter(nil, &filter, true)) + require.False(t, filter.RestrictGroups) + require.Nil(t, filter.AllowedGroupIDs) + require.Empty(t, authorizer.calls) +} diff --git a/backend/internal/repository/channel_monitor_v2_repo.go b/backend/internal/repository/channel_monitor_v2_repo.go index c9ad5031f1..978fb44bb0 100644 --- a/backend/internal/repository/channel_monitor_v2_repo.go +++ b/backend/internal/repository/channel_monitor_v2_repo.go @@ -115,6 +115,13 @@ type channelMonitorV2Histogram struct { } func (r *channelMonitorV2Repository) GetDimensions(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) (*service.ChannelMonitorV2Dimensions, error) { + if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) { + return &service.ChannelMonitorV2Dimensions{ + Platforms: []service.ChannelMonitorV2Dimension{}, + Groups: []service.ChannelMonitorV2GroupDimension{}, + Models: []service.ChannelMonitorV2Dimension{}, + }, nil + } coverage, err := r.loadCoverage(ctx, filter) if err != nil { return nil, err @@ -211,6 +218,9 @@ func (r *channelMonitorV2Repository) GetDimensions(ctx context.Context, filter s } func (r *channelMonitorV2Repository) GetSnapshot(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2Snapshot, error) { + if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) { + return &service.ChannelMonitorV2Snapshot{Trend: []service.ChannelMonitorV2TrendPoint{}}, nil + } coverage, err := r.loadCoverage(ctx, filter) if err != nil { return nil, err @@ -275,6 +285,9 @@ func (r *channelMonitorV2Repository) GetSnapshot(ctx context.Context, filter ser } func (r *channelMonitorV2Repository) GetModels(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow], error) { + if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) { + return &service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow]{Items: []service.ChannelMonitorV2ModelRow{}}, nil + } coverage, err := r.loadCoverage(ctx, filter) if err != nil { return nil, err @@ -353,6 +366,9 @@ type channelMonitorV2MatrixAccumulator struct { } func (r *channelMonitorV2Repository) GetMatrix(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, groupBy service.ChannelMonitorV2GroupBy, admin bool) (*service.ChannelMonitorV2Matrix, error) { + if channelMonitorV2RestrictedGroupScopeEmpty(filter, cfg) { + return &service.ChannelMonitorV2Matrix{GroupBy: groupBy, Items: []service.ChannelMonitorV2MatrixRow{}}, nil + } coverage, err := r.loadCoverage(ctx, filter) if err != nil { return nil, err @@ -370,7 +386,7 @@ func (r *channelMonitorV2Repository) GetMatrix(ctx context.Context, filter servi seedGroupIDs := configuredChannelMonitorV2GroupIDs(filter, cfg) // Empty config group list means all groups — load active groups so matrix seed // can materialize real platform/group rows (not bare platform placeholders). - if len(seedGroupIDs) == 0 && + if len(seedGroupIDs) == 0 && !filter.RestrictGroups && (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) { allIDs, loadErr := r.listActiveGroupIDs(ctx) if loadErr != nil { @@ -503,7 +519,7 @@ func seedChannelMonitorV2MatrixAccumulators(filter service.ChannelMonitorV2Filte groupIDs := []int64{0} if needsGroup { groupIDs = configuredChannelMonitorV2GroupIDs(filter, cfg) - if len(groupIDs) == 0 { + if len(groupIDs) == 0 && !filter.RestrictGroups { // Empty config group list means "all groups": use discovered active groups. for id := range groupInfo { groupIDs = append(groupIDs, id) @@ -581,15 +597,44 @@ func channelMonitorV2CatalogFilter(filter service.ChannelMonitorV2Filter) servic } func configuredChannelMonitorV2GroupIDs(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) []int64 { + groups, empty := channelMonitorV2ScopedGroupIDs(filter, cfg) + if empty { + return []int64{} + } + return groups +} + +func channelMonitorV2ScopedGroupIDs(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) ([]int64, bool) { groups := append([]int64(nil), cfg.GroupIDs...) + if filter.RestrictGroups { + if len(groups) > 0 { + groups = intersectInt64(groups, filter.AllowedGroupIDs) + } else { + groups = append([]int64(nil), filter.AllowedGroupIDs...) + } + if len(groups) == 0 { + return nil, true + } + } if len(filter.GroupIDs) > 0 { if len(groups) > 0 { groups = intersectInt64(groups, filter.GroupIDs) } else { groups = append([]int64(nil), filter.GroupIDs...) } + if len(groups) == 0 { + return nil, true + } } - return groups + return groups, false +} + +func channelMonitorV2RestrictedGroupScopeEmpty(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) bool { + if !filter.RestrictGroups { + return false + } + _, empty := channelMonitorV2ScopedGroupIDs(filter, cfg) + return empty } type channelMonitorV2GroupInfo struct { @@ -723,16 +768,7 @@ func (r *channelMonitorV2Repository) loadErrorDetails(ctx context.Context, filte } else { conditions = append(conditions, "FALSE") } - groups := cfg.GroupIDs - groupScopeEmpty := false - if len(filter.GroupIDs) > 0 { - if len(groups) > 0 { - groups = intersectInt64(groups, filter.GroupIDs) - groupScopeEmpty = len(groups) == 0 - } else { - groups = filter.GroupIDs - } - } + groups, groupScopeEmpty := channelMonitorV2ScopedGroupIDs(filter, cfg) if groupScopeEmpty { conditions = append(conditions, "FALSE") } else if len(groups) > 0 { @@ -1136,16 +1172,7 @@ func channelMonitorV2Where(filter service.ChannelMonitorV2Filter, cfg service.Ch } else { conditions = append(conditions, "FALSE") } - groups := cfg.GroupIDs - groupScopeEmpty := false - if len(filter.GroupIDs) > 0 { - if len(groups) > 0 { - groups = intersectInt64(groups, filter.GroupIDs) - groupScopeEmpty = len(groups) == 0 - } else { - groups = filter.GroupIDs - } - } + groups, groupScopeEmpty := channelMonitorV2ScopedGroupIDs(filter, cfg) if groupScopeEmpty { conditions = append(conditions, "FALSE") } else if len(groups) > 0 { diff --git a/backend/internal/repository/channel_monitor_v2_repo_test.go b/backend/internal/repository/channel_monitor_v2_repo_test.go index 82d5e61130..c6cec9c926 100644 --- a/backend/internal/repository/channel_monitor_v2_repo_test.go +++ b/backend/internal/repository/channel_monitor_v2_repo_test.go @@ -1,11 +1,14 @@ package repository import ( + "context" "strings" "testing" "time" + "github.com/DATA-DOG/go-sqlmock" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" "github.com/stretchr/testify/require" ) @@ -93,6 +96,171 @@ func TestChannelMonitorV2WhereRejectsGroupFilterOutsideConfiguredScope(t *testin require.Len(t, args, 3) } +func TestChannelMonitorV2WhereRestrictsOrdinaryViewerToAllowedConfiguredGroups(t *testing.T) { + filter := service.ChannelMonitorV2Filter{ + Start: time.Unix(1, 0), End: time.Unix(2, 0), + GroupIDs: []int64{4, 9}, AllowedGroupIDs: []int64{3, 4}, RestrictGroups: true, + } + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}}, + GroupIDs: []int64{3, 4, 9}, + } + where, args := channelMonitorV2Where(filter, cfg, "m") + require.Contains(t, where, "m.group_id = ANY($4)") + require.Equal(t, pq.Array([]int64{4}), args[3]) +} + +func TestChannelMonitorV2WhereRejectsOrdinaryViewerWithNoAllowedGroups(t *testing.T) { + filter := service.ChannelMonitorV2Filter{ + Start: time.Unix(1, 0), End: time.Unix(2, 0), + GroupIDs: []int64{9}, RestrictGroups: true, + } + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}}, + GroupIDs: []int64{3, 9}, + } + where, _ := channelMonitorV2Where(filter, cfg, "m") + require.Contains(t, where, "FALSE") + require.NotContains(t, where, "m.group_id = ANY") +} + +func TestChannelMonitorV2CatalogKeepsViewerScopeWhileIgnoringPickerFilters(t *testing.T) { + filter := service.ChannelMonitorV2Filter{ + Platforms: []string{"openai"}, GroupIDs: []int64{9}, Models: []string{"gpt-5"}, + AllowedGroupIDs: []int64{3}, RestrictGroups: true, + } + catalog := channelMonitorV2CatalogFilter(filter) + require.Empty(t, catalog.Platforms) + require.Empty(t, catalog.GroupIDs) + require.Empty(t, catalog.Models) + require.True(t, catalog.RestrictGroups) + require.Equal(t, []int64{3}, catalog.AllowedGroupIDs) +} + +func TestChannelMonitorV2AdminScopeRemainsGlobal(t *testing.T) { + filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9}} + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}}, + GroupIDs: []int64{3, 9}, + } + where, args := channelMonitorV2Where(filter, cfg, "m") + require.Contains(t, where, "m.group_id = ANY($4)") + require.Equal(t, pq.Array([]int64{9}), args[3]) +} + +func TestChannelMonitorV2MatrixDoesNotSeedGroupsForEmptyViewerScope(t *testing.T) { + filter := service.ChannelMonitorV2Filter{RestrictGroups: true} + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}}, + GroupIDs: []int64{3, 9}, + } + accs := seedChannelMonitorV2MatrixAccumulators(filter, cfg, service.ChannelMonitorV2GroupByPlatformGroup, map[int64]channelMonitorV2GroupInfo{ + 3: {name: "private"}, 9: {name: "other-private"}, + }) + require.Empty(t, accs) +} + +func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptyInventory(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + repo := &channelMonitorV2Repository{db: db} + filter := service.ChannelMonitorV2Filter{RestrictGroups: true} + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}}, + GroupIDs: []int64{3, 9}, + } + + dimensions, err := repo.GetDimensions(context.Background(), filter, cfg) + require.NoError(t, err) + require.Empty(t, dimensions.Platforms) + require.Empty(t, dimensions.Groups) + require.Empty(t, dimensions.Models) + require.NotNil(t, dimensions.Platforms) + require.NotNil(t, dimensions.Groups) + require.NotNil(t, dimensions.Models) + + models, err := repo.GetModels(context.Background(), filter, cfg, false) + require.NoError(t, err) + require.Empty(t, models.Items) + require.NotNil(t, models.Items) + require.Equal(t, service.ChannelMonitorV2Coverage{}, models.Coverage) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptySnapshotWithoutQueries(t *testing.T) { + tests := []struct { + name string + filter service.ChannelMonitorV2Filter + }{ + { + name: "no allowed groups", + filter: service.ChannelMonitorV2Filter{RestrictGroups: true}, + }, + { + name: "requested groups exclude allowed configured scope", + filter: service.ChannelMonitorV2Filter{ + GroupIDs: []int64{9}, AllowedGroupIDs: []int64{3}, RestrictGroups: true, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + repo := &channelMonitorV2Repository{db: db} + cfg := service.ChannelMonitorV2Config{ + Enabled: true, + Platforms: []service.ChannelMonitorV2PlatformConfig{{ + Platform: "openai", Enabled: true, Models: []string{"gpt-5"}, + }}, + GroupIDs: []int64{3}, + } + + snapshot, err := repo.GetSnapshot(context.Background(), test.filter, cfg, false) + require.NoError(t, err) + require.Equal(t, service.ChannelMonitorV2Config{}, snapshot.Config) + require.Equal(t, service.ChannelMonitorV2Coverage{}, snapshot.Coverage) + require.Equal(t, service.ChannelMonitorV2Metric{}, snapshot.Metrics) + require.Equal(t, service.ChannelMonitorV2Health{}, snapshot.Health) + require.Empty(t, snapshot.Trend) + require.NotNil(t, snapshot.Trend) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptyMatrixForEveryGrouping(t *testing.T) { + groupings := []service.ChannelMonitorV2GroupBy{ + service.ChannelMonitorV2GroupByPlatform, + service.ChannelMonitorV2GroupByPlatformModel, + service.ChannelMonitorV2GroupByPlatformGroup, + service.ChannelMonitorV2GroupByPlatformGroupModel, + } + for _, groupBy := range groupings { + t.Run(string(groupBy), func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + repo := &channelMonitorV2Repository{db: db} + filter := service.ChannelMonitorV2Filter{RestrictGroups: true} + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}}, + GroupIDs: []int64{3, 9}, + } + + matrix, err := repo.GetMatrix(context.Background(), filter, cfg, groupBy, false) + require.NoError(t, err) + require.Equal(t, groupBy, matrix.GroupBy) + require.Empty(t, matrix.Items) + require.NotNil(t, matrix.Items) + require.Equal(t, service.ChannelMonitorV2Coverage{}, matrix.Coverage) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) { query := strings.ToLower(channelMonitorV2ErrorAggregationSQL) require.Contains(t, query, "not current_error.is_count_tokens") diff --git a/backend/internal/service/channel_monitor_v2.go b/backend/internal/service/channel_monitor_v2.go index 8d6198918b..1afc20293c 100644 --- a/backend/internal/service/channel_monitor_v2.go +++ b/backend/internal/service/channel_monitor_v2.go @@ -77,10 +77,15 @@ type ChannelMonitorV2Filter struct { Range string Platforms []string GroupIDs []int64 - Models []string - Start time.Time - End time.Time - Bucket time.Duration + // AllowedGroupIDs is the authenticated viewer's server-derived group scope. + // RestrictGroups distinguishes an ordinary user with no allowed groups from + // the unrestricted admin/configured scope represented by an empty slice. + AllowedGroupIDs []int64 + RestrictGroups bool + Models []string + Start time.Time + End time.Time + Bucket time.Duration } type ChannelMonitorV2Metric struct {