package cabana_test import ( "fmt" "net/http" "net/http/httptest" "reflect" "strings" "testing" ) // TestRecordActionScope: the record is loaded through the form scope, so a // missing id and an id outside the scope are the same 404 (T-12.1-04). func TestRecordActionScope(t *testing.T) { env, gdb := newRosterEnv(t) foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"}) trashed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Trashed", DeletedAt: rosterDeleted()}) var bodies []string for _, id := range []uint{foreign, 999999} { rec := env.expect(t, http.StatusNotFound, http.MethodPost, rosterPath(id, "/actions/activate"), `{}`, "bearer") actErrorCode(t, rec.Body.Bytes(), "not_found") bodies = append(bodies, rec.Body.String()) } if bodies[0] != bodies[1] { t.Fatalf("an out-of-scope id and a missing id answer differently:\n%s\n%s", bodies[0], bodies[1]) } for _, id := range []string{"abc", "0", "-1", "1.5"} { if rec := env.call(t, http.MethodPost, rosterPeople+"/"+id+"/actions/activate", `{}`, "bearer"); rec.Code/100 == 2 { t.Fatalf("id %q answered %d", id, rec.Code) } } if rosterLoad(t, gdb, foreign).Active || len(env.spy.takeRecord()) != 0 { t.Fatal("an out-of-scope record was changed") } // The form scope of this controller includes soft-deleted records. env.expect(t, http.StatusOK, http.MethodPost, rosterPath(trashed, "/actions/activate"), `{}`, "bearer") if !rosterLoad(t, gdb, trashed).Active { t.Fatal("an in-scope soft-deleted record was not reached") } } // TestRecordActionApplies: Applies is checked again inside the transaction; // an action that does not apply is a 409 and does not run. func TestRecordActionApplies(t *testing.T) { env, gdb := newRosterEnv(t) active := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Active", Active: true}) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) rec := env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(active, "/actions/activate"), `{}`, "bearer") actErrorCode(t, rec.Body.Bytes(), "conflict") rec = env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(idle, "/actions/reinstate"), `{}`, "bearer") actErrorCode(t, rec.Body.Bytes(), "conflict") if len(env.spy.takeRecord()) != 0 { t.Fatal("an action ran on a record it does not apply to") } // It runs once; the second request finds it no longer applies. env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer") env.expect(t, http.StatusConflict, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer") if calls := env.spy.takeRecord(); len(calls) != 1 || calls[0].RecordID != uint64(idle) { t.Fatalf("Run calls = %+v", calls) } } // TestRecordActionBody: the body is a strict, empty JSON object. func TestRecordActionBody(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"}) for _, body := range []string{ fmt.Sprintf(`{"record_id":%d}`, idle), `{"record_id":null,"values":{}}`, `{"values":{"active":true}}`, `{"extra":1}`, `{"field":"name"}x`, `{} {}`, `{}{}`, `null {}`, `[]`, `"activate"`, `{`, ``, } { rec := env.call(t, http.MethodPost, rosterPath(idle, "/actions/activate"), body, "bearer") if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("body %q = %d, want 422: %s", body, rec.Code, rec.Body.String()) } actErrorCode(t, rec.Body.Bytes(), "validation_failed") } if rosterLoad(t, gdb, idle).Active || len(env.spy.takeRecord()) != 0 { t.Fatal("a malformed body ran the action") } env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "bearer") // A JSON null is read as the empty object: it carries no input either. other := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Other"}) env.expect(t, http.StatusOK, http.MethodPost, rosterPath(other, "/actions/activate"), `null`, "bearer") } // TestRecordActionPermissions: the controller's permission and the action's // own, and the CSRF header for cookie requests (T-12.1-02, T-12.1-03). func TestRecordActionPermissions(t *testing.T) { env, gdb := newRosterEnv(t) idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle", Banned: true}) rec := env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "limited") actErrorCode(t, rec.Body.Bytes(), "forbidden") env.expect(t, http.StatusForbidden, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "cookie-only") req := httptest.NewRequest(http.MethodPost, adminAPI(rosterPath(idle, "/actions/activate")), strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") anonymous := httptest.NewRecorder() env.h.ServeHTTP(anonymous, req) if anonymous.Code != http.StatusUnauthorized { t.Fatalf("anonymous record action = %d", anonymous.Code) } if rosterLoad(t, gdb, idle).Active || len(env.spy.takeRecord()) != 0 { t.Fatal("a refused request ran the action") } // reinstate asks for no permission of its own. env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/reinstate"), `{}`, "limited") env.expect(t, http.StatusOK, http.MethodPost, rosterPath(idle, "/actions/activate"), `{}`, "cookie") if stored := rosterLoad(t, gdb, idle); stored.Banned || !stored.Active { t.Fatalf("after the permitted actions: %+v", stored) } } // TestRecordActionOffered: meta.actions of the show response lists the // actions in declared order and leaves out the denied and the non-applicable. func TestRecordActionOffered(t *testing.T) { env, gdb := newRosterEnv(t) both := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Both", Banned: true}) neither := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Neither", Active: true}) if got := rosterOffered(t, env, both, "bearer"); !reflect.DeepEqual(got, []string{"activate", "reinstate"}) { t.Fatalf("both apply, full admin: %v (declared order is activate, reinstate)", got) } if got := rosterOffered(t, env, both, "limited"); !reflect.DeepEqual(got, []string{"reinstate"}) { t.Fatalf("both apply, limited admin: %v", got) } if got := rosterOffered(t, env, neither, "bearer"); len(got) != 0 { t.Fatalf("none applies: %v", got) } rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(neither, ""), "", "bearer") if strings.Contains(rec.Body.String(), `"actions"`) { t.Fatalf("meta.actions sent without an offered action: %s", rec.Body.String()) } // Labels and confirm texts follow the request locale; an action without a // confirm has no confirm key. rec = rosterLocale(env, http.MethodGet, rosterPath(both, ""), "", "pl") if !strings.Contains(rec.Body.String(), `"actions":[{"name":"activate","label":"Aktywuj"},{"name":"reinstate","label":"Przywróć","confirm":"Zdjąć blokadę z tej osoby?"}]`) { t.Fatalf("pl actions = %s", rec.Body.String()) } // The list rows never carry actions. list := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer") if strings.Contains(list.Body.String(), `"actions"`) { t.Fatalf("the list carries actions: %s", list.Body.String()) } } // TestRecordActionRollback: a refusal or a failure after the action's write // rolls the write back. func TestRecordActionRollback(t *testing.T) { env, gdb := newRosterEnv(t) for name, status := range map[string]int{rosterLocked: http.StatusForbidden, rosterRunErr: http.StatusInternalServerError} { id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name, Active: true, Banned: true}) rec := env.expect(t, status, http.MethodPost, rosterPath(id, "/actions/reinstate"), `{}`, "bearer") if strings.Contains(rec.Body.String(), "hunter2") { t.Fatalf("%s: the error text of Run is in the body: %s", name, rec.Body.String()) } if !rosterLoad(t, gdb, id).Banned { t.Fatalf("%s: the action's write survived its error", name) } if calls := env.spy.takeRecord(); len(calls) != 1 { t.Fatalf("%s: Run calls = %d", name, len(calls)) } } } // TestRecordActionAppliesError: an error from Applies is the generic 500, on // the action route and on the show route that offers actions. func TestRecordActionAppliesError(t *testing.T) { env, gdb := newRosterEnv(t) logs := captureLog(t) id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterAppliesErr}) for _, call := range []struct{ method, rel, body string }{ {http.MethodPost, rosterPath(id, "/actions/activate"), `{}`}, {http.MethodGet, rosterPath(id, ""), ""}, } { rec := env.expect(t, http.StatusInternalServerError, call.method, call.rel, call.body, "bearer") got := rosterError(t, rec) if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "applies check") { t.Fatalf("%s %s = %s", call.method, call.rel, rec.Body.String()) } } if rosterLoad(t, gdb, id).Active || len(env.spy.takeRecord()) != 0 { t.Fatal("the action ran although Applies failed") } // The cause is logged on the server, with the controller and the action. failed := logs.matching("cabana: admin record action failed") if len(failed) != 2 || !strings.Contains(failed[0], "action=activate") || !strings.Contains(failed[0], "hunter2") { t.Fatalf("server log = %q", failed) } // An administrator who is not offered the action never triggers Applies. env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "limited") }