]> git.giacomolli.uk - onehouse/blob - protocol/message_test.go
79871dde99ee1b2f35164bd9d5ca49fb064aae2f
[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 }