Files
DDI/server/internal/service/source_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 00b9ffb65b feat(server): operating modes, push deployments, rollback, diff, pending changes and SSE
- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
  ApplyRevisionCommand with the deployment ID, re-sends open pushes on
  connect and announces them in GetDesiredState; reports with the ID
  complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
  with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:47 +02:00

168 lines
7.0 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"bytes"
"context"
"encoding/json"
"net/netip"
"reflect"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
)
func addrPtr(s string) *netip.Addr {
a := netip.MustParseAddr(s)
return &a
}
func intPtr(v int) *int { return &v }
// testSource covers the types that must survive JSON: netip values (also
// zero values and pointers), maps, nil and empty slices, times.
func testSource(serial uint32) configgen.Source {
at := time.Date(2026, 10, 13, 12, 0, 0, 123456000, time.FixedZone("CEST", 2*3600))
return configgen.Source{
Serial: serial,
Networks: []ipam.Network{
{ID: "n1", CIDR: netip.MustParsePrefix("192.0.2.0/24"), Name: "Office", Gateway: addrPtr("192.0.2.1"),
Domain: "office.test", SearchDomains: []string{"office.test", "example.test"}, DHCPEnabled: true,
LeaseTime: intPtr(7200), DNSServers: []netip.Addr{netip.MustParseAddr("192.0.2.53")},
NTPServers: []netip.Addr{}, Pools: []ipam.Pool{{Start: netip.MustParseAddr("192.0.2.100"), End: netip.MustParseAddr("192.0.2.199")}},
Version: 3, CreatedAt: at, UpdatedAt: at},
{ID: "n2", CIDR: netip.MustParsePrefix("2001:db8:1::/64"), Name: "v6", Domain: "v6.test"},
{ID: "n3", CIDR: netip.MustParsePrefix("198.51.100.0/25"), Name: "Lab", DHCPEnabled: true},
},
Addresses: []ipam.Address{
{ID: "a1", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.53"), Type: ipam.TypeStatic, Hostname: "ns1",
FQDN: "ns1.office.test", Tags: []string{"dns"}, CreatedAt: at, UpdatedAt: at},
{ID: "a2", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.10"), Type: ipam.TypeManualDHCP,
MAC: "00:11:22:33:44:55", Hostname: "printer", FQDN: "printer.office.test", LastSeen: &at},
{ID: "a3", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.11"), Type: ipam.TypeManualDHCP, Hostname: "nomac"},
{ID: "a4", NetworkID: "n2", IP: netip.MustParseAddr("2001:db8:1::10"), Type: ipam.TypeStatic, Hostname: "web",
FQDN: "web.v6.test"},
{ID: "a5", NetworkID: "n1", IP: netip.MustParseAddr("192.0.2.12"), Type: ipam.TypeReserved},
},
SubnetIDs: map[string]uint32{"n1": 1, "n2": 2},
DNS: configgen.DNSSettings{
ZoneDir: "/var/lib/bind/ddi-suite",
Options: configgen.DNSOptions{Recursion: true, AllowRecursion: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, Forwarders: []netip.Addr{netip.MustParseAddr("9.9.9.9")}},
DefaultTTL: 3600,
SOA: configgen.SOA{PrimaryNS: "ns1.office.test", Hostmaster: "hostmaster.office.test", Refresh: 3600, Retry: 600, Expire: 604800, Minimum: 300},
NameServers: []configgen.NameServer{{Name: "ns1.office.test", Addrs: []netip.Addr{netip.MustParseAddr("192.0.2.53")}},
{Name: "ns2.example.net"}},
},
DHCP: configgen.DHCPSettings{Interfaces: []string{"eth0"}, DirectClients: true, ControlSocket: "/run/kea/kea4-ctrl-socket",
LeaseFile: "/var/lib/kea/kea-leases4.csv", ValidLifetime: 3600},
}
}
func sameOutput(t *testing.T, a, b *Generated) {
t.Helper()
if !sameFiles(revisionFiles(a), revisionFiles(b)) {
t.Error("rendered files differ")
}
if !reflect.DeepEqual(a.Warnings, b.Warnings) || !reflect.DeepEqual(a.DNSError, b.DNSError) || !reflect.DeepEqual(a.DHCPError, b.DHCPError) {
t.Errorf("warnings or errors differ: %+v / %+v", a, b)
}
}
func TestSourceJSONRoundTrip(t *testing.T) {
src := testSource(7)
orig := render(src)
if orig.DNSError != nil || orig.DHCPError != nil || len(orig.DNS) == 0 || len(orig.DHCP) == 0 || len(orig.Warnings) == 0 {
t.Fatalf("test source does not exercise the generator: %+v", orig)
}
raw, err := json.Marshal(src)
if err != nil {
t.Fatal(err)
}
var back configgen.Source
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
sameOutput(t, orig, render(back))
again, _ := json.Marshal(back)
if !bytes.Equal(raw, again) {
t.Errorf("JSON is not stable:\n%s\n%s", raw, again)
}
if back.Networks[0].Gateway == nil || *back.Networks[0].Gateway != *src.Networks[0].Gateway ||
back.Networks[1].Gateway != nil || back.Networks[0].NTPServers == nil || back.Networks[1].DNSServers != nil ||
back.SubnetIDs["n2"] != 2 || !back.Networks[0].CreatedAt.Equal(src.Networks[0].CreatedAt) {
t.Errorf("values changed: %+v", back.Networks)
}
// Another serial changes only the SOA serial.
back.Serial = 8
if sameFiles(revisionFiles(orig), revisionFiles(render(back))) {
t.Error("serial did not change the zones")
}
}
// TestStoredSourceReRendersIdentically: the source stored with a revision
// (through jsonb) renders the revision's files byte for byte.
func TestStoredSourceReRendersIdentically(t *testing.T) {
st := storetest.New(t)
ctx := context.Background()
svc := New(Options{Store: st})
a := SystemActor
g := DefaultGeneratorSettings()
g.DNS.PrimaryNS = "ns1.office.test"
g.DNS.Hostmaster = "hostmaster@office.test"
g.DNS.NameServers = []GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}}
g.DNS.Recursion = true
g.DNS.AllowRecursion = []string{"192.0.2.0/24", "2001:db8::/32"}
g.DNS.Forwarders = []string{"9.9.9.9"}
if _, err := svc.SetGeneratorSettings(ctx, a, g); err != nil {
t.Fatal(err)
}
n4, err := svc.CreateNetwork(ctx, a, NetworkInput{CIDR: netip.MustParsePrefix("192.0.2.0/24"), Name: "Office",
Gateway: addrPtr("192.0.2.1"), Domain: "office.test", DHCPEnabled: true, LeaseTime: intPtr(7200),
DNSServers: []netip.Addr{netip.MustParseAddr("192.0.2.53")}, SearchDomains: []string{"office.test"},
Pools: []PoolDTO{{Start: netip.MustParseAddr("192.0.2.100"), End: netip.MustParseAddr("192.0.2.199")}}})
if err != nil {
t.Fatal(err)
}
n6, err := svc.CreateNetwork(ctx, a, NetworkInput{CIDR: netip.MustParsePrefix("2001:db8:1::/64"), Name: "v6", Domain: "v6.test"})
if err != nil {
t.Fatal(err)
}
for _, in := range []AddressInput{
{NetworkID: n4.ID, IP: netip.MustParseAddr("192.0.2.53"), Type: ipam.TypeStatic, Hostname: "ns1"},
{NetworkID: n4.ID, IP: netip.MustParseAddr("192.0.2.10"), Type: ipam.TypeManualDHCP, MAC: "00:11:22:33:44:55", Hostname: "printer"},
{NetworkID: n4.ID, IP: netip.MustParseAddr("192.0.2.150"), Type: ipam.TypeReserved},
{NetworkID: n6.ID, IP: netip.MustParseAddr("2001:db8:1::10"), Type: ipam.TypeStatic, Hostname: "web"},
} {
if _, err := svc.CreateAddress(ctx, a, in); err != nil {
t.Fatalf("address %v: %v", in.IP, err)
}
}
rev, created, err := svc.CreateRevision(ctx, a, "")
if err != nil || !created {
t.Fatalf("revision: %v %v", created, err)
}
raw, err := st.Q().RevisionSource(ctx, rev.Number)
if err != nil || raw == nil {
t.Fatalf("source %s %v", raw, err)
}
var src configgen.Source
if err := json.Unmarshal(raw, &src); err != nil {
t.Fatal(err)
}
if src.Serial != uint32(rev.Number) { //nolint:gosec // small
t.Errorf("stored serial %d", src.Serial)
}
stored, err := st.Q().RevisionFiles(ctx, rev.Number, "")
if err != nil {
t.Fatal(err)
}
if !sameFiles(revisionFiles(render(src)), stored) {
t.Error("the stored source does not render the stored files")
}
}