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

396 lines
12 KiB
Go

package test
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store"
)
func TestDeleteMemoShare_VerifiesShareBelongsToMemo(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
userOne, err := ts.CreateRegularUser(ctx, "share-owner-one")
require.NoError(t, err)
userTwo, err := ts.CreateRegularUser(ctx, "share-owner-two")
require.NoError(t, err)
userOneCtx := ts.CreateUserContext(ctx, userOne.ID)
userTwoCtx := ts.CreateUserContext(ctx, userTwo.ID)
memoOne, err := ts.Service.CreateMemo(userOneCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo one",
Visibility: apiv1.Visibility_PRIVATE,
},
})
require.NoError(t, err)
memoTwo, err := ts.Service.CreateMemo(userTwoCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo two",
Visibility: apiv1.Visibility_PRIVATE,
},
})
require.NoError(t, err)
share, err := ts.Service.CreateMemoShare(userTwoCtx, &apiv1.CreateMemoShareRequest{
Parent: memoTwo.Name,
MemoShare: &apiv1.MemoShare{},
})
require.NoError(t, err)
shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:]
forgedName := memoOne.Name + "/shares/" + shareToken
_, err = ts.Service.DeleteMemoShare(userOneCtx, &apiv1.DeleteMemoShareRequest{
Name: forgedName,
})
require.Error(t, err)
require.Equal(t, codes.NotFound, status.Code(err))
sharedMemo, err := ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{
ShareToken: shareToken,
})
require.NoError(t, err)
require.Equal(t, memoTwo.Name, sharedMemo.Name)
}
func TestDeleteMemoShare_RevalidatesSpaceWriteAuthority(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
owner, err := ts.CreateRegularUser(ctx, "share-space-owner")
require.NoError(t, err)
admin, err := ts.CreateRegularUser(ctx, "share-space-admin")
require.NoError(t, err)
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
space, err := ts.Store.CreateSpace(ctx, &store.Space{UID: "share-space", Title: "Share Space"}, owner.ID)
require.NoError(t, err)
_, err = ts.InviteAndAcceptSpaceMember(ctx, &store.SpaceMember{
SpaceID: space.ID, UserID: admin.ID, Role: store.SpaceMemberRoleAdmin,
}, owner.ID)
require.NoError(t, err)
memo, err := ts.Service.CreateMemo(ownerCtx, &apiv1.CreateMemoRequest{Memo: &apiv1.Memo{
Content: "assigned share", Visibility: apiv1.Visibility_PUBLIC, Space: ptr("spaces/" + space.UID),
}})
require.NoError(t, err)
share, err := ts.Service.CreateMemoShare(ownerCtx, &apiv1.CreateMemoShareRequest{Parent: memo.Name, MemoShare: &apiv1.MemoShare{}})
require.NoError(t, err)
shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:]
require.NoError(t, ts.Store.DeleteSpaceMember(ctx, &store.DeleteSpaceMember{SpaceID: space.ID, UserID: owner.ID}, admin.ID))
_, err = ts.Service.DeleteMemoShare(ownerCtx, &apiv1.DeleteMemoShareRequest{Name: share.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err))
requireSharePresent(ctx, t, ts, shareToken)
_, err = ts.InviteAndAcceptSpaceMember(ctx, &store.SpaceMember{
SpaceID: space.ID, UserID: owner.ID, Role: store.SpaceMemberRoleUser,
}, admin.ID)
require.NoError(t, err)
_, err = ts.Service.DeleteMemoShare(ownerCtx, &apiv1.DeleteMemoShareRequest{Name: share.Name})
require.NoError(t, err)
storedShare, err := ts.Store.GetMemoShare(ctx, &store.FindMemoShare{UID: &shareToken})
require.NoError(t, err)
require.Nil(t, storedShare)
}
func TestDeleteMemoShare_OwnerCanRevokeLegacyAdminShare(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
owner, err := ts.CreateRegularUser(ctx, "legacy-share-owner")
require.NoError(t, err)
legacyAdmin, err := ts.CreateHostUser(ctx, "legacy-share-admin")
require.NoError(t, err)
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
memo, err := ts.Service.CreateMemo(ownerCtx, &apiv1.CreateMemoRequest{Memo: &apiv1.Memo{
Content: "memo with a legacy admin share",
Visibility: apiv1.Visibility_PRIVATE,
}})
require.NoError(t, err)
memoUID := strings.TrimPrefix(memo.Name, "memos/")
storedMemo, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
require.NoError(t, err)
require.NotNil(t, storedMemo)
const shareToken = "legacy-admin-created-share"
_, err = ts.Store.CreateMemoShare(ctx, &store.MemoShare{
UID: shareToken,
MemoID: storedMemo.ID,
CreatorID: legacyAdmin.ID,
})
require.NoError(t, err)
_, err = ts.Service.DeleteMemoShare(ownerCtx, &apiv1.DeleteMemoShareRequest{
Name: memo.Name + "/shares/" + shareToken,
})
require.NoError(t, err)
share, err := ts.Store.GetMemoShare(ctx, &store.FindMemoShare{UID: ptr(shareToken)})
require.NoError(t, err)
require.Nil(t, share)
_, err = ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{ShareToken: shareToken})
require.Equal(t, codes.NotFound, status.Code(err))
}
func TestGetSharedMemo_IncludesReactions(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "share-reactions")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo with reactions",
Visibility: apiv1.Visibility_PRIVATE,
},
})
require.NoError(t, err)
reaction, err := ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{
Name: memo.Name,
Reaction: &apiv1.Reaction{
ReactionType: "👍",
},
})
require.NoError(t, err)
require.NotNil(t, reaction)
share, err := ts.Service.CreateMemoShare(userCtx, &apiv1.CreateMemoShareRequest{
Parent: memo.Name,
MemoShare: &apiv1.MemoShare{},
})
require.NoError(t, err)
shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:]
sharedMemo, err := ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{
ShareToken: shareToken,
})
require.NoError(t, err)
require.Len(t, sharedMemo.Reactions, 1)
require.Equal(t, "👍", sharedMemo.Reactions[0].ReactionType)
require.Equal(t, reaction.Name, sharedMemo.Reactions[0].Name)
}
func TestCreateMemoShare_SharesOnlyTheCommentMemo(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "share-single-memo")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
parent, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "parent must not be shared",
Visibility: apiv1.Visibility_PRIVATE,
},
})
require.NoError(t, err)
comment, err := ts.Service.CreateMemoComment(userCtx, &apiv1.CreateMemoCommentRequest{
Name: parent.Name,
Comment: &apiv1.Memo{
Content: "only this memo is shared",
Visibility: apiv1.Visibility_PRIVATE,
},
})
require.NoError(t, err)
require.NotEmpty(t, comment.Relations)
regularMemo, err := ts.Service.GetMemo(userCtx, &apiv1.GetMemoRequest{Name: comment.Name})
require.NoError(t, err)
require.NotEmpty(t, regularMemo.GetParent())
require.NotEmpty(t, regularMemo.Relations)
share, err := ts.Service.CreateMemoShare(userCtx, &apiv1.CreateMemoShareRequest{
Parent: comment.Name,
MemoShare: &apiv1.MemoShare{},
})
require.NoError(t, err)
shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:]
sharedComment, err := ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{ShareToken: shareToken})
require.NoError(t, err)
require.Equal(t, comment.Name, sharedComment.Name)
require.Equal(t, "only this memo is shared", sharedComment.Content)
require.Empty(t, sharedComment.GetParent())
require.Empty(t, sharedComment.Relations)
}
func TestGetSharedMemo_SkipsReactionsWithMissingCreators(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
owner, err := ts.CreateRegularUser(ctx, "share-owner")
require.NoError(t, err)
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
reactor, err := ts.CreateRegularUser(ctx, "share-reaction-orphan")
require.NoError(t, err)
reactorCtx := ts.CreateUserContext(ctx, reactor.ID)
memo, err := ts.Service.CreateMemo(ownerCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo with orphan share reaction",
Visibility: apiv1.Visibility_PUBLIC,
},
})
require.NoError(t, err)
_, err = ts.Service.UpsertMemoReaction(reactorCtx, &apiv1.UpsertMemoReactionRequest{
Name: memo.Name,
Reaction: &apiv1.Reaction{
ReactionType: "👍",
},
})
require.NoError(t, err)
share, err := ts.Service.CreateMemoShare(ownerCtx, &apiv1.CreateMemoShareRequest{
Parent: memo.Name,
MemoShare: &apiv1.MemoShare{},
})
require.NoError(t, err)
_, err = ts.Store.DeleteUser(ctx, &store.DeleteUser{ID: reactor.ID})
require.NoError(t, err)
shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:]
sharedMemo, err := ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{
ShareToken: shareToken,
})
require.NoError(t, err)
require.Empty(t, sharedMemo.Reactions)
}
func TestGetSharedMemo_ReturnsNotFoundForUnknownShare(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
_, err := ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{
ShareToken: "missing-share-token",
})
require.Error(t, err)
require.Equal(t, codes.NotFound, status.Code(err))
}
func TestGetSharedMemo_ReturnsNotFoundForExpiredShare(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "share-expired")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo with expired share",
Visibility: apiv1.Visibility_PRIVATE,
},
})
require.NoError(t, err)
expiredTsSec := time.Now().Add(-time.Hour).Unix()
expiredShare, err := ts.Store.CreateMemoShare(ctx, &store.MemoShare{
UID: "expired-share-token",
MemoID: parseMemoIDFromNameForTest(t, ts, memo.Name),
CreatorID: user.ID,
ExpiresTs: &expiredTsSec,
})
require.NoError(t, err)
_, err = ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{
ShareToken: expiredShare.UID,
})
require.Error(t, err)
require.Equal(t, codes.NotFound, status.Code(err))
}
func TestGetSharedMemo_ReturnsNotFoundForArchivedMemo(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "share-archived")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
memoResp, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "memo that will be archived",
Visibility: apiv1.Visibility_PRIVATE,
},
})
require.NoError(t, err)
share, err := ts.Service.CreateMemoShare(userCtx, &apiv1.CreateMemoShareRequest{
Parent: memoResp.Name,
MemoShare: &apiv1.MemoShare{},
})
require.NoError(t, err)
memoID := parseMemoIDFromNameForTest(t, ts, memoResp.Name)
memo, err := ts.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID})
require.NoError(t, err)
require.NotNil(t, memo)
archived := store.Archived
err = ts.Store.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
RowStatus: &archived,
})
require.NoError(t, err)
shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:]
_, err = ts.Service.GetSharedMemo(ctx, &apiv1.GetSharedMemoRequest{
ShareToken: shareToken,
})
require.Error(t, err)
require.Equal(t, codes.NotFound, status.Code(err))
}
func parseMemoIDFromNameForTest(t *testing.T, ts *TestService, memoName string) int32 {
t.Helper()
memoUID, ok := strings.CutPrefix(memoName, "memos/")
require.True(t, ok, "memo name must start with memos/: %s", memoName)
memo, err := ts.Store.GetMemo(context.Background(), &store.FindMemo{UID: &memoUID})
require.NoError(t, err)
require.NotNil(t, memo)
return memo.ID
}
func requireSharePresent(ctx context.Context, t *testing.T, ts *TestService, token string) {
t.Helper()
share, err := ts.Store.GetMemoShare(ctx, &store.FindMemoShare{UID: &token})
require.NoError(t, err)
require.NotNil(t, share)
}