- FieldRelationContract.WritableForeignKey makes a belongsTo field over a protected foreign key writable; the protected key list is unchanged - cabana.RelationLockProvider names related ids an administrator may not add or remove: options and labels carry locked, and a create or update that changes the locked subset is 403 before any row is written - columns.yaml invisible keeps a column searchable and out of the rows - a controller implementing pact.FilterOptions serves a scope filter's choices before the model - SPA: locked chips and options in RelationField, DataTable skips invisible columns - README, docs, OpenAPI document, TS types and dist updated
831 lines
42 KiB
Go
831 lines
42 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPermissionEditorSmoke drives `type: permissioneditor` through the
|
|
// assembled router on PostgreSQL (D-16; T-12.1-13): options per administrator,
|
|
// the code and value checks, the locked guard, kept unknown codes and the
|
|
// boot rules.
|
|
func TestPermissionEditorSmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
legacy := `{"legacy.code":1,"reports.export":1}`
|
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &legacy})
|
|
record := fmt.Sprintf("%s/%d", rosterPeople, id)
|
|
stored := func(t *testing.T) map[string]int {
|
|
t.Helper()
|
|
person := rosterLoad(t, gdb, id)
|
|
out := map[string]int{}
|
|
if person.Permissions != nil {
|
|
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
|
|
t.Fatalf("stored permissions %q: %v", *person.Permissions, err)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
same := func(t *testing.T, got, want map[string]int) {
|
|
t.Helper()
|
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Fatalf("permissions = %v, want %v", got, want)
|
|
}
|
|
}
|
|
const unknown = "The permissions field contains an unknown permission."
|
|
const invalid = "The permissions field contains an invalid value."
|
|
const shape = "The permissions field must be an object of permission codes."
|
|
|
|
t.Run("the schema carries localized options, locked only for the limited admin", func(t *testing.T) {
|
|
view, raw := rosterFormSchema(t, env, "bearer")
|
|
if !strings.Contains(raw, `"name":"permissions","type":"permissioneditor","label":"Permissions","tab":"Permissions","context":"update","mode":"radio","permissionOptions":[{"code":"posts.edit","label":"Edit posts","tab":"Content","comment":"Change the text of any post."},{"code":"posts.publish","label":"Publish posts","tab":"Content"},{"code":"reports.export","label":"Export reports","tab":"Reports"},{"code":"misc.beta","label":"Try beta features"}]`) {
|
|
t.Fatalf("permission field is not in the schema: %s", raw)
|
|
}
|
|
if strings.Contains(raw, `"locked"`) {
|
|
t.Fatalf("an option is locked for the full admin: %s", raw)
|
|
}
|
|
_ = view
|
|
limited, raw := rosterFormSchema(t, env, "limited")
|
|
if !strings.Contains(raw, `{"code":"reports.export","label":"Export reports","tab":"Reports","locked":true}`) || strings.Count(raw, `"locked":true`) != 1 {
|
|
t.Fatalf("limited admin's options: %s", raw)
|
|
}
|
|
// The cached schema was not mutated by either request.
|
|
for _, field := range limited.Fields {
|
|
if field.Type == "permissioneditor" && len(field.PermissionOptions) != 4 {
|
|
t.Fatalf("options = %+v", field.PermissionOptions)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("show returns the stored codes as an object", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"permissions":{"legacy.code":1,"reports.export":1}`) {
|
|
t.Fatalf("show: %s", rec.Body.String())
|
|
}
|
|
blank := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Blank", Active: true})
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, blank), "", "bearer")
|
|
if !strings.Contains(rec.Body.String(), `"permissions":{}`) {
|
|
t.Fatalf("show without stored permissions: %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("an update stores offered codes, drops inherit and keeps a stored code that is not offered", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"posts.publish":-1,"misc.beta":0,"reports.export":1}}`, "bearer")
|
|
want := map[string]int{"legacy.code": 1, "posts.edit": 1, "posts.publish": -1, "reports.export": 1}
|
|
same(t, stored(t), want)
|
|
got, _ := rosterRecord(t, rec.Body.Bytes()).Data["permissions"].(map[string]any)
|
|
if len(got) != 4 || got["posts.publish"] != float64(-1) || got["legacy.code"] != float64(1) {
|
|
t.Fatalf("update answered %v", got)
|
|
}
|
|
// An offered code that is left out goes back to inherit.
|
|
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"reports.export":1}}`, "bearer")
|
|
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
|
|
// A save without the field leaves the column alone.
|
|
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Perm B"}`, "bearer")
|
|
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
|
|
})
|
|
|
|
t.Run("an unknown code, a value outside the set and a non-object are 422", func(t *testing.T) {
|
|
before := stored(t)
|
|
for body, message := range map[string]string{
|
|
`{"permissions":{"posts.edit":1,"admin.root":1}}`: unknown,
|
|
// A stored code that is not offered cannot be submitted either.
|
|
`{"permissions":{"legacy.code":1}}`: unknown,
|
|
`{"permissions":{"posts.edit":2}}`: invalid,
|
|
`{"permissions":{"posts.edit":-2}}`: invalid,
|
|
`{"permissions":{"posts.edit":"1"}}`: shape,
|
|
`{"permissions":{"posts.edit":1.5}}`: shape,
|
|
`{"permissions":{"posts.edit":true}}`: shape,
|
|
`{"permissions":{"posts.edit":{"a":1}}}`: shape,
|
|
`{"permissions":["posts.edit"]}`: shape,
|
|
`{"permissions":"posts.edit"}`: shape,
|
|
`{"permissions":null}`: shape,
|
|
} {
|
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, body, "bearer")
|
|
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", message)
|
|
}
|
|
same(t, stored(t), before)
|
|
})
|
|
|
|
t.Run("a changed locked code is 403 for the limited admin and nothing is written", func(t *testing.T) {
|
|
before := stored(t)
|
|
const locked = "You cannot change this permission."
|
|
// Removing it (leaving it out), denying it and renaming at the same time.
|
|
for _, body := range []string{
|
|
`{"name":"Sneaky","permissions":{"posts.edit":1}}`,
|
|
`{"name":"Sneaky","permissions":{"posts.edit":1,"reports.export":-1}}`,
|
|
`{"name":"Sneaky","permissions":{"reports.export":0}}`,
|
|
} {
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPut, record, body, "limited")
|
|
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
|
|
}
|
|
same(t, stored(t), before)
|
|
if person := rosterLoad(t, gdb, id); person.Name != "Perm B" {
|
|
t.Fatalf("a refused save renamed the person: %q", person.Name)
|
|
}
|
|
// Granting it where it is not stored is refused too.
|
|
bare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, bare), `{"permissions":{"reports.export":1}}`, "limited")
|
|
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
|
|
if person := rosterLoad(t, gdb, bare); person.Permissions != nil {
|
|
t.Fatalf("a refused save wrote %q", *person.Permissions)
|
|
}
|
|
|
|
// The limited admin may change the other codes while the locked one
|
|
// keeps its stored value.
|
|
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1,"reports.export":1}}`, "limited")
|
|
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1, "reports.export": 1})
|
|
// The full admin may change it.
|
|
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1}}`, "bearer")
|
|
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1})
|
|
})
|
|
|
|
t.Run("a field hidden on create is not written by a create", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1","permissions":{"posts.edit":1}}`, "bearer")
|
|
created := rosterRecord(t, rec.Body.Bytes())
|
|
newID, _ := created.Data["id"].(float64)
|
|
if person := rosterLoad(t, gdb, uint(newID)); person.Permissions != nil {
|
|
t.Fatalf("create wrote permissions %q", *person.Permissions)
|
|
}
|
|
if got, ok := created.Data["permissions"].(map[string]any); !ok || len(got) != 0 {
|
|
t.Fatalf("create answered permissions %v", created.Data["permissions"])
|
|
}
|
|
})
|
|
|
|
t.Run("boot rules", func(t *testing.T) {
|
|
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", "")},
|
|
"field permissions: mode must be radio or checkbox on type: permissioneditor", rosterFieldsFile)
|
|
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: tabs\n")},
|
|
"mode must be radio or checkbox on type: permissioneditor")
|
|
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
|
|
"default is not valid on type: permissioneditor")
|
|
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
|
|
"mode is only valid on type: fileupload, datepicker or permissioneditor")
|
|
if err := rosterBoot(t, rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")})); err != nil {
|
|
t.Fatalf("mode: checkbox did not boot: %v", err)
|
|
}
|
|
// A controller without the provider cannot have the field.
|
|
err := activateFields(t, datepickerFields(" rights:\n type: permissioneditor\n mode: checkbox\n"))
|
|
const want = "field rights: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider"
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("error = %v, want %q", err, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// rosterPivot reads a person's tag ids in ascending order.
|
|
func rosterPivot(t *testing.T, gdb *gorm.DB, person uint) []uint {
|
|
t.Helper()
|
|
ids := []uint{}
|
|
if err := gdb.Model(&rosterPersonTag{}).Where("person_id = ?", person).Order("tag_id").Pluck("tag_id", &ids).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// rosterSeed stores any fixture row.
|
|
func rosterSeed(t *testing.T, gdb *gorm.DB, row any) {
|
|
t.Helper()
|
|
if err := gdb.Create(row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestWritableForeignKeySmoke drives FieldRelationContract.WritableForeignKey
|
|
// (D-27 G3; T-12.1-11): a belongsTo field over a protected foreign key is
|
|
// writable only with the explicit opt-in, and submitted ids still pass the
|
|
// scoped options query.
|
|
func TestWritableForeignKeySmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
home, away := rosterTeam{Tenant: "acme", Name: "Home"}, rosterTeam{Tenant: "other", Name: "Away"}
|
|
rosterSeed(t, gdb, &home)
|
|
rosterSeed(t, gdb, &away)
|
|
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tess", Active: true})
|
|
record := fmt.Sprintf("%s/%d", rosterPeople, id)
|
|
org := func(t *testing.T) uint {
|
|
t.Helper()
|
|
if person := rosterLoad(t, gdb, id); person.OrganisationID != nil {
|
|
return *person.OrganisationID
|
|
}
|
|
return 0
|
|
}
|
|
|
|
t.Run("the field is writable and offers the scoped options", func(t *testing.T) {
|
|
_, raw := rosterFormSchema(t, env, "bearer")
|
|
if !strings.Contains(raw, `"name":"team","type":"relation","label":"Team","nameFrom":"name","emptyOption":"No team"}`) {
|
|
t.Fatalf("team field is read-only or missing: %s", raw)
|
|
}
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer")
|
|
if body := rec.Body.String(); !strings.Contains(body, `"label":"Home"`) || strings.Contains(body, "Away") {
|
|
t.Fatalf("options = %s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("a save sets the protected key through the relation field only", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPut, record, fmt.Sprintf(`{"team":%d}`, home.ID), "bearer")
|
|
if org(t) != home.ID {
|
|
t.Fatalf("organisation_id = %d, want %d", org(t), home.ID)
|
|
}
|
|
body := rosterRecord(t, rec.Body.Bytes())
|
|
if body.Data["team"] != float64(home.ID) || len(body.Meta.Labels["team"]) != 1 || body.Meta.Labels["team"][0].Label != "Home" {
|
|
t.Fatalf("update answered data=%v labels=%v", body.Data["team"], body.Meta.Labels["team"])
|
|
}
|
|
if _, leaked := body.Data["organisation_id"]; leaked {
|
|
t.Fatalf("the response carries organisation_id: %v", body.Data)
|
|
}
|
|
// The scalar key stays protected: it is dropped from a body.
|
|
env.expect(t, http.StatusOK, http.MethodPut, record, fmt.Sprintf(`{"organisation_id":%d}`, away.ID), "bearer")
|
|
if org(t) != home.ID {
|
|
t.Fatalf("a scalar organisation_id was written: %d", org(t))
|
|
}
|
|
})
|
|
|
|
t.Run("an id outside the options scope is 422 and null clears the key", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, fmt.Sprintf(`{"team":%d}`, away.ID), "bearer")
|
|
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "team", "The selected team is invalid.")
|
|
if org(t) != home.ID {
|
|
t.Fatalf("a refused save wrote organisation_id = %d", org(t))
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, record, `{"team":null}`, "bearer")
|
|
if org(t) != 0 {
|
|
t.Fatalf("null did not clear organisation_id: %d", org(t))
|
|
}
|
|
})
|
|
|
|
t.Run("the same contract without the flag is read-only", func(t *testing.T) {
|
|
plain, plainDB := newRosterEnvWith(t, func(p *rosterPlugin) {
|
|
p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
|
|
in[0].WritableForeignKey = false
|
|
return in
|
|
}
|
|
})
|
|
team := rosterTeam{Tenant: "acme", Name: "Home"}
|
|
rosterSeed(t, plainDB, &team)
|
|
person := rosterInsert(t, plainDB, rosterPerson{Tenant: "acme", Name: "Ro", Active: true})
|
|
_, raw := rosterFormSchema(t, plain, "bearer")
|
|
if !strings.Contains(raw, `"name":"team","type":"relation","label":"Team","nameFrom":"name","emptyOption":"No team","readOnly":true}`) {
|
|
t.Fatalf("team field is not read-only: %s", raw)
|
|
}
|
|
plain.expect(t, http.StatusNotFound, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer")
|
|
plain.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, person), fmt.Sprintf(`{"team":%d}`, team.ID), "bearer")
|
|
if stored := rosterLoad(t, plainDB, person); stored.OrganisationID != nil {
|
|
t.Fatalf("a read-only relation field wrote organisation_id = %d", *stored.OrganisationID)
|
|
}
|
|
})
|
|
|
|
t.Run("the flag is refused on belongsToMany", func(t *testing.T) {
|
|
err := rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, relations: func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
|
|
in[1].WritableForeignKey = true
|
|
return in
|
|
}})
|
|
const want = "field tags: WritableForeignKey is only valid on belongsTo"
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("error = %v, want %q", err, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRelationLockSmoke drives cabana.RelationLockProvider (D-27 G4, D-07;
|
|
// T-12.1-12): locked ids are flagged for the administrator they are locked
|
|
// for, and a create or update that changes the locked subset is 403 and
|
|
// writes nothing.
|
|
func TestRelationLockSmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
staff, news, beta := rosterTag{Name: "staff"}, rosterTag{Name: "news"}, rosterTag{Name: "beta"}
|
|
for _, tag := range []*rosterTag{&staff, &news, &beta} {
|
|
rosterSeed(t, gdb, tag)
|
|
}
|
|
plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
|
|
member := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Member", Active: true})
|
|
rosterSeed(t, gdb, &rosterPersonTag{PersonID: plain, TagID: news.ID})
|
|
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: staff.ID})
|
|
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: news.ID})
|
|
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
|
|
tags := func(ids ...uint) string {
|
|
parts := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
parts[i] = fmt.Sprint(id)
|
|
}
|
|
return `"tags":[` + strings.Join(parts, ",") + `]`
|
|
}
|
|
same := func(t *testing.T, person uint, want ...uint) {
|
|
t.Helper()
|
|
sort.Slice(want, func(i, j int) bool { return want[i] < want[j] })
|
|
if got := rosterPivot(t, gdb, person); fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Fatalf("pivot of %d = %v, want %v", person, got, want)
|
|
}
|
|
}
|
|
const message = "You need an additional permission to change the staff tag."
|
|
refused := func(t *testing.T, method, rel, body string) {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusForbidden, method, rel, body, "limited")
|
|
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "tags", message)
|
|
var envelope cabana.ErrorEnvelope
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil || envelope.Error.Message != message {
|
|
t.Fatalf("message = %q err=%v", envelope.Error.Message, err)
|
|
}
|
|
}
|
|
|
|
t.Run("options and labels carry locked for the limited admin only", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "limited")
|
|
if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf(`{"value":%d,"label":"staff","locked":true}`, staff.ID)) || strings.Count(body, `"locked"`) != 1 {
|
|
t.Fatalf("limited options = %s", body)
|
|
}
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "bearer")
|
|
if strings.Contains(rec.Body.String(), `"locked"`) {
|
|
t.Fatalf("full admin options = %s", rec.Body.String())
|
|
}
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, path(member), "", "limited")
|
|
if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf(`{"value":%d,"label":"staff","locked":true}`, staff.ID)) || strings.Count(body, `"locked"`) != 1 {
|
|
t.Fatalf("limited labels = %s", body)
|
|
}
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, path(member), "", "bearer")
|
|
if strings.Contains(rec.Body.String(), `"locked"`) {
|
|
t.Fatalf("full admin labels = %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("adding or removing a locked id on update is 403 and the pivot is unchanged", func(t *testing.T) {
|
|
refused(t, http.MethodPut, path(plain), `{"name":"Sneaky",`+tags(news.ID, staff.ID)+`}`)
|
|
same(t, plain, news.ID)
|
|
if person := rosterLoad(t, gdb, plain); person.Name != "Plain" {
|
|
t.Fatalf("a refused save renamed the person: %q", person.Name)
|
|
}
|
|
refused(t, http.MethodPut, path(member), `{`+tags(news.ID)+`}`)
|
|
refused(t, http.MethodPut, path(member), `{`+tags()+`}`)
|
|
same(t, member, staff.ID, news.ID)
|
|
})
|
|
|
|
t.Run("creating a record with a locked id is 403 and no row is created", func(t *testing.T) {
|
|
refused(t, http.MethodPost, rosterPeople, `{"name":"Smuggled","password":"long-enough-1","password_confirmation":"long-enough-1",`+tags(staff.ID)+`}`)
|
|
var count int64
|
|
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Smuggled").Count(&count).Error; err != nil || count != 0 {
|
|
t.Fatalf("rows named Smuggled = %d err=%v", count, err)
|
|
}
|
|
})
|
|
|
|
t.Run("a change that leaves the locked subset alone passes", func(t *testing.T) {
|
|
rec := env.expect(t, http.StatusOK, http.MethodPut, path(member), `{`+tags(staff.ID, beta.ID)+`}`, "limited")
|
|
same(t, member, staff.ID, beta.ID)
|
|
if !strings.Contains(rec.Body.String(), `"locked":true`) {
|
|
t.Fatalf("update response does not flag the locked label: %s", rec.Body.String())
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, path(plain), `{`+tags(beta.ID)+`}`, "limited")
|
|
same(t, plain, beta.ID)
|
|
// A save that does not send the field is not checked.
|
|
env.expect(t, http.StatusOK, http.MethodPut, path(member), `{"name":"Member B"}`, "limited")
|
|
same(t, member, staff.ID, beta.ID)
|
|
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1",`+tags(news.ID)+`}`, "limited")
|
|
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
|
|
same(t, uint(created), news.ID)
|
|
})
|
|
|
|
t.Run("the full admin may change the locked id", func(t *testing.T) {
|
|
env.expect(t, http.StatusOK, http.MethodPut, path(plain), `{`+tags(staff.ID)+`}`, "bearer")
|
|
same(t, plain, staff.ID)
|
|
env.expect(t, http.StatusOK, http.MethodPut, path(member), `{`+tags()+`}`, "bearer")
|
|
same(t, member)
|
|
})
|
|
}
|
|
|
|
// TestInvisibleColumnSmoke drives the columns.yaml invisible key (D-27 G6):
|
|
// the column is flagged in the schema, searched on the server and left out of
|
|
// the rows.
|
|
func TestInvisibleColumnSmoke(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@hidden.example.test", Active: true})
|
|
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test", Active: true})
|
|
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
|
|
if raw := rec.Body.String(); !strings.Contains(raw, `{"key":"email","label":"Email","searchable":true,"sortable":true,"invisible":true}`) || strings.Count(raw, `"invisible"`) != 1 {
|
|
t.Fatalf("list schema = %s", raw)
|
|
}
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
|
|
if raw := rec.Body.String(); strings.Contains(raw, "email") || strings.Contains(raw, "example.test") || !strings.Contains(raw, `"name":"Ada"`) {
|
|
t.Fatalf("rows carry the invisible column: %s", raw)
|
|
}
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search=hidden.example", "", "bearer")
|
|
if raw := rec.Body.String(); !strings.Contains(raw, `"name":"Ada"`) || strings.Contains(raw, `"name":"Bob"`) || strings.Contains(raw, "hidden.example") {
|
|
t.Fatalf("search by the invisible column = %s", raw)
|
|
}
|
|
// It still sorts on the server.
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?sort=email&dir=desc", "", "bearer")
|
|
if raw := rec.Body.String(); strings.Index(raw, `"name":"Bob"`) > strings.Index(raw, `"name":"Ada"`) {
|
|
t.Fatalf("sort by the invisible column = %s", raw)
|
|
}
|
|
}
|
|
|
|
// TestFilterOptionsController checks that a controller implementing
|
|
// pact.FilterOptions serves a scope filter's choices before the model, so the
|
|
// choices can come from the database. The model-only path is covered by
|
|
// TestPhase10FilterOptions.
|
|
func TestFilterOptionsController(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
news, beta := rosterTag{Name: "news"}, rosterTag{Name: "beta"}
|
|
rosterSeed(t, gdb, &news)
|
|
rosterSeed(t, gdb, &beta)
|
|
tagged := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tagged", Active: true})
|
|
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
|
|
rosterSeed(t, gdb, &rosterPersonTag{PersonID: tagged, TagID: news.ID})
|
|
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/filters/tagged/options", "", "bearer")
|
|
want := fmt.Sprintf(`"data":[{"value":"%d","label":"beta"},{"value":"%d","label":"news"}]`, beta.ID, news.ID)
|
|
if !strings.Contains(rec.Body.String(), want) {
|
|
t.Fatalf("options = %s, want %s", rec.Body.String(), want)
|
|
}
|
|
// The scope itself is still the model's.
|
|
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s?filter[tagged]=%d", rosterPeople, news.ID), "", "bearer")
|
|
if raw := rec.Body.String(); !strings.Contains(raw, `"name":"Tagged"`) || strings.Contains(raw, `"name":"Bare"`) {
|
|
t.Fatalf("filtered list = %s", raw)
|
|
}
|
|
// The model does not implement FilterOptions: without the controller's
|
|
// the filter would not compile.
|
|
if _, ok := any(rosterPerson{}).(pact.FilterOptions); ok {
|
|
t.Fatal("the fixture model serves filter options itself")
|
|
}
|
|
}
|