From 7b5de594cd92e30b9c3d40ffda119acad794cc64 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 14 Jan 2026 09:29:33 -0800 Subject: [PATCH 1/2] Fix permission check on org project operations (#36318) --- models/project/column.go | 12 ++++ models/project/project.go | 12 ++++ routers/web/org/projects.go | 99 +++++++++----------------------- routers/web/org/projects_test.go | 30 ++++++++++ 4 files changed, 82 insertions(+), 71 deletions(-) diff --git a/models/project/column.go b/models/project/column.go index 9b9d874997..79f6dfe911 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -213,6 +213,18 @@ func GetColumn(ctx context.Context, columnID int64) (*Column, error) { return column, nil } +func GetColumnByIDAndProjectID(ctx context.Context, columnID, projectID int64) (*Column, error) { + column := new(Column) + has, err := db.GetEngine(ctx).ID(columnID).And("project_id=?", projectID).Get(column) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectColumnNotExist{ColumnID: columnID} + } + + return column, nil +} + // UpdateColumn updates a project column func UpdateColumn(ctx context.Context, column *Column) error { var fieldToUpdate []string diff --git a/models/project/project.go b/models/project/project.go index c003664fa3..7646c3dd71 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -302,6 +302,18 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) { return p, nil } +func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) { + p := new(Project) + has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectNotExist{ID: id} + } + + return p, nil +} + // GetProjectForRepoByID returns the projects in a repository func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) { p := new(Project) diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index d524409c41..f4a54db006 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -205,22 +205,24 @@ func ChangeProjectStatus(ctx *context.Context) { } id := ctx.PathParamInt64("id") - if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { - ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) - return - } - ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, id)) -} - -// DeleteProject delete a project -func DeleteProject(ctx *context.Context) { - p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) + project, err := project_model.GetProjectByIDAndOwner(ctx, id, ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound(nil) + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, project.ID, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) + return + } + ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, project.ID)) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } @@ -246,15 +248,11 @@ func RenderEditProject(ctx *context.Context) { return } - p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) + p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound(nil) - return - } ctx.Data["projectID"] = p.ID ctx.Data["title"] = p.Title @@ -288,15 +286,11 @@ func EditProjectPost(ctx *context.Context) { return } - p, err := project_model.GetProjectByID(ctx, projectID) + p, err := project_model.GetProjectByIDAndOwner(ctx, projectID, ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound(nil) - return - } p.Title = form.Title p.Description = form.Content @@ -316,15 +310,12 @@ func EditProjectPost(ctx *context.Context) { // ViewProject renders the project with board view for a project func ViewProject(ctx *context.Context) { - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) + project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if project.OwnerID != ctx.ContextUser.ID { - ctx.NotFound(nil) - return - } + if err := project.LoadOwner(ctx); err != nil { ctx.ServerError("LoadOwner", err) return @@ -455,28 +446,15 @@ func DeleteProjectColumn(ctx *context.Context) { return } - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) + project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID")) + _, err = project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID) if err != nil { - ctx.ServerError("GetProjectColumn", err) - return - } - if pb.ProjectID != ctx.PathParamInt64("id") { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), - }) - return - } - - if project.OwnerID != ctx.ContextUser.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), - }) + ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err) return } @@ -492,7 +470,7 @@ func DeleteProjectColumn(ctx *context.Context) { func AddColumnToProjectPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.EditProjectColumnForm) - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) + project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return @@ -520,30 +498,18 @@ func CheckProjectColumnChangePermissions(ctx *context.Context) (*project_model.P return nil, nil } - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) + project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return nil, nil } - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID")) + column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID) if err != nil { - ctx.ServerError("GetProjectColumn", err) - return nil, nil - } - if column.ProjectID != ctx.PathParamInt64("id") { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), - }) + ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err) return nil, nil } - if project.OwnerID != ctx.ContextUser.ID { - ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ - "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), - }) - return nil, nil - } return project, column } @@ -595,24 +561,15 @@ func MoveIssues(ctx *context.Context) { return } - project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) + project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID) if err != nil { ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if project.OwnerID != ctx.ContextUser.ID { - ctx.NotFound(nil) - return - } - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID")) + column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID) if err != nil { - ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) - return - } - - if column.ProjectID != project.ID { - ctx.NotFound(nil) + ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err) return } diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go index c3a769e621..63bcefb6e2 100644 --- a/routers/web/org/projects_test.go +++ b/routers/web/org/projects_test.go @@ -4,11 +4,14 @@ package org_test import ( + "net/http" "testing" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/org" "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" "github.com/stretchr/testify/assert" ) @@ -26,3 +29,30 @@ func TestCheckProjectColumnChangePermissions(t *testing.T) { assert.NotNil(t, column) assert.False(t, ctx.Written()) } + +func TestChangeProjectStatusRejectsForeignProjects(t *testing.T) { + unittest.PrepareTestEnv(t) + // project 4 is owned by user2 not user1 + ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/close") + contexttest.LoadUser(t, ctx, 1) + ctx.ContextUser = ctx.Doer + ctx.SetPathParam("action", "close") + ctx.SetPathParam("id", "4") + + org.ChangeProjectStatus(ctx) + + assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) +} + +func TestAddColumnToProjectPostRejectsForeignProjects(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/columns/new") + contexttest.LoadUser(t, ctx, 1) + ctx.ContextUser = ctx.Doer + ctx.SetPathParam("id", "4") + web.SetForm(ctx, &forms.EditProjectColumnForm{Title: "foreign"}) + + org.AddColumnToProjectPost(ctx) + + assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) +} From 14e8c9b767d36d59eadb075c08207d40648a847d Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 14 Jan 2026 11:37:53 -0800 Subject: [PATCH 2/2] Release attachments must belong to the intended repo (#36347) --- models/repo/attachment.go | 5 ++++ models/repo/attachment_test.go | 16 ++++++++++++ models/repo/release.go | 9 +++++++ models/repo/release_test.go | 14 +++++++++++ routers/web/repo/attachment.go | 35 +++++++++++++++++++------- services/repository/repository.go | 23 ++++++++--------- services/repository/repository_test.go | 16 +++++------- services/user/user.go | 18 +++++++++++++ services/user/user_test.go | 18 +++++++++++++ 9 files changed, 122 insertions(+), 32 deletions(-) diff --git a/models/repo/attachment.go b/models/repo/attachment.go index 835bee5402..27856f2d2e 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -166,6 +166,11 @@ func GetAttachmentByReleaseIDFileName(ctx context.Context, releaseID int64, file return attach, nil } +func GetUnlinkedAttachmentsByUserID(ctx context.Context, userID int64) ([]*Attachment, error) { + attachments := make([]*Attachment, 0, 10) + return attachments, db.GetEngine(ctx).Where("uploader_id = ? AND issue_id = 0 AND release_id = 0 AND comment_id = 0", userID).Find(&attachments) +} + // DeleteAttachment deletes the given attachment and optionally the associated file. func DeleteAttachment(ctx context.Context, a *Attachment, remove bool) error { _, err := DeleteAttachments(ctx, []*Attachment{a}, remove) diff --git a/models/repo/attachment_test.go b/models/repo/attachment_test.go index d41008344d..07f4c587a7 100644 --- a/models/repo/attachment_test.go +++ b/models/repo/attachment_test.go @@ -101,3 +101,19 @@ func TestGetAttachmentsByUUIDs(t *testing.T) { assert.Equal(t, int64(1), attachList[0].IssueID) assert.Equal(t, int64(5), attachList[1].IssueID) } + +func TestGetUnlinkedAttachmentsByUserID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 8) + assert.NoError(t, err) + assert.Len(t, attachments, 1) + assert.Equal(t, int64(10), attachments[0].ID) + assert.Zero(t, attachments[0].IssueID) + assert.Zero(t, attachments[0].ReleaseID) + assert.Zero(t, attachments[0].CommentID) + + attachments, err = repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 1) + assert.NoError(t, err) + assert.Empty(t, attachments) +} diff --git a/models/repo/release.go b/models/repo/release.go index 05475899b8..68fb6b1724 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -174,6 +174,11 @@ func UpdateReleaseNumCommits(ctx context.Context, rel *Release) error { // AddReleaseAttachments adds a release attachments func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs []string) (err error) { + rel, err := GetReleaseByID(ctx, releaseID) + if err != nil { + return err + } + // Check attachments attachments, err := GetAttachmentsByUUIDs(ctx, attachmentUUIDs) if err != nil { @@ -181,6 +186,10 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs } for i := range attachments { + if attachments[i].RepoID != rel.RepoID { + return util.NewPermissionDeniedErrorf("attachment belongs to different repository") + } + if attachments[i].ReleaseID != 0 { return util.NewPermissionDeniedErrorf("release permission denied") } diff --git a/models/repo/release_test.go b/models/repo/release_test.go index 01f0fb3cff..8e30e76f49 100644 --- a/models/repo/release_test.go +++ b/models/repo/release_test.go @@ -7,6 +7,7 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" ) @@ -37,3 +38,16 @@ func Test_FindTagsByCommitIDs(t *testing.T) { assert.Equal(t, "delete-tag", rels[1].TagName) assert.Equal(t, "v1.0", rels[2].TagName) } + +func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12" // attachment 2 belongs to repo 2 + err := AddReleaseAttachments(t.Context(), 1, []string{uuid}) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrPermissionDenied) + + attach, err := GetAttachmentByUUID(t.Context(), uuid) + assert.NoError(t, err) + assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure") +} diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index bff91b51a7..c8501792ce 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -132,23 +132,40 @@ func ServeAttachment(ctx *context.Context, uuid string) { return } - repository, unitType, err := repo_service.LinkedRepository(ctx, attach) - if err != nil { - ctx.ServerError("LinkedRepository", err) + // prevent visiting attachment from other repository directly + if ctx.Repo.Repository != nil && ctx.Repo.Repository.ID != attach.RepoID { + ctx.HTTPError(http.StatusNotFound) return } - if repository == nil { // If not linked + unitType, err := repo_service.GetAttachmentLinkedType(ctx, attach) + if err != nil { + ctx.ServerError("GetAttachmentLinkedType", err) + return + } + + if unitType == unit.TypeInvalid { // unlinked attachment can only be accessed by the uploader if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader ctx.HTTPError(http.StatusNotFound) return } - } else { // If we have the repository we check access - perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserRepoPermission", err) - return + } else { // If we have the linked type, we need to check access + var perm access_model.Permission + if ctx.Repo.Repository == nil { + repo, err := repo_model.GetRepositoryByID(ctx, attach.RepoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + perm, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + } else { + perm = ctx.Repo.Permission } + if !perm.CanRead(unitType) { ctx.HTTPError(http.StatusNotFound) return diff --git a/services/repository/repository.go b/services/repository/repository.go index 21a75a559f..318ab423e5 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -221,28 +221,25 @@ func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository) (err erro }) } -// LinkedRepository returns the linked repo if any -func LinkedRepository(ctx context.Context, a *repo_model.Attachment) (*repo_model.Repository, unit.Type, error) { +// GetAttachmentLinkedType returns the linked type of attachment if any +func GetAttachmentLinkedType(ctx context.Context, a *repo_model.Attachment) (unit.Type, error) { if a.IssueID != 0 { iss, err := issues_model.GetIssueByID(ctx, a.IssueID) if err != nil { - return nil, unit.TypeIssues, err + return unit.TypeIssues, err } - repo, err := repo_model.GetRepositoryByID(ctx, iss.RepoID) unitType := unit.TypeIssues if iss.IsPull { unitType = unit.TypePullRequests } - return repo, unitType, err - } else if a.ReleaseID != 0 { - rel, err := repo_model.GetReleaseByID(ctx, a.ReleaseID) - if err != nil { - return nil, unit.TypeReleases, err - } - repo, err := repo_model.GetRepositoryByID(ctx, rel.RepoID) - return repo, unit.TypeReleases, err + return unitType, nil } - return nil, -1, nil + + if a.ReleaseID != 0 { + _, err := repo_model.GetReleaseByID(ctx, a.ReleaseID) + return unit.TypeReleases, err + } + return unit.TypeInvalid, nil } // CheckDaemonExportOK creates/removes git-daemon-export-ok for git-daemon... diff --git a/services/repository/repository_test.go b/services/repository/repository_test.go index cf7dd6b7ed..b3447ae166 100644 --- a/services/repository/repository_test.go +++ b/services/repository/repository_test.go @@ -16,28 +16,24 @@ import ( "github.com/stretchr/testify/require" ) -func TestLinkedRepository(t *testing.T) { +func TestAttachLinkedType(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) testCases := []struct { name string attachID int64 - expectedRepo *repo_model.Repository expectedUnitType unit.Type }{ - {"LinkedIssue", 1, &repo_model.Repository{ID: 1}, unit.TypeIssues}, - {"LinkedComment", 3, &repo_model.Repository{ID: 1}, unit.TypePullRequests}, - {"LinkedRelease", 9, &repo_model.Repository{ID: 1}, unit.TypeReleases}, - {"Notlinked", 10, nil, -1}, + {"LinkedIssue", 1, unit.TypeIssues}, + {"LinkedComment", 3, unit.TypePullRequests}, + {"LinkedRelease", 9, unit.TypeReleases}, + {"Notlinked", 10, unit.TypeInvalid}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { attach, err := repo_model.GetAttachmentByID(t.Context(), tc.attachID) assert.NoError(t, err) - repo, unitType, err := LinkedRepository(t.Context(), attach) + unitType, err := GetAttachmentLinkedType(t.Context(), attach) assert.NoError(t, err) - if tc.expectedRepo != nil { - assert.Equal(t, tc.expectedRepo.ID, repo.ID) - } assert.Equal(t, tc.expectedUnitType, unitType) }) } diff --git a/services/user/user.go b/services/user/user.go index 8e42fa3ccd..9b8bcf83c0 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -239,6 +239,11 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { if err := deleteUser(ctx, u, purge); err != nil { return fmt.Errorf("DeleteUser: %w", err) } + + // Finally delete any unlinked attachments, this will also delete the attached files + if err := deleteUserUnlinkedAttachments(ctx, u); err != nil { + return fmt.Errorf("deleteUserUnlinkedAttachments: %w", err) + } return nil }); err != nil { return err @@ -269,6 +274,19 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return nil } +func deleteUserUnlinkedAttachments(ctx context.Context, u *user_model.User) error { + attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(ctx, u.ID) + if err != nil { + return fmt.Errorf("GetUnlinkedAttachmentsByUserID: %w", err) + } + for _, attach := range attachments { + if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil { + return fmt.Errorf("DeleteAttachment ID[%d]: %w", attach.ID, err) + } + } + return nil +} + // DeleteInactiveUsers deletes all inactive users and their email addresses. func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { inactiveUsers, err := user_model.GetInactiveUsers(ctx, olderThan) diff --git a/services/user/user_test.go b/services/user/user_test.go index 25e8ee7b2f..4d8d448dcd 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -63,6 +63,24 @@ func TestDeleteUser(t *testing.T) { assert.Error(t, DeleteUser(t.Context(), org, false)) } +func TestDeleteUserUnlinkedAttachments(t *testing.T) { + t.Run("DeleteExisting", func(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 10}) + + assert.NoError(t, deleteUserUnlinkedAttachments(t.Context(), user)) + unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: 10}) + }) + + t.Run("NoUnlinkedAttachments", func(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.NoError(t, deleteUserUnlinkedAttachments(t.Context(), user)) + }) +} + func TestPurgeUser(t *testing.T) { test := func(userID int64) { assert.NoError(t, unittest.PrepareTestDatabase())