feat(12.1-02): password and form-only fields, rules per operation and preset
- pact.FormVirtualFields lists form fields that are not model columns: never bound, filled or projected; their values reach the Form hooks through cabana.VirtualFieldsFromContext when the field's context allows the operation - type: password is a masked field that must be listed as virtual - pact.FormRules supplies the rule set per operation and replaces the model's Rules() for admin saves; a rule on a virtual field sees the submitted value - preset on a text field follows another text field on the create form - SPA: PasswordField, preset handling in FormView, empty password left out of an update - README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -93,7 +96,7 @@ func TestPreviewSmoke(t *testing.T) {
|
||||
if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip {
|
||||
t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP)
|
||||
}
|
||||
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2"}`, "bearer")
|
||||
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2","password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
|
||||
created := rosterRecord(t, rec.Body.Bytes())
|
||||
id, _ := created.Data["id"].(float64)
|
||||
if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil {
|
||||
@@ -136,3 +139,250 @@ func TestPreviewSmoke(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// rosterFields is the fixture's fields.yaml with old replaced by new (boot
|
||||
// tests); an empty old appends new.
|
||||
func rosterFields(t *testing.T, old, new string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(rosterDir, rosterFieldsFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
if old == "" {
|
||||
return text + new
|
||||
}
|
||||
if !strings.Contains(text, old) {
|
||||
t.Fatalf("fields.yaml does not contain %q", old)
|
||||
}
|
||||
return strings.Replace(text, old, new, 1)
|
||||
}
|
||||
|
||||
const rosterFieldsFile = "models/person/fields.yaml"
|
||||
|
||||
// rosterErrorDetail asserts a 4xx body's code and one field message.
|
||||
func rosterErrorDetail(t *testing.T, raw []byte, code, field, message string) {
|
||||
t.Helper()
|
||||
var body cabana.ErrorEnvelope
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("error body %s: %v", raw, err)
|
||||
}
|
||||
if body.Error.Code != code {
|
||||
t.Fatalf("code = %q, want %s; body %s", body.Error.Code, code, raw)
|
||||
}
|
||||
list, _ := body.Error.Details[field].([]any)
|
||||
for _, item := range list {
|
||||
if item == message {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("details[%s] = %v, want %q; body %s", field, body.Error.Details[field], message, raw)
|
||||
}
|
||||
|
||||
// TestPasswordFieldSmoke drives `type: password` through the assembled router
|
||||
// on PostgreSQL (D-27 G1; T-12.1-10): the value reaches the controller's hook,
|
||||
// which stores a hash, and no record response on any route carries the key or
|
||||
// the plain text.
|
||||
func TestPasswordFieldSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
const plain, next = "s3cret-plain-text", "another-plain-9"
|
||||
leaks := func(t *testing.T, route, body string) {
|
||||
t.Helper()
|
||||
for _, part := range []string{"password", plain, next, "sha256:"} {
|
||||
if strings.Contains(body, part) {
|
||||
t.Fatalf("%s response carries %q: %s", route, part, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople,
|
||||
fmt.Sprintf(`{"name":"Pat","password":%q,"password_confirmation":%q}`, plain, plain), "bearer")
|
||||
leaks(t, "create", rec.Body.String())
|
||||
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
|
||||
id := uint(idFloat)
|
||||
record := fmt.Sprintf("%s/%d", rosterPeople, id)
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(plain) || stored.Password == plain {
|
||||
t.Fatalf("stored password = %q", stored.Password)
|
||||
}
|
||||
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
|
||||
leaks(t, "show", rec.Body.String())
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
|
||||
leaks(t, "list", rec.Body.String())
|
||||
|
||||
rec = env.expect(t, http.StatusOK, http.MethodPut, record,
|
||||
fmt.Sprintf(`{"name":"Pat B","password":%q,"password_confirmation":%q}`, next, next), "bearer")
|
||||
leaks(t, "update", rec.Body.String())
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat B" {
|
||||
t.Fatalf("after update: %+v", stored)
|
||||
}
|
||||
|
||||
t.Run("an update without a password keeps the stored one", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Pat C"}`, "bearer")
|
||||
leaks(t, "update", rec.Body.String())
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
|
||||
t.Fatalf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a mismatch, a lone confirmation and a short password are 422 on password", func(t *testing.T) {
|
||||
const mismatch = "The password confirmation does not match."
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":"Pat D","password":"long-enough-1","password_confirmation":"long-enough-2"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password_confirmation":"long-enough-2"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":"short","password_confirmation":"short"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password must be between 8 and 255 characters.")
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Mis","password":"long-enough-1","password_confirmation":"other-enough-1"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
|
||||
// Nothing of the refused saves was written.
|
||||
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
|
||||
t.Fatalf("a refused save wrote: %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the schema serves the field without a value", func(t *testing.T) {
|
||||
_, raw := rosterFormSchema(t, env, "bearer")
|
||||
if !strings.Contains(raw, `"name":"password","type":"password","label":"Password","span":"left","context":["create","update"]`) {
|
||||
t.Fatalf("password field is not in the schema: %s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestVirtualFieldsSmoke drives pact.FormVirtualFields (D-27 G2; T-12.1-09):
|
||||
// submitted values reach the Form hooks through VirtualFieldsFromContext only
|
||||
// when the field's context allows the operation, and are never filled or
|
||||
// returned.
|
||||
func TestVirtualFieldsSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
if _, ok := cabana.VirtualFieldsFromContext(context.Background()); ok {
|
||||
t.Fatal("virtual fields reported outside a save")
|
||||
}
|
||||
const body = `{"name":"Vic","notify":true,"password":"long-enough-1","password_confirmation":"long-enough-1"}`
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, body, "bearer")
|
||||
for _, name := range []string{"notify", "password", "password_confirmation"} {
|
||||
if strings.Contains(rec.Body.String(), name) {
|
||||
t.Fatalf("create response carries %s: %s", name, rec.Body.String())
|
||||
}
|
||||
}
|
||||
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
|
||||
record := fmt.Sprintf("%s/%d", rosterPeople, uint(idFloat))
|
||||
|
||||
seen := env.spy.takeVirtual()
|
||||
if len(seen) != 2 || seen[0].Hook != "before-create" || seen[1].Hook != "after-create" {
|
||||
t.Fatalf("hooks = %+v", seen)
|
||||
}
|
||||
for _, hook := range seen {
|
||||
// The before hook deleted notify from its copy; the after hook
|
||||
// still sees it.
|
||||
if !hook.Found || hook.Values["notify"] != true || hook.Values["password"] != "long-enough-1" || hook.Values["password_confirmation"] != "long-enough-1" || len(hook.Values) != 3 {
|
||||
t.Fatalf("%s saw %+v found=%v", hook.Hook, hook.Values, hook.Found)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("a field whose context hides it on update never reaches the hook", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Vic B","notify":true}`, "bearer")
|
||||
if strings.Contains(rec.Body.String(), "notify") {
|
||||
t.Fatalf("update response: %s", rec.Body.String())
|
||||
}
|
||||
seen := env.spy.takeVirtual()
|
||||
if len(seen) != 1 || seen[0].Hook != "before-update" || !seen[0].Found || len(seen[0].Values) != 0 {
|
||||
t.Fatalf("update hook saw %+v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a nested value is 422 on the field and nothing is written", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Nest","notify":{"on":true},"password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field has an invalid value.")
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":["a","b"]}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field has an invalid value.")
|
||||
var count int64
|
||||
if err := gdb.Model(&rosterPerson{}).Where("name = ?", "Nest").Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("nested create wrote %d rows err=%v", count, err)
|
||||
}
|
||||
if seen := env.spy.takeVirtual(); len(seen) != 0 {
|
||||
t.Fatalf("a refused save reached a hook: %+v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a virtual name is never a fill key", func(t *testing.T) {
|
||||
// The model has a password column; the submitted text must reach it
|
||||
// only through the hook (as a hash), never through Fill.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"password":"plain-through-fill","password_confirmation":"plain-through-fill"}`, "bearer")
|
||||
var stored rosterPerson
|
||||
if err := gdb.Unscoped().First(&stored, uint(idFloat)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Password != rosterHash("plain-through-fill") {
|
||||
t.Fatalf("stored password = %q", stored.Password)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormRulesSmoke drives pact.FormRules (D-28 G5): the controller's rule
|
||||
// set per operation replaces the model's Rules() for admin saves.
|
||||
func TestFormRulesSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Rae", Active: true, Password: rosterHash("stored-before")})
|
||||
record := fmt.Sprintf("%s/%d", rosterPeople, id)
|
||||
|
||||
t.Run("an update of name alone passes although the model demands a confirmed password", func(t *testing.T) {
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Rae B"}`, "bearer")
|
||||
if stored := rosterLoad(t, gdb, id); stored.Name != "Rae B" || stored.Password != rosterHash("stored-before") {
|
||||
t.Fatalf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the create rules need a password and the update rules a name", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"No password"}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field is required.")
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":""}`, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
|
||||
})
|
||||
|
||||
t.Run("boot rules", func(t *testing.T) {
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " secret:\n type: password\n")},
|
||||
"field secret: type password needs the controller to list it in FormVirtualFields", "acme.roster.people", rosterFieldsFile)
|
||||
// A form-only text field the controller does not list is still a
|
||||
// column error.
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " nickname:\n type: text\n")},
|
||||
"field nickname is not a model column")
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " notify:\n label: acme.roster::lang.people.notify\n type: checkbox\n default: true\n context: create\n", "")},
|
||||
"FormVirtualFields: field notify is not a field of this form", rosterFieldsFile)
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: partial\n path: status\n")},
|
||||
"FormVirtualFields: field notify has type partial")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPresetSchema checks the fields.yaml preset key (D-27 G7): the schema
|
||||
// carries it and its boot rules hold.
|
||||
func TestPresetSchema(t *testing.T) {
|
||||
env, _ := newRosterEnv(t)
|
||||
_, raw := rosterFormSchema(t, env, "bearer")
|
||||
if !strings.Contains(raw, `"name":"slug","type":"text","label":"Slug","preset":{"field":"name","type":"slug"}`) {
|
||||
t.Fatalf("preset is not in the schema: %s", raw)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, old, new string
|
||||
want string
|
||||
}{
|
||||
{"mapping with exact", " preset: name\n", " preset:\n field: name\n type: exact\n", ""},
|
||||
{"unsupported type", " preset: name\n", " preset:\n field: name\n type: camel\n", "preset type camel is not supported (want slug or exact)"},
|
||||
{"unknown key", " preset: name\n", " preset:\n field: name\n prefix: x\n", "preset: unknown field prefix"},
|
||||
{"not a text target", " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n", "preset is only valid on type: text"},
|
||||
{"unknown source", " preset: name\n", " preset: title\n", "field slug: preset field title is not a field of this form"},
|
||||
{"source is not text", " preset: name\n", " preset: notify\n", "field slug: preset field notify must be a text field"},
|
||||
{"itself", " preset: name\n", " preset: slug\n", "field slug: preset names the field itself"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
replace := map[string]string{rosterFieldsFile: rosterFields(t, tc.old, tc.new)}
|
||||
if tc.want == "" {
|
||||
if err := rosterBoot(t, rosterTree(t, replace)); err != nil {
|
||||
t.Fatalf("did not boot: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
rosterBootFails(t, replace, tc.want, rosterFieldsFile)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user