test(12.2-05): cover every Go behaviour of phase 12.2

- lagoon: Date and TimeOfDay through JSON, text and real DATE/TIME
  columns; Fill text fallback without changing earlier conversions;
  required on zero dates; deferred_bindings shape, store isolation and
  envelope; PurgeDeferred cut-off, after-commit blobs, SKIP LOCKED,
  skipped types and the deferred:purge command
- attach: Store limits, extensions, MIME patterns, default lists, key
  shape and blob cleanup; IsAllowedImage formats, polyglots, ceiling
- conga and pact: framework purge schedule entry and forged jobs; the
  six relation child hook interfaces
- cabana: fileupload and datepicker compile, upload, remove, caption,
  reorder and bounds; deferred commit order, rollback, applied-only and
  concurrent saves; relation contracts, forms, CRUD, deferral, schema
This commit is contained in:
Jakub Zych
2026-10-02 20:34:23 +02:00
parent 9c87a59532
commit c8d41a68c6
16 changed files with 3509 additions and 10 deletions

View File

@@ -0,0 +1,109 @@
package attach_test
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"testing"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
)
// guardWebP is a 16x12 lossless WebP.
var guardWebP = []byte{
0x52, 0x49, 0x46, 0x46, 0x2a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c,
0x1d, 0x00, 0x00, 0x00, 0x2f, 0x0f, 0xc0, 0x02, 0x00, 0x0f, 0x70, 0x14, 0xfb, 0x53, 0xd0, 0x5e,
0x88, 0x7b, 0xfe, 0x83, 0x07, 0x62, 0xc1, 0x64, 0xfe, 0xd2, 0xbd, 0x21, 0x44, 0xf4, 0x3f, 0x74,
0x01, 0x00,
}
func guardGIF(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := gif.Encode(&buf, image.NewPaletted(image.Rect(0, 0, 3, 2), []color.Color{color.Black, color.White}), nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func guardJPEG(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 3, 2)), nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// pngHeader is a PNG signature and a valid IHDR chunk for w x h, the part
// image.DecodeConfig reads, without pixel data.
func pngHeader(w, h uint32) []byte {
var ihdr bytes.Buffer
ihdr.WriteString("IHDR")
_ = binary.Write(&ihdr, binary.BigEndian, w)
_ = binary.Write(&ihdr, binary.BigEndian, h)
ihdr.Write([]byte{8, 0, 0, 0, 0}) // 8-bit grayscale, no interlace
var out bytes.Buffer
out.Write([]byte("\x89PNG\r\n\x1a\n"))
_ = binary.Write(&out, binary.BigEndian, uint32(13))
out.Write(ihdr.Bytes())
_ = binary.Write(&out, binary.BigEndian, crc32.ChecksumIEEE(ihdr.Bytes()))
return out.Bytes()
}
// TestIsAllowedImageAccepts the four formats the thumbnailer decodes, and
// an image exactly at the pixel ceiling.
func TestIsAllowedImageAccepts(t *testing.T) {
var p bytes.Buffer
if err := png.Encode(&p, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
for name, data := range map[string][]byte{
"png": p.Bytes(),
"gif": guardGIF(t),
"jpeg": guardJPEG(t),
"webp": guardWebP,
"at the ceiling": pngHeader(4096, 4096),
"one tall column": pngHeader(1, 4096*4096),
} {
if !attach.IsAllowedImage(data) {
t.Errorf("%s refused", name)
}
}
if attach.MaxImagePixels != 4096*4096 {
t.Fatalf("MaxImagePixels = %d", attach.MaxImagePixels)
}
}
// TestIsAllowedImageRefuses SVG, HTML, a GIF signature followed by script,
// a truncated PNG, empty input, an image over 4096x4096 pixels, a zero-size
// image and a BMP (a real image the thumbnailer does not decode).
func TestIsAllowedImageRefuses(t *testing.T) {
var p bytes.Buffer
if err := png.Encode(&p, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil {
t.Fatal(err)
}
bmp := append([]byte("BM"), make([]byte, 60)...)
for name, data := range map[string][]byte{
"svg": []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`),
"html": []byte("<!DOCTYPE html><html><script>alert(1)</script></html>"),
"gif polyglot": []byte("GIF89a<script>alert(document.domain)</script>"),
"png polyglot": append([]byte("\x89PNG\r\n\x1a\n"), []byte("<html><script>alert(1)</script>")...),
"truncated png": p.Bytes()[:20],
"empty": nil,
"over the ceiling": pngHeader(4097, 4096),
"huge": pngHeader(100000, 100000),
"zero width": pngHeader(0, 10),
"bmp": bmp,
"text": []byte("just text"),
} {
if attach.IsAllowedImage(data) {
t.Errorf("%s accepted", name)
}
}
}

View File

@@ -7,6 +7,7 @@ import (
"image"
"image/png"
"io"
"regexp"
"strings"
"testing"
@@ -124,3 +125,162 @@ func TestStoreSmokeRefusals(t *testing.T) {
t.Fatalf("blobs left behind: %v", keys)
}
}
// TestStore covers attach.Store against Postgres (D-07, D-08): a failed
// row insert deletes its blob, size limits at the boundary and while
// streaming, extension and MIME rules, the default lists per mode, the
// content type from the sniff or the extension, the disk name and blob key
// shape, sort_order and is_public. No refusal leaves a blob behind.
func TestStore(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
png := smokePNG(t)
t.Run("row failure deletes the blob", func(t *testing.T) {
// system_files does not exist yet: the insert fails after the blob
// was written, and Store deletes it again.
_, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "a.png", Body: bytes.NewReader(png)}, attach.Limits{Image: true})
if err == nil || !strings.Contains(err.Error(), "store row") {
t.Fatalf("err = %v", err)
}
if keys := bucketKeys(t, bucket); len(keys) != 0 {
t.Fatalf("blobs left %v", keys)
}
})
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
store := func(name string, body []byte, lim attach.Limits, public bool) (*attach.File, error) {
return attach.Store(ctx, gdb, bucket, attach.Upload{FileName: name, Body: bytes.NewReader(body), Public: public}, lim)
}
refuse := func(t *testing.T, what string, want error, name string, body []byte, lim attach.Limits) {
t.Helper()
before := len(bucketKeys(t, bucket))
f, err := store(name, body, lim, false)
if !errors.Is(err, want) {
t.Fatalf("%s: file=%v err=%v, want %v", what, f, err, want)
}
if after := len(bucketKeys(t, bucket)); after != before {
t.Fatalf("%s left a blob (%d -> %d)", what, before, after)
}
}
t.Run("size", func(t *testing.T) {
for _, n := range []int64{1, 64, 1 << 20, 1<<20 + 1, 3 << 20} {
body := bytes.Repeat([]byte("a"), int(n))
f, err := store("notes.txt", body, attach.Limits{MaxBytes: n}, false)
if err != nil || f.FileSize != n {
t.Fatalf("exactly %d bytes: %v %v", n, f, err)
}
refuse(t, "one byte over", attach.ErrTooLarge, "notes.txt", append(body, 'b'), attach.Limits{MaxBytes: n})
}
big := bytes.Repeat([]byte("z"), 3<<20)
if f, err := store("big.txt", big, attach.Limits{}, false); err != nil || f.FileSize != int64(len(big)) {
t.Fatalf("MaxBytes 0 means no limit: %v %v", f, err)
}
if _, err := store("neg.txt", []byte("x"), attach.Limits{MaxBytes: -1}, false); err == nil {
t.Fatal("a negative limit was accepted")
}
})
t.Run("extensions", func(t *testing.T) {
f, err := store(`..\..\Uploads/PHOTO.PNG`, png, attach.Limits{Image: true}, false)
if err != nil {
t.Fatal(err)
}
if f.FileName != "PHOTO.PNG" || !regexp.MustCompile(`^[0-9a-f]{22}\.png$`).MatchString(f.DiskName) {
t.Fatalf("name %q disk %q", f.FileName, f.DiskName)
}
key := attach.BlobKey(f.DiskName)
if strings.Contains(key, "Uploads") || strings.Contains(key, "..") || strings.Contains(key, "PHOTO") {
t.Fatalf("client path reached the key %q", key)
}
if ok, err := bucket.Exists(ctx, key); err != nil || !ok {
t.Fatalf("blob at %s: %v %v", key, ok, err)
}
if f, err := store("a.png", png, attach.Limits{Extensions: []string{" .PNG "}}, false); err != nil || !strings.HasSuffix(f.DiskName, ".png") {
t.Fatalf("normalised extension list: %v %v", f, err)
}
for _, name := range []string{"noext", "dot.", "a.toolongextension", "a.p-g", "a.p g", "a.ünï"} {
refuse(t, "name "+name, attach.ErrFileType, name, png, attach.Limits{Image: true})
}
refuse(t, "extension outside the list", attach.ErrFileType, "a.gif", png, attach.Limits{Extensions: []string{"png"}})
})
t.Run("default lists", func(t *testing.T) {
for _, ext := range []string{"svg", "js", "html", "css", "xml", "swf", "map", "less", "scss", "php"} {
refuse(t, "file mode ."+ext, attach.ErrFileType, "x."+ext, []byte("x"), attach.Limits{})
}
for _, ext := range []string{"pdf", "txt", "zip", "docx", "png"} {
if _, err := store("x."+ext, []byte("plain bytes"), attach.Limits{}, false); err != nil {
t.Fatalf("file mode .%s: %v", ext, err)
}
}
for _, ext := range []string{"pdf", "svg", "bmp", "avif"} {
refuse(t, "image mode ."+ext, attach.ErrFileType, "x."+ext, png, attach.Limits{Image: true})
}
refuse(t, "image mode non-image bytes", attach.ErrNotImage, "x.png", []byte("<html></html>"), attach.Limits{Image: true})
refuse(t, "image mode truncated", attach.ErrNotImage, "x.png", png[:16], attach.Limits{Image: true})
if f, err := store("x.webp", guardWebP, attach.Limits{Image: true}, false); err != nil || f.ContentType != "image/webp" {
t.Fatalf("webp: %v %v", f, err)
}
})
t.Run("content type and MIME patterns", func(t *testing.T) {
// The sniff wins over the extension.
if f, err := store("looks.txt", png, attach.Limits{Extensions: []string{"txt"}}, false); err != nil || f.ContentType != "image/png" {
t.Fatalf("sniffed type: %v %v", f, err)
}
// Bytes the sniff cannot place take the extension's type.
opaque := []byte{0x00, 0x01, 0x02, 0x03, 0xfe, 0xff}
if f, err := store("doc.pdf", opaque, attach.Limits{}, false); err != nil || f.ContentType != "application/pdf" {
t.Fatalf("extension fallback: %v %v", f, err)
}
if _, err := store("a.png", png, attach.Limits{MIMETypes: []string{"image/*"}}, false); err != nil {
t.Fatalf("image/*: %v", err)
}
if _, err := store("a.png", png, attach.Limits{MIMETypes: []string{"IMAGE/PNG"}}, false); err != nil {
t.Fatalf("case-insensitive pattern: %v", err)
}
if _, err := store("doc.pdf", opaque, attach.Limits{MIMETypes: []string{"pdf"}}, false); err != nil {
t.Fatalf("bare extension pattern: %v", err)
}
refuse(t, "text under image/*", attach.ErrMIMEType, "a.txt", []byte("hello"), attach.Limits{MIMETypes: []string{"image/*"}})
refuse(t, "png under text/plain", attach.ErrMIMEType, "a.png", png, attach.Limits{MIMETypes: []string{"text/plain", "", "jpg"}})
refuse(t, "png under */jpeg", attach.ErrMIMEType, "a.png", png, attach.Limits{MIMETypes: []string{"*/jpeg"}})
})
t.Run("row", func(t *testing.T) {
for _, public := range []bool{true, false} {
f, err := store("row.png", png, attach.Limits{Image: true}, public)
if err != nil {
t.Fatal(err)
}
var stored attach.File
if err := gdb.First(&stored, f.ID).Error; err != nil {
t.Fatal(err)
}
if stored.SortOrder != int(stored.ID) || stored.Public() != public || stored.AttachmentID != "" || stored.AttachmentType != "" || stored.Field != "" {
t.Fatalf("row %+v public=%v", stored, public)
}
}
})
t.Run("arguments", func(t *testing.T) {
if _, err := attach.Store(ctx, nil, bucket, attach.Upload{FileName: "a.png", Body: bytes.NewReader(png)}, attach.Limits{}); err == nil {
t.Fatal("nil db accepted")
}
if _, err := attach.Store(ctx, gdb, nil, attach.Upload{FileName: "a.png", Body: bytes.NewReader(png)}, attach.Limits{}); err == nil {
t.Fatal("nil bucket accepted")
}
if _, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "a.png"}, attach.Limits{}); err == nil {
t.Fatal("nil body accepted")
}
})
}

View File

@@ -135,3 +135,250 @@ func TestValidateRequiredZeroDateSmoke(t *testing.T) {
t.Fatalf("set date: %v %v", errs, err)
}
}
// TestDateParseAndConstructors covers the constructors and the strict
// YYYY-MM-DD parser.
func TestDateParseAndConstructors(t *testing.T) {
for _, bad := range []string{"", "2026-1-2", "2026/10/02", "02.10.2026", "2026-13-01", "2026-02-30", "2026-10-02 ", "2026-10-02T00:00:00Z", "next tuesday"} {
if d, err := ParseDate(bad); err == nil {
t.Errorf("ParseDate(%q) = %v, want an error", bad, d)
}
}
d := NewDate(2024, time.February, 29)
if d.String() != "2024-02-29" || d.IsZero() {
t.Fatalf("leap day %v", d)
}
if got := NewDate(2025, time.February, 29).String(); got != "2025-03-01" {
t.Fatalf("NewDate normalises a non-leap 29th to %s", got)
}
if !DateOf(time.Time{}).IsZero() {
t.Fatal("DateOf(zero time) must be the zero Date")
}
if got := d.Time(nil); !got.Equal(time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("Time(nil) = %v", got)
}
warsaw := time.FixedZone("CET", 3600)
if got := d.Time(warsaw); got.Location() != warsaw || got.Hour() != 0 || got.Day() != 29 {
t.Fatalf("Time(loc) = %v", got)
}
if !(Date{}).Time(time.UTC).IsZero() || (Date{}).String() != "" {
t.Fatal("zero Date must give the zero time and an empty string")
}
}
// TestDateTextAndJSON round-trips Date through JSON and text, including
// null and the empty string.
func TestDateTextAndJSON(t *testing.T) {
d := NewDate(2026, 10, 2)
text, err := d.MarshalText()
if err != nil || string(text) != "2026-10-02" {
t.Fatalf("MarshalText %q %v", text, err)
}
var back Date
if err := back.UnmarshalText(text); err != nil || back != d {
t.Fatalf("UnmarshalText %v %v", back, err)
}
if err := back.UnmarshalText([]byte(" ")); err != nil || !back.IsZero() {
t.Fatalf("empty text %v %v", back, err)
}
if err := back.UnmarshalText([]byte("2026-10-32")); err == nil {
t.Fatal("invalid text must fail")
}
for raw, wantZero := range map[string]bool{`null`: true, `""`: true, `"2026-10-02"`: false} {
var got Date
if err := json.Unmarshal([]byte(raw), &got); err != nil || got.IsZero() != wantZero {
t.Fatalf("UnmarshalJSON(%s) = %v %v", raw, got, err)
}
}
for _, raw := range []string{`20261002`, `true`, `"2026-10-02T10:00:00Z"`, `{}`} {
var got Date
if err := json.Unmarshal([]byte(raw), &got); err == nil {
t.Fatalf("UnmarshalJSON(%s) accepted %v", raw, got)
}
}
var ptr *Date
if err := json.Unmarshal([]byte(`"2026-10-02"`), &ptr); err != nil || ptr == nil || *ptr != d {
t.Fatalf("pointer unmarshal %v %v", ptr, err)
}
if err := json.Unmarshal([]byte(`null`), &ptr); err != nil || ptr != nil {
t.Fatalf("pointer null %v %v", ptr, err)
}
}
// TestDateScan covers every Scan source.
func TestDateScan(t *testing.T) {
var d Date
for src, want := range map[any]string{
"2026-10-02": "2026-10-02",
"2026-10-02T13:14:15Z": "2026-10-02",
"2026-10-02 13:14:15": "2026-10-02",
time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC): "2026-10-02",
// The calendar date is taken as the driver returned it: no zone
// conversion moves it to the previous or next day.
time.Date(2026, 10, 2, 23, 0, 0, 0, time.FixedZone("x", -10*3600)): "2026-10-02",
} {
if err := d.Scan(src); err != nil || d.String() != want {
t.Fatalf("Scan(%v) = %v %v, want %s", src, d, err, want)
}
}
if err := d.Scan([]byte("2026-10-03")); err != nil || d.String() != "2026-10-03" {
t.Fatalf("Scan([]byte) = %v %v", d, err)
}
for _, bad := range []any{"garbage", 42, 3.5, true} {
if err := d.Scan(bad); err == nil {
t.Fatalf("Scan(%v) must fail", bad)
}
}
var nilDate *Date
if err := nilDate.Scan("2026-10-02"); err == nil {
t.Fatal("Scan on a nil receiver must fail")
}
}
// TestTimeOfDayParseAndText covers the parser, the accessors and the text
// and JSON forms.
func TestTimeOfDayParseAndText(t *testing.T) {
for in, want := range map[string]string{
"00:00": "00:00:00",
"23:59": "23:59:00",
"23:59:59": "23:59:59",
"07:08:09.000001": "07:08:09",
} {
got, err := ParseTimeOfDay(in)
if err != nil || got.String() != want {
t.Fatalf("ParseTimeOfDay(%q) = %v %v, want %s", in, got, err, want)
}
}
for _, bad := range []string{"", "7", "24:00", "12:60", "12:00:60", "12:00:00.", "12:00:00.1a", "12:00.5", "noon", "12-00"} {
if got, err := ParseTimeOfDay(bad); err == nil {
t.Errorf("ParseTimeOfDay(%q) = %v, want an error", bad, got)
}
}
if got := NewTimeOfDay(25, 61, 0).String(); got != "02:01:00" {
t.Fatalf("NewTimeOfDay wraps to one day: %s", got)
}
tod := NewTimeOfDay(9, 5, 7)
if tod.Hour() != 9 || tod.Minute() != 5 || tod.Second() != 7 {
t.Fatalf("accessors %d %d %d", tod.Hour(), tod.Minute(), tod.Second())
}
text, err := tod.MarshalText()
if err != nil || string(text) != "09:05:07" {
t.Fatalf("MarshalText %q %v", text, err)
}
var back TimeOfDay
if err := back.UnmarshalText(text); err != nil || back != tod {
t.Fatalf("UnmarshalText %v %v", back, err)
}
if err := back.UnmarshalText(nil); err != nil || !back.IsZero() {
t.Fatalf("empty text %v %v", back, err)
}
if (TimeOfDay{}).String() != "" {
t.Fatal("zero TimeOfDay string")
}
for raw, wantZero := range map[string]bool{`null`: true, `""`: true, `"09:05"`: false} {
var got TimeOfDay
if err := json.Unmarshal([]byte(raw), &got); err != nil || got.IsZero() != wantZero {
t.Fatalf("UnmarshalJSON(%s) = %v %v", raw, got, err)
}
}
var bad TimeOfDay
if err := json.Unmarshal([]byte(`930`), &bad); err == nil {
t.Fatal("a JSON number must fail")
}
if err := json.Unmarshal([]byte(`"25:00"`), &bad); err == nil {
t.Fatal("an invalid JSON string must fail")
}
}
// TestTimeOfDayScan covers every Scan source.
func TestTimeOfDayScan(t *testing.T) {
var tod TimeOfDay
for src, want := range map[any]string{
"14:30": "14:30:00",
"14:30:15": "14:30:15",
time.Date(0, 1, 1, 6, 7, 8, 0, time.UTC): "06:07:08",
} {
if err := tod.Scan(src); err != nil || tod.String() != want {
t.Fatalf("Scan(%v) = %v %v", src, tod, err)
}
}
if err := tod.Scan([]byte("01:02:03")); err != nil || tod.String() != "01:02:03" {
t.Fatalf("Scan([]byte) = %v %v", tod, err)
}
if err := tod.Scan(nil); err != nil || !tod.IsZero() {
t.Fatalf("Scan(nil) = %v %v", tod, err)
}
for _, bad := range []any{"x", []byte("99:00"), 5, false} {
if err := tod.Scan(bad); err == nil {
t.Fatalf("Scan(%v) must fail", bad)
}
}
var nilTOD *TimeOfDay
if err := nilTOD.Scan("10:00"); err == nil {
t.Fatal("Scan on a nil receiver must fail")
}
if v, err := NewTimeOfDay(0, 0, 0).Value(); v != "00:00:00" || err != nil {
t.Fatalf("midnight Value = %v %v", v, err)
}
}
// dateRoundTrip spells the TIME columns as "time without time zone": GORM
// reads a bare `type:time` tag as its own time data type and AutoMigrate
// would create a timestamptz column.
type dateRoundTrip struct {
ID uint `gorm:"column:id;primaryKey"`
Day Date `gorm:"column:day;type:date"`
DayPtr *Date `gorm:"column:day_ptr;type:date"`
Clock TimeOfDay `gorm:"column:clock;type:time without time zone"`
ClockPtr *TimeOfDay `gorm:"column:clock_ptr;type:time without time zone"`
At *time.Time `gorm:"column:at"`
}
func (dateRoundTrip) TableName() string { return "lagoon_date_round_trip" }
// TestDatePostgresRoundTrip writes Date and TimeOfDay through real DATE and
// TIME columns: set values come back unchanged, the zero values store NULL
// and read back as zero, and nil pointers stay nil.
func TestDatePostgresRoundTrip(t *testing.T) {
db, _ := dedicatedDB(t, "lagoon_date_round_trip")
gdb, err := Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&dateRoundTrip{}); err != nil {
t.Fatal(err)
}
day := NewDate(2026, 10, 2)
clock := NewTimeOfDay(23, 59, 58)
at := time.Date(2026, 10, 2, 10, 30, 0, 0, time.FixedZone("CEST", 2*3600))
set := dateRoundTrip{Day: day, DayPtr: &day, Clock: clock, ClockPtr: &clock, At: &at}
zero := dateRoundTrip{}
if err := gdb.Create(&set).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Create(&zero).Error; err != nil {
t.Fatal(err)
}
var nulls int64
if err := gdb.Raw(`SELECT COUNT(*) FROM lagoon_date_round_trip WHERE id = ? AND day IS NULL AND day_ptr IS NULL AND clock IS NULL AND clock_ptr IS NULL`, zero.ID).Scan(&nulls).Error; err != nil || nulls != 1 {
t.Fatalf("zero values did not store NULL (%d, %v)", nulls, err)
}
var text string
if err := gdb.Raw(`SELECT day::text || ' ' || clock::text FROM lagoon_date_round_trip WHERE id = ?`, set.ID).Scan(&text).Error; err != nil || text != "2026-10-02 23:59:58" {
t.Fatalf("stored text %q (%v)", text, err)
}
var got dateRoundTrip
if err := gdb.First(&got, set.ID).Error; err != nil {
t.Fatal(err)
}
if got.Day != day || got.DayPtr == nil || *got.DayPtr != day || got.Clock != clock || got.ClockPtr == nil || *got.ClockPtr != clock || got.At == nil || !got.At.Equal(at) {
t.Fatalf("round trip = %+v", got)
}
var empty dateRoundTrip
if err := gdb.First(&empty, zero.ID).Error; err != nil {
t.Fatal(err)
}
if !empty.Day.IsZero() || empty.DayPtr != nil || !empty.Clock.IsZero() || empty.ClockPtr != nil || empty.At != nil {
t.Fatalf("NULL round trip = %+v", empty)
}
}

View File

@@ -3,9 +3,11 @@ package lagoon
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"strconv"
"strings"
"testing"
"time"
@@ -114,3 +116,356 @@ func TestDeferredUploadPurgeTracer(t *testing.T) {
t.Fatalf("bindings after cancel %d (%v)", count, err)
}
}
// deferredDB is a migrated dedicated database for the deferred-binding
// tests.
func deferredDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db, _ := dedicatedDB(t, name)
gdb, err := Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
return gdb
}
// TestDeferredMigrations checks the deferred_bindings shape (D-01): the
// WinterCMS columns plus a NOT NULL backend_user_id, the six indexes, the
// summercms.deferred history, and a rollback that drops the table.
func TestDeferredMigrations(t *testing.T) {
gdb := deferredDB(t, "lagoon_deferred_migrations")
type column struct {
Name string `gorm:"column:column_name"`
Type string `gorm:"column:data_type"`
Nullable string `gorm:"column:is_nullable"`
}
var cols []column
if err := gdb.Raw(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'deferred_bindings' ORDER BY ordinal_position`).Scan(&cols).Error; err != nil {
t.Fatal(err)
}
want := []column{
{"id", "integer", "NO"},
{"master_type", "text", "NO"},
{"master_field", "text", "NO"},
{"slave_type", "text", "NO"},
{"slave_id", "text", "NO"},
{"pivot_data", "text", "YES"},
{"session_key", "text", "NO"},
{"is_bind", "boolean", "NO"},
{"backend_user_id", "integer", "NO"},
{"created_at", "timestamp with time zone", "NO"},
{"updated_at", "timestamp with time zone", "NO"},
}
if fmt.Sprint(cols) != fmt.Sprint(want) {
t.Fatalf("columns\n got %v\nwant %v", cols, want)
}
var indexes []string
if err := gdb.Raw(`SELECT indexname FROM pg_indexes WHERE tablename = 'deferred_bindings' AND indexname <> 'deferred_bindings_pkey' ORDER BY indexname`).Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
wantIdx := []string{
"deferred_bindings_created_at_index",
"deferred_bindings_master_field_index",
"deferred_bindings_master_type_index",
"deferred_bindings_session_lookup_index",
"deferred_bindings_slave_id_index",
"deferred_bindings_slave_type_index",
}
if strings.Join(indexes, ",") != strings.Join(wantIdx, ",") {
t.Fatalf("indexes %v", indexes)
}
table, err := HistoryTableName(DeferredHistoryID)
if err != nil || table != "summer_migrations_summercms_deferred" {
t.Fatalf("history table %q (%v)", table, err)
}
var ids []string
if err := gdb.Raw(`SELECT id FROM ` + table).Scan(&ids).Error; err != nil || len(ids) != 1 || ids[0] != DeferredBindingMigrations[0].ID {
t.Fatalf("history ids %v (%v)", ids, err)
}
if err := gdb.Exec(`INSERT INTO deferred_bindings (master_type, master_field, slave_type, slave_id, session_key) VALUES ('a', 'b', 'c', '1', 'k')`).Error; err == nil {
t.Fatal("a binding without backend_user_id was accepted")
}
m, err := migrator(gdb, DeferredHistoryID, DeferredBindingMigrations)
if err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
if gdb.Migrator().HasTable("deferred_bindings") {
t.Fatal("rollback left deferred_bindings")
}
if err := Migrate(gdb, nil); err != nil || !gdb.Migrator().HasTable("deferred_bindings") {
t.Fatalf("re-migrate: %v", err)
}
}
// bindingRows lists every binding in id order.
func bindingRows(t *testing.T, gdb *gorm.DB) []DeferredBinding {
t.Helper()
var rows []DeferredBinding
if err := gdb.Order("id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// inTx runs fn in a lagoon transaction and fails the test on error.
func inTx(t *testing.T, gdb *gorm.DB, fn func(ctx context.Context, tx *gorm.DB) error) {
t.Helper()
if err := Transaction(t.Context(), gdb, fn); err != nil {
t.Fatal(err)
}
}
// TestDeferredStore covers the binding store (D-01, D-02, D-22): bind
// dedupe, unbind dedupe, bind/unbind and unbind/bind cancellation, admin and
// master type isolation, argument refusals, DeferredSlaves and
// DeferredForget, and the pivot_data envelope.
func TestDeferredStore(t *testing.T) {
gdb := deferredDB(t, "lagoon_deferred_store")
key := DeferredKey{SessionKey: "store-session-key-0123456789abcdef", AdminID: 3, MasterType: "acme_posts"}
t.Run("refusals", func(t *testing.T) {
ctx := t.Context()
for name, k := range map[string]DeferredKey{
"empty key": {SessionKey: " ", AdminID: 3, MasterType: "acme_posts"},
"zero admin": {SessionKey: key.SessionKey, MasterType: "acme_posts"},
"empty master type": {SessionKey: key.SessionKey, AdminID: 3},
} {
if err := DeferredBind(ctx, gdb, k, "tags", "acme_tags", "1", nil); err == nil {
t.Fatalf("bind with %s accepted", name)
}
if _, err := DeferredUnbind(ctx, gdb, k, "tags", "acme_tags", "1"); err == nil {
t.Fatalf("unbind with %s accepted", name)
}
if _, err := DeferredBindings(ctx, gdb, k, []string{"tags"}); err == nil {
t.Fatalf("read with %s accepted", name)
}
}
for name, args := range map[string][3]string{"field": {"", "acme_tags", "1"}, "slave type": {"tags", "", "1"}, "slave id": {"tags", "acme_tags", ""}} {
if err := DeferredBind(ctx, gdb, key, args[0], args[1], args[2], nil); err == nil {
t.Fatalf("bind with an empty %s accepted", name)
}
}
if err := DeferredBind(ctx, nil, key, "tags", "acme_tags", "1", nil); err == nil {
t.Fatal("bind with a nil db accepted")
}
if err := DeferredForget(ctx, nil, []uint{1}); err == nil {
t.Fatal("forget with a nil db accepted")
}
if rows, err := DeferredBindings(ctx, gdb, key, nil); err != nil || rows != nil {
t.Fatalf("no fields = %v %v", rows, err)
}
if len(bindingRows(t, gdb)) != 0 {
t.Fatal("a refused call wrote a binding")
}
})
t.Run("dedupe and cancel", func(t *testing.T) {
inTx(t, gdb, func(ctx context.Context, tx *gorm.DB) error {
for i := 0; i < 2; i++ {
if err := DeferredBind(ctx, tx, key, "tags", "acme_tags", "1", nil); err != nil {
return err
}
if _, err := DeferredUnbind(ctx, tx, key, "tags", "acme_tags", "2"); err != nil {
return err
}
}
return nil
})
rows := bindingRows(t, gdb)
if len(rows) != 2 || !rows[0].IsBind || rows[0].SlaveID != "1" || rows[1].IsBind || rows[1].SlaveID != "2" {
t.Fatalf("dedupe rows %+v", rows)
}
// unbind of a pending bind cancels it and returns it; bind of a
// pending unbind cancels the unbind and writes nothing.
var cancelled *DeferredBinding
inTx(t, gdb, func(ctx context.Context, tx *gorm.DB) error {
var err error
if cancelled, err = DeferredUnbind(ctx, tx, key, "tags", "acme_tags", "1"); err != nil {
return err
}
return DeferredBind(ctx, tx, key, "tags", "acme_tags", "2", nil)
})
if cancelled == nil || cancelled.SlaveID != "1" || !cancelled.IsBind {
t.Fatalf("cancelled %+v", cancelled)
}
if rows := bindingRows(t, gdb); len(rows) != 0 {
t.Fatalf("rows after cancelling both pairs %+v", rows)
}
})
t.Run("isolation", func(t *testing.T) {
otherAdmin := key
otherAdmin.AdminID = 4
otherMaster := key
otherMaster.MasterType = "acme_pages"
inTx(t, gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := DeferredBind(ctx, tx, key, "tags", "acme_tags", "7", nil); err != nil {
return err
}
// The same slave under another admin or master type is a
// separate binding, and does not cancel the first.
if _, err := DeferredUnbind(ctx, tx, otherAdmin, "tags", "acme_tags", "7"); err != nil {
return err
}
return DeferredBind(ctx, tx, otherMaster, "tags", "acme_tags", "8", nil)
})
inTx(t, gdb, func(ctx context.Context, tx *gorm.DB) error {
for k, wantIDs := range map[DeferredKey]string{key: "7", otherAdmin: "7", otherMaster: "8"} {
rows, err := DeferredBindings(ctx, tx, k, []string{"tags", "other"})
if err != nil {
return err
}
if len(rows) != 1 || rows[0].SlaveID != wantIDs || rows[0].BackendUserID != k.AdminID || rows[0].MasterType != k.MasterType {
t.Errorf("%+v sees %+v", k, rows)
}
}
var ids []string
if err := DeferredSlaves(tx, key, "tags", "acme_tags", true).Scan(&ids).Error; err != nil || fmt.Sprint(ids) != "[7]" {
t.Errorf("bind slaves %v %v", ids, err)
}
ids = nil
if err := DeferredSlaves(tx, otherAdmin, "tags", "acme_tags", false).Scan(&ids).Error; err != nil || fmt.Sprint(ids) != "[7]" {
t.Errorf("unbind slaves %v %v", ids, err)
}
var none []string
if err := DeferredSlaves(tx, otherAdmin, "tags", "acme_tags", true).Scan(&none).Error; err != nil || len(none) != 0 {
t.Errorf("other admin's bind slaves %v %v", none, err)
}
return nil
})
rows := bindingRows(t, gdb)
if len(rows) != 3 {
t.Fatalf("rows %+v", rows)
}
inTx(t, gdb, func(ctx context.Context, tx *gorm.DB) error {
return DeferredForget(ctx, tx, []uint{rows[0].ID})
})
if left := bindingRows(t, gdb); len(left) != 2 || left[0].ID != rows[1].ID {
t.Fatalf("forget deleted more than its id: %+v", left)
}
if err := gdb.Exec(`DELETE FROM deferred_bindings`).Error; err != nil {
t.Fatal(err)
}
})
t.Run("envelope", func(t *testing.T) {
inTx(t, gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := DeferredBind(ctx, tx, key, "parts", "acme_parts", "1", &DeferredEnvelope{Created: true}); err != nil {
return err
}
if err := DeferredBind(ctx, tx, key, "members", "acme_members", "2", &DeferredEnvelope{Pivot: map[string]any{"note": "hi"}}); err != nil {
return err
}
// An empty envelope stores no pivot_data.
return DeferredBind(ctx, tx, key, "members", "acme_members", "3", &DeferredEnvelope{})
})
rows := bindingRows(t, gdb)
if len(rows) != 3 || rows[0].PivotData == nil || *rows[0].PivotData != `{"created":true}` ||
rows[1].PivotData == nil || *rows[1].PivotData != `{"pivot":{"note":"hi"}}` || rows[2].PivotData != nil {
t.Fatalf("pivot_data %+v", rows)
}
for i, want := range []DeferredEnvelope{{Created: true}, {Pivot: map[string]any{"note": "hi"}}, {}} {
env, err := rows[i].Envelope()
if err != nil || fmt.Sprint(env) != fmt.Sprint(want) {
t.Fatalf("envelope %d = %+v %v", i, env, err)
}
}
bad := "{not json"
if _, err := (DeferredBinding{ID: 9, PivotData: &bad}).Envelope(); err == nil {
t.Fatal("invalid pivot_data decoded")
}
blank := " "
if env, err := (DeferredBinding{PivotData: &blank}).Envelope(); err != nil || env.Created || env.Pivot != nil {
t.Fatalf("blank pivot_data = %+v %v", env, err)
}
// A foreign JSON shape is a plain link.
winter := `{"sort_order":3}`
if env, err := (DeferredBinding{PivotData: &winter}).Envelope(); err != nil || env.Created || env.Pivot != nil {
t.Fatalf("winter pivot_data = %+v %v", env, err)
}
})
}
// morphOwner implements attach.Owner.
type morphOwner struct{ name string }
func (o morphOwner) MorphName() string { return o.name }
type morphTable struct {
ID uint `gorm:"column:id;primaryKey"`
}
func (morphTable) TableName() string { return "acme_morph_rows" }
// TestDeferredMorphType: MorphName wins, else the GORM table name; empty
// and nil are errors.
func TestDeferredMorphType(t *testing.T) {
gdb := deferredDB(t, "lagoon_deferred_morph")
if got, err := MorphType(gdb, morphOwner{name: "acme.blog.post"}); err != nil || got != "acme.blog.post" {
t.Fatalf("owner morph %q %v", got, err)
}
if got, err := MorphType(gdb, &morphTable{}); err != nil || got != "acme_morph_rows" {
t.Fatalf("table morph %q %v", got, err)
}
for name, model := range map[string]any{"nil": nil, "empty morph name": morphOwner{name: " "}} {
if _, err := MorphType(gdb, model); err == nil {
t.Fatalf("%s: no error", name)
}
}
if _, err := MorphType(nil, &morphTable{}); err == nil {
t.Fatal("nil db: no error")
}
}
// TestDeferredConcurrentFirstBind documents a gap WinterCMS shares: two
// transactions binding the same slave for the first time can both insert,
// because nothing makes (key, field, slave) unique. The duplicate is
// harmless: a save applies both rows idempotently and deletes both. No
// unique index is added without a decision (12.2-01 open item).
func TestDeferredConcurrentFirstBind(t *testing.T) {
gdb := deferredDB(t, "lagoon_deferred_concurrent")
key := DeferredKey{SessionKey: "race-session-key-0123456789abcdef", AdminID: 5, MasterType: "acme_posts"}
ctx := t.Context()
tx1 := gdb.Begin()
tx2 := gdb.Begin()
defer tx1.Rollback()
defer tx2.Rollback()
if err := DeferredBind(ctx, tx1, key, "tags", "acme_tags", "1", nil); err != nil {
t.Fatal(err)
}
// tx2 cannot see tx1's uncommitted row, so it inserts its own.
if err := DeferredBind(ctx, tx2, key, "tags", "acme_tags", "1", nil); err != nil {
t.Fatal(err)
}
if err := tx1.Commit().Error; err != nil {
t.Fatal(err)
}
if err := tx2.Commit().Error; err != nil {
t.Fatal(err)
}
rows := bindingRows(t, gdb)
if len(rows) != 2 || rows[0].SlaveID != "1" || rows[1].SlaveID != "1" {
t.Fatalf("concurrent first binds = %+v", rows)
}
// Reading them for a save returns both, so the commit sees and forgets
// both.
inTx(t, gdb, func(ctx context.Context, tx *gorm.DB) error {
got, err := DeferredBindings(ctx, tx, key, []string{"tags"})
if err != nil {
return err
}
if len(got) != 2 {
t.Errorf("bindings read for a save = %d", len(got))
}
return DeferredForget(ctx, tx, []uint{got[0].ID, got[1].ID})
})
if rows := bindingRows(t, gdb); len(rows) != 0 {
t.Fatalf("rows after forget %+v", rows)
}
}

View File

@@ -2,11 +2,15 @@ package lagoon
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"strings"
"testing"
"time"
)
type fillFixture struct {
@@ -229,3 +233,145 @@ func TestFillTypeErrorNamesTheKey(t *testing.T) {
t.Fatalf("non-pointer model: err = %v, want a plain error", err)
}
}
// fillDual implements both sql.Scanner and encoding.TextUnmarshaler with
// parsers that disagree; Fill must keep filling it through Scan, as it did
// before the text fallback existed.
type fillDual struct{ via string }
func (d *fillDual) Scan(src any) error {
d.via = fmt.Sprintf("scan:%s", src)
return nil
}
func (d *fillDual) UnmarshalText(b []byte) error {
d.via = "text:" + string(b)
return nil
}
// fillTextOnly implements only encoding.TextUnmarshaler.
type fillTextOnly struct{ got string }
func (d *fillTextOnly) UnmarshalText(b []byte) error {
if string(b) == "bad" {
return errors.New("bad text")
}
d.got = string(b)
return nil
}
// TestFillTextDateTypes fills every date type and pointer variant from
// strings: RFC 3339 with an offset keeps the instant, an empty string gives
// the zero Date/TimeOfDay, and an unparsable string is a FillTypeError on
// its key.
func TestFillTextDateTypes(t *testing.T) {
allowed := []string{"at", "at_ptr", "day", "day_ptr", "clock", "clock_ptr"}
var m fillDates
err := Fill(&m, allowed, map[string]any{
"at": "2026-10-02T14:30:00+02:00",
"at_ptr": []byte("2026-10-02T12:30:00Z"),
"day": []byte("2026-10-02"),
"day_ptr": "2026-02-28",
"clock": "00:00",
"clock_ptr": "23:59:59",
}, true)
if err != nil {
t.Fatal(err)
}
instant := time.Date(2026, 10, 2, 12, 30, 0, 0, time.UTC)
if !m.At.Equal(instant) || m.AtPtr == nil || !m.AtPtr.Equal(instant) {
t.Fatalf("instants %v %v", m.At, m.AtPtr)
}
if _, offset := m.At.Zone(); offset != 2*3600 {
t.Fatalf("offset %d, want the request's +02:00 kept on the value", offset)
}
if m.Day.String() != "2026-10-02" || m.DayPtr.String() != "2026-02-28" || m.Clock.String() != "00:00:00" || m.Clock.IsZero() || m.ClkPtr.String() != "23:59:59" {
t.Fatalf("dates %+v", m)
}
if err := Fill(&m, allowed, map[string]any{"day": "", "clock": "", "day_ptr": nil, "clock_ptr": nil, "at_ptr": nil}, true); err != nil {
t.Fatal(err)
}
if !m.Day.IsZero() || !m.Clock.IsZero() || m.DayPtr != nil || m.ClkPtr != nil || m.AtPtr != nil {
t.Fatalf("cleared %+v", m)
}
for key, value := range map[string]any{
"at": "2026-10-02",
"at_ptr": "yesterday",
"day": "02/10/2026",
"day_ptr": "2026-13-01",
"clock": "25:00",
"clock_ptr": "noon",
} {
var row fillDates
err := Fill(&row, allowed, map[string]any{key: value}, true)
var typed *FillTypeError
if !errors.As(err, &typed) || typed.Key != key {
t.Fatalf("%s = %v: err = %v, want a FillTypeError on %s", key, value, err, key)
}
}
// A plain Date also takes the date part of a timestamp: after the text
// parser refuses it, Fill falls back to Date.Scan, which accepts the
// driver's "YYYY-MM-DDT..." text form.
var lenient fillDates
if err := Fill(&lenient, allowed, map[string]any{"day": "2026-10-02T23:30:00Z"}, true); err != nil || lenient.Day.String() != "2026-10-02" {
t.Fatalf("timestamp into Date = %v %v", lenient.Day, err)
}
}
// TestFillTextKeepsEarlierConversions pins conversions that worked before
// the text fallback: assignable values, convertible kinds, json.Number,
// Scanner-only types, and a type that implements both Scanner and
// TextUnmarshaler still filling through Scan.
func TestFillTextKeepsEarlierConversions(t *testing.T) {
type row struct {
Name string `gorm:"column:name"`
Bytes []byte `gorm:"column:bytes"`
Count int `gorm:"column:count"`
Null sql.NullString `gorm:"column:null"`
Dual fillDual `gorm:"column:dual"`
DualPtr *fillDual `gorm:"column:dual_ptr"`
IP net.IP `gorm:"column:ip"`
At time.Time `gorm:"column:at"`
TextOnly fillTextOnly `gorm:"column:text_only"`
}
allowed := []string{"name", "bytes", "count", "null", "dual", "dual_ptr", "ip", "at", "text_only"}
at := time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)
var r row
err := Fill(&r, allowed, map[string]any{
"name": "plain",
"bytes": "raw",
"count": json.Number("7"),
"null": "scanned",
"dual": "x",
"ip": "10.0.0.1",
"at": at,
"text_only": "fallback",
}, true)
if err != nil {
t.Fatal(err)
}
if r.Name != "plain" || string(r.Bytes) != "raw" || r.Count != 7 || !r.Null.Valid || r.Null.String != "scanned" || !r.At.Equal(at) {
t.Fatalf("plain conversions %+v", r)
}
if r.Dual.via != "scan:x" {
t.Fatalf("Scanner+TextUnmarshaler filled through %q, want scan", r.Dual.via)
}
// A string converts to net.IP's []byte kind before any text parsing,
// exactly as before the fallback.
if string(r.IP) != "10.0.0.1" {
t.Fatalf("net.IP = %v", []byte(r.IP))
}
if r.TextOnly.got != "fallback" {
t.Fatalf("TextUnmarshaler-only type = %+v", r.TextOnly)
}
var typed *FillTypeError
if err := Fill(&r, allowed, map[string]any{"text_only": "bad"}, true); !errors.As(err, &typed) || typed.Key != "text_only" {
t.Fatalf("bad text = %v", err)
}
if err := Fill(&r, allowed, map[string]any{"text_only": 5}, true); !errors.As(err, &typed) {
t.Fatalf("a number into a text type = %v, want a FillTypeError", err)
}
}

View File

@@ -0,0 +1,503 @@
package lagoon
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// purgeChild is a model created under deferral: soft-deletable, with a
// delete hook that records its calls.
type purgeChild struct {
ID uint `gorm:"column:id;primaryKey"`
PostID *uint `gorm:"column:post_id"`
Label string `gorm:"column:label"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (purgeChild) TableName() string { return "acme_purge_children" }
var (
purgeHookMu sync.Mutex
purgeHookCalls []uint
)
func (c *purgeChild) BeforeDelete(*gorm.DB) error {
purgeHookMu.Lock()
defer purgeHookMu.Unlock()
purgeHookCalls = append(purgeHookCalls, c.ID)
return nil
}
// purgeEnv is a migrated database with the child table and a bucket.
type purgeEnv struct {
gdb *gorm.DB
dsn string
bucket *blob.Bucket
key DeferredKey
}
func newPurgeEnv(t *testing.T, name string) *purgeEnv {
t.Helper()
db, dsn := dedicatedDB(t, name)
gdb, err := Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&purgeChild{}); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
return &purgeEnv{gdb: gdb, dsn: dsn, bucket: bucket, key: DeferredKey{SessionKey: "purge-session-key-0123456789abcdef", AdminID: 1, MasterType: "acme_posts"}}
}
// bind writes a binding through DeferredBind and backdates it by age.
func (e *purgeEnv) bind(t *testing.T, field, slaveType, slaveID string, env *DeferredEnvelope, age time.Duration) uint {
t.Helper()
inTx(t, e.gdb, func(ctx context.Context, tx *gorm.DB) error {
return DeferredBind(ctx, tx, e.key, field, slaveType, slaveID, env)
})
var row DeferredBinding
if err := e.gdb.Where("master_field = ? AND slave_type = ? AND slave_id = ?", field, slaveType, slaveID).Order("id DESC").Take(&row).Error; err != nil {
t.Fatal(err)
}
if err := e.gdb.Exec(`UPDATE deferred_bindings SET created_at = ? WHERE id = ?`, time.Now().Add(-age), row.ID).Error; err != nil {
t.Fatal(err)
}
return row.ID
}
// unbind writes an unbind and backdates it by age.
func (e *purgeEnv) unbind(t *testing.T, field, slaveType, slaveID string, age time.Duration) {
t.Helper()
inTx(t, e.gdb, func(ctx context.Context, tx *gorm.DB) error {
_, err := DeferredUnbind(ctx, tx, e.key, field, slaveType, slaveID)
return err
})
if err := e.gdb.Exec(`UPDATE deferred_bindings SET created_at = ? WHERE slave_id = ? AND NOT is_bind`, time.Now().Add(-age), slaveID).Error; err != nil {
t.Fatal(err)
}
}
// file stores a small PNG; attachedTo, when set, attaches it.
func (e *purgeEnv) file(t *testing.T, attachedTo string) attach.File {
t.Helper()
f, err := attach.Store(t.Context(), e.gdb, e.bucket, attach.Upload{FileName: "x.png", Body: bytes.NewReader(purgePNG)}, attach.Limits{Image: true})
if err != nil {
t.Fatal(err)
}
if attachedTo != "" {
if err := e.gdb.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{"attachment_type": "acme_posts", "attachment_id": attachedTo, "field": "cover"}).Error; err != nil {
t.Fatal(err)
}
}
return *f
}
func (e *purgeEnv) child(t *testing.T, label string) uint {
t.Helper()
c := purgeChild{Label: label}
if err := e.gdb.Create(&c).Error; err != nil {
t.Fatal(err)
}
return c.ID
}
func (e *purgeEnv) blobExists(t *testing.T, f attach.File) bool {
t.Helper()
ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(f.DiskName))
if err != nil {
t.Fatal(err)
}
return ok
}
func (e *purgeEnv) fileExists(t *testing.T, id uint) bool {
t.Helper()
var n int64
if err := e.gdb.Model(&attach.File{}).Where("id = ?", id).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n == 1
}
func (e *purgeEnv) bindingIDs(t *testing.T) []uint {
t.Helper()
var ids []uint
if err := e.gdb.Model(&DeferredBinding{}).Order("id").Pluck("id", &ids).Error; err != nil {
t.Fatal(err)
}
return ids
}
func (e *purgeEnv) models() func(string) (any, bool) {
return func(slaveType string) (any, bool) {
if slaveType == "acme_purge_children" {
return &purgeChild{}, true
}
return nil, false
}
}
var purgePNG = func() []byte {
// The smallest valid 1x1 PNG.
return []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, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
}()
const day = 24 * time.Hour
// TestPurgeDeferredRules: only bindings older than the cut-off go; an
// unattached pending upload loses its row and blob, an attached file is
// kept, a created child is deleted through its model (hook and soft
// delete), a linked-only record and the slave of an unbind are kept, and an
// unresolvable created child is skipped with its binding left in place.
func TestPurgeDeferredRules(t *testing.T) {
e := newPurgeEnv(t, "lagoon_purge_rules")
pending := e.file(t, "")
attached := e.file(t, "9")
fresh := e.file(t, "")
created := e.child(t, "created")
linked := e.child(t, "linked")
e.bind(t, "cover", DeferredFileType, fmt.Sprint(pending.ID), nil, 6*day)
e.bind(t, "cover", DeferredFileType, fmt.Sprint(attached.ID), nil, 6*day)
freshID := e.bind(t, "cover", DeferredFileType, fmt.Sprint(fresh.ID), nil, 4*day)
e.bind(t, "children", "acme_purge_children", fmt.Sprint(created), &DeferredEnvelope{Created: true}, 6*day)
e.bind(t, "children", "acme_purge_children", fmt.Sprint(linked), nil, 6*day)
e.unbind(t, "children", "acme_purge_children", "77", 6*day)
skippedID := e.bind(t, "widgets", "acme_unknown", "5", &DeferredEnvelope{Created: true}, 6*day)
e.bind(t, "cover", DeferredFileType, "not-a-number", nil, 6*day)
purgeHookMu.Lock()
purgeHookCalls = nil
purgeHookMu.Unlock()
res, err := PurgeDeferred(t.Context(), e.gdb, e.bucket, PurgeOptions{Before: time.Now().Add(-5 * day), Models: e.models()})
if err != nil {
t.Fatal(err)
}
if res.Bindings != 6 || res.Files != 1 || res.Children != 1 || res.Skipped != 1 {
t.Fatalf("result %+v", res)
}
if ids := e.bindingIDs(t); fmt.Sprint(ids) != fmt.Sprint([]uint{freshID, skippedID}) {
t.Fatalf("bindings left %v, want the fresh and the skipped one", ids)
}
if e.fileExists(t, pending.ID) || e.blobExists(t, pending) {
t.Fatal("the pending upload's row or blob is left")
}
if !e.fileExists(t, attached.ID) || !e.blobExists(t, attached) {
t.Fatal("an attached file was purged")
}
if !e.fileExists(t, fresh.ID) || !e.blobExists(t, fresh) {
t.Fatal("a binding newer than the cut-off was purged")
}
var c purgeChild
if err := e.gdb.Unscoped().First(&c, created).Error; err != nil || !c.DeletedAt.Valid {
t.Fatalf("created child %+v (%v), want soft deleted", c, err)
}
purgeHookMu.Lock()
calls := fmt.Sprint(purgeHookCalls)
purgeHookMu.Unlock()
if calls != fmt.Sprint([]uint{created}) {
t.Fatalf("model hook calls %s, want [%d]", calls, created)
}
var kept purgeChild
if err := e.gdb.First(&kept, linked).Error; err != nil {
t.Fatalf("linked-only child purged: %v", err)
}
// A second run finds only the skipped binding again.
res, err = PurgeDeferred(t.Context(), e.gdb, e.bucket, PurgeOptions{Before: time.Now().Add(-5 * day), Models: e.models()})
if err != nil || res.Bindings != 0 || res.Skipped != 1 {
t.Fatalf("second run %+v %v", res, err)
}
}
// TestPurgeDeferredCutoff: created_at equal to the cut-off is not expired,
// a moment earlier is; a zero cut-off and a nil db are errors.
func TestPurgeDeferredCutoff(t *testing.T) {
e := newPurgeEnv(t, "lagoon_purge_cutoff")
cut := time.Now().Add(-5 * day).Truncate(time.Second)
at := e.bind(t, "children", "acme_purge_children", "1", nil, 0)
before := e.bind(t, "children", "acme_purge_children", "2", nil, 0)
if err := e.gdb.Exec(`UPDATE deferred_bindings SET created_at = ? WHERE id = ?`, cut, at).Error; err != nil {
t.Fatal(err)
}
if err := e.gdb.Exec(`UPDATE deferred_bindings SET created_at = ? WHERE id = ?`, cut.Add(-time.Millisecond), before).Error; err != nil {
t.Fatal(err)
}
res, err := PurgeDeferred(t.Context(), e.gdb, e.bucket, PurgeOptions{Before: cut})
if err != nil || res.Bindings != 1 {
t.Fatalf("result %+v %v", res, err)
}
if ids := e.bindingIDs(t); fmt.Sprint(ids) != fmt.Sprint([]uint{at}) {
t.Fatalf("left %v, want only the binding at the cut-off", ids)
}
if _, err := PurgeDeferred(t.Context(), e.gdb, e.bucket, PurgeOptions{}); err == nil {
t.Fatal("a zero cut-off was accepted")
}
if _, err := PurgeDeferred(t.Context(), nil, e.bucket, PurgeOptions{Before: cut}); err == nil {
t.Fatal("a nil db was accepted")
}
}
// TestPurgeDeferredBlobsAfterCommit: inside an outer transaction the purge
// deletes rows but leaves blobs until the commit; a rolled-back purge
// keeps rows, bindings and blobs.
func TestPurgeDeferredBlobsAfterCommit(t *testing.T) {
e := newPurgeEnv(t, "lagoon_purge_after_commit")
f := e.file(t, "")
e.bind(t, "cover", DeferredFileType, fmt.Sprint(f.ID), nil, 6*day)
opts := PurgeOptions{Before: time.Now().Add(-5 * day)}
rollback := errors.New("roll back")
err := Transaction(t.Context(), e.gdb, func(ctx context.Context, tx *gorm.DB) error {
res, err := PurgeDeferred(ctx, tx, e.bucket, opts)
if err != nil {
return err
}
if res.Files != 1 {
t.Errorf("result inside the transaction %+v", res)
}
if !e.blobExists(t, f) {
t.Error("the blob was deleted before the commit")
}
return rollback
})
if !errors.Is(err, rollback) {
t.Fatalf("err = %v", err)
}
if !e.fileExists(t, f.ID) || !e.blobExists(t, f) || len(e.bindingIDs(t)) != 1 {
t.Fatal("a rolled-back purge lost the row, the blob or the binding")
}
err = Transaction(t.Context(), e.gdb, func(ctx context.Context, tx *gorm.DB) error {
if _, err := PurgeDeferred(ctx, tx, e.bucket, opts); err != nil {
return err
}
if !e.blobExists(t, f) {
t.Error("the blob was deleted before the commit")
}
return nil
})
if err != nil {
t.Fatal(err)
}
if e.fileExists(t, f.ID) || e.blobExists(t, f) || len(e.bindingIDs(t)) != 0 {
t.Fatal("the committed purge left the row, the blob or the binding")
}
}
// TestPurgeDeferredSkipsLocked: a binding locked by a running save (FOR
// UPDATE in another transaction) is skipped, the rest are purged, and the
// next run takes it once the lock is gone.
func TestPurgeDeferredSkipsLocked(t *testing.T) {
e := newPurgeEnv(t, "lagoon_purge_skip_locked")
locked := e.bind(t, "children", "acme_purge_children", "1", nil, 6*day)
free := e.bind(t, "children", "acme_purge_children", "2", nil, 6*day)
save := e.gdb.Begin()
defer save.Rollback()
var row DeferredBinding
if err := save.Raw(`SELECT * FROM deferred_bindings WHERE id = ? FOR UPDATE`, locked).Scan(&row).Error; err != nil || row.ID != locked {
t.Fatalf("lock %+v %v", row, err)
}
done := make(chan struct{})
var res PurgeResult
var err error
go func() {
defer close(done)
res, err = PurgeDeferred(context.Background(), e.gdb, e.bucket, PurgeOptions{Before: time.Now().Add(-5 * day)})
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the purge waited on a locked binding instead of skipping it")
}
if err != nil || res.Bindings != 1 {
t.Fatalf("result %+v %v", res, err)
}
if ids := e.bindingIDs(t); fmt.Sprint(ids) != fmt.Sprint([]uint{locked}) {
t.Fatalf("left %v, want the locked binding %d (free %d purged)", ids, locked, free)
}
if err := save.Rollback().Error; err != nil {
t.Fatal(err)
}
res, err = PurgeDeferred(t.Context(), e.gdb, e.bucket, PurgeOptions{Before: time.Now().Add(-5 * day)})
if err != nil || res.Bindings != 1 || len(e.bindingIDs(t)) != 0 {
t.Fatalf("after the lock: %+v %v", res, err)
}
}
// TestPurgeDeferredNilBucket: without a bucket the purge runs when no
// expired binding points at a file, and refuses before deleting anything
// when one does.
func TestPurgeDeferredNilBucket(t *testing.T) {
e := newPurgeEnv(t, "lagoon_purge_nil_bucket")
e.bind(t, "children", "acme_purge_children", "1", nil, 6*day)
opts := PurgeOptions{Before: time.Now().Add(-5 * day)}
if res, err := PurgeDeferred(t.Context(), e.gdb, nil, opts); err != nil || res.Bindings != 1 {
t.Fatalf("no file bindings: %+v %v", res, err)
}
f := e.file(t, "")
e.bind(t, "cover", DeferredFileType, fmt.Sprint(f.ID), nil, 6*day)
e.bind(t, "children", "acme_purge_children", "2", nil, 6*day)
if _, err := PurgeDeferred(t.Context(), e.gdb, nil, opts); err == nil || !strings.Contains(err.Error(), "bucket") {
t.Fatalf("err = %v, want a bucket error", err)
}
if len(e.bindingIDs(t)) != 2 || !e.fileExists(t, f.ID) {
t.Fatal("a refused purge deleted something")
}
}
// purgeInput is a bonfire.Input with flags only.
type purgeInput map[string]string
func (purgeInput) Args() []string { return nil }
func (purgeInput) Argument(string) (string, bool) { return "", false }
func (in purgeInput) Flag(n string) (string, bool) { v, ok := in[n]; return v, ok }
func (purgeInput) Flags(string) []string { return nil }
func purgeConfig(t *testing.T, values map[string]any) *compass.Config {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: lagoon-purge\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
for k, v := range values {
if err := cfg.Set(k, v); err != nil {
t.Fatal(err)
}
}
return cfg
}
// TestPurgeDeferredDays: --days wins over database.deferred_bindings
// .purge_days, which wins over the default of five; negative and
// non-integer values are refused.
func TestPurgeDeferredDays(t *testing.T) {
cases := []struct {
name string
cfg map[string]any
in purgeInput
want int
err bool
}{
{"default", nil, nil, 5, false},
{"nil app", nil, nil, 5, false},
{"config int", map[string]any{purgeDaysKey: 9}, nil, 9, false},
{"config string", map[string]any{purgeDaysKey: "3"}, nil, 3, false},
{"config whole float", map[string]any{purgeDaysKey: 2.0}, nil, 2, false},
{"flag wins", map[string]any{purgeDaysKey: 9}, purgeInput{"days": "0"}, 0, false},
{"flag negative", nil, purgeInput{"days": "-1"}, 0, true},
{"flag text", nil, purgeInput{"days": "five"}, 0, true},
{"config negative", map[string]any{purgeDaysKey: -2}, nil, 0, true},
{"config fraction", map[string]any{purgeDaysKey: 1.5}, nil, 0, true},
{"config text", map[string]any{purgeDaysKey: "x"}, nil, 0, true},
{"config bool", map[string]any{purgeDaysKey: true}, nil, 0, true},
}
for _, tc := range cases {
var app *backpack.App
if tc.name != "nil app" {
app = backpack.New(purgeConfig(t, tc.cfg))
}
var in bonfire.Input
if tc.in != nil {
in = tc.in
}
got, err := purgeDays(app, in)
if (err != nil) != tc.err || (!tc.err && got != tc.want) {
t.Fatalf("%s: days=%d err=%v", tc.name, got, err)
}
}
}
// purgePlugin lists the child model for the deferred:purge command.
type purgePlugin struct{ models []any }
func (purgePlugin) ID() string { return "acme.purge" }
func (purgePlugin) Requires() []string { return nil }
func (purgePlugin) Register(*backpack.App) error { return nil }
func (purgePlugin) Boot(*backpack.App) error { return nil }
func (p purgePlugin) Models() []any { return p.models }
type otherChild struct {
ID uint `gorm:"column:id;primaryKey"`
}
func (otherChild) TableName() string { return "acme_purge_children" }
// TestPurgeDeferredCommand runs deferred:purge end to end: it resolves
// created children through the plugins' Models(), uses the published
// bucket and reports the counts; a morph type shared by two model types is
// refused.
func TestPurgeDeferredCommand(t *testing.T) {
e := newPurgeEnv(t, "lagoon_purge_command")
f := e.file(t, "")
created := e.child(t, "created")
e.bind(t, "cover", DeferredFileType, fmt.Sprint(f.ID), nil, 3*day)
e.bind(t, "children", "acme_purge_children", fmt.Sprint(created), &DeferredEnvelope{Created: true}, 3*day)
run := func(plugins []party.Plugin, args ...string) (string, error) {
app := backpack.New(purgeConfig(t, map[string]any{"database.dsn": e.dsn, "app.key": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}))
if err := attach.Publish(app, e.bucket); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root, err := bonfire.NewRoot("app", RuntimeCommands(app, plugins), &out)
if err != nil {
t.Fatal(err)
}
root.SetArgs(append([]string{"deferred:purge"}, args...))
root.SetOut(&out)
root.SetErr(&out)
err = root.Execute()
return out.String(), err
}
// The default five days keeps three-day-old bindings.
if out, err := run([]party.Plugin{purgePlugin{models: []any{&purgeChild{}}}}); err != nil || !strings.Contains(out, "purged 0 deferred bindings older than 5 days") {
t.Fatalf("default run: %q %v", out, err)
}
if _, err := run([]party.Plugin{purgePlugin{models: []any{&purgeChild{}, &otherChild{}}}}, "--days", "2"); err == nil || !strings.Contains(err.Error(), "share the morph type") {
t.Fatalf("shared morph type: %v", err)
}
if _, err := run(nil, "--days", "-3"); err == nil {
t.Fatal("negative --days accepted")
}
out, err := run([]party.Plugin{purgePlugin{models: []any{&purgeChild{}}}}, "--days", "2")
if err != nil || !strings.Contains(out, "purged 2 deferred bindings older than 2 days (1 files, 1 children, 0 skipped)") {
t.Fatalf("run: %q %v", out, err)
}
if e.fileExists(t, f.ID) || e.blobExists(t, f) || len(e.bindingIDs(t)) != 0 {
t.Fatal("the command left the file, its blob or a binding")
}
}

View File

@@ -3,6 +3,7 @@ package lagoon
import (
"strings"
"testing"
"time"
)
func TestValidateBetweenYear(t *testing.T) {
@@ -269,3 +270,48 @@ func TestValidateNumericRangeMessageTranslated(t *testing.T) {
t.Fatalf("pl between = %v", got)
}
}
// TestValidateRequiredZeroDates: required rejects a zero time.Time, Date or
// TimeOfDay and a non-nil pointer to one, accepts set values (midnight
// included), and keeps the old emptiness of every other type: an int 0, a
// false bool and a non-date struct are present.
func TestValidateRequiredZeroDates(t *testing.T) {
zeroTime, zeroDate, zeroClock := time.Time{}, Date{}, TimeOfDay{}
setTime, setDate, midnight := time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC), NewDate(2026, 10, 2), NewTimeOfDay(0, 0, 0)
type other struct{ A int }
cases := []struct {
name string
value any
empty bool
}{
{"zero time", zeroTime, true},
{"pointer to zero time", &zeroTime, true},
{"nil time pointer", (*time.Time)(nil), true},
{"zero date", zeroDate, true},
{"pointer to zero date", &zeroDate, true},
{"nil date pointer", (*Date)(nil), true},
{"zero clock", zeroClock, true},
{"pointer to zero clock", &zeroClock, true},
{"nil clock pointer", (*TimeOfDay)(nil), true},
{"set time", setTime, false},
{"pointer to set time", &setTime, false},
{"set date", setDate, false},
{"pointer to set date", &setDate, false},
{"midnight", midnight, false},
{"pointer to midnight", &midnight, false},
{"int zero", 0, false},
{"false", false, false},
{"zero struct", other{}, false},
{"empty string", "", true},
{"nil", nil, true},
}
for _, tc := range cases {
errs, err := Validate(t.Context(), nil, &fillDates{}, map[string]string{"v": "required"}, map[string]any{"v": tc.value}, nil)
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got := len(errs["v"]) > 0; got != tc.empty {
t.Fatalf("%s: required failed=%v, want %v (%v)", tc.name, got, tc.empty, errs)
}
}
}