Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions relay/contract_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
44 changes: 26 additions & 18 deletions relay/protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
}
})
}
}

Expand Down