Files

370 lines
12 KiB
Go
Raw Permalink Normal View History

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