package images import ( "bytes" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitea.parodia.dev/drwily/petal/internal/auth" "gitea.parodia.dev/drwily/petal/internal/db" ) // a 1x1 transparent PNG. var pngBytes = []byte{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, } // another 1x1 PNG, differing in one pixel byte, so it hashes elsewhere. var otherPNG = append(append([]byte{}, pngBytes[:len(pngBytes)-8]...), 0x01, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44) // newStore returns a handler over a fresh directory and database, plus a router // per user: identical but for who the auth middleware says is calling. Two users // over one store is the situation that ownership exists to handle. func newStore(t *testing.T) (dir string, alice, bob http.Handler) { t.Helper() dir = t.TempDir() database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { database.Close() }) if _, err := database.Exec( `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`, "bob", "bob@petal.local", "Bob", ); err != nil { t.Fatalf("seed second user: %v", err) } h, err := New(dir, database.DB, db.LocalUserID) if err != nil { t.Fatalf("new store: %v", err) } mount := func(userID string) http.Handler { return auth.Middleware(auth.StaticResolver(userID))(h.Routes()) } return dir, mount(db.LocalUserID), mount("bob") } func uploadReq(t *testing.T, field string, data []byte) *http.Request { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreateFormFile(field, "x.png") if err != nil { t.Fatal(err) } fw.Write(data) mw.Close() req := httptest.NewRequest(http.MethodPost, "/", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) return req } // upload posts an image and returns its stored name. func upload(t *testing.T, h http.Handler, data []byte) string { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, uploadReq(t, "image", data)) if rec.Code != http.StatusOK { t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body) } var resp struct{ URL string } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") { t.Fatalf("unexpected url %q", resp.URL) } return strings.TrimPrefix(resp.URL, "/api/images/") } func get(t *testing.T, h http.Handler, name string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/"+name, nil)) return rec } func TestUploadAndServe(t *testing.T) { _, alice, _ := newStore(t) name := upload(t, alice, pngBytes) // The same content uploaded again dedupes to the same URL. if again := upload(t, alice, pngBytes); again != name { t.Fatalf("expected dedup to same name, got %q vs %q", again, name) } rec := get(t, alice, name) if rec.Code != http.StatusOK { t.Fatalf("serve code=%d", rec.Code) } if !bytes.Equal(rec.Body.Bytes(), pngBytes) { t.Fatal("served bytes differ from uploaded") } } // The point of the ownership table: a hash is not a capability. func TestImageIsolation(t *testing.T) { _, alice, bob := newStore(t) name := upload(t, alice, pngBytes) if rec := get(t, bob, name); rec.Code != http.StatusNotFound { t.Fatalf("bob fetched alice's image: code=%d", rec.Code) } // Nor can he delete it out from under her. rec := httptest.NewRecorder() bob.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil)) if rec.Code != http.StatusNotFound { t.Fatalf("bob deleted alice's image: code=%d", rec.Code) } if got := get(t, alice, name); got.Code != http.StatusOK { t.Fatalf("alice's image disappeared: code=%d", got.Code) } } // Deduplication has to survive ownership: one file, one row each. func TestDedupAcrossUsers(t *testing.T) { dir, alice, bob := newStore(t) name := upload(t, alice, pngBytes) if bobName := upload(t, bob, pngBytes); bobName != name { t.Fatalf("expected the same stored name, got %q vs %q", bobName, name) } entries, _ := os.ReadDir(dir) if len(entries) != 1 { t.Fatalf("expected 1 file on disk, found %d", len(entries)) } for _, h := range []http.Handler{alice, bob} { if rec := get(t, h, name); rec.Code != http.StatusOK { t.Fatalf("owner could not fetch shared image: code=%d", rec.Code) } } // Alice dropping her copy must not take Bob's picture away with it. rec := httptest.NewRecorder() alice.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil)) if rec.Code != http.StatusNoContent { t.Fatalf("delete code=%d", rec.Code) } if got := get(t, alice, name); got.Code != http.StatusNotFound { t.Fatalf("alice still sees a deleted image: code=%d", got.Code) } if got := get(t, bob, name); got.Code != http.StatusOK { t.Fatalf("bob lost his image when alice deleted hers: code=%d", got.Code) } if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Fatalf("file removed while still owned: %v", err) } // The last owner leaving takes the file with them. rec2 := httptest.NewRecorder() bob.ServeHTTP(rec2, httptest.NewRequest(http.MethodDelete, "/"+name, nil)) if rec2.Code != http.StatusNoContent { t.Fatalf("delete code=%d", rec2.Code) } if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { t.Fatalf("file survived its last owner: %v", err) } } // Images that predate ownership must not vanish from documents that use them. func TestBackfillClaimsExistingFiles(t *testing.T) { dir := t.TempDir() database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("open db: %v", err) } defer database.Close() orphan := "deadbeefdeadbeefdeadbeefdeadbeef.png" if err := os.WriteFile(filepath.Join(dir, orphan), pngBytes, 0o644); err != nil { t.Fatal(err) } h, err := New(dir, database.DB, db.LocalUserID) if err != nil { t.Fatalf("new store: %v", err) } alice := auth.Middleware(auth.StaticResolver(db.LocalUserID))(h.Routes()) if rec := get(t, alice, orphan); rec.Code != http.StatusOK { t.Fatalf("pre-existing image not claimed: code=%d", rec.Code) } // Re-running the backfill (i.e. a restart) must not double up or reassign. if _, err := New(dir, database.DB, "bob"); err != nil { t.Fatalf("second backfill: %v", err) } // And an owner who no longer exists — which is what the `local` account // becomes once it has been migrated onto a real one — must be skipped, not // turned into a foreign-key error that takes startup down with it. if _, err := New(dir, database.DB, "nobody-at-all"); err != nil { t.Fatalf("backfill for a missing owner should be a no-op, got: %v", err) } var owners int if err := database.QueryRow(`SELECT COUNT(*) FROM images WHERE name = ?`, orphan).Scan(&owners); err != nil { t.Fatal(err) } if owners != 1 { t.Fatalf("expected the backfill to be idempotent, got %d owners", owners) } } func TestUploadRejectsNonImage(t *testing.T) { _, alice, _ := newStore(t) rec := httptest.NewRecorder() alice.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all"))) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("expected 415, got %d", rec.Code) } } func TestServeMissing(t *testing.T) { _, alice, _ := newStore(t) if rec := get(t, alice, "deadbeef.png"); rec.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rec.Code) } }