package httpapi import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" "testing" "time" "cloudipvalidator/internal/db" ) // finishedRun builds one finished run of two addresses through the real // queue paths and returns its id: 9.9.9.1 passes, 9.9.9.2 has a failed ssh. func finishedRun(t *testing.T, d *db.DB) int64 { t.Helper() ctx := context.Background() if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.1", "9.9.9.2"}, db.RunManual); err != nil { t.Fatal(err) } for _, addr := range []string{"9.9.9.1", "9.9.9.2"} { ip, err := d.GetIPByAddress(ctx, addr) if err != nil { t.Fatal(err) } if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil { t.Fatal(err) } ip, _ = d.GetIP(ctx, ip.ID) put := func(src, typ, target, detail string, ok bool) { if _, err := d.UpsertCheckIfOpen(ctx, db.Check{IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber, ValidatorID: "vkiplab-v1", Source: src, CheckType: typ, Target: target, Success: ok, Detail: detail, CheckedAt: db.Now()}); err != nil { t.Fatal(err) } } put(db.SourceEgress, "https", "https://a.test", "", true) put(db.InboundSource(1), "icmp", addr, "", true) sshOK := addr == "9.9.9.1" put(db.InboundSource(1), "ssh", addr, "dial tcp: i/o timeout", sshOK) verdict := db.ResultPass if !sshOK { verdict = db.ResultPartial } if err := d.FinishIPExpected(ctx, ip.ID, verdict, 3); err != nil { t.Fatal(err) } } rs, err := d.ListRuns(ctx) if err != nil || len(rs) != 1 || rs[0].State != db.RunFinalized { t.Fatalf("expected one finalized run: %+v %v", rs, err) } return rs[0].ID } func TestAnalyticsEndpoints(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"}) id := finishedRun(t, d) base := "/api/v1/admin/analytics/runs" resp, body := fc.do(http.MethodGet, base, nil) var list []analyticsRunDTO if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &list) != nil || len(list) != 1 || list[0].State != "finalized" || list[0].Addresses != 2 || list[0].Pass != 1 || list[0].Partial != 1 { t.Fatalf("runs: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id), nil) var rep struct { Summary struct { Addresses int `json:"addresses"` IngressSSHAny int `json:"ingress_ssh_any_failed"` IngressSSHAll int `json:"ingress_ssh_all_failed"` } `json:"summary"` Errors []struct { Name string `json:"name"` Count int `json:"count"` } `json:"errors"` Sites struct { Rows []struct { Site string `json:"site"` } `json:"rows"` } `json:"sites"` } if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Summary.Addresses != 2 || rep.Summary.IngressSSHAny != 1 || rep.Summary.IngressSSHAll != 1 || len(rep.Errors) != 1 || rep.Errors[0].Name != "SSH: таймаут" || len(rep.Sites.Rows) != 1 || rep.Sites.Rows[0].Site != "rxmsk" { t.Fatalf("report: %d %s", resp.StatusCode, body) } // The list as JSON and as a CSV file with BOM and a download name. resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any", nil) var l struct { Columns []string `json:"columns"` Rows [][]string `json:"rows"` } if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" { t.Fatalf("list: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any?format=csv", nil) if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "\xef\xbb\xbf") || !strings.Contains(string(body), "9.9.9.2") || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") || !strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="ingress_ssh_any_run`+itoa64(id)+`.csv"`) { t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body) } // A verdict list: 9.9.9.2 is the only partial address. resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial", nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" || l.Columns[len(l.Columns)-1] != "Причина" { t.Fatalf("verdict list: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial?format=csv", nil) if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "9.9.9.2") || !strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="verdict_partial_run`+itoa64(id)+`.csv"`) { t.Fatalf("verdict csv: %d %v %q", resp.StatusCode, resp.Header, body) } q := url.Values{"class": {"SSH: таймаут"}, "format": {"csv"}} resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/error?"+q.Encode(), nil) if resp.StatusCode != http.StatusOK || !strings.Contains(resp.Header.Get("Content-Disposition"), "error-ssh_run") { t.Fatalf("error csv: %d %v %q", resp.StatusCode, resp.Header, body) } for path, want := range map[string]int{ base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound, base + "/" + itoa64(id) + "/lists/verdict_cancelled": http.StatusNotFound, base + "/9999": http.StatusNotFound, base + "/abc": http.StatusBadRequest, base + "/9999/lists/ingress_ssh_any": http.StatusNotFound, } { if resp, body := fc.do(http.MethodGet, path, nil); resp.StatusCode != want { t.Errorf("%s: %d %s, want %d", path, resp.StatusCode, body, want) } } } // secondRun builds the run after finishedRun's and returns its id: 9.9.9.1 is // not in it, 9.9.9.2 is checked again and passes now, 9.9.9.3 is new and has a // failed ssh. Against the first run: one new, one left, one changed address. func secondRun(t *testing.T, d *db.DB) int64 { t.Helper() ctx := context.Background() if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.2", "9.9.9.3"}, db.RunManual); err != nil { t.Fatal(err) } for _, addr := range []string{"9.9.9.2", "9.9.9.3"} { ip, err := d.GetIPByAddress(ctx, addr) if err != nil { t.Fatal(err) } if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil { t.Fatal(err) } ip, _ = d.GetIP(ctx, ip.ID) put := func(src, typ, target string, ok bool) { if _, err := d.UpsertCheckIfOpen(ctx, db.Check{IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber, ValidatorID: "vkiplab-v1", Source: src, CheckType: typ, Target: target, Success: ok, Detail: "dial tcp: i/o timeout", CheckedAt: db.Now()}); err != nil { t.Fatal(err) } } put(db.SourceEgress, "https", "https://a.test", true) put(db.InboundSource(1), "icmp", addr, true) sshOK := addr == "9.9.9.2" put(db.InboundSource(1), "ssh", addr, sshOK) verdict := db.ResultPass if !sshOK { verdict = db.ResultPartial } if err := d.FinishIPExpected(ctx, ip.ID, verdict, 3); err != nil { t.Fatal(err) } } rs, err := d.ListRuns(ctx) if err != nil || len(rs) != 2 || rs[0].State != db.RunFinalized { t.Fatalf("expected two finalized runs: %+v %v", rs, err) } return rs[0].ID } func TestAnalyticsCompareEndpoints(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"}) a := finishedRun(t, d) b := secondRun(t, d) cmp := "/api/v1/admin/analytics/compare" q := "?base=" + itoa64(a) + "&target=" + itoa64(b) resp, body := fc.do(http.MethodGet, cmp+q, nil) var rep struct { Runs struct { Base struct{ ID, Addresses int64 } `json:"base"` Target struct{ ID, Addresses int64 } `json:"target"` } `json:"runs"` Groups struct { New, Left, Common, Changed, Same int } `json:"groups"` Indicators []struct { Key string Base, Target, Delta, New, Left, Entered, Exited int } `json:"indicators"` Transitions struct { Matrix [][]int `json:"matrix"` } `json:"transitions"` } if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Runs.Base.ID != a || rep.Runs.Target.ID != b || rep.Runs.Base.Addresses != 2 || rep.Runs.Target.Addresses != 2 || rep.Groups.New != 1 || rep.Groups.Left != 1 || rep.Groups.Common != 1 || rep.Groups.Changed != 1 || rep.Groups.Same != 0 || len(rep.Indicators) != 7 || len(rep.Transitions.Matrix) != 3 || rep.Transitions.Matrix[1][0] != 1 { t.Fatalf("compare: %d %s", resp.StatusCode, body) } for _, ind := range rep.Indicators { if ind.Delta != ind.New-ind.Left+ind.Entered-ind.Exited || ind.Delta != ind.Target-ind.Base { t.Errorf("indicator %s: %+v", ind.Key, ind) } if ind.Key == "ingress_ssh_any" && (ind.Base != 1 || ind.Target != 1 || ind.New != 1 || ind.Exited != 1) { t.Errorf("ssh any: %+v", ind) } } // Lists: JSON, then CSV with BOM and the name of the file. var l struct { Group string `json:"group"` Columns []string `json:"columns"` Rows [][]string `json:"rows"` } resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q, nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || l.Group != "changed" || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" || !strings.HasPrefix(l.Rows[0][2], "partial → pass") { t.Fatalf("changed list: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial", nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.3" { t.Fatalf("new list: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, cmp+"/lists/left"+q+"&indicator=verdict_pass", nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" { t.Fatalf("left list: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass", nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" { t.Fatalf("matrix cell list: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, cmp+"/lists/exited"+q+"&indicator=ingress_ssh_any", nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" { t.Fatalf("exited list: %d %s", resp.StatusCode, body) } resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q+"&format=csv", nil) if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "\xef\xbb\xbf") || !strings.Contains(string(body), "9.9.9.2") || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") || !strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="compare_changed_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) { t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body) } resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial&format=csv", nil) if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_new_verdict_partial_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) { t.Fatalf("csv with an indicator: %d %v %q", resp.StatusCode, resp.Header, body) } resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass&format=csv", nil) if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_common_partial-pass_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) { t.Fatalf("csv with verdicts: %d %v %q", resp.StatusCode, resp.Header, body) } // The order is the caller's: swapped, the new address is the one that left. swapped := "?base=" + itoa64(b) + "&target=" + itoa64(a) resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+swapped, nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" { t.Fatalf("swapped: %d %s", resp.StatusCode, body) } // An open run cannot be compared. if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.9"}); err != nil { t.Fatal(err) } rs, _ := d.ListRuns(context.Background()) open := itoa64(rs[0].ID) if rs[0].State == db.RunFinalized { t.Fatalf("expected an open run: %+v", rs[0]) } ida, idb := itoa64(a), itoa64(b) for _, c := range []struct { path string want int }{ {cmp, http.StatusBadRequest}, // no ids {cmp + "?base=" + ida, http.StatusBadRequest}, {cmp + "?target=" + ida, http.StatusBadRequest}, {cmp + "?base=abc&target=" + ida, http.StatusBadRequest}, {cmp + "?base=0&target=" + ida, http.StatusBadRequest}, {cmp + "?base=" + ida + "&target=" + ida, http.StatusBadRequest}, // the same run twice {cmp + "/lists/changed?base=" + ida + "&target=" + ida, http.StatusBadRequest}, {cmp + "/lists/changed", http.StatusBadRequest}, {cmp + "?base=9999&target=" + ida, http.StatusNotFound}, {cmp + "?base=" + ida + "&target=9999", http.StatusNotFound}, {cmp + "?base=" + ida + "&target=" + open, http.StatusConflict}, {cmp + "/lists/changed?base=" + open + "&target=" + idb, http.StatusConflict}, {cmp + "/lists/nonsense" + q, http.StatusNotFound}, {cmp + "/lists/new" + q + "&indicator=nonsense", http.StatusNotFound}, {cmp + "/lists/entered" + q, http.StatusNotFound}, // an indicator is required {cmp + "/lists/common" + q + "&from=pass", http.StatusNotFound}, {cmp + "/lists/common" + q + "&from=pass&to=cancelled", http.StatusNotFound}, } { if resp, body := fc.do(http.MethodGet, c.path, nil); resp.StatusCode != c.want { t.Errorf("%s: %d %s, want %d", c.path, resp.StatusCode, body, c.want) } } } // An open run has no analytics yet. func TestAnalyticsOfOpenRunIsRefused(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.1"}); err != nil { t.Fatal(err) } rs, _ := d.ListRuns(context.Background()) resp, body := fc.do(http.MethodGet, "/api/v1/admin/analytics/runs/"+itoa64(rs[0].ID), nil) if resp.StatusCode != http.StatusConflict { t.Fatalf("open run: %d %s", resp.StatusCode, body) } } // The result is cached while the run is unchanged and recomputed when a // check of the run is written or the subnet list changes. func TestAnalyticsCacheFollowsData(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) id := finishedRun(t, d) path := "/api/v1/admin/analytics/runs/" + itoa64(id) subnetsOf := func() []string { _, body := fc.do(http.MethodGet, path, nil) var rep struct { Subnets []struct { CIDR string `json:"cidr"` } `json:"subnets"` } if err := json.Unmarshal(body, &rep); err != nil { t.Fatal(err) } var out []string for _, s := range rep.Subnets { out = append(out, s.CIDR) } return out } if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.9.0/24" { t.Fatalf("without a list addresses group by /24: %v", got) } resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "9.9.0.0/16", Label: "девятые"}}}) if resp.StatusCode != http.StatusOK { t.Fatalf("put subnets: %d %s", resp.StatusCode, body) } if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.0.0/16" { t.Fatalf("a changed subnet list must change the report: %v", got) } } func TestSubnetsConfigEndpoints(t *testing.T) { fc, _, _, _ := newConfigTestHarness(t) resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: " 10.1.2.3/24 ", Label: "a"}, {CIDR: "10.0.0.0/8"}}}) var got subnetsDTO if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 || got.Subnets[0].CIDR != "10.0.0.0/8" || got.Subnets[1].CIDR != "10.1.2.0/24" { t.Fatalf("put: %d %s", resp.StatusCode, body) } if resp, _ = fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "garbage"}}}); resp.StatusCode != http.StatusBadRequest { t.Fatalf("garbage must be a 400, got %d", resp.StatusCode) } resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/subnets", nil) if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 { t.Fatalf("a rejected list must leave the old one: %d %s", resp.StatusCode, body) } } func TestRegistryRunAndSubnetFilters(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) id := finishedRun(t, d) // an address outside the run if _, err := d.SubmitIPs(context.Background(), []string{"8.8.8.8"}); err != nil { t.Fatal(err) } count := func(q string) int { t.Helper() resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=50&"+q, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("%s: %d %s", q, resp.StatusCode, body) } var p registryPageResponse if err := json.Unmarshal(body, &p); err != nil { t.Fatal(err) } return p.Total } if n := count("run=" + itoa64(id)); n != 2 { t.Errorf("run filter: %d", n) } if n := count("subnet=" + url.QueryEscape("9.9.9.0/24")); n != 2 { t.Errorf("subnet filter: %d", n) } if n := count("run=" + itoa64(id) + "&subnet=" + url.QueryEscape("8.8.8.0/24")); n != 0 { t.Errorf("run and subnet together: %d", n) } for _, bad := range []string{"run=abc", "run=0", "subnet=nonsense"} { if resp, _ := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=5&"+bad, nil); resp.StatusCode != http.StatusBadRequest { t.Errorf("%s: %d, want 400", bad, resp.StatusCode) } } } func itoa64(n int64) string { return strconv.FormatInt(n, 10) }