diff --git a/relay/contract_test.go b/relay/contract_test.go new file mode 100644 index 0000000..925eb38 --- /dev/null +++ b/relay/contract_test.go @@ -0,0 +1,98 @@ +package relay + +import ( + "encoding/json" + "reflect" + "testing" +) + +// Device-flow states are values on the HTTP control-plane wire. The literals +// here record what independently released agents and relays already exchange; +// comparing the constants only with each other would let both uses drift. +func TestDeviceStatusValuesArePinnedToTheirWireLiterals(t *testing.T) { + for _, tc := range []struct{ name, got, want string }{ + {"DeviceStatusPending", DeviceStatusPending, "pending"}, + {"DeviceStatusExpired", DeviceStatusExpired, "expired"}, + {"DeviceStatusApproved", DeviceStatusApproved, "approved"}, + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want the deployed control-plane wire value %q", tc.name, tc.got, tc.want) + } + } +} + +// All 27 JSON tag occurrences in contract.go are shared HTTP control-plane +// contracts between the api and system binaries, so none are excluded. This is +// the complete inventory, grouped by DTO: +// +// - SystemInfo (5): id, name, hostname, online, ip_addr +// - PortInfo (3): project, port, label +// - ProjectInfo (4): id, name, root_dir, ports +// - PortEntry (3): id, port, label +// - DeviceStartRequest (2): client_id, hostname +// - DeviceStartResponse (5): user_code, device_code, verification_url, +// expires_in, interval +// - DevicePollRequest (1): device_code +// - DevicePollResponse (1): status +// - ProvisionResponse (3): systemId, token, name +// +// Each populated value is encoded against hand-written JSON and that same +// literal is decoded independently. A same-struct round trip would stay green +// if a tag changed and therefore cannot guard this contract. +func TestControlPlaneDTOTagsArePinnedToLiteralPayloads(t *testing.T) { + t.Run("SystemInfo", func(t *testing.T) { + assertLiteralJSON(t, `{"id":"sys-1","name":"Desk","hostname":"desk.local","online":true,"ip_addr":"192.0.2.1"}`, + SystemInfo{ID: "sys-1", Name: "Desk", Hostname: "desk.local", Online: true, IPAddr: "192.0.2.1"}) + }) + t.Run("PortInfo", func(t *testing.T) { + assertLiteralJSON(t, `{"project":"ormos","port":8080,"label":"web"}`, + PortInfo{Project: "ormos", Port: 8080, Label: "web"}) + }) + t.Run("ProjectInfo", func(t *testing.T) { + assertLiteralJSON(t, `{"id":"proj-1","name":"ormos","root_dir":"/code/ormos","ports":[{"id":"port-1","port":8080,"label":"web"}]}`, + ProjectInfo{ID: "proj-1", Name: "ormos", RootDir: "/code/ormos", Ports: []PortEntry{{ID: "port-1", Port: 8080, Label: "web"}}}) + }) + t.Run("PortEntry", func(t *testing.T) { + assertLiteralJSON(t, `{"id":"port-1","port":8080,"label":"web"}`, + PortEntry{ID: "port-1", Port: 8080, Label: "web"}) + }) + t.Run("DeviceStartRequest", func(t *testing.T) { + assertLiteralJSON(t, `{"client_id":"client-1","hostname":"desk.local"}`, + DeviceStartRequest{ClientID: "client-1", Hostname: "desk.local"}) + }) + t.Run("DeviceStartResponse", func(t *testing.T) { + assertLiteralJSON(t, `{"user_code":"ABCD-EFGH","device_code":"device-1","verification_url":"https://ormos.dev/device","expires_in":600,"interval":5}`, + DeviceStartResponse{UserCode: "ABCD-EFGH", DeviceCode: "device-1", VerificationURL: "https://ormos.dev/device", ExpiresIn: 600, Interval: 5}) + }) + t.Run("DevicePollRequest", func(t *testing.T) { + assertLiteralJSON(t, `{"device_code":"device-1"}`, + DevicePollRequest{DeviceCode: "device-1"}) + }) + t.Run("DevicePollResponse", func(t *testing.T) { + assertLiteralJSON(t, `{"status":"approved","systemId":"sys-1","token":"pairing-token","name":"Desk"}`, + DevicePollResponse{Status: "approved", ProvisionResponse: ProvisionResponse{SystemID: "sys-1", Token: "pairing-token", Name: "Desk"}}) + }) + t.Run("ProvisionResponse", func(t *testing.T) { + assertLiteralJSON(t, `{"systemId":"sys-1","token":"pairing-token","name":"Desk"}`, + ProvisionResponse{SystemID: "sys-1", Token: "pairing-token", Name: "Desk"}) + }) +} + +func assertLiteralJSON[T any](t *testing.T, wire string, want T) { + t.Helper() + encoded, err := json.Marshal(want) + if err != nil { + t.Fatalf("encode DTO: %v", err) + } + if string(encoded) != wire { + t.Errorf("DTO marshalled to\n %s\nwant the recorded wire payload\n %s", encoded, wire) + } + + var got T + if err := json.Unmarshal([]byte(wire), &got); err != nil { + t.Fatalf("decode recorded DTO payload: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("recorded DTO payload decoded to\n %+v\nwant\n %+v", got, want) + } +} diff --git a/relay/protocol_test.go b/relay/protocol_test.go index 3d5769f..98ab5e8 100644 --- a/relay/protocol_test.go +++ b/relay/protocol_test.go @@ -83,8 +83,9 @@ func TestCurrentAgentAdvertisesOnlyV2(t *testing.T) { // The wire values of four groups of protocol string, pinned to their literals: // the stream-fence versions, the StreamKinds, the ActionAck statuses, and the -// terminal seal's HKDF label. Not every string in this package — see the list of -// what is NOT pinned, below, which is the boundary this test does not cross. +// terminal seal's HKDF label. This test covers exactly those groups; device-flow +// statuses are pinned in contract_test.go, and JSON tags and numeric terminal +// tags are pinned against complete literal payloads in their dedicated tests. // // TestCurrentAgentAdvertisesOnlyV2 above pins the fence version's alias // RELATIONSHIP (StreamFenceVersion == StreamFenceVersionV2) and the empty-string @@ -152,13 +153,11 @@ func TestCurrentAgentAdvertisesOnlyV2(t *testing.T) { // - whether the agent still SENDS any of them. That is a different gap, and // for the two handshake headers it is covered by // TestAgentDialAdvertisesItsKeyAndFenceVersion in internal/system. -// - the rest of this package's wire strings, which are a real and open gap -// rather than a decision that they do not matter: contract.go's -// DeviceStatusPending/Expired/Approved and its 27 DTO JSON tags, the -// Resize and activityFrame tags one screen below StreamHeader, and the -// numeric termTag values. All of them are tracked on -// nicodes/ormos-be#433. Nothing above guards any of them, and a reader who -// needs one of them guarded must add it rather than assume this table did. +// - contract.go's DeviceStatus values and 27 DTO JSON tags, Resize and +// activityFrame JSON tags, and numeric termTag values. Nothing above guards +// them; TestDeviceStatusValuesArePinnedToTheirWireLiterals, +// TestControlPlaneDTOTagsArePinnedToLiteralPayloads, and +// TestTerminalFramesArePinnedToLiteralBytes guard them separately. func TestWireStringValuesArePinnedToTheirLiterals(t *testing.T) { for _, tc := range []struct{ name, got, want string }{ {"StreamFenceVersionV1", StreamFenceVersionV1, "1"}, @@ -450,15 +449,24 @@ func TestTerminalFrameRoundTrip(t *testing.T) { } } -// The wire format must match the TypeScript encoder byte for byte, so pin it: -// one tag byte, four big-endian length bytes, then the payload. -func TestTerminalFrameLayout(t *testing.T) { - got := EncodeData(bytes.Repeat([]byte{7}, 258)) - if got[0] != 0 { - t.Fatalf("data tag = %d, want 0", got[0]) - } - if !bytes.Equal(got[1:5], []byte{0, 0, 1, 2}) { - t.Fatalf("length bytes = %v, want big-endian 258", got[1:5]) +// The terminal wire format must match the browser byte for byte. These +// hand-written payloads pin all three numeric tags and the resize/activity JSON +// keys without decoding through the same constants or structs being checked. +func TestTerminalFramesArePinnedToLiteralBytes(t *testing.T) { + for _, tc := range []struct { + name string + got []byte + want []byte + }{ + {"tagData and multi-byte length", EncodeData(bytes.Repeat([]byte{7}, 258)), append([]byte{0, 0, 0, 1, 2}, bytes.Repeat([]byte{7}, 258)...)}, + {"tagResize and cols/rows", EncodeResize(120, 40), append([]byte{1, 0, 0, 0, 22}, []byte(`{"cols":120,"rows":40}`)...)}, + {"tagActivity and active", EncodeActivity(true), append([]byte{2, 0, 0, 0, 15}, []byte(`{"active":true}`)...)}, + } { + t.Run(tc.name, func(t *testing.T) { + if !bytes.Equal(tc.got, tc.want) { + t.Errorf("terminal frame bytes = %v (%q), want literal wire bytes %v (%q)", tc.got, tc.got, tc.want, tc.want) + } + }) } }