package cabana_test import ( "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "testing" "git.golem15.com/golem15/summercms/modules/cabana" ) // rosterFormHead is the smallest config_form.yaml of the roster fixture. const rosterFormHead = "form: ~/plugins/acme/roster/models/person/fields.yaml\nmodelClass: Person\n" // rosterFormSchema fetches the people form schema as auth sees it. func rosterFormSchema(t *testing.T, env *rosterEnv, auth string) (cabana.FormView, string) { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/form", "", auth) var body cabana.Envelope[cabana.FormView] if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("form schema: %v\n%s", err, rec.Body.String()) } return body.Data, rec.Body.String() } // rosterRecord decodes a record response. func rosterRecord(t *testing.T, raw []byte) cabana.RecordEnvelope { t.Helper() var body cabana.RecordEnvelope if err := json.Unmarshal(raw, &body); err != nil { t.Fatalf("record body %s: %v", raw, err) } return body } // rosterBootFails asserts that the roster plugin with the replaced files does // not boot and that the error carries every wanted part. func rosterBootFails(t *testing.T, replace map[string]string, want ...string) { t.Helper() err := rosterBoot(t, rosterTree(t, replace)) if err == nil { t.Fatalf("the plugin booted, want an error naming %q", want) } for _, part := range want { if !strings.Contains(err.Error(), part) { t.Fatalf("error %q does not name %q", err, part) } } } // TestPreviewSmoke drives the preview context through the assembled router on // PostgreSQL (D-11; T-12.1-14, T-12.1-15): the schema's preview block and // messages, a preview-only field that is shown and never written, the status // hint through the partial route with the form scope, and the boot rules. func TestPreviewSmoke(t *testing.T) { env, gdb := newRosterEnv(t) ip := "203.0.113.7" ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test", Active: true, JoinedIP: &ip}) banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea", Active: true, Banned: true}) foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Banned: true}) record := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) } t.Run("schema reports the preview, its messages and the preview-only field", func(t *testing.T) { view, raw := rosterFormSchema(t, env, "bearer") if view.Preview == nil || view.Preview.HeaderPartial != "status" { t.Fatalf("preview = %+v", view.Preview) } if !strings.Contains(raw, `"preview":{"headerPartial":"status"}`) { t.Fatalf("preview block is not in the schema: %s", raw) } if view.Messages.Preview["other"] != "Person details" || view.Messages.Edit["other"] != "Edit person" { t.Fatalf("messages = %+v", view.Messages) } if !strings.Contains(raw, `"name":"joined_ip","type":"text","label":"Joined from IP address","context":"preview"`) { t.Fatalf("preview-only field is not in the schema: %s", raw) } if !strings.Contains(raw, `"redirectClose":"acme/roster/people/preview/:id"`) { t.Fatalf("redirects do not point at the preview: %s", raw) } }) t.Run("a preview-only field is shown and never written", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodGet, record(ada), "", "bearer") if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip { t.Fatalf("show joined_ip = %v", got) } rec = env.expect(t, http.StatusOK, http.MethodPut, record(ada), `{"name":"Ada L","joined_ip":"198.51.100.1"}`, "bearer") if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip { t.Fatalf("update answered joined_ip = %v", got) } stored := rosterLoad(t, gdb, ada) 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","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 { t.Fatalf("create wrote joined_ip = %q", *got.JoinedIP) } }) t.Run("the status hint renders through the partial route in the form scope", func(t *testing.T) { rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, banned), "", "bearer") body := rec.Body.String() for _, part := range []string{`"class":"summer-callout summer-callout--danger"`, `"role":"status"`, `"class":"summer-callout__title"`, "This person is banned", "A banned person cannot sign in until the ban is lifted."} { if !strings.Contains(body, part) { t.Fatalf("hint %s does not carry %s", body, part) } } // No state applies: zero nodes, so the screen renders no hint. rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, ada), "", "bearer") var view cabana.Envelope[cabana.PartialView] if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil || len(view.Data.Nodes) != 0 { t.Fatalf("hint for an active person = %s err=%v", rec.Body.String(), err) } // Another tenant's person is outside the form scope. rec = env.expect(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, foreign), "", "bearer") actErrorCode(t, rec.Body.Bytes(), "not_found") }) t.Run("boot rules", func(t *testing.T) { const form = "controllers/people/config_form.yaml" rosterBootFails(t, map[string]string{form: rosterFormHead + "recordActions: [activate]\n"}, "recordActions needs a preview block (record actions are offered on the preview screen)", "acme.roster.people", form) rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n"}, "preview must be a mapping; write preview: {} to enable the preview screen without a header partial", form) rosterBootFails(t, map[string]string{form: rosterFormHead + "preview: true\n"}, "preview must be a mapping") rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n toolbar: x\n"}, "preview: unknown field toolbar") rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: $/acme/status.htm\n"}, "headerPartial", "path must be a partial name") rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: missing\n"}, "partial missing", "controllers/people/_missing.htm") // preview: {} enables the screen without a hint. if err := rosterBoot(t, rosterTree(t, map[string]string{form: rosterFormHead + "preview: {}\nrecordActions: [activate]\n"})); err != nil { t.Fatalf("preview: {} did not boot: %v", err) } }) } // 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) }) } }