From c8d41a68c6005730bec6a556187de504d7463da7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 20:34:23 +0200 Subject: [PATCH] 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 --- modules/cabana/datepicker_test.go | 353 +++++++++++ modules/cabana/deferred_commit_test.go | 261 ++++++++ modules/cabana/fileupload_test.go | 334 ++++++++++ modules/cabana/phase122_fixture_test.go | 144 ++++- modules/cabana/relation_child_test.go | 588 ++++++++++++++++++ .../controllers/locked/config_list.yaml | 2 + .../deferred/models/gadget/fields.yaml | 1 + modules/conga/schedule_test.go | 169 +++++ modules/lagoon/attach/guard_test.go | 109 ++++ modules/lagoon/attach/store_test.go | 160 +++++ modules/lagoon/date_test.go | 247 ++++++++ modules/lagoon/deferred_test.go | 355 +++++++++++ modules/lagoon/fill_test.go | 146 +++++ modules/lagoon/purge_test.go | 503 +++++++++++++++ modules/lagoon/validate_test.go | 46 ++ modules/pact/capabilities_test.go | 101 +++ 16 files changed, 3509 insertions(+), 10 deletions(-) create mode 100644 modules/cabana/datepicker_test.go create mode 100644 modules/cabana/deferred_commit_test.go create mode 100644 modules/cabana/fileupload_test.go create mode 100644 modules/cabana/relation_child_test.go create mode 100644 modules/lagoon/attach/guard_test.go create mode 100644 modules/lagoon/purge_test.go diff --git a/modules/cabana/datepicker_test.go b/modules/cabana/datepicker_test.go new file mode 100644 index 0000000..0e08189 --- /dev/null +++ b/modules/cabana/datepicker_test.go @@ -0,0 +1,353 @@ +package cabana_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/cabana" +) + +// dfGadgetFields is a minimal gadget fields.yaml (the two relation +// managers the contracts need) plus extra. +func dfGadgetFields(extra string) string { + return `fields: + name: + label: Name + type: text + members: + type: relation-manager + relation: members + parts: + type: relation-manager + relation: parts +` + extra +} + +// bootGadgetFields boots the fixture with the gadget fields.yaml replaced. +func bootGadgetFields(t *testing.T, fields string) error { + t.Helper() + return activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})}, nil) +} + +// TestDatepickerCompile: every datepicker compile error stops boot with a +// message that names the field's file (D-20), and the Go type of the +// column must match the mode (D-19). +func TestDatepickerCompile(t *testing.T) { + field := func(name, body string) string { + return " " + name + ":\n type: datepicker\n" + body + } + for name, tc := range map[string]struct { + field string + want string + }{ + "unknown key": {field("released_on", " mode: date\n showWeekNumber: true\n"), "unknown field showWeekNumber"}, + "unknown mode": {field("released_on", " mode: week\n"), `mode "week" must be date, datetime or time`}, + "options key": {field("released_on", " mode: date\n options: {a: b}\n"), "options is not valid on type: datepicker"}, + "format not a string": {field("starts_at", " format: [Y]\n"), "format must be a PHP date format string"}, + "unmapped token": {field("starts_at", " format: d.m.Y T\n"), "token T has no equivalent"}, + "unmapped token U": {field("starts_at", " format: U\n"), "token U has no equivalent"}, + "minDate on time": {field("opens_at", " mode: time\n minDate: 2026-01-01\n"), "minDate is not valid with mode: time"}, + "maxDate on time": {field("opens_at", " mode: time\n maxDate: 2026-01-01\n"), "maxDate is not valid with mode: time"}, + "bad minDate": {field("released_on", " mode: date\n minDate: 01.01.2026\n"), "minDate:"}, + "min after max": {field("starts_at", " minDate: 2026-02-01\n maxDate: 2026-01-01\n"), "is after maxDate"}, + "yearRange one year": {field("starts_at", " yearRange: [2000]\n"), "yearRange: a list must hold"}, + "yearRange three years": {field("starts_at", " yearRange: [2000, 2010, 2020]\n"), "yearRange: a list must hold"}, + "yearRange reversed": {field("starts_at", " yearRange: [2040, 2000]\n"), "yearRange: from 2040 is after to 2000"}, + "yearRange zero": {field("starts_at", " yearRange: 0\n"), "yearRange:"}, + "yearRange text": {field("starts_at", " yearRange: soon\n"), "yearRange:"}, + "firstDay seven": {field("starts_at", " firstDay: 7\n"), `firstDay "7" must be an integer from 0 (Sunday) to 6`}, + "firstDay negative": {field("starts_at", " firstDay: -1\n"), "firstDay"}, + "twelveHour not bool": {field("starts_at", " twelveHour: maybe\n"), "twelveHour:"}, + "ignoreTimezone not bool": {field("starts_at", " ignoreTimezone: maybe\n"), "ignoreTimezone:"}, + "ignoreTimezone on date": {field("released_on", " mode: date\n ignoreTimezone: true\n"), "ignoreTimezone is only valid with mode: datetime"}, + "key on another type": {" name2:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"}, + "mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"}, + "date on a datetime": {field("starts_at", " mode: date\n"), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time"}, + "datetime on a date": {field("released_on", " mode: datetime\n"), "datepicker mode datetime needs a time.Time or *time.Time column, found lagoon.Date"}, + "time on a date": {field("released_on", " mode: time\n"), "datepicker mode time needs a lagoon.TimeOfDay or *lagoon.TimeOfDay column, found lagoon.Date"}, + "date on a time": {field("opens_at", " mode: date\n"), "found lagoon.TimeOfDay"}, + "not a date column": {field("hidden", " mode: date\n"), "found bool"}, + } { + t.Run(name, func(t *testing.T) { + err := bootGadgetFields(t, dfGadgetFields(tc.field)) + if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "models/gadget/fields.yaml") { + t.Fatalf("err = %v, want %q naming the file", err, tc.want) + } + }) + } + // A datepicker on a name that is no model column stops boot too (the + // generic writable-field check, which names the controller). + if err := bootGadgetFields(t, dfGadgetFields(field("nothere", " mode: date\n"))); err == nil || !strings.Contains(err.Error(), "field nothere is not a model column") { + t.Fatalf("not a column: %v", err) + } + // Each mode on its own Go type, pointer variants included, boots. + ok := dfGadgetFields(field("released_on", " mode: date\n") + field("starts_at", " mode: datetime\n") + field("opens_at", " mode: time\n twelveHour: true\n")) + if err := bootGadgetFields(t, ok); err != nil { + t.Fatalf("matching types failed boot: %v", err) + } + part := "fields:\n label:\n type: text\n due_on:\n type: datepicker\n mode: date\n" + if err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/part/fields.yaml": part})}, nil); err != nil { + t.Fatalf("*lagoon.Date column failed boot: %v", err) + } +} + +// compiledField is one field of a compiled form's canonical JSON. +func compiledField(t *testing.T, fields, name string) map[string]any { + t.Helper() + form, err := cabana.CompileForm("acme.deferred", dfController{id: "acme.deferred.gadgets", dir: "controllers/gadgets"}, + dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})) + if err != nil { + t.Fatal(err) + } + raw, err := form.CanonicalJSON() + if err != nil { + t.Fatal(err) + } + var doc struct { + Fields []map[string]any `json:"fields"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + for _, f := range doc.Fields { + if f["name"] == name { + return f + } + } + t.Fatalf("field %s not compiled", name) + return nil +} + +// TestDatepickerDisplayFormat maps PHP date() formats to the SPA's display +// format with WinterCMS's momentFormat table; an escaped letter stays +// literal and no format means no displayFormat. +func TestDatepickerDisplayFormat(t *testing.T) { + for format, want := range map[string]string{ + "d.m.Y H:i": "DD.MM.YYYY HH:mm", + "j F Y": "D MMMM YYYY", + "D, d M y": "ddd, DD MMM YY", + "l G:i:s A": "dddd H:mm:ss A", + "g:i a": "h:mm a", + "n/j/Y h:i": "M/D/YYYY hh:mm", + `Y-m-d \a\t H:i`: "YYYY-MM-DD [a][t] HH:mm", + "Y-m-d (H:i:s)": "YYYY-MM-DD (HH:mm:ss)", + "d/m/Y": "DD/MM/YYYY", + "Y. m. d. G시 i분": "YYYY. MM. DD. H시 mm분", + } { + f := compiledField(t, dfGadgetFields(" starts_at:\n type: datepicker\n format: '"+strings.ReplaceAll(format, "'", "''")+"'\n"), "starts_at") + if f["format"] != format || f["displayFormat"] != want { + t.Errorf("format %q: displayFormat = %v, want %q", format, f["displayFormat"], want) + } + } + plain := compiledField(t, dfGadgetFields(" starts_at:\n type: datepicker\n"), "starts_at") + if _, ok := plain["displayFormat"]; ok || plain["mode"] != "datetime" { + t.Fatalf("no format: %v", plain) + } +} + +// TestDatepickerSchemaKeys: yearRange forms, firstDay, twelveHour, +// ignoreTimezone and the bounds reach the schema. +func TestDatepickerSchemaKeys(t *testing.T) { + f := compiledField(t, dfGadgetFields(" starts_at:\n type: datepicker\n yearRange: 10\n firstDay: 0\n twelveHour: true\n ignoreTimezone: true\n minDate: 2000-01-01\n maxDate: 2030-12-31\n"), "starts_at") + if years, _ := f["yearRange"].([]any); len(years) != 1 || years[0] != float64(10) { + t.Fatalf("yearRange 10 = %v", f["yearRange"]) + } + if f["firstDay"] != float64(0) || f["twelveHour"] != true || f["ignoreTimezone"] != true || f["minDate"] != "2000-01-01" || f["maxDate"] != "2030-12-31" { + t.Fatalf("keys = %v", f) + } + f = compiledField(t, dfGadgetFields(" released_on:\n type: datepicker\n mode: date\n yearRange: [1990, 2040]\n"), "released_on") + if years, _ := f["yearRange"].([]any); len(years) != 2 || years[0] != float64(1990) || years[1] != float64(2040) || f["mode"] != "date" { + t.Fatalf("yearRange list = %v", f) + } +} + +// gadgetRow reads the stored date columns of a gadget as text. +type gadgetDates struct { + Day *string + UTC *string + Clock *string +} + +func (e *dfEnv) gadgetDates(t *testing.T, id uint) gadgetDates { + t.Helper() + var row gadgetDates + err := e.db.Raw(`SELECT released_on::text AS day, to_char(starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS utc, opens_at::text AS clock FROM cabana_deferred_gadgets WHERE id = ?`, id).Scan(&row).Error + if err != nil { + t.Fatal(err) + } + return row +} + +func strOrNil(s *string) string { + if s == nil { + return "" + } + return *s +} + +// TestDatepickerSave stores each mode through the admin API: a date stays +// the calendar day, a datetime with an offset is stored in UTC, a time +// keeps its clock, and null or an empty string clears each one. +func TestDatepickerSave(t *testing.T) { + env := newDeferredEnv(t) + rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{ + "name": "dates-" + env.stamp, "released_on": "2026-10-02", "starts_at": "2026-10-02T01:30:00+02:00", "opens_at": "14:30", + }, nil) + want(t, "create with dates", rec, http.StatusCreated) + id := dataID(t, rec.Body.Bytes()) + if got := env.gadgetDates(t, id); strOrNil(got.Day) != "2026-10-02" || strOrNil(got.UTC) != "2026-10-01 23:30:00" || strOrNil(got.Clock) != "14:30:00" { + t.Fatalf("stored = %s %s %s", strOrNil(got.Day), strOrNil(got.UTC), strOrNil(got.Clock)) + } + var body struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data["released_on"] != "2026-10-02" || body.Data["opens_at"] != "14:30:00" { + t.Fatalf("record = %s", rec.Body.String()) + } + if at, err := time.Parse(time.RFC3339, fmt.Sprint(body.Data["starts_at"])); err != nil || !at.Equal(time.Date(2026, 10, 1, 23, 30, 0, 0, time.UTC)) { + t.Fatalf("starts_at = %v (%v)", body.Data["starts_at"], err) + } + + upd := env.a.do(t, http.MethodPut, dfPath(id, ""), map[string]any{"name": "dates-" + env.stamp, "released_on": "2026-12-24", "starts_at": "2026-12-24T18:00:00Z", "opens_at": "00:00:00"}, nil) + want(t, "update dates", upd, http.StatusOK) + if got := env.gadgetDates(t, id); strOrNil(got.Day) != "2026-12-24" || strOrNil(got.UTC) != "2026-12-24 18:00:00" || strOrNil(got.Clock) != "00:00:00" { + t.Fatalf("updated = %s %s %s", strOrNil(got.Day), strOrNil(got.UTC), strOrNil(got.Clock)) + } + + clr := env.a.do(t, http.MethodPut, dfPath(id, ""), map[string]any{"name": "dates-" + env.stamp, "released_on": nil, "starts_at": nil, "opens_at": ""}, nil) + want(t, "clear dates", clr, http.StatusOK) + if got := env.gadgetDates(t, id); got.Day != nil || got.UTC != nil || got.Clock != nil { + t.Fatalf("cleared = %s %s %s", strOrNil(got.Day), strOrNil(got.UTC), strOrNil(got.Clock)) + } + + for name, bad := range map[string]map[string]any{ + "garbage date": {"released_on": "02.10.2026"}, + "garbage datetime": {"starts_at": "tomorrow"}, + "garbage time": {"opens_at": "25:00"}, + } { + bad["name"] = "bad-" + env.stamp + rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", bad, nil) + want(t, name, rec, http.StatusUnprocessableEntity) + for key := range bad { + if key != "name" && len(errorDetails(t, rec)[key]) == 0 { + t.Fatalf("%s: details %v lack %s", name, errorDetails(t, rec), key) + } + } + } +} + +// TestDatepickerBounds: minDate and maxDate are enforced on the server and +// inclusive (D-20). A date compares as is, a datetime by its UTC date, and +// with ignoreTimezone by the wall-clock date it was sent with; the check +// runs on create and update and answers Laravel's after_or_equal and +// before_or_equal text on the field. +func TestDatepickerBounds(t *testing.T) { + env := newDeferredEnv(t) + save := func(id uint, values map[string]any) (int, map[string][]string) { + values["name"] = "bounds-" + env.stamp + var rec = env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", values, nil) + if id > 0 { + rec = env.a.do(t, http.MethodPut, dfPath(id, ""), values, nil) + } + if rec.Code == http.StatusUnprocessableEntity { + return rec.Code, errorDetails(t, rec) + } + return rec.Code, nil + } + existing := env.gadget(t, "existing-"+env.stamp, false) + for _, id := range []uint{0, existing} { + for _, tc := range []struct { + field, value string + ok bool + msg string + }{ + {"released_on", "2000-01-01", true, ""}, + {"released_on", "2030-12-31", true, ""}, + {"released_on", "1999-12-31", false, "released on must be a date after or equal to 2000-01-01"}, + {"released_on", "2031-01-01", false, "released on must be a date before or equal to 2030-12-31"}, + {"starts_at", "2000-01-01T00:00:00Z", true, ""}, + {"starts_at", "2030-12-31T23:59:59Z", true, ""}, + // 00:30 at +01:00 is 23:30 UTC on the day before. + {"starts_at", "2000-01-01T00:30:00+01:00", false, "starts at must be a date after or equal to 2000-01-01"}, + {"starts_at", "2031-01-01T00:30:00+01:00", true, ""}, + {"starts_at", "2031-01-01T00:00:00Z", false, "starts at must be a date before or equal to 2030-12-31"}, + } { + code, details := save(id, map[string]any{tc.field: tc.value}) + if tc.ok && code/100 != 2 { + t.Fatalf("id %d %s=%s: status %d %v, want accepted", id, tc.field, tc.value, code, details) + } + if !tc.ok && (code != http.StatusUnprocessableEntity || len(details[tc.field]) != 1 || details[tc.field][0] != "The "+tc.msg+".") { + t.Fatalf("id %d %s=%s: status %d %v, want %q", id, tc.field, tc.value, code, details, tc.msg) + } + } + } + // A cleared value is not out of bounds. + if code, details := save(existing, map[string]any{"released_on": nil, "starts_at": nil}); code != http.StatusOK { + t.Fatalf("cleared: %d %v", code, details) + } + + fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " mode: datetime\n", " mode: datetime\n ignoreTimezone: true\n", 1) + wall := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})}) + for value, ok := range map[string]bool{ + "2031-01-01T00:30:00+01:00": false, // wall-clock date 2031-01-01 + "2000-01-01T00:30:00+01:00": true, // wall-clock date 2000-01-01 + "2030-12-31T23:30:00-05:00": true, + "1999-12-31T23:30:00-05:00": false, + } { + rec := wall.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "wall-" + value, "starts_at": value}, nil) + if ok != (rec.Code == http.StatusCreated) { + t.Fatalf("ignoreTimezone %s: status %d body %s", value, rec.Code, rec.Body.String()) + } + } +} + +// TestDatepickerListColumns: type: date and type: time list columns show +// the stored values, and Scanner/Valuer struct columns are listed as +// values, not taken for relations. +func TestDatepickerListColumns(t *testing.T) { + env := newDeferredEnv(t) + rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "list-" + env.stamp, "released_on": "2026-10-02", "opens_at": "08:15"}, nil) + want(t, "create", rec, http.StatusCreated) + env.gadget(t, "empty-"+env.stamp, false) + + schema := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/list", nil, nil) + want(t, "list schema", schema, http.StatusOK) + var s struct { + Data struct { + Columns []struct { + Key string `json:"key"` + Type string `json:"type"` + } `json:"columns"` + } `json:"data"` + } + if err := json.Unmarshal(schema.Body.Bytes(), &s); err != nil { + t.Fatal(err) + } + types := map[string]string{} + for _, c := range s.Data.Columns { + types[c.Key] = c.Type + } + if types["released_on"] != "date" || types["opens_at"] != "time" { + t.Fatalf("column types = %v (%s)", types, schema.Body.String()) + } + + list := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets?search="+env.stamp, nil, nil) + want(t, "list", list, http.StatusOK) + var body struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(list.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, row := range body.Data { + got[fmt.Sprint(row["name"])] = fmt.Sprintf("%v|%v", row["released_on"], row["opens_at"]) + } + if got["list-"+env.stamp] != "2026-10-02|08:15:00" || got["empty-"+env.stamp] != "|" { + t.Fatalf("rows = %v", got) + } +} diff --git a/modules/cabana/deferred_commit_test.go b/modules/cabana/deferred_commit_test.go new file mode 100644 index 0000000..5345c2f --- /dev/null +++ b/modules/cabana/deferred_commit_test.go @@ -0,0 +1,261 @@ +package cabana_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gorm.io/gorm" +) + +// pendingWork fills a create session: one photo upload, one created part +// and one member linked with a pivot note. It returns the photo and part +// ids and the member id. +func (e *dfEnv) pendingWork(t *testing.T, c dfClient, key string) (photo, part, member uint) { + t.Helper() + up := c.upload(t, photosPath(0, ""), "p.png", conformPNG(t), sk(key)) + want(t, "pending upload", up, http.StatusCreated) + created := c.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "pending"}, sk(key)) + want(t, "pending part", created, http.StatusCreated) + member = e.member(t, fmt.Sprintf("m%d-%s@example.test", len(e.state(t).Members), e.stamp)) + want(t, "pending link", c.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{member}, "pivot": map[string]any{"note": "n"}}, sk(key)), http.StatusOK) + return dataID(t, up.Body.Bytes()), dataID(t, created.Body.Bytes()), member +} + +// formCalls keeps the Form hook calls of the recorder. +func formCalls(calls []string) []string { + var out []string + for _, c := range calls { + if strings.HasPrefix(c, "Form") { + out = append(out, c) + } + } + return out +} + +// TestDeferredCommitOrder: the session's bindings are applied inside the +// create and update transactions, after FormBefore* and before FormAfter* +// (D-04). The hooks see the transaction: before the commit the bindings +// are there and nothing is attached, after it the reverse. +func TestDeferredCommitOrder(t *testing.T) { + env := newDeferredEnv(t) + key := newSessionKey(t) + photo, part, member := env.pendingWork(t, env.a, key) + env.rec.reset() + env.rec.probe = func(tx *gorm.DB) string { + var bindings, files, parts, pivots int64 + tx.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&bindings) + tx.Model(&attach.File{}).Where("id = ? AND attachment_id <> ''", photo).Count(&files) + tx.Model(&dfPart{}).Where("id = ? AND gadget_id IS NOT NULL", part).Count(&parts) + tx.Model(&dfGadgetMember{}).Where("member_id = ?", member).Count(&pivots) + return fmt.Sprintf("bindings=%d,files=%d,parts=%d,pivots=%d", bindings, files, parts, pivots) + } + saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, sk(key)) + want(t, "create", saved, http.StatusCreated) + got := formCalls(env.rec.snapshot()) + wantCalls := []string{"FormBeforeCreate:bindings=3,files=0,parts=0,pivots=0", "FormAfterCreate:bindings=0,files=1,parts=1,pivots=1"} + if fmt.Sprint(got) != fmt.Sprint(wantCalls) { + t.Fatalf("form hooks = %v\nwant %v", got, wantCalls) + } + if !slices.ContainsFunc(env.rec.snapshot(), func(s string) bool { return strings.HasPrefix(s, "RelationBeforeLink:members") }) { + t.Fatalf("the deferred link skipped RelationBeforeLink: %v", env.rec.snapshot()) + } + + g := dataID(t, saved.Body.Bytes()) + k2 := newSessionKey(t) + up := env.a.upload(t, photosPath(g, ""), "q.png", conformPNG(t), sk(k2)) + want(t, "upload on update", up, http.StatusCreated) + photo = dataID(t, up.Body.Bytes()) + key = k2 + env.rec.reset() + env.rec.probe = func(tx *gorm.DB) string { + var bindings, files int64 + tx.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&bindings) + tx.Model(&attach.File{}).Where("id = ? AND attachment_id <> ''", photo).Count(&files) + return fmt.Sprintf("bindings=%d,files=%d", bindings, files) + } + want(t, "update", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(k2)), http.StatusOK) + if got := fmt.Sprint(formCalls(env.rec.snapshot())); got != "[FormBeforeUpdate:bindings=1,files=0 FormAfterUpdate:bindings=0,files=1]" { + t.Fatalf("update hooks = %s", got) + } +} + +// TestDeferredCommitRollback: a 422 at save rolls the whole transaction +// back and keeps every binding of the session for the next attempt: a +// datepicker bound, an ineligible deferred link, a required fileupload and +// maxFiles over the limit across sessions. +func TestDeferredCommitRollback(t *testing.T) { + env := newDeferredEnv(t) + + t.Run("datepicker bound", func(t *testing.T) { + key := newSessionKey(t) + env.pendingWork(t, env.a, key) + before := env.state(t) + rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "bound-" + env.stamp, "released_on": "1999-01-01"}, sk(key)) + want(t, "save out of bounds", rec, http.StatusUnprocessableEntity) + if len(errorDetails(t, rec)["released_on"]) != 1 { + t.Fatalf("details %v", errorDetails(t, rec)) + } + env.unchanged(t, "a rolled-back save", before) + want(t, "save again", env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "bound-" + env.stamp}, sk(key)), http.StatusCreated) + var left int64 + env.db.Model(&lagoon.DeferredBinding{}).Where("session_key = ?", key).Count(&left) + if left != 0 { + t.Fatalf("bindings after the retry = %d", left) + } + }) + + t.Run("ineligible link", func(t *testing.T) { + key := newSessionKey(t) + _, _, member := env.pendingWork(t, env.a, key) + // The linked member is gone by the time the record is saved. + if err := env.db.Delete(&dfMember{}, member).Error; err != nil { + t.Fatal(err) + } + before := env.state(t) + rec := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "ineligible-" + env.stamp}, sk(key)) + want(t, "save with an ineligible link", rec, http.StatusUnprocessableEntity) + if len(errorDetails(t, rec)["members"]) != 1 { + t.Fatalf("details %v", errorDetails(t, rec)) + } + env.unchanged(t, "a rolled-back save", before) + }) + + t.Run("required fileupload", func(t *testing.T) { + key := newSessionKey(t) + rec := env.a.do(t, http.MethodPost, "/acme/deferred/locked", map[string]any{"name": "locked-" + env.stamp}, sk(key)) + want(t, "save without the required file", rec, http.StatusUnprocessableEntity) + if d := errorDetails(t, rec)["manual"]; len(d) != 1 || d[0] != "The manual field is required." { + t.Fatalf("details %v", errorDetails(t, rec)) + } + up := env.a.upload(t, "/acme/deferred/locked/0/files/manual", "m.txt", []byte("manual"), sk(key)) + want(t, "upload the required file", up, http.StatusCreated) + saved := env.a.do(t, http.MethodPost, "/acme/deferred/locked", map[string]any{"name": "locked-" + env.stamp}, sk(key)) + want(t, "save with the required file", saved, http.StatusCreated) + // Removing the only file of a required field fails the next save. + g := dataID(t, saved.Body.Bytes()) + k2 := newSessionKey(t) + want(t, "remove it", env.a.do(t, http.MethodDelete, fmt.Sprintf("/acme/deferred/locked/%d/files/manual/%d", g, dataID(t, up.Body.Bytes())), nil, sk(k2)), http.StatusOK) + before := env.state(t) + want(t, "save without it", env.a.do(t, http.MethodPut, fmt.Sprintf("/acme/deferred/locked/%d", g), map[string]any{"name": "locked-" + env.stamp}, sk(k2)), http.StatusUnprocessableEntity) + env.unchanged(t, "a rolled-back removal", before) + }) + + t.Run("maxFiles at save", func(t *testing.T) { + g := env.gadget(t, "max-"+env.stamp, false) + k1, k2 := newSessionKey(t), newSessionKey(t) + for _, key := range []string{k1, k1, k2, k2} { + want(t, "upload", env.a.upload(t, photosPath(g, ""), "p.png", conformPNG(t), sk(key)), http.StatusCreated) + } + want(t, "first session", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "max-" + env.stamp}, sk(k1)), http.StatusOK) + before := env.state(t) + rec := env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "max-" + env.stamp}, sk(k2)) + want(t, "second session over maxFiles", rec, http.StatusUnprocessableEntity) + if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not have more than 3 items." { + t.Fatalf("details %v", errorDetails(t, rec)) + } + env.unchanged(t, "a rolled-back save", before) + }) +} + +// TestDeferredCommitAppliedOnly: a successful save deletes exactly the +// bindings it applied. Bindings of a field the form does not declare, of +// another master type, of another admin, and of a field whose context +// excludes the operation stay in place, and their slaves are untouched. +func TestDeferredCommitAppliedOnly(t *testing.T) { + fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " useCaption: true\n", " useCaption: true\n context: update\n", 1) + env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})}) + key := newSessionKey(t) + photo, _, _ := env.pendingWork(t, env.a, key) + stray := func(admin uint, master, field string) uint { + f := env.storeFile(t, "", 0, "", "stray.png", conformPNG(t), false) + err := lagoon.Transaction(t.Context(), env.db, func(ctx context.Context, tx *gorm.DB) error { + return lagoon.DeferredBind(ctx, tx, lagoon.DeferredKey{SessionKey: key, AdminID: admin, MasterType: master}, field, lagoon.DeferredFileType, fmt.Sprint(f.ID), nil) + }) + if err != nil { + t.Fatal(err) + } + return f.ID + } + undeclared := stray(env.a.id, dfGadgetMorph, "brochure") + foreignMorph := stray(env.a.id, dfPartMorph, "photos") + foreignAdmin := stray(env.b.id, dfGadgetMorph, "photos") + updateOnly := stray(env.a.id, dfGadgetMorph, "manual") + + saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, sk(key)) + want(t, "save", saved, http.StatusCreated) + if f, _ := env.fileRow(t, photo); f.AttachmentID == "" { + t.Fatal("the declared photo was not attached") + } + var rows []lagoon.DeferredBinding + if err := env.db.Where("session_key = ?", key).Order("id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + var left []string + for _, r := range rows { + left = append(left, fmt.Sprintf("%d/%s/%s/%s", r.BackendUserID, r.MasterType, r.MasterField, r.SlaveID)) + } + wantLeft := []string{ + fmt.Sprintf("%d/%s/brochure/%d", env.a.id, dfGadgetMorph, undeclared), + fmt.Sprintf("%d/%s/photos/%d", env.a.id, dfPartMorph, foreignMorph), + fmt.Sprintf("%d/%s/photos/%d", env.b.id, dfGadgetMorph, foreignAdmin), + fmt.Sprintf("%d/%s/manual/%d", env.a.id, dfGadgetMorph, updateOnly), + } + if fmt.Sprint(left) != fmt.Sprint(wantLeft) { + t.Fatalf("bindings left\n got %v\nwant %v", left, wantLeft) + } + for _, id := range []uint{undeclared, foreignMorph, foreignAdmin, updateOnly} { + if f, ok := env.fileRow(t, id); !ok || f.AttachmentID != "" { + t.Fatalf("stray file %d was touched: %+v", id, f) + } + } +} + +// TestDeferredCommitConcurrent: two saves with one session key at the +// same time apply each binding once. The commit reads the bindings FOR +// UPDATE, so the second save waits, finds them gone and attaches nothing. +func TestDeferredCommitConcurrent(t *testing.T) { + env := newDeferredEnv(t) + key := newSessionKey(t) + photo, part, member := env.pendingWork(t, env.a, key) + var wg sync.WaitGroup + recs := make([]*httptest.ResponseRecorder, 2) + for i := range recs { + wg.Add(1) + go func(i int) { + defer wg.Done() + recs[i] = env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": fmt.Sprintf("race%d-%s", i, env.stamp)}, sk(key)) + }(i) + } + wg.Wait() + ids := map[uint]bool{} + for i, rec := range recs { + want(t, fmt.Sprintf("concurrent save %d", i), rec, http.StatusCreated) + ids[dataID(t, rec.Body.Bytes())] = true + } + f, _ := env.fileRow(t, photo) + p, _ := env.partRow(t, part) + var pivots []dfGadgetMember + if err := env.db.Where("member_id = ?", member).Find(&pivots).Error; err != nil { + t.Fatal(err) + } + if f.AttachmentID == "" || p.GadgetID == nil || len(pivots) != 1 { + t.Fatalf("applied: file %q part %v pivots %d", f.AttachmentID, p.GadgetID, len(pivots)) + } + owner := fmt.Sprint(*p.GadgetID) + if f.AttachmentID != owner || fmt.Sprint(pivots[0].GadgetID) != owner || !ids[*p.GadgetID] { + t.Fatalf("bindings split across saves: file %s part %s pivot %d", f.AttachmentID, owner, pivots[0].GadgetID) + } + var files int64 + env.db.Model(&attach.File{}).Where("attachment_type = ? AND field = 'photos'", dfGadgetMorph).Count(&files) + if files != 1 || len(env.state(t).Bindings) != 0 { + t.Fatalf("files %d bindings %d", files, len(env.state(t).Bindings)) + } +} diff --git a/modules/cabana/fileupload_test.go b/modules/cabana/fileupload_test.go new file mode 100644 index 0000000..7971039 --- /dev/null +++ b/modules/cabana/fileupload_test.go @@ -0,0 +1,334 @@ +package cabana_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" +) + +// TestFileuploadCompile: every fileupload compile error stops boot naming +// the field's file (D-08, D-09). +func TestFileuploadCompile(t *testing.T) { + photo := func(body string) string { + return " photos:\n type: fileupload\n" + body + } + for name, tc := range map[string]struct { + field string + want string + }{ + "unknown key": {photo(" mode: image\n resize: true\n"), "unknown field resize"}, + "datepicker key": {photo(" firstDay: 1\n"), "firstDay is only valid on type: datepicker"}, + "unknown mode": {photo(" mode: video\n"), `mode "video" must be image or file on type: fileupload`}, + "image-mode fileType": {photo(" mode: image\n fileTypes: [png, pdf]\n"), "fileTypes: pdf is not an image type"}, + "bad fileType": {photo(" fileTypes: [p-f]\n"), `fileTypes: "p-f" is not a file extension`}, + "bad mimeType": {photo(" mimeTypes: ['no such']\n"), `mimeTypes: "no such" is not a MIME type or extension`}, + "maxFilesize zero": {photo(" maxFilesize: 0\n"), "must be a positive number of megabytes"}, + "maxFilesize text": {photo(" maxFilesize: big\n"), "must be a positive number of megabytes"}, + "maxFiles zero": {photo(" maxFiles: 0\n"), "maxFiles \"0\" must be a positive integer"}, + "imageWidth zero": {photo(" imageWidth: 0\n"), "imageWidth"}, + "imageHeight huge": {photo(" imageHeight: 99999\n"), "imageHeight"}, + "thumbOptions key": {photo(" thumbOptions:\n size: 1\n"), "thumbOptions: unknown field size (only mode is supported)"}, + "thumbOptions mode": {photo(" thumbOptions:\n mode: stretch\n"), `thumbOptions: mode "stretch" must be auto, exact, crop or fit`}, + "thumbOptions list": {photo(" thumbOptions: [crop]\n"), "thumbOptions: must be a mapping"}, + "useCaption not bool": {photo(" useCaption: maybe\n"), "useCaption:"}, + "no relation": {" brochure:\n type: fileupload\n", "field brochure: is not an attachment relation the model declares in AttachRelations"}, + "maxFiles on attachOne": {" manual:\n type: fileupload\n maxFiles: 2\n", "field manual: maxFiles is only valid on an attachMany relation"}, + "key on another type": {" name2:\n type: text\n maxFiles: 2\n", "maxFiles is only valid on type: fileupload"}, + } { + t.Run(name, func(t *testing.T) { + err := bootGadgetFields(t, dfGadgetFields(tc.field)) + if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "models/gadget/fields.yaml") { + t.Fatalf("err = %v, want %q naming the file", err, tc.want) + } + }) + } + + // A fileupload on a model without attach.Owner (the members manage + // form) stops boot. + member := "fields:\n email:\n type: text\n avatar:\n type: fileupload\n" + err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/member/fields.yaml": member})}, nil) + if err == nil || !strings.Contains(err.Error(), "type fileupload needs a model implementing attach.Owner") { + t.Fatalf("model without attach.Owner: %v", err) + } + // maxFilesize above http.body_limits.upload_bytes stops boot. + err = bootGadgetFields(t, dfGadgetFields(photo(" maxFilesize: 2\n"))) + if err != nil { + t.Fatalf("2 MB without an upload cap: %v", err) + } + err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 2\n"))})}, + map[string]any{"http.body_limits.upload_bytes": 1048576}) + if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize exceeds http.body_limits.upload_bytes") { + t.Fatalf("maxFilesize over upload_bytes: %v", err) + } + if err := bootGadgetFields(t, dfGadgetFields(photo(" mode: image\n fileTypes: jpg|png\n mimeTypes: image/png, png\n maxFiles: 2\n imageWidth: 120\n thumbOptions:\n mode: fit\n useCaption: true\n prompt: Drop\n"))); err != nil { + t.Fatalf("every key: %v", err) + } +} + +// photosPath and manualPath build a gadget's photos file route. +func photosPath(gadget uint, rest string) string { return dfPath(gadget, "/files/photos"+rest) } + +// fileItems decodes a file list. +func fileItems(t *testing.T, c dfClient, gadget uint, field, key string) []cabana.FileItem { + t.Helper() + var h map[string]string + if key != "" { + h = sk(key) + } + return fileList(t, c.do(t, http.MethodGet, dfPath(gadget, "/files/"+field), nil, h)) +} + +func itemIDs(items []cabana.FileItem) []uint { + out := make([]uint, 0, len(items)) + for _, it := range items { + out = append(out, it.ID) + } + return out +} + +func (e *dfEnv) fileRow(t *testing.T, id uint) (attach.File, bool) { + t.Helper() + var f attach.File + err := e.db.Where("id = ?", id).Limit(1).Find(&f).Error + if err != nil { + t.Fatal(err) + } + return f, f.ID != 0 +} + +func (e *dfEnv) blob(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 +} + +// TestFileuploadUpload: an upload on id 0 is 201 and pending; the request +// cap answers 413 and the field limits answer 422 on the field in the +// request locale; maxFiles counts the visible files at upload; the list +// orders by sort_order and flags pending uploads (D-03, D-08, D-09). +func TestFileuploadUpload(t *testing.T) { + env := newDeferredEnv(t) + key := newSessionKey(t) + png := conformPNG(t) + + up := env.a.upload(t, photosPath(0, ""), "a.png", png, sk(key)) + want(t, "upload", up, http.StatusCreated) + var item cabana.Envelope[cabana.FileItem] + if err := json.Unmarshal(up.Body.Bytes(), &item); err != nil || !item.Data.Pending || item.Data.FileName != "a.png" || item.Data.URL == "" || item.Data.ContentType != "image/png" { + t.Fatalf("upload item = %s (%v)", up.Body.String(), err) + } + + before := env.state(t) + // photos: maxFilesize 0.5 MB, cap 512 KiB + 64 KiB of multipart framing. + big := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 700<<10)...) + want(t, "body past the cap", env.a.upload(t, photosPath(0, ""), "big.png", big, sk(key)), http.StatusRequestEntityTooLarge) + over := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 530<<10)...) + rec := env.a.upload(t, photosPath(0, ""), "over.png", over, sk(key)) + want(t, "file over maxFilesize", rec, http.StatusUnprocessableEntity) + if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not be greater than 512 kilobytes." { + t.Fatalf("size message %v", d) + } + rec = env.a.upload(t, photosPath(0, ""), "x.svg", []byte(""), sk(key)) + want(t, "wrong type", rec, http.StatusUnprocessableEntity) + if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos must be a file of type: jpg, jpeg, png, gif, webp." { + t.Fatalf("type message %v", d) + } + rec = env.a.upload(t, photosPath(0, ""), "fake.png", []byte("GIF89a"), map[string]string{cabana.SessionKeyHeader: key, "Accept-Language": "pl"}) + want(t, "polyglot", rec, http.StatusUnprocessableEntity) + if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "photos musi być obrazkiem." { + t.Fatalf("localized image message %v", d) + } + want(t, "no key", env.a.upload(t, photosPath(0, ""), "a.png", png, nil), http.StatusUnprocessableEntity) + want(t, "unknown field", env.a.upload(t, dfPath(0, "/files/nothere"), "a.png", png, sk(key)), http.StatusNotFound) + env.unchanged(t, "refused uploads", before) + + for i := 2; i <= 3; i++ { + want(t, fmt.Sprintf("upload %d", i), env.a.upload(t, photosPath(0, ""), fmt.Sprintf("%d.png", i), png, sk(key)), http.StatusCreated) + } + rec = env.a.upload(t, photosPath(0, ""), "4.png", png, sk(key)) + want(t, "fourth upload over maxFiles", rec, http.StatusUnprocessableEntity) + if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not have more than 3 items." { + t.Fatalf("maxFiles message %v", d) + } + items := fileItems(t, env.a, 0, "photos", key) + if len(items) != 3 || !slices.IsSortedFunc(items, func(a, b cabana.FileItem) int { return a.SortOrder - b.SortOrder }) { + t.Fatalf("pending list = %+v", items) + } + for _, it := range items { + if !it.Pending || it.URL == "" || it.ThumbURL == "" { + t.Fatalf("pending public item %+v", it) + } + } + + // After the save the same files are attached and no longer pending. + saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, sk(key)) + want(t, "save", saved, http.StatusCreated) + g := dataID(t, saved.Body.Bytes()) + attached := fileItems(t, env.a, g, "photos", "") + if fmt.Sprint(itemIDs(attached)) != fmt.Sprint(itemIDs(items)) { + t.Fatalf("attached %v, pending was %v", itemIDs(attached), itemIDs(items)) + } + for _, it := range attached { + if it.Pending { + t.Fatalf("attached item still pending %+v", it) + } + } + // A new session sees the attached files plus its own pending upload. + k2 := newSessionKey(t) + want(t, "upload on a full field", env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2)), http.StatusUnprocessableEntity) + first := attached[0].ID + want(t, "remove one", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", first)), nil, sk(k2)), http.StatusOK) + up = env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2)) + want(t, "upload after a deferred removal", up, http.StatusCreated) + mixed := fileItems(t, env.a, g, "photos", k2) + if len(mixed) != 3 || mixed[2].ID != dataID(t, up.Body.Bytes()) || !mixed[2].Pending || mixed[0].Pending || slices.Contains(itemIDs(mixed), first) { + t.Fatalf("session list = %+v", mixed) + } + if got := fileItems(t, env.a, g, "photos", ""); len(got) != 3 || !slices.Contains(itemIDs(got), first) { + t.Fatalf("list without the session = %v", itemIDs(got)) + } +} + +// TestFileuploadRemove: removing a pending upload deletes its row and blob +// at once; removing an attached file is deferred to the save, which then +// deletes row and blob after commit. +func TestFileuploadRemove(t *testing.T) { + env := newDeferredEnv(t) + g := env.gadget(t, "g-"+env.stamp, false) + attached := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true) + key := newSessionKey(t) + up := env.a.upload(t, photosPath(g, ""), "p.png", conformPNG(t), sk(key)) + want(t, "upload", up, http.StatusCreated) + pending, _ := env.fileRow(t, dataID(t, up.Body.Bytes())) + + want(t, "remove pending", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", pending.ID)), nil, sk(key)), http.StatusOK) + if _, ok := env.fileRow(t, pending.ID); ok || env.blob(t, pending) { + t.Fatal("removed pending upload kept its row or blob") + } + want(t, "remove attached", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK) + want(t, "remove attached again", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK) + if _, ok := env.fileRow(t, attached.ID); !ok || !env.blob(t, attached) { + t.Fatal("a deferred removal deleted the file before the save") + } + if got := fileItems(t, env.a, g, "photos", key); len(got) != 0 { + t.Fatalf("session still lists %v", itemIDs(got)) + } + want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK) + if _, ok := env.fileRow(t, attached.ID); ok || env.blob(t, attached) { + t.Fatal("the save kept the removed file or its blob") + } + if n := len(env.state(t).Bindings); n != 0 { + t.Fatalf("bindings left %d", n) + } + want(t, "remove without a key", env.a.do(t, http.MethodDelete, photosPath(g, "/1"), nil, nil), http.StatusUnprocessableEntity) +} + +// TestFileuploadCaption: a field without useCaption refuses caption edits +// (403); with it the title and description are saved at once, not +// deferred, and are limited in length. +func TestFileuploadCaption(t *testing.T) { + env := newDeferredEnv(t) + g := env.gadget(t, "g-"+env.stamp, false) + photo := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true) + manual := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("manual"), false) + key := newSessionKey(t) + want(t, "caption without useCaption", env.a.do(t, http.MethodPut, photosPath(g, fmt.Sprintf("/%d", photo.ID)), map[string]any{"title": "x"}, sk(key)), http.StatusForbidden) + rec := env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "Manual", "description": "The full manual"}, sk(key)) + want(t, "caption", rec, http.StatusOK) + f, _ := env.fileRow(t, manual.ID) + if strOrNil(f.Title) != "Manual" || strOrNil(f.Description) != "The full manual" { + t.Fatalf("caption not saved at once: %+v", f) + } + want(t, "caption with an unknown key", env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "x", "sort_order": 1}, sk(key)), http.StatusUnprocessableEntity) +} + +// TestFileuploadReorder: the id set must equal the visible files exactly +// (422 otherwise), the new order is written to sort_order at once, and an +// attachOne field has no reorder. +func TestFileuploadReorder(t *testing.T) { + env := newDeferredEnv(t) + g := env.gadget(t, "g-"+env.stamp, false) + a := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true) + b := env.storeFile(t, dfGadgetMorph, g, "photos", "b.png", conformPNG(t), true) + c := env.storeFile(t, dfGadgetMorph, g, "photos", "c.png", conformPNG(t), true) + key := newSessionKey(t) + for name, ids := range map[string][]uint{ + "missing one": {a.ID, b.ID}, + "extra id": {a.ID, b.ID, c.ID, c.ID + 100}, + "duplicate id": {a.ID, a.ID, b.ID}, + "empty": {}, + } { + before := env.state(t) + want(t, "reorder "+name, env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": ids}, sk(key)), http.StatusUnprocessableEntity) + env.unchanged(t, "reorder "+name, before) + } + rec := env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": []uint{c.ID, a.ID, b.ID}}, sk(key)) + want(t, "reorder", rec, http.StatusOK) + if got := itemIDs(fileItems(t, env.a, g, "photos", "")); fmt.Sprint(got) != fmt.Sprint([]uint{c.ID, a.ID, b.ID}) { + t.Fatalf("order = %v", got) + } + // The permutation reuses the existing sort_order values. + var orders []int + if err := env.db.Model(&attach.File{}).Where("id IN ?", []uint{a.ID, b.ID, c.ID}).Order("sort_order").Pluck("sort_order", &orders).Error; err != nil { + t.Fatal(err) + } + if fmt.Sprint(orders) != fmt.Sprint([]int{a.SortOrder, b.SortOrder, c.SortOrder}) { + t.Fatalf("sort orders %v, want the permutation of %d %d %d", orders, a.SortOrder, b.SortOrder, c.SortOrder) + } + m := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("m"), false) + rec = env.a.do(t, http.MethodPost, dfPath(g, "/files/manual/reorder"), map[string]any{"ids": []uint{m.ID}}, sk(key)) + if rec.Code/100 == 2 { + t.Fatalf("attachOne reorder status=%d", rec.Code) + } +} + +// TestFileuploadAttachOneReplace: two uploads into an attachOne field in +// one session leave the last one attached at the save; the replaced file +// and the file attached before lose their rows and blobs after commit. +func TestFileuploadAttachOneReplace(t *testing.T) { + env := newDeferredEnv(t) + g := env.gadget(t, "g-"+env.stamp, false) + old := env.storeFile(t, dfGadgetMorph, g, "manual", "old.txt", []byte("old"), false) + key := newSessionKey(t) + upA := env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("a"), sk(key)) + upB := env.a.upload(t, dfPath(g, "/files/manual"), "b.txt", []byte("b"), sk(key)) + want(t, "upload a", upA, http.StatusCreated) + want(t, "upload b", upB, http.StatusCreated) + a, _ := env.fileRow(t, dataID(t, upA.Body.Bytes())) + want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK) + got := fileItems(t, env.a, g, "manual", "") + if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) || got[0].URL != "" { + t.Fatalf("attachOne after save = %+v", got) + } + for _, f := range []attach.File{old, a} { + if _, ok := env.fileRow(t, f.ID); ok || env.blob(t, f) { + t.Fatalf("replaced file %s kept its row or blob", f.FileName) + } + } +} + +// TestFileuploadMIME: a mimeTypes list refuses an allowed extension whose +// sniffed type is not listed (422 on the field). +func TestFileuploadMIME(t *testing.T) { + fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " fileTypes: [pdf, png, svg, txt, html]\n", " fileTypes: [pdf, png, svg, txt, html]\n mimeTypes: [application/pdf, text/plain]\n", 1) + env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})}) + g := env.gadget(t, "g-"+env.stamp, false) + key := newSessionKey(t) + rec := env.a.upload(t, dfPath(g, "/files/manual"), "a.png", conformPNG(t), sk(key)) + want(t, "png under a pdf/text list", rec, http.StatusUnprocessableEntity) + if d := errorDetails(t, rec)["manual"]; len(d) != 1 || d[0] != "The manual must be a file of type: application/pdf, text/plain." { + t.Fatalf("mime message %v", d) + } + want(t, "text", env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("hello"), sk(key)), http.StatusCreated) +} diff --git a/modules/cabana/phase122_fixture_test.go b/modules/cabana/phase122_fixture_test.go index 003e015..a1e62d6 100644 --- a/modules/cabana/phase122_fixture_test.go +++ b/modules/cabana/phase122_fixture_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "sync" "testing" + "testing/fstest" "time" "git.golem15.com/golem15/summercms/modules/backpack" @@ -59,11 +60,13 @@ type dfGadget struct { Hidden bool `gorm:"column:hidden;not null;default:false"` ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"` StartsAt *time.Time `gorm:"column:starts_at"` - OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time"` + OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time without time zone"` Members []dfMember `gorm:"-"` Parts []dfPart `gorm:"-"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` + // StrictParts backs a test-only hasMany on the NOT NULL dfStrictPart. + StrictParts []dfStrictPart `gorm:"-"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` } func (dfGadget) TableName() string { return "cabana_deferred_gadgets" } @@ -183,13 +186,24 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error { return nil } -type dfPlugin struct{ rec *dfRecorder } +// dfPlugin is the fixture plugin. The zero options give the testdata tree, +// the gadgets and locked controllers and the full Models() list; a test +// may replace the tree (dfOverlay), the controllers or the models. +type dfPlugin struct { + rec *dfRecorder + fsys fs.FS + controllers func(rec *dfRecorder) []pact.AdminController + noModels bool +} func (dfPlugin) ID() string { return "acme.deferred" } func (dfPlugin) Requires() []string { return nil } func (dfPlugin) Register(*backpack.App) error { return nil } func (dfPlugin) Boot(*backpack.App) error { return nil } func (p dfPlugin) AdminControllers() []pact.AdminController { + if p.controllers != nil { + return p.controllers(p.rec) + } return []pact.AdminController{ dfController{rec: p.rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets"}, dfController{rec: p.rec, id: "acme.deferred.locked", dir: "controllers/locked"}, @@ -197,7 +211,12 @@ func (p dfPlugin) AdminControllers() []pact.AdminController { } // Models lists every fixture model deferred:purge may delete. -func (dfPlugin) Models() []any { return []any{&dfGadget{}, &dfPart{}, &dfMember{}} } +func (p dfPlugin) Models() []any { + if p.noModels { + return nil + } + return []any{&dfGadget{}, &dfPart{}, &dfMember{}} +} func (dfPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.deferred.access", Roles: []string{"developer"}}} @@ -207,13 +226,84 @@ func (dfPlugin) Navigation() []pact.NavigationItem { Permissions: []string{"acme.deferred.access"}}} } -// AdminFS is the YAML tree under testdata/deferred. -func (dfPlugin) AdminFS() fs.FS { return os.DirFS(filepath.Join("testdata", "deferred")) } +// AdminFS is the YAML tree under testdata/deferred, or the test's overlay. +func (p dfPlugin) AdminFS() fs.FS { + if p.fsys != nil { + return p.fsys + } + return os.DirFS(filepath.Join("testdata", "deferred")) +} + +// dfOverlay is the testdata tree with files replaced; an empty value +// removes the file. +func dfOverlay(t *testing.T, files map[string]string) fs.FS { + t.Helper() + base := os.DirFS(filepath.Join("testdata", "deferred")) + out := fstest.MapFS{} + err := fs.WalkDir(base, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := fs.ReadFile(base, path) + if err != nil { + return err + } + out[path] = &fstest.MapFile{Data: data} + return nil + }) + if err != nil { + t.Fatal(err) + } + for name, data := range files { + if data == "" { + delete(out, name) + continue + } + out[name] = &fstest.MapFile{Data: []byte(data)} + } + return out +} + +// dfFile reads one testdata file, for a test that edits it. +func dfFile(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "deferred", filepath.FromSlash(name))) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// activateDeferred boots cabana with the fixture plugin p (no database) +// and returns the boot error. +func activateDeferred(t *testing.T, p dfPlugin, config map[string]any) error { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-deferred-boot\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) + if err != nil { + t.Fatal(err) + } + for k, v := range config { + if err := cfg.Set(k, v); err != nil { + t.Fatal(err) + } + } + if p.rec == nil { + p.rec = &dfRecorder{fail: map[string]bool{}} + } + _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p}) + return err +} type dfController struct { rec *dfRecorder id string dir string + // contracts, when set, replaces the relation contracts. + contracts func() []cabana.RelationContract } func (c dfController) ID() string { return c.id } @@ -221,7 +311,16 @@ func (dfController) ModelName() string { return "Gadget" } func (c dfController) ConfigDir() string { return c.dir } func (dfController) RequiredPermissions() []string { return []string{"acme.deferred.access"} } func (dfController) NewRecord() any { return &dfGadget{} } -func (dfController) AdminRelationContracts() []cabana.RelationContract { +func (c dfController) AdminRelationContracts() []cabana.RelationContract { + if c.contracts != nil { + return c.contracts() + } + return dfContracts() +} + +// dfContracts are the fixture's relation contracts: members (belongsToMany +// by the empty Kind) and parts (hasMany on the nullable gadget_id). +func dfContracts() []cabana.RelationContract { return []cabana.RelationContract{{ Name: "members", NewRelated: func() any { return &dfMember{} }, NewPivot: func() any { return &dfGadgetMember{} }, ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"}, @@ -232,6 +331,15 @@ func (dfController) AdminRelationContracts() []cabana.RelationContract { }} } +// AdminActions registers one widget action, so a relation form's widget +// field reaches the D-23 refusal instead of failing on its action. +func (dfController) AdminActions() []pact.AdminAction { + return []pact.AdminAction{{Name: "lookup", Label: "Look up", Permissions: []string{"acme.deferred.access"}, + Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) { + return pact.AdminActionResult{}, nil + }}} +} + // FormExtendQuery hides gadgets marked hidden. func (dfController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return db.Where("hidden = ?", false) @@ -298,6 +406,13 @@ type dfClient struct { var dfModels = []any{&dfGadget{}, &dfPart{}, &dfMember{}, &dfGadgetMember{}} func newDeferredEnv(t *testing.T) *dfEnv { + t.Helper() + return newDeferredEnvWith(t, dfPlugin{}) +} + +// newDeferredEnvWith assembles the router over the plugin p (its recorder +// is replaced by the env's). +func newDeferredEnvWith(t *testing.T, p dfPlugin) *dfEnv { t.Helper() gdb := adminGorm(t) if err := gdb.Migrator().DropTable(dfModels...); err != nil { @@ -339,7 +454,8 @@ func newDeferredEnv(t *testing.T) *dfEnv { t.Fatal(err) } rec := &dfRecorder{fail: map[string]bool{}} - plugins := []party.Plugin{dfPlugin{rec: rec}} + p.rec = rec + plugins := []party.Plugin{p} if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) } @@ -572,11 +688,19 @@ func (e *dfEnv) state(t *testing.T) dfState { for _, q := range []struct { dest any order string - }{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}, {&s.Files, "id"}, {&s.Bindings, "id"}} { + }{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}} { if err := e.db.Order(q.order).Find(q.dest).Error; err != nil { t.Fatal(err) } } + // Other tests share the database: only the fixture's files (and the + // unattached ones, cleared when the env starts) and bindings count. + if err := e.db.Where("attachment_type IN ? OR attachment_id IS NULL OR attachment_id = ''", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Files).Error; err != nil { + t.Fatal(err) + } + if err := e.db.Where("master_type IN ?", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Bindings).Error; err != nil { + t.Fatal(err) + } if err := e.db.Unscoped().Order("id").Find(&s.Parts).Error; err != nil { t.Fatal(err) } diff --git a/modules/cabana/relation_child_test.go b/modules/cabana/relation_child_test.go new file mode 100644 index 0000000..725c0d0 --- /dev/null +++ b/modules/cabana/relation_child_test.go @@ -0,0 +1,588 @@ +package cabana_test + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// dfStrictPart is a part whose gadget_id is NOT NULL: its hasMany is not +// deferrable and cannot be unlinked. +type dfStrictPart struct { + ID uint `gorm:"column:id;primaryKey"` + GadgetID uint `gorm:"column:gadget_id"` + Label string `gorm:"column:label"` + DueOn *lagoon.Date `gorm:"column:due_on;type:date"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (dfStrictPart) TableName() string { return "cabana_deferred_parts" } +func (dfStrictPart) MorphName() string { return dfPartMorph } +func (dfStrictPart) Fillable() []string { return []string{"label", "due_on"} } +func (dfStrictPart) Rules() map[string]string { return map[string]string{} } +func (dfStrictPart) AttachRelations() []attach.Relation { + return []attach.Relation{{Name: "images", Many: true}, {Name: "sheet"}} +} + +// withContracts is a plugin whose gadgets controller uses contracts. +func withContracts(fsys func(*testing.T) map[string]string, contracts func() []cabana.RelationContract) func(t *testing.T) dfPlugin { + return func(t *testing.T) dfPlugin { + p := dfPlugin{controllers: func(rec *dfRecorder) []pact.AdminController { + return []pact.AdminController{dfController{rec: rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets", contracts: contracts}} + }} + if fsys != nil { + p.fsys = dfOverlay(t, fsys(t)) + } + return p + } +} + +// TestRelationChildContract: hasMany and belongsToMany contracts are +// validated at boot (D-11). +func TestRelationChildContract(t *testing.T) { + part := func(mut func(*cabana.RelationContract)) func() []cabana.RelationContract { + return func() []cabana.RelationContract { + cs := dfContracts() + mut(&cs[1]) + return cs + } + } + member := func(mut func(*cabana.RelationContract)) func() []cabana.RelationContract { + return func() []cabana.RelationContract { + cs := dfContracts() + mut(&cs[0]) + return cs + } + } + for name, tc := range map[string]struct { + contracts func() []cabana.RelationContract + want string + }{ + "hasMany with a pivot": {part(func(c *cabana.RelationContract) { c.NewPivot = func() any { return &dfGadgetMember{} } }), + "relation parts: a hasMany contract cannot declare NewPivot, ParentForeignKey, RelatedForeignKey or HookPivotColumns"}, + "hasMany with hook columns": {part(func(c *cabana.RelationContract) { c.HookPivotColumns = []string{"role"} }), "a hasMany contract cannot declare"}, + "hasMany without ForeignKey": {part(func(c *cabana.RelationContract) { c.ForeignKey = "" }), "relation parts: hasMany needs a ForeignKey column"}, + "hasMany text ForeignKey": {part(func(c *cabana.RelationContract) { c.ForeignKey = "label" }), "relation parts: ForeignKey label must be an integer column, found string"}, + "hasMany missing ForeignKey": {part(func(c *cabana.RelationContract) { c.ForeignKey = "owner_id" }), "relation parts target is missing column owner_id"}, + "unknown kind": {part(func(c *cabana.RelationContract) { c.Kind = "hasOne" }), `relation parts has unknown kind "hasOne"`}, + "belongsToMany ForeignKey": {member(func(c *cabana.RelationContract) { c.ForeignKey = "gadget_id" }), "relation members: a belongsToMany contract cannot declare ForeignKey"}, + "belongsToMany no pivot": {member(func(c *cabana.RelationContract) { c.NewPivot = nil }), "relation members requires target and pivot models"}, + } { + t.Run(name, func(t *testing.T) { + err := activateDeferred(t, withContracts(nil, tc.contracts)(t), nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } + + // A hasMany on a NOT NULL key (dfStrictPart) cannot declare unlink and + // is not deferrable. + strict := func() []cabana.RelationContract { + return append(dfContracts(), cabana.RelationContract{ + Name: "strictparts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &dfStrictPart{} }, + ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"}, + }) + } + strictFiles := func(buttons string) func(t *testing.T) map[string]string { + return func(t *testing.T) map[string]string { + return map[string]string{ + "controllers/gadgets/config_relation.yaml": dfFile(t, "controllers/gadgets/config_relation.yaml") + `strictparts: + label: Strict parts + view: + list: + columns: + label: + label: Label + toolbarButtons: ` + buttons + ` + manage: + form: $/acme/deferred/models/part/fields.yaml +`, + "models/gadget/fields.yaml": dfFile(t, "models/gadget/fields.yaml") + " strictparts:\n type: relation-manager\n relation: strictparts\n", + } + } + } + err := activateDeferred(t, withContracts(strictFiles("create|update|delete|link|unlink"), strict)(t), nil) + if err == nil || !strings.Contains(err.Error(), "relation strictparts: unlink needs a nullable ForeignKey gadget_id (a pointer field)") { + t.Fatalf("unlink on a NOT NULL key: %v", err) + } + env := newDeferredEnvWith(t, withContracts(strictFiles("create|update|delete|link"), strict)(t)) + rec := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/relation/strictparts", nil, nil) + var s cabana.Envelope[cabana.RelationSchema] + if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil || s.Data.Kind != cabana.RelationHasMany || s.Data.Deferrable { + t.Fatalf("NOT NULL hasMany schema = %s", rec.Body.String()) + } + want(t, "create on id 0 of a non-deferrable relation", env.a.do(t, http.MethodPost, dfPath(0, "/relations/strictparts/records"), map[string]any{"label": "x"}, sk(newSessionKey(t))), http.StatusNotFound) +} + +// TestRelationChildZeroKind: a contract with an empty Kind behaves exactly +// like an explicit belongsToMany: same schema kind, same linked and +// candidate lists, and link and unlink write the same pivot rows (D-11). +func TestRelationChildZeroKind(t *testing.T) { + explicit := func() []cabana.RelationContract { + cs := dfContracts() + cs[0].Kind = cabana.RelationBelongsToMany + return cs + } + env := newDeferredEnvWith(t, dfPlugin{controllers: func(rec *dfRecorder) []pact.AdminController { + return []pact.AdminController{ + dfController{rec: rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets"}, + dfController{rec: rec, id: "acme.deferred.explicit", dir: "controllers/gadgets", contracts: explicit}, + } + }}) + if dfContracts()[0].Kind != "" { + t.Fatal("the fixture's members contract must use the empty Kind") + } + g := env.gadget(t, "g-"+env.stamp, false) + m1 := env.member(t, "a-"+env.stamp+"@example.test") + m2 := env.member(t, "b-"+env.stamp+"@example.test") + m3 := env.member(t, "c-"+env.stamp+"@example.test") + env.pivot(t, g, m1, "seed") + path := func(ctl, rest string) string { + return fmt.Sprintf("/acme/deferred/%s/%d/relations/members%s", ctl, g, rest) + } + body := func(ctl, rest string) string { + rec := env.a.do(t, http.MethodGet, path(ctl, rest), nil, nil) + want(t, ctl+rest, rec, http.StatusOK) + return rec.Body.String() + } + for _, rest := range []string{"", "/candidates", "?sort=email&dir=desc", "/candidates?search=b-"} { + if a, b := body("gadgets", rest), body("explicit", rest); a != b { + t.Fatalf("%s differs:\nzero kind %s\nexplicit %s", rest, a, b) + } + } + schema := func(ctl string) (kind string, deferrable bool) { + rec := env.a.do(t, http.MethodGet, "/acme/deferred/"+ctl+"/schema/relation/members", nil, nil) + var s cabana.Envelope[cabana.RelationSchema] + if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil { + t.Fatal(err) + } + return s.Data.Kind, s.Data.Deferrable + } + if k, d := schema("gadgets"); k != cabana.RelationBelongsToMany || !d { + t.Fatalf("zero kind schema %s %v", k, d) + } + if k, d := schema("explicit"); k != cabana.RelationBelongsToMany || !d { + t.Fatalf("explicit schema %s %v", k, d) + } + want(t, "link through the zero kind", env.a.do(t, http.MethodPost, path("gadgets", "/link"), map[string]any{"ids": []uint{m2}}, nil), http.StatusOK) + want(t, "link through the explicit kind", env.a.do(t, http.MethodPost, path("explicit", "/link"), map[string]any{"ids": []uint{m3}}, nil), http.StatusOK) + var rows []dfGadgetMember + if err := env.db.Where("gadget_id = ?", g).Order("member_id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 3 || rows[1].Role != "linked" || rows[2].Role != "linked" { + t.Fatalf("pivot rows %+v", rows) + } + want(t, "unlink through the explicit kind", env.a.do(t, http.MethodPost, path("explicit", "/unlink"), map[string]any{"ids": []uint{m2}}, nil), http.StatusOK) + want(t, "unlink through the zero kind", env.a.do(t, http.MethodPost, path("gadgets", "/unlink"), map[string]any{"ids": []uint{m3}}, nil), http.StatusOK) + if a, b := body("gadgets", ""), body("explicit", ""); a != b || !strings.Contains(a, fmt.Sprintf(`"id":%d`, m1)) || strings.Contains(a, fmt.Sprintf(`"id":%d,`, m2)) { + t.Fatalf("after unlink: %s / %s", a, b) + } +} + +// relationYAML edits the gadgets config_relation.yaml. +func relationYAML(t *testing.T, edit func(string) string) map[string]string { + return map[string]string{"controllers/gadgets/config_relation.yaml": edit(dfFile(t, "controllers/gadgets/config_relation.yaml"))} +} + +// TestRelationChildForms: relation forms compile from manage.form, +// view.form or the top-level form, resolve $/ paths inside the plugin +// only, refuse the D-23 field types and server-owned columns, and enforce +// the toolbar rules (D-11, D-12, D-16, D-23). +func TestRelationChildForms(t *testing.T) { + partFields := func(extra string) map[string]string { + return map[string]string{"models/part/fields.yaml": "fields:\n label:\n type: text\n" + extra} + } + pivotFields := func(extra string) map[string]string { + return map[string]string{"models/member/pivot_fields.yaml": "fields:\n pivot[note]:\n type: text\n" + extra} + } + replace := func(old, new string) func(t *testing.T) map[string]string { + return func(t *testing.T) map[string]string { + return relationYAML(t, func(s string) string { + if !strings.Contains(s, old) { + t.Fatalf("fixture lacks %q", old) + } + return strings.Replace(s, old, new, 1) + }) + } + } + for name, tc := range map[string]struct { + files func(t *testing.T) map[string]string + want string + }{ + "relation field": {func(*testing.T) map[string]string { return partFields(" owner:\n type: relation\n") }, "field owner: type relation is not supported in a relation form (manage)"}, + "relation-manager field": {func(*testing.T) map[string]string { + return partFields(" more:\n type: relation-manager\n relation: x\n") + }, "type relation-manager is not supported in a relation form"}, + "widget field": {func(*testing.T) map[string]string { + return partFields(" w:\n type: widget\n widget: acme-x\n action: lookup\n") + }, "field w: type widget is not supported in a relation form (manage)"}, + "partial field": {func(*testing.T) map[string]string { + return partFields(" p:\n type: partial\n path: x\n") + }, "type partial is not supported in a relation form"}, + "foreign key field": {func(*testing.T) map[string]string { return partFields(" gadget_id:\n type: number\n") }, "field gadget_id is the relation's ForeignKey; the server sets it"}, + "pivot foreign key": {func(*testing.T) map[string]string { + return pivotFields(" pivot[gadget_id]:\n type: number\n") + }, "field gadget_id is a server-owned pivot column"}, + "pivot hook column": {func(*testing.T) map[string]string { return pivotFields(" pivot[role]:\n type: text\n") }, "field role is a server-owned pivot column"}, + "pivot fileupload": {func(*testing.T) map[string]string { return pivotFields(" pivot[doc]:\n type: fileupload\n") }, "type fileupload is not supported in a pivot form"}, + "cross-plugin path": {replace("form: $/acme/deferred/models/part/fields.yaml", "form: $/acme/other/models/part/fields.yaml"), "relation parts manage form"}, + "missing form file": {replace("form: $/acme/deferred/models/part/fields.yaml", "form: $/acme/deferred/models/part/nothere.yaml"), "relation parts manage form"}, + "create without a form": {replace(" manage:\n form: $/acme/deferred/models/part/fields.yaml\n", " manage:\n"), "relation parts: toolbar buttons create and update need manage.form (or a top-level form)"}, + "unknown button": {replace("create|update|delete|link|unlink\n manage:\n form: $/acme/deferred/models/part", "create|edit\n manage:\n form: $/acme/deferred/models/part"), "unsupported relation action edit"}, + "duplicate button": {replace("create|update|delete|link|unlink\n manage:\n form: $/acme/deferred/models/part", "link|link\n manage:\n form: $/acme/deferred/models/part"), "duplicate relation action link"}, + "manage panel button": {replace(" manage:\n form: $/acme/deferred/models/part/fields.yaml\n", " manage:\n form: $/acme/deferred/models/part/fields.yaml\n toolbarButtons: create\n"), "manage panel cannot declare create (only link)"}, + "pivot on hasMany": {func(t *testing.T) map[string]string { + return relationYAML(t, func(s string) string { return s + " pivot:\n form: models/member/pivot_fields.yaml\n" }) + }, "relation parts: pivot.form is only valid on a belongsToMany relation"}, + "pivot without a form": {replace(" pivot:\n form: $/acme/deferred/models/member/pivot_fields.yaml\n", " pivot:\n form: ''\n"), "relation members: pivot needs a form"}, + } { + t.Run(name, func(t *testing.T) { + err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, tc.files(t))}, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } + + // The top-level form is the manage and the view form; a view.form + // alone lets the relation preview its rows read-only. + fallback := relationYAML(t, func(s string) string { + s = strings.Replace(s, " manage:\n form: $/acme/deferred/models/part/fields.yaml\n", " form: $/acme/deferred/models/part/fields.yaml\n manage:\n", 1) + return strings.Replace(s, " toolbarButtons: create|update|delete|link|unlink\n showSearch: true\n manage:\n form: $/acme/deferred/models/member/fields.yaml\n", + " toolbarButtons: link|unlink\n showSearch: true\n form: $/acme/deferred/models/member/fields.yaml\n manage:\n", 1) + }) + env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, fallback)}) + rec := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/relation/parts", nil, nil) + var s cabana.Envelope[cabana.RelationSchema] + if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil || len(s.Data.ManageForm) == 0 || len(s.Data.ViewForm) == 0 { + t.Fatalf("top-level form fallback: %s", rec.Body.String()) + } + g := env.gadget(t, "g-"+env.stamp, false) + m := env.member(t, "m-"+env.stamp+"@example.test") + env.pivot(t, g, m, "n") + shown := env.a.do(t, http.MethodGet, dfPath(g, fmt.Sprintf("/relations/members/records/%d", m)), nil, nil) + want(t, "preview through view.form", shown, http.StatusOK) + if !strings.Contains(shown.Body.String(), `"email":"m-`) { + t.Fatalf("preview = %s", shown.Body.String()) + } + want(t, "update without the update button", env.a.do(t, http.MethodPut, dfPath(g, fmt.Sprintf("/relations/members/records/%d", m)), map[string]any{"email": "x"}, nil), http.StatusForbidden) +} + +// TestRelationChildCRUD walks create, show, update and delete for both +// kinds on a saved gadget: hasMany children get the parent's key from the +// server, hasMany link adopts free rows only and unlink clears the key, a +// hasMany delete runs the model's hooks and soft delete, a belongsToMany +// delete removes the pivot row before the related record, and the relation +// hooks run in order around each write (D-11, D-12, D-14, D-16). +func TestRelationChildCRUD(t *testing.T) { + env := newDeferredEnv(t) + g := env.gadget(t, "g-"+env.stamp, false) + other := env.gadget(t, "o-"+env.stamp, false) + parts := func(rest string) string { return dfPath(g, "/relations/parts"+rest) } + members := func(rest string) string { return dfPath(g, "/relations/members"+rest) } + + t.Run("hasMany", func(t *testing.T) { + env.rec.reset() + created := env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "wheel", "due_on": "2026-11-01", "gadget_id": other}, nil) + want(t, "create", created, http.StatusCreated) + id := dataID(t, created.Body.Bytes()) + if p, _ := env.partRow(t, id); p.GadgetID == nil || *p.GadgetID != g || p.DueOn == nil || p.DueOn.String() != "2026-11-01" { + t.Fatalf("created part %+v", p) + } + want(t, "create below the part's minDate", env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "old", "due_on": "2019-01-01"}, nil), http.StatusUnprocessableEntity) + shown := env.a.do(t, http.MethodGet, parts(fmt.Sprintf("/records/%d", id)), nil, nil) + want(t, "show", shown, http.StatusOK) + if !strings.Contains(shown.Body.String(), `"label":"wheel"`) { + t.Fatalf("show = %s", shown.Body.String()) + } + want(t, "update", env.a.do(t, http.MethodPut, parts(fmt.Sprintf("/records/%d", id)), map[string]any{"label": "tyre"}, nil), http.StatusOK) + if p, _ := env.partRow(t, id); p.Label != "tyre" || *p.GadgetID != g { + t.Fatalf("updated part %+v", p) + } + + free := env.part(t, 0, "free") + owned := env.part(t, other, "owned") + cands := dfIDs(t, env.a.do(t, http.MethodGet, parts("/candidates"), nil, nil)) + if !slices.Contains(cands, free) || slices.Contains(cands, owned) || slices.Contains(cands, id) { + t.Fatalf("candidates %v (free %d owned %d own %d)", cands, free, owned, id) + } + want(t, "link an owned part", env.a.do(t, http.MethodPost, parts("/link"), map[string]any{"ids": []uint{owned}}, nil), http.StatusUnprocessableEntity) + want(t, "link a free part", env.a.do(t, http.MethodPost, parts("/link"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK) + if p, _ := env.partRow(t, free); p.GadgetID == nil || *p.GadgetID != g { + t.Fatalf("linked part %+v", p) + } + want(t, "unlink", env.a.do(t, http.MethodPost, parts("/unlink"), map[string]any{"ids": []uint{free}}, nil), http.StatusOK) + if p, ok := env.partRow(t, free); !ok || p.GadgetID != nil || p.DeletedAt.Valid { + t.Fatalf("unlinked part %+v (present %v), want kept with a NULL key", p, ok) + } + + dfPartHooks.reset() + want(t, "delete", env.a.do(t, http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{id}}, nil), http.StatusOK) + if p, ok := env.partRow(t, id); !ok || !p.DeletedAt.Valid { + t.Fatalf("deleted part %+v (present %v), want soft deleted", p, ok) + } + if got := dfPartHooks.snapshot(); fmt.Sprint(got) != fmt.Sprintf("[BeforeDelete:%d]", id) { + t.Fatalf("model hooks %v", got) + } + wantCalls := []string{ + "RelationBeforeCreate:parts", "RelationAfterCreate:parts", + "RelationBeforeUpdate:parts", "RelationAfterUpdate:parts", + "RelationBeforeDelete:parts", "RelationAfterDelete:parts", + } + if got := env.rec.snapshot(); fmt.Sprint(got) != fmt.Sprint(wantCalls) { + t.Fatalf("relation hooks %v\nwant %v", got, wantCalls) + } + want(t, "show a deleted part", env.a.do(t, http.MethodGet, parts(fmt.Sprintf("/records/%d", id)), nil, nil), http.StatusNotFound) + }) + + t.Run("belongsToMany", func(t *testing.T) { + // A real foreign key from the pivot to the member proves the delete + // order: deleting the member first would violate it. + if err := env.db.Exec(`ALTER TABLE cabana_deferred_gadget_members ADD CONSTRAINT df_pivot_member FOREIGN KEY (member_id) REFERENCES cabana_deferred_members (id)`).Error; err != nil { + t.Fatal(err) + } + env.rec.reset() + created := env.a.do(t, http.MethodPost, members("/records"), map[string]any{"email": "new-" + env.stamp + "@example.test"}, nil) + want(t, "create", created, http.StatusCreated) + id := dataID(t, created.Body.Bytes()) + var row dfGadgetMember + if err := env.db.Where("gadget_id = ? AND member_id = ?", g, id).Take(&row).Error; err != nil || row.Role != "linked" { + t.Fatalf("pivot of a created member %+v (%v)", row, err) + } + want(t, "update", env.a.do(t, http.MethodPut, members(fmt.Sprintf("/records/%d", id)), map[string]any{"email": "renamed-" + env.stamp + "@example.test"}, nil), http.StatusOK) + want(t, "delete", env.a.do(t, http.MethodPost, members("/delete"), map[string]any{"ids": []uint{id}}, nil), http.StatusOK) + var n int64 + env.db.Model(&dfMember{}).Where("id = ?", id).Count(&n) + var p int64 + env.db.Model(&dfGadgetMember{}).Where("member_id = ?", id).Count(&p) + if n != 0 || p != 0 { + t.Fatalf("after delete: members %d pivots %d", n, p) + } + wantCalls := []string{ + "RelationBeforeCreate:members", "RelationBeforeLink:members", "RelationAfterCreate:members", + "RelationBeforeUpdate:members", "RelationAfterUpdate:members", + "RelationBeforeDelete:members", "RelationAfterDelete:members", + } + if got := env.rec.snapshot(); fmt.Sprint(got) != fmt.Sprint(wantCalls) { + t.Fatalf("relation hooks %v\nwant %v", got, wantCalls) + } + + m1 := env.member(t, "p1-"+env.stamp+"@example.test") + m2 := env.member(t, "p2-"+env.stamp+"@example.test") + want(t, "link two with pivot values", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m1, m2}, "pivot": map[string]any{"note": "x"}}, nil), http.StatusUnprocessableEntity) + want(t, "link with an unknown pivot key", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m1}, "pivot": map[string]any{"colour": "x"}}, nil), http.StatusUnprocessableEntity) + want(t, "link with a note", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m1}, "pivot": map[string]any{"note": "hello"}}, nil), http.StatusOK) + var linked dfGadgetMember + if err := env.db.Where("gadget_id = ? AND member_id = ?", g, m1).Take(&linked).Error; err != nil || linked.Note != "hello" || linked.Role != "linked" { + t.Fatalf("pivot row %+v (%v)", linked, err) + } + pivot := env.a.do(t, http.MethodGet, members(fmt.Sprintf("/pivot/%d", m1)), nil, nil) + want(t, "pivot show", pivot, http.StatusOK) + var pv struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(pivot.Body.Bytes(), &pv); err != nil || pv.Data["note"] != "hello" || pv.Data["id"] != float64(m1) || len(pv.Data) != 2 { + t.Fatalf("pivot show = %s", pivot.Body.String()) + } + want(t, "pivot of an unlinked member", env.a.do(t, http.MethodGet, members(fmt.Sprintf("/pivot/%d", m2)), nil, nil), http.StatusNotFound) + }) + + t.Run("hook failure rolls back", func(t *testing.T) { + env.rec.reset() + env.rec.failOn("RelationAfterCreate:parts") + before := env.state(t) + rec := env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "doomed"}, nil) + if rec.Code/100 == 2 { + t.Fatalf("a failing hook answered %d", rec.Code) + } + env.unchanged(t, "a create whose hook failed", before) + + id := env.part(t, g, "stays") + env.rec.reset() + env.rec.failOn("RelationAfterUpdate:parts") + before = env.state(t) + if rec := env.a.do(t, http.MethodPut, parts(fmt.Sprintf("/records/%d", id)), map[string]any{"label": "changed"}, nil); rec.Code/100 == 2 { + t.Fatalf("a failing update hook answered %d", rec.Code) + } + env.unchanged(t, "an update whose hook failed", before) + + env.rec.reset() + env.rec.failOn("RelationBeforeDelete:parts") + if rec := env.a.do(t, http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{id}}, nil); rec.Code/100 == 2 { + t.Fatalf("a failing delete hook answered %d", rec.Code) + } + env.unchanged(t, "a delete whose hook failed", before) + env.rec.reset() + }) + + t.Run("body cap", func(t *testing.T) { + huge := strings.Repeat("x", 1100<<10) + want(t, "child body past default_bytes", env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": huge}, nil), http.StatusRequestEntityTooLarge) + }) +} + +// TestRelationChildDeferred manages both relations on a gadget not saved +// yet (D-03, D-04, D-17): created, linked, unlinked and deleted children +// and pivot edits are held against the session key, a child form's files +// against its own child key, and the gadget's create save applies them all. +func TestRelationChildDeferred(t *testing.T) { + env := newDeferredEnv(t) + key := newSessionKey(t) + h := sk(key) + parts := func(rest string) string { return dfPath(0, "/relations/parts"+rest) } + members := func(rest string) string { return dfPath(0, "/relations/members"+rest) } + + // A part created in the session, with an image uploaded to child 0 + // under its own child key first. + childKey := newSessionKey(t) + both := map[string]string{cabana.SessionKeyHeader: key, cabana.ChildSessionKeyHeader: childKey} + up := env.a.upload(t, parts("/records/0/files/images"), "c.png", conformPNG(t), both) + want(t, "child 0 upload", up, http.StatusCreated) + sheet := env.a.upload(t, parts("/records/0/files/sheet"), "s.txt", []byte("sheet"), both) + want(t, "child 0 attachOne upload", sheet, http.StatusCreated) + want(t, "child 0 without the child key", env.a.upload(t, parts("/records/0/files/images"), "c.png", conformPNG(t), h), http.StatusUnprocessableEntity) + created := env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "pending"}, both) + want(t, "deferred create", created, http.StatusCreated) + pending := dataID(t, created.Body.Bytes()) + for _, f := range []uint{dataID(t, up.Body.Bytes()), dataID(t, sheet.Body.Bytes())} { + if row, _ := env.fileRow(t, f); row.AttachmentType != dfPartMorph || row.AttachmentID != fmt.Sprint(pending) { + t.Fatalf("child file %d attached to %s/%s", f, row.AttachmentType, row.AttachmentID) + } + } + if p, _ := env.partRow(t, pending); p.GadgetID != nil { + t.Fatalf("pending part owned by %d", *p.GadgetID) + } + + // An existing free part linked, another created then unlinked (it is + // deleted), a third created then deleted. + free := env.part(t, 0, "free") + stray := env.part(t, 0, "stray") + if got := dfIDs(t, env.a.do(t, http.MethodGet, parts("/candidates"), nil, h)); !slices.Contains(got, free) || slices.Contains(got, pending) { + t.Fatalf("candidates %v", got) + } + want(t, "deferred link", env.a.do(t, http.MethodPost, parts("/link"), map[string]any{"ids": []uint{free, stray}}, h), http.StatusOK) + want(t, "deferred unlink of a linked part", env.a.do(t, http.MethodPost, parts("/unlink"), map[string]any{"ids": []uint{stray}}, h), http.StatusOK) + if p, ok := env.partRow(t, stray); !ok || p.GadgetID != nil || p.DeletedAt.Valid { + t.Fatalf("unlinking a linked existing part touched it: %+v", p) + } + dropped := dataID(t, env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "dropped"}, h).Body.Bytes()) + want(t, "deferred unlink of a created part", env.a.do(t, http.MethodPost, parts("/unlink"), map[string]any{"ids": []uint{dropped}}, h), http.StatusOK) + if _, ok := env.partRow(t, dropped); ok { + if p, _ := env.partRow(t, dropped); !p.DeletedAt.Valid { + t.Fatal("an unlinked created part is still live") + } + } + deleted := dataID(t, env.a.do(t, http.MethodPost, parts("/records"), map[string]any{"label": "deleted"}, h).Body.Bytes()) + want(t, "deferred delete", env.a.do(t, http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{deleted}}, h), http.StatusOK) + if p, ok := env.partRow(t, deleted); ok && !p.DeletedAt.Valid { + t.Fatal("a deleted pending part is still live") + } + if got := dfIDs(t, env.a.do(t, http.MethodGet, parts(""), nil, h)); len(got) != 2 || !slices.Contains(got, pending) || !slices.Contains(got, free) { + t.Fatalf("pending parts %v, want [%d %d]", got, pending, free) + } + + // A member linked with a note, the note edited, then a server-owned + // key refused. + m := env.member(t, "m-"+env.stamp+"@example.test") + want(t, "deferred member link", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{m}, "pivot": map[string]any{"note": "a"}}, h), http.StatusOK) + want(t, "deferred pivot edit", env.a.do(t, http.MethodPut, members(fmt.Sprintf("/pivot/%d", m)), map[string]any{"note": "b"}, h), http.StatusOK) + want(t, "deferred pivot edit of a hook column", env.a.do(t, http.MethodPut, members(fmt.Sprintf("/pivot/%d", m)), map[string]any{"role": "admin"}, h), http.StatusUnprocessableEntity) + shown := env.a.do(t, http.MethodGet, members(fmt.Sprintf("/pivot/%d", m)), nil, h) + if !strings.Contains(shown.Body.String(), `"note":"b"`) { + t.Fatalf("pending pivot = %s", shown.Body.String()) + } + // A member created in the session. + newMember := dataID(t, env.a.do(t, http.MethodPost, members("/records"), map[string]any{"email": "n-" + env.stamp + "@example.test"}, h).Body.Bytes()) + + saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, h) + want(t, "save", saved, http.StatusCreated) + g := dataID(t, saved.Body.Bytes()) + for _, id := range []uint{pending, free} { + if p, _ := env.partRow(t, id); p.GadgetID == nil || *p.GadgetID != g { + t.Fatalf("part %d owner %v, want %d", id, p.GadgetID, g) + } + } + if p, _ := env.partRow(t, stray); p.GadgetID != nil { + t.Fatal("the unlinked part was attached") + } + var rows []dfGadgetMember + if err := env.db.Where("gadget_id = ?", g).Order("member_id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].MemberID != m || rows[0].Note != "b" || rows[0].Role != "linked" || rows[1].MemberID != newMember || rows[1].Role != "linked" { + t.Fatalf("pivot rows %+v", rows) + } + if n := len(env.state(t).Bindings); n != 0 { + t.Fatalf("bindings left %d", n) + } + + // On the saved gadget a child's files commit with the child's own save. + ck2 := newSessionKey(t) + up = env.a.upload(t, dfPath(g, "/relations/parts/records/0/files/images"), "d.png", conformPNG(t), ck(ck2)) + want(t, "child 0 upload under a saved gadget", up, http.StatusCreated) + child := env.a.do(t, http.MethodPost, dfPath(g, "/relations/parts/records"), map[string]any{"label": "with image"}, ck(ck2)) + want(t, "child create", child, http.StatusCreated) + if row, _ := env.fileRow(t, dataID(t, up.Body.Bytes())); row.AttachmentID != fmt.Sprint(dataID(t, child.Body.Bytes())) { + t.Fatalf("child file attached to %q", row.AttachmentID) + } +} + +// TestRelationChildSchema: the relation schema carries kind and +// deferrable, the relation-manager form field carries deferrable, and a +// belongsToMany manager is deferrable on the create screen unless its +// field says context: update (user decision, 12.2-04). +func TestRelationChildSchema(t *testing.T) { + env := newDeferredEnv(t) + for name, wantKind := range map[string]string{"parts": cabana.RelationHasMany, "members": cabana.RelationBelongsToMany} { + rec := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/relation/"+name, nil, nil) + var s cabana.Envelope[cabana.RelationSchema] + if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil || s.Data.Kind != wantKind || !s.Data.Deferrable || len(s.Data.ManageForm) == 0 { + t.Fatalf("%s schema = %s", name, rec.Body.String()) + } + } + form := env.a.do(t, http.MethodGet, "/acme/deferred/gadgets/schema/form", nil, nil) + var f cabana.Envelope[cabana.FormView] + if err := json.Unmarshal(form.Body.Bytes(), &f); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(f.Data) + for _, name := range []string{"parts", "members"} { + if !strings.Contains(string(raw), `"name":"`+name+`"`) { + t.Fatalf("form lacks %s", name) + } + } + if strings.Count(string(raw), `"deferrable":true`) < 2 { + t.Fatalf("relation-manager fields are not deferrable: %s", raw) + } + + fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " relation: members\n", " relation: members\n context: update\n", 1) + upd := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})}) + key := newSessionKey(t) + m := upd.member(t, "m-"+upd.stamp+"@example.test") + want(t, "members link on id 0 with context: update", upd.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}}, sk(key)), http.StatusNotFound) + want(t, "parts still deferrable", upd.a.do(t, http.MethodPost, dfPath(0, "/relations/parts/records"), map[string]any{"label": "x"}, sk(key)), http.StatusCreated) +} + +// TestRelationChildPurgeModels: a deferrable relation that creates children +// stops boot when no plugin lists its related model in Models(), so +// deferred:purge can always delete abandoned children (Pitfall 9). +func TestRelationChildPurgeModels(t *testing.T) { + err := activateDeferred(t, dfPlugin{noModels: true}, nil) + if err == nil || !strings.Contains(err.Error(), "records under deferral, but no activated plugin lists that model in Models()") { + t.Fatalf("err = %v", err) + } + if err := activateDeferred(t, dfPlugin{}, nil); err != nil { + t.Fatalf("listed models failed boot: %v", err) + } +} diff --git a/modules/cabana/testdata/deferred/controllers/locked/config_list.yaml b/modules/cabana/testdata/deferred/controllers/locked/config_list.yaml index 430906f..1f2ecb2 100644 --- a/modules/cabana/testdata/deferred/controllers/locked/config_list.yaml +++ b/modules/cabana/testdata/deferred/controllers/locked/config_list.yaml @@ -2,3 +2,5 @@ list: ~/plugins/acme/deferred/models/gadget/columns.yaml modelClass: Gadget title: Locked gadgets recordUrl: acme/deferred/locked/update/:id +toolbar: + buttons: [create] diff --git a/modules/cabana/testdata/deferred/models/gadget/fields.yaml b/modules/cabana/testdata/deferred/models/gadget/fields.yaml index 4085d28..7a6e1ee 100644 --- a/modules/cabana/testdata/deferred/models/gadget/fields.yaml +++ b/modules/cabana/testdata/deferred/models/gadget/fields.yaml @@ -30,6 +30,7 @@ fields: type: fileupload mode: image maxFiles: 3 + maxFilesize: 0.5 manual: label: Manual type: fileupload diff --git a/modules/conga/schedule_test.go b/modules/conga/schedule_test.go index adb4b81..7db543d 100644 --- a/modules/conga/schedule_test.go +++ b/modules/conga/schedule_test.go @@ -14,6 +14,7 @@ import ( "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" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "gorm.io/gorm" @@ -843,3 +844,171 @@ func TestFrameworkScheduleSmoke(t *testing.T) { t.Fatalf("malformed purge_at = %v", err) } } + +// frameworkApp is an app with config and extra keys set. +func frameworkApp(t *testing.T, extra map[string]any) *backpack.App { + t.Helper() + cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}}) + if err != nil { + t.Fatal(err) + } + for k, v := range extra { + if err := cfg.Set(k, v); err != nil { + t.Fatal(err) + } + } + return backpack.New(cfg) +} + +// TestFrameworkSchedulePurgeAt covers lagoon.FrameworkSchedule's reading +// of database.deferred_bindings.purge_at (D-05): the default 03:00, every +// valid HH:MM, an empty or null value disabling the entry, and malformed +// values or types failing with the key named. +func TestFrameworkSchedulePurgeAt(t *testing.T) { + const key = "database.deferred_bindings.purge_at" + for _, tc := range []struct { + value any + set bool + cadence pact.Cadence + none bool + err bool + }{ + {set: false, cadence: pact.DailyAt(3, 0)}, + {value: "00:00", set: true, cadence: pact.DailyAt(0, 0)}, + {value: "23:59", set: true, cadence: pact.DailyAt(23, 59)}, + {value: " 07:05 ", set: true, cadence: pact.DailyAt(7, 5)}, + {value: "", set: true, none: true}, + {value: " ", set: true, none: true}, + {value: nil, set: true, none: true}, + {value: "24:00", set: true, err: true}, + {value: "7:00", set: true, err: true}, + {value: "12:60", set: true, err: true}, + {value: "12:00:00", set: true, err: true}, + {value: "noon", set: true, err: true}, + {value: 300, set: true, err: true}, + } { + extra := map[string]any{} + if tc.set { + extra[key] = tc.value + } + got, err := lagoon.FrameworkSchedule(frameworkApp(t, extra)) + switch { + case tc.err: + if err == nil || !strings.Contains(err.Error(), key) { + t.Fatalf("%v: err = %v, want one naming %s", tc.value, err, key) + } + case tc.none: + if err != nil || len(got) != 0 { + t.Fatalf("%q: %+v %v, want no entry", tc.value, got, err) + } + default: + if err != nil || len(got) != 1 || got[0].Command != "deferred:purge" || len(got[0].Args) != 0 || got[0].Cadence != tc.cadence { + t.Fatalf("%v: %+v %v", tc.value, got, err) + } + } + } + for name, app := range map[string]*backpack.App{"nil app": nil, "no config": backpack.New(nil)} { + if got, err := lagoon.FrameworkSchedule(app); err != nil || got != nil { + t.Fatalf("%s: %+v %v", name, got, err) + } + } +} + +// TestFrameworkScheduleEntries: the framework entry is first, listed under +// summercms.lagoon with id summercms.lagoon[0]:deferred:purge, joins the +// compiled table at the configured time, and leaves plugin entry ids +// unchanged. +func TestFrameworkScheduleEntries(t *testing.T) { + plugins := schedulePlugins( + pact.ScheduledCommand{Command: "acme:tick", Cadence: pact.Daily()}, + pact.ScheduledCommand{Command: "acme:tock", Cadence: pact.Every(time.Hour)}, + ) + entries, err := scheduleEntries(frameworkApp(t, map[string]any{"database.deferred_bindings.purge_at": "05:15"}), plugins) + if err != nil { + t.Fatal(err) + } + ids := make([]string, 0, len(entries)) + for _, e := range entries { + ids = append(ids, e.id) + } + if strings.Join(ids, ",") != "summercms.lagoon[0]:deferred:purge,acme.test[0]:acme:tick,acme.test[1]:acme:tock" { + t.Fatalf("ids = %v", ids) + } + if entries[0].plugin != lagoon.FrameworkScheduleID || entries[0].cmd.Cadence != pact.DailyAt(5, 15) { + t.Fatalf("framework entry = %+v", entries[0]) + } + _, table, err := periodicJobs(frameworkApp(t, nil), plugins) + if err != nil { + t.Fatal(err) + } + if fw, ok := table["summercms.lagoon[0]:deferred:purge"]; !ok || fw.Command != "deferred:purge" || fw.Cadence != pact.DailyAt(3, 0) { + t.Fatalf("compiled table = %+v", table) + } + if _, _, err := periodicJobs(frameworkApp(t, map[string]any{"database.deferred_bindings.purge_at": "25:00"}), plugins); err == nil { + t.Fatal("a malformed purge_at compiled") + } +} + +// TestFrameworkScheduleForgedArgs: a job naming the framework entry with +// other args or another command is skipped like any forged scheduled job; +// the exact entry runs deferred:purge with no args. +func TestFrameworkScheduleForgedArgs(t *testing.T) { + db, dsn := migratedDB(t) + app, _ := testApp(t, db, dsn, nil) + logs := &captureHandler{} + if err := app.Publish(slog.New(logs)); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var ran [][]string + record := func(name string) bonfire.Command { + return bonfire.Command{Name: name, Flags: []bonfire.Flag{{Name: "days"}}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + mu.Lock() + defer mu.Unlock() + days, _ := in.Flag("days") + ran = append(ran, append([]string{name, "days=" + days}, in.Args()...)) + return nil + }} + } + if err := app.Publish(bonfire.NewCatalog([]bonfire.Command{record("deferred:purge"), record("acme:other")})); err != nil { + t.Fatal(err) + } + w, err := StartWorker(t.Context(), app, schedulePlugins(pact.ScheduledCommand{Command: "acme:tick", Cadence: pact.Daily()}), WorkerOptions{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = w.Stop(context.Background()) }() + m, err := From(app) + if err != nil { + t.Fatal(err) + } + const entry = "summercms.lagoon[0]:deferred:purge" + const skip = "schedule: job does not match a compiled schedule entry; skipping" + for _, forged := range []ScheduledCommandArgs{ + {Entry: entry, Command: "deferred:purge", Args: []string{"--days", "0"}}, + {Entry: entry, Command: "acme:other"}, + {Entry: "summercms.lagoon[1]:deferred:purge", Command: "deferred:purge"}, + } { + if err := m.runScheduled(t.Context(), forged); err != nil { + t.Fatalf("%+v: %v", forged, err) + } + if !logs.find(slog.LevelWarn, skip, "command", forged.Command) { + t.Fatalf("%+v: no skip warning", forged) + } + } + mu.Lock() + got := fmt.Sprint(ran) + mu.Unlock() + if got != "[]" { + t.Fatalf("forged framework jobs ran %s", got) + } + if err := m.runScheduled(t.Context(), ScheduledCommandArgs{Entry: entry, Command: "deferred:purge"}); err != nil { + t.Fatal(err) + } + mu.Lock() + got = fmt.Sprint(ran) + mu.Unlock() + if got != "[[deferred:purge days=]]" { + t.Fatalf("ran = %s", got) + } +} diff --git a/modules/lagoon/attach/guard_test.go b/modules/lagoon/attach/guard_test.go new file mode 100644 index 0000000..784a599 --- /dev/null +++ b/modules/lagoon/attach/guard_test.go @@ -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(``), + "html": []byte(""), + "gif polyglot": []byte("GIF89a"), + "png polyglot": append([]byte("\x89PNG\r\n\x1a\n"), []byte("")...), + "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) + } + } +} diff --git a/modules/lagoon/attach/store_test.go b/modules/lagoon/attach/store_test.go index deeb33d..6e3e6fa 100644 --- a/modules/lagoon/attach/store_test.go +++ b/modules/lagoon/attach/store_test.go @@ -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(""), 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") + } + }) +} diff --git a/modules/lagoon/date_test.go b/modules/lagoon/date_test.go index d412f94..5c1eb41 100644 --- a/modules/lagoon/date_test.go +++ b/modules/lagoon/date_test.go @@ -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) + } +} diff --git a/modules/lagoon/deferred_test.go b/modules/lagoon/deferred_test.go index ff25b15..106fbce 100644 --- a/modules/lagoon/deferred_test.go +++ b/modules/lagoon/deferred_test.go @@ -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) + } +} diff --git a/modules/lagoon/fill_test.go b/modules/lagoon/fill_test.go index f23fa77..64c4b46 100644 --- a/modules/lagoon/fill_test.go +++ b/modules/lagoon/fill_test.go @@ -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) + } +} diff --git a/modules/lagoon/purge_test.go b/modules/lagoon/purge_test.go new file mode 100644 index 0000000..5e1dd16 --- /dev/null +++ b/modules/lagoon/purge_test.go @@ -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") + } +} diff --git a/modules/lagoon/validate_test.go b/modules/lagoon/validate_test.go index e632c51..30f3f77 100644 --- a/modules/lagoon/validate_test.go +++ b/modules/lagoon/validate_test.go @@ -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) + } + } +} diff --git a/modules/pact/capabilities_test.go b/modules/pact/capabilities_test.go index 2ceae89..1459d77 100644 --- a/modules/pact/capabilities_test.go +++ b/modules/pact/capabilities_test.go @@ -1,7 +1,10 @@ package pact import ( + "context" + "errors" "io/fs" + "strings" "testing" "testing/fstest" @@ -146,3 +149,101 @@ func TestHTTPCapabilitiesDiscoveredByTypeAssertion(t *testing.T) { t.Fatalf("Models = %+v", models.Models()) } } + +// relationHooks implements every relation child hook with the documented +// signature and records the calls. +type relationHooks struct{ calls *[]string } + +func (h relationHooks) record(name, relation string, parent, child any) error { + *h.calls = append(*h.calls, name+":"+relation+":"+parent.(string)+":"+child.(string)) + if relation == "fail" { + return errors.New(name + " refused") + } + return nil +} + +func (h relationHooks) RelationBeforeCreate(_ context.Context, relation string, parent, child any) error { + return h.record("BeforeCreate", relation, parent, child) +} +func (h relationHooks) RelationAfterCreate(_ context.Context, relation string, parent, child any) error { + return h.record("AfterCreate", relation, parent, child) +} +func (h relationHooks) RelationBeforeUpdate(_ context.Context, relation string, parent, child any) error { + return h.record("BeforeUpdate", relation, parent, child) +} +func (h relationHooks) RelationAfterUpdate(_ context.Context, relation string, parent, child any) error { + return h.record("AfterUpdate", relation, parent, child) +} +func (h relationHooks) RelationBeforeDelete(_ context.Context, relation string, parent, child any) error { + return h.record("BeforeDelete", relation, parent, child) +} +func (h relationHooks) RelationAfterDelete(_ context.Context, relation string, parent, child any) error { + return h.record("AfterDelete", relation, parent, child) +} + +var ( + _ RelationBeforeCreate = relationHooks{} + _ RelationAfterCreate = relationHooks{} + _ RelationBeforeUpdate = relationHooks{} + _ RelationAfterUpdate = relationHooks{} + _ RelationBeforeDelete = relationHooks{} + _ RelationAfterDelete = relationHooks{} +) + +// TestRelationHookInterfaces: each of the six optional relation child hooks +// is discovered by a type assertion on the controller value, receives the +// relation name, the parent and the child, and passes its error back; a +// controller without them asserts false. +func TestRelationHookInterfaces(t *testing.T) { + var calls []string + var ctl any = relationHooks{calls: &calls} + hooks := []func(context.Context, string, any, any) error{} + if h, ok := ctl.(RelationBeforeCreate); ok { + hooks = append(hooks, h.RelationBeforeCreate) + } + if h, ok := ctl.(RelationAfterCreate); ok { + hooks = append(hooks, h.RelationAfterCreate) + } + if h, ok := ctl.(RelationBeforeUpdate); ok { + hooks = append(hooks, h.RelationBeforeUpdate) + } + if h, ok := ctl.(RelationAfterUpdate); ok { + hooks = append(hooks, h.RelationAfterUpdate) + } + if h, ok := ctl.(RelationBeforeDelete); ok { + hooks = append(hooks, h.RelationBeforeDelete) + } + if h, ok := ctl.(RelationAfterDelete); ok { + hooks = append(hooks, h.RelationAfterDelete) + } + if len(hooks) != 6 { + t.Fatalf("discovered %d hooks, want 6", len(hooks)) + } + for _, hook := range hooks { + if err := hook(t.Context(), "parts", "gadget", "part"); err != nil { + t.Fatal(err) + } + if err := hook(t.Context(), "fail", "gadget", "part"); err == nil { + t.Fatal("hook error was not passed back") + } + } + want := "BeforeCreate:parts:gadget:part,BeforeCreate:fail:gadget:part,AfterCreate:parts:gadget:part,AfterCreate:fail:gadget:part," + + "BeforeUpdate:parts:gadget:part,BeforeUpdate:fail:gadget:part,AfterUpdate:parts:gadget:part,AfterUpdate:fail:gadget:part," + + "BeforeDelete:parts:gadget:part,BeforeDelete:fail:gadget:part,AfterDelete:parts:gadget:part,AfterDelete:fail:gadget:part" + if got := strings.Join(calls, ","); got != want { + t.Fatalf("calls = %s", got) + } + var plain any = struct{}{} + for name, ok := range map[string]bool{ + "BeforeCreate": func() bool { _, ok := plain.(RelationBeforeCreate); return ok }(), + "AfterCreate": func() bool { _, ok := plain.(RelationAfterCreate); return ok }(), + "BeforeUpdate": func() bool { _, ok := plain.(RelationBeforeUpdate); return ok }(), + "AfterUpdate": func() bool { _, ok := plain.(RelationAfterUpdate); return ok }(), + "BeforeDelete": func() bool { _, ok := plain.(RelationBeforeDelete); return ok }(), + "AfterDelete": func() bool { _, ok := plain.(RelationAfterDelete); return ok }(), + } { + if ok { + t.Fatalf("a plain value implements Relation%s", name) + } + } +}