package integration_test import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) func TestAccountLifecycle(t *testing.T) { r, pool := setupAPIPool(t) ctx := context.Background() superTok := adminLogin(t, r, "admin", "change-me") // AC-S-03 / AC-S-04: no admin session _, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+uuid.New().String()+"/status", map[string]string{"status": "banned", "reason": "x"}, "") if code != http.StatusUnauthorized { t.Fatalf("expected 401 POST status, got %d", code) } _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+uuid.New().String()+"/status-transitions", nil, "") if code != http.StatusUnauthorized { t.Fatalf("expected 401 GET transitions, got %d", code) } key := mustRegister(t, r) env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok) if code != 200 { t.Fatalf("list users: %d", code) } var list struct { Items []struct { ID string `json:"id"` } `json:"items"` } _ = json.Unmarshal(env.Data, &list) if len(list.Items) == 0 { t.Fatal("need user") } userID := list.Items[0].ID // AC-F-01 ban env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", map[string]string{"status": "banned", "reason": "abuse"}, superTok) if code != 200 || env.Code != 0 { t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message) } var detail struct { Status string `json:"status"` } _ = json.Unmarshal(env.Data, &detail) if detail.Status != "banned" { t.Fatalf("expected banned, got %s", detail.Status) } // AC-F-03 same status _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", map[string]string{"status": "banned", "reason": "again"}, superTok) if code != http.StatusBadRequest { t.Fatalf("expected 400 same status, got %d", code) } // AC-S-02 C-end reject if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != http.StatusUnauthorized { t.Fatalf("expected 401 banned bearer, got %d", code) } // AC-F-04 + AC-P-01 + AC-O start := time.Now() env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/status-transitions?limit=50", nil, superTok) if code != 200 || time.Since(start) > 500*time.Millisecond { t.Fatalf("transitions failed/slow http=%d dur=%v", code, time.Since(start)) } var tr struct { Items []struct { FromStatus string `json:"from_status"` ToStatus string `json:"to_status"` Reason string `json:"reason"` AdminID string `json:"admin_id"` } `json:"items"` } _ = json.Unmarshal(env.Data, &tr) if len(tr.Items) == 0 || tr.Items[0].ToStatus != "banned" || tr.Items[0].Reason != "abuse" || tr.Items[0].AdminID == "" { t.Fatalf("unexpected transitions %#v", tr.Items) } env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok) if code != 200 { t.Fatalf("audit %d", code) } var audit struct { Items []struct { Action string `json:"action"` } `json:"items"` } _ = json.Unmarshal(env.Data, &audit) found := false for _, it := range audit.Items { if it.Action == "users.status.transition" { found = true break } } if !found { t.Fatal("missing users.status.transition audit") } // AC-F-02 restore _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", map[string]string{"status": "active", "reason": "appeal"}, superTok) if code != 200 { t.Fatalf("restore failed %d", code) } if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != 200 { t.Fatalf("expected me ok after unban, got %d", code) } // AC-S-01 limited admin without status.write limitedRoleID := uuid.New() _, err := pool.Exec(ctx, ` INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`, limitedRoleID, "lc_"+limitedRoleID.String()[:8]) if err != nil { t.Fatal(err) } _, _ = pool.Exec(ctx, ` INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID) hash, _ := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost) limitedUser := fmt.Sprintf("lc_%d", time.Now().UnixNano()) _, err = pool.Exec(ctx, ` INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`, limitedUser, string(hash), limitedRoleID) if err != nil { t.Fatal(err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser) _, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID) }) limitedTok := adminLogin(t, r, limitedUser, "limited-pass") _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", map[string]string{"status": "suspended", "reason": "nope"}, limitedTok) if code != http.StatusForbidden { t.Fatalf("expected 403 status.write, got %d", code) } } func deviceGET(t *testing.T, r http.Handler, path, deviceKey, bearer string) int { t.Helper() req := httptest.NewRequest(http.MethodGet, path, bytes.NewReader(nil)) if deviceKey != "" { req.Header.Set("X-Device-Key", deviceKey) } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w.Code }