- bulk action: empty, duplicate, unordered, absent, partial, out-of-scope, rollback, concurrent runs, permissions, CSRF, body cap - record action: scope, Applies, strict body, offered order, rollback, Applies error - ForbiddenError from every Form hook, the bulk delete and the relation link and child hooks - permission editor modes, locked codes and provider errors; relation locks on create, update and belongsTo - TestPhase121BootErrors: every boot error of plans 01 and 02 with plugin, controller and file - pact: the action, row state and filter contracts on a sample controller
314 lines
16 KiB
Go
314 lines
16 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const rosterTagLocked = "You need an additional permission to change the staff tag."
|
|
|
|
// rosterLockSeed stores the staff, news and beta tags.
|
|
func rosterLockSeed(t *testing.T, gdb *gorm.DB) (staff, news, beta rosterTag) {
|
|
t.Helper()
|
|
staff, news, beta = rosterTag{Name: "staff"}, rosterTag{Name: "news"}, rosterTag{Name: "beta"}
|
|
for _, tag := range []*rosterTag{&staff, &news, &beta} {
|
|
rosterSeed(t, gdb, tag)
|
|
}
|
|
return staff, news, beta
|
|
}
|
|
|
|
// rosterTags is the tags key of a save body.
|
|
func rosterTags(ids ...uint) string {
|
|
parts := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
parts[i] = fmt.Sprint(id)
|
|
}
|
|
return `"tags":[` + strings.Join(parts, ",") + `]`
|
|
}
|
|
|
|
// rosterLockRefused asserts the 403 of a locked relation field.
|
|
func rosterLockRefused(t *testing.T, env *rosterEnv, method, rel, body, field, message string) {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusForbidden, method, rel, body, "limited")
|
|
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", field, message)
|
|
}
|
|
|
|
// TestRelationLockCreate: a create that carries a locked id is 403 and
|
|
// creates nothing; the pivot is written only by a create that leaves the
|
|
// locked subset empty (D-07; T-12.1-12).
|
|
func TestRelationLockCreate(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
staff, news, _ := rosterLockSeed(t, gdb)
|
|
create := func(name string, ids ...uint) string {
|
|
return fmt.Sprintf(`{"name":%q,%s,%s}`, name, rosterPair, rosterTags(ids...))
|
|
}
|
|
rosterLockRefused(t, env, http.MethodPost, rosterPeople, create("Smuggled", staff.ID), "tags", rosterTagLocked)
|
|
rosterLockRefused(t, env, http.MethodPost, rosterPeople, create("Smuggled", news.ID, staff.ID), "tags", rosterTagLocked)
|
|
if n := rosterCount(t, env, "Smuggled"); n != 0 {
|
|
t.Fatalf("a refused create left %d rows", n)
|
|
}
|
|
var pivots int64
|
|
if err := gdb.Model(&rosterPersonTag{}).Count(&pivots).Error; err != nil || pivots != 0 {
|
|
t.Fatalf("a refused create left %d pivot rows (%v)", pivots, err)
|
|
}
|
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, create("Plain", news.ID), "limited")
|
|
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
|
|
if got := rosterPivot(t, gdb, uint(created)); !reflect.DeepEqual(got, []uint{news.ID}) {
|
|
t.Fatalf("pivot of the created person = %v", got)
|
|
}
|
|
// The administrator the id is not locked for creates with it.
|
|
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, create("Staffer", staff.ID), "bearer")
|
|
created, _ = rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
|
|
if got := rosterPivot(t, gdb, uint(created)); !reflect.DeepEqual(got, []uint{staff.ID}) {
|
|
t.Fatalf("pivot of the full admin's person = %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRelationLockUpdate: adding, removing or replacing a locked id on update
|
|
// is 403 and writes nothing; a provider error is the generic 500.
|
|
func TestRelationLockUpdate(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
staff, news, beta := rosterLockSeed(t, gdb)
|
|
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})
|
|
for _, tc := range []struct {
|
|
person uint
|
|
body string
|
|
}{
|
|
{plain, `{"name":"Sneaky",` + rosterTags(news.ID, staff.ID) + `}`},
|
|
{plain, `{` + rosterTags(staff.ID) + `}`},
|
|
{member, `{` + rosterTags(news.ID) + `}`},
|
|
{member, `{` + rosterTags() + `}`},
|
|
{member, `{"name":"Sneaky",` + rosterTags(beta.ID) + `}`},
|
|
} {
|
|
rosterLockRefused(t, env, http.MethodPut, rosterPath(tc.person, ""), tc.body, "tags", rosterTagLocked)
|
|
}
|
|
if got := rosterPivot(t, gdb, plain); !reflect.DeepEqual(got, []uint{news.ID}) {
|
|
t.Fatalf("pivot of the plain person = %v", got)
|
|
}
|
|
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID, news.ID}) {
|
|
t.Fatalf("pivot of the member = %v", got)
|
|
}
|
|
if rosterLoad(t, gdb, plain).Name != "Plain" || rosterLoad(t, gdb, member).Name != "Member" {
|
|
t.Fatal("a refused save wrote another field of its body")
|
|
}
|
|
// The locked subset unchanged: the rest of the pivot may change.
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(member, ""), `{`+rosterTags(beta.ID, staff.ID)+`}`, "limited")
|
|
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID, beta.ID}) {
|
|
t.Fatalf("pivot after an allowed change = %v", got)
|
|
}
|
|
// The refusal follows the request locale.
|
|
req := httptest.NewRequest(http.MethodPut, adminAPI(rosterPath(member, "")), strings.NewReader(`{`+rosterTags()+`}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept-Language", "pl")
|
|
req.Header.Set("Authorization", "Bearer "+env.limited)
|
|
pl := httptest.NewRecorder()
|
|
env.h.ServeHTTP(pl, req)
|
|
if pl.Code != http.StatusForbidden {
|
|
t.Fatalf("pl refusal = %d %s", pl.Code, pl.Body.String())
|
|
}
|
|
rosterErrorDetail(t, pl.Body.Bytes(), "forbidden", "tags", "Zmiana tagu staff wymaga dodatkowego uprawnienia.")
|
|
|
|
// A provider that fails: the generic 500, and nothing is written.
|
|
env.knobs.relationLocks.Store(true)
|
|
rec := env.expect(t, http.StatusInternalServerError, http.MethodPut, rosterPath(plain, ""), `{"name":"Changed",`+rosterTags(beta.ID)+`}`, "limited")
|
|
if got := rosterError(t, rec); got.Code != "error" || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "lock table") {
|
|
t.Fatalf("500 body = %s", rec.Body.String())
|
|
}
|
|
env.expect(t, http.StatusInternalServerError, http.MethodGet, rosterPeople+"/fields/tags/options", "", "limited")
|
|
env.knobs.relationLocks.Store(false)
|
|
if got := rosterPivot(t, gdb, plain); !reflect.DeepEqual(got, []uint{news.ID}) || rosterLoad(t, gdb, plain).Name != "Plain" {
|
|
t.Fatalf("a failed save wrote: pivot %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRelationLockBelongsTo: for a belongsTo field a change is refused when
|
|
// the current or the submitted id is locked. A lock without a message of its
|
|
// own answers the framework's text.
|
|
func TestRelationLockBelongsTo(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
vault, open, other := rosterTeam{Tenant: "acme", Name: "vault"}, rosterTeam{Tenant: "acme", Name: "open"}, rosterTeam{Tenant: "acme", Name: "other"}
|
|
for _, team := range []*rosterTeam{&vault, &open, &other} {
|
|
rosterSeed(t, gdb, team)
|
|
}
|
|
inside := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Inside", Active: true, OrganisationID: &vault.ID})
|
|
outside := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Outside", Active: true, OrganisationID: &open.ID})
|
|
const message = "You do not have permission to make this change. Nothing was saved."
|
|
team := func(id uint) uint {
|
|
t.Helper()
|
|
if stored := rosterLoad(t, gdb, id); stored.OrganisationID != nil {
|
|
return *stored.OrganisationID
|
|
}
|
|
return 0
|
|
}
|
|
// Into the locked team, out of it, and clearing it.
|
|
rosterLockRefused(t, env, http.MethodPut, rosterPath(outside, ""), fmt.Sprintf(`{"name":"Sneaky","team":%d}`, vault.ID), "team", message)
|
|
rosterLockRefused(t, env, http.MethodPut, rosterPath(inside, ""), fmt.Sprintf(`{"team":%d}`, open.ID), "team", message)
|
|
rosterLockRefused(t, env, http.MethodPut, rosterPath(inside, ""), `{"team":null}`, "team", message)
|
|
rosterLockRefused(t, env, http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":"Born inside",%s,"team":%d}`, rosterPair, vault.ID), "team", message)
|
|
if team(outside) != open.ID || team(inside) != vault.ID || rosterLoad(t, gdb, outside).Name != "Outside" || rosterCount(t, env, "Born inside") != 0 {
|
|
t.Fatal("a refused save changed a team")
|
|
}
|
|
// A refusal without a message has an empty message and the framework's
|
|
// text on the field.
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(inside, ""), `{"team":null}`, "limited")
|
|
if got := rosterError(t, rec); got.Message != "" {
|
|
t.Fatalf("message of a lock without one = %q", got.Message)
|
|
}
|
|
// The locked id sent back unchanged, and a change between unlocked teams.
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(inside, ""), fmt.Sprintf(`{"name":"Inside B","team":%d}`, vault.ID), "limited")
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(outside, ""), fmt.Sprintf(`{"team":%d}`, other.ID), "limited")
|
|
if team(inside) != vault.ID || team(outside) != other.ID {
|
|
t.Fatalf("teams after the allowed saves = %d and %d", team(inside), team(outside))
|
|
}
|
|
// The options and the label carry the flag for the limited administrator.
|
|
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/team/options", "", "limited").Body.String()
|
|
if !strings.Contains(options, fmt.Sprintf(`{"value":%d,"label":"vault","locked":true}`, vault.ID)) || strings.Count(options, `"locked"`) != 1 {
|
|
t.Fatalf("team options = %s", options)
|
|
}
|
|
if shown := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(inside, ""), "", "limited").Body.String(); strings.Count(shown, `"locked":true`) != 1 {
|
|
t.Fatalf("the locked team label is not flagged: %s", shown)
|
|
}
|
|
// The full administrator moves a person out of the locked team.
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(inside, ""), `{"team":null}`, "bearer")
|
|
if team(inside) != 0 {
|
|
t.Fatal("the full admin could not clear the locked team")
|
|
}
|
|
}
|
|
|
|
// TestRelationLockAbsentField: only relation fields present in the body are
|
|
// checked, so an ordinary update of a record that holds a locked id passes.
|
|
func TestRelationLockAbsentField(t *testing.T) {
|
|
env, gdb := newRosterEnv(t)
|
|
staff, _, _ := rosterLockSeed(t, gdb)
|
|
vault := rosterTeam{Tenant: "acme", Name: "vault"}
|
|
rosterSeed(t, gdb, &vault)
|
|
member := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Member", Active: true, OrganisationID: &vault.ID})
|
|
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: staff.ID})
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(member, ""), `{"name":"Member B","email":"member@example.test"}`, "limited")
|
|
stored := rosterLoad(t, gdb, member)
|
|
if stored.Name != "Member B" || stored.OrganisationID == nil || *stored.OrganisationID != vault.ID {
|
|
t.Fatalf("stored = %+v", stored)
|
|
}
|
|
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID}) {
|
|
t.Fatalf("pivot = %v", got)
|
|
}
|
|
// A record action and a bulk action on the same record pass too: they do
|
|
// not write the relation.
|
|
env.expect(t, http.StatusOK, http.MethodPost, rosterArchive, rosterIDs(member), "limited")
|
|
if got := rosterPivot(t, gdb, member); !reflect.DeepEqual(got, []uint{staff.ID}) {
|
|
t.Fatalf("pivot after a bulk action = %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRelationLockNoProvider: a controller without cabana.RelationLockProvider
|
|
// has no locked option and no refusal.
|
|
func TestRelationLockNoProvider(t *testing.T) {
|
|
env, gdb := newRosterBareEnv(t)
|
|
staff, news, _ := rosterLockSeed(t, gdb)
|
|
person := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Free", Active: true, Password: rosterHash("stored-before")})
|
|
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "limited").Body.String()
|
|
if strings.Contains(options, `"locked"`) || !strings.Contains(options, `"label":"staff"`) {
|
|
t.Fatalf("options without a provider = %s", options)
|
|
}
|
|
rec := env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), `{"name":"Free",`+rosterPair+`,`+rosterTags(staff.ID, news.ID)+`}`, "limited")
|
|
if strings.Contains(rec.Body.String(), `"locked"`) {
|
|
t.Fatalf("a label is flagged without a provider: %s", rec.Body.String())
|
|
}
|
|
if got := rosterPivot(t, gdb, person); !reflect.DeepEqual(got, []uint{staff.ID, news.ID}) {
|
|
t.Fatalf("pivot = %v", got)
|
|
}
|
|
}
|
|
|
|
// TestWritableForeignKeyOptIn: a belongsTo field over a protected foreign key
|
|
// is writable only when its contract says so (D-27 G3; T-12.1-11).
|
|
func TestWritableForeignKeyOptIn(t *testing.T) {
|
|
for _, writable := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("WritableForeignKey=%v", writable), func(t *testing.T) {
|
|
env, gdb := newRosterEnvWith(t, func(p *rosterPlugin) {
|
|
p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
|
|
in[0].WritableForeignKey = writable
|
|
return in
|
|
}
|
|
})
|
|
team := rosterTeam{Tenant: "acme", Name: "Home"}
|
|
rosterSeed(t, gdb, &team)
|
|
person := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ro", Active: true})
|
|
_, raw := rosterFormSchema(t, env, "bearer")
|
|
readOnly := strings.Contains(raw, `"emptyOption":"No team","readOnly":true}`)
|
|
if readOnly == writable {
|
|
t.Fatalf("readOnly = %v for WritableForeignKey = %v: %s", readOnly, writable, raw)
|
|
}
|
|
options := env.call(t, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer")
|
|
if (options.Code == http.StatusOK) != writable {
|
|
t.Fatalf("options status = %d", options.Code)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), fmt.Sprintf(`{"team":%d}`, team.ID), "bearer")
|
|
stored := rosterLoad(t, gdb, person).OrganisationID
|
|
if writable && (stored == nil || *stored != team.ID) {
|
|
t.Fatalf("the opted-in field did not write the key: %v", stored)
|
|
}
|
|
if !writable && stored != nil {
|
|
t.Fatalf("the field wrote the protected key without the opt-in: %d", *stored)
|
|
}
|
|
// The scalar column itself is never a fill key.
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), `{"organisation_id":987654}`, "bearer")
|
|
if after := rosterLoad(t, gdb, person).OrganisationID; !reflect.DeepEqual(after, stored) {
|
|
t.Fatalf("a scalar organisation_id was written: %v", after)
|
|
}
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(person, ""), "", "bearer")
|
|
if _, leaked := rosterRecord(t, rec.Body.Bytes()).Data["organisation_id"]; leaked {
|
|
t.Fatalf("the record carries organisation_id: %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWritableForeignKeyScope: a submitted id passes the scoped options query
|
|
// before it is written.
|
|
func TestWritableForeignKeyScope(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)
|
|
person := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tess", Active: true, OrganisationID: &home.ID})
|
|
for _, value := range []string{fmt.Sprint(away.ID), "999999", `"x"`, `[1]`, `{"id":1}`, "-1", "1.5"} {
|
|
rec := env.call(t, http.MethodPut, rosterPath(person, ""), `{"name":"Changed","team":`+value+`}`, "bearer")
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("team %s = %d, want 422: %s", value, rec.Code, rec.Body.String())
|
|
}
|
|
var details map[string]any = rosterError(t, rec).Details
|
|
if _, ok := details["team"]; !ok {
|
|
t.Fatalf("team %s: no detail on the field: %s", value, rec.Body.String())
|
|
}
|
|
}
|
|
if stored := rosterLoad(t, gdb, person); stored.Name != "Tess" || stored.OrganisationID == nil || *stored.OrganisationID != home.ID {
|
|
t.Fatalf("a refused save wrote: %+v", stored)
|
|
}
|
|
// The out-of-scope team is not offered either.
|
|
options := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer").Body.String()
|
|
if strings.Contains(options, "Away") || !strings.Contains(options, "Home") {
|
|
t.Fatalf("options = %s", options)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, rosterPath(person, ""), `{"team":null}`, "bearer")
|
|
if stored := rosterLoad(t, gdb, person); stored.OrganisationID != nil {
|
|
t.Fatalf("null did not clear the key: %d", *stored.OrganisationID)
|
|
}
|
|
// On create as well.
|
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":"Away born",%s,"team":%d}`, rosterPair, away.ID), "bearer")
|
|
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "team", "The selected team is invalid.")
|
|
if n := rosterCount(t, env, "Away born"); n != 0 {
|
|
t.Fatalf("a refused create left %d rows", n)
|
|
}
|
|
}
|