390 lines
16 KiB
Go
390 lines
16 KiB
Go
package test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/lithammer/shortuuid/v4"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
|
|
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
|
api "github.com/usememos/memos/server/router/api/v1"
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
func TestMemoManagedAttachmentImages(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("create accepts canonical and legacy managed image URLs", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-create")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
first := createTestImageAttachment(userCtx, t, ts, "first.png")
|
|
second := createTestImageAttachment(userCtx, t, ts, "second.png")
|
|
firstUID := strings.TrimPrefix(first.Name, "attachments/")
|
|
secondUID := strings.TrimPrefix(second.Name, "attachments/")
|
|
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: fmt.Sprintf("\n", firstUID, secondUID),
|
|
Attachments: []*v1pb.Attachment{
|
|
{Name: first.Name},
|
|
{Name: second.Name},
|
|
},
|
|
Visibility: v1pb.Visibility_PRIVATE,
|
|
}})
|
|
require.NoError(t, err)
|
|
require.Len(t, memo.Attachments, 2)
|
|
})
|
|
|
|
t.Run("create validates references before creating the memo", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-missing")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
|
|
_, err = ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{
|
|
MemoId: "managed-missing-memo",
|
|
Memo: &v1pb.Memo{
|
|
Content: "",
|
|
Visibility: v1pb.Visibility_PRIVATE,
|
|
},
|
|
})
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
uid := "managed-missing-memo"
|
|
stored, getErr := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &uid})
|
|
require.NoError(t, getErr)
|
|
require.Nil(t, stored)
|
|
})
|
|
|
|
t.Run("managed image must have an image MIME type", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-mime")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
textAttachment, err := ts.Service.CreateAttachment(userCtx, &v1pb.CreateAttachmentRequest{Attachment: &v1pb.Attachment{
|
|
Filename: "not-image.txt",
|
|
Type: "text/plain",
|
|
Content: []byte("not an image"),
|
|
}})
|
|
require.NoError(t, err)
|
|
uid := strings.TrimPrefix(textAttachment.Name, "attachments/")
|
|
|
|
_, err = ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: fmt.Sprintf("", uid),
|
|
Attachments: []*v1pb.Attachment{{Name: textAttachment.Name}},
|
|
}})
|
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
|
})
|
|
|
|
t.Run("malformed managed URLs are rejected", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-malformed")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "query.png")
|
|
uid := strings.TrimPrefix(image.Name, "attachments/")
|
|
|
|
_, err = ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: fmt.Sprintf("", uid),
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
}})
|
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
|
})
|
|
|
|
t.Run("protocol-relative managed URLs are rejected", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-network-path")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "network-path.png")
|
|
uid := strings.TrimPrefix(image.Name, "attachments/")
|
|
|
|
_, err = ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: fmt.Sprintf("", uid),
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
}})
|
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
|
})
|
|
|
|
t.Run("cross-origin protocol-relative image URLs remain external", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "external-network-path")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: "",
|
|
}})
|
|
require.NoError(t, err)
|
|
require.Empty(t, memo.Attachments)
|
|
})
|
|
|
|
t.Run("same-origin absolute managed URL is verified and accepted", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
ts.Profile.InstanceURL = "http://localhost"
|
|
user, err := ts.CreateRegularUser(ctx, "managed-absolute")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "absolute.png")
|
|
uid := strings.TrimPrefix(image.Name, "attachments/")
|
|
|
|
content := fmt.Sprintf("", uid)
|
|
_, err = ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{Content: content}})
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: content,
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
}})
|
|
require.NoError(t, err)
|
|
require.Len(t, memo.Attachments, 1)
|
|
})
|
|
|
|
t.Run("content validation sees attachments beyond the default page", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-many-attachments")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{Content: "many attachments"}})
|
|
require.NoError(t, err)
|
|
memoID := memoIDFromName(ctx, t, ts, memo.Name)
|
|
|
|
referenced, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
|
|
UID: shortuuid.New(), CreatorID: user.ID, Filename: "referenced.png", Type: "image/png", MemoID: &memoID,
|
|
})
|
|
require.NoError(t, err)
|
|
for i := 0; i < 100; i++ {
|
|
attachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
|
|
UID: shortuuid.New(), CreatorID: user.ID, Filename: fmt.Sprintf("extra-%03d.png", i), Type: "image/png", MemoID: &memoID,
|
|
})
|
|
require.NoError(t, err)
|
|
updatedTs := referenced.UpdatedTs + int64(i) + 1
|
|
require.NoError(t, ts.Store.UpdateAttachment(ctx, &store.UpdateAttachment{ID: attachment.ID, UpdatedTs: &updatedTs}))
|
|
}
|
|
|
|
updated, err := ts.Service.UpdateMemo(userCtx, &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{
|
|
Name: memo.Name,
|
|
Content: fmt.Sprintf("", referenced.UID),
|
|
},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"content"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, updated.Attachments, 101)
|
|
})
|
|
|
|
t.Run("set attachments cannot remove an image referenced by content", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-set")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "kept.png")
|
|
uid := strings.TrimPrefix(image.Name, "attachments/")
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: fmt.Sprintf("", uid),
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ts.Service.SetMemoAttachments(userCtx, &v1pb.SetMemoAttachmentsRequest{Name: memo.Name})
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
attachments, listErr := ts.Service.ListMemoAttachments(userCtx, &v1pb.ListMemoAttachmentsRequest{Name: memo.Name})
|
|
require.NoError(t, listErr)
|
|
require.Len(t, attachments.Attachments, 1)
|
|
})
|
|
|
|
t.Run("content and attachments are validated as one final update state", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-update")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "removed.png")
|
|
uid := strings.TrimPrefix(image.Name, "attachments/")
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: fmt.Sprintf("", uid),
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
updated, err := ts.Service.UpdateMemo(userCtx, &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{Name: memo.Name, Content: "image removed", Attachments: []*v1pb.Attachment{}},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"attachments", "content"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "image removed", updated.Content)
|
|
require.Empty(t, updated.Attachments)
|
|
stored, getErr := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
|
|
require.NoError(t, getErr)
|
|
require.Nil(t, stored)
|
|
})
|
|
|
|
t.Run("local deletion failure reports committed row deletion", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-delete-retry")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "retry.png")
|
|
uid := strings.TrimPrefix(image.Name, "attachments/")
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: "attachment will be removed",
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
}})
|
|
require.NoError(t, err)
|
|
storedImage, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedImage)
|
|
localReference := "retry-delete.png"
|
|
localPath := filepath.Join(ts.Profile.Data, localReference)
|
|
require.NoError(t, os.WriteFile(localPath, []byte("local image"), 0o600))
|
|
_, err = ts.Store.GetDriver().GetDB().ExecContext(ctx,
|
|
"UPDATE attachment SET storage_type = ?, reference = ? WHERE id = ?",
|
|
"LOCAL", localReference, storedImage.ID,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = ts.Service.UpdateMemo(store.WithDeleteAttachmentStorageFailpoint(userCtx), &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{Name: memo.Name, Content: "attachment removed", Attachments: []*v1pb.Attachment{}},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"content", "attachments"}},
|
|
})
|
|
require.Equal(t, codes.Internal, status.Code(err))
|
|
require.ErrorContains(t, err, "memo was updated but attachment storage cleanup failed")
|
|
require.ErrorContains(t, err, store.ErrDeleteAttachmentStorageFailpoint.Error())
|
|
storedMemo, getErr := ts.Service.GetMemo(userCtx, &v1pb.GetMemoRequest{Name: memo.Name})
|
|
require.NoError(t, getErr)
|
|
require.Equal(t, "attachment removed", storedMemo.Content)
|
|
storedImage, getErr = ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
|
|
require.NoError(t, getErr)
|
|
require.Nil(t, storedImage)
|
|
_, statErr := os.Stat(localPath)
|
|
require.NoError(t, statErr)
|
|
})
|
|
|
|
t.Run("failed content validation does not mutate the memo", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-update-failure")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
memo, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{Content: "original"}})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ts.Service.UpdateMemo(userCtx, &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{Name: memo.Name, Content: ""},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"content"}},
|
|
})
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
stored, getErr := ts.Service.GetMemo(userCtx, &v1pb.GetMemoRequest{Name: memo.Name})
|
|
require.NoError(t, getErr)
|
|
require.Equal(t, "original", stored.Content)
|
|
})
|
|
|
|
t.Run("delete APIs reject referenced attachments", func(t *testing.T) {
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "managed-delete")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "referenced.png")
|
|
uid := strings.TrimPrefix(image.Name, "attachments/")
|
|
_, err = ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: fmt.Sprintf("", uid),
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ts.Service.DeleteAttachment(userCtx, &v1pb.DeleteAttachmentRequest{Name: image.Name})
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
_, err = ts.Service.BatchDeleteAttachments(userCtx, &v1pb.BatchDeleteAttachmentsRequest{Names: []string{image.Name}})
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
stored, getErr := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
|
|
require.NoError(t, getErr)
|
|
require.NotNil(t, stored)
|
|
})
|
|
}
|
|
|
|
func TestMemoAttachmentBindingDoesNotReparent(t *testing.T) {
|
|
ctx := context.Background()
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
user, err := ts.CreateRegularUser(ctx, "no-reparent")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
image := createTestImageAttachment(userCtx, t, ts, "bound.png")
|
|
first, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{Attachments: []*v1pb.Attachment{{Name: image.Name}}}})
|
|
require.NoError(t, err)
|
|
second, err := ts.Service.CreateMemo(userCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{Content: "second"}})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ts.Service.SetMemoAttachments(userCtx, &v1pb.SetMemoAttachmentsRequest{
|
|
Name: second.Name,
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
})
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
uid, err := api.ExtractAttachmentUIDFromName(image.Name)
|
|
require.NoError(t, err)
|
|
stored, err := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored)
|
|
require.NotNil(t, stored.MemoID)
|
|
require.Equal(t, memoIDFromName(ctx, t, ts, first.Name), *stored.MemoID)
|
|
}
|
|
|
|
func TestCreateMemoDoesNotBindAnotherUsersAttachmentOrCreatePartialMemo(t *testing.T) {
|
|
ctx := context.Background()
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
owner, err := ts.CreateRegularUser(ctx, "foreign-attachment-owner")
|
|
require.NoError(t, err)
|
|
other, err := ts.CreateRegularUser(ctx, "foreign-attachment-caller")
|
|
require.NoError(t, err)
|
|
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
|
|
otherCtx := ts.CreateUserContext(ctx, other.ID)
|
|
image := createTestImageAttachment(ownerCtx, t, ts, "foreign.png")
|
|
|
|
_, err = ts.Service.CreateMemo(otherCtx, &v1pb.CreateMemoRequest{
|
|
MemoId: "no-partial-foreign-memo",
|
|
Memo: &v1pb.Memo{
|
|
Content: "foreign attachment",
|
|
Attachments: []*v1pb.Attachment{{Name: image.Name}},
|
|
},
|
|
})
|
|
require.Equal(t, codes.NotFound, status.Code(err))
|
|
uid := "no-partial-foreign-memo"
|
|
memo, getErr := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &uid})
|
|
require.NoError(t, getErr)
|
|
require.Nil(t, memo)
|
|
attachmentUID := strings.TrimPrefix(image.Name, "attachments/")
|
|
attachment, getErr := ts.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
|
|
require.NoError(t, getErr)
|
|
require.NotNil(t, attachment)
|
|
require.Nil(t, attachment.MemoID)
|
|
}
|
|
|
|
func createTestImageAttachment(ctx context.Context, t *testing.T, ts *TestService, filename string) *v1pb.Attachment {
|
|
t.Helper()
|
|
attachment, err := ts.Service.CreateAttachment(ctx, &v1pb.CreateAttachmentRequest{Attachment: &v1pb.Attachment{
|
|
Filename: filename,
|
|
Type: "image/png",
|
|
Content: []byte("test image"),
|
|
}})
|
|
require.NoError(t, err)
|
|
return attachment
|
|
}
|