package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/require" ) type fakeRPUsageQueryRepo struct { query domain.RPUsageQuery items []domain.RPUsageDailyMetric } func (f *fakeRPUsageQueryRepo) FindRPUsage(ctx context.Context, query domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) { f.query = query return f.items, nil } type fakeAdminKeto struct { allowed bool subject string object string relation string } func (f *fakeAdminKeto) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { f.subject = subject f.object = object f.relation = relation return f.allowed, nil } func (f *fakeAdminKeto) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { return nil } func (f *fakeAdminKeto) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { return nil } func (f *fakeAdminKeto) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { return nil, nil } func (f *fakeAdminKeto) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { return nil, nil } type fakeOverviewAuditRepo struct { mockAuditRepo since time.Time count int64 } func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { f.since = since return f.count, nil } type fakeAdminUserProjectionRepo struct { status domain.UserProjectionStatus } func (f *fakeAdminUserProjectionRepo) IsReady(ctx context.Context) (bool, error) { return f.status.Ready, nil } func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { return nil, nil } func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error { return nil } func (f *fakeAdminUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error { return nil } func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) { return f.status, nil } type fakeAdminUserProjectionSyncer struct { count int err error calls int } func (f *fakeAdminUserProjectionSyncer) Reconcile(ctx context.Context) (int, error) { f.calls++ return f.count, f.err } func TestAdminHandler_GetRPUsageDaily(t *testing.T) { repo := &fakeRPUsageQueryRepo{ items: []domain.RPUsageDailyMetric{ { Date: "2026-05-06", TenantID: "tenant-1", TenantType: domain.TenantTypeCompany, ClientID: "orgfront", ClientName: "OrgFront", LoginRequests: 12, OtherRequests: 4, UniqueSubjects: 8, }, }, } h := &AdminHandler{RPUsageQueries: repo} app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin}) return c.Next() }) app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?days=7&period=week&tenantId=tenant-1", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, 7, repo.query.Days) require.Equal(t, "week", repo.query.Period) require.Equal(t, "tenant-1", repo.query.TenantID) var body struct { Items []domain.RPUsageDailyMetric `json:"items"` Days int `json:"days"` Period string `json:"period"` TenantID string `json:"tenantId"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Equal(t, 7, body.Days) require.Equal(t, "week", body.Period) require.Equal(t, "tenant-1", body.TenantID) require.Len(t, body.Items, 1) require.Equal(t, "orgfront", body.Items[0].ClientID) require.Equal(t, uint64(12), body.Items[0].LoginRequests) } func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) { h := &AdminHandler{ UserProjectionRepo: &fakeAdminUserProjectionRepo{ status: domain.UserProjectionStatus{Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true}, }, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin}) return c.Next() }) app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusForbidden, resp.StatusCode) } func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t *testing.T) { syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC) h := &AdminHandler{ UserProjectionRepo: &fakeAdminUserProjectionRepo{ status: domain.UserProjectionStatus{ Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true, LastSyncedAt: &syncedAt, ProjectedUsers: 152, }, }, } app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var body domain.UserProjectionStatus require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Equal(t, domain.UserProjectionNameKratos, body.Name) require.Equal(t, domain.UserProjectionStatusReady, body.Status) require.True(t, body.Ready) require.Equal(t, int64(152), body.ProjectedUsers) } func TestAdminHandler_ReconcileUserProjectionRequiresSuperAdminAndRunsSyncer(t *testing.T) { syncer := &fakeAdminUserProjectionSyncer{count: 4} h := &AdminHandler{UserProjectionSyncer: syncer} app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, 1, syncer.calls) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Equal(t, "success", body["status"]) require.Equal(t, float64(4), body["syncedUsers"]) } func TestAdminHandler_ReconcileUserProjectionReturnsServiceUnavailableOnSyncFailure(t *testing.T) { syncer := &fakeAdminUserProjectionSyncer{err: errors.New("kratos down")} h := &AdminHandler{UserProjectionSyncer: syncer} app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin}) return c.Next() }) app.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) } func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) { repo := &fakeRPUsageQueryRepo{} keto := &fakeAdminKeto{allowed: true} h := &AdminHandler{RPUsageQueries: repo, Keto: keto} app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleTenantAdmin, }) return c.Next() }) app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?tenantId=tenant-allowed", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, "User:user-1", keto.subject) require.Equal(t, "tenant-allowed", keto.object) require.Equal(t, "view_rp_usage_stats", keto.relation) require.Equal(t, "tenant-allowed", repo.query.TenantID) } func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) { auditRepo := &fakeOverviewAuditRepo{count: 22} h := &AdminHandler{AuditRepo: auditRepo} app := fiber.New() app.Get("/api/v1/admin/stats", h.GetSystemStats) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.Contains(t, body, "totalTenants") require.Contains(t, body, "oidcClients") require.Contains(t, body, "auditEvents24h") require.Equal(t, float64(22), body["auditEvents24h"]) require.Equal(t, time.UTC, auditRepo.since.Location()) }