You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
memos/server/router/api/v1/test/user_service_delete_test.go

490 lines
15 KiB
Go

package test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"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 TestDeleteUserSelfDeleteRemovesOwnedResourcesAndMemoSubtrees(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)
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,
ContentID: ownMemo.Name,
ReactionType: "👍",
})
require.NoError(t, err)
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: peer.ID,
ContentID: userCommentOnForeignMemo.Name,
ReactionType: "🔥",
})
require.NoError(t, err)
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: user.ID,
ContentID: foreignMemo.Name,
ReactionType: "👋",
})
require.NoError(t, err)
peerReactionOnForeignMemo, err := ts.Store.UpsertReaction(ctx, &store.Reaction{
CreatorID: peer.ID,
ContentID: foreignMemo.Name,
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,
peerCommentOnOwnMemo.Name,
peerNestedCommentOnOwnMemo.Name,
userCommentOnForeignMemo.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.Nil(t, memo, memoName)
}
foreignMemoAfterDelete, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &foreignMemoUID})
require.NoError(t, err)
require.NotNil(t, foreignMemoAfterDelete)
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{ContentID: &ownMemo.Name})
require.NoError(t, err)
require.Empty(t, ownMemoReactions)
userCommentReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &userCommentOnForeignMemo.Name})
require.NoError(t, err)
require.Empty(t, userCommentReactions)
foreignMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &foreignMemo.Name})
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 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,
ContentID: ownMemo.Name,
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 TestDeleteUserReturnsErrorWhenAttachmentStorageCleanupFails(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)
headerCtx := apiv1.WithHeaderCarrier(ctx)
failCtx := store.WithDeleteAttachmentStorageFailpoint(headerCtx)
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, "attachment storage cleanup failed")
require.ErrorContains(t, err, "attachment_id=")
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)
}