feat: contact form backend for linux and freebsd servers
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
// Package contactform provides the types and the declarative validation
|
||||
// the form pipeline runs on.
|
||||
package contactform
|
||||
|
||||
// Request is the JSON body sent to the contact endpoint.
|
||||
type Request struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Response is the success response.
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// FieldError describes a single validation failure.
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrorResponse is returned for any non-2xx response.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details []FieldError `json:"details,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package contactform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MinNameRunes is the default minimum allowed length of a name.
|
||||
const MinNameRunes = 2
|
||||
|
||||
// MaxNameRunes is the default maximum allowed length of a name.
|
||||
const MaxNameRunes = 100
|
||||
|
||||
// MinMessageRunes is the default minimum allowed length of a message body.
|
||||
const MinMessageRunes = 10
|
||||
|
||||
// MaxMessageRunes is the default maximum allowed length of a message body.
|
||||
const MaxMessageRunes = 5000
|
||||
|
||||
// ServiceAny is the services entry that accepts any service value.
|
||||
const ServiceAny = "*"
|
||||
|
||||
// DefaultServices is the built-in service allow-list the contact preset
|
||||
// applies when a form does not define its own list. The empty value is
|
||||
// always accepted on top of whatever this list holds, because a form that
|
||||
// offers no choice never sends the field at all.
|
||||
var DefaultServices = []string{
|
||||
"architecture",
|
||||
"ai",
|
||||
"infrastructure",
|
||||
"software",
|
||||
"unix",
|
||||
"other",
|
||||
}
|
||||
|
||||
// Policy is the declarative validation rule set for one form. The server
|
||||
// builds it from the form's configuration; a library caller writes it
|
||||
// directly. The zero value accepts any name and message, so every limit
|
||||
// that matters must be set explicitly.
|
||||
type Policy struct {
|
||||
// RequireName and RequireMessage switch the length checks for the two
|
||||
// free-text fields on and off. The email address is always required
|
||||
// and always checked: every form delivers mail and needs a reply-to.
|
||||
RequireName bool
|
||||
RequireMessage bool
|
||||
|
||||
MinNameRunes int
|
||||
MaxNameRunes int
|
||||
MinMessageRunes int
|
||||
MaxMessageRunes int
|
||||
|
||||
// Services is the allow-list for the optional service field. A nil
|
||||
// list means the field is not validated at all; a non-nil list holds
|
||||
// the accepted values, with the empty value always accepted and the
|
||||
// ServiceAny entry lifting the restriction entirely.
|
||||
Services []string
|
||||
}
|
||||
|
||||
// Preset returns the built-in policy for a form type. An empty or unknown
|
||||
// type falls back to the contact preset, which is also how the server
|
||||
// treats an unconfigured type. Every preset carries the default length
|
||||
// limits; the newsletter preset simply leaves both free-text fields out
|
||||
// of the checks, so switching them on later starts from sane limits.
|
||||
func Preset(formType string) Policy {
|
||||
switch formType {
|
||||
case "newsletter":
|
||||
return Policy{
|
||||
MinNameRunes: MinNameRunes,
|
||||
MaxNameRunes: MaxNameRunes,
|
||||
MinMessageRunes: MinMessageRunes,
|
||||
MaxMessageRunes: MaxMessageRunes,
|
||||
}
|
||||
case "feedback", "generic":
|
||||
return Policy{
|
||||
RequireName: true,
|
||||
RequireMessage: true,
|
||||
MinNameRunes: MinNameRunes,
|
||||
MaxNameRunes: MaxNameRunes,
|
||||
MinMessageRunes: MinMessageRunes,
|
||||
MaxMessageRunes: MaxMessageRunes,
|
||||
}
|
||||
default: // contact, and the fallback for every unknown type
|
||||
return Policy{
|
||||
RequireName: true,
|
||||
RequireMessage: true,
|
||||
MinNameRunes: MinNameRunes,
|
||||
MaxNameRunes: MaxNameRunes,
|
||||
MinMessageRunes: MinMessageRunes,
|
||||
MaxMessageRunes: MaxMessageRunes,
|
||||
Services: slices.Clone(DefaultServices),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate normalises the request in place (trims whitespace from every
|
||||
// text field) and checks it against p. It returns one entry per failed
|
||||
// field; a nil slice means the request is valid.
|
||||
func Validate(r *Request, p Policy) []FieldError {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.Email = strings.TrimSpace(r.Email)
|
||||
r.Service = strings.TrimSpace(r.Service)
|
||||
r.Message = strings.TrimSpace(r.Message)
|
||||
|
||||
var errs []FieldError
|
||||
|
||||
if p.RequireName {
|
||||
errs = append(errs, checkRunes("name", r.Name, p.MinNameRunes, p.MaxNameRunes)...)
|
||||
}
|
||||
|
||||
if _, err := mail.ParseAddress(r.Email); err != nil {
|
||||
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
|
||||
}
|
||||
|
||||
if p.Services != nil && !serviceAllowed(p.Services, r.Service) {
|
||||
errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"})
|
||||
}
|
||||
|
||||
if p.RequireMessage {
|
||||
errs = append(errs, checkRunes("message", r.Message, p.MinMessageRunes, p.MaxMessageRunes)...)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// NormalizeAndValidate trims whitespace from all text fields and then
|
||||
// checks the request against the built-in preset for the form type.
|
||||
// Supported types: contact, feedback, newsletter, generic; an empty or
|
||||
// unknown type falls back to contact. Callers that need their own limits
|
||||
// or their own service allow-list build a Policy and call Validate.
|
||||
func NormalizeAndValidate(r *Request, formType string) []FieldError {
|
||||
return Validate(r, Preset(formType))
|
||||
}
|
||||
|
||||
// checkRunes reports the length errors for one field in rune counts.
|
||||
func checkRunes(field, value string, minRunes, maxRunes int) []FieldError {
|
||||
n := utf8.RuneCountInString(value)
|
||||
var errs []FieldError
|
||||
if n < minRunes {
|
||||
errs = append(errs, FieldError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("%s must be at least %d characters", field, minRunes),
|
||||
})
|
||||
} else if n > maxRunes {
|
||||
errs = append(errs, FieldError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("%s must be at most %d characters", field, maxRunes),
|
||||
})
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// serviceAllowed reports whether s passes the allow-list. The empty value
|
||||
// is always legitimate: petrbalvin.org and every other frontend is free to
|
||||
// post a payload without a service field. The ServiceAny entry accepts any
|
||||
// non-empty value.
|
||||
func serviceAllowed(list []string, s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(list, ServiceAny) {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(list, s)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package contactform
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateContact_Happy(t *testing.T) {
|
||||
r := &Request{
|
||||
Name: "Jane Doe",
|
||||
Email: "jane@example.com",
|
||||
Service: "architecture",
|
||||
Message: "Hello, I would like to discuss a project.",
|
||||
}
|
||||
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContact_DefaultType(t *testing.T) {
|
||||
// Unknown / empty type falls back to contact validation.
|
||||
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."}
|
||||
if errs := NormalizeAndValidate(r, ""); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors with empty type, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContact_RejectsInvalidEmail(t *testing.T) {
|
||||
r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."}
|
||||
errs := NormalizeAndValidate(r, "contact")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for invalid email")
|
||||
}
|
||||
if errs[0].Field != "email" {
|
||||
t.Errorf("expected field=email, got %q", errs[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContact_RejectsBadService(t *testing.T) {
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}
|
||||
errs := NormalizeAndValidate(r, "contact")
|
||||
if len(errs) == 0 || errs[0].Field != "service" {
|
||||
t.Fatalf("expected service error, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFeedback_OmitsService(t *testing.T) {
|
||||
// Feedback validates like generic: name, email, message; no service field.
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}
|
||||
if errs := NormalizeAndValidate(r, "feedback"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeneric_RejectsShortMessage(t *testing.T) {
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"}
|
||||
errs := NormalizeAndValidate(r, "generic")
|
||||
if len(errs) == 0 || errs[0].Field != "message" {
|
||||
t.Fatalf("expected message error, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNewsletter_Happy(t *testing.T) {
|
||||
r := &Request{Email: "jane@example.com"}
|
||||
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
|
||||
r := &Request{Email: "garbage"}
|
||||
errs := NormalizeAndValidate(r, "newsletter")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for missing/invalid email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) {
|
||||
// Newsletter type only cares about the email field.
|
||||
r := &Request{Email: "jane@example.com", Name: "", Message: ""}
|
||||
if errs := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
|
||||
t.Fatalf("newsletter should ignore empty name/message, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) {
|
||||
// Sanity: an unsupported form type (e.g. "foo") falls back to contact
|
||||
// validation, not to a hard error. The hard error is enforced at the
|
||||
// config layer, not in Validate itself.
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."}
|
||||
if errs := NormalizeAndValidate(r, "foo"); len(errs) > 0 {
|
||||
t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTrimsWhitespace(t *testing.T) {
|
||||
r := &Request{
|
||||
Name: " Jane ",
|
||||
Email: " jane@example.com ",
|
||||
Service: " architecture ",
|
||||
Message: " hello there ",
|
||||
}
|
||||
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" {
|
||||
t.Errorf("expected whitespace to be trimmed, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy-driven validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The presets must reproduce the exact rules the fixed validators enforced
|
||||
// before the policy layer existed.
|
||||
func TestPresets(t *testing.T) {
|
||||
contact := Preset("contact")
|
||||
if !contact.RequireName || !contact.RequireMessage {
|
||||
t.Error("contact preset must require name and message")
|
||||
}
|
||||
if contact.MinNameRunes != MinNameRunes || contact.MaxNameRunes != MaxNameRunes {
|
||||
t.Errorf("contact name limits = %d/%d, want %d/%d",
|
||||
contact.MinNameRunes, contact.MaxNameRunes, MinNameRunes, MaxNameRunes)
|
||||
}
|
||||
if contact.MinMessageRunes != MinMessageRunes || contact.MaxMessageRunes != MaxMessageRunes {
|
||||
t.Errorf("contact message limits = %d/%d, want %d/%d",
|
||||
contact.MinMessageRunes, contact.MaxMessageRunes, MinMessageRunes, MaxMessageRunes)
|
||||
}
|
||||
if !slices.Contains(contact.Services, "architecture") || !slices.Contains(contact.Services, "other") {
|
||||
t.Errorf("contact preset services = %v, want the built-in list", contact.Services)
|
||||
}
|
||||
|
||||
for _, typ := range []string{"feedback", "generic"} {
|
||||
p := Preset(typ)
|
||||
if !p.RequireName || !p.RequireMessage {
|
||||
t.Errorf("%s preset must require name and message", typ)
|
||||
}
|
||||
if p.Services != nil {
|
||||
t.Errorf("%s preset must not validate service, got %v", typ, p.Services)
|
||||
}
|
||||
}
|
||||
|
||||
news := Preset("newsletter")
|
||||
if news.RequireName || news.RequireMessage || news.Services != nil {
|
||||
t.Errorf("newsletter preset = %+v, want email-only", news)
|
||||
}
|
||||
|
||||
// Unknown and empty types fall back to contact.
|
||||
fallback := Preset("nonsense")
|
||||
if !fallback.RequireName || fallback.Services == nil {
|
||||
t.Errorf("unknown type preset = %+v, want the contact preset", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// A payload without a service field is legitimate under the contact
|
||||
// preset: the field is optional, never a mandatory category.
|
||||
func TestValidateContactWithoutServiceField(t *testing.T) {
|
||||
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "A plain hello for you."}
|
||||
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
|
||||
t.Fatalf("payload without service must pass, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil Services list means the field is not validated at all, so any
|
||||
// value rides through untouched.
|
||||
func TestPolicyNilServicesSkipsValidation(t *testing.T) {
|
||||
p := Preset("feedback")
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "anything-goes", Message: "A message long enough."}
|
||||
if errs := Validate(r, p); len(errs) > 0 {
|
||||
t.Fatalf("nil services list must skip the field, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyServices(t *testing.T) {
|
||||
base := func(services []string, service string) []FieldError {
|
||||
p := Policy{
|
||||
RequireName: true,
|
||||
RequireMessage: true,
|
||||
MinNameRunes: 2, MaxNameRunes: 100,
|
||||
MinMessageRunes: 10, MaxMessageRunes: 5000,
|
||||
Services: services,
|
||||
}
|
||||
r := &Request{Name: "Jane", Email: "jane@example.com", Service: service, Message: "A message long enough."}
|
||||
return Validate(r, p)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
services []string
|
||||
service string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty service always passes", []string{"consulting"}, "", false},
|
||||
{"listed value passes", []string{"consulting", "support"}, "support", false},
|
||||
{"unlisted value fails", []string{"consulting"}, "hairstyling", true},
|
||||
{"explicit empty list rejects any value", []string{}, "consulting", true},
|
||||
{"explicit empty list keeps empty value", []string{}, "", false},
|
||||
{"wildcard accepts anything", []string{ServiceAny}, "hairstyling", false},
|
||||
{"wildcard beside entries still accepts anything", []string{"consulting", ServiceAny}, "anything", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
errs := base(tt.services, tt.service)
|
||||
if gotErr := len(errs) > 0; gotErr != tt.wantErr {
|
||||
t.Fatalf("Validate(service=%q, list=%v) errors = %v, wantErr %v",
|
||||
tt.service, tt.services, errs, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && errs[0].Field != "service" {
|
||||
t.Errorf("error field = %q, want service", errs[0].Field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Custom limits are honoured and the error messages carry the configured
|
||||
// numbers, not the built-in defaults.
|
||||
func TestPolicyCustomLimits(t *testing.T) {
|
||||
p := Policy{
|
||||
RequireName: true,
|
||||
RequireMessage: true,
|
||||
MinNameRunes: 5,
|
||||
MaxNameRunes: 10,
|
||||
MinMessageRunes: 20,
|
||||
MaxMessageRunes: 40,
|
||||
}
|
||||
r := &Request{Name: "Ja", Email: "jane@example.com", Message: "too short"}
|
||||
errs := Validate(r, p)
|
||||
if len(errs) != 2 {
|
||||
t.Fatalf("errors = %v, want two length failures", errs)
|
||||
}
|
||||
if errs[0].Field != "name" || !strings.Contains(errs[0].Message, "at least 5 characters") {
|
||||
t.Errorf("name error = %+v, want the configured minimum", errs[0])
|
||||
}
|
||||
if errs[1].Field != "message" || !strings.Contains(errs[1].Message, "at least 20 characters") {
|
||||
t.Errorf("message error = %+v, want the configured minimum", errs[1])
|
||||
}
|
||||
|
||||
r2 := &Request{Name: "A very long name over the limit", Email: "jane@example.com",
|
||||
Message: strings.Repeat("x", 41)}
|
||||
errs = Validate(r2, p)
|
||||
if len(errs) != 2 {
|
||||
t.Fatalf("errors = %v, want two maximum failures", errs)
|
||||
}
|
||||
if !strings.Contains(errs[0].Message, "at most 10 characters") ||
|
||||
!strings.Contains(errs[1].Message, "at most 40 characters") {
|
||||
t.Errorf("errors = %v, want the configured maxima", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// A false Require flag takes the whole field out of the checks; the field
|
||||
// is still trimmed so the email body sees clean text.
|
||||
func TestPolicyOptionalFields(t *testing.T) {
|
||||
p := Policy{RequireName: false, RequireMessage: false, Services: []string{ServiceAny}}
|
||||
r := &Request{Name: "", Email: "jane@example.com", Message: "", Service: "x"}
|
||||
if errs := Validate(r, p); len(errs) > 0 {
|
||||
t.Fatalf("optional fields must pass empty, got %v", errs)
|
||||
}
|
||||
if r.Name != "" || r.Message != "" || r.Service != "x" {
|
||||
t.Errorf("fields should stay trimmed, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// The email address is never optional: it is the reply-to of the mail the
|
||||
// form produces.
|
||||
func TestPolicyEmailAlwaysRequired(t *testing.T) {
|
||||
p := Policy{}
|
||||
r := &Request{Name: "Jane", Email: "not-an-email", Message: "hello"}
|
||||
errs := Validate(r, p)
|
||||
if len(errs) != 1 || errs[0].Field != "email" {
|
||||
t.Fatalf("errors = %v, want exactly the email failure", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate must produce the same verdicts as the type dispatcher did
|
||||
// before the policy layer: every case the old tests covered still holds
|
||||
// through the wrapper.
|
||||
func TestNormalizeAndValidateBackwardCompatible(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
formType string
|
||||
req Request
|
||||
wantErr bool
|
||||
}{
|
||||
{"contact happy", "contact", Request{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss a project."}, false},
|
||||
{"contact bad service", "contact", Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}, true},
|
||||
{"feedback happy", "feedback", Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}, false},
|
||||
{"generic short message", "generic", Request{Name: "Jane", Email: "jane@example.com", Message: "short"}, true},
|
||||
{"newsletter happy", "newsletter", Request{Email: "jane@example.com"}, false},
|
||||
{"newsletter bad email", "newsletter", Request{Email: "garbage"}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := tc.req
|
||||
errs := NormalizeAndValidate(&req, tc.formType)
|
||||
if got := len(errs) > 0; got != tc.wantErr {
|
||||
t.Fatalf("NormalizeAndValidate(%q) errors = %v, wantErr %v", tc.formType, errs, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) {
|
||||
// Sanity: the minLength error message in the en.json UI mirror should
|
||||
// remain a simple string. Here we just check that error messages are
|
||||
// human-readable, not empty.
|
||||
r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"}
|
||||
errs := NormalizeAndValidate(r, "contact")
|
||||
if len(errs) < 2 {
|
||||
t.Fatalf("expected at least 2 errors, got %v", errs)
|
||||
}
|
||||
for _, e := range errs {
|
||||
if strings.TrimSpace(e.Message) == "" {
|
||||
t.Errorf("error message is empty for field %q", e.Field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzValidate explores the validator with arbitrary payloads and
|
||||
// policies. The invariants: it never panics, every reported error names
|
||||
// one of the four known fields with a non-empty message, and validation
|
||||
// is deterministic in the trimmed request.
|
||||
func FuzzValidate(f *testing.F) {
|
||||
seeds := []Request{
|
||||
{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss an engagement."},
|
||||
{Name: "", Email: "", Service: "", Message: ""},
|
||||
{Name: " J ", Email: " j@example.com ", Service: " unix ", Message: "x"},
|
||||
{Name: "<script>", Email: "a@b", Service: "unknown", Message: strings.Repeat("m", 6000)},
|
||||
{Name: "Elf", Email: "u@exämple.com", Service: "*", Message: "emoji 🚀 body"},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s.Name, s.Email, s.Service, s.Message, true, true, 2, 100, 10, 5000, 0)
|
||||
}
|
||||
f.Add("A", "a@b.c", "", "long enough", false, false, 0, 1, 0, 1, 3)
|
||||
|
||||
knownFields := []string{"name", "email", "service", "message"}
|
||||
serviceLists := [][]string{nil, {}, {"architecture", "unix"}, {"*"}}
|
||||
f.Fuzz(func(t *testing.T, name, email, service, message string,
|
||||
requireName, requireMessage bool,
|
||||
minName, maxName, minMessage, maxMessage, serviceChoice int) {
|
||||
policy := Policy{
|
||||
RequireName: requireName,
|
||||
RequireMessage: requireMessage,
|
||||
MinNameRunes: minName,
|
||||
MaxNameRunes: maxName,
|
||||
MinMessageRunes: minMessage,
|
||||
MaxMessageRunes: maxMessage,
|
||||
Services: serviceLists[((serviceChoice%len(serviceLists))+len(serviceLists))%len(serviceLists)],
|
||||
}
|
||||
req := Request{Name: name, Email: email, Service: service, Message: message}
|
||||
|
||||
errs := Validate(&req, policy)
|
||||
for _, e := range errs {
|
||||
if !slices.Contains(knownFields, e.Field) {
|
||||
t.Fatalf("unknown field %q in error %q", e.Field, e.Message)
|
||||
}
|
||||
if strings.TrimSpace(e.Message) == "" {
|
||||
t.Fatalf("empty message on field %q", e.Field)
|
||||
}
|
||||
}
|
||||
again := Validate(&req, policy)
|
||||
if !slices.Equal(errs, again) {
|
||||
t.Fatalf("validation is not deterministic: %v then %v", errs, again)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user