package httpapi import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "testing" "time" "cloudipvalidator/internal/db" ) func getScanStatus(t *testing.T, fc *fakeClient) scanStatusDTO { t.Helper() resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips/scan", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("scan status: %d %s", resp.StatusCode, body) } var st scanStatusDTO if err := json.Unmarshal(body, &st); err != nil { t.Fatalf("unmarshal scan status %q: %v", body, err) } return st } func waitScanDone(t *testing.T, fc *fakeClient) scanStatusDTO { t.Helper() deadline := time.Now().Add(30 * time.Second) for { st := getScanStatus(t, fc) if !st.Running { return st } if time.Now().After(deadline) { t.Fatalf("scan still running: %+v", st) } time.Sleep(5 * time.Millisecond) } } func TestScanStartReturns202AndStatusIsPollable(t *testing.T) { fc, _, _, mock := newConfigTestHarness(t) // Before any scan the status is idle, with explicit null times. resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips/scan", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status: %d %s", resp.StatusCode, body) } var raw map[string]json.RawMessage if err := json.Unmarshal(body, &raw); err != nil { t.Fatal(err) } for _, k := range []string{"state", "running", "dry_run", "pages", "discovered", "free", "added", "requeued", "reordered", "skipped_in_progress", "started_at", "finished_at", "error"} { if _, ok := raw[k]; !ok { t.Fatalf("missing key %q in %s", k, body) } } if string(raw["state"]) != `"idle"` || string(raw["started_at"]) != "null" || string(raw["finished_at"]) != "null" { t.Fatalf("expected idle with null times, got %s", body) } mock.SeedMany("fip", 250) mock.SeedWithPort("busy", "203.0.113.5", "svc", "port-x") mock.PageSize = 100 mock.PageDelay = 60 * time.Millisecond resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/scan", nil) if resp.StatusCode != http.StatusAccepted { t.Fatalf("start: expected 202, got %d %s", resp.StatusCode, body) } var st scanStatusDTO if err := json.Unmarshal(body, &st); err != nil { t.Fatal(err) } if !st.Running || st.State != "listing" || st.StartedAt == nil { t.Fatalf("expected a running listing job, got %+v", st) } // Starting again while it runs joins the same job: still 202, running. resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/scan?dry_run=true", nil) if resp.StatusCode != http.StatusAccepted { t.Fatalf("join: expected 202, got %d %s", resp.StatusCode, body) } var joined scanStatusDTO if err := json.Unmarshal(body, &joined); err != nil { t.Fatal(err) } if !joined.Running || joined.DryRun || joined.StartedAt == nil || !joined.StartedAt.Equal(*st.StartedAt) { t.Fatalf("expected the running (non-dry) job's status, got %+v", joined) } fin := waitScanDone(t, fc) if fin.State != "done" || fin.Pages != 3 || fin.Discovered != 251 || fin.Free != 250 || fin.Added != 250 || fin.FinishedAt == nil || fin.Error != "" { t.Fatalf("final status: %+v", fin) } if _, body := fc.do(http.MethodGet, "/api/v1/admin/status", nil); !strings.Contains(string(body), `"total_ips":250`) { t.Fatalf("expected 250 queued, got %s", body) } } func TestScanDryRunDoesNotTouchQueue(t *testing.T) { fc, d, _, mock := newConfigTestHarness(t) mock.SeedMany("fip", 30) if err := d.SeedQueue(context.Background(), []string{"9.9.9.9"}); err != nil { t.Fatal(err) } resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?dry_run=true", nil) if resp.StatusCode != http.StatusAccepted { t.Fatalf("dry run start: %d %s", resp.StatusCode, body) } fin := waitScanDone(t, fc) if fin.State != "done" || !fin.DryRun || fin.Free != 30 || fin.Added != 0 { t.Fatalf("dry run status: %+v", fin) } _, body = fc.do(http.MethodGet, "/api/v1/admin/ips", nil) var ips []db.IPQueueItem if err := json.Unmarshal(body, &ips); err != nil || len(ips) != 1 || ips[0].IPAddress != "9.9.9.9" { t.Fatalf("dry run must not touch the queue: %s err=%v", body, err) } // dry_run + wait: counters in the classic body, still no queue change. resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/scan?dry_run=true&wait=true", nil) var sr scanIPsResponse if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &sr) != nil || sr.ScannedFree != 30 || len(sr.Added) != 0 { t.Fatalf("dry run wait: %d %s", resp.StatusCode, body) } } func TestScanWaitErrorIs502AndStatusShowsError(t *testing.T) { fc, d, _, mock := newConfigTestHarness(t) mock.SeedMany("fip", 5) mock.ListFailure = errors.New("neutron is down") if err := d.SeedQueue(context.Background(), []string{"9.9.9.9"}); err != nil { t.Fatal(err) } resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?wait=true", nil) if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(body), "neutron is down") { t.Fatalf("expected 502 with the cause, got %d %s", resp.StatusCode, body) } st := getScanStatus(t, fc) if st.State != "error" || st.Running || !strings.Contains(st.Error, "neutron is down") { t.Fatalf("status after failure: %+v", st) } _, body = fc.do(http.MethodGet, "/api/v1/admin/ips", nil) var ips []db.IPQueueItem if err := json.Unmarshal(body, &ips); err != nil || len(ips) != 1 { t.Fatalf("queue must be untouched after a failed scan: %s", body) } for _, q := range []string{"wait=maybe", "dry_run=2"} { if resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/scan?"+q, nil); resp.StatusCode != http.StatusBadRequest { t.Fatalf("%s: expected 400, got %d %s", q, resp.StatusCode, body) } } } // seedMixedQueue creates addresses 10.0.0.1..10.0.0.n (all queued), then moves // some to done/failed with results. func seedMixedQueue(t *testing.T, d *db.DB, n int) []string { t.Helper() ctx := context.Background() var addrs []string for i := 1; i <= n; i++ { addrs = append(addrs, fmt.Sprintf("10.0.0.%d", i)) } if _, err := d.SubmitIPs(ctx, addrs); err != nil { t.Fatal(err) } finish := func(addr, result string, ok, bad int) { ip, err := d.GetIPByAddress(ctx, addr) if err != nil { t.Fatal(err) } for i := 0; i < ok+bad; i++ { if err := d.UpsertCheck(ctx, db.Check{ IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber, Source: db.SourceEgress, CheckType: "https", Target: fmt.Sprintf("t%d", i), Success: i < ok, CheckedAt: db.Now(), }); err != nil { t.Fatal(err) } } if err := d.FinishIP(ctx, ip.ID, result); err != nil { t.Fatal(err) } } finish("10.0.0.1", db.ResultPass, 1, 0) finish("10.0.0.2", db.ResultPass, 1, 0) finish("10.0.0.3", db.ResultFail, 0, 1) finish("10.0.0.4", db.ResultPartial, 1, 1) return addrs } func TestAdminIPsPaginationFiltersAndCompat(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) seedMixedQueue(t, d, 12) // No params: exactly the old bare array. resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips", nil) if resp.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(string(body)), "[") { t.Fatalf("expected a bare array, got %d %.60s", resp.StatusCode, body) } var all []db.IPQueueItem if err := json.Unmarshal(body, &all); err != nil || len(all) != 12 { t.Fatalf("bare array: n=%d err=%v", len(all), err) } page := func(query string) ipsPageResponse { t.Helper() resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips?"+query, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("%s: %d %s", query, resp.StatusCode, body) } var p ipsPageResponse if err := json.Unmarshal(body, &p); err != nil { t.Fatalf("%s: unmarshal: %v (%s)", query, err, body) } return p } p := page("limit=5") if len(p.Items) != 5 || p.Total != 12 || p.Limit != 5 || p.Offset != 0 || p.Items[0].IPAddress != "10.0.0.1" { t.Fatalf("limit=5: %+v", p) } p = page("limit=5&offset=10") if len(p.Items) != 2 || p.Total != 12 || p.Offset != 10 { t.Fatalf("offset=10: len=%d total=%d", len(p.Items), p.Total) } p = page("limit=50&state=done,failed") if p.Total != 4 || len(p.Items) != 4 { t.Fatalf("state csv: %+v", p) } p = page("limit=50&state=queued&q=0.0.1") if p.Total != 3 { // 10.0.0.10, .11, .12 t.Fatalf("state+q: total=%d", p.Total) } p = page("limit=50&result=fail") if p.Total != 1 || p.Items[0].IPAddress != "10.0.0.3" { t.Fatalf("result: %+v", p) } p = page("limit=2&state=done,failed&order=aggregated_at_desc") if p.Total != 4 || len(p.Items) != 2 || p.Items[0].AggregatedAt == nil { t.Fatalf("order: %+v", p) } if p = page("limit=5&state=checking"); p.Total != 0 || p.Items == nil || len(p.Items) != 0 { t.Fatalf("empty page must have items: [] , got %+v", p) } for _, bad := range []string{ "limit=0", "limit=1001", "limit=abc", "limit=5&offset=-1", "limit=5&offset=x", "offset=5", "limit=5&state=bogus", "limit=5&state=done,nope", "limit=5&result=weird", "limit=5&order=random", } { if resp, body := fc.do(http.MethodGet, "/api/v1/admin/ips?"+bad, nil); resp.StatusCode != http.StatusBadRequest { t.Fatalf("%s: expected 400, got %d %s", bad, resp.StatusCode, body) } } if resp, _ := fc.do(http.MethodGet, "/api/v1/admin/ips?limit=1000", nil); resp.StatusCode != http.StatusOK { t.Fatalf("limit=1000 must be allowed") } } func TestAdminRegistryPaginationFiltersAndCompat(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) seedMixedQueue(t, d, 12) resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry", nil) var bare []registryDTO if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &bare) != nil || len(bare) != 12 { t.Fatalf("bare array: %d %.80s", resp.StatusCode, body) } page := func(query string) registryPageResponse { t.Helper() resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?"+query, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("%s: %d %s", query, resp.StatusCode, body) } var p registryPageResponse if err := json.Unmarshal(body, &p); err != nil { t.Fatal(err) } return p } p := page("limit=5&offset=5") if len(p.Items) != 5 || p.Total != 12 || p.Limit != 5 || p.Offset != 5 || p.Items[0].IPAddress != bare[5].IPAddress { t.Fatalf("paging: %+v", p) } if p = page("limit=50&last_result=pass"); p.Total != 2 || len(p.Items) != 2 || p.Items[0].LastResult != "pass" { t.Fatalf("last_result=pass: %+v", p) } if p = page("limit=50&last_result=partial"); p.Total != 1 || p.Items[0].IPAddress != "10.0.0.4" { t.Fatalf("last_result=partial: %+v", p) } if p = page("limit=50&last_result=cancelled"); p.Total != 0 || p.Items == nil { t.Fatalf("last_result=cancelled: %+v", p) } if p = page("limit=50&q=0.0.1"); p.Total != 4 { // 10.0.0.1, .10, .11, .12 t.Fatalf("q: %+v", p) } for _, bad := range []string{"limit=0", "limit=1001", "offset=1", "limit=5&last_result=bogus", "limit=5&offset=-3"} { if resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?"+bad, nil); resp.StatusCode != http.StatusBadRequest { t.Fatalf("%s: expected 400, got %d %s", bad, resp.StatusCode, body) } } } func TestAdminStatusResultsByOverall(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) // Empty: all four keys present with zeros. _, body := fc.do(http.MethodGet, "/api/v1/admin/status", nil) var st struct { TotalIPs int `json:"total_ips"` IPsByState map[string]int `json:"ips_by_state"` TotalValidators int `json:"total_validators"` ResultsByOverall map[string]int `json:"results_by_overall"` } if err := json.Unmarshal(body, &st); err != nil { t.Fatalf("%s: %v", body, err) } if st.TotalIPs != 0 || len(st.ResultsByOverall) != 4 || st.ResultsByOverall["pass"] != 0 { t.Fatalf("empty status: %s", body) } seedMixedQueue(t, d, 6) _, body = fc.do(http.MethodGet, "/api/v1/admin/status", nil) if err := json.Unmarshal(body, &st); err != nil { t.Fatal(err) } r := st.ResultsByOverall if st.TotalIPs != 6 || st.IPsByState["queued"] != 2 || st.IPsByState["done"] != 3 || st.IPsByState["failed"] != 1 { t.Fatalf("by state: %s", body) } if r["pass"] != 2 || r["partial"] != 1 || r["fail"] != 1 || r["cancelled"] != 0 { t.Fatalf("results_by_overall: %v", r) } } func TestAdminClearReportsCount(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) seedMixedQueue(t, d, 7) resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/clear", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("clear: %d %s", resp.StatusCode, body) } var cr struct { Deleted []string `json:"deleted"` Count int `json:"count"` } if err := json.Unmarshal(body, &cr); err != nil || cr.Count != 7 || len(cr.Deleted) != 7 { t.Fatalf("clear body: %s err=%v", body, err) } resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/clear", nil) if err := json.Unmarshal(body, &cr); err != nil || cr.Count != 0 || cr.Deleted == nil || len(cr.Deleted) != 0 { t.Fatalf("empty clear: %s", body) } }