fillRegistrySummary derived LastResult from whichever single checks row happened to have the latest checked_at, not the cycle's actual aggregated result — a cycle with a mix of passing and failing checks (e.g. one egress target timed out while the rest, including the chronologically-last check, succeeded) rendered as a green "pass" badge on /registry, disagreeing with the correct "partial" badge already shown on /ips for the same address. Now prefers ip_queue.overall_result (the orchestrator's own aggregation) when a live queue row has a finished cycle, leaves the badge blank while a cycle is still in progress, and only falls back to classifying the most recent cycle's own checks (pass/fail/partial) once the address has been deleted from the queue and overall_result is no longer available. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package db
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"cloudipvalidator/internal/config"
|
|
)
|
|
|
|
// TestRegistryHistorySurvivesIPDeletion proves that deleting an address
|
|
// from ip_queue no longer destroys its check history — the whole point of
|
|
// ip_registry (see migrations/0007_ip_registry.sql).
|
|
func TestRegistryHistorySurvivesIPDeletion(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips: %v", err)
|
|
}
|
|
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get ip: %v", err)
|
|
}
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
|
|
Success: true, CheckedAt: Now(),
|
|
}); err != nil {
|
|
t.Fatalf("upsert check: %v", err)
|
|
}
|
|
|
|
if err := d.DeleteIP(ctx, ip.ID); err != nil {
|
|
t.Fatalf("delete ip: %v", err)
|
|
}
|
|
|
|
// The live queue no longer knows about the address...
|
|
if _, err := d.GetIPByAddress(ctx, "1.2.3.4"); err == nil {
|
|
t.Fatalf("expected ip_queue row gone after delete")
|
|
}
|
|
|
|
// ...but the registry and its check history are untouched.
|
|
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry: %v", err)
|
|
}
|
|
if summary.TotalCycles != 1 {
|
|
t.Fatalf("expected 1 cycle retained, got %d", summary.TotalCycles)
|
|
}
|
|
checks, err := d.ListChecksForRegistry(ctx, summary.ID, nil)
|
|
if err != nil {
|
|
t.Fatalf("list checks for registry: %v", err)
|
|
}
|
|
if len(checks) != 1 {
|
|
t.Fatalf("expected 1 retained check, got %+v", checks)
|
|
}
|
|
if checks[0].IPID != 0 {
|
|
t.Fatalf("expected orphaned check's ip_id cleared to 0, got %d", checks[0].IPID)
|
|
}
|
|
}
|
|
|
|
// TestRegistryCycleSurvivesReaddWithoutCollision proves that deleting an
|
|
// address and resubmitting it later gives the new generation of checks a
|
|
// cycle_id distinct from the old one, so both generations' history is kept
|
|
// as separate rows rather than colliding on the UNIQUE constraint.
|
|
func TestRegistryCycleSurvivesReaddWithoutCollision(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips (1st time): %v", err)
|
|
}
|
|
ip1, err := d.GetIPByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get ip: %v", err)
|
|
}
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip1.ID, IPAddress: ip1.IPAddress, AttemptNumber: ip1.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
|
|
Success: true, CheckedAt: Now(),
|
|
}); err != nil {
|
|
t.Fatalf("upsert check (1st time): %v", err)
|
|
}
|
|
if err := d.DeleteIP(ctx, ip1.ID); err != nil {
|
|
t.Fatalf("delete ip: %v", err)
|
|
}
|
|
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips (2nd time): %v", err)
|
|
}
|
|
ip2, err := d.GetIPByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get ip (2nd time): %v", err)
|
|
}
|
|
if ip2.ID == ip1.ID {
|
|
t.Fatalf("expected a fresh ip_queue row, got the same id %d", ip1.ID)
|
|
}
|
|
if ip2.CycleID == ip1.CycleID {
|
|
t.Fatalf("expected a fresh cycle_id, got the same value %d twice", ip1.CycleID)
|
|
}
|
|
// Same check_type/target/source as the first cycle — would collide on
|
|
// the old UNIQUE(ip_id, attempt_number, ...) key structure.
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip2.ID, IPAddress: ip2.IPAddress, AttemptNumber: ip2.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
|
|
Success: false, CheckedAt: Now(),
|
|
}); err != nil {
|
|
t.Fatalf("upsert check (2nd time): %v", err)
|
|
}
|
|
|
|
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry: %v", err)
|
|
}
|
|
if summary.TotalCycles != 2 {
|
|
t.Fatalf("expected 2 distinct cycles retained, got %d", summary.TotalCycles)
|
|
}
|
|
checks, err := d.ListChecksForRegistry(ctx, summary.ID, nil)
|
|
if err != nil {
|
|
t.Fatalf("list checks for registry: %v", err)
|
|
}
|
|
if len(checks) != 2 {
|
|
t.Fatalf("expected 2 separate check rows (one per cycle), got %+v", checks)
|
|
}
|
|
}
|
|
|
|
// TestPruneRegistryHistoryKeepsOnlyNewestCycles proves the retention-depth
|
|
// setting actually deletes older cycles' checks once an address has
|
|
// accumulated more cycles than the configured depth.
|
|
func TestPruneRegistryHistoryKeepsOnlyNewestCycles(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
|
|
var registryID int64
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips (cycle %d): %v", i, err)
|
|
}
|
|
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get ip (cycle %d): %v", i, err)
|
|
}
|
|
registryID = ip.RegistryID
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
|
|
Success: true, CheckedAt: Now(),
|
|
}); err != nil {
|
|
t.Fatalf("upsert check (cycle %d): %v", i, err)
|
|
}
|
|
if i < 2 {
|
|
if err := d.DeleteIP(ctx, ip.ID); err != nil {
|
|
t.Fatalf("delete ip (cycle %d): %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry: %v", err)
|
|
}
|
|
if summary.TotalCycles != 3 {
|
|
t.Fatalf("expected 3 cycles before pruning, got %d", summary.TotalCycles)
|
|
}
|
|
|
|
if err := d.PruneRegistryHistory(ctx, registryID, 1); err != nil {
|
|
t.Fatalf("prune registry history: %v", err)
|
|
}
|
|
|
|
summary, err = d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry after prune: %v", err)
|
|
}
|
|
if summary.TotalCycles != 1 {
|
|
t.Fatalf("expected 1 cycle after pruning to depth 1, got %d", summary.TotalCycles)
|
|
}
|
|
|
|
// Pruning must never touch next_cycle — otherwise a future cycle_id
|
|
// could collide with one that was just pruned away.
|
|
var nextCycle int
|
|
if err := d.QueryRowContext(ctx, `SELECT next_cycle FROM ip_registry WHERE id=?`, registryID).Scan(&nextCycle); err != nil {
|
|
t.Fatalf("read next_cycle: %v", err)
|
|
}
|
|
if nextCycle != 4 {
|
|
t.Fatalf("expected next_cycle unaffected by pruning (still 4), got %d", nextCycle)
|
|
}
|
|
}
|
|
|
|
// TestPruneRegistryHistoryNoopWhenUnderDepth proves pruning is a no-op when
|
|
// an address has fewer cycles than the configured retention depth.
|
|
func TestPruneRegistryHistoryNoopWhenUnderDepth(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips: %v", err)
|
|
}
|
|
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get ip: %v", err)
|
|
}
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
|
|
Success: true, CheckedAt: Now(),
|
|
}); err != nil {
|
|
t.Fatalf("upsert check: %v", err)
|
|
}
|
|
|
|
if err := d.PruneRegistryHistory(ctx, ip.RegistryID, 10); err != nil {
|
|
t.Fatalf("prune registry history: %v", err)
|
|
}
|
|
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry: %v", err)
|
|
}
|
|
if summary.TotalCycles != 1 {
|
|
t.Fatalf("expected the single cycle to survive a no-op prune, got %d", summary.TotalCycles)
|
|
}
|
|
}
|
|
|
|
func TestSetHistoryRetentionCyclesRejectsNegative(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
if err := d.BootstrapFromConfig(ctx, &config.ControlAPI{}); err != nil {
|
|
t.Fatalf("bootstrap: %v", err)
|
|
}
|
|
if err := d.SetHistoryRetentionCycles(ctx, -1); !errors.Is(err, ErrValidation) {
|
|
t.Fatalf("expected ErrValidation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetSetHistoryRetentionCyclesRoundTrip(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
if err := d.BootstrapFromConfig(ctx, &config.ControlAPI{}); err != nil {
|
|
t.Fatalf("bootstrap: %v", err)
|
|
}
|
|
if err := d.SetHistoryRetentionCycles(ctx, 5); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
s, err := d.GetSettings(ctx)
|
|
if err != nil {
|
|
t.Fatalf("get settings: %v", err)
|
|
}
|
|
if s.HistoryRetentionCycles != 5 {
|
|
t.Fatalf("expected 5, got %d", s.HistoryRetentionCycles)
|
|
}
|
|
}
|
|
|
|
// TestRegistryLastResultReflectsAggregatedOutcome guards against the badge
|
|
// bug where LastResult was derived from whichever single check happened to
|
|
// have the latest checked_at, instead of the cycle's actual aggregated
|
|
// result — a cycle with one failing check and several passing ones is
|
|
// "partial", even if the chronologically-last check to report in passed.
|
|
func TestRegistryLastResultReflectsAggregatedOutcome(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips: %v", err)
|
|
}
|
|
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get ip: %v", err)
|
|
}
|
|
|
|
// The chronologically-last check (icmp, checked_at later) passes, but
|
|
// an earlier one (https) failed — the cycle as a whole is partial.
|
|
now := Now()
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
|
|
Success: false, CheckedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("upsert check (https, fail): %v", err)
|
|
}
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "icmp", Target: "https://example.test",
|
|
Success: true, CheckedAt: now.Add(time.Second),
|
|
}); err != nil {
|
|
t.Fatalf("upsert check (icmp, pass): %v", err)
|
|
}
|
|
|
|
// Simulate what orchestrator.aggregateAndRelease actually does: compute
|
|
// the aggregated result and persist it via FinishIP.
|
|
if err := d.FinishIP(ctx, ip.ID, ResultPartial); err != nil {
|
|
t.Fatalf("finish ip: %v", err)
|
|
}
|
|
|
|
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry: %v", err)
|
|
}
|
|
if summary.LastResult != ResultPartial {
|
|
t.Fatalf("expected LastResult=partial (aggregated outcome), got %q", summary.LastResult)
|
|
}
|
|
if !summary.InQueue || summary.CurrentState != IPDone {
|
|
t.Fatalf("expected still in queue as done, got in_queue=%v state=%q", summary.InQueue, summary.CurrentState)
|
|
}
|
|
|
|
all, err := d.ListRegistry(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list registry: %v", err)
|
|
}
|
|
if len(all) != 1 || all[0].LastResult != ResultPartial {
|
|
t.Fatalf("expected ListRegistry to agree, got %+v", all)
|
|
}
|
|
}
|
|
|
|
// TestRegistryLastResultEmptyWhileCycleInProgress proves an address with a
|
|
// live but not-yet-finished cycle (overall_result still empty) doesn't get
|
|
// a premature pass/fail verdict.
|
|
func TestRegistryLastResultEmptyWhileCycleInProgress(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips: %v", err)
|
|
}
|
|
|
|
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry: %v", err)
|
|
}
|
|
if summary.LastResult != "" {
|
|
t.Fatalf("expected no verdict yet for a still-queued address, got %q", summary.LastResult)
|
|
}
|
|
if !summary.InQueue || summary.CurrentState != IPQueued {
|
|
t.Fatalf("expected in_queue=true state=queued, got in_queue=%v state=%q", summary.InQueue, summary.CurrentState)
|
|
}
|
|
}
|
|
|
|
// TestRegistryLastResultFallsBackToChecksAfterDeletion proves that once an
|
|
// address's ip_queue row is gone (no more overall_result to read), the
|
|
// registry still derives a sensible partial/pass/fail verdict from the
|
|
// last recorded cycle's own checks.
|
|
func TestRegistryLastResultFallsBackToChecksAfterDeletion(t *testing.T) {
|
|
d, ctx := newTestDB(t)
|
|
|
|
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
|
|
t.Fatalf("submit ips: %v", err)
|
|
}
|
|
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get ip: %v", err)
|
|
}
|
|
now := Now()
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
|
|
Success: false, CheckedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("upsert check (fail): %v", err)
|
|
}
|
|
if err := d.UpsertCheck(ctx, Check{
|
|
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
|
|
Source: SourceEgress, CheckType: "icmp", Target: "https://example.test",
|
|
Success: true, CheckedAt: now.Add(time.Second),
|
|
}); err != nil {
|
|
t.Fatalf("upsert check (pass): %v", err)
|
|
}
|
|
if err := d.DeleteIP(ctx, ip.ID); err != nil {
|
|
t.Fatalf("delete ip: %v", err)
|
|
}
|
|
|
|
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
|
|
if err != nil {
|
|
t.Fatalf("get registry: %v", err)
|
|
}
|
|
if summary.InQueue {
|
|
t.Fatalf("expected address no longer in queue")
|
|
}
|
|
if summary.LastResult != ResultPartial {
|
|
t.Fatalf("expected fallback classification partial (mixed pass/fail checks), got %q", summary.LastResult)
|
|
}
|
|
}
|