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) } } }