Files

448 lines
15 KiB
Go
Raw Permalink Normal View History

2026-08-23 20:39:22 +03:00
package db
import (
"context"
2026-08-23 22:24:55 +03:00
"database/sql"
2026-08-23 20:39:22 +03:00
"errors"
"path/filepath"
"testing"
"time"
"cloudipvalidator/internal/config"
)
func newTestDB(t *testing.T) (*DB, context.Context) {
t.Helper()
ctx := context.Background()
d, err := Open(ctx, filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
return d, ctx
}
func TestUpsertSiteValidation(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertSite(ctx, 0, "site-1"); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for idx=0, got %v", err)
}
if err := d.UpsertSite(ctx, 1, ""); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for empty site_id, got %v", err)
}
// No cap on the number of slots — idx=4 and idx=100 must both succeed.
if err := d.UpsertSite(ctx, 4, "site-4"); err != nil {
t.Fatalf("upsert site 4: %v", err)
}
if err := d.UpsertSite(ctx, 100, "site-100"); err != nil {
t.Fatalf("upsert site 100: %v", err)
}
2026-08-23 20:39:22 +03:00
if err := d.UpsertSite(ctx, 1, "site-1"); err != nil {
t.Fatalf("upsert site 1: %v", err)
}
if err := d.UpsertSite(ctx, 2, "site-1"); !errors.Is(err, ErrConflict) {
t.Fatalf("expected ErrConflict assigning site-1 to slot 2, got %v", err)
}
// Renaming the same slot is fine (not a conflict with itself).
if err := d.UpsertSite(ctx, 1, "site-1-renamed"); err != nil {
t.Fatalf("rename slot 1: %v", err)
}
if err := d.DeleteSite(ctx, 1); err != nil {
t.Fatalf("delete site 1: %v", err)
}
if err := d.DeleteSite(ctx, 1); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound deleting already-gone slot, got %v", err)
}
}
func TestTargetGroupInUseCannotBeDeleted(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertTargetGroup(ctx, "web", []string{"https://example.test"}); err != nil {
t.Fatalf("upsert target group: %v", err)
}
if err := d.UpsertTargetGroup(ctx, "empty", nil); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for empty targets, got %v", err)
}
if err := d.UpsertCheckType(ctx, "https", true, []string{"web"}); err != nil {
t.Fatalf("upsert check type: %v", err)
}
if err := d.DeleteTargetGroup(ctx, "web"); !errors.Is(err, ErrInUse) {
t.Fatalf("expected ErrInUse deleting group referenced by check type, got %v", err)
}
if err := d.DeleteCheckType(ctx, "https"); err != nil {
t.Fatalf("delete check type: %v", err)
}
if err := d.DeleteTargetGroup(ctx, "web"); err != nil {
t.Fatalf("delete target group after check type removed: %v", err)
}
}
func TestUpsertCheckTypeUnknownGroup(t *testing.T) {
d, ctx := newTestDB(t)
err := d.UpsertCheckType(ctx, "https", true, []string{"does-not-exist"})
if !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for unknown target group, got %v", err)
}
}
func TestListResolvedCheckTypesExpandsGroups(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertTargetGroup(ctx, "web", []string{"https://a.test", "https://b.test"}); err != nil {
t.Fatalf("upsert web group: %v", err)
}
if err := d.UpsertTargetGroup(ctx, "extra", []string{"https://c.test"}); err != nil {
t.Fatalf("upsert extra group: %v", err)
}
if err := d.UpsertCheckType(ctx, "https", true, []string{"web", "extra"}); err != nil {
t.Fatalf("upsert https check type: %v", err)
}
if err := d.UpsertCheckType(ctx, "ssh", false, []string{"web"}); err != nil {
t.Fatalf("upsert disabled ssh check type: %v", err)
}
resolved, err := d.ListResolvedCheckTypes(ctx)
if err != nil {
t.Fatalf("list resolved check types: %v", err)
}
if len(resolved) != 1 {
t.Fatalf("expected only the enabled check type, got %d entries: %+v", len(resolved), resolved)
}
if resolved[0].Type != "https" || len(resolved[0].Targets) != 3 {
t.Fatalf("expected https with 3 expanded targets, got %+v", resolved[0])
}
}
func TestAdminValidatorCRUD(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatalf("create validator: %v", err)
}
if err := d.AdminCreateValidator(ctx, "validator-1", "port-2"); !errors.Is(err, ErrConflict) {
t.Fatalf("expected ErrConflict creating duplicate validator, got %v", err)
}
if err := d.AdminUpdateValidatorPort(ctx, "validator-1", "port-3"); err != nil {
t.Fatalf("update validator port: %v", err)
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil || v.OSPortID != "port-3" {
t.Fatalf("expected port-3 after update, got %+v (err=%v)", v, err)
}
if err := d.AdminUpdateValidatorPort(ctx, "no-such-validator", "port-9"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound updating unknown validator, got %v", err)
}
// Claim it onto an IP, then deletion should be refused as busy.
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
if _, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute); err != nil {
t.Fatalf("claim next queued: %v", err)
}
if err := d.DeleteValidator(ctx, "validator-1"); !errors.Is(err, ErrBusy) {
t.Fatalf("expected ErrBusy deleting validator that owns an ip, got %v", err)
}
if err := d.ReleaseFIP(ctx, 1, "validator-1"); err != nil {
t.Fatalf("release fip: %v", err)
}
if err := d.DeleteValidator(ctx, "validator-1"); err != nil {
t.Fatalf("delete validator after release: %v", err)
}
if err := d.DeleteValidator(ctx, "validator-1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound deleting already-gone validator, got %v", err)
}
}
func TestBootstrapFromConfigIgnoresYAMLOnNonEmptyTables(t *testing.T) {
d, ctx := newTestDB(t)
cfg := &config.ControlAPI{
Validators: []config.ValidatorConfig{{ValidatorID: "validator-1", OSPortID: "port-1"}},
Sites: []config.SiteConfig{{SiteID: "site-1", Index: 1}},
Targets: map[string][]string{"web": {"https://example.test"}},
CheckTypes: []config.CheckTypeConfig{{Name: "https", Enabled: true, Targets: []string{"web"}}},
}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("first bootstrap: %v", err)
}
// Simulate an admin API change that should survive a second bootstrap
// pass (e.g. a restart) even though the YAML still names the old port.
if err := d.AdminUpdateValidatorPort(ctx, "validator-1", "port-changed-via-api"); err != nil {
t.Fatalf("update validator port via api: %v", err)
}
if err := d.UpsertSite(ctx, 1, "site-changed-via-api"); err != nil {
t.Fatalf("update site via api: %v", err)
}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil {
t.Fatalf("get validator: %v", err)
}
if v.OSPortID != "port-changed-via-api" {
t.Fatalf("expected YAML to be ignored on non-empty table, got os_port_id=%q", v.OSPortID)
}
sites, err := d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites: %v", err)
}
if len(sites) != 1 || sites[0].SiteID != "site-changed-via-api" {
t.Fatalf("expected YAML to be ignored on non-empty sites table, got %+v", sites)
}
}
func TestSubmitIPsMixedBatch(t *testing.T) {
d, ctx := newTestDB(t)
// Seed: 1.1.1.1 done, 2.2.2.2 already queued, 3.3.3.3 mid-check.
if err := d.SeedQueue(ctx, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
ip1, err := d.GetIPByAddress(ctx, "1.1.1.1")
if err != nil {
t.Fatalf("get ip1: %v", err)
}
if err := d.FinishIP(ctx, ip1.ID, ResultPass); err != nil {
t.Fatalf("finish ip1: %v", err)
}
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatalf("create validator: %v", err)
}
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim next queued: item=%+v err=%v", claimed, err)
}
if claimed.IPAddress != "2.2.2.2" {
t.Fatalf("expected to claim 2.2.2.2 (lowest sequence still queued), got %s", claimed.IPAddress)
}
// 2.2.2.2 is now assigning_fip (mid-check); 3.3.3.3 is still queued.
result, err := d.SubmitIPs(ctx, []string{"4.4.4.4", "1.1.1.1", "2.2.2.2", "3.3.3.3"})
if err != nil {
t.Fatalf("submit ips: %v", err)
}
if len(result.Added) != 1 || result.Added[0] != "4.4.4.4" {
t.Fatalf("expected 4.4.4.4 added, got %+v", result.Added)
}
if len(result.Requeued) != 1 || result.Requeued[0] != "1.1.1.1" {
t.Fatalf("expected 1.1.1.1 requeued (was done), got %+v", result.Requeued)
}
if len(result.Reordered) != 1 || result.Reordered[0] != "3.3.3.3" {
t.Fatalf("expected 3.3.3.3 reordered (was queued), got %+v", result.Reordered)
}
if len(result.SkippedInProgress) != 1 || result.SkippedInProgress[0] != "2.2.2.2" {
t.Fatalf("expected 2.2.2.2 skipped (mid-check), got %+v", result.SkippedInProgress)
}
// 2.2.2.2 must be untouched: still assigning_fip, same owner.
ip2, err := d.GetIPByAddress(ctx, "2.2.2.2")
if err != nil {
t.Fatalf("get ip2: %v", err)
}
if ip2.State != IPAssigningFIP || ip2.OwnerValidatorID == nil || *ip2.OwnerValidatorID != "validator-1" {
t.Fatalf("expected 2.2.2.2 untouched mid-check, got %+v", ip2)
}
// 1.1.1.1 must be freshly queued again with a bumped attempt number and
// cleared result.
ip1, err = d.GetIPByAddress(ctx, "1.1.1.1")
if err != nil {
t.Fatalf("get ip1 after requeue: %v", err)
}
if ip1.State != IPQueued || ip1.AttemptNumber != 2 || ip1.OverallResult != "" {
t.Fatalf("expected 1.1.1.1 requeued fresh, got %+v", ip1)
}
// Queue processing order should follow the submitted list order for the
// touched/new addresses: 4.4.4.4, 1.1.1.1, 3.3.3.3 (2.2.2.2 excluded,
// already claimed by validator-1, which is now busy — use a second
// idle validator to observe what's claimed next).
if err := d.AdminCreateValidator(ctx, "validator-2", "port-2"); err != nil {
t.Fatalf("create validator-2: %v", err)
}
claimed, err = d.ClaimNextQueued(ctx, "validator-2", time.Minute)
if err != nil {
t.Fatalf("claim after submit: %v", err)
}
if claimed == nil || claimed.IPAddress != "4.4.4.4" {
t.Fatalf("expected 4.4.4.4 to be claimed first per batch order, got %+v", claimed)
}
}
func TestSubmitIPsEmptyListRejected(t *testing.T) {
d, ctx := newTestDB(t)
if _, err := d.SubmitIPs(ctx, nil); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for empty address list, got %v", err)
}
}
func TestCancelIP(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get ip: %v", err)
}
// Cancel straight from queued.
if err := d.CancelIP(ctx, ip.ID); err != nil {
t.Fatalf("cancel queued ip: %v", err)
}
ip, _ = d.GetIP(ctx, ip.ID)
if ip.State != IPFailed || ip.OverallResult != ResultCancelled {
t.Fatalf("expected failed/cancelled, got state=%s result=%s", ip.State, ip.OverallResult)
}
// Cancelling an already-terminal IP is rejected.
if err := d.CancelIP(ctx, ip.ID); !errors.Is(err, ErrInvalidState) {
t.Fatalf("expected ErrInvalidState cancelling an already-finished ip, got %v", err)
}
// Cancel mid-check: claim, associate, then cancel — owner/lease/fip
// should be cleared by CancelIP itself (disassociation is the
// orchestrator's job, not this method's).
if err := d.SeedQueue(ctx, []string{"5.6.7.8"}); err != nil {
t.Fatalf("seed queue 2: %v", err)
}
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatalf("create validator: %v", err)
}
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim: item=%+v err=%v", claimed, err)
}
if err := d.CancelIP(ctx, claimed.ID); err != nil {
t.Fatalf("cancel mid-check ip: %v", err)
}
got, _ := d.GetIP(ctx, claimed.ID)
if got.State != IPFailed || got.OverallResult != ResultCancelled || got.OwnerValidatorID != nil {
t.Fatalf("expected cancelled + owner cleared, got %+v", got)
}
}
2026-08-23 22:24:55 +03:00
func TestDeleteIP(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %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.InsertEvent(ctx, Event{SourceType: "control-api", IPID: &ip.ID, EventType: "test_event", OccurredAt: Now()}); err != nil {
t.Fatalf("insert event: %v", err)
}
if err := d.SetSiteComplete(ctx, ip.ID, 1); err != nil {
t.Fatalf("set site complete: %v", err)
}
2026-08-23 22:24:55 +03:00
if err := d.DeleteIP(ctx, ip.ID); err != nil {
t.Fatalf("delete ip: %v", err)
}
if _, err := d.GetIP(ctx, ip.ID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected row gone, got %v", err)
}
checks, err := d.ListChecksForAttempt(ctx, ip.ID, ip.AttemptNumber)
if err != nil {
t.Fatalf("list checks: %v", err)
}
if len(checks) != 0 {
t.Fatalf("expected checks gone, got %+v", checks)
}
completed, err := d.ListCompletedSiteIndices(ctx, ip.ID, ip.AttemptNumber)
if err != nil {
t.Fatalf("list completed site indices: %v", err)
}
if len(completed) != 0 {
t.Fatalf("expected ip_site_checks rows gone, got %+v", completed)
}
2026-08-23 22:24:55 +03:00
events, err := d.ListEventsForIP(ctx, ip.ID)
if err != nil {
t.Fatalf("list events: %v", err)
}
if len(events) != 0 {
t.Fatalf("expected events gone, got %+v", events)
}
if err := d.DeleteIP(ctx, ip.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound deleting already-gone ip, got %v", err)
}
}
func TestDeleteIPFreesOwningValidator(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatalf("create validator: %v", err)
}
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim: item=%+v err=%v", claimed, err)
}
if err := d.DeleteIP(ctx, claimed.ID); err != nil {
t.Fatalf("delete ip: %v", err)
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil {
t.Fatalf("get validator: %v", err)
}
if v.State != ValidatorIdle || v.CurrentIPID != nil {
t.Fatalf("expected validator freed, got state=%s current_ip=%v", v.State, v.CurrentIPID)
}
}
func TestDeleteIPs(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.1.1.1", "2.2.2.2"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
result, err := d.DeleteIPs(ctx, []string{"1.1.1.1", "no-such-address", "2.2.2.2"})
if err != nil {
t.Fatalf("delete ips: %v", err)
}
if len(result.Deleted) != 2 || result.Deleted[0] != "1.1.1.1" || result.Deleted[1] != "2.2.2.2" {
t.Fatalf("expected both real addresses deleted, got %+v", result)
}
if len(result.NotFound) != 1 || result.NotFound[0] != "no-such-address" {
t.Fatalf("expected unknown address in NotFound, got %+v", result)
}
ips, err := d.ListIPs(ctx)
if err != nil {
t.Fatalf("list ips: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected empty queue, got %+v", ips)
}
}