Files
summercms/lagoon/attach/lifecycle_test.go

304 lines
8.8 KiB
Go

package attach_test
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/summercms/lagoon"
"git.golem15.com/golem15/summercms/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
}