internal/server/api/wire_golden_test.go
Ref: Size: 10.0 KiB History
package api
import (
"bytes"
"encoding/json"
"flag"
"os"
"path/filepath"
"testing"
"time"
"github.com/a73x/eitri/internal/server/api/types"
)
var updateGolden = flag.Bool("update", false, "rewrite golden wire fixtures")
// goldenCheck marshals v (indented, deterministic) and compares it to
// testdata/<name>.golden.json. The fixtures byte-pin the HTTP wire contract:
// the contract-extraction refactor must not change a single byte.
func goldenCheck(t *testing.T, name string, v any) {
t.Helper()
got, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatalf("marshal %s: %v", name, err)
}
got = append(got, '\n')
path := filepath.Join("testdata", name+".golden.json")
if *updateGolden {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s (run with -update to create): %v", path, err)
}
if !bytes.Equal(got, want) {
t.Errorf("%s: wire bytes changed\ngot:\n%s\nwant:\n%s", name, got, want)
}
}
// TestWireGolden byte-pins the JSON wire shape of every DTO the HTTP API
// serves or accepts. Every field carries a distinctive non-zero value so a
// dropped field, renamed tag, or swapped tag shows up as a byte diff.
func TestWireGolden(t *testing.T) {
base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
lastSeen := base.Add(30 * time.Second)
sinceLastSeen := int64(42)
host := types.Host{
ID: "h-1234",
Name: "mewtwo",
OS: "linux",
Arch: "amd64",
Provisioner: "cloudhypervisor",
BridgeCIDR: "10.77.1.0/24",
Status: "active",
EnrolledAt: base,
Online: true,
LastSeen: &lastSeen,
SecondsSinceLastSeen: &sinceLastSeen,
Stale: true,
Sessions: 3,
Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512},
Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100},
AgentVersion: "v0.0.1-agent",
AgentUpdateAvailable: true,
PendingUpgrade: &types.PendingUpgrade{Version: "v0.0.2-agent", AgeS: 12},
OSID: "arch",
OSPretty: "Arch Linux",
OSVersion: "rolling",
Kernel: "6.15.4-arch1-1",
CPUModel: "AMD Ryzen 9 7950X",
Virt: "kvm",
UplinkAddr: "192.168.0.190",
HostNetworks: []string{"lan", "lab"},
Metrics: &types.Metrics{
UptimeS: 86400,
MemUsedMB: 12000,
MemAvailableMB: 20768,
Load1: 1.5,
Load5: 2.5,
Load15: 3.5,
DiskUsedGB: 200,
DiskFreeGB: 312,
},
}
goldenCheck(t, "host", host)
vm := types.VM{
ID: "v-5678",
HostID: "h-1234",
Name: "sandbox-abc123",
ImageURL: "https://images.example.com/resolute.img",
VCPUs: 2,
MemMB: 2048,
DiskGB: 10,
PowerState: "running",
Status: "ready",
LastError: "boot timeout",
AssignedIP: "10.77.1.2",
Network: "lan",
NetworkIP: "192.168.0.42",
CreatedAt: base.Add(time.Minute),
Deleted: true,
ActualPower: "stopped",
Phase: "creating",
StatusDetail: "downloading image 1.2/3.7 GiB",
DestroyAt: 1785153600,
Lifecycle: "deleting",
InjectedKey: &types.InjectedKey{
Type: "ssh-ed25519",
Fingerprint: "SHA256:0000000000000000000000000000000000000000000",
Comment: "alex@laptop",
},
TrustedCAs: &[]types.TrustedCA{{
Label: "laptop-ca",
Fingerprint: "SHA256:1111111111111111111111111111111111111111111",
}},
}
goldenCheck(t, "vm", vm)
// TrustedCAs' null-vs-empty distinction is load-bearing (see the field's doc
// in types.go): null means the VM predates the record and its trusted set was
// never written down, while an empty list cannot occur — create refuses a
// tenant with no CA. A client must not conflate them, so both marshalings are
// pinned here, not only the populated case in "vm" above. null must serialise
// as JSON null (present, not [] and not omitted); a non-nil empty slice as [].
vmTrustUnknown := vm
vmTrustUnknown.TrustedCAs = nil
goldenCheck(t, "vm-trusted-cas-null", vmTrustUnknown)
vmTrustEmpty := vm
emptyCAs := []types.TrustedCA{}
vmTrustEmpty.TrustedCAs = &emptyCAs
goldenCheck(t, "vm-trusted-cas-empty", vmTrustEmpty)
goldenCheck(t, "snapshot", types.StateSnapshot{
Hosts: []types.Host{host},
VMs: []types.VM{vm},
ServerVersion: "v0.0.1-test",
LatestVersion: "v0.0.2-test",
})
goldenCheck(t, "audit", []types.AuditEvent{{
At: base.Add(2 * time.Minute),
Action: "vm.create",
Detail: json.RawMessage(`{"vm_id":"v-1"}`),
}})
goldenCheck(t, "revoked-cert", []types.RevokedCert{{
Serial: "18446744073709551615",
RevokedAt: base.Add(3 * time.Minute),
Reason: "key compromised",
}})
// bridge_cidr carries a proposal here so the golden pins the field's
// presence and its pointer shape; nil and "" are separately meaningful and
// covered by the store's own tests.
proposal := "10.42.0.0/24"
goldenCheck(t, "enroll-request", types.EnrollRequest{
BridgeCIDR: &proposal,
Token: "tok-secret-01",
Name: "host-nine",
OS: "linux",
Arch: "arm64",
Provisioner: "cloudhypervisor",
})
goldenCheck(t, "create-vm-request", types.CreateVMRequest{
HostID: "h-1234",
Name: "worker-7",
ImageURL: "https://images.example.com/resolute.img",
ImageSHA256: "deadbeefcafe",
CloudInit: "#cloud-config\npackages: [git]",
SSHAuthorizedKey: "ssh-ed25519 AAAAC3Nza key-comment",
PowerState: "running",
VCPUs: 4,
MemMB: 4096,
DiskGB: 20,
Network: "lan",
VolumeClaims: []string{"project-data", "scratch"},
})
goldenCheck(t, "patch-vm-request", types.PatchVMRequest{
PowerState: "stopped",
})
goldenCheck(t, "user-ca-request", types.UserCARequest{
PublicKey: "ssh-ed25519 AAAAC3Nza ca-comment",
Label: "team-alpha-ca",
})
serial := uint64(9007199254740993)
goldenCheck(t, "revoke-cert-request", types.RevokeSSHCertRequest{
Serial: &serial,
Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment",
Reason: "rotated out",
})
// The named response shapes that replaced handlers' inline
// map[string]string literals (their declarations in the types package
// explain the byte-compatible field ordering).
goldenCheck(t, "enroll-response", types.EnrollResponse{
BridgeCIDR: "10.77.1.0/24",
Credential: "cred-opaque-01",
HostID: "h-1234",
ServerCertSHA256: "cafebabef00d",
})
goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{
Join: "eyJqb2luIjoiYmxvYiJ9",
Token: "tok-secret-01",
})
goldenCheck(t, "create-vm-response", types.CreateVMResponse{
ID: "v-5678",
Name: "sandbox-abc123",
})
goldenCheck(t, "ssh-ca-response", types.SSHCAResponse{
CA: "ssh-ed25519 AAAAC3Nza eitri-host-ca",
})
goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{
Ticket: "tkt-onetime-01",
})
goldenCheck(t, "user-ca-upload-response", types.UserCAUploadResponse{
Fingerprint: "SHA256:abcdefghijk",
})
goldenCheck(t, "delegation-challenge", types.DelegationChallenge{
PublicKey: "ssh-ed25519 AAAAC3Nza eitri-delegation",
Principal: "ubuntu",
Instructions: "ssh-keygen -s <your-ca-key> -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub",
})
goldenCheck(t, "delegation-request", types.DelegationRequest{
Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAdelegation alex@laptop",
})
goldenCheck(t, "delegation", types.Delegation{
PublicKey: "ssh-ed25519 AAAAC3Nza eitri-delegation",
CAFingerprint: "SHA256:abcdefghijk",
KeyID: "eitri-delegation",
Serial: "12345678901234567890",
Principals: []string{"ubuntu"},
ExpiresAt: "2026-08-08T12:00:00Z",
})
goldenCheck(t, "user-ca-list", []types.UserCA{{
Fingerprint: "SHA256:abcdefghijk",
Label: "team-alpha-ca",
PubKey: "ssh-ed25519 AAAAC3Nza ca-comment",
}})
goldenCheck(t, "me", types.Me{
Email: "alex@emery.xyz",
SSHGate: "gate.eitri.sh:2222",
Tenant: "alex",
})
goldenCheck(t, "create-api-token-request", types.CreateAPITokenRequest{
Name: "boot-gate",
TTLSeconds: 3600,
})
goldenCheck(t, "create-api-token-response", types.CreateAPITokenResponse{
ExpiresAt: "2026-07-27T13:00:00Z",
ID: "tok-abc123",
Name: "boot-gate",
Token: "eitri_pat_deadbeef",
})
goldenCheck(t, "api-token-list", []types.APIToken{{
CreatedAt: "2026-07-27T12:00:00Z",
ExpiresAt: "2026-07-27T13:00:00Z",
ID: "tok-abc123",
LastUsedAt: "2026-07-27T12:30:00Z",
Name: "boot-gate",
RevokedAt: "",
}})
goldenCheck(t, "create-exposure-request", types.CreateExposureRequest{
GuestPort: 8080,
HostPort: 30080,
Protocol: "udp",
})
goldenCheck(t, "exposure", []types.Exposure{{
ID: "x-9012",
VMID: "v-5678",
HostID: "h-1234",
GuestPort: 8080,
HostPort: 30080,
HostAddr: "192.168.0.190",
Protocol: "tcp",
Scope: "lan",
State: "failed",
Reason: "listen tcp 0.0.0.0:30080: bind: address already in use",
Sessions: &types.ExposureSessions{Active: 7, Refused: 12, Dropped: 3},
CreatedAt: base.Add(4 * time.Minute),
}})
goldenCheck(t, "create-volume-claim-request", types.CreateVolumeClaimRequest{
Name: "project-data",
SizeGB: 50,
})
// The PENDING exemplar, deliberately: it is the shape with the most null in
// it, and each null is load-bearing. host_id and vm_id are "" because
// nothing has placed or attached it, and present is null because no host
// has been asked about a volume that does not exist yet — null is not
// false, and a client must not render "missing" for "unreported".
goldenCheck(t, "volume-claim", types.VolumeClaim{
ID: "c-3456",
Name: "project-data",
SizeGB: 50,
Status: "pending",
CreatedAt: base.Add(5 * time.Minute),
})
}