//go:build integration package httpx import ( "fmt" "net/http" "testing" "go.mongodb.org/mongo-driver/v2/bson" "bountyboard/internal/domain" "bountyboard/internal/store" ) // promote grants roles to a user directly in the store. func promote(t *testing.T, st *store.Store, userID string, roles map[string]bool) { t.Helper() set := bson.M{} for role, v := range roles { set["roles."+role] = v } if _, err := st.DB.Collection("users").UpdateOne(t.Context(), bson.M{"_id": userID}, bson.M{"$set": set}); err != nil { t.Fatal(err) } } // adminClient registers + promotes an admin and returns a logged-in client. func adminClient(t *testing.T, ts string, st *store.Store, email string) (*http.Client, string, domain.User) { t.Helper() c := newClient(t) u := registerUser(t, ts, c, email, "Admin "+email) promote(t, st, u.ID, map[string]bool{"admin": true}) return c, csrfFrom(t, c, ts), u } func TestAdminRBACDenied(t *testing.T) { ts, _, _, _ := newAuthStack(t, nil) c := newClient(t) registerUser(t, ts.URL, c, "plebeian@example.com", "Plebeian") csrf := csrfFrom(t, c, ts.URL) // developer is denied on every admin route for _, probe := range []struct{ method, path string }{ {"GET", "/api/v1/admin/users"}, {"GET", "/api/v1/admin/customers"}, {"GET", "/api/v1/admin/settings"}, {"GET", "/api/v1/admin/audit-log"}, {"GET", "/api/v1/admin/service-status"}, } { req, _ := http.NewRequest(probe.method, ts.URL+probe.path, nil) req.Header.Set(csrfHeader, csrf) resp, err := c.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("%s %s as developer: %d, want 403", probe.method, probe.path, resp.StatusCode) } } } func TestAdminCustomerLifecycle(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c, csrf, _ := adminClient(t, ts.URL, st, "boss@example.com") // a consultant to assign cc := newClient(t) consultant := registerUser(t, ts.URL, cc, "cons@example.com", "Cons") promote(t, st, consultant.ID, map[string]bool{"consultant": true}) // create demo customer resp := postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{ "name": "ACME Demo", "type": "demo", "projectKey": "ACME", "pollIntervalSec": 15, "defaultBudget": 2000, "consultantIds": []string{consultant.ID}, "credentials": map[string]string{}, }, csrf) if resp.StatusCode != http.StatusCreated { t.Fatalf("create customer: %d", resp.StatusCode) } var created struct { Customer struct { ID string `json:"id"` Version int64 `json:"version"` Ticketing struct { HasCredentials bool `json:"hasCredentials"` } `json:"ticketing"` } `json:"customer"` } bodyJSON(t, resp, &created) id := created.Customer.ID // jira customer requires credentials resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{ "name": "Broken Jira", "type": "jira", "credentials": map[string]string{"email": "x@y.z"}, }, csrf) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("jira without token: %d", resp.StatusCode) } resp.Body.Close() // duplicate name resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{ "name": "ACME Demo", "type": "demo", }, csrf) if resp.StatusCode != http.StatusConflict { t.Fatalf("duplicate name: %d", resp.StatusCode) } resp.Body.Close() // stored credentials round-trip through test-connection (demo always ok) resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id+"/test-connection", map[string]any{}, csrf) var test struct { OK bool `json:"ok"` } bodyJSON(t, resp, &test) if !test.OK { t.Fatal("demo test-connection should pass") } // unsaved-credentials test endpoint (jira shape validation) resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/test-connection", map[string]any{ "type": "jira", "baseUrl": "https://nope.invalid", "credentials": map[string]string{}, }, csrf) bodyJSON(t, resp, &test) if test.OK { t.Fatal("jira with empty credentials must fail the test") } // patch: rename + archive resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{ "name": "ACME Renamed", "archived": true, "version": created.Customer.Version, }, csrf) if resp.StatusCode != http.StatusOK { t.Fatalf("patch customer: %d", resp.StatusCode) } resp.Body.Close() // stale version conflicts resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{ "name": "Stale", "version": created.Customer.Version, }, csrf) if resp.StatusCode != http.StatusConflict { t.Fatalf("stale customer patch: %d", resp.StatusCode) } resp.Body.Close() // delete works while no tasks exist req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/customers/"+id, nil) req.Header.Set(csrfHeader, csrf) dResp, err := c.Do(req) if err != nil { t.Fatal(err) } dResp.Body.Close() if dResp.StatusCode != http.StatusNoContent { t.Fatalf("delete customer: %d", dResp.StatusCode) } // audit log recorded the mutations aResp, err := c.Get(ts.URL + "/api/v1/admin/audit-log") if err != nil { t.Fatal(err) } var audit struct { Entries []store.AuditEntry `json:"entries"` } bodyJSON(t, aResp, &audit) actions := map[string]bool{} for _, e := range audit.Entries { actions[e.Action] = true } for _, want := range []string{"customer.create", "customer.update", "customer.delete"} { if !actions[want] { t.Errorf("audit log missing action %q (have %v)", want, actions) } } } func TestAdminUserManagement(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c, csrf, admin := adminClient(t, ts.URL, st, "root2@example.com") tc := newClient(t) target := registerUser(t, ts.URL, tc, "target@example.com", "Target") // search finds the user resp, err := c.Get(ts.URL + "/api/v1/admin/users?q=target") if err != nil { t.Fatal(err) } var list struct { Users []domain.User `json:"users"` } bodyJSON(t, resp, &list) if len(list.Users) != 1 || list.Users[0].ID != target.ID { t.Fatalf("search: %+v", list.Users) } // grant consultant role resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{ "roles": map[string]bool{"admin": false, "consultant": true, "developer": true}, }, csrf) if resp.StatusCode != http.StatusOK { t.Fatalf("grant role: %d", resp.StatusCode) } var patched struct { User domain.User `json:"user"` } bodyJSON(t, resp, &patched) if !patched.User.Roles.Consultant { t.Fatal("consultant role not granted") } // force reset marks mustChange resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"forceReset": true}, csrf) if resp.StatusCode != http.StatusOK { t.Fatalf("force reset: %d", resp.StatusCode) } resp.Body.Close() fresh, err := st.UserByID(t.Context(), target.ID) if err != nil { t.Fatal(err) } if !fresh.MustChangePassword() { t.Fatal("forceReset did not set mustChange") } // disabling kicks the user's session resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"disabled": true}, csrf) resp.Body.Close() meResp, _ := tc.Get(ts.URL + "/api/v1/auth/me") meResp.Body.Close() if meResp.StatusCode == http.StatusOK { t.Fatal("disabled user still has a working session") } // self-demotion and self-disable are blocked for i, body := range []map[string]any{ {"roles": map[string]bool{"admin": false, "consultant": false, "developer": true}}, {"disabled": true}, } { resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+admin.ID, body, csrf) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("self-mutation %d: %d, want 400", i, resp.StatusCode) } resp.Body.Close() } // delete target req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/users/"+target.ID, nil) req.Header.Set(csrfHeader, csrf) dResp, err := c.Do(req) if err != nil { t.Fatal(err) } dResp.Body.Close() if dResp.StatusCode != http.StatusNoContent { t.Fatalf("delete user: %d", dResp.StatusCode) } } func TestAdminSettings(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c, csrf, _ := adminClient(t, ts.URL, st, "settings@example.com") resp, err := c.Get(ts.URL + "/api/v1/admin/settings") if err != nil { t.Fatal(err) } var got struct { Settings domain.AppSettings `json:"settings"` } bodyJSON(t, resp, &got) if got.Settings.DefaultPollIntervalSec != 60 { t.Fatalf("default poll = %d", got.Settings.DefaultPollIntervalSec) } resp = patchJSON(t, c, ts.URL+"/api/v1/admin/settings", map[string]any{ "brandingName": "ACME Bounties", "defaultPollIntervalSec": 30, "hideCompetingClaims": true, }, csrf) if resp.StatusCode != http.StatusOK { t.Fatalf("patch settings: %d", resp.StatusCode) } bodyJSON(t, resp, &got) if got.Settings.BrandingName != "ACME Bounties" || got.Settings.DefaultPollIntervalSec != 30 || !got.Settings.HideCompetingClaims { t.Fatalf("settings after patch: %+v", got.Settings) } } func TestAdminServiceStatusShape(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c, _, _ := adminClient(t, ts.URL, st, fmt.Sprintf("status-%d@example.com", 1)) resp, err := c.Get(ts.URL + "/api/v1/admin/service-status") if err != nil { t.Fatal(err) } var out map[string]any bodyJSON(t, resp, &out) for _, key := range []string{"mongo", "atomizer", "workPerformer", "syncWorkers", "jobs"} { if _, ok := out[key]; !ok { t.Errorf("service-status missing %q", key) } } mongo := out["mongo"].(map[string]any) if mongo["healthy"] != true { t.Error("mongo should be healthy in tests") } }