]> git.giacomolli.uk - onehouse/blob - protocol/message_test.go
Define independent M31 module address ranges
[onehouse] / protocol / message_test.go
1 package protocol
2
3 import (
4 "encoding/json"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/require"
10 )
11
12 func TestPayloadValidationRejectsInvalidContracts(t *testing.T) {
13 deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
14 metadata := Metadata{
15 MessageID: messageID,
16 ExpiresAt: &deadline,
17 }
18 descriptor := `{"manufacturer":"example","model":"relay","firmware":"1","capabilities":[{"id":"relay","type":"switch","reports_state":true,"accepts_commands":false}]}`
19 testCases := []struct {
20 name string
21 kind Kind
22 target string
23 payload string
24 }{
25 {
26 name: "rejects unknown descriptor field",
27 kind: Descriptor,
28 payload: strings.Replace(descriptor, `"model":"relay"`, `"model":"relay","room":"kitchen"`, 1),
29 },
30 {
31 name: "rejects unknown capability field",
32 kind: Descriptor,
33 payload: strings.Replace(descriptor, `"type":"switch"`, `"type":"switch","extra":true`, 1),
34 },
35 {
36 name: "requires both access flags",
37 kind: Descriptor,
38 payload: strings.Replace(descriptor, `,"accepts_commands":false`, ``, 1),
39 },
40 {
41 name: "rejects null access flag",
42 kind: Descriptor,
43 payload: strings.Replace(descriptor, `"accepts_commands":false`, `"accepts_commands":null`, 1),
44 },
45 {
46 name: "requires active access mode",
47 kind: Descriptor,
48 payload: strings.Replace(descriptor, `"reports_state":true`, `"reports_state":false`, 1),
49 },
50 {
51 name: "requires device metadata",
52 kind: Descriptor,
53 payload: strings.Replace(descriptor, `"model":"relay"`, `"model":""`, 1),
54 },
55 {
56 name: "requires capabilities",
57 kind: Descriptor,
58 payload: `{"manufacturer":"example","model":"relay","firmware":"1","capabilities":[]}`,
59 },
60 {
61 name: "rejects invalid capability identifier",
62 kind: Descriptor,
63 payload: strings.Replace(descriptor, `"id":"relay"`, `"id":"relay/1"`, 1),
64 },
65 {
66 name: "rejects duplicate capability",
67 kind: Descriptor,
68 payload: strings.Replace(descriptor, `}]`, `},{"id":"relay","type":"switch","reports_state":true,"accepts_commands":false}]`, 1),
69 },
70 {
71 name: "rejects unknown availability status",
72 kind: Availability,
73 payload: `{"status":"ready"}`,
74 },
75 {
76 name: "rejects unknown availability field",
77 kind: Availability,
78 payload: `{"status":"online","extra":1}`,
79 },
80 {
81 name: "requires report value",
82 kind: Reported,
83 target: "relay",
84 payload: `{}`,
85 },
86 {
87 name: "rejects unknown report field",
88 kind: Reported,
89 target: "relay",
90 payload: `{"value":true,"extra":1}`,
91 },
92 {
93 name: "requires telemetry value",
94 kind: Telemetry,
95 target: "power",
96 payload: `{}`,
97 },
98 {
99 name: "rejects unknown telemetry field",
100 kind: Telemetry,
101 target: "power",
102 payload: `{"value":1,"extra":1}`,
103 },
104 {
105 name: "rejects unknown command field",
106 kind: Command,
107 target: "relay",
108 payload: `{"value":true,"extra":1}`,
109 },
110 {
111 name: "requires command value",
112 kind: Command,
113 target: "relay",
114 payload: `{}`,
115 },
116 {
117 name: "rejects array event",
118 kind: Event,
119 target: "press",
120 payload: `[]`,
121 },
122 {
123 name: "rejects unknown result field",
124 kind: Result,
125 target: commandID,
126 payload: `{"status":"completed","extra":1}`,
127 },
128 {
129 name: "rejects unknown hub field",
130 kind: HubStatus,
131 payload: `{"id":"` + deviceID + `","status":"ready","address":"example.com","extra":1}`,
132 },
133 {
134 name: "rejects unknown diagnostic field",
135 kind: HubStatus,
136 payload: `{"id":"` + deviceID + `","status":"ready","address":"example.com","diagnostics":{"observed_at":"2026-01-01T00:00:00Z","stale":false,"extra":1}}`,
137 },
138 {
139 name: "rejects unknown pairing field",
140 kind: HubStatus,
141 payload: `{"id":"` + deviceID + `","status":"ready","address":"example.com","pairing":{"extra":1}}`,
142 },
143 {
144 name: "rejects hub identity mismatch",
145 kind: HubStatus,
146 payload: `{"id":"` + commandID + `","status":"ready","address":"example.com"}`,
147 },
148 {
149 name: "rejects invalid hub status",
150 kind: HubStatus,
151 payload: `{"id":"` + deviceID + `","status":"online","address":"example.com"}`,
152 },
153 }
154
155 for _, tc := range testCases {
156 t.Run(tc.name, func(t *testing.T) {
157 data, err := encodeEnvelope(metadata, json.RawMessage(tc.payload))
158 if tc.kind == Event {
159 require.Error(t, err)
160
161 return
162 }
163
164 require.NoError(t, err)
165
166 topic, err := (Address{
167 Kind: tc.kind,
168 ID: deviceID,
169 Target: tc.target,
170 }).topic()
171 require.NoError(t, err)
172
173 _, err = Parse(topic, data, false)
174 require.Error(t, err)
175 })
176 }
177 }
178
179 func TestBuildersApplyCommandAndResultRules(t *testing.T) {
180 metadata := Metadata{MessageID: messageID}
181 _, err := BuildCommand(deviceID, "relay", metadata, ValuePayload{Value: json.RawMessage(`true`)})
182 require.Error(t, err)
183
184 _, err = BuildHubStatus(deviceID, metadata, HubStatusPayload{
185 ID: deviceID,
186 Address: "example.com",
187 Status: "ready",
188 })
189 require.Error(t, err)
190
191 _, err = BuildDescriptor("bad", metadata, testDescriptor())
192 require.Error(t, err)
193
194 _, err = BuildReported(deviceID, "relay", metadata, ValuePayload{Value: json.RawMessage(`bad`)})
195 require.Error(t, err)
196
197 _, err = BuildAvailability(deviceID, metadata, AvailabilityPayload{Status: "bad"})
198 require.Error(t, err)
199
200 for _, value := range []string{`null`, `true`, `12`, `"open"`, `{"mode":"off"}`, `[1,2]`} {
201 var publication Publication
202
203 publication, err = BuildReported(deviceID, "point", metadata, ValuePayload{Value: json.RawMessage(value)})
204 require.NoError(t, err)
205
206 var message Message[ValuePayload]
207
208 message, err = ParseReported(publication.Topic, publication.Data, false)
209 require.NoError(t, err)
210 require.JSONEq(t, value, string(message.Payload.Value))
211 }
212
213 testCases := []struct {
214 name string
215 status string
216 reason string
217 valid bool
218 }{
219 {
220 name: "accepts intermediate result",
221 status: "accepted",
222 valid: true,
223 },
224 {
225 name: "accepts completed result",
226 status: "completed",
227 valid: true,
228 },
229 {
230 name: "accepts rejection reason",
231 status: "rejected",
232 reason: "unsupported",
233 valid: true,
234 },
235 {
236 name: "accepts failure reason",
237 status: "failed",
238 reason: "readback",
239 valid: true,
240 },
241 {
242 name: "requires rejection reason",
243 status: "rejected",
244 },
245 {
246 name: "requires failure reason",
247 status: "failed",
248 },
249 {
250 name: "rejects successful reason",
251 status: "completed",
252 reason: "extra",
253 },
254 {
255 name: "rejects unknown result status",
256 status: "expired",
257 },
258 }
259
260 for _, tc := range testCases {
261 t.Run(tc.name, func(t *testing.T) {
262 correlated := Metadata{
263 MessageID: messageID,
264 CorrelationID: commandID,
265 }
266
267 var publication Publication
268
269 publication, err = BuildResult(deviceID, commandID, correlated, ResultPayload{
270 Status: tc.status,
271 Reason: tc.reason,
272 })
273 if !tc.valid {
274 require.Error(t, err)
275
276 return
277 }
278
279 require.NoError(t, err)
280
281 var parsed Message[ResultPayload]
282
283 parsed, err = ParseResult(publication.Topic, publication.Data, false)
284 require.NoError(t, err)
285 require.Equal(t, tc.status, parsed.Payload.Status)
286 })
287 }
288
289 _, err = BuildResult(deviceID, commandID, metadata, ResultPayload{Status: "completed"})
290 require.Error(t, err)
291
292 descriptor := testDescriptor()
293 descriptor.Attribution = strings.Repeat("x", 2001)
294 _, err = BuildDescriptor(deviceID, metadata, descriptor)
295 require.Error(t, err)
296 }
297
298 func TestBuildersKeepNullValuesWithoutChangingInputs(t *testing.T) {
299 metadata := Metadata{MessageID: messageID}
300 payload := ValuePayload{}
301 publication, err := BuildReported(deviceID, "relay", metadata, payload)
302 require.NoError(t, err)
303 require.Nil(t, payload.Value)
304
305 parsed, err := ParseReported(publication.Topic, publication.Data, false)
306 require.NoError(t, err)
307 require.JSONEq(t, `null`, string(parsed.Payload.Value))
308
309 deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
310 batch := testBatch(deadline)
311 batch.Commands[0].Payload.Value = nil
312 publication, err = BuildCommandBatch(deviceID, metadata, batch)
313 require.NoError(t, err)
314 require.Nil(t, batch.Commands[0].Payload.Value)
315
316 commands, err := ParseCommandBatch(publication.Topic, publication.Data, false)
317 require.NoError(t, err)
318 require.JSONEq(t, `null`, string(commands.Payload.Commands[0].Payload.Value))
319 }
320
321 func TestBuildEventRejectsNonObjectAndInvalidValues(t *testing.T) {
322 testCases := []struct {
323 name string
324 payload EventPayload
325 valid bool
326 }{
327 {
328 name: "rejects null instead of an object",
329 },
330 {
331 name: "rejects malformed event data",
332 payload: EventPayload{"action": json.RawMessage(`bad`)},
333 },
334 {
335 name: "rejects empty nonnull event data",
336 payload: EventPayload{"action": json.RawMessage{}},
337 },
338 {
339 name: "keeps null event members",
340 payload: EventPayload{"action": nil},
341 valid: true,
342 },
343 }
344
345 for _, tc := range testCases {
346 t.Run(tc.name, func(t *testing.T) {
347 publication, err := BuildEvent(deviceID, "press", Metadata{MessageID: messageID}, tc.payload)
348 if !tc.valid {
349 require.Error(t, err)
350
351 return
352 }
353
354 require.NoError(t, err)
355
356 parsed, err := ParseEvent(publication.Topic, publication.Data, false)
357 require.NoError(t, err)
358 require.JSONEq(t, `null`, string(parsed.Payload["action"]))
359 })
360 }
361 }
362
363 func TestBuildRejectsInvalidMetadataBeforeReturningPublications(t *testing.T) {
364 deadline := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
365 metadata := Metadata{MessageID: messageID}
366 testCases := []struct {
367 name string
368 mutate func(*Metadata)
369 }{
370 {
371 name: "rejects malformed message identity",
372 mutate: func(metadata *Metadata) {
373 metadata.MessageID = "bad"
374 },
375 },
376 {
377 name: "rejects malformed correlation identity",
378 mutate: func(metadata *Metadata) {
379 metadata.CorrelationID = "bad"
380 },
381 },
382 {
383 name: "rejects malformed causation identity",
384 mutate: func(metadata *Metadata) {
385 metadata.CausationID = "bad"
386 },
387 },
388 {
389 name: "rejects non UTC expiry",
390 mutate: func(metadata *Metadata) {
391 expiry := deadline.In(time.FixedZone("test", 3600))
392 metadata.ExpiresAt = &expiry
393 },
394 },
395 }
396
397 for _, tc := range testCases {
398 t.Run(tc.name, func(t *testing.T) {
399 invalid := metadata
400 tc.mutate(&invalid)
401
402 _, err := BuildReported(deviceID, "relay", invalid, ValuePayload{Value: json.RawMessage(`true`)})
403 require.Error(t, err)
404
405 batch := testBatch(deadline)
406 tc.mutate(&batch.Commands[1].Metadata)
407
408 _, err = BuildCommandBatch(deviceID, metadata, batch)
409 require.Error(t, err)
410 })
411 }
412 }
413
414 func TestDescriptorAttributionLimitUsesEncodedText(t *testing.T) {
415 payload := testDescriptor()
416 payload.Attribution = strings.Repeat("\xff", 667)
417 _, err := BuildDescriptor(deviceID, Metadata{MessageID: messageID}, payload)
418 require.Error(t, err)
419
420 payload.Attribution = strings.Repeat("\xff", 666)
421 publication, err := BuildDescriptor(deviceID, Metadata{MessageID: messageID}, payload)
422 require.NoError(t, err)
423
424 parsed, err := ParseDescriptor(publication.Topic, publication.Data, true)
425 require.NoError(t, err)
426 require.Len(t, parsed.Payload.Attribution, 1998)
427 }