package workbench_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "slices" "testing" "time" "github.com/Tencent/WeKnora/internal/accesscontrol" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/workbench" "github.com/gin-gonic/gin" "gorm.io/gorm" ) func accountScopeFixture(t *testing.T) (*gorm.DB, *gin.Engine) { t.Helper() gin.SetMode(gin.TestMode) db := operationTestDB(t) if err := db.AutoMigrate(&types.User{}, &types.TenantMember{}, &accesscontrol.Assignment{}, &workbench.Balance{}, &workbench.Transaction{}, &workbench.UsageQuota{}, &workbench.Usage{}, &workbench.BalanceHold{}); err != nil { t.Fatal(err) } deleted := gorm.DeletedAt{Time: time.Now().Add(-time.Hour), Valid: true} for _, row := range []struct { id string tenant uint64 role types.TenantRole status types.TenantMemberStatus deletedUser bool deletedMember bool }{ {"admin", 1, types.TenantRoleAdmin, types.TenantMemberStatusActive, false, false}, {"ordinary", 1, types.TenantRoleContributor, types.TenantMemberStatusActive, false, false}, {"inactive", 1, types.TenantRoleViewer, types.TenantMemberStatusActive, false, false}, {"suspended", 1, types.TenantRoleContributor, types.TenantMemberStatusSuspended, false, false}, {"acceptance-legitimate", 1, types.TenantRoleContributor, types.TenantMemberStatusActive, false, false}, {"reenrolled", 1, types.TenantRoleContributor, types.TenantMemberStatusActive, false, false}, {"deleted-user", 1, types.TenantRoleContributor, types.TenantMemberStatusActive, true, false}, {"deleted-member", 1, types.TenantRoleContributor, types.TenantMemberStatusActive, false, true}, {"deleted-both", 1, types.TenantRoleContributor, types.TenantMemberStatusActive, true, true}, {"other-tenant", 2, types.TenantRoleContributor, types.TenantMemberStatusActive, false, false}, } { user := types.User{ID: row.id, Username: row.id, Email: row.id + "@example.test", TenantID: row.tenant, IsSystemAdmin: row.id == "admin"} member := types.TenantMember{TenantID: row.tenant, UserID: row.id, Role: row.role, Status: row.status} if row.deletedUser { user.DeletedAt = deleted } if row.deletedMember { member.DeletedAt = deleted } if err := db.Create(&user).Error; err != nil { t.Fatal(err) } if err := db.Create(&member).Error; err != nil { t.Fatal(err) } if err := db.Create(&workbench.UsageQuota{ID: row.id, TenantID: row.tenant, UserID: row.id, Period: "daily", OveragePolicy: "warn"}).Error; err != nil { t.Fatal(err) } } if err := db.Model(&types.User{}).Where("id=?", "inactive").Update("is_active", false).Error; err != nil { t.Fatal(err) } if err := db.Create(&types.TenantMember{TenantID: 1, UserID: "reenrolled", Role: types.TenantRoleViewer, DeletedAt: deleted}).Error; err != nil { t.Fatal(err) } if err := db.Create(&types.TenantMember{TenantID: 1, UserID: "missing-user", Role: types.TenantRoleContributor}).Error; err != nil { t.Fatal(err) } // A balance in another tenant must not be displayed for this tenant's account. for _, balance := range []workbench.Balance{{TenantID: 1, UserID: "ordinary", Balance: 70, Reserved: 20}, {TenantID: 2, UserID: "ordinary", Balance: 900}} { if err := db.Create(&balance).Error; err != nil { t.Fatal(err) } } h := workbench.NewHandler(workbench.NewService(db)) router := gin.New() router.GET("/accounts", h.Accounts) router.POST("/accounts/grant", h.GrantBalance) router.GET("/quotas", h.Quotas) router.PUT("/quotas/:userID", h.SaveQuota) router.GET("/users", accesscontrol.NewService(db).ListUsers) return db, router } func accountScopeRequest(t *testing.T, router *gin.Engine, tenant uint64, role types.TenantRole, method, path string, body any) *httptest.ResponseRecorder { t.Helper() ctx := context.WithValue(operationTestContext(), types.TenantIDContextKey, tenant) ctx = context.WithValue(ctx, types.TenantRoleContextKey, role) encoded, err := json.Marshal(body) if err != nil { t.Fatal(err) } request := httptest.NewRequest(method, path, bytes.NewReader(encoded)).WithContext(ctx) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } func TestBalanceAccountsMatchAccessControlLiveUsers(t *testing.T) { _, router := accountScopeFixture(t) expected := []string{"acceptance-legitimate", "admin", "inactive", "ordinary", "reenrolled", "suspended"} for _, tenant := range []uint64{1, 2, 3} { want := expected if tenant == 2 { want = []string{"other-tenant"} } else if tenant == 3 { want = []string{} } for _, path := range []string{"/users", "/accounts", "/quotas"} { response := accountScopeRequest(t, router, tenant, types.TenantRoleAdmin, http.MethodGet, path, nil) if response.Code != http.StatusOK { t.Fatalf("%s tenant %d: %d %s", path, tenant, response.Code, response.Body.String()) } var payload struct { Data []struct { ID string `json:"id"` UserID string `json:"user_id"` Balance int64 `json:"balance"` Reserved int64 `json:"reserved"` } `json:"data"` } if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if payload.Data == nil { t.Fatalf("%s must return an empty array instead of null", path) } ids := []string{} for _, row := range payload.Data { id := row.UserID if path == "/users" { id = row.ID } ids = append(ids, id) if path == "/accounts" { balance, reserved := int64(0), int64(0) if tenant == 1 && id == "ordinary" { balance, reserved = 70, 20 } if row.Balance != balance || row.Reserved != reserved { t.Fatalf("incorrect balance for %s in tenant %d: %+v", id, tenant, row) } } } if !slices.Equal(ids, want) { t.Fatalf("%s tenant %d users = %v, want %v", path, tenant, ids, want) } } } } func TestBalanceManagementRejectsDeletedAndOutOfScopeRecipients(t *testing.T) { db, router := accountScopeFixture(t) for _, user := range []string{"deleted-user", "deleted-member", "deleted-both", "missing-user", "other-tenant", "unknown"} { grant := map[string]any{"user_id": user, "amount": 10, "reason": "scope regression", "idempotency_key": "scope-test-" + user} quota := map[string]any{"period": "monthly", "enabled": true, "token_limit": 100, "overage_policy": "warn"} for _, req := range []struct { method, path string body any }{{http.MethodPost, "/accounts/grant", grant}, {http.MethodPut, "/quotas/" + user, quota}} { response := accountScopeRequest(t, router, 1, types.TenantRoleAdmin, req.method, req.path, req.body) if response.Code != http.StatusNotFound { t.Fatalf("%s %s: got %d %s", req.method, req.path, response.Code, response.Body.String()) } } } for _, check := range []struct { model any want int64 }{{&workbench.Transaction{}, 0}, {&workbench.Balance{}, 2}, {&workbench.UsageQuota{}, 10}} { var count int64 if err := db.Model(check.model).Count(&count).Error; err != nil { t.Fatal(err) } if count != check.want { t.Fatalf("rejected request mutated %T: count=%d want=%d", check.model, count, check.want) } } // Preserve ordinary grant behavior and idempotency for an existing member. grant := map[string]any{"user_id": "ordinary", "amount": 10, "reason": "valid grant", "idempotency_key": "valid-grant-key"} for range 2 { response := accountScopeRequest(t, router, 1, types.TenantRoleAdmin, http.MethodPost, "/accounts/grant", grant) if response.Code != http.StatusOK { t.Fatalf("valid grant: %d %s", response.Code, response.Body.String()) } } var balance workbench.Balance if err := db.Where("tenant_id=1 AND user_id=?", "ordinary").Take(&balance).Error; err != nil { t.Fatal(err) } if balance.Balance != 80 || balance.Reserved != 20 { t.Fatalf("grant changed balance incorrectly: %+v", balance) } } func TestBalanceManagementRemainsAdminOnly(t *testing.T) { _, router := accountScopeFixture(t) for _, role := range []types.TenantRole{types.TenantRoleViewer, types.TenantRoleContributor} { for _, req := range []struct { method, path string body any }{ {http.MethodGet, "/accounts", nil}, {http.MethodGet, "/quotas", nil}, {http.MethodPost, "/accounts/grant", map[string]any{"user_id": "ordinary", "amount": 10, "idempotency_key": "denied-grant-key"}}, {http.MethodPut, "/quotas/ordinary", map[string]any{"period": "daily", "overage_policy": "warn"}}, } { response := accountScopeRequest(t, router, 1, role, req.method, req.path, req.body) if response.Code != http.StatusForbidden { t.Fatalf("%s %s with role %s: got %d", req.method, req.path, role, response.Code) } } } }