Registry: the "last result" column now also shows, per level (egress,
ingress), how many of the recorded checks of the latest cycle succeeded, split
by check family (tcp-22 and tcp-443 are both "tcp"). One grouped query per
chunk of addresses; new fields last_cycle_id, egress, ingress in
GET /admin/registry; the dashboard renders them under the verdict.
Verdict integrity (migration 0010):
- the prober is handed an address once per site and attempt, not on every
poll, so results are no longer overwritten by later probe rounds;
- UpsertCheckIfOpen refuses writes once the address is aggregating or has its
verdict, or for an older attempt; senders get {"ok":true,"ignored":N} and a
result_dropped event is recorded;
- the checking window counts from checking_started_at, not from assigned_at;
- checks.recorded_at (server clock) and checks.after_verdict (flag for rows
written after the verdict in existing data);
- the verdict rule is a pure function (computeVerdict) and the aggregated
event carries the egress/ingress check counts.
Rebuilt bin/control-api and bin/admin-dashboard to match. Plans and summaries
are in docs/changes; README, API, USAGE, DASHBOARD and DIAGRAMS are updated.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
134 lines
5.5 KiB
Go
134 lines
5.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"cloudipvalidator/internal/db"
|
|
)
|
|
|
|
// setupCheckingIP stands up two sites and one validator and leaves 9.9.9.9 in
|
|
// the checking state.
|
|
func setupCheckingIP(t *testing.T) (*fakeClient, *db.DB, *db.IPQueueItem) {
|
|
t.Helper()
|
|
fc, d, orch, mock := newConfigTestHarness(t)
|
|
ctx := context.Background()
|
|
mock.Seed("fip-1", "9.9.9.9", "svc-project")
|
|
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "site-1"})
|
|
fc.do(http.MethodPut, "/api/v1/admin/config/sites/2", putSiteRequest{SiteID: "site-2"})
|
|
fc.do(http.MethodPost, "/api/v1/admin/config/validators", createValidatorRequest{ValidatorID: "validator-1", OSPortID: "port-1"})
|
|
fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{ValidatorID: "validator-1"})
|
|
fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}})
|
|
orch.Tick(ctx)
|
|
_, body := fc.do(http.MethodGet, "/api/v1/agents/validator-1/assignment", nil)
|
|
var a assignmentResponse
|
|
if err := json.Unmarshal(body, &a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fc.do(http.MethodPost, "/api/v1/agents/validator-1/self-check", selfCheckRequest{IPID: a.IPID, DetectedEgress: "9.9.9.9", Success: true, Detail: "matched"})
|
|
fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: "site-1"})
|
|
fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: "site-2"})
|
|
ip, err := d.GetIP(ctx, a.IPID)
|
|
if err != nil || ip.State != db.IPChecking {
|
|
t.Fatalf("expected checking: %v %+v", err, ip)
|
|
}
|
|
return fc, d, ip
|
|
}
|
|
|
|
func proberAssignmentsFor(t *testing.T, fc *fakeClient, site string) []proberAssignment {
|
|
t.Helper()
|
|
resp, body := fc.do(http.MethodGet, "/api/v1/probers/"+site+"/assignments", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s assignments: %d %s", site, resp.StatusCode, body)
|
|
}
|
|
var out []proberAssignment
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func proberResult(ip *db.IPQueueItem, ct string, ok, complete bool) proberResultDTO {
|
|
return proberResultDTO{IPID: ip.ID, IPAddress: ip.IPAddress, CheckType: ct, Success: ok,
|
|
CheckedAt: time.Now().UTC().Format(time.RFC3339Nano), Complete: complete}
|
|
}
|
|
|
|
// A prober site gets an address until it has reported it complete, then not
|
|
// again; the other site still gets it.
|
|
func TestProberAssignmentsOncePerSite(t *testing.T) {
|
|
fc, _, ip := setupCheckingIP(t)
|
|
|
|
if len(proberAssignmentsFor(t, fc, "site-1")) != 1 || len(proberAssignmentsFor(t, fc, "site-2")) != 1 {
|
|
t.Fatal("both sites must get the address before reporting")
|
|
}
|
|
resp, body := fc.do(http.MethodPost, "/api/v1/probers/site-1/results", proberResultsRequest{
|
|
Results: []proberResultDTO{proberResult(ip, "tcp-22", true, false), proberResult(ip, "icmp", true, true)},
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("results: %d %s", resp.StatusCode, body)
|
|
}
|
|
if got := proberAssignmentsFor(t, fc, "site-1"); len(got) != 0 {
|
|
t.Fatalf("site-1 must not get the address again after completing it: %+v", got)
|
|
}
|
|
if got := proberAssignmentsFor(t, fc, "site-2"); len(got) != 1 {
|
|
t.Fatalf("site-2 still has to probe the address: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Results that arrive after the verdict are refused with ignored>0, leave the
|
|
// stored checks untouched and leave a result_dropped event behind; the same
|
|
// for the validator agent's results.
|
|
func TestResultsAfterVerdictAreIgnored(t *testing.T) {
|
|
fc, d, ip := setupCheckingIP(t)
|
|
ctx := context.Background()
|
|
|
|
fc.do(http.MethodPost, "/api/v1/probers/site-1/results", proberResultsRequest{
|
|
Results: []proberResultDTO{proberResult(ip, "tcp-22", true, true)},
|
|
})
|
|
fc.do(http.MethodPost, "/api/v1/agents/validator-1/results", agentResultsRequest{
|
|
Results: []checkResultDTO{{IPID: ip.ID, CheckType: "https", Target: "https://example.test", Success: true, CheckedAt: time.Now().UTC().Format(time.RFC3339Nano)}},
|
|
})
|
|
if err := d.SetAggregating(ctx, ip.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.FinishIP(ctx, ip.ID, db.ResultPartial); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// prober: one new check and one that would flip a stored success
|
|
resp, body := fc.do(http.MethodPost, "/api/v1/probers/site-1/results", proberResultsRequest{
|
|
Results: []proberResultDTO{proberResult(ip, "tcp-22", false, false), proberResult(ip, "ssh", false, true)},
|
|
})
|
|
var rr resultsResponse
|
|
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rr) != nil || !rr.OK || rr.Ignored != 2 {
|
|
t.Fatalf("prober late results: %d %s", resp.StatusCode, body)
|
|
}
|
|
// agent
|
|
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/results", agentResultsRequest{
|
|
Results: []checkResultDTO{{IPID: ip.ID, CheckType: "https", Target: "https://example.test", Success: false, CheckedAt: time.Now().UTC().Format(time.RFC3339Nano)}},
|
|
})
|
|
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rr) != nil || rr.Ignored != 1 {
|
|
t.Fatalf("agent late results: %d %s", resp.StatusCode, body)
|
|
}
|
|
|
|
rows, err := d.ListChecksForAttempt(ctx, ip.ID, ip.AttemptNumber)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 2 {
|
|
t.Fatalf("expected the 2 checks written before the verdict, got %d", len(rows))
|
|
}
|
|
for _, c := range rows {
|
|
if !c.Success {
|
|
t.Errorf("%s/%s changed after the verdict", c.Source, c.CheckType)
|
|
}
|
|
}
|
|
var dropped int
|
|
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM events WHERE event_type='result_dropped' AND ip_id=?`, ip.ID).Scan(&dropped); err != nil || dropped != 2 {
|
|
t.Fatalf("expected 2 result_dropped events (prober, agent), got %d err=%v", dropped, err)
|
|
}
|
|
}
|