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) } }) } }