memos/server/router/api/v1/test/user_service_delete_test.go

561 lines
19 KiB
Go

package test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
apiv1 "github.com/usememos/memos/server/router/api/v1"
"github.com/usememos/memos/store"
)
func TestDeleteUserSelfDeleteCleansAccountDataAndAuthCookies(t *testing.T) {
t.Parallel()
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "alice")
require.NoError(t, err)
_, err = ts.Store.CreateUserIdentity(ctx, &store.UserIdentity{
UserID: user.ID,
Provider: "google",
ExternUID: "alice-google-sub",
})
require.NoError(t, err)
err = ts.Store.AddUserRefreshToken(ctx, user.ID, &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: "refresh-token-id",
ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)),
CreatedAt: timestamppb.Now(),
})
require.NoError(t, err)
headerCtx := apiv1.WithHeaderCarrier(ctx)
authCtx := ts.CreateUserContext(headerCtx, user.ID)
_, err = ts.Service.DeleteUser(authCtx, &v1pb.DeleteUserRequest{
Name: apiv1.BuildUserName(user.Username),
})
require.NoError(t, err)
deletedUser, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.Nil(t, deletedUser)
identities, err := ts.Store.ListUserIdentities(ctx, &store.FindUserIdentity{UserID: &user.ID})
require.NoError(t, err)
require.Empty(t, identities)
refreshSetting, err := ts.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_REFRESH_TOKENS,
})
require.NoError(t, err)
require.Nil(t, refreshSetting)
carrier := apiv1.GetHeaderCarrier(authCtx)
require.NotNil(t, carrier)
require.Contains(t, strings.ToLower(carrier.Get("Set-Cookie")), "memos_refresh=")
}
func TestDeleteUserSelfDeleteRemovesOnlyOwnedMemosAndResources(t *testing.T) {
t.Parallel()
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "resource-owner")
require.NoError(t, err)
peer, err := ts.CreateRegularUser(ctx, "resource-peer")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
peerCtx := ts.CreateUserContext(ctx, peer.ID)
ownMemo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{
Memo: &v1pb.Memo{
Content: "owner memo",
Visibility: v1pb.Visibility_PUBLIC,
},
})
require.NoError(t, err)
foreignMemo, err := ts.Service.CreateMemo(peerCtx, &v1pb.CreateMemoRequest{
Memo: &v1pb.Memo{
Content: "peer memo",
Visibility: v1pb.Visibility_PUBLIC,
},
})
require.NoError(t, err)
peerCommentOnOwnMemo, err := ts.Service.CreateMemoComment(peerCtx, &v1pb.CreateMemoCommentRequest{
Name: ownMemo.Name,
Comment: &v1pb.Memo{
Content: "peer comment on owner memo",
Visibility: v1pb.Visibility_PUBLIC,
},
})
require.NoError(t, err)
peerNestedCommentOnOwnMemo, err := ts.Service.CreateMemoComment(peerCtx, &v1pb.CreateMemoCommentRequest{
Name: peerCommentOnOwnMemo.Name,
Comment: &v1pb.Memo{
Content: "peer nested comment on owner memo",
Visibility: v1pb.Visibility_PUBLIC,
},
})
require.NoError(t, err)
userCommentOnForeignMemo, err := ts.Service.CreateMemoComment(userCtx, &v1pb.CreateMemoCommentRequest{
Name: foreignMemo.Name,
Comment: &v1pb.Memo{
Content: "owner comment on peer memo",
Visibility: v1pb.Visibility_PUBLIC,
},
})
require.NoError(t, err)
peerReplyToUserComment, err := ts.Service.CreateMemoComment(peerCtx, &v1pb.CreateMemoCommentRequest{
Name: userCommentOnForeignMemo.Name,
Comment: &v1pb.Memo{
Content: "peer reply to owner comment",
Visibility: v1pb.Visibility_PUBLIC,
},
})
require.NoError(t, err)
ownMemoUID, err := apiv1.ExtractMemoUIDFromName(ownMemo.Name)
require.NoError(t, err)
ownMemoStore, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &ownMemoUID})
require.NoError(t, err)
require.NotNil(t, ownMemoStore)
foreignMemoUID, err := apiv1.ExtractMemoUIDFromName(foreignMemo.Name)
require.NoError(t, err)
foreignMemoStore, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &foreignMemoUID})
require.NoError(t, err)
require.NotNil(t, foreignMemoStore)
peerCommentOnOwnMemoID := parseMemoIDFromNameForTest(t, ts, peerCommentOnOwnMemo.Name)
peerNestedCommentOnOwnMemoID := parseMemoIDFromNameForTest(t, ts, peerNestedCommentOnOwnMemo.Name)
userCommentOnForeignMemoID := parseMemoIDFromNameForTest(t, ts, userCommentOnForeignMemo.Name)
peerReplyToUserCommentID := parseMemoIDFromNameForTest(t, ts, peerReplyToUserComment.Name)
attachedAttachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
UID: "attach-owner-memo",
CreatorID: user.ID,
Filename: "owner.txt",
Type: "text/plain",
Size: 4,
Blob: []byte("memo"),
MemoID: &ownMemoStore.ID,
})
require.NoError(t, err)
thumbnailCachePath := filepath.Join(ts.Profile.Data, ".thumbnail_cache", attachedAttachment.UID+".jpeg")
motionCachePath := filepath.Join(ts.Profile.Data, ".motion_cache", attachedAttachment.UID+".mp4")
require.NoError(t, os.MkdirAll(filepath.Dir(thumbnailCachePath), 0o755))
require.NoError(t, os.WriteFile(thumbnailCachePath, []byte("thumb"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Dir(motionCachePath), 0o755))
require.NoError(t, os.WriteFile(motionCachePath, []byte("motion"), 0o644))
unattachedAttachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
UID: "attach-owner-loose",
CreatorID: user.ID,
Filename: "loose.txt",
Type: "text/plain",
Size: 5,
Blob: []byte("loose"),
})
require.NoError(t, err)
peerAttachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
UID: "attach-peer-keep",
CreatorID: peer.ID,
Filename: "peer.txt",
Type: "text/plain",
Size: 4,
Blob: []byte("peer"),
MemoID: &foreignMemoStore.ID,
})
require.NoError(t, err)
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: peer.ID,
MemoID: ownMemoStore.ID,
ReactionType: "👍",
})
require.NoError(t, err)
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: peer.ID,
MemoID: userCommentOnForeignMemoID,
ReactionType: "🔥",
})
require.NoError(t, err)
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
MemoID: foreignMemoStore.ID,
ReactionType: "👋",
})
require.NoError(t, err)
peerReactionOnForeignMemo, err := ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: peer.ID,
MemoID: foreignMemoStore.ID,
ReactionType: "✅",
})
require.NoError(t, err)
_, err = ts.Store.CreateMemoShare(ctx, &store.MemoShare{
UID: "share-owner-ownmemo",
MemoID: ownMemoStore.ID,
CreatorID: user.ID,
})
require.NoError(t, err)
_, err = ts.Store.CreateMemoShare(ctx, &store.MemoShare{
UID: "share-owner-foreignmemo",
MemoID: foreignMemoStore.ID,
CreatorID: user.ID,
})
require.NoError(t, err)
peerShare, err := ts.Store.CreateMemoShare(ctx, &store.MemoShare{
UID: "share-peer-foreignmemo",
MemoID: foreignMemoStore.ID,
CreatorID: peer.ID,
})
require.NoError(t, err)
_, err = ts.Store.CreateUserIdentity(ctx, &store.UserIdentity{
UserID: user.ID,
Provider: "google",
ExternUID: "resource-owner-google-sub",
})
require.NoError(t, err)
err = ts.Store.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
TokenId: "pat-owner",
TokenHash: "pat-owner-hash",
Description: "owner pat",
})
require.NoError(t, err)
headerCtx := apiv1.WithHeaderCarrier(ctx)
authCtx := ts.CreateUserContext(headerCtx, user.ID)
_, err = ts.Service.DeleteUser(authCtx, &v1pb.DeleteUserRequest{
Name: apiv1.BuildUserName(user.Username),
})
require.NoError(t, err)
deletedUser, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.Nil(t, deletedUser)
for _, memoName := range []string{
ownMemo.Name,
userCommentOnForeignMemo.Name,
} {
memoUID, extractErr := apiv1.ExtractMemoUIDFromName(memoName)
require.NoError(t, extractErr)
memo, getErr := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
require.NoError(t, getErr)
require.Nil(t, memo, memoName)
}
for _, memoName := range []string{
peerCommentOnOwnMemo.Name,
peerNestedCommentOnOwnMemo.Name,
peerReplyToUserComment.Name,
} {
memoUID, extractErr := apiv1.ExtractMemoUIDFromName(memoName)
require.NoError(t, extractErr)
memo, getErr := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
require.NoError(t, getErr)
require.NotNil(t, memo, memoName)
}
foreignMemoAfterDelete, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &foreignMemoUID})
require.NoError(t, err)
require.NotNil(t, foreignMemoAfterDelete)
nestedRelations, err := ts.Store.ListMemoRelations(ctx, &store.FindMemoRelation{MemoID: &peerNestedCommentOnOwnMemoID})
require.NoError(t, err)
require.Len(t, nestedRelations, 1)
require.Equal(t, peerCommentOnOwnMemoID, nestedRelations[0].RelatedMemoID)
for _, memoID := range []int32{ownMemoStore.ID, userCommentOnForeignMemoID} {
incidentRelations, listErr := ts.Store.ListMemoRelations(ctx, &store.FindMemoRelation{MemoIDList: []int32{memoID}})
require.NoError(t, listErr)
require.Empty(t, incidentRelations)
}
contextlessReplyRelations, err := ts.Store.ListMemoRelations(ctx, &store.FindMemoRelation{MemoID: &peerReplyToUserCommentID})
require.NoError(t, err)
require.Empty(t, contextlessReplyRelations)
for _, attachmentID := range []int32{attachedAttachment.ID, unattachedAttachment.ID} {
attachment, getErr := ts.Store.GetAttachment(ctx, &store.FindAttachment{ID: &attachmentID})
require.NoError(t, getErr)
require.Nil(t, attachment)
}
peerAttachmentAfterDelete, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{ID: &peerAttachment.ID})
require.NoError(t, err)
require.NotNil(t, peerAttachmentAfterDelete)
_, err = os.Stat(thumbnailCachePath)
require.ErrorIs(t, err, os.ErrNotExist)
_, err = os.Stat(motionCachePath)
require.ErrorIs(t, err, os.ErrNotExist)
ownMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{MemoID: &ownMemoStore.ID})
require.NoError(t, err)
require.Empty(t, ownMemoReactions)
userCommentReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{MemoID: &userCommentOnForeignMemoID})
require.NoError(t, err)
require.Empty(t, userCommentReactions)
foreignMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{MemoID: &foreignMemoStore.ID})
require.NoError(t, err)
require.Len(t, foreignMemoReactions, 1)
require.Equal(t, peerReactionOnForeignMemo.ID, foreignMemoReactions[0].ID)
ownerShares, err := ts.Store.ListMemoShares(ctx, &store.FindMemoShare{CreatorID: &user.ID})
require.NoError(t, err)
require.Empty(t, ownerShares)
peerShares, err := ts.Store.ListMemoShares(ctx, &store.FindMemoShare{CreatorID: &peer.ID})
require.NoError(t, err)
require.Len(t, peerShares, 1)
require.Equal(t, peerShare.ID, peerShares[0].ID)
sentInboxes, err := ts.Store.ListInboxes(ctx, &store.FindInbox{SenderID: &user.ID})
require.NoError(t, err)
require.Empty(t, sentInboxes)
receivedInboxes, err := ts.Store.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user.ID})
require.NoError(t, err)
require.Empty(t, receivedInboxes)
identities, err := ts.Store.ListUserIdentities(ctx, &store.FindUserIdentity{UserID: &user.ID})
require.NoError(t, err)
require.Empty(t, identities)
patSetting, err := ts.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS,
})
require.NoError(t, err)
require.Nil(t, patSetting)
}
func TestDeleteUserSpaceMembershipGuardIgnoresForce(t *testing.T) {
for _, tc := range []struct {
name string
force bool
suffix string
}{
{name: "without force", suffix: "normal"},
{name: "with force", force: true, suffix: "force"},
} {
t.Run(tc.name, func(t *testing.T) {
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "membership-delete-"+tc.suffix)
require.NoError(t, err)
space, err := ts.Store.CreateSpace(ctx, &store.Space{UID: "membership-delete-" + tc.suffix, Title: "Membership guard"}, user.ID)
require.NoError(t, err)
memo, err := ts.Service.CreateMemo(ts.CreateUserContext(ctx, user.ID), &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
Content: "must survive failed account deletion",
Visibility: v1pb.Visibility_PUBLIC,
}})
require.NoError(t, err)
_, err = ts.Service.DeleteUser(ts.CreateUserContext(ctx, user.ID), &v1pb.DeleteUserRequest{
Name: apiv1.BuildUserName(user.Username),
Force: tc.force,
})
require.Equal(t, codes.FailedPrecondition, status.Code(err))
require.ErrorContains(t, err, "leave all spaces")
remainingUser, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.NotNil(t, remainingUser)
membership, err := ts.Store.GetSpaceMember(ctx, &store.FindSpaceMember{SpaceID: &space.ID, UserID: &user.ID, ViewerUserID: &user.ID})
require.NoError(t, err)
require.NotNil(t, membership)
memoUID, err := apiv1.ExtractMemoUIDFromName(memo.Name)
require.NoError(t, err)
remainingMemo, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
require.NoError(t, err)
require.NotNil(t, remainingMemo)
})
}
}
func TestDeleteUserRollbackPreservesAllResources(t *testing.T) {
t.Parallel()
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "rollback-owner")
require.NoError(t, err)
peer, err := ts.CreateRegularUser(ctx, "rollback-peer")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
ownMemo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{
Memo: &v1pb.Memo{
Content: "rollback owner memo",
Visibility: v1pb.Visibility_PUBLIC,
},
})
require.NoError(t, err)
ownMemoUID, err := apiv1.ExtractMemoUIDFromName(ownMemo.Name)
require.NoError(t, err)
ownMemoStore, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &ownMemoUID})
require.NoError(t, err)
require.NotNil(t, ownMemoStore)
attachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
UID: "attach-rollback-owner",
CreatorID: user.ID,
Filename: "rollback.txt",
Type: "text/plain",
Size: 8,
Blob: []byte("rollback"),
MemoID: &ownMemoStore.ID,
})
require.NoError(t, err)
reaction, err := ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
MemoID: ownMemoStore.ID,
ReactionType: "💥",
})
require.NoError(t, err)
share, err := ts.Store.CreateMemoShare(ctx, &store.MemoShare{
UID: "share-rollback-owner",
MemoID: ownMemoStore.ID,
CreatorID: user.ID,
})
require.NoError(t, err)
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
SenderID: peer.ID,
ReceiverID: user.ID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_MEMO_COMMENT,
Payload: &storepb.InboxMessage_MemoComment{
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
MemoId: ownMemoStore.ID,
},
},
},
})
require.NoError(t, err)
_, err = ts.Store.CreateUserIdentity(ctx, &store.UserIdentity{
UserID: user.ID,
Provider: "google",
ExternUID: "rollback-owner-google-sub",
})
require.NoError(t, err)
err = ts.Store.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
TokenId: "pat-rollback-owner",
TokenHash: "pat-rollback-owner-hash",
Description: "rollback pat",
})
require.NoError(t, err)
headerCtx := apiv1.WithHeaderCarrier(ctx)
failCtx := store.WithDeleteUserFailpoint(headerCtx, store.DeleteUserFailpointBeforeCommit)
authCtx := ts.CreateUserContext(failCtx, user.ID)
_, err = ts.Service.DeleteUser(authCtx, &v1pb.DeleteUserRequest{
Name: apiv1.BuildUserName(user.Username),
})
require.Error(t, err)
require.ErrorContains(t, err, "delete user failpoint before commit")
userAfterRollback, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.NotNil(t, userAfterRollback)
memoAfterRollback, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &ownMemoUID})
require.NoError(t, err)
require.NotNil(t, memoAfterRollback)
attachmentAfterRollback, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID})
require.NoError(t, err)
require.NotNil(t, attachmentAfterRollback)
reactionAfterRollback, err := ts.Store.GetReaction(ctx, &store.FindReaction{ID: &reaction.ID})
require.NoError(t, err)
require.NotNil(t, reactionAfterRollback)
shareAfterRollback, err := ts.Store.GetMemoShare(ctx, &store.FindMemoShare{ID: &share.ID})
require.NoError(t, err)
require.NotNil(t, shareAfterRollback)
inboxesAfterRollback, err := ts.Store.ListInboxes(ctx, &store.FindInbox{ID: &inbox.ID})
require.NoError(t, err)
require.Len(t, inboxesAfterRollback, 1)
identitiesAfterRollback, err := ts.Store.ListUserIdentities(ctx, &store.FindUserIdentity{UserID: &user.ID})
require.NoError(t, err)
require.Len(t, identitiesAfterRollback, 1)
patSetting, err := ts.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS,
})
require.NoError(t, err)
require.NotNil(t, patSetting)
}
func TestDeleteUserReportsPostCommitAttachmentStorageCleanupFailure(t *testing.T) {
t.Parallel()
ts := NewTestService(t)
defer ts.Cleanup()
ctx := context.Background()
user, err := ts.CreateRegularUser(ctx, "cleanup-failure-owner")
require.NoError(t, err)
_, err = ts.Store.CreateAttachment(ctx, &store.Attachment{
UID: "attach-cleanup-failure",
CreatorID: user.ID,
Filename: "failure.txt",
Type: "text/plain",
Size: 7,
Blob: []byte("failure"),
StorageType: storepb.AttachmentStorageType_LOCAL,
Reference: "cleanup-failure.txt",
})
require.NoError(t, err)
attachmentPath := filepath.Join(ts.Profile.Data, "cleanup-failure.txt")
require.NoError(t, os.WriteFile(attachmentPath, []byte("failure"), 0o600))
headerCtx := apiv1.WithHeaderCarrier(ctx)
authCtx := ts.CreateUserContext(store.WithDeleteAttachmentStorageFailpoint(headerCtx), user.ID)
_, err = ts.Service.DeleteUser(authCtx, &v1pb.DeleteUserRequest{
Name: apiv1.BuildUserName(user.Username),
})
require.Equal(t, codes.Internal, status.Code(err))
require.ErrorContains(t, err, "user was deleted but attachment storage cleanup failed")
require.ErrorContains(t, err, store.ErrDeleteAttachmentStorageFailpoint.Error())
deletedUser, err := ts.Store.GetUser(ctx, &store.FindUser{ID: &user.ID})
require.NoError(t, err)
require.Nil(t, deletedUser)
require.FileExists(t, attachmentPath, "storage cleanup failure happens after the database deletion commits")
}