memos/server/router/api/v1/test/memo_attachment_markdown_test.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("![first](/file/attachments/%s)\n![second](/file/attachments/%s/second.png)", 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: "![missing](http://localhost:8080/file/attachments/missing-image)",
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("![wrong type](/file/attachments/%s)", 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("![query](/file/attachments/%s?share_token=secret)", 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("![network path](//localhost:8080/file/attachments/%s)", 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: "![external](//cdn.example.com/file/attachments/external-image)",
}})
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("![absolute](http://localhost:80/file/attachments/%s)", 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("![oldest](/file/attachments/%s)", 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("![kept](/file/attachments/%s)", 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("![removed](/file/attachments/%s)", 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: "![missing](/file/attachments/missing-update-image)"},
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("![referenced](/file/attachments/%s)", 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
}