458ba6a7ee
- 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>
316 lines
9.3 KiB
Go
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")
|
|
}
|
|
}
|