- 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
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package lagoon
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fillFixture struct {
|
|
Name string `gorm:"column:name"`
|
|
CollectionID uint `gorm:"column:collection_id"`
|
|
Notes *string `gorm:"column:notes"`
|
|
}
|
|
|
|
func (fillFixture) Fillable() []string { return []string{"name", "notes"} }
|
|
func (fillFixture) Hidden() []string { return []string{"collection_id"} }
|
|
|
|
var (
|
|
_ HasFillable = fillFixture{}
|
|
_ HasHidden = fillFixture{}
|
|
)
|
|
|
|
func TestFillAllowList(t *testing.T) {
|
|
var row fillFixture
|
|
row.CollectionID = 3
|
|
err := Fill(&row, []string{"name"}, map[string]any{
|
|
"name": "x",
|
|
"collection_id": uint(9),
|
|
}, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.Name != "x" {
|
|
t.Fatalf("name = %q", row.Name)
|
|
}
|
|
if row.CollectionID != 3 {
|
|
t.Fatalf("collection_id mutated to %d", row.CollectionID)
|
|
}
|
|
}
|
|
|
|
func TestFillDroppedKeyLogsOnce(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
|
defer slog.SetDefault(prev)
|
|
|
|
var row fillFixture
|
|
requested := map[string]any{"name": "once", "collection_id": uint(1)}
|
|
if err := Fill(&row, []string{"name"}, requested, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Fill(&row, []string{"name"}, requested, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
log := buf.String()
|
|
if strings.Count(log, "collection_id") != 1 {
|
|
t.Fatalf("dropped key should log once, got %q", log)
|
|
}
|
|
if !strings.Contains(log, "lagoon: dropped non-fillable key") {
|
|
t.Fatalf("missing warn message: %q", log)
|
|
}
|
|
}
|
|
|
|
func TestFillProductionSilent(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
|
defer slog.SetDefault(prev)
|
|
|
|
var row fillFixture
|
|
if err := Fill(&row, []string{"name"}, map[string]any{
|
|
"name": "prod",
|
|
"unknown_field": true,
|
|
}, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if buf.Len() != 0 {
|
|
t.Fatalf("production fill must be silent, got %q", buf.String())
|
|
}
|
|
if row.Name != "prod" {
|
|
t.Fatalf("name = %q", row.Name)
|
|
}
|
|
}
|
|
|
|
func TestFillNilPointerClearsNullable(t *testing.T) {
|
|
existing := "keep"
|
|
row := fillFixture{Notes: &existing}
|
|
if err := Fill(&row, []string{"notes"}, map[string]any{"notes": nil}, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.Notes != nil {
|
|
t.Fatalf("notes = %v, want nil", row.Notes)
|
|
}
|
|
}
|
|
|
|
func TestFillDroppedKeyNeverErrors(t *testing.T) {
|
|
var row fillFixture
|
|
if err := Fill(&row, []string{"name"}, map[string]any{"collection_id": uint(9), "nope": 1}, true); err != nil {
|
|
t.Fatalf("dropped keys must not error: %v", err)
|
|
}
|
|
if row.CollectionID != 0 {
|
|
t.Fatalf("collection_id = %d", row.CollectionID)
|
|
}
|
|
}
|
|
|
|
type fillSecretFixture struct {
|
|
Name string `gorm:"column:name"`
|
|
APIKey Encrypted `gorm:"column:api_key" json:"-"`
|
|
Token *Encrypted `gorm:"column:token" json:"-"`
|
|
}
|
|
|
|
func TestFillEncryptedTakesPlaintext(t *testing.T) {
|
|
if err := PublishEncryptionKeys(nil, bytes.Repeat([]byte("F"), 32), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
allowed := []string{"name", "api_key", "token"}
|
|
var row fillSecretFixture
|
|
if err := Fill(&row, allowed, map[string]any{"api_key": "sk-plain", "token": "tok-plain"}, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.APIKey.Reveal() != "sk-plain" {
|
|
t.Fatalf("api_key Reveal = %q", row.APIKey.Reveal())
|
|
}
|
|
if row.Token == nil || row.Token.Reveal() != "tok-plain" {
|
|
t.Fatalf("token = %v", row.Token)
|
|
}
|
|
|
|
// Another row's ciphertext is stored as literal text, never decrypted.
|
|
stolen, err := NewEncrypted("victim-secret").Value()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ciphertext, ok := stolen.(string)
|
|
if !ok {
|
|
t.Fatalf("ciphertext driver value is %T", stolen)
|
|
}
|
|
var thief fillSecretFixture
|
|
if err := Fill(&thief, allowed, map[string]any{"api_key": ciphertext}, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if thief.APIKey.Reveal() != ciphertext {
|
|
t.Fatal("ciphertext in a request must not be decrypted into the victim's plaintext")
|
|
}
|
|
|
|
for _, bad := range []any{42, true, map[string]any{"x": 1}, []byte("raw")} {
|
|
var r fillSecretFixture
|
|
if err := Fill(&r, allowed, map[string]any{"api_key": bad}, true); err == nil {
|
|
t.Fatalf("api_key=%T must be rejected", bad)
|
|
}
|
|
if r.APIKey.Reveal() != "" {
|
|
t.Fatalf("api_key=%T left a value behind", bad)
|
|
}
|
|
}
|
|
|
|
if err := Fill(&row, allowed, map[string]any{"api_key": nil, "token": nil}, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.APIKey.Reveal() != "" || row.Token != nil {
|
|
t.Fatalf("nil must clear: api_key=%q token=%v", row.APIKey.Reveal(), row.Token)
|
|
}
|
|
}
|
|
|
|
// TestFillJSONNumber covers request bodies decoded with UseNumber: a
|
|
// json.Number fills integer, unsigned and float fields (plain or pointer), a
|
|
// string field keeps the number's text, and a fraction or an overflow into an
|
|
// integer field is an error rather than a silent truncation.
|
|
func TestFillJSONNumber(t *testing.T) {
|
|
type numbers struct {
|
|
Year *int `gorm:"column:year"`
|
|
Count uint8 `gorm:"column:count"`
|
|
Price float64 `gorm:"column:price"`
|
|
Code string `gorm:"column:code"`
|
|
}
|
|
allowed := []string{"year", "count", "price", "code"}
|
|
var row numbers
|
|
err := Fill(&row, allowed, map[string]any{
|
|
"year": json.Number("1977"),
|
|
"count": json.Number("200"),
|
|
"price": json.Number("12.5"),
|
|
"code": json.Number("0042"),
|
|
}, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if row.Year == nil || *row.Year != 1977 || row.Count != 200 || row.Price != 12.5 || row.Code != "0042" {
|
|
t.Fatalf("row = %+v (year %v)", row, row.Year)
|
|
}
|
|
for key, value := range map[string]json.Number{"year": "1977.5", "count": "256"} {
|
|
if err := Fill(&row, allowed, map[string]any{key: value}, true); err == nil {
|
|
t.Fatalf("%s = %s filled without an error", key, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFillTypeErrorNamesTheKey proves a value that does not fit its column
|
|
// comes back as a *FillTypeError carrying the requested key, so a caller can
|
|
// answer it as a validation failure on that field, while a bad model stays a
|
|
// plain error.
|
|
func TestFillTypeErrorNamesTheKey(t *testing.T) {
|
|
type numbers struct {
|
|
Year int `gorm:"column:year"`
|
|
Count uint8 `gorm:"column:count"`
|
|
Title string `gorm:"column:title"`
|
|
Price float64 `gorm:"column:price"`
|
|
}
|
|
allowed := []string{"year", "count", "title", "price"}
|
|
cases := map[string]any{
|
|
"year": json.Number("1977.5"),
|
|
"count": json.Number("1e21"),
|
|
"title": true,
|
|
"price": "cheap",
|
|
}
|
|
for key, value := range cases {
|
|
var row numbers
|
|
err := Fill(&row, allowed, map[string]any{key: value}, true)
|
|
var typed *FillTypeError
|
|
if !errors.As(err, &typed) || typed.Key != key || typed.Err == nil {
|
|
t.Fatalf("%s = %v: err = %#v, want *FillTypeError for %s", key, value, err, key)
|
|
}
|
|
if !strings.Contains(err.Error(), "lagoon: fill "+key+": ") {
|
|
t.Fatalf("%s: message = %q", key, err.Error())
|
|
}
|
|
}
|
|
var typed *FillTypeError
|
|
if err := Fill(numbers{}, allowed, map[string]any{"year": 1}, true); err == nil || errors.As(err, &typed) {
|
|
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)
|
|
}
|
|
}
|