Files
cloud-ip-validator/internal/db/queries_registry_test.go
T
ayurishchevandClaude Sonnet 5 582b44f314 Add Floating IP scanning and a durable address registry with configurable history depth
Adds POST /api/v1/admin/ips/scan (plus an optional periodic ticker) to
discover free Floating IPs in the OpenStack project and feed them straight
into the check queue. More importantly, decouples check/event history from
ip_queue's lifecycle: a new ip_registry table (migration 0007) gives every
address ever submitted a durable identity, so deleting it from the queue no
longer destroys its history — it's still reachable via the new
GET /api/v1/admin/registry[/{ip}] endpoints and the dashboard's /registry
pages, with retention depth configurable in check cycles per address
(history_retention_cycles, 0 = unlimited).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:52:01 +03:00

243 lines
7.7 KiB
Go

package db
import (
"errors"
"testing"
"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)
}
}