feat(12.1-02): writable foreign keys, locked relation options, invisible columns
- 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
This commit is contained in:
@@ -7,10 +7,13 @@ import (
|
||||
"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.
|
||||
@@ -553,3 +556,275 @@ func TestPermissionEditorSmoke(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user