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:
Jakub Zych
2026-10-05 10:58:38 +02:00
parent f50d9b8f10
commit df5cace852
39 changed files with 1115 additions and 84 deletions

View File

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