mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
fix(monitor): scope groups by user access
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user