Files
summercms/modules/cabana/phase121_fields_test.go
Jakub Zych 2e94cbf9f8 test(12.1-05): unit tests for bulk and record actions, row state, forbidden, preview and the form seams
- bulk action: empty, duplicate, unordered, absent, partial, out-of-scope, rollback, concurrent runs, permissions, CSRF, body cap
- record action: scope, Applies, strict body, offered order, rollback, Applies error
- ForbiddenError from every Form hook, the bulk delete and the relation link and child hooks
- permission editor modes, locked codes and provider errors; relation locks on create, update and belongsTo
- TestPhase121BootErrors: every boot error of plans 01 and 02 with plugin, controller and file
- pact: the action, row state and filter contracts on a sample controller
2026-10-05 14:48:34 +02:00

239 lines
14 KiB
Go

package cabana_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// TestPasswordFieldNeverProjected: no response of any route carries the
// password key, the submitted text or the stored hash, and the schema serves
// the field without a value (D-27 G1; T-12.1-10).
func TestPasswordFieldNeverProjected(t *testing.T) {
env, gdb := newRosterEnv(t)
const plain = "s3cret-plain-text"
pair := fmt.Sprintf(`"password":%q,"password_confirmation":%q`, plain, plain)
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Pat",`+pair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
id := uint(created)
hash := rosterHash(plain)
if rosterLoad(t, gdb, id).Password != hash {
t.Fatal("the hook did not store the hash")
}
bodies := map[string]string{
"create": rec.Body.String(),
"show": env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "bearer").Body.String(),
"list": env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer").Body.String(),
"list search": env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search="+plain, "", "bearer").Body.String(),
"update": env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Pat B",`+pair+`}`, "bearer").Body.String(),
"update, plain": env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Pat C"}`, "bearer").Body.String(),
"record action": env.expect(t, http.StatusOK, http.MethodPost, rosterPath(id, "/actions/activate"), `{}`, "bearer").Body.String(),
"bulk action": env.expect(t, http.StatusOK, http.MethodPost, rosterArchive, rosterIDs(id), "bearer").Body.String(),
"refusal": env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"name":"Reserved",`+pair+`}`, "bearer").Body.String(),
"failure": env.expect(t, http.StatusInternalServerError, http.MethodPut, rosterPath(id, ""), `{"name":"Boom",`+pair+`}`, "bearer").Body.String(),
}
for route, body := range bodies {
if strings.Contains(body, "password") || strings.Contains(body, plain) || strings.Contains(body, hash) || strings.Contains(body, "sha256:") {
t.Fatalf("the %s response carries the password: %s", route, body)
}
}
// A validation failure names the field and still carries no value.
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"password":%q,"password_confirmation":"other-enough-1"}`, plain), "bearer")
if strings.Contains(rec.Body.String(), plain) || strings.Contains(rec.Body.String(), "other-enough-1") {
t.Fatalf("a 422 echoes the password: %s", rec.Body.String())
}
// The search above did not match on the password column either.
if strings.Contains(bodies["list search"], `"name":"Pat"`) {
t.Fatalf("the list searches the password column: %s", bodies["list search"])
}
_, raw := rosterFormSchema(t, env, "bearer")
for _, field := range []string{
`{"name":"password","type":"password","label":"Password","span":"left","context":["create","update"]}`,
`{"name":"password_confirmation","type":"password","label":"Repeat the password","span":"right","context":["create","update"]}`,
} {
if !strings.Contains(raw, field) {
t.Fatalf("the schema does not serve %s without a value: %s", field, raw)
}
}
}
// TestVirtualFieldsContext: a virtual value reaches the hooks only when the
// field's context allows the operation, as a copy, and never outside a save
// (D-27 G2; T-12.1-09).
func TestVirtualFieldsContext(t *testing.T) {
env, gdb := newRosterEnv(t)
if values, ok := cabana.VirtualFieldsFromContext(context.Background()); ok || values != nil {
t.Fatalf("virtual fields outside a save: %v %v", values, ok)
}
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Vic","notify":false,`+rosterPair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
id := uint(created)
seen := env.spy.takeVirtual()
if len(seen) != 2 || seen[0].Hook != "before-create" || seen[1].Hook != "after-create" {
t.Fatalf("create hooks = %+v", seen)
}
for _, hook := range seen {
// The before hook removed notify from its own copy.
if !hook.Found || len(hook.Values) != 3 || hook.Values["notify"] != false || hook.Values["password"] != "long-enough-1" {
t.Fatalf("%s saw %+v", hook.Hook, hook.Values)
}
}
// A create that does not submit a virtual field passes no entry for it.
env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Val",`+rosterPair+`}`, "bearer")
if seen = env.spy.takeVirtual(); len(seen) != 2 || len(seen[0].Values) != 2 {
t.Fatalf("create without notify: %+v", seen)
}
if _, ok := seen[0].Values["notify"]; ok {
t.Fatalf("an absent virtual field got an entry: %+v", seen[0].Values)
}
// notify has context create: an update never passes it. The password pair
// has context create and update: it is passed.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Vic B","notify":true,"password":"another-plain-9","password_confirmation":"another-plain-9"}`, "bearer")
seen = env.spy.takeVirtual()
if len(seen) != 1 || seen[0].Hook != "before-update" || !seen[0].Found || len(seen[0].Values) != 2 || seen[0].Values["password"] != "another-plain-9" {
t.Fatalf("update hook saw %+v", seen)
}
if rosterLoad(t, gdb, id).Password != rosterHash("another-plain-9") {
t.Fatal("the update hook did not receive the password")
}
// An update without virtual values still reports a save: an empty map.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Vic C"}`, "bearer")
if seen = env.spy.takeVirtual(); len(seen) != 1 || !seen[0].Found || len(seen[0].Values) != 0 {
t.Fatalf("update without virtual values: %+v", seen)
}
// A number arrives as decoded from JSON, a json.Number: the fixture's hook
// takes a string only, so the stored hash stays.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"password":12345678,"password_confirmation":12345678}`, "bearer")
seen = env.spy.takeVirtual()
if number, ok := seen[0].Values["password"].(json.Number); len(seen) != 1 || !ok || number.String() != "12345678" {
t.Fatalf("a numeric virtual value arrived as %T %v", seen[0].Values["password"], seen[0].Values["password"])
}
if rosterLoad(t, gdb, id).Password != rosterHash("another-plain-9") {
t.Fatal("a numeric password was stored")
}
}
// TestVirtualFieldsNested: a nested value for a virtual field is 422 on the
// field and reaches no hook.
func TestVirtualFieldsNested(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Nest", Password: rosterHash("stored-before")})
for field, value := range map[string]string{
"password": `{"$ne":""}`,
"password_confirmation": `["a","b"]`,
} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"name":"Changed",%q:%s}`, field, value), "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", field, "The "+field+" field has an invalid value.")
}
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Nested","notify":[true],`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field has an invalid value.")
if stored := rosterLoad(t, gdb, id); stored.Name != "Nest" || stored.Password != rosterHash("stored-before") {
t.Fatalf("a refused save wrote: %+v", stored)
}
if n := rosterCount(t, env, "Nested"); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
if seen := env.spy.takeVirtual(); len(seen) != 0 {
t.Fatalf("a refused save reached a hook: %+v", seen)
}
}
// TestFormRulesReplaceModelRules: the controller's FormRules are the rules of
// an admin save, per operation; without them the model's Rules apply (D-28
// G5).
func TestFormRulesReplaceModelRules(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Rae", Password: rosterHash("stored-before")})
// The model demands a confirmed password on every save; the controller's
// update rules do not.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Rae B"}`, "bearer")
// The create rules do.
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.")
// Both operations keep the name rule.
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"name":""}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
// The same form on a controller without FormRules: the model's rules.
bare, bareDB := newRosterBareEnv(t)
other := rosterInsert(t, bareDB, rosterPerson{Tenant: "acme", Name: "Rae", Password: rosterHash("stored-before")})
rec = bare.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(other, ""), `{"name":"Rae B"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field is required.")
bare.expect(t, http.StatusOK, http.MethodPut, rosterPath(other, ""), `{"name":"Rae B",`+rosterPair+`}`, "bearer")
if stored := rosterLoad(t, bareDB, other); stored.Name != "Rae B" || stored.Password != rosterHash("long-enough-1") {
t.Fatalf("stored = %+v", stored)
}
}
// TestFormRulesRequiredMerge: `required: true` in fields.yaml is merged into
// the rules of a column field and of a virtual field, for the operations the
// field's context allows.
func TestFormRulesRequiredMerge(t *testing.T) {
fields := rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n required: true\n")
fields = strings.Replace(fields, " type: text\n span: right\n", " type: text\n span: right\n required: true\n", 1)
if !strings.Contains(fields, "span: right\n required: true") {
t.Fatal("the email field was not made required")
}
env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{rosterFieldsFile: fields})
})
const email = `"email":"req@example.test"`
// The virtual field notify is required on create, where its context is.
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Req",`+email+`,`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field is required.")
// The column field email is required although FormRules does not name it.
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Req","notify":true,`+rosterPair+`}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "email", "The email field is required.")
if n := rosterCount(t, env, "Req"); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Req","notify":true,`+email+`,`+rosterPair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
// On update notify is outside its context and is not asked for; email is.
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(uint(created), ""), `{"name":"Req B"}`, "bearer")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(uint(created), ""), `{"email":""}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "email", "The email field is required.")
if stored := rosterLoad(t, gdb, uint(created)); stored.Name != "Req B" || stored.Email != "req@example.test" {
t.Fatalf("stored = %+v", stored)
}
// The schema tells the client.
_, raw := rosterFormSchema(t, env, "bearer")
if strings.Count(raw, `"required":true`) != 2 {
t.Fatalf("required flags in the schema: %s", raw)
}
}
// TestPresetSchemaShapes: the preset key is served as field and type in both
// of its YAML shapes (D-27 G7).
func TestPresetSchemaShapes(t *testing.T) {
for _, tc := range []struct{ name, yaml, want string }{
{"a field name", " preset: name\n", `"preset":{"field":"name","type":"slug"}`},
{"a mapping with slug", " preset:\n field: name\n type: slug\n", `"preset":{"field":"name","type":"slug"}`},
{"a mapping with exact", " preset:\n field: name\n type: exact\n", `"preset":{"field":"name","type":"exact"}`},
{"a mapping without a type", " preset:\n field: email\n", `"preset":{"field":"email","type":"slug"}`},
} {
t.Run(tc.name, func(t *testing.T) {
env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", tc.yaml)})
})
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"slug","type":"text","label":"Slug",`+tc.want) || strings.Count(raw, `"preset"`) != 1 {
t.Fatalf("schema = %s, want %s", raw, tc.want)
}
// The preset is a client rule: the server stores what it is sent.
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Zażółć Gęślą","slug":"sent-by-client",`+rosterPair+`}`, "bearer")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
if stored := rosterLoad(t, gdb, uint(created)); stored.Slug != "sent-by-client" {
t.Fatalf("stored slug = %q", stored.Slug)
}
})
}
}