364 lines
13 KiB
Go
364 lines
13 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|