package classes import ( "bytes" "context" "errors" "strconv" "strings" "testing" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gocloud.dev/blob/memblob" ) func TestMediaObjectPath(t *testing.T) { got := mediaObjectPath("../etc", "cover.png") if !strings.HasPrefix(got, "/journal/") { t.Fatalf("path = %q, want /journal/ prefix", got) } if strings.Contains(got, "..") { t.Fatalf("path still contains ..: %q", got) } if got := mediaObjectPath("photos", "a.png"); got != "/journal/photos/a.png" { t.Fatalf("nested = %q", got) } if got := mediaObjectPath("journal/photos", "a.png"); got != "/journal/photos/a.png" { t.Fatalf("prefixed folder = %q", got) } if got := mediaObjectPath("", "a.png"); got != "/journal/a.png" { t.Fatalf("no folder = %q", got) } } func TestMediaFolderPatternRejectsDotDot(t *testing.T) { folder := "../etc" if MediaFolderPattern.MatchString(folder) && !strings.Contains(folder, "..") { t.Fatal("folder ../etc should be rejected") } if !strings.Contains(folder, "..") { t.Fatal("test fixture lost ..") } if MediaFolderPattern.MatchString("ok_folder-1") { return } t.Fatal("ok_folder-1 should match") } // failingReader fails every read. type failingReader struct{} func (failingReader) Read([]byte) (int, error) { return 0, errors.New("disk on fire") } func TestStoreMedia(t *testing.T) { ctx := context.Background() bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) mediaErr := func(t *testing.T, err error, field, message string) { t.Helper() var refused *MediaError if !errors.As(err, &refused) || refused.Field != field || refused.Message != message || refused.Error() != message { t.Fatalf("error = %v, want MediaError on %s %q", err, field, message) } } t.Run("allowed extensions, key layout and URL", func(t *testing.T) { for _, tc := range []struct{ folder, name, path string }{ {"photos", "a.jpg", "/journal/photos/a.jpg"}, {"/journal/photos/", "b.JPEG", "/journal/photos/b.JPEG"}, {"", "c.png", "/journal/c.png"}, {"x//y", "d.gif", "/journal/x/y/d.gif"}, {"Journal", "e.webp", "/journal/e.webp"}, {"posts", `C:\uploads\f.png`, "/journal/posts/f.png"}, {"posts", "../../g.png", "/journal/posts/g.png"}, } { got, err := StoreMedia(ctx, bucket, tc.folder, tc.name, bytes.NewReader(testPNG)) if err != nil { t.Fatalf("%s/%s: %v", tc.folder, tc.name, err) } key := strings.TrimPrefix(tc.path, "/") if got.Path != tc.path || got.URL != attach.PublicURL(key) { t.Fatalf("%s/%s = %+v, want path %s", tc.folder, tc.name, got, tc.path) } data, err := bucket.ReadAll(ctx, key) if err != nil || !bytes.Equal(data, testPNG) { t.Fatalf("stored %s: %v", key, err) } } }) t.Run("a taken name gets the next numbered suffix", func(t *testing.T) { var paths []string for i := 0; i < 3; i++ { got, err := StoreMedia(ctx, bucket, "dup", "same.png", bytes.NewReader(testPNG)) if err != nil { t.Fatal(err) } paths = append(paths, got.Path) } if strings.Join(paths, " ") != "/journal/dup/same.png /journal/dup/same-1.png /journal/dup/same-2.png" { t.Fatalf("paths = %v", paths) } }) t.Run("a name with every suffix taken fails instead of overwriting", func(t *testing.T) { full := memblob.OpenBucket(nil) t.Cleanup(func() { _ = full.Close() }) keys := []string{"journal/full/a.png"} for n := 1; n < mediaMaxSuffix; n++ { keys = append(keys, "journal/full/a-"+strconv.Itoa(n)+".png") } for _, key := range keys { if err := full.WriteAll(ctx, key, []byte("old"), nil); err != nil { t.Fatal(err) } } _, err := StoreMedia(ctx, full, "full", "a.png", bytes.NewReader(testPNG)) if err == nil || errors.As(err, new(*MediaError)) { t.Fatalf("error = %v, want an opaque error", err) } for _, key := range keys { if data, _ := full.ReadAll(ctx, key); string(data) != "old" { t.Fatalf("%s was overwritten", key) } } }) t.Run("other extensions are refused", func(t *testing.T) { for _, name := range []string{"a.svg", "a.html", "a.bmp", "a", "a.png.exe", ".png.txt"} { _, err := StoreMedia(ctx, bucket, "", name, bytes.NewReader(testPNG)) mediaErr(t, err, "file", "The file must be an image.") } }) t.Run("the size limit is 10240 KiB", func(t *testing.T) { if MediaMaxBytes != 10240*1024 { t.Fatalf("MediaMaxBytes = %d", MediaMaxBytes) } exact := make([]byte, MediaMaxBytes) if _, err := StoreMedia(ctx, bucket, "size", "exact.png", bytes.NewReader(exact)); err != nil { t.Fatalf("exactly MediaMaxBytes: %v", err) } _, err := StoreMedia(ctx, bucket, "size", "over.png", bytes.NewReader(make([]byte, MediaMaxBytes+1))) mediaErr(t, err, "file", "The file may not be greater than 10240 kilobytes.") if ok, _ := bucket.Exists(ctx, "journal/size/over.png"); ok { t.Fatal("an oversized file was stored") } }) t.Run("the folder rule is checked", func(t *testing.T) { for _, folder := range []string{"../up", "a..b", "a b", `a\b`, strings.Repeat("a", 101), "a?b"} { _, err := StoreMedia(ctx, bucket, folder, "a.png", bytes.NewReader(testPNG)) mediaErr(t, err, "folder", "The folder format is invalid.") } }) t.Run("a missing bucket and a read failure are opaque errors", func(t *testing.T) { if _, err := StoreMedia(ctx, nil, "", "a.png", bytes.NewReader(testPNG)); err == nil || errors.As(err, new(*MediaError)) { t.Fatalf("nil bucket error = %v", err) } if _, err := StoreMedia(ctx, bucket, "", "a.png", failingReader{}); err == nil || errors.As(err, new(*MediaError)) || !strings.Contains(err.Error(), "disk on fire") { t.Fatalf("read failure error = %v", err) } }) }