package cabana_test import ( "fmt" "net/http" "net/http/httptest" "net/url" "reflect" "strings" "testing" ) const rosterLockedMessage = "This person is locked and cannot be changed." // rosterCount counts the people named name, soft-deleted or not. func rosterCount(t *testing.T, env *rosterEnv, name string) int { t.Helper() body, _ := rosterList(t, env, "?search="+url.QueryEscape(name)) n := 0 for _, row := range body.Data { if row["name"] == name { n++ } } return n } // TestForbiddenFromEveryHook: a cabana.ForbiddenError is a 403 with the // plugin's message from every Form hook, from the bulk delete and from the // relation link and child hooks, and the write it refuses is rolled back // (D-27; T-12.1-05, T-12.1-06). func TestForbiddenFromEveryHook(t *testing.T) { t.Run("form hooks", func(t *testing.T) { env, gdb := newRosterEnv(t) refused := func(what string, rec *httptest.ResponseRecorder) { t.Helper() if got := rosterError(t, rec); rec.Code != http.StatusForbidden || got.Code != "forbidden" || got.Message != rosterLockedMessage { t.Fatalf("%s = %d %s", what, rec.Code, rec.Body.String()) } } // Before and after create: no row is left. for _, name := range []string{rosterDenyCreate, rosterDenyAfterCreate} { refused("create "+name, env.call(t, http.MethodPost, rosterPeople, fmt.Sprintf(`{"name":%q,%s}`, name, rosterPair), "bearer")) if n := rosterCount(t, env, name); n != 0 { t.Fatalf("a refused create left %d rows named %s", n, name) } } // After update: the row was written inside the transaction. id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"}) refused("after update", env.call(t, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"name":%q,"email":"changed@example.test"}`, rosterKeepAfter), "bearer")) if stored := rosterLoad(t, gdb, id); stored.Name != "Ada" || stored.Email != "ada@example.test" { t.Fatalf("a refusal after the update kept the write: %+v", stored) } // Before update is covered by the fixture's reserved name. rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"name":"Reserved"}`, "bearer") if got := rosterError(t, rec); got.Message != "You may not rename this person." { t.Fatalf("before update = %+v", got) } // Before delete, and after delete once the row is gone inside the // transaction. keep := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterKeep}) after := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterKeepAfter}) for _, target := range []uint{keep, after} { refused("delete", env.call(t, http.MethodDelete, rosterPath(target, ""), "", "bearer")) if stored := rosterLoad(t, gdb, target); stored.DeletedAt.Valid { t.Fatalf("a refused delete removed or soft-deleted row %d", target) } } // Bulk delete: one refused record keeps the whole selection. first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"}) last := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Last"}) refused("bulk delete", env.call(t, http.MethodPost, rosterPeople+"/bulk-delete", rosterIDs(first, keep, last), "bearer")) refused("bulk delete, refusal after the row delete", env.call(t, http.MethodPost, rosterPeople+"/bulk-delete", rosterIDs(first, after), "bearer")) for _, target := range []uint{first, keep, after, last} { rosterLoad(t, gdb, target) } // The same selection without the refused record is deleted. env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", rosterIDs(first, last), "bearer") }) t.Run("relation link and child hooks", func(t *testing.T) { env := newDeferredEnv(t) g := env.gadget(t, "g-"+env.stamp, false) parts := func(rest string) string { return dfPath(g, "/relations/parts"+rest) } members := func(rest string) string { return dfPath(g, "/relations/members"+rest) } part := env.part(t, g, "stays") member := env.member(t, "refused-"+env.stamp+"@example.test") linked := env.member(t, "linked-"+env.stamp+"@example.test") want(t, "link", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{linked}}, nil), http.StatusOK) for _, tc := range []struct { hook, method, rel string body map[string]any }{ {"RelationBeforeLink:members", http.MethodPost, members("/link"), map[string]any{"ids": []uint{member}}}, {"RelationBeforeCreate:parts", http.MethodPost, parts("/records"), map[string]any{"label": "doomed"}}, {"RelationAfterCreate:parts", http.MethodPost, parts("/records"), map[string]any{"label": "doomed"}}, {"RelationBeforeUpdate:parts", http.MethodPut, parts(fmt.Sprintf("/records/%d", part)), map[string]any{"label": "changed"}}, {"RelationAfterUpdate:parts", http.MethodPut, parts(fmt.Sprintf("/records/%d", part)), map[string]any{"label": "changed"}}, {"RelationBeforeDelete:parts", http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{part}}}, {"RelationAfterDelete:parts", http.MethodPost, parts("/delete"), map[string]any{"ids": []uint{part}}}, {"RelationBeforeCreate:members", http.MethodPost, members("/records"), map[string]any{"email": "new-" + env.stamp + "@example.test"}}, {"RelationAfterDelete:members", http.MethodPost, members("/delete"), map[string]any{"ids": []uint{linked}}}, } { env.rec.reset() env.rec.refuseOn(tc.hook) before := env.state(t) rec := env.a.do(t, tc.method, tc.rel, tc.body, nil) got := rosterError(t, rec) if rec.Code != http.StatusForbidden || got.Code != "forbidden" || got.Message != dfRefusal { t.Fatalf("%s = %d %s, want the hook's 403", tc.hook, rec.Code, rec.Body.String()) } if !reflect.DeepEqual(got.Details, map[string]any{"hook": []any{tc.hook}}) { t.Fatalf("%s details = %#v", tc.hook, got.Details) } env.unchanged(t, "a refusal from "+tc.hook, before) } env.rec.reset() // Without the refusal the same link runs. want(t, "link after the refusals", env.a.do(t, http.MethodPost, members("/link"), map[string]any{"ids": []uint{member}}, nil), http.StatusOK) }) } // TestForbiddenLocalized: the message and every detail are phrase keys // resolved in the request locale; plain text passes through. func TestForbiddenLocalized(t *testing.T) { env, gdb := newRosterEnv(t) id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"}) for locale, want := range map[string][2]string{ "en": {"You may not rename this person.", "This name is reserved."}, "pl": {"Nie możesz zmienić nazwy tej osoby.", "Ta nazwa jest zastrzeżona."}, } { rec := rosterLocale(env, http.MethodPut, rosterPath(id, ""), `{"name":"Reserved"}`, locale) got := rosterError(t, rec) if rec.Code != http.StatusForbidden || got.Message != want[0] || !reflect.DeepEqual(got.Details, map[string]any{"name": []any{want[1]}}) { t.Fatalf("%s: status=%d error=%+v", locale, rec.Code, got) } } // The refusal of a bulk action is localized as well. locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked}) rec := rosterLocale(env, http.MethodPost, rosterArchive, rosterIDs(locked), "pl") if got := rosterError(t, rec); got.Message != "Ta osoba jest zablokowana i nie można jej zmienić." { t.Fatalf("pl bulk refusal = %+v", got) } // The plugin's shared error value is never written to. if rosterRefused.Message != "acme.roster::lang.people.locked" || rosterRefused.Details != nil { t.Fatalf("the plugin's error value was modified: %+v", rosterRefused) } } // TestForbiddenRollsBack: nothing of a refused request stays, also when the // refusal comes after rows were written. func TestForbiddenRollsBack(t *testing.T) { env, gdb := newRosterEnv(t) first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"}) second := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"}) locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked, Active: true, Banned: true}) env.expect(t, http.StatusForbidden, http.MethodPost, rosterArchive, rosterIDs(first, second, locked), "bearer") for _, id := range []uint{first, second, locked} { if rosterLoad(t, gdb, id).DeletedAt.Valid { t.Fatalf("row %d kept the write of a refused bulk action", id) } } env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(locked, "/actions/reinstate"), `{}`, "bearer") if !rosterLoad(t, gdb, locked).Banned { t.Fatal("a refused record action kept its write") } // A refused update keeps no field of the body, not even the allowed ones. env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(first, ""), `{"name":"Reserved","email":"kept@example.test","slug":"kept"}`, "bearer") if stored := rosterLoad(t, gdb, first); stored.Name != "First" || stored.Email != "" || stored.Slug != "" { t.Fatalf("a refused update kept a field: %+v", stored) } } // TestForbiddenEmptyMessage: a refusal without a message answers an empty // message and an object for details; the framework's own permission denial // keeps its fixed text. func TestForbiddenEmptyMessage(t *testing.T) { env, gdb := newRosterEnv(t) id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea"}) rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"name":"Silent"}`, "bearer") if !strings.Contains(rec.Body.String(), `"code":"forbidden"`) || !strings.Contains(rec.Body.String(), `"message":""`) || !strings.Contains(rec.Body.String(), `"details":{}`) { t.Fatalf("body = %s", rec.Body.String()) } if rosterLoad(t, gdb, id).Name != "Bea" { t.Fatal("a refused update changed the row") } denied := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(id), "limited") if got := rosterError(t, denied); got.Code != "forbidden" || got.Message == "" { t.Fatalf("permission denial = %+v", got) } }