Files
DDI/server/internal/service/source_test.go
T
sir_melonchenandClaude Opus 5.5 2b04661792 feat: clusters, TSIG keys, zones and records in service and REST API
Implements the M5 endpoints of the contract (ADR 0023): clusters with
member validation, transfer keys and HA credentials, TSIG keys with
audited secret access and rotation, explicit zones and typed records,
the DHCP cluster of networks, the cluster of agents and the default
clusters and HA password file of the generator settings. Revisions are
stored per target with sealed secret files and sealed source secrets
(restored for rollbacks); bundles carry an agent's own target or the
default group. Active leases are adopted as dynamic addresses.

The derivation is called through a temporary adapter until derive
implements the M5 contract.

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

241 lines
9.5 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"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/secrets"
"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 || orig.fileCount(RoleDNS) == 0 || orig.fileCount(RoleDHCP) == 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, Keyring: testKeyring(t)})
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, sealed, 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) || src.Version != configgen.SourceVersion { //nolint:gosec // small
t.Errorf("stored serial %d, version %d", src.Serial, src.Version)
}
if err := svc.restoreSourceSecrets(rev.Number, sealed, &src); err != nil {
t.Fatal(err)
}
stored, err := svc.loadRevisionFiles(ctx, st.Q(), 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")
}
}
func testKeyring(t *testing.T) *secrets.Keyring {
t.Helper()
data, err := secrets.GenerateKeyFile("k1")
if err != nil {
t.Fatal(err)
}
kr, err := secrets.ParseKeyFile(data)
if err != nil {
t.Fatal(err)
}
return kr
}
// TestSourceSecrets: secrets never enter the JSON of the source; they are
// sealed separately and restored for rollbacks.
func TestSourceSecrets(t *testing.T) {
svc := New(Options{Keyring: testKeyring(t)})
src := testSource(3)
src.Keys = []configgen.KeySource{{ID: "k1", Name: "transfer", Algorithm: "hmac-sha256", Secret: "c2VjcmV0LWtleQ=="}}
src.Clusters = []configgen.ClusterSource{
{ID: "c1", Name: "DNS", Role: RoleDNS, TransferKeyID: "k1"},
{ID: "c2", Name: "DHCP", Role: RoleDHCP, HAMode: configgen.HAHotStandby, HAPort: 8001, HAUser: "ha",
HAPassword: "ha-password-value"},
}
raw, err := json.Marshal(src)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("c2VjcmV0LWtleQ==")) || bytes.Contains(raw, []byte("ha-password-value")) {
t.Fatalf("secret in the source JSON: %s", raw)
}
sealed, err := svc.sealSourceSecrets(3, src)
if err != nil || bytes.Contains(sealed, []byte("ha-password-value")) {
t.Fatalf("seal: %v", err)
}
var back configgen.Source
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
if err := svc.restoreSourceSecrets(3, sealed, &back); err != nil {
t.Fatal(err)
}
if back.Keys[0].Secret != src.Keys[0].Secret || back.Clusters[1].HAPassword != src.Clusters[1].HAPassword {
t.Errorf("restored %+v %+v", back.Keys, back.Clusters)
}
unavailable := func(err error) bool {
var se *Error
return errors.As(err, &se) && se.Code == ErrRollbackUnavailable.Code
}
// Bound to the revision number.
if err := svc.restoreSourceSecrets(4, sealed, &back); !unavailable(err) {
t.Errorf("other revision: %v", err)
}
// Missing secrets make the rollback impossible.
if err := svc.restoreSourceSecrets(3, nil, &back); !unavailable(err) {
t.Errorf("no secrets: %v", err)
}
// Sources before M5 have none and need none.
old := testSource(3)
if err := svc.restoreSourceSecrets(3, nil, &old); err != nil {
t.Errorf("M4 source: %v", err)
}
if s, err := svc.sealSourceSecrets(3, old); s != nil || err != nil {
t.Errorf("M4 source sealed %v %v", s, err)
}
}