Files
cloud-ip-validator/internal/analytics/analytics_test.go
T

229 lines
9.1 KiB
Go
Raw Normal View History

package analytics
import (
"reflect"
"testing"
"time"
"cloudipvalidator/internal/db"
)
var t0 = time.Date(2026, 10, 2, 13, 0, 0, 0, time.UTC)
type fixture struct {
results []db.RunResult
checks []db.RunCheck
}
func (f *fixture) addr(reg int64, ip, verdict string, expected int) {
f.results = append(f.results, db.RunResult{RegistryID: reg, IPAddress: ip, CycleID: 1, Verdict: verdict,
AggregatedAt: t0.Add(time.Minute), ExpectedChecks: expected})
}
func (f *fixture) check(reg int64, source, typ, target string, ok bool, validator, detail string, late bool) {
rec := t0
if late {
rec = t0.Add(time.Hour)
}
f.checks = append(f.checks, db.RunCheck{RegistryID: reg, Source: source, CheckType: typ, Target: target, Success: ok,
ValidatorID: validator, Detail: detail, RecordedAt: rec})
}
func (f *fixture) compute(t *testing.T, subnets []db.Subnet) *Analysis {
t.Helper()
end := t0.Add(10 * time.Minute)
an, err := Compute(Input{
Run: db.CheckRun{ID: 7, Kind: db.RunManual, State: db.RunFinalized, StartedAt: t0, FinalizedAt: &end},
Results: f.results,
Subnets: subnets,
SiteNames: map[int]string{1: "rxmsk", 2: "rxyc"},
Each: func(fn func(db.RunCheck)) error {
for _, c := range f.checks {
fn(c)
}
return nil
},
})
if err != nil {
t.Fatal(err)
}
return an
}
const (
eg = db.SourceEgress
s1 = "inbound-site-1"
s2 = "inbound-site-2"
)
func TestComputeCountsFactsPerAddress(t *testing.T) {
f := &fixture{}
// 1: all fine
f.addr(1, "10.0.0.1", db.ResultPass, 6)
// 2: egress https fails on both targets, rest fine
f.addr(2, "10.0.0.2", db.ResultPartial, 6)
// 3: ingress ssh fails on one site, set incomplete (5 of 6 stored)
f.addr(3, "10.0.1.1", db.ResultPartial, 6)
// 4: pass at the verdict, but a failed ingress check arrived afterwards
f.addr(4, "10.0.1.2", db.ResultPass, 6)
// 5: cancelled, not counted
f.addr(5, "10.0.1.3", db.ResultCancelled, 6)
good := func(reg int64) {
f.check(reg, eg, "https", "https://a.test/x", true, "vkiplab-v1", "", false)
f.check(reg, eg, "https", "https://b.test", true, "vkiplab-v1", "", false)
f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false)
f.check(reg, s1, "ssh", "ip", true, "vkiplab-v1", "", false)
f.check(reg, s2, "icmp", "ip", true, "vkiplab-v1", "", false)
f.check(reg, s2, "ssh", "ip", true, "vkiplab-v1", "", false)
}
good(1)
f.check(2, eg, "https", "https://a.test/x", false, "vkiplab-v2", `Get "https://a.test/x": context deadline exceeded`, false)
f.check(2, eg, "https", "https://b.test", false, "vkiplab-v2", "", false)
for _, s := range []string{s1, s2} {
f.check(2, s, "icmp", "ip", true, "vkiplab-v2", "", false)
f.check(2, s, "ssh", "ip", true, "vkiplab-v2", "", false)
}
f.check(3, eg, "https", "https://a.test/x", true, "vkiplab-v3", "", false)
f.check(3, eg, "https", "https://b.test", true, "vkiplab-v3", "", false)
f.check(3, s1, "icmp", "ip", true, "vkiplab-v3", "", false)
f.check(3, s1, "ssh", "ip", false, "vkiplab-v3", "dial tcp 1.2.3.4:22: i/o timeout", false)
f.check(3, s2, "icmp", "ip", true, "vkiplab-v3", "", false) // the 6th check is missing
// 4: the failed ssh arrived after the verdict
f.check(4, eg, "https", "https://a.test/x", true, "vkiplab-v4", "", false)
f.check(4, eg, "https", "https://b.test", true, "vkiplab-v4", "", false)
f.check(4, s1, "icmp", "ip", true, "vkiplab-v4", "", false)
f.check(4, s1, "ssh", "ip", false, "vkiplab-v4", `unexpected banner prefix "Not allo"`, true)
f.check(4, s2, "icmp", "ip", true, "vkiplab-v4", "", false)
f.check(4, s2, "ssh", "ip", true, "vkiplab-v4", "", false)
good(5)
an := f.compute(t, []db.Subnet{{CIDR: "10.0.0.0/24"}, {CIDR: "10.0.1.0/24"}})
r := an.Report
want := Summary{Addresses: 4, Pass: 2, Partial: 2, Cancelled: 1, EgressOK: 3, IngressOK: 2,
EgressHTTPSAny: 1, EgressHTTPSAll: 1, EgressHTTPSAllTargets: 1, IngressSSHAny: 2, IngressSSHAll: 1}
got := r.Summary
got.PerMinute = 0
if got != want {
t.Errorf("summary = %+v\nwant %+v", got, want)
}
if r.Run.DurationSec != 600 || r.Run.ID != 7 {
t.Errorf("run info: %+v", r.Run)
}
if wantReasons := []Reason{{"Только egress", 1}, {"Ingress и неполный набор", 1}}; !reflect.DeepEqual(r.Reasons, wantReasons) {
t.Errorf("reasons = %+v, want %+v", r.Reasons, wantReasons)
}
q := r.Quality
if q.Incomplete != 1 || q.PassWithFailed != 1 || q.PassByFacts != 1 || q.LateFailedAtPass != 1 || q.LateFailedAtPassAddresses != 1 ||
q.IngressFailed != 2 || q.IngressFailedLate != 1 {
t.Errorf("quality = %+v", q)
}
// Errors are classed by check type and reason.
wantErrs := []ErrorClass{{"SSH: баннер «Not allowed»", 1}, {"SSH: таймаут", 1}}
if !reflect.DeepEqual(r.Errors, wantErrs) {
t.Errorf("errors = %+v", r.Errors)
}
// Targets: hosts, https is the lead type; address 2 failed both.
if !reflect.DeepEqual(r.Targets.Targets, []string{"a.test", "b.test"}) || !reflect.DeepEqual(r.Targets.Failed["https"], []int{1, 1}) {
t.Errorf("targets = %+v", r.Targets)
}
if len(r.Subnets) != 2 || r.Subnets[0].Addresses != 2 {
t.Errorf("subnets = %+v", r.Subnets)
}
if rows := r.Matrix["https"]; len(rows) != 2 || rows[0].CIDR != "10.0.0.0/24" && rows[0].CIDR != "10.0.1.0/24" {
t.Errorf("matrix = %+v", r.Matrix)
}
// Sites in index order, types sorted.
if !reflect.DeepEqual(r.Sites.Types, []string{"icmp", "ssh"}) || len(r.Sites.Rows) != 2 || r.Sites.Rows[0].Site != "rxmsk" {
t.Errorf("sites = %+v", r.Sites)
}
// ssh at rxmsk: addresses 1, 2, 3, 4 (the cancelled one is not counted) -> 4 checks, 2 failed.
if st := r.Sites.Rows[0].Stats[1]; st.Total != 4 || st.OK != 2 {
t.Errorf("rxmsk ssh = %+v", st)
}
// Validators by number.
if len(r.Validators) != 4 || r.Validators[0].Validator != "vkiplab-v1" || r.Validators[0].Total != 2 {
t.Errorf("validators = %+v", r.Validators)
}
}
func TestSubnetMatching(t *testing.T) {
in := []db.Subnet{{CIDR: "10.0.0.0/8"}, {CIDR: "10.1.0.0/16"}}
m := newSubnetMatcher(in)
for ip, want := range map[string]string{"10.1.2.3": "10.1.0.0/16", "10.2.0.1": "10.0.0.0/8", "192.0.2.1": "прочие", "garbage": "прочие"} {
if got := m(ip); got != want {
t.Errorf("%s -> %s, want %s", ip, got, want)
}
}
auto := newSubnetMatcher(nil)
if got := auto("203.0.113.77"); got != "203.0.113.0/24" {
t.Errorf("without a list addresses group by /24, got %s", got)
}
}
func TestErrorClassOf(t *testing.T) {
for _, c := range []struct{ typ, detail, want string }{
{"ssh", `read banner: read tcp 1.2.3.4:5->6.7.8.9:22: i/o timeout`, "SSH: таймаут"},
{"ssh", `unexpected banner prefix "Not allo"`, "SSH: баннер «Not allowed»"},
{"ssh", `dial tcp 1.2.3.4:22: connect: no route to host`, "SSH: нет маршрута"},
{"tcp-22", `dial tcp 1.2.3.4:22: i/o timeout`, "TCP-22: таймаут"},
{"tcp-22", `connect: connection refused`, "TCP-22: отказ в соединении"},
{"icmp", `read echo reply: read ip4 0.0.0.0: i/o timeout`, "ICMP: нет ответа"},
{"icmp", `unexpected icmp type time exceeded`, "ICMP: time exceeded"},
{"ssh", `something new`, "SSH: прочее"},
} {
if got := ErrorClassOf(c.typ, c.detail); got != c.want {
t.Errorf("%s %q = %q, want %q", c.typ, c.detail, got, c.want)
}
}
}
func TestListsAndShortValidator(t *testing.T) {
f := &fixture{}
f.addr(1, "10.0.0.9", db.ResultPartial, 4)
f.addr(2, "10.0.0.10", db.ResultPass, 4)
f.check(1, eg, "https", "https://a.test", false, "vkiplab-v12", "", false)
f.check(1, eg, "https", "https://b.test", false, "vkiplab-v12", "", false)
f.check(1, s2, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", false)
f.check(1, s1, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", true)
f.check(2, eg, "https", "https://a.test", true, "vkiplab-v3", "", false)
f.check(2, eg, "https", "https://b.test", false, "vkiplab-v3", "", false)
an := f.compute(t, nil)
l, err := an.List(ListEgressHTTPSAny, "")
if err != nil || len(l.Rows) != 2 || l.Rows[0][0] != "10.0.0.9" || l.Rows[1][0] != "10.0.0.10" { // numeric order
t.Fatalf("any: %+v %v", l, err)
}
if l.Rows[0][2] != "v12" || l.Rows[0][3] != "2 из 2" || l.Rows[1][3] != "1 из 2" || l.Rows[1][4] != "b.test" {
t.Errorf("any rows: %+v", l.Rows)
}
l, _ = an.List(ListEgressHTTPSAll, "")
if len(l.Rows) != 1 || l.Rows[0][3] != "2" || l.Rows[0][4] != "a.test, b.test" {
t.Errorf("all: %+v", l.Rows)
}
l, _ = an.List(ListIngressSSHAll, "")
if len(l.Rows) != 1 || l.Rows[0][2] != "rxmsk, rxyc" || l.Rows[0][3] != "таймаут" { // sites in index order
t.Errorf("ssh all: %+v", l.Rows)
}
l, _ = an.List(ListError, "SSH: таймаут")
if len(l.Rows) != 2 || l.Rows[0][2] != "rxmsk" || l.Rows[0][5] != "провал, после вердикта" || l.Rows[1][5] != "провал, в вердикте" {
t.Errorf("error list: %+v", l.Rows)
}
if _, err := an.List(ListError, ""); err == nil {
t.Error("an error list needs a class")
}
if _, err := an.List("nonsense", ""); err == nil {
t.Error("unknown list must fail")
}
for in, want := range map[string]string{"vkiplab-v12": "v12", "validator": "validator", "": ""} {
if got := ShortValidator(in); got != want {
t.Errorf("ShortValidator(%q) = %q", in, got)
}
}
}