package store_test import ( "context" "testing" "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" "git.unkin.net/unkin/tomswallapi/internal/database" "git.unkin.net/unkin/tomswallapi/internal/model" "git.unkin.net/unkin/tomswallapi/internal/store" ) // newTestStore spins up a throwaway Postgres, applies migrations, and returns a // Store. It skips (rather than fails) when Docker is unavailable, so the default // `go test -short` run in CI passes without a container runtime. func newTestStore(t *testing.T) *store.Store { t.Helper() if testing.Short() { t.Skip("skipping container-backed test in -short mode") } ctx := context.Background() pg, err := postgres.Run(ctx, "postgres:17-alpine", postgres.WithDatabase("tomswallapi"), postgres.WithUsername("tomswallapi"), postgres.WithPassword("tomswallapi"), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2).WithStartupTimeout(60*time.Second)), ) if err != nil { t.Skipf("skipping: cannot start postgres container (Docker unavailable?): %v", err) } t.Cleanup(func() { _ = pg.Terminate(ctx) }) dsn, err := pg.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("connection string: %v", err) } db, err := database.New(ctx, dsn) if err != nil { t.Fatalf("connect: %v", err) } t.Cleanup(db.Close) if err := db.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } return store.New(db.Pool) } func TestZoneLifecycleAndGeneration(t *testing.T) { s := newTestStore(t) ctx := context.Background() gen0, err := s.Generation(ctx) if err != nil { t.Fatalf("generation: %v", err) } if err := s.UpsertZone(ctx, model.Zone{Name: "zone-a", Type: "ip", Subnets: []string{"10.1.0.0/24"}}); err != nil { t.Fatalf("upsert zone: %v", err) } gen1, _ := s.Generation(ctx) if gen1 <= gen0 { t.Errorf("generation should bump on write: %d -> %d", gen0, gen1) } got, err := s.GetZone(ctx, "zone-a") if err != nil { t.Fatalf("get zone: %v", err) } if len(got.Subnets) != 1 || got.Subnets[0] != "10.1.0.0/24" { t.Errorf("subnets round-trip failed: %+v", got) } zones, err := s.ListZones(ctx) if err != nil || len(zones) != 1 { t.Fatalf("list zones = %d (%v)", len(zones), err) } if err := s.DeleteZone(ctx, "zone-a"); err != nil { t.Fatalf("delete zone: %v", err) } if _, err := s.GetZone(ctx, "zone-a"); err != store.ErrNotFound { t.Errorf("expected ErrNotFound after delete, got %v", err) } gen2, _ := s.Generation(ctx) if gen2 <= gen1 { t.Errorf("generation should bump on delete: %d -> %d", gen1, gen2) } } func TestRuleGrammarValidationAtStore(t *testing.T) { s := newTestStore(t) ctx := context.Background() // Valid rule with a paired selector. if _, err := s.CreateRule(ctx, model.Rule{ Action: "accept", Source: []string{"loc"}, Dest: []string{"net:+cloudflare"}, }); err != nil { t.Fatalf("valid rule rejected: %v", err) } // Bare selector must be rejected before it reaches the DB. if _, err := s.CreateRule(ctx, model.Rule{ Action: "accept", Source: []string{"+cloudflare"}, Dest: []string{"loc"}, }); err == nil { t.Fatal("bare selector rule should be rejected") } } func TestNATTierRoundTrip(t *testing.T) { s := newTestStore(t) ctx := context.Background() // nat references a device via FK. if err := s.UpsertDevice(ctx, model.Device{Name: "fw-a", Class: model.ClassFirewall}); err != nil { t.Fatalf("upsert device: %v", err) } sid, err := s.CreateSNAT(ctx, model.SNATRule{Action: "masquerade", Source: "loc", Egress: "net"}) if err != nil { t.Fatalf("create snat: %v", err) } if got, err := s.GetSNAT(ctx, sid); err != nil || got.Action != "masquerade" { t.Errorf("snat round-trip: %+v (%v)", got, err) } nid, err := s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.10", Internal: "10.1.0.10"}) if err != nil { t.Fatalf("create nat: %v", err) } if err := s.DeleteNAT(ctx, nid); err != nil { t.Fatalf("delete nat: %v", err) } if err := s.DeleteNAT(ctx, nid); err != store.ErrNotFound { t.Errorf("second delete should be ErrNotFound, got %v", err) } // Deleting the device cascades to its nat rows. _, _ = s.CreateNAT(ctx, model.NATRule{Device: "fw-a", External: "203.0.113.11", Internal: "10.1.0.11"}) if err := s.DeleteDevice(ctx, "fw-a"); err != nil { t.Fatalf("delete device: %v", err) } nats, _ := s.ListNAT(ctx) if len(nats) != 0 { t.Errorf("expected nat rows to cascade-delete with device, got %d", len(nats)) } }