a73x

internal/transport/fieldnumbers_test.go

Ref:   Size: 12.2 KiB   History

package transport

import (
	"fmt"
	"testing"

	"github.com/a73x/eitri/internal/pb"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/reflect/protoreflect"
	"google.golang.org/protobuf/reflect/protoregistry"
)

// A proto field's NUMBER is its wire identity: it is what a peer that predates a
// field reads past, and what an agent one release behind matches its known
// fields against. Renaming a field is a source change; RENUMBERING one is a wire
// change that no compiler and no `make proto-check` can see — the code builds,
// the generated Go regenerates cleanly, and every fielded agent silently reads
// the moved field as absent (see contract_test.go for what "absent" then means:
// a dropped exposure set, an uncertified host key, a guest stranded ephemeral).
//
// These two tests make a renumber loud. The first LOCKS every field number in
// the wire schema against a written-down table, so no number can move — or field
// appear or vanish — without this table and a human moving with it. The second
// proves the table is not lying by exercising every field through a real
// round-trip, so a field the wire never actually carries cannot hide behind a
// table entry.

// wireSchema is the committed truth: every message in proto/eitri/v1/sync.proto,
// every field, and the number it is pinned to. Reserved numbers are deliberately
// absent — a reserved slot carries nothing, and re-using one is exactly the
// mistake this table exists to catch (a new field on an old number reads, on a
// fielded agent, as whatever used to live there). Update this table ONLY in the
// same change that edits the .proto, and only ever by adding — never by moving.
var wireSchema = map[string]map[string]protoreflect.FieldNumber{
	"AgentMessage": {
		"hello":          1,
		"report":         2,
		"console_opened": 3,
		"tcp_opened":     4,
	},
	"ServerMessage": {
		"snapshot":     1,
		"console_open": 2,
		"tcp_open":     3,
	},
	"Hello": {
		"host_id":         1,
		"hostname":        2,
		"os":              3,
		"arch":            4,
		"provisioner":     5,
		"last_seen_epoch": 7,
		"capacity":        8,
		"credential":      9,
		"facts":           10,
		"host_networks":   11,
	},
	"Capacity": {
		"vcpus":   1,
		"mem_mb":  2,
		"disk_gb": 3,
	},
	"HostFacts": {
		"os_id":         1,
		"os_pretty":     2,
		"os_version":    3,
		"kernel":        4,
		"cpu_model":     5,
		"virt":          6,
		"agent_version": 7,
	},
	"HostMetrics": {
		"uptime_s":         1,
		"mem_used_mb":      2,
		"mem_available_mb": 3,
		"load1":            4,
		"load5":            5,
		"load15":           6,
		"disk_used_gb":     7,
		"disk_free_gb":     8,
	},
	"VMStatus": {
		"vm_id":           1,
		"power_state":     2,
		"phase":           3,
		"ip":              4,
		"last_error":      5,
		"ssh_host_pubkey": 6,
		"status_detail":   7,
		"network_ip":      8,
	},
	"QuarantinedVM": {
		"vm_id":           1,
		"name":            2,
		"vmspec_json":     3,
		"destroy_at_unix": 4,
	},
	"Report": {
		"vms":              1,
		"destroyed":        2,
		"quarantined":      3,
		"capacity":         4,
		"fence_violation":  5,
		"last_seen_epoch":  6,
		"metrics":          7,
		"guest_cidr":       8,
		"exposures":        9,
		"host_uplink_addr": 10,
		"volumes":          11,
	},
	"VMSpec": {
		"vm_id":                       1,
		"name":                        2,
		"image_url":                   3,
		"image_sha256":                4,
		"cloud_init":                  5,
		"vcpus":                       6,
		"mem_mb":                      7,
		"disk_gb":                     8,
		"persistent":                  9,
		"power_state":                 10,
		"tombstoned":                  11,
		"ssh_authorized_key":          12,
		"ssh_host_cert":               17,
		"ssh_user_ca_authorized_keys": 18,
		"host_cert_required":          19,
		"network":                     20,
		"volume_ids":                  21,
	},
	"Snapshot": {
		"epoch":             1,
		"vms":               2,
		"agent_upgrade":     3,
		"exposures":         4,
		"volumes":           5,
		"min_agent_version": 6,
	},
	"AgentUpgrade": {
		"version": 1,
		"url":     2,
		"sha256":  3,
	},
	"ConsoleOpen": {
		"vm_id": 1,
	},
	"ConsoleOpened": {
		"ok":    1,
		"error": 2,
	},
	"TCPOpen": {
		"vm_id": 1,
		"port":  2,
	},
	"TCPOpened": {
		"ok":    1,
		"error": 2,
	},
	"ExposureSpec": {
		"id":         1,
		"vm_id":      2,
		"guest_port": 3,
		"host_port":  4,
		"protocol":   5,
	},
	"ExposureStatus": {
		"id":       1,
		"state":    2,
		"reason":   3,
		"sessions": 4,
	},
	"ExposureSessions": {
		"active":  1,
		"refused": 2,
		"dropped": 3,
	},
	"VolumeSpec": {
		"volume_id":  1,
		"size_gb":    2,
		"tombstoned": 3,
	},
	"VolumeStatus": {
		"volume_id": 1,
		"present":   2,
		"size_gb":   3,
	},
}

// TestWireFieldNumbersAreLocked walks every message in the compiled wire schema
// and asserts its field numbers, exactly, against wireSchema. It fails three
// ways, each the tripwire it is meant to be: a field whose number moved, a field
// added to the .proto without a table entry, and a table entry for a field the
// .proto no longer has. Because it enumerates the descriptors rather than the
// table, no message and no field can slip the check by being left out.
func TestWireFieldNumbersAreLocked(t *testing.T) {
	fd := pb.File_proto_eitri_v1_sync_proto
	require.NotNil(t, fd, "wire file descriptor is not registered")

	msgs := fd.Messages()
	seen := make(map[string]bool, msgs.Len())

	for i := 0; i < msgs.Len(); i++ {
		md := msgs.Get(i)
		name := string(md.Name())
		seen[name] = true

		want, ok := wireSchema[name]
		if !assert.Truef(t, ok, "message %s is in the wire schema but not in wireSchema — add its field numbers to the table", name) {
			continue
		}

		fields := md.Fields()
		got := make(map[string]protoreflect.FieldNumber, fields.Len())
		for j := 0; j < fields.Len(); j++ {
			f := fields.Get(j)
			got[string(f.Name())] = f.Number()
		}

		for fname, wantNum := range want {
			gotNum, present := got[fname]
			if !assert.Truef(t, present, "%s.%s is in wireSchema but not in the .proto", name, fname) {
				continue
			}
			assert.Equalf(t, wantNum, gotNum, "%s.%s field number moved: schema says %d, wire says %d", name, fname, wantNum, gotNum)
		}
		for fname := range got {
			assert.Containsf(t, want, fname, "%s.%s is a new field with no wireSchema entry — pin its number in the table", name, fname)
		}
	}

	for name := range wireSchema {
		assert.Truef(t, seen[name], "wireSchema has message %s that the .proto no longer defines", name)
	}
}

// TestEveryFieldSurvivesARoundTrip is the completeness half: it proves wireSchema
// is not a comfortable fiction. contract_test.go round-trips hand-written
// literals, so a field simply left out of a literal round-trips trivially and is
// never tested — the very gap that let a renumber pass unseen. Here, for every
// message and every field, we set that ONE field to a distinct non-zero sentinel
// by reflection, marshal it through the real wire codec, read it back, and assert
// the field came back set and equal. A field wired to a number no peer expects
// would decode as absent and fail proto.Equal; a field the schema forgot cannot
// be forgotten here because we iterate the descriptors, not a list.
//
// One field at a time (rather than every field at once) is deliberate: it sets
// oneof members without them evicting each other, it needs no per-message
// knowledge of which fields conflict, and it pins each number independently, so
// two same-typed fields swapping numbers is caught by distinct sentinels rather
// than masked by a shared value.
func TestEveryFieldSurvivesARoundTrip(t *testing.T) {
	fd := pb.File_proto_eitri_v1_sync_proto
	msgs := fd.Messages()

	for i := 0; i < msgs.Len(); i++ {
		md := msgs.Get(i)
		fields := md.Fields()
		for j := 0; j < fields.Len(); j++ {
			f := fields.Get(j)
			t.Run(fmt.Sprintf("%s/%s", md.Name(), f.Name()), func(t *testing.T) {
				in := newMessage(t, md)
				setSentinel(t, in, f)

				out := newMessage(t, md)
				roundTrip(t, in.Interface(), out.Interface())

				assert.Truef(t, out.Has(f),
					"%s.%s did not survive the round-trip — its wire number is not the one the codec reads",
					md.Name(), f.Name())
				assert.Truef(t, proto.Equal(in.Interface(), out.Interface()),
					"%s.%s round-tripped to a different value:\n in=%v\nout=%v",
					md.Name(), f.Name(), in.Interface(), out.Interface())
			})
		}
	}
}

// newMessage makes a fresh, empty message for a descriptor, using its registered
// Go type so proto.Marshal/Unmarshal and proto.Equal all operate on the real
// generated types rather than a dynamic stand-in.
//
//nolint:ireturn // protoreflect.Message IS the interface the reflection API returns; there is no concrete type here.
func newMessage(t *testing.T, md protoreflect.MessageDescriptor) protoreflect.Message {
	t.Helper()
	mt, err := protoregistry.GlobalTypes.FindMessageByName(md.FullName())
	require.NoErrorf(t, err, "no registered Go type for %s", md.FullName())
	return mt.New()
}

// setSentinel sets exactly field f of m to a distinct, non-zero value. For a
// message or repeated field it builds a minimally-populated value so the field
// is present on the wire; the point is that the NUMBER carries, not that nested
// content is exhaustive (each nested message is exercised in full as its own
// top-level case).
func setSentinel(t *testing.T, m protoreflect.Message, f protoreflect.FieldDescriptor) {
	t.Helper()
	switch {
	case f.IsList():
		list := m.NewField(f).List()
		list.Append(scalarSentinel(t, f))
		m.Set(f, protoreflect.ValueOfList(list))
	case f.IsMap():
		// No map fields exist in this schema; fail loudly if one is added so
		// this guard is extended rather than silently skipping the field.
		t.Fatalf("%s is a map field — extend setSentinel to cover maps", f.FullName())
	default:
		m.Set(f, scalarSentinel(t, f))
	}
}

// scalarSentinel returns a distinct non-zero value for one (non-list) element of
// f's element type.
func scalarSentinel(t *testing.T, f protoreflect.FieldDescriptor) protoreflect.Value {
	t.Helper()
	switch f.Kind() {
	case protoreflect.BoolKind:
		return protoreflect.ValueOfBool(true)
	case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind:
		return protoreflect.ValueOfInt32(int32(f.Number()) + 1)
	case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
		return protoreflect.ValueOfInt64(int64(f.Number()) + 1)
	case protoreflect.Uint32Kind, protoreflect.Fixed32Kind:
		return protoreflect.ValueOfUint32(uint32(f.Number()) + 1)
	case protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
		return protoreflect.ValueOfUint64(uint64(f.Number()) + 1)
	case protoreflect.FloatKind:
		return protoreflect.ValueOfFloat32(float32(f.Number()) + 1.5)
	case protoreflect.DoubleKind:
		return protoreflect.ValueOfFloat64(float64(f.Number()) + 1.5)
	case protoreflect.StringKind:
		return protoreflect.ValueOfString(fmt.Sprintf("sentinel-%s-%d", f.Name(), f.Number()))
	case protoreflect.BytesKind:
		return protoreflect.ValueOfBytes([]byte(fmt.Sprintf("sentinel-%s-%d", f.Name(), f.Number())))
	case protoreflect.EnumKind:
		// No enums in this schema; pick the first non-zero value if one is ever
		// added, else fail so the guard is extended deliberately.
		vals := f.Enum().Values()
		if vals.Len() < 2 {
			t.Fatalf("%s is an enum with no non-zero value — extend scalarSentinel", f.FullName())
		}
		return protoreflect.ValueOfEnum(vals.Get(1).Number())
	case protoreflect.MessageKind, protoreflect.GroupKind:
		nested := newMessage(t, f.Message())
		populateOne(t, nested)
		return protoreflect.ValueOfMessage(nested)
	default:
		t.Fatalf("%s has unhandled kind %v — extend scalarSentinel", f.FullName(), f.Kind())
		return protoreflect.Value{}
	}
}

// populateOne sets a single non-zero field on a nested message so that the
// message is non-empty on the wire (an all-zero nested message would still marshal
// to a present-but-empty field, but a populated one is a stronger witness). It is
// intentionally shallow: full field coverage of every message comes from that
// message's own top-level cases.
func populateOne(t *testing.T, m protoreflect.Message) {
	t.Helper()
	fields := m.Descriptor().Fields()
	if fields.Len() == 0 {
		return
	}
	setSentinel(t, m, fields.Get(0))
}