feat(12.1-01): cabana.ForbiddenError answers a refused write with 403
- hooks and bulk, record, toolbar and widget actions may return it - 403 forbidden with the localized message and field details; the write's transaction is rolled back; other errors stay the opaque 500 - form shows a refused save as a persistent banner and keeps the values; a refused delete is a toast - smoke tests, OpenAPI notes, dist, README, docs
This commit is contained in:
@@ -653,3 +653,119 @@ func TestSoftDeletedRecordSmoke(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// rosterError decodes a D-10 error envelope.
|
||||
func rosterError(t *testing.T, rec *httptest.ResponseRecorder) cabana.ErrorBody {
|
||||
t.Helper()
|
||||
var body cabana.ErrorEnvelope
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("error body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Error
|
||||
}
|
||||
|
||||
// TestForbiddenSmoke checks cabana.ForbiddenError through the assembled
|
||||
// router on PostgreSQL (D-27; T-12.1-05, T-12.1-06): a hook, a bulk action, a
|
||||
// record action and a widget action that refuse a write are answered 403
|
||||
// with the localized message and details and change nothing, and every other
|
||||
// error stays the opaque 500.
|
||||
func TestForbiddenSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
|
||||
|
||||
t.Run("a hook refuses an update", func(t *testing.T) {
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Reserved","email":"new@example.test"}`, "bearer")
|
||||
got := rosterError(t, rec)
|
||||
if got.Code != "forbidden" || got.Message != "You may not rename this person." {
|
||||
t.Fatalf("error = %+v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Details, map[string]any{"name": []any{"This name is reserved."}}) {
|
||||
t.Fatalf("details = %#v", got.Details)
|
||||
}
|
||||
if person := rosterLoad(t, gdb, id); person.Name != "Ada" || person.Email != "ada@example.test" {
|
||||
t.Fatalf("a refused update changed the row: %+v", person)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty message stays empty and details stay an object", func(t *testing.T) {
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea"})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPut, path(id), `{"name":"Silent"}`, "bearer")
|
||||
if !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")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a bulk action refuses and rolls every row back", func(t *testing.T) {
|
||||
first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"})
|
||||
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/archive", fmt.Sprintf(`{"ids":[%d,%d]}`, first, locked), "bearer")
|
||||
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." || len(got.Details) != 0 {
|
||||
t.Fatalf("error = %+v", got)
|
||||
}
|
||||
// The first row was already soft-deleted inside the transaction.
|
||||
if rosterLoad(t, gdb, first).DeletedAt.Valid || rosterLoad(t, gdb, locked).DeletedAt.Valid {
|
||||
t.Fatal("a refused bulk action changed a selected row")
|
||||
}
|
||||
if rosterRefused.Message != "acme.roster::lang.people.locked" {
|
||||
t.Fatalf("the plugin's error value was modified: %+v", rosterRefused)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a record action refuses and rolls its write back", func(t *testing.T) {
|
||||
locked := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterLocked, Active: true, Banned: true})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPost, path(locked)+"/actions/reinstate", `{}`, "bearer")
|
||||
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "This person is locked and cannot be changed." {
|
||||
t.Fatalf("error = %+v", got)
|
||||
}
|
||||
if !rosterLoad(t, gdb, locked).Banned {
|
||||
t.Fatal("a refused record action changed the row")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the message follows the request locale", func(t *testing.T) {
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy"})
|
||||
req := httptest.NewRequest(http.MethodPut, adminAPI(path(id)), strings.NewReader(`{"name":"Reserved"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept-Language", "pl")
|
||||
req.Header.Set("Authorization", "Bearer "+env.token)
|
||||
rec := httptest.NewRecorder()
|
||||
env.h.ServeHTTP(rec, req)
|
||||
if got := rosterError(t, rec); rec.Code != http.StatusForbidden || got.Message != "Nie możesz zmienić nazwy tej osoby." ||
|
||||
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Ta nazwa jest zastrzeżona."}}) {
|
||||
t.Fatalf("status=%d error=%+v", rec.Code, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a widget action refuses", func(t *testing.T) {
|
||||
demo, _ := newActEnv(t)
|
||||
rec := demo.expect(t, http.StatusForbidden, http.MethodPost, "/acme/demo/gadgets/widgets/lookup", `{"values":{"name":"refused"}}`, "bearer")
|
||||
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message != "Name and Active were filled in." ||
|
||||
!reflect.DeepEqual(got.Details, map[string]any{"name": []any{"Name"}}) {
|
||||
t.Fatalf("error = %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain hook error is the opaque 500", func(t *testing.T) {
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Dee"})
|
||||
rec := env.expect(t, http.StatusInternalServerError, http.MethodPut, path(id), `{"name":"Boom"}`, "bearer")
|
||||
got := rosterError(t, rec)
|
||||
if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "roster database") {
|
||||
t.Fatalf("500 body = %s", rec.Body.String())
|
||||
}
|
||||
if rosterLoad(t, gdb, id).Name != "Dee" {
|
||||
t.Fatal("a failed update changed the row")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a permission denial keeps the framework text", func(t *testing.T) {
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Eve"})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "limited")
|
||||
if got := rosterError(t, rec); got.Code != "forbidden" || got.Message == "" {
|
||||
t.Fatalf("error = %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user