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