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