Files
summercms/modules/lagoon/fill_test.go
Jakub Zych e60e69745e fix(10.1): CR-01 answer a value that does not fit its column with a 422
lagoon.Fill now returns a *lagoon.FillTypeError naming the key when a
requested value cannot be stored in its column (a fraction, exponent or
overflow for an integer field, or a value of the wrong type). The admin
save path maps it to a validation_failed 422 on that field instead of a
500 CapabilityError; genuine capability failures keep the 500.
2026-09-29 09:44:14 +02:00

232 lines
6.9 KiB
Go

package lagoon
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
)
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)
}
}