a73x

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),
	})
}