1use crate::{ClientMessage, ResumeStatus, ServerMessage, PROTOCOL_VERSION_V1};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4
5pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[PROTOCOL_VERSION_V1];
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ProtocolDirection {
10 ClientToServer,
11 ServerToClient,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ProtocolAuthority {
17 UntrustedClient,
18 TrustedServer,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ProtocolInstructionClass {
24 Lifecycle,
25 Event,
26 Render,
27 Navigation,
28 Upload,
29 Diagnostics,
30 Stream,
31 Chart,
32 Notification,
33 Grid,
34 Interop,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ProtocolOrdering {
40 Unordered,
41 PerSessionOrdered,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ProtocolDurability {
47 Ephemeral,
48 Replayable,
49 Resumable,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum ProtocolRenderEffect {
55 None,
56 Patch,
57 Diff,
58 Stream,
59 Navigation,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ProtocolInstructionDescriptor {
64 pub class: ProtocolInstructionClass,
65 pub direction: ProtocolDirection,
66 pub authority: ProtocolAuthority,
67 pub ordering: ProtocolOrdering,
68 pub durability: ProtocolDurability,
69 pub render_effect: ProtocolRenderEffect,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ProtocolInvariantViolation {
74 pub code: String,
75 pub message: String,
76}
77
78impl ProtocolInvariantViolation {
79 fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
80 Self {
81 code: code.into(),
82 message: message.into(),
83 }
84 }
85}
86
87pub fn is_supported_protocol_version(protocol: &str) -> bool {
88 SUPPORTED_PROTOCOL_VERSIONS.contains(&protocol)
89}
90
91pub fn describe_client_message(message: &ClientMessage) -> ProtocolInstructionDescriptor {
92 let class = match message {
93 ClientMessage::Connect { .. } => ProtocolInstructionClass::Lifecycle,
94 ClientMessage::Event { .. } => ProtocolInstructionClass::Event,
95 ClientMessage::Ping { .. } => ProtocolInstructionClass::Diagnostics,
96 ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
97 ProtocolInstructionClass::Navigation
98 }
99 ClientMessage::UploadStart { .. }
100 | ClientMessage::UploadChunk { .. }
101 | ClientMessage::UploadComplete { .. } => ProtocolInstructionClass::Upload,
102 };
103 let durability = match message {
104 ClientMessage::Connect { .. } => ProtocolDurability::Resumable,
105 ClientMessage::Event { .. } => ProtocolDurability::Replayable,
106 ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
107 ProtocolDurability::Replayable
108 }
109 ClientMessage::UploadStart { .. }
110 | ClientMessage::UploadChunk { .. }
111 | ClientMessage::UploadComplete { .. }
112 | ClientMessage::Ping { .. } => ProtocolDurability::Ephemeral,
113 };
114 let render_effect = match message {
115 ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
116 ProtocolRenderEffect::Navigation
117 }
118 _ => ProtocolRenderEffect::None,
119 };
120 ProtocolInstructionDescriptor {
121 class,
122 direction: ProtocolDirection::ClientToServer,
123 authority: ProtocolAuthority::UntrustedClient,
124 ordering: ProtocolOrdering::PerSessionOrdered,
125 durability,
126 render_effect,
127 }
128}
129
130pub fn describe_server_message(message: &ServerMessage) -> ProtocolInstructionDescriptor {
131 let (class, render_effect, durability) = match message {
132 ServerMessage::Hello { .. } => (
133 ProtocolInstructionClass::Lifecycle,
134 ProtocolRenderEffect::None,
135 ProtocolDurability::Resumable,
136 ),
137 ServerMessage::Patch { .. } => (
138 ProtocolInstructionClass::Render,
139 ProtocolRenderEffect::Patch,
140 ProtocolDurability::Replayable,
141 ),
142 ServerMessage::Diff { .. } => (
143 ProtocolInstructionClass::Render,
144 ProtocolRenderEffect::Diff,
145 ProtocolDurability::Replayable,
146 ),
147 ServerMessage::StreamInsert { .. }
148 | ServerMessage::StreamDelete { .. }
149 | ServerMessage::StreamBatch { .. } => (
150 ProtocolInstructionClass::Stream,
151 ProtocolRenderEffect::Stream,
152 ProtocolDurability::Replayable,
153 ),
154 ServerMessage::ChartSeriesAppend { .. }
155 | ServerMessage::ChartSeriesAppendMany { .. }
156 | ServerMessage::ChartSeriesReplace { .. }
157 | ServerMessage::ChartReset { .. }
158 | ServerMessage::ChartAnnotationUpsert { .. }
159 | ServerMessage::ChartAnnotationDelete { .. } => (
160 ProtocolInstructionClass::Chart,
161 ProtocolRenderEffect::None,
162 ProtocolDurability::Replayable,
163 ),
164 ServerMessage::ToastPush { .. }
165 | ServerMessage::ToastDismiss { .. }
166 | ServerMessage::InboxUpsert { .. }
167 | ServerMessage::InboxDelete { .. } => (
168 ProtocolInstructionClass::Notification,
169 ProtocolRenderEffect::None,
170 ProtocolDurability::Replayable,
171 ),
172 ServerMessage::GridReplace { .. } | ServerMessage::GridRowsReplace { .. } => (
173 ProtocolInstructionClass::Grid,
174 ProtocolRenderEffect::None,
175 ProtocolDurability::Replayable,
176 ),
177 ServerMessage::InteropDispatch { .. } => (
178 ProtocolInstructionClass::Interop,
179 ProtocolRenderEffect::None,
180 ProtocolDurability::Ephemeral,
181 ),
182 ServerMessage::Pong { .. } | ServerMessage::Error { .. } => (
183 ProtocolInstructionClass::Diagnostics,
184 ProtocolRenderEffect::None,
185 ProtocolDurability::Ephemeral,
186 ),
187 ServerMessage::Redirect { .. }
188 | ServerMessage::PatchUrl { .. }
189 | ServerMessage::Navigate { .. } => (
190 ProtocolInstructionClass::Navigation,
191 ProtocolRenderEffect::Navigation,
192 ProtocolDurability::Replayable,
193 ),
194 ServerMessage::UploadProgress { .. }
195 | ServerMessage::UploadComplete { .. }
196 | ServerMessage::UploadError { .. } => (
197 ProtocolInstructionClass::Upload,
198 ProtocolRenderEffect::None,
199 ProtocolDurability::Ephemeral,
200 ),
201 };
202 ProtocolInstructionDescriptor {
203 class,
204 direction: ProtocolDirection::ServerToClient,
205 authority: ProtocolAuthority::TrustedServer,
206 ordering: ProtocolOrdering::PerSessionOrdered,
207 durability,
208 render_effect,
209 }
210}
211
212pub fn validate_client_message_invariants(
213 message: &ClientMessage,
214) -> Result<(), ProtocolInvariantViolation> {
215 match message {
216 ClientMessage::Connect {
217 protocol,
218 trace_id,
219 span_id,
220 parent_span_id,
221 ..
222 } => {
223 if !is_supported_protocol_version(protocol) {
224 return Err(ProtocolInvariantViolation::new(
225 "unsupported_protocol_version",
226 format!(
227 "client connect protocol '{}' is unsupported; expected one of {:?}",
228 protocol, SUPPORTED_PROTOCOL_VERSIONS
229 ),
230 ));
231 }
232 validate_hex_id("trace_id", trace_id.as_deref(), 32)?;
233 validate_hex_id("span_id", span_id.as_deref(), 16)?;
234 validate_hex_id("parent_span_id", parent_span_id.as_deref(), 16)?;
235 Ok(())
236 }
237 ClientMessage::Event { event, .. } => {
238 if event.trim().is_empty() {
239 return Err(ProtocolInvariantViolation::new(
240 "empty_event_name",
241 "event message must include a non-empty event name",
242 ));
243 }
244 Ok(())
245 }
246 ClientMessage::PatchUrl { to } => validate_internal_path("patch_url", to),
247 ClientMessage::Navigate { to } => validate_internal_path("navigate", to),
248 ClientMessage::UploadStart {
249 upload_id,
250 event,
251 name,
252 ..
253 } => {
254 if upload_id.trim().is_empty() {
255 return Err(ProtocolInvariantViolation::new(
256 "empty_upload_id",
257 "upload_start.upload_id cannot be empty",
258 ));
259 }
260 if event.trim().is_empty() {
261 return Err(ProtocolInvariantViolation::new(
262 "empty_upload_event",
263 "upload_start.event cannot be empty",
264 ));
265 }
266 if name.trim().is_empty() {
267 return Err(ProtocolInvariantViolation::new(
268 "empty_upload_name",
269 "upload_start.name cannot be empty",
270 ));
271 }
272 Ok(())
273 }
274 ClientMessage::UploadChunk { upload_id, .. }
275 | ClientMessage::UploadComplete { upload_id } => {
276 if upload_id.trim().is_empty() {
277 return Err(ProtocolInvariantViolation::new(
278 "empty_upload_id",
279 "upload message upload_id cannot be empty",
280 ));
281 }
282 Ok(())
283 }
284 ClientMessage::Ping { .. } => Ok(()),
285 }
286}
287
288pub fn validate_server_message_invariants(
289 message: &ServerMessage,
290) -> Result<(), ProtocolInvariantViolation> {
291 match message {
292 ServerMessage::Hello {
293 session_id,
294 target,
295 protocol,
296 revision,
297 server_revision,
298 resume_status,
299 resume_reason,
300 resume_token,
301 resume_expires_in_ms,
302 } => {
303 if session_id.trim().is_empty() {
304 return Err(ProtocolInvariantViolation::new(
305 "empty_session_id",
306 "hello.session_id cannot be empty",
307 ));
308 }
309 if target.trim().is_empty() {
310 return Err(ProtocolInvariantViolation::new(
311 "empty_target",
312 "hello.target cannot be empty",
313 ));
314 }
315 if !is_supported_protocol_version(protocol) {
316 return Err(ProtocolInvariantViolation::new(
317 "unsupported_protocol_version",
318 format!(
319 "hello.protocol '{}' is unsupported; expected one of {:?}",
320 protocol, SUPPORTED_PROTOCOL_VERSIONS
321 ),
322 ));
323 }
324 if let Some(server_revision) = server_revision {
325 if server_revision < revision {
326 return Err(ProtocolInvariantViolation::new(
327 "server_revision_regression",
328 format!(
329 "hello.server_revision ({server_revision}) cannot be lower than hello.revision ({revision})",
330 ),
331 ));
332 }
333 }
334 if let Some(status) = resume_status {
335 if matches!(status, ResumeStatus::Fallback)
336 && resume_reason
337 .as_ref()
338 .map(|reason| reason.trim().is_empty())
339 .unwrap_or(true)
340 {
341 return Err(ProtocolInvariantViolation::new(
342 "missing_resume_reason",
343 "hello.resume_reason must be present for fallback resume status",
344 ));
345 }
346 }
347 if let Some(reason) = resume_reason {
348 if reason.trim().is_empty() {
349 return Err(ProtocolInvariantViolation::new(
350 "empty_resume_reason",
351 "hello.resume_reason cannot be empty when present",
352 ));
353 }
354 }
355 if resume_expires_in_ms.is_some() && resume_token.is_none() {
356 return Err(ProtocolInvariantViolation::new(
357 "missing_resume_token",
358 "hello.resume_token must be present when resume_expires_in_ms is set",
359 ));
360 }
361 Ok(())
362 }
363 ServerMessage::Patch { target, .. } => validate_non_empty("patch.target", target),
364 ServerMessage::Diff { target, slots, .. } => {
365 validate_non_empty("diff.target", target)?;
366 let mut indices = BTreeSet::new();
367 for slot in slots {
368 if !indices.insert(slot.index) {
369 return Err(ProtocolInvariantViolation::new(
370 "duplicate_diff_slot",
371 format!("diff contains duplicate slot index {}", slot.index),
372 ));
373 }
374 }
375 Ok(())
376 }
377 ServerMessage::StreamInsert { target, id, .. }
378 | ServerMessage::StreamDelete { target, id } => {
379 validate_non_empty("stream.target", target)?;
380 validate_non_empty("stream.id", id)
381 }
382 ServerMessage::StreamBatch { target, operations } => {
383 validate_non_empty("stream_batch.target", target)?;
384 if operations.is_empty() {
385 return Err(ProtocolInvariantViolation::new(
386 "empty_stream_batch",
387 "stream_batch.operations cannot be empty",
388 ));
389 }
390 Ok(())
391 }
392 ServerMessage::Redirect { to }
393 | ServerMessage::PatchUrl { to }
394 | ServerMessage::Navigate { to } => validate_internal_path("server_navigation", to),
395 ServerMessage::UploadProgress {
396 upload_id,
397 received,
398 total,
399 } => {
400 validate_non_empty("upload_progress.upload_id", upload_id)?;
401 if received > total {
402 return Err(ProtocolInvariantViolation::new(
403 "upload_progress_out_of_bounds",
404 format!("upload progress received {received} cannot exceed total {total}"),
405 ));
406 }
407 Ok(())
408 }
409 ServerMessage::UploadComplete {
410 upload_id, name, ..
411 } => {
412 validate_non_empty("upload_complete.upload_id", upload_id)?;
413 validate_non_empty("upload_complete.name", name)
414 }
415 ServerMessage::UploadError {
416 upload_id, message, ..
417 } => {
418 validate_non_empty("upload_error.upload_id", upload_id)?;
419 validate_non_empty("upload_error.message", message)
420 }
421 ServerMessage::Error { message, .. } => validate_non_empty("error.message", message),
422 ServerMessage::ChartSeriesAppend { chart, series, .. }
423 | ServerMessage::ChartSeriesAppendMany { chart, series, .. }
424 | ServerMessage::ChartSeriesReplace { chart, series, .. } => {
425 validate_non_empty("chart", chart)?;
426 validate_non_empty("series", series)
427 }
428 ServerMessage::ChartReset { chart } => validate_non_empty("chart", chart),
429 ServerMessage::ChartAnnotationUpsert { chart, annotation } => {
430 validate_non_empty("chart", chart)?;
431 validate_non_empty("annotation.id", &annotation.id)
432 }
433 ServerMessage::ChartAnnotationDelete { chart, id } => {
434 validate_non_empty("chart", chart)?;
435 validate_non_empty("annotation.id", id)
436 }
437 ServerMessage::ToastPush { toast } => validate_non_empty("toast.id", &toast.id),
438 ServerMessage::ToastDismiss { id } => validate_non_empty("toast.id", id),
439 ServerMessage::InboxUpsert { item } => validate_non_empty("inbox.id", &item.id),
440 ServerMessage::InboxDelete { id } => validate_non_empty("inbox.id", id),
441 ServerMessage::GridReplace { grid, .. } | ServerMessage::GridRowsReplace { grid, .. } => {
442 validate_non_empty("grid", grid)
443 }
444 ServerMessage::InteropDispatch { dispatch } => {
445 validate_non_empty("interop.event", &dispatch.event)
446 }
447 ServerMessage::Pong { .. } => Ok(()),
448 }
449}
450
451pub fn validate_server_message_sequence(
452 messages: &[ServerMessage],
453) -> Result<(), ProtocolInvariantViolation> {
454 let mut seen_hello = false;
455 let mut last_render_revision: Option<u64> = None;
456
457 for (index, message) in messages.iter().enumerate() {
458 validate_server_message_invariants(message)?;
459 if matches!(message, ServerMessage::Hello { .. }) {
460 if seen_hello {
461 return Err(ProtocolInvariantViolation::new(
462 "duplicate_hello",
463 "server transcript cannot contain multiple hello messages",
464 ));
465 }
466 if index != 0 {
467 return Err(ProtocolInvariantViolation::new(
468 "hello_not_first",
469 "hello message must be first in a server transcript",
470 ));
471 }
472 seen_hello = true;
473 continue;
474 }
475
476 let current_render_revision = match message {
477 ServerMessage::Patch { revision, .. } | ServerMessage::Diff { revision, .. } => {
478 Some(*revision)
479 }
480 _ => None,
481 };
482 if let Some(current_render_revision) = current_render_revision {
483 if let Some(previous) = last_render_revision {
484 if current_render_revision <= previous {
485 return Err(ProtocolInvariantViolation::new(
486 "non_monotonic_revision",
487 format!(
488 "render revisions must increase monotonically (previous={previous}, next={current_render_revision})"
489 ),
490 ));
491 }
492 }
493 last_render_revision = Some(current_render_revision);
494 }
495 }
496 Ok(())
497}
498
499fn validate_internal_path(label: &str, path: &str) -> Result<(), ProtocolInvariantViolation> {
500 if path.trim().is_empty() {
501 return Err(ProtocolInvariantViolation::new(
502 "empty_path",
503 format!("{label} path cannot be empty"),
504 ));
505 }
506 let normalized = path.trim();
507 if !normalized.starts_with('/') {
508 return Err(ProtocolInvariantViolation::new(
509 "non_internal_path",
510 format!("{label} path must start with '/'"),
511 ));
512 }
513 if normalized.starts_with("//")
514 || normalized.starts_with("/\\")
515 || normalized.contains("://")
516 || normalized.starts_with("/http")
517 {
518 return Err(ProtocolInvariantViolation::new(
519 "external_path",
520 format!("{label} path must stay internal to the current application"),
521 ));
522 }
523 Ok(())
524}
525
526fn validate_non_empty(label: &str, value: &str) -> Result<(), ProtocolInvariantViolation> {
527 if value.trim().is_empty() {
528 return Err(ProtocolInvariantViolation::new(
529 "empty_field",
530 format!("{label} cannot be empty"),
531 ));
532 }
533 Ok(())
534}
535
536fn validate_hex_id(
537 label: &str,
538 value: Option<&str>,
539 expected_len: usize,
540) -> Result<(), ProtocolInvariantViolation> {
541 let Some(value) = value else {
542 return Ok(());
543 };
544 if value.len() != expected_len {
545 return Err(ProtocolInvariantViolation::new(
546 "invalid_hex_id_length",
547 format!("{label} must contain exactly {expected_len} hex chars"),
548 ));
549 }
550 if !value.chars().all(|char| char.is_ascii_hexdigit()) {
551 return Err(ProtocolInvariantViolation::new(
552 "invalid_hex_id_charset",
553 format!("{label} must contain only ascii hex chars"),
554 ));
555 }
556 Ok(())
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use serde_json::json;
563
564 fn valid_connect() -> ClientMessage {
565 ClientMessage::Connect {
566 protocol: PROTOCOL_VERSION_V1.to_string(),
567 session_id: None,
568 last_revision: None,
569 resume_token: None,
570 tenant_id: None,
571 trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
572 span_id: Some("00f067aa0ba902b7".to_string()),
573 parent_span_id: Some("89abcdef01234567".to_string()),
574 correlation_id: None,
575 request_id: None,
576 }
577 }
578
579 fn valid_hello() -> ServerMessage {
580 ServerMessage::Hello {
581 session_id: "sid".to_string(),
582 target: "root".to_string(),
583 revision: 0,
584 protocol: PROTOCOL_VERSION_V1.to_string(),
585 server_revision: Some(0),
586 resume_status: Some(ResumeStatus::Fresh),
587 resume_reason: None,
588 resume_token: None,
589 resume_expires_in_ms: None,
590 }
591 }
592
593 #[test]
594 fn client_connect_rejects_unsupported_protocol() {
595 let invalid = ClientMessage::Connect {
596 protocol: "shelly/2".to_string(),
597 session_id: None,
598 last_revision: None,
599 resume_token: None,
600 tenant_id: None,
601 trace_id: None,
602 span_id: None,
603 parent_span_id: None,
604 correlation_id: None,
605 request_id: None,
606 };
607 let err = validate_client_message_invariants(&invalid).expect_err("unsupported protocol");
608 assert_eq!(err.code, "unsupported_protocol_version");
609 }
610
611 #[test]
612 fn client_invariants_cover_hex_event_navigation_and_upload_rules() {
613 assert!(validate_client_message_invariants(&valid_connect()).is_ok());
614
615 let mut invalid_trace = valid_connect();
616 if let ClientMessage::Connect { trace_id, .. } = &mut invalid_trace {
617 *trace_id = Some("abcd".to_string());
618 }
619 let err = validate_client_message_invariants(&invalid_trace).expect_err("trace length");
620 assert_eq!(err.code, "invalid_hex_id_length");
621
622 let mut invalid_span = valid_connect();
623 if let ClientMessage::Connect { span_id, .. } = &mut invalid_span {
624 *span_id = Some("zzzzzzzzzzzzzzzz".to_string());
625 }
626 let err = validate_client_message_invariants(&invalid_span).expect_err("span charset");
627 assert_eq!(err.code, "invalid_hex_id_charset");
628
629 let err = validate_client_message_invariants(&ClientMessage::Event {
630 event: " ".to_string(),
631 target: None,
632 value: json!({}),
633 metadata: serde_json::Map::new(),
634 })
635 .expect_err("empty event should fail");
636 assert_eq!(err.code, "empty_event_name");
637
638 let err = validate_client_message_invariants(&ClientMessage::PatchUrl {
639 to: "users/1".to_string(),
640 })
641 .expect_err("patch_url must stay internal");
642 assert_eq!(err.code, "non_internal_path");
643
644 let err = validate_client_message_invariants(&ClientMessage::Navigate {
645 to: "/http://evil.example".to_string(),
646 })
647 .expect_err("navigate must stay internal");
648 assert_eq!(err.code, "external_path");
649
650 let err = validate_client_message_invariants(&ClientMessage::UploadStart {
651 upload_id: "".to_string(),
652 event: "uploaded".to_string(),
653 target: None,
654 name: "file.txt".to_string(),
655 size: 1,
656 content_type: None,
657 })
658 .expect_err("upload id is required");
659 assert_eq!(err.code, "empty_upload_id");
660
661 let err = validate_client_message_invariants(&ClientMessage::UploadStart {
662 upload_id: "u1".to_string(),
663 event: " ".to_string(),
664 target: None,
665 name: "file.txt".to_string(),
666 size: 1,
667 content_type: None,
668 })
669 .expect_err("upload event is required");
670 assert_eq!(err.code, "empty_upload_event");
671
672 let err = validate_client_message_invariants(&ClientMessage::UploadStart {
673 upload_id: "u1".to_string(),
674 event: "uploaded".to_string(),
675 target: None,
676 name: " ".to_string(),
677 size: 1,
678 content_type: None,
679 })
680 .expect_err("upload name is required");
681 assert_eq!(err.code, "empty_upload_name");
682
683 let err = validate_client_message_invariants(&ClientMessage::UploadChunk {
684 upload_id: " ".to_string(),
685 offset: 0,
686 data: "AA==".to_string(),
687 })
688 .expect_err("upload chunk id is required");
689 assert_eq!(err.code, "empty_upload_id");
690
691 let err = validate_client_message_invariants(&ClientMessage::UploadComplete {
692 upload_id: " ".to_string(),
693 })
694 .expect_err("upload complete id is required");
695 assert_eq!(err.code, "empty_upload_id");
696
697 assert!(validate_client_message_invariants(&ClientMessage::Ping { nonce: None }).is_ok());
698 }
699
700 #[test]
701 fn server_hello_invariants_cover_resume_and_protocol_rules() {
702 assert!(validate_server_message_invariants(&valid_hello()).is_ok());
703
704 let mut message = valid_hello();
705 if let ServerMessage::Hello { session_id, .. } = &mut message {
706 *session_id = " ".to_string();
707 }
708 let err = validate_server_message_invariants(&message).expect_err("session id required");
709 assert_eq!(err.code, "empty_session_id");
710
711 let mut message = valid_hello();
712 if let ServerMessage::Hello { target, .. } = &mut message {
713 *target = " ".to_string();
714 }
715 let err = validate_server_message_invariants(&message).expect_err("target required");
716 assert_eq!(err.code, "empty_target");
717
718 let mut message = valid_hello();
719 if let ServerMessage::Hello { protocol, .. } = &mut message {
720 *protocol = "shelly/9".to_string();
721 }
722 let err = validate_server_message_invariants(&message).expect_err("protocol unsupported");
723 assert_eq!(err.code, "unsupported_protocol_version");
724
725 let mut message = valid_hello();
726 if let ServerMessage::Hello {
727 revision,
728 server_revision,
729 ..
730 } = &mut message
731 {
732 *revision = 3;
733 *server_revision = Some(2);
734 }
735 let err = validate_server_message_invariants(&message)
736 .expect_err("server revision cannot regress below revision");
737 assert_eq!(err.code, "server_revision_regression");
738
739 let mut message = valid_hello();
740 if let ServerMessage::Hello {
741 resume_status,
742 resume_reason,
743 ..
744 } = &mut message
745 {
746 *resume_status = Some(ResumeStatus::Fallback);
747 *resume_reason = None;
748 }
749 let err =
750 validate_server_message_invariants(&message).expect_err("fallback reason is required");
751 assert_eq!(err.code, "missing_resume_reason");
752
753 let mut message = valid_hello();
754 if let ServerMessage::Hello {
755 resume_status,
756 resume_reason,
757 ..
758 } = &mut message
759 {
760 *resume_status = Some(ResumeStatus::Fresh);
761 *resume_reason = Some(" ".to_string());
762 }
763 let err = validate_server_message_invariants(&message)
764 .expect_err("resume reason cannot be blank when present");
765 assert_eq!(err.code, "empty_resume_reason");
766
767 let mut message = valid_hello();
768 if let ServerMessage::Hello {
769 resume_token,
770 resume_expires_in_ms,
771 ..
772 } = &mut message
773 {
774 *resume_token = None;
775 *resume_expires_in_ms = Some(60_000);
776 }
777 let err = validate_server_message_invariants(&message)
778 .expect_err("resume expiry requires resume token");
779 assert_eq!(err.code, "missing_resume_token");
780 }
781
782 #[test]
783 fn server_message_invariants_cover_render_stream_upload_and_navigation() {
784 let err = validate_server_message_invariants(&ServerMessage::Patch {
785 target: "".to_string(),
786 html: "<p>x</p>".to_string(),
787 revision: 1,
788 })
789 .expect_err("patch target is required");
790 assert_eq!(err.code, "empty_field");
791
792 let err = validate_server_message_invariants(&ServerMessage::Diff {
793 target: "root".to_string(),
794 revision: 1,
795 slots: vec![
796 crate::DynamicSlotPatch {
797 index: 1,
798 html: "a".to_string(),
799 },
800 crate::DynamicSlotPatch {
801 index: 1,
802 html: "b".to_string(),
803 },
804 ],
805 })
806 .expect_err("duplicate diff slots should fail");
807 assert_eq!(err.code, "duplicate_diff_slot");
808
809 let err = validate_server_message_invariants(&ServerMessage::StreamInsert {
810 target: "root".to_string(),
811 id: "".to_string(),
812 html: "<li>x</li>".to_string(),
813 at: crate::StreamPosition::Append,
814 })
815 .expect_err("stream id is required");
816 assert_eq!(err.code, "empty_field");
817
818 let err = validate_server_message_invariants(&ServerMessage::StreamBatch {
819 target: "root".to_string(),
820 operations: vec![],
821 })
822 .expect_err("stream batch must not be empty");
823 assert_eq!(err.code, "empty_stream_batch");
824
825 let err = validate_server_message_invariants(&ServerMessage::Redirect {
826 to: "/http://evil.example".to_string(),
827 })
828 .expect_err("redirect must stay internal");
829 assert_eq!(err.code, "external_path");
830
831 let err = validate_server_message_invariants(&ServerMessage::UploadProgress {
832 upload_id: "u1".to_string(),
833 received: 2,
834 total: 1,
835 })
836 .expect_err("upload progress bounds");
837 assert_eq!(err.code, "upload_progress_out_of_bounds");
838
839 let err = validate_server_message_invariants(&ServerMessage::UploadComplete {
840 upload_id: "u1".to_string(),
841 name: "".to_string(),
842 size: 1,
843 content_type: None,
844 })
845 .expect_err("upload name required");
846 assert_eq!(err.code, "empty_field");
847
848 let err = validate_server_message_invariants(&ServerMessage::UploadError {
849 upload_id: "u1".to_string(),
850 message: "".to_string(),
851 code: Some("bad".to_string()),
852 })
853 .expect_err("upload error message required");
854 assert_eq!(err.code, "empty_field");
855
856 let err = validate_server_message_invariants(&ServerMessage::Error {
857 message: "".to_string(),
858 code: None,
859 })
860 .expect_err("error message required");
861 assert_eq!(err.code, "empty_field");
862 }
863
864 #[test]
865 fn server_message_invariants_cover_chart_notification_grid_and_interop() {
866 let err = validate_server_message_invariants(&ServerMessage::ChartSeriesAppend {
867 chart: "".to_string(),
868 series: "s1".to_string(),
869 point: crate::ChartPoint { x: 1.0, y: 2.0 },
870 })
871 .expect_err("chart id required");
872 assert_eq!(err.code, "empty_field");
873
874 let err = validate_server_message_invariants(&ServerMessage::ChartAnnotationUpsert {
875 chart: "chart-1".to_string(),
876 annotation: crate::ChartAnnotation {
877 id: "".to_string(),
878 x: 1.0,
879 label: "L".to_string(),
880 },
881 })
882 .expect_err("annotation id required");
883 assert_eq!(err.code, "empty_field");
884
885 let err = validate_server_message_invariants(&ServerMessage::ChartAnnotationDelete {
886 chart: "chart-1".to_string(),
887 id: "".to_string(),
888 })
889 .expect_err("annotation delete id required");
890 assert_eq!(err.code, "empty_field");
891
892 let err = validate_server_message_invariants(&ServerMessage::ToastPush {
893 toast: crate::Toast {
894 id: "".to_string(),
895 level: crate::ToastLevel::Info,
896 title: None,
897 message: "hello".to_string(),
898 ttl_ms: None,
899 },
900 })
901 .expect_err("toast id required");
902 assert_eq!(err.code, "empty_field");
903
904 let err = validate_server_message_invariants(&ServerMessage::InboxUpsert {
905 item: crate::InboxItem {
906 id: "".to_string(),
907 title: "t".to_string(),
908 body: "b".to_string(),
909 read: false,
910 inserted_at: None,
911 },
912 })
913 .expect_err("inbox id required");
914 assert_eq!(err.code, "empty_field");
915
916 let err = validate_server_message_invariants(&ServerMessage::GridReplace {
917 grid: "".to_string(),
918 state: crate::GridState {
919 columns: vec![],
920 rows: vec![],
921 total_rows: 0,
922 offset: 0,
923 limit: 20,
924 views: vec![],
925 active_view: None,
926 group_by: None,
927 query: None,
928 sort: None,
929 },
930 })
931 .expect_err("grid id required");
932 assert_eq!(err.code, "empty_field");
933
934 let err = validate_server_message_invariants(&ServerMessage::InteropDispatch {
935 dispatch: crate::JsInteropDispatch {
936 target: None,
937 event: "".to_string(),
938 detail: json!({}),
939 bubbles: true,
940 },
941 })
942 .expect_err("interop event required");
943 assert_eq!(err.code, "empty_field");
944
945 assert!(validate_server_message_invariants(&ServerMessage::Pong { nonce: None }).is_ok());
946 }
947
948 #[test]
949 fn server_sequence_requires_monotonic_revisions() {
950 let messages = vec![
951 ServerMessage::Patch {
952 target: "root".to_string(),
953 html: "<p>1</p>".to_string(),
954 revision: 2,
955 },
956 ServerMessage::Diff {
957 target: "root".to_string(),
958 revision: 2,
959 slots: vec![],
960 },
961 ];
962 let err =
963 validate_server_message_sequence(&messages).expect_err("non monotonic should fail");
964 assert_eq!(err.code, "non_monotonic_revision");
965 }
966
967 #[test]
968 fn server_sequence_rejects_hello_order_and_duplicates() {
969 let err = validate_server_message_sequence(&[
970 ServerMessage::Patch {
971 target: "root".to_string(),
972 html: "<p>1</p>".to_string(),
973 revision: 1,
974 },
975 valid_hello(),
976 ])
977 .expect_err("hello must be first");
978 assert_eq!(err.code, "hello_not_first");
979
980 let err = validate_server_message_sequence(&[valid_hello(), valid_hello()])
981 .expect_err("duplicate hello should fail");
982 assert_eq!(err.code, "duplicate_hello");
983
984 assert!(validate_server_message_sequence(&[
985 valid_hello(),
986 ServerMessage::Patch {
987 target: "root".to_string(),
988 html: "<p>1</p>".to_string(),
989 revision: 1,
990 },
991 ServerMessage::Diff {
992 target: "root".to_string(),
993 revision: 2,
994 slots: vec![],
995 },
996 ])
997 .is_ok());
998 }
999
1000 #[test]
1001 fn hello_fallback_requires_reason() {
1002 let message = ServerMessage::Hello {
1003 session_id: "sid".to_string(),
1004 target: "root".to_string(),
1005 revision: 0,
1006 protocol: PROTOCOL_VERSION_V1.to_string(),
1007 server_revision: None,
1008 resume_status: Some(ResumeStatus::Fallback),
1009 resume_reason: None,
1010 resume_token: Some("token".to_string()),
1011 resume_expires_in_ms: Some(120_000),
1012 };
1013 let err =
1014 validate_server_message_invariants(&message).expect_err("missing reason should fail");
1015 assert_eq!(err.code, "missing_resume_reason");
1016 }
1017
1018 #[test]
1019 fn descriptors_map_to_expected_axes() {
1020 let client = ClientMessage::Event {
1021 event: "save".to_string(),
1022 target: None,
1023 value: json!({"id": 1}),
1024 metadata: serde_json::Map::new(),
1025 };
1026 let server = ServerMessage::Patch {
1027 target: "root".to_string(),
1028 html: "<p>ok</p>".to_string(),
1029 revision: 1,
1030 };
1031 let c = describe_client_message(&client);
1032 let s = describe_server_message(&server);
1033 assert_eq!(c.direction, ProtocolDirection::ClientToServer);
1034 assert_eq!(c.authority, ProtocolAuthority::UntrustedClient);
1035 assert_eq!(c.class, ProtocolInstructionClass::Event);
1036 assert_eq!(s.direction, ProtocolDirection::ServerToClient);
1037 assert_eq!(s.authority, ProtocolAuthority::TrustedServer);
1038 assert_eq!(s.render_effect, ProtocolRenderEffect::Patch);
1039 }
1040
1041 #[test]
1042 fn describe_client_message_covers_every_instruction_class_branch() {
1043 let descriptors = vec![
1044 describe_client_message(&ClientMessage::Connect {
1045 protocol: PROTOCOL_VERSION_V1.to_string(),
1046 session_id: None,
1047 last_revision: None,
1048 resume_token: None,
1049 tenant_id: None,
1050 trace_id: None,
1051 span_id: None,
1052 parent_span_id: None,
1053 correlation_id: None,
1054 request_id: None,
1055 }),
1056 describe_client_message(&ClientMessage::Event {
1057 event: "save".to_string(),
1058 target: None,
1059 value: json!({"ok": true}),
1060 metadata: serde_json::Map::new(),
1061 }),
1062 describe_client_message(&ClientMessage::Ping { nonce: None }),
1063 describe_client_message(&ClientMessage::PatchUrl {
1064 to: "/users".to_string(),
1065 }),
1066 describe_client_message(&ClientMessage::Navigate {
1067 to: "/users/1".to_string(),
1068 }),
1069 describe_client_message(&ClientMessage::UploadStart {
1070 upload_id: "u1".to_string(),
1071 event: "upload".to_string(),
1072 target: None,
1073 name: "avatar.png".to_string(),
1074 size: 1,
1075 content_type: Some("image/png".to_string()),
1076 }),
1077 describe_client_message(&ClientMessage::UploadChunk {
1078 upload_id: "u1".to_string(),
1079 offset: 0,
1080 data: "AA==".to_string(),
1081 }),
1082 describe_client_message(&ClientMessage::UploadComplete {
1083 upload_id: "u1".to_string(),
1084 }),
1085 ];
1086
1087 assert!(descriptors.iter().all(|descriptor| {
1088 descriptor.direction == ProtocolDirection::ClientToServer
1089 && descriptor.authority == ProtocolAuthority::UntrustedClient
1090 && descriptor.ordering == ProtocolOrdering::PerSessionOrdered
1091 }));
1092 assert!(descriptors
1093 .iter()
1094 .any(|descriptor| descriptor.class == ProtocolInstructionClass::Lifecycle));
1095 assert!(descriptors
1096 .iter()
1097 .any(|descriptor| descriptor.class == ProtocolInstructionClass::Event));
1098 assert!(descriptors
1099 .iter()
1100 .any(|descriptor| descriptor.class == ProtocolInstructionClass::Diagnostics));
1101 assert!(descriptors
1102 .iter()
1103 .any(|descriptor| descriptor.class == ProtocolInstructionClass::Navigation));
1104 assert!(descriptors
1105 .iter()
1106 .any(|descriptor| descriptor.class == ProtocolInstructionClass::Upload));
1107 }
1108
1109 #[test]
1110 fn describe_server_message_and_invariants_cover_remaining_variant_matrix() {
1111 let messages = vec![
1112 valid_hello(),
1113 ServerMessage::Patch {
1114 target: "root".to_string(),
1115 html: "<p>ok</p>".to_string(),
1116 revision: 1,
1117 },
1118 ServerMessage::Diff {
1119 target: "root".to_string(),
1120 revision: 2,
1121 slots: vec![crate::DynamicSlotPatch {
1122 index: 0,
1123 html: "ok".to_string(),
1124 }],
1125 },
1126 ServerMessage::StreamInsert {
1127 target: "items".to_string(),
1128 id: "item-1".to_string(),
1129 html: "<li>One</li>".to_string(),
1130 at: crate::StreamPosition::Append,
1131 },
1132 ServerMessage::StreamDelete {
1133 target: "items".to_string(),
1134 id: "item-1".to_string(),
1135 },
1136 ServerMessage::StreamBatch {
1137 target: "items".to_string(),
1138 operations: vec![
1139 crate::StreamBatchOperation::Insert {
1140 id: "item-2".to_string(),
1141 html: "<li>Two</li>".to_string(),
1142 at: crate::StreamPosition::Append,
1143 },
1144 crate::StreamBatchOperation::Delete {
1145 id: "item-1".to_string(),
1146 },
1147 ],
1148 },
1149 ServerMessage::ChartSeriesAppend {
1150 chart: "throughput".to_string(),
1151 series: "p95".to_string(),
1152 point: crate::ChartPoint { x: 1.0, y: 2.0 },
1153 },
1154 ServerMessage::ChartSeriesAppendMany {
1155 chart: "throughput".to_string(),
1156 series: "p95".to_string(),
1157 points: vec![crate::ChartPoint { x: 2.0, y: 3.0 }],
1158 },
1159 ServerMessage::ChartSeriesReplace {
1160 chart: "throughput".to_string(),
1161 series: "p95".to_string(),
1162 points: vec![crate::ChartPoint { x: 3.0, y: 4.0 }],
1163 },
1164 ServerMessage::ChartReset {
1165 chart: "throughput".to_string(),
1166 },
1167 ServerMessage::ChartAnnotationUpsert {
1168 chart: "throughput".to_string(),
1169 annotation: crate::ChartAnnotation {
1170 id: "a-1".to_string(),
1171 x: 4.0,
1172 label: "deploy".to_string(),
1173 },
1174 },
1175 ServerMessage::ChartAnnotationDelete {
1176 chart: "throughput".to_string(),
1177 id: "a-1".to_string(),
1178 },
1179 ServerMessage::ToastPush {
1180 toast: crate::Toast {
1181 id: "toast-1".to_string(),
1182 level: crate::ToastLevel::Info,
1183 title: Some("Info".to_string()),
1184 message: "ok".to_string(),
1185 ttl_ms: Some(2_000),
1186 },
1187 },
1188 ServerMessage::ToastDismiss {
1189 id: "toast-1".to_string(),
1190 },
1191 ServerMessage::InboxUpsert {
1192 item: crate::InboxItem {
1193 id: "inbox-1".to_string(),
1194 title: "Ready".to_string(),
1195 body: "Done".to_string(),
1196 read: false,
1197 inserted_at: None,
1198 },
1199 },
1200 ServerMessage::InboxDelete {
1201 id: "inbox-1".to_string(),
1202 },
1203 ServerMessage::GridReplace {
1204 grid: "accounts".to_string(),
1205 state: crate::GridState {
1206 columns: vec![],
1207 rows: vec![],
1208 total_rows: 0,
1209 offset: 0,
1210 limit: 25,
1211 views: vec![],
1212 active_view: None,
1213 group_by: None,
1214 query: None,
1215 sort: None,
1216 },
1217 },
1218 ServerMessage::GridRowsReplace {
1219 grid: "accounts".to_string(),
1220 window: crate::GridRowsWindow {
1221 offset: 0,
1222 total_rows: 0,
1223 rows: vec![],
1224 },
1225 },
1226 ServerMessage::InteropDispatch {
1227 dispatch: crate::JsInteropDispatch {
1228 target: Some("#root".to_string()),
1229 event: "interop:event".to_string(),
1230 detail: json!({"k": "v"}),
1231 bubbles: true,
1232 },
1233 },
1234 ServerMessage::Pong { nonce: None },
1235 ServerMessage::Redirect {
1236 to: "/dashboard".to_string(),
1237 },
1238 ServerMessage::PatchUrl {
1239 to: "/dashboard?page=2".to_string(),
1240 },
1241 ServerMessage::Navigate {
1242 to: "/settings".to_string(),
1243 },
1244 ServerMessage::UploadProgress {
1245 upload_id: "u1".to_string(),
1246 received: 1,
1247 total: 2,
1248 },
1249 ServerMessage::UploadComplete {
1250 upload_id: "u1".to_string(),
1251 name: "avatar.png".to_string(),
1252 size: 2,
1253 content_type: Some("image/png".to_string()),
1254 },
1255 ServerMessage::UploadError {
1256 upload_id: "u1".to_string(),
1257 message: "rejected".to_string(),
1258 code: Some("too_large".to_string()),
1259 },
1260 ServerMessage::Error {
1261 message: "oops".to_string(),
1262 code: Some("runtime".to_string()),
1263 },
1264 ];
1265
1266 for message in &messages {
1267 let descriptor = describe_server_message(message);
1268 assert_eq!(descriptor.direction, ProtocolDirection::ServerToClient);
1269 assert_eq!(descriptor.authority, ProtocolAuthority::TrustedServer);
1270 assert_eq!(descriptor.ordering, ProtocolOrdering::PerSessionOrdered);
1271 assert!(validate_server_message_invariants(message).is_ok());
1272 }
1273 }
1274}