package attach_test import ( "context" "database/sql" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "time" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" _ "github.com/jackc/pgx/v5/stdlib" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "gocloud.dev/blob" "gocloud.dev/blob/memblob" "gorm.io/gorm" ) type lifecycleOwner struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` } func (lifecycleOwner) TableName() string { return "attach_lifecycle_owners" } func (lifecycleOwner) MorphName() string { return `Golem15\Fonoteka\Models\Album` } func TestFileLifecycle(t *testing.T) { if testing.Short() { t.Skip("requires testcontainers postgres") } ctx := t.Context() gdb := attachGorm(t) if err := lagoon.Migrate(gdb, nil); err != nil { t.Fatal(err) } if err := gdb.Exec(` CREATE TABLE attach_lifecycle_owners ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, deleted_at TIMESTAMPTZ )`).Error; err != nil { t.Fatal(err) } bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) owner := lifecycleOwner{Name: "album"} if err := gdb.Create(&owner).Error; err != nil { t.Fatal(err) } isPublic := true file := attach.File{ DiskName: "abc123xyz.jpg", FileName: "cover.jpg", FileSize: 12, ContentType: "image/jpeg", Field: "photos", AttachmentID: strconv.FormatUint(uint64(owner.ID), 10), AttachmentType: owner.MorphName(), IsPublic: &isPublic, } if err := gdb.Create(&file).Error; err != nil { t.Fatal(err) } origKey := attach.BlobKey(file.DiskName) thumbKey := attach.PartitionDirectory(file.DiskName) + attach.ThumbFilename(file.ID, 200, 200, 0, 0, "crop", "jpg") if err := bucket.WriteAll(ctx, origKey, []byte("original"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { t.Fatal(err) } if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { t.Fatal(err) } if err := gdb.Delete(&owner).Error; err != nil { t.Fatal(err) } var softOwner lifecycleOwner if err := gdb.Unscoped().First(&softOwner, owner.ID).Error; err != nil { t.Fatal(err) } if !softOwner.DeletedAt.Valid { t.Fatal("soft-delete must set deleted_at") } assertFileRow(t, gdb, file.ID, true) assertBlob(t, ctx, bucket, origKey, true) assertBlob(t, ctx, bucket, thumbKey, true) if err := gdb.Transaction(func(tx *gorm.DB) error { return attach.DeleteForOwner(tx, owner, file.AttachmentID, func(keys []string) error { assertBlob(t, ctx, bucket, origKey, true) assertBlob(t, ctx, bucket, thumbKey, true) return fmt.Errorf("rollback after collecting keys") }) }); err == nil { t.Fatal("expected rollback") } assertFileRow(t, gdb, file.ID, true) assertBlob(t, ctx, bucket, origKey, true) assertBlob(t, ctx, bucket, thumbKey, true) var pending []string if err := gdb.Transaction(func(tx *gorm.DB) error { if err := tx.Unscoped().Delete(&owner).Error; err != nil { return err } return attach.DeleteForOwner(tx, owner, file.AttachmentID, func(keys []string) error { pending = append([]string(nil), keys...) var n int64 if err := tx.Model(&attach.File{}).Where("id = ?", file.ID).Count(&n).Error; err != nil { return err } if n != 0 { return fmt.Errorf("system_files row must be gone inside the force-delete transaction") } exists, err := bucket.Exists(ctx, origKey) if err != nil { return err } if !exists { return fmt.Errorf("blob must still exist before commit") } return nil }) }); err != nil { t.Fatal(err) } assertFileRow(t, gdb, file.ID, false) assertBlob(t, ctx, bucket, origKey, true) if err := attach.DeleteKeys(ctx, bucket, pending); err != nil { t.Fatal(err) } assertBlob(t, ctx, bucket, origKey, false) assertBlob(t, ctx, bucket, thumbKey, false) } func TestFileCreateDefaultsIsPublic(t *testing.T) { if testing.Short() { t.Skip("requires testcontainers postgres") } gdb := attachGorm(t) if err := lagoon.Migrate(gdb, nil); err != nil { t.Fatal(err) } file := attach.File{ DiskName: "abc123xyz.jpg", FileName: "cover.jpg", FileSize: 1, ContentType: "image/jpeg", } if err := gdb.Create(&file).Error; err != nil { t.Fatal(err) } var got attach.File if err := gdb.First(&got, file.ID).Error; err != nil { t.Fatal(err) } if !got.Public() { t.Fatalf("Create without IsPublic stored %v, want true", got.IsPublic) } priv := false hidden := attach.File{ DiskName: "def456uvw.jpg", FileName: "secret.jpg", FileSize: 1, ContentType: "image/jpeg", IsPublic: &priv, } if err := gdb.Create(&hidden).Error; err != nil { t.Fatal(err) } var gotHidden attach.File if err := gdb.First(&gotHidden, hidden.ID).Error; err != nil { t.Fatal(err) } if gotHidden.Public() { t.Fatal("explicit is_public=false must persist") } } func TestStaticHandlerPublicLooksUpIsPublic(t *testing.T) { if testing.Short() { t.Skip("requires testcontainers postgres") } ctx := t.Context() gdb := attachGorm(t) if err := lagoon.Migrate(gdb, nil); err != nil { t.Fatal(err) } bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) pub := true priv := false publicFile := attach.File{DiskName: "abc123xyz.jpg", FileName: "cover.jpg", FileSize: 1, ContentType: "image/jpeg", IsPublic: &pub} privateFile := attach.File{DiskName: "def456uvw.jpg", FileName: "secret.jpg", FileSize: 1, ContentType: "image/jpeg", IsPublic: &priv} if err := gdb.Create(&publicFile).Error; err != nil { t.Fatal(err) } if err := gdb.Create(&privateFile).Error; err != nil { t.Fatal(err) } for _, f := range []attach.File{publicFile, privateFile} { if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), []byte(f.FileName), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { t.Fatal(err) } thumbKey := attach.PartitionDirectory(f.DiskName) + attach.ThumbFilename(f.ID, 50, 50, 0, 0, "crop", "jpg") if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil { t.Fatal(err) } } h := attach.StaticHandlerPublic(bucket, "/storage/uploads", gdb) get := func(path string) int { t.Helper() rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) return rr.Code } if code := get("/storage/uploads/abc/123/xyz/abc123xyz.jpg"); code != http.StatusOK { t.Fatalf("public original status = %d, want 200", code) } if code := get("/storage/uploads/abc/123/xyz/" + attach.ThumbFilename(publicFile.ID, 50, 50, 0, 0, "crop", "jpg")); code != http.StatusOK { t.Fatalf("public thumb status = %d, want 200", code) } if code := get("/storage/uploads/def/456/uvw/def456uvw.jpg"); code != http.StatusNotFound { t.Fatalf("private original status = %d, want 404", code) } if code := get("/storage/uploads/def/456/uvw/" + attach.ThumbFilename(privateFile.ID, 50, 50, 0, 0, "crop", "jpg")); code != http.StatusNotFound { t.Fatalf("private thumb status = %d, want 404", code) } if code := get("/storage/uploads/abc/123/xyz/missing.jpg"); code != http.StatusNotFound { t.Fatalf("unknown disk_name status = %d, want 404", code) } } func assertFileRow(t *testing.T, gdb *gorm.DB, id uint, want bool) { t.Helper() var n int64 if err := gdb.Model(&attach.File{}).Where("id = ?", id).Count(&n).Error; err != nil { t.Fatal(err) } if got := n > 0; got != want { t.Fatalf("system_files id %d exists=%v, want %v", id, got, want) } } func assertBlob(t *testing.T, ctx context.Context, bucket *blob.Bucket, key string, want bool) { t.Helper() got, err := bucket.Exists(ctx, key) if err != nil { t.Fatal(err) } if got != want { t.Fatalf("blob %q exists=%v, want %v", key, got, want) } } func attachGorm(t *testing.T) *gorm.DB { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) ctr, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("attach"), postgres.WithUsername("attach"), postgres.WithPassword("attach"), postgres.BasicWaitStrategies(), testcontainers.WithEnv(map[string]string{ "POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8", }), ) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) }) dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatal(err) } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := db.PingContext(ctx); err != nil { t.Fatal(err) } gdb, err := lagoon.Use(ctx, db) if err != nil { t.Fatalf("lagoon.Use: %v", err) } return gdb }