226 lines
7.7 KiB
Go
226 lines
7.7 KiB
Go
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))
|
|
}
|
|
}
|
|
|
|
func TestRecordDeviceStatus(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
if err := s.UpsertDevice(ctx, model.Device{Name: "fw1", Class: model.ClassFirewall}); err != nil {
|
|
t.Fatalf("upsert device: %v", err)
|
|
}
|
|
if d, _ := s.GetDevice(ctx, "fw1"); d.AgentStatus != nil {
|
|
t.Fatalf("unreported device should have no agent_status: %+v", d.AgentStatus)
|
|
}
|
|
|
|
report := func(r model.StatusReport) model.AgentStatus {
|
|
t.Helper()
|
|
if err := r.Normalize(); err != nil {
|
|
t.Fatalf("normalize: %v", err)
|
|
}
|
|
if err := s.RecordDeviceStatus(ctx, "fw1", r); err != nil {
|
|
t.Fatalf("record %+v: %v", r, err)
|
|
}
|
|
d, err := s.GetDevice(ctx, "fw1")
|
|
if err != nil || d.AgentStatus == nil {
|
|
t.Fatalf("get device: %v %+v", err, d.AgentStatus)
|
|
}
|
|
return *d.AgentStatus
|
|
}
|
|
|
|
// Old agent payload: generation only.
|
|
got := report(model.StatusReport{Generation: 5})
|
|
if got.AppliedGeneration != 5 || got.Status != model.StatusApplied || got.Generation != 5 || got.ReportedAt.IsZero() {
|
|
t.Errorf("old payload: %+v", got)
|
|
}
|
|
|
|
got = report(model.StatusReport{Status: model.StatusApplied, Generation: 6})
|
|
if got.AppliedGeneration != 6 || got.Status != model.StatusApplied {
|
|
t.Errorf("applied: %+v", got)
|
|
}
|
|
|
|
got = report(model.StatusReport{Status: model.StatusReverted, Generation: 7, Error: "api unreachable"})
|
|
if got.AppliedGeneration != 6 || got.Status != model.StatusReverted || got.Generation != 7 || got.Error != "api unreachable" {
|
|
t.Errorf("reverted must not advance applied generation: %+v", got)
|
|
}
|
|
|
|
got = report(model.StatusReport{Status: model.StatusFailed, Generation: 8, Error: "nft: syntax error"})
|
|
if got.AppliedGeneration != 6 || got.Status != model.StatusFailed || got.Generation != 8 || got.Error != "nft: syntax error" {
|
|
t.Errorf("failed must not advance applied generation: %+v", got)
|
|
}
|
|
|
|
got = report(model.StatusReport{Status: model.StatusApplied, Generation: 9})
|
|
if got.AppliedGeneration != 9 || got.Status != model.StatusApplied || got.Generation != 9 {
|
|
t.Errorf("applied 9: %+v", got)
|
|
}
|
|
|
|
got = report(model.StatusReport{Status: model.StatusApplied, Generation: 4})
|
|
if got.AppliedGeneration != 9 || got.Status != model.StatusApplied || got.Generation != 9 {
|
|
t.Errorf("older applied must not regress: %+v", got)
|
|
}
|
|
|
|
got = report(model.StatusReport{Status: model.StatusReverted, Generation: 8, Error: "stale"})
|
|
if got.AppliedGeneration != 9 || got.Status != model.StatusApplied || got.Generation != 9 || got.Error != "" {
|
|
t.Errorf("older reverted must not overwrite newer status: %+v", got)
|
|
}
|
|
|
|
got = report(model.StatusReport{Status: model.StatusReverted, Generation: 9, Error: "lost api"})
|
|
if got.AppliedGeneration != 9 || got.Status != model.StatusReverted || got.Generation != 9 || got.Error != "lost api" {
|
|
t.Errorf("same-generation reverted after applied must update: %+v", got)
|
|
}
|
|
|
|
if err := s.RecordDeviceStatus(ctx, "fw1", model.StatusReport{Status: "bogus", Generation: 10}); err == nil {
|
|
t.Error("db should reject unknown status")
|
|
}
|
|
if err := s.RecordDeviceStatus(ctx, "nope", model.StatusReport{Status: model.StatusApplied}); err != store.ErrNotFound {
|
|
t.Errorf("unknown device: want ErrNotFound, got %v", err)
|
|
}
|
|
}
|