|
8 | 8 | "net/http" |
9 | 9 | "net/http/httptest" |
10 | 10 | "strings" |
| 11 | + "time" |
11 | 12 |
|
12 | 13 | "github.com/google/uuid" |
13 | 14 | "github.com/labstack/echo/v4" |
@@ -276,11 +277,11 @@ func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo |
276 | 277 | if currentUser.ID == targetID { |
277 | 278 | return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"}) |
278 | 279 | } |
279 | | - db.Where("user_id = ?", targetID).Delete(&auth.Session{}) |
280 | | - db.Where("user_id = ?", targetID).Delete(&auth.UserAPIKey{}) |
281 | | - result := db.Where("id = ?", targetID).Delete(&auth.User{}) |
282 | | - if result.RowsAffected == 0 { |
283 | | - return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) |
| 280 | + if err := auth.DeleteUserCascade(db, targetID); err != nil { |
| 281 | + if err == gorm.ErrRecordNotFound { |
| 282 | + return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) |
| 283 | + } |
| 284 | + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete user: " + err.Error()}) |
284 | 285 | } |
285 | 286 | return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"}) |
286 | 287 | }, adminMw) |
@@ -686,6 +687,107 @@ var _ = Describe("Auth Routes", Label("auth"), func() { |
686 | 687 | rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID)) |
687 | 688 | Expect(rec.Code).To(Equal(http.StatusForbidden)) |
688 | 689 | }) |
| 690 | + |
| 691 | + // Regression coverage for the production bug: in distributed mode the |
| 692 | + // auth DB is PostgreSQL, which strictly enforces foreign keys. The old |
| 693 | + // handler did not clean up invite_codes, user_permissions, quota_rules, |
| 694 | + // or usage_records, which either caused FK violations (surfaced as a |
| 695 | + // misleading 404 "user not found") or left orphan rows after delete. |
| 696 | + It("removes invite codes the user authored", func() { |
| 697 | + admin := createRouteTestUser(db, "admin-inv-author@test.com", auth.RoleAdmin) |
| 698 | + target := createRouteTestUser(db, "deletes-author@test.com", auth.RoleAdmin) |
| 699 | + Expect(db.Create(&auth.InviteCode{ |
| 700 | + ID: uuid.New().String(), Code: "code-authored", CodePrefix: "code-aut", |
| 701 | + CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour), |
| 702 | + }).Error).ToNot(HaveOccurred()) |
| 703 | + sessionID, _ := auth.CreateSession(db, admin.ID, "") |
| 704 | + app := newTestAuthApp(db, appConfig) |
| 705 | + |
| 706 | + rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID)) |
| 707 | + Expect(rec.Code).To(Equal(http.StatusOK)) |
| 708 | + |
| 709 | + var count int64 |
| 710 | + db.Model(&auth.InviteCode{}).Where("created_by = ?", target.ID).Count(&count) |
| 711 | + Expect(count).To(Equal(int64(0))) |
| 712 | + }) |
| 713 | + |
| 714 | + It("nulls used_by on invite codes the user consumed", func() { |
| 715 | + admin := createRouteTestUser(db, "admin-inv-consumer@test.com", auth.RoleAdmin) |
| 716 | + target := createRouteTestUser(db, "deletes-consumer@test.com", auth.RoleUser) |
| 717 | + usedBy := target.ID |
| 718 | + now := time.Now() |
| 719 | + Expect(db.Create(&auth.InviteCode{ |
| 720 | + ID: uuid.New().String(), Code: "code-used", CodePrefix: "code-use", |
| 721 | + CreatedBy: admin.ID, UsedBy: &usedBy, UsedAt: &now, |
| 722 | + ExpiresAt: now.Add(time.Hour), |
| 723 | + }).Error).ToNot(HaveOccurred()) |
| 724 | + sessionID, _ := auth.CreateSession(db, admin.ID, "") |
| 725 | + app := newTestAuthApp(db, appConfig) |
| 726 | + |
| 727 | + rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID)) |
| 728 | + Expect(rec.Code).To(Equal(http.StatusOK)) |
| 729 | + |
| 730 | + // Audit row stays, but no longer points to the deleted user. |
| 731 | + var stale int64 |
| 732 | + db.Model(&auth.InviteCode{}).Where("used_by = ?", target.ID).Count(&stale) |
| 733 | + Expect(stale).To(Equal(int64(0))) |
| 734 | + var total int64 |
| 735 | + db.Model(&auth.InviteCode{}).Where("created_by = ?", admin.ID).Count(&total) |
| 736 | + Expect(total).To(Equal(int64(1))) |
| 737 | + }) |
| 738 | + |
| 739 | + It("wipes permissions, quotas, and usage metrics", func() { |
| 740 | + admin := createRouteTestUser(db, "admin-clean@test.com", auth.RoleAdmin) |
| 741 | + target := createRouteTestUser(db, "deletes-clean@test.com", auth.RoleUser) |
| 742 | + |
| 743 | + Expect(auth.UpdateUserPermissions(db, target.ID, auth.PermissionMap{auth.FeatureChat: true})).ToNot(HaveOccurred()) |
| 744 | + max := int64(100) |
| 745 | + _, err := auth.CreateOrUpdateQuotaRule(db, target.ID, "", &max, nil, 3600) |
| 746 | + Expect(err).ToNot(HaveOccurred()) |
| 747 | + Expect(auth.RecordUsage(db, &auth.UsageRecord{ |
| 748 | + UserID: target.ID, UserName: target.Name, Model: "test-model", |
| 749 | + Endpoint: "/v1/chat/completions", PromptTokens: 5, CompletionTokens: 10, TotalTokens: 15, |
| 750 | + })).ToNot(HaveOccurred()) |
| 751 | + |
| 752 | + sessionID, _ := auth.CreateSession(db, admin.ID, "") |
| 753 | + app := newTestAuthApp(db, appConfig) |
| 754 | + |
| 755 | + rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID)) |
| 756 | + Expect(rec.Code).To(Equal(http.StatusOK)) |
| 757 | + |
| 758 | + var perms, quotas, usage int64 |
| 759 | + db.Model(&auth.UserPermission{}).Where("user_id = ?", target.ID).Count(&perms) |
| 760 | + db.Model(&auth.QuotaRule{}).Where("user_id = ?", target.ID).Count("as) |
| 761 | + db.Model(&auth.UsageRecord{}).Where("user_id = ?", target.ID).Count(&usage) |
| 762 | + Expect(perms).To(Equal(int64(0))) |
| 763 | + Expect(quotas).To(Equal(int64(0))) |
| 764 | + Expect(usage).To(Equal(int64(0))) |
| 765 | + }) |
| 766 | + |
| 767 | + It("returns 200 even when foreign keys are enforced and the user authored invites", func() { |
| 768 | + // Mirror PostgreSQL's strict FK behavior on the SQLite test DB. This |
| 769 | + // exercises exactly the production failure: without the cleanup, |
| 770 | + // the user delete would be rejected by the engine and the handler |
| 771 | + // would surface a misleading 404. |
| 772 | + Expect(db.Exec("PRAGMA foreign_keys = ON").Error).ToNot(HaveOccurred()) |
| 773 | + |
| 774 | + admin := createRouteTestUser(db, "admin-fk@test.com", auth.RoleAdmin) |
| 775 | + target := createRouteTestUser(db, "deletes-fk@test.com", auth.RoleAdmin) |
| 776 | + Expect(db.Create(&auth.InviteCode{ |
| 777 | + ID: uuid.New().String(), Code: "code-fk", CodePrefix: "code-fk1", |
| 778 | + CreatedBy: target.ID, ExpiresAt: time.Now().Add(time.Hour), |
| 779 | + }).Error).ToNot(HaveOccurred()) |
| 780 | + |
| 781 | + sessionID, _ := auth.CreateSession(db, admin.ID, "") |
| 782 | + app := newTestAuthApp(db, appConfig) |
| 783 | + |
| 784 | + rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID)) |
| 785 | + Expect(rec.Code).To(Equal(http.StatusOK), "body=%s", rec.Body.String()) |
| 786 | + |
| 787 | + var users int64 |
| 788 | + db.Model(&auth.User{}).Where("id = ?", target.ID).Count(&users) |
| 789 | + Expect(users).To(Equal(int64(0))) |
| 790 | + }) |
689 | 791 | }) |
690 | 792 |
|
691 | 793 | Context("POST /api/auth/register", func() { |
|
0 commit comments