- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
304 lines
8.8 KiB
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/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
|
|
}
|