Files
BountyBoard/internal/http/admin_integration_test.go
T
etalon 458ba6a7ee feat: admin area with customers, users, settings, audit log, service status (phase 5)
- customer CRUD wizard backend: per-system credential shapes validated via
  connector construction, AES-256-GCM at rest, write-only over the API
- ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection
- user management: roles, disable (revokes sessions), force password reset,
  delete; self-demotion/disable/delete guards
- admin-editable runtime settings ({_id:app}) incl. atomizer URL override
- audit log written on every privileged mutation + filterable list API
- service status panel: mongo/atomizer/work-performer health + latency with
  late-bound breaker, sync and jobs status providers
- tabbed admin UI (customers wizard dialog, users table, settings, status, audit)
- compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:57:10 +02:00

316 lines
9.3 KiB
Go

//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")
}
}