1package brokerwire
2
3// Preview contract tests (broker preview slice 1).
4//
5// StartPreview carries tags 112 with scope, generation, the observed
6// daemon route (never a stream identity), and the terminal preview request; CancelPreview
7// carries tag 113 with scope and generation. PreviewPublication carries
8// tag 210 with scope, generation, and exactly one of preview/error via
9// its result oneof. These tests pin the stateless codec's half of that
10// contract: frozen tags, byte round trips, the strict oneof presence
11// rule, exact authority preservation, range-strict narrowing, and the
12// ports preview validation parity (routes that do not own the target
13// refused).
14
15import (
16 "testing"
17
18 renderer "github.com/bnema/vev-vt"
19 "google.golang.org/protobuf/encoding/protowire"
20 "google.golang.org/protobuf/proto"
21
22 "github.com/bnema/vev/internal/domain"
23 "github.com/bnema/vev/internal/ports"
24 "github.com/bnema/vev/internal/protocol"
25 "github.com/bnema/vev/internal/protocol/wire"
26 "github.com/stretchr/testify/require"
27)
28
29func testPreviewTarget() domain.RemoteSessionTarget {
30 return domain.RemoteSessionTarget{
31 Endpoint: "dev@host:22", DisplayOrigin: "dev@host",
32 LifecycleID: domain.SessionLifecycleID{5, 6, 7},
33 SessionName: "work", LiveTabID: "tab-1",
34 }
35}
36
37func testPreviewRequest() protocol.RemotePreviewRequest {
38 return protocol.RemotePreviewRequest{
39 Version: protocol.RemotePreviewSchemaVersion,
40 Target: testPreviewTarget(),
41 Width: 2, Height: 1,
42 }
43}
44
45func testPreviewRoute() ports.BrokerPreviewRoute {
46 return ports.BrokerPreviewRoute{Endpoint: "dev@host:22", Registration: testRegistration(), Policy: testPolicy()}
47}
48
49func testStartPreview(connection ports.BrokerConnectionID) StartPreview {
50 return StartPreview{
51 Epoch: 7, Connection: connection, Generation: 3,
52 Route: testPreviewRoute(), Preview: testPreviewRequest(),
53 }
54}
55
56func testLocalStartPreview(connection ports.BrokerConnectionID) StartPreview {
57 m := testStartPreview(connection)
58 m.Route = ports.BrokerPreviewRoute{Local: true, Policy: testPolicy()}
59 m.Preview.Target.Endpoint = ports.BrokerPreviewLocalEndpoint
60 m.Preview.Target.DisplayOrigin = ports.BrokerPreviewLocalEndpoint
61 return m
62}
63
64func testRemotePreview() protocol.RemotePreview {
65 return protocol.RemotePreview{
66 Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK,
67 LifecycleID: testPreviewTarget().LifecycleID, TabID: testPreviewTarget().LiveTabID,
68 Revision: 11, Width: 2, Height: 1,
69 Cells: []renderer.Cell{{Rune: 'a'}, {Rune: 'b'}},
70 }
71}
72
73func testPreviewPublication(connection ports.BrokerConnectionID) PreviewPublication {
74 return PreviewPublication{
75 Epoch: 7, Connection: connection, Generation: 3,
76 Preview: testRemotePreview(),
77 }
78}
79
80// TestPreviewVariantTags pins the frozen envelope tags: start_preview 112,
81// cancel_preview 113, preview_publication 210.
82func TestPreviewVariantTags(t *testing.T) {
83 connection := testConnectionID(0x61)
84 raw := mustEncodeClient(t, testStartPreview(connection))
85 number, wireType, consumed := protowire.ConsumeTag(raw)
86 require.Greater(t, consumed, 0)
87 require.Equal(t, protowire.Number(112), number)
88 require.Equal(t, protowire.BytesType, wireType)
89
90 raw = mustEncodeClient(t, CancelPreview{Epoch: 7, Connection: connection, Generation: 3})
91 number, _, _ = protowire.ConsumeTag(raw)
92 require.Equal(t, protowire.Number(113), number)
93
94 raw = mustEncodeServer(t, testPreviewPublication(connection))
95 number, wireType, consumed = protowire.ConsumeTag(raw)
96 require.Greater(t, consumed, 0)
97 require.Equal(t, protowire.Number(210), number)
98 require.Equal(t, protowire.BytesType, wireType)
99}
100
101// TestPreviewByteRoundTrip proves all three preview messages survive an
102// encode/decode/re-encode cycle byte for byte, in both success and typed
103// error publication shapes.
104func TestPreviewByteRoundTrip(t *testing.T) {
105 connection := testConnectionID(0x62)
106 clients := []struct {
107 name string
108 message ClientMessage
109 }{
110 {"start_preview", testStartPreview(connection)},
111 {"start_preview_local", testLocalStartPreview(connection)},
112 {"cancel_preview", CancelPreview{Epoch: 7, Connection: connection, Generation: 5}},
113 }
114 for _, tc := range clients {
115 t.Run(tc.name, func(t *testing.T) {
116 raw := mustEncodeClient(t, tc.message)
117 decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
118 require.NoError(t, err)
119 require.Equal(t, tc.message, decoded)
120 again, err := EncodeClient(decoded, testEnvelopeCeiling, testChunkCeiling)
121 require.NoError(t, err)
122 require.Equal(t, raw, again, "re-encode must be byte-for-byte identical")
123 })
124 }
125
126 connection = testConnectionID(0x63)
127 servers := []struct {
128 name string
129 message PreviewPublication
130 }{
131 {"success", testPreviewPublication(connection)},
132 {"failed", PreviewPublication{
133 Epoch: 7, Connection: connection, Generation: 4,
134 Error: testErrorDetail(), HasError: true,
135 }},
136 }
137 for _, tc := range servers {
138 t.Run(tc.name, func(t *testing.T) {
139 raw := mustEncodeServer(t, tc.message)
140 decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
141 require.NoError(t, err)
142 require.Equal(t, tc.message, decoded)
143 again, err := EncodeServer(decoded, testEnvelopeCeiling, testChunkCeiling)
144 require.NoError(t, err)
145 require.Equal(t, raw, again, "re-encode must be byte-for-byte identical")
146 })
147 }
148}
149
150// TestPreviewRequestValidationParity proves the codec refuses exactly what
151// ports.BrokerPreviewRequest.Validate refuses: a route that does not own the
152// target, a remote route without matching registration, a zero generation,
153// and an invalid preview request.
154func TestPreviewRequestValidationParity(t *testing.T) {
155 connection := testConnectionID(0x64)
156 base := testStartPreview(connection)
157 local := testLocalStartPreview(connection)
158 cases := map[string]func() StartPreview{
159 "local route for remote target": func() StartPreview {
160 m := base
161 m.Route = ports.BrokerPreviewRoute{Local: true, Policy: testPolicy()}
162 return m
163 },
164 "remote route for local target": func() StartPreview {
165 m := local
166 m.Route = testPreviewRoute()
167 return m
168 },
169 "local route carries registration": func() StartPreview {
170 m := local
171 m.Route.Registration = testRegistration()
172 return m
173 },
174 "remote route without registration": func() StartPreview {
175 m := base
176 m.Route.Registration = domain.RemoteRegistration{}
177 return m
178 },
179 "remote route endpoint mismatch": func() StartPreview {
180 m := base
181 m.Route.Endpoint = "other@host:22"
182 return m
183 },
184 "target on another host": func() StartPreview {
185 m := base
186 m.Preview.Target.Endpoint = "other@host:22"
187 return m
188 },
189 "zero generation": func() StartPreview {
190 m := base
191 m.Generation = 0
192 return m
193 },
194 "invalid preview dimensions": func() StartPreview {
195 m := base
196 m.Preview.Width = 0
197 return m
198 },
199 "stopped preview target": func() StartPreview {
200 m := base
201 m.Preview.Target.Stopped = true
202 m.Preview.Target.LiveTabID = ""
203 return m
204 },
205 }
206 for name, mutate := range cases {
207 t.Run(name, func(t *testing.T) {
208 // The candidate must already fail the ports gate, so the codec
209 // refusal is parity, not invention.
210 require.Error(t, previewRequest(mutate()).Validate())
211 _, err := EncodeClient(mutate(), testEnvelopeCeiling, testChunkCeiling)
212 require.ErrorIs(t, err, ErrInvalidMessage)
213 })
214 }
215
216 decodeCases := map[string]func(*wire.PreviewRoute){
217 "missing route": func(r *wire.PreviewRoute) { *r = wire.PreviewRoute{} },
218 "missing policy": func(r *wire.PreviewRoute) { r.Policy = nil },
219 "local with registration": func(r *wire.PreviewRoute) { r.Local = true },
220 "remote without registration": func(r *wire.PreviewRoute) { r.Registration = nil },
221 }
222 for name, mutate := range decodeCases {
223 t.Run("decode "+name, func(t *testing.T) {
224 raw := mustEncodeClient(t, base)
225 envelope := &wire.BrokerClientEnvelope{}
226 require.NoError(t, wire.ScanEnvelope(envelope, raw))
227 require.NoError(t, proto.Unmarshal(raw, envelope))
228 mutate(envelope.GetStartPreview().GetRoute())
229 mutated, err := proto.Marshal(envelope)
230 require.NoError(t, err)
231 _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
232 require.Error(t, err)
233 })
234 }
235}
236
237// TestPreviewPublicationResultPresence proves exactly one of preview/error
238// travels: a publication missing both results, setting a nil result
239// member, or carrying a viewport alongside its error is refused, and a
240// nonzero ErrorDetail without HasError is refused rather than dropped.
241func TestPreviewPublicationResultPresence(t *testing.T) {
242 connection := testConnectionID(0x65)
243
244 t.Run("absent result refused", func(t *testing.T) {
245 message := &wire.PreviewPublication{
246 Scope: &wire.BrokerScope{BrokerEpoch: 7, ConnectionId: connection[:]},
247 Generation: 3,
248 }
249 raw, err := proto.Marshal(&wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_PreviewPublication{PreviewPublication: message}})
250 require.NoError(t, err)
251 _, err = DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
252 require.ErrorIs(t, err, ErrInvalidMessage)
253 })
254
255 t.Run("nil result members refused", func(t *testing.T) {
256 // A nil oneof member fails structurally before semantic validation
257 // selects its error: either refusal proves the payload never decodes.
258 for _, message := range []*wire.PreviewPublication{
259 {
260 Scope: &wire.BrokerScope{BrokerEpoch: 7, ConnectionId: connection[:]},
261 Generation: 3, Result: &wire.PreviewPublication_Preview{},
262 },
263 {
264 Scope: &wire.BrokerScope{BrokerEpoch: 7, ConnectionId: connection[:]},
265 Generation: 3, Result: &wire.PreviewPublication_Error{},
266 },
267 } {
268 raw, err := proto.Marshal(&wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_PreviewPublication{PreviewPublication: message}})
269 require.NoError(t, err)
270 _, err = DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
271 require.Error(t, err)
272 }
273 })
274
275 t.Run("error with viewport refused", func(t *testing.T) {
276 published := PreviewPublication{
277 Epoch: 7, Connection: connection, Generation: 3,
278 Preview: testRemotePreview(), Error: testErrorDetail(), HasError: true,
279 }
280 _, err := EncodeServer(published, testEnvelopeCeiling, testChunkCeiling)
281 require.ErrorIs(t, err, ErrInvalidMessage)
282 })
283
284 t.Run("dropped detail refused", func(t *testing.T) {
285 published := testPreviewPublication(connection)
286 published.Error = testErrorDetail()
287 _, err := EncodeServer(published, testEnvelopeCeiling, testChunkCeiling)
288 require.ErrorIs(t, err, ErrInvalidMessage)
289 })
290}
291
292// TestPreviewNarrowingRefused proves every narrowing cast on the preview
293// path is range-strict: values above uint16 aliasing a valid enum or
294// dimension are refused before any cast.
295func TestPreviewNarrowingRefused(t *testing.T) {
296 connection := testConnectionID(0x66)
297 base := testStartPreview(connection)
298 raw := mustEncodeClient(t, base)
299 envelope := &wire.BrokerClientEnvelope{}
300 require.NoError(t, wire.ScanEnvelope(envelope, raw))
301 require.NoError(t, proto.Unmarshal(raw, envelope))
302
303 mutate := func(apply func(*wire.BrokerClientEnvelope)) []byte {
304 clone := proto.Clone(envelope).(*wire.BrokerClientEnvelope)
305 apply(clone)
306 mutated, err := proto.Marshal(clone)
307 require.NoError(t, err)
308 return mutated
309 }
310 for _, tc := range []struct {
311 name string
312 mutate func(*wire.BrokerClientEnvelope)
313 }{
314 // 257 aliases RemotePreviewUnavailable (1) under truncation.
315 {"preview version", func(e *wire.BrokerClientEnvelope) { e.GetStartPreview().GetPreview().Version = 1<<16 + 1 }},
316 {"preview width", func(e *wire.BrokerClientEnvelope) { e.GetStartPreview().GetPreview().Width = 1<<16 + 1 }},
317 {"preview height", func(e *wire.BrokerClientEnvelope) { e.GetStartPreview().GetPreview().Height = 1<<16 + 80 }},
318 } {
319 t.Run(tc.name, func(t *testing.T) {
320 _, err := DecodeClient(mutate(tc.mutate), testEnvelopeCeiling, testChunkCeiling)
321 require.ErrorIs(t, err, errConvertRange)
322 })
323 }
324
325 published := mustEncodeServer(t, testPreviewPublication(connection))
326 serverEnvelope := &wire.BrokerServerEnvelope{}
327 require.NoError(t, wire.ScanEnvelope(serverEnvelope, published))
328 require.NoError(t, proto.Unmarshal(published, serverEnvelope))
329 serverEnvelope.GetPreviewPublication().GetPreview().Status = 1<<16 + 1
330 mutated, err := proto.Marshal(serverEnvelope)
331 require.NoError(t, err)
332 _, err = DecodeServer(mutated, testEnvelopeCeiling, testChunkCeiling)
333 require.ErrorIs(t, err, errConvertRange)
334
335 t.Run("truncated and trailing", func(t *testing.T) {
336 for _, message := range []struct {
337 name string
338 encode func() []byte
339 decode func([]byte) (any, error)
340 }{
341 {"start", func() []byte { return mustEncodeClient(t, base) }, func(raw []byte) (any, error) {
342 return DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
343 }},
344 {"publication", func() []byte { return mustEncodeServer(t, testPreviewPublication(connection)) }, func(raw []byte) (any, error) {
345 return DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
346 }},
347 } {
348 raw := message.encode()
349 for size := range len(raw) {
350 _, err := message.decode(raw[:size])
351 require.Error(t, err, "%s prefix[:%d] accepted", message.name, size)
352 }
353 _, err := message.decode(append(append([]byte(nil), raw...), 0xFF))
354 require.Error(t, err)
355 }
356 })
357}
358
359// TestPreviewWrongDirection proves preview payloads fail closed across
360// directions: client payloads never decode as server messages and the
361// reverse.
362func TestPreviewWrongDirection(t *testing.T) {
363 connection := testConnectionID(0x67)
364 _, err := EncodeServer(unknownServerMessage{}, testEnvelopeCeiling, testChunkCeiling)
365 require.ErrorIs(t, err, ErrWrongDirection)
366 _, err = EncodeClient(unknownClientMessage{}, testEnvelopeCeiling, testChunkCeiling)
367 require.ErrorIs(t, err, ErrWrongDirection)
368
369 raw := mustEncodeClient(t, testStartPreview(connection))
370 _, err = DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
371 require.Error(t, err)
372 raw = mustEncodeServer(t, testPreviewPublication(connection))
373 _, err = DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
374 require.Error(t, err)
375}