package cabana_test import ( "fmt" "net/http" "net/http/httptest" "reflect" "strings" "sync" "testing" "time" ) // rosterLocale sends one bearer request with an Accept-Language header. func rosterLocale(env *rosterEnv, method, rel, body, locale string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, adminAPI(rel), strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept-Language", locale) req.Header.Set("Authorization", "Bearer "+env.token) rec := httptest.NewRecorder() env.h.ServeHTTP(rec, req) return rec } const ( rosterActivate = rosterPeople + "/bulk/activate" rosterArchive = rosterPeople + "/bulk/archive" ) // rosterNames are the names of the records one bulk Run received, in order. func rosterNames(records []any) []string { names := make([]string, len(records)) for i, record := range records { names[i] = record.(*rosterPerson).Name } return names } // TestBulkActionEmpty: a request without ids is 422 and never reaches Run // (D-09). func TestBulkActionEmpty(t *testing.T) { env, _ := newRosterEnv(t) for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":null}`, `{"ids":["x"]}`, `{"ids":[-1]}`, `{"ids":[1.5]}`, `[1]`, `{`, ``} { rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterActivate, body, "bearer") actErrorCode(t, rec.Body.Bytes(), "validation_failed") } if calls := env.spy.takeBulk(); len(calls) != 0 { t.Fatalf("a request without ids reached Run: %+v", calls) } } // TestBulkActionDuplicates: repeated ids are one record each. func TestBulkActionDuplicates(t *testing.T) { env, gdb := newRosterEnv(t) ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"}) bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"}) rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(bob, ada, bob, ada, ada), "bearer") if result := rosterBulkResult(t, rec); result.Affected != 2 { t.Fatalf("affected = %d, want 2", result.Affected) } calls := env.spy.takeBulk() if len(calls) != 1 || !reflect.DeepEqual(rosterNames(calls[0].Records), []string{"Ada", "Bob"}) { t.Fatalf("Run received %+v, want Ada and Bob once each", calls) } } // TestBulkActionOrder: records reach Run ordered by primary key, whatever the // order of the posted ids. func TestBulkActionOrder(t *testing.T) { env, gdb := newRosterEnv(t) var ids []uint for _, name := range []string{"One", "Two", "Three", "Four"} { ids = append(ids, rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name})) } env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(ids[3], ids[0], ids[2], ids[1]), "bearer") calls := env.spy.takeBulk() if len(calls) != 1 || !reflect.DeepEqual(rosterNames(calls[0].Records), []string{"One", "Two", "Three", "Four"}) { t.Fatalf("Run received %v", calls) } for i, record := range calls[0].Records { if record.(*rosterPerson).ID != ids[i] { t.Fatalf("record %d has id %d, want %d", i, record.(*rosterPerson).ID, ids[i]) } } } // TestBulkActionAbsent: a selection that matches no row answers affected 0 // and does not call Run. func TestBulkActionAbsent(t *testing.T) { env, gdb := newRosterEnv(t) kept := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Kept"}) // Id 0 is a well-formed id that no row has. for _, body := range []string{rosterIDs(999998, 999999), rosterIDs(0)} { rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, body, "bearer") if result := rosterBulkResult(t, rec); result.Affected != 0 || result.Message != "" { t.Fatalf("%s: result = %+v", body, result) } } if calls := env.spy.takeBulk(); len(calls) != 0 { t.Fatalf("Run was called for an absent selection: %+v", calls) } if rosterLoad(t, gdb, kept).Active { t.Fatal("an unselected row was changed") } } // TestBulkActionPartial: a selection with a present and an absent id is a 409 // and changes nothing. func TestBulkActionPartial(t *testing.T) { env, gdb := newRosterEnv(t) ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"}) bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"}) rec := env.expect(t, http.StatusConflict, http.MethodPost, rosterActivate, rosterIDs(ada, 999999, bob), "bearer") actErrorCode(t, rec.Body.Bytes(), "conflict") if rosterLoad(t, gdb, ada).Active || rosterLoad(t, gdb, bob).Active { t.Fatal("a partial selection changed a row") } if calls := env.spy.takeBulk(); len(calls) != 0 { t.Fatalf("Run was called for a partial selection: %+v", calls) } } // TestBulkActionScope: ids are resolved through the list scope // (ListExtendQuery), so a row of another tenant is absent (T-12.1-01). func TestBulkActionScope(t *testing.T) { env, gdb := newRosterEnv(t) own := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Own"}) foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"}) trashed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Trashed", DeletedAt: rosterDeleted()}) rec := env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(foreign), "bearer") if result := rosterBulkResult(t, rec); result.Affected != 0 { t.Fatalf("out-of-scope selection: %+v", result) } env.expect(t, http.StatusConflict, http.MethodPost, rosterActivate, rosterIDs(own, foreign), "bearer") if calls := env.spy.takeBulk(); len(calls) != 0 { t.Fatalf("Run saw a selection with an out-of-scope id: %+v", calls) } if rosterLoad(t, gdb, foreign).Active || rosterLoad(t, gdb, own).Active { t.Fatal("a row was changed") } // The scope of this controller includes soft-deleted rows. rec = env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(own, trashed), "bearer") if result := rosterBulkResult(t, rec); result.Affected != 2 { t.Fatalf("in-scope selection: %+v", result) } } // TestBulkActionRollback: an error from Run, refusal or failure, rolls every // row of the selection back (T-12.1-05). func TestBulkActionRollback(t *testing.T) { env, gdb := newRosterEnv(t) for _, last := range []struct { name string status int }{{rosterCrash, http.StatusInternalServerError}, {rosterLocked, http.StatusForbidden}} { first := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "First"}) second := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"}) failing := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: last.name}) rec := env.expect(t, last.status, http.MethodPost, rosterArchive, rosterIDs(first, second, failing), "bearer") if strings.Contains(rec.Body.String(), "hunter2") { t.Fatalf("the error text of Run is in the body: %s", rec.Body.String()) } for _, id := range []uint{first, second, failing} { if rosterLoad(t, gdb, id).DeletedAt.Valid { t.Fatalf("%s: row %d kept the write of a failed bulk action", last.name, id) } } // Run did see all three rows: the first two were written before the // failure. if calls := env.spy.takeBulk(); len(calls) != 1 || len(calls[0].Records) != 3 { t.Fatalf("%s: Run received %+v", last.name, calls) } } } // TestBulkActionConcurrent: two runs over the same rows, posted in opposite // id order, do not deadlock: the second waits for the row locks of the first // and then sees its result. func TestBulkActionConcurrent(t *testing.T) { env, gdb := newRosterEnv(t) ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"}) bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob"}) inside, release := make(chan struct{}), make(chan struct{}) var once sync.Once wait := func() { blocked := false once.Do(func() { blocked = true }) if blocked { close(inside) <-release } } env.knobs.slowBulk.Store(&wait) t.Cleanup(func() { env.knobs.slowBulk.Store(nil) }) type answer struct { code int affected int } run := func(body string, out chan<- answer) { rec := env.call(t, http.MethodPost, rosterActivate, body, "bearer") out <- answer{rec.Code, rosterBulkResult(t, rec).Affected} } first, second := make(chan answer, 1), make(chan answer, 1) go run(rosterIDs(ada, bob), first) select { case <-inside: case <-time.After(10 * time.Second): t.Fatal("the first run never reached its action") } // The first run holds the row locks. The second must wait for them. go run(rosterIDs(bob, ada), second) select { case got := <-second: t.Fatalf("the second run finished while the first held the rows: %+v", got) case <-time.After(300 * time.Millisecond): } close(release) var answers []answer for _, ch := range []chan answer{first, second} { select { case got := <-ch: answers = append(answers, got) case <-time.After(15 * time.Second): t.Fatal("a concurrent bulk action did not finish (deadlock)") } } // Both answer 200; the rows were activated exactly once. if answers[0].code != http.StatusOK || answers[1].code != http.StatusOK { t.Fatalf("answers = %+v", answers) } if answers[0].affected != 2 || answers[1].affected != 0 { t.Fatalf("affected = %d and %d, want 2 and 0: the second run must see the first one's result", answers[0].affected, answers[1].affected) } if !rosterLoad(t, gdb, ada).Active || !rosterLoad(t, gdb, bob).Active { t.Fatal("the rows are not active after both runs") } } // TestBulkActionPermissions: the controller's permission and the action's own // (T-12.1-02). func TestBulkActionPermissions(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(idle), "limited") actErrorCode(t, rec.Body.Bytes(), "forbidden") // The permission is checked before the body is read. env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, `{`, "limited") if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 { t.Fatal("an action ran without its permission") } // Without a token the request is not authenticated at all. req := httptest.NewRequest(http.MethodPost, adminAPI(rosterActivate), strings.NewReader(rosterIDs(idle))) req.Header.Set("Content-Type", "application/json") anonymous := httptest.NewRecorder() env.h.ServeHTTP(anonymous, req) if anonymous.Code != http.StatusUnauthorized { t.Fatalf("anonymous bulk action = %d", anonymous.Code) } // An action that asks for the controller's permission only runs for the // limited administrator. env.expect(t, http.StatusOK, http.MethodPost, rosterArchive, rosterIDs(idle), "limited") if !rosterLoad(t, gdb, idle).DeletedAt.Valid { t.Fatal("archive did not run for the limited administrator") } } // TestBulkActionUndeclared: a name the list does not declare is 404, whatever // else is registered under it. func TestBulkActionUndeclared(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Banned: true}) // missing: nowhere. reinstate: a record action. delete and create: the // built-in names, which are never declared actions. for _, name := range []string{"missing", "reinstate", "delete", "create", "Activate", "activate%20"} { rec := env.call(t, http.MethodPost, rosterPeople+"/bulk/"+name, rosterIDs(idle), "bearer") if rec.Code != http.StatusNotFound { t.Fatalf("bulk/%s = %d, want 404", name, rec.Code) } } env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/roster/nobody/bulk/activate", rosterIDs(idle), "bearer") // The route is POST only. if rec := env.call(t, http.MethodGet, rosterActivate, "", "bearer"); rec.Code == http.StatusOK { t.Fatalf("GET on the bulk action route = %d", rec.Code) } if stored := rosterLoad(t, gdb, idle); stored.Active || !stored.Banned || len(env.spy.takeBulk()) != 0 { t.Fatal("an undeclared name ran an action") } // A controller without declared bulk actions has none to run. demo, demoDB := newActEnv(t) gadget := actInsert(t, demoDB, "plain", "acme") demo.expect(t, http.StatusNotFound, http.MethodPost, "/acme/demo/gadgets/bulk/activate", rosterIDs(gadget), "bearer") } // TestBulkActionCSRF: a cookie request needs X-Requested-With (T-12.1-03). func TestBulkActionCSRF(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterActivate, rosterIDs(idle), "cookie-only") actErrorCode(t, rec.Body.Bytes(), "forbidden") if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 { t.Fatal("a cookie request without the header ran the action") } env.expect(t, http.StatusOK, http.MethodPost, rosterActivate, rosterIDs(idle), "cookie") if !rosterLoad(t, gdb, idle).Active { t.Fatal("a cookie request with the header did not run") } } // TestBulkActionMessageLocalized: the action's message is a phrase key // resolved in the request locale; an action without one answers an empty // message. func TestBulkActionMessageLocalized(t *testing.T) { env, gdb := newRosterEnv(t) for locale, want := range map[string]string{ "en": "The selected people were archived.", "pl": "Zaznaczone osoby zostały zarchiwizowane.", } { id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Spare " + locale}) rec := rosterLocale(env, http.MethodPost, rosterArchive, rosterIDs(id), locale) if result := rosterBulkResult(t, rec); rec.Code != http.StatusOK || result.Message != want || result.Affected != 1 { t.Fatalf("%s: status=%d result=%+v, want %q", locale, rec.Code, result, want) } } id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Quiet"}) rec := rosterLocale(env, http.MethodPost, rosterActivate, rosterIDs(id), "pl") if result := rosterBulkResult(t, rec); result.Message != "" || !strings.Contains(rec.Body.String(), `"message":""`) { t.Fatalf("an action without a message answered %s", rec.Body.String()) } // The list schema localizes label and confirm the same way. schema := rosterLocale(env, http.MethodGet, rosterPeople+"/schema/list", "", "pl") if !strings.Contains(schema.Body.String(), `"label":"Aktywuj","confirm":"Aktywować zaznaczone osoby?"`) { t.Fatalf("pl list schema = %s", schema.Body.String()) } } // TestBulkActionBodyCap: a body past http.body_limits.default_bytes is // refused before the action runs. func TestBulkActionBodyCap(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) huge := fmt.Sprintf(`{"ids":[%d],"pad":"%s"}`, idle, strings.Repeat("x", 1100<<10)) rec := env.expect(t, http.StatusRequestEntityTooLarge, http.MethodPost, rosterActivate, huge, "bearer") if rosterLoad(t, gdb, idle).Active || len(env.spy.takeBulk()) != 0 { t.Fatalf("an oversized request ran the action: %s", rec.Body.String()) } } // TestListSchemaBulkActionsFiltered: the list schema offers each // administrator the bulk actions they may run, and a filtered answer leaves // the cached schema whole. func TestListSchemaBulkActionsFiltered(t *testing.T) { env, _ := newRosterEnv(t) full := []string{"delete", "activate", "archive"} for range 2 { if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) { t.Fatalf("limited admin = %v", got) } if got := rosterBulkNames(t, env, "bearer"); !reflect.DeepEqual(got, full) { t.Fatalf("full admin after a filtered request = %v, want %v", got, full) } if got := rosterBulkNames(t, env, "cookie"); !reflect.DeepEqual(got, full) { t.Fatalf("full admin by cookie = %v", got) } } }