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) } }