use crate::{ClientMessage, ResumeStatus, ServerMessage, PROTOCOL_VERSION_V1};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[PROTOCOL_VERSION_V1];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolDirection {
ClientToServer,
ServerToClient,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolAuthority {
UntrustedClient,
TrustedServer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolInstructionClass {
Lifecycle,
Event,
Render,
Navigation,
Upload,
Diagnostics,
Stream,
Chart,
Notification,
Grid,
Interop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolOrdering {
Unordered,
PerSessionOrdered,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolDurability {
Ephemeral,
Replayable,
Resumable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolRenderEffect {
None,
Patch,
Diff,
Stream,
Navigation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolInstructionDescriptor {
pub class: ProtocolInstructionClass,
pub direction: ProtocolDirection,
pub authority: ProtocolAuthority,
pub ordering: ProtocolOrdering,
pub durability: ProtocolDurability,
pub render_effect: ProtocolRenderEffect,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolInvariantViolation {
pub code: String,
pub message: String,
}
impl ProtocolInvariantViolation {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
pub fn is_supported_protocol_version(protocol: &str) -> bool {
SUPPORTED_PROTOCOL_VERSIONS.contains(&protocol)
}
pub fn describe_client_message(message: &ClientMessage) -> ProtocolInstructionDescriptor {
let class = match message {
ClientMessage::Connect { .. } => ProtocolInstructionClass::Lifecycle,
ClientMessage::Event { .. } => ProtocolInstructionClass::Event,
ClientMessage::Ping { .. } => ProtocolInstructionClass::Diagnostics,
ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
ProtocolInstructionClass::Navigation
}
ClientMessage::UploadStart { .. }
| ClientMessage::UploadChunk { .. }
| ClientMessage::UploadComplete { .. } => ProtocolInstructionClass::Upload,
};
let durability = match message {
ClientMessage::Connect { .. } => ProtocolDurability::Resumable,
ClientMessage::Event { .. } => ProtocolDurability::Replayable,
ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
ProtocolDurability::Replayable
}
ClientMessage::UploadStart { .. }
| ClientMessage::UploadChunk { .. }
| ClientMessage::UploadComplete { .. }
| ClientMessage::Ping { .. } => ProtocolDurability::Ephemeral,
};
let render_effect = match message {
ClientMessage::PatchUrl { .. } | ClientMessage::Navigate { .. } => {
ProtocolRenderEffect::Navigation
}
_ => ProtocolRenderEffect::None,
};
ProtocolInstructionDescriptor {
class,
direction: ProtocolDirection::ClientToServer,
authority: ProtocolAuthority::UntrustedClient,
ordering: ProtocolOrdering::PerSessionOrdered,
durability,
render_effect,
}
}
pub fn describe_server_message(message: &ServerMessage) -> ProtocolInstructionDescriptor {
let (class, render_effect, durability) = match message {
ServerMessage::Hello { .. } => (
ProtocolInstructionClass::Lifecycle,
ProtocolRenderEffect::None,
ProtocolDurability::Resumable,
),
ServerMessage::Patch { .. } => (
ProtocolInstructionClass::Render,
ProtocolRenderEffect::Patch,
ProtocolDurability::Replayable,
),
ServerMessage::Diff { .. } => (
ProtocolInstructionClass::Render,
ProtocolRenderEffect::Diff,
ProtocolDurability::Replayable,
),
ServerMessage::StreamInsert { .. }
| ServerMessage::StreamDelete { .. }
| ServerMessage::StreamBatch { .. } => (
ProtocolInstructionClass::Stream,
ProtocolRenderEffect::Stream,
ProtocolDurability::Replayable,
),
ServerMessage::ChartSeriesAppend { .. }
| ServerMessage::ChartSeriesAppendMany { .. }
| ServerMessage::ChartSeriesReplace { .. }
| ServerMessage::ChartReset { .. }
| ServerMessage::ChartAnnotationUpsert { .. }
| ServerMessage::ChartAnnotationDelete { .. } => (
ProtocolInstructionClass::Chart,
ProtocolRenderEffect::None,
ProtocolDurability::Replayable,
),
ServerMessage::ToastPush { .. }
| ServerMessage::ToastDismiss { .. }
| ServerMessage::InboxUpsert { .. }
| ServerMessage::InboxDelete { .. } => (
ProtocolInstructionClass::Notification,
ProtocolRenderEffect::None,
ProtocolDurability::Replayable,
),
ServerMessage::GridReplace { .. } | ServerMessage::GridRowsReplace { .. } => (
ProtocolInstructionClass::Grid,
ProtocolRenderEffect::None,
ProtocolDurability::Replayable,
),
ServerMessage::InteropDispatch { .. } => (
ProtocolInstructionClass::Interop,
ProtocolRenderEffect::None,
ProtocolDurability::Ephemeral,
),
ServerMessage::Pong { .. } | ServerMessage::Error { .. } => (
ProtocolInstructionClass::Diagnostics,
ProtocolRenderEffect::None,
ProtocolDurability::Ephemeral,
),
ServerMessage::Redirect { .. }
| ServerMessage::PatchUrl { .. }
| ServerMessage::Navigate { .. } => (
ProtocolInstructionClass::Navigation,
ProtocolRenderEffect::Navigation,
ProtocolDurability::Replayable,
),
ServerMessage::UploadProgress { .. }
| ServerMessage::UploadComplete { .. }
| ServerMessage::UploadError { .. } => (
ProtocolInstructionClass::Upload,
ProtocolRenderEffect::None,
ProtocolDurability::Ephemeral,
),
};
ProtocolInstructionDescriptor {
class,
direction: ProtocolDirection::ServerToClient,
authority: ProtocolAuthority::TrustedServer,
ordering: ProtocolOrdering::PerSessionOrdered,
durability,
render_effect,
}
}
pub fn validate_client_message_invariants(
message: &ClientMessage,
) -> Result<(), ProtocolInvariantViolation> {
match message {
ClientMessage::Connect {
protocol,
trace_id,
span_id,
parent_span_id,
..
} => {
if !is_supported_protocol_version(protocol) {
return Err(ProtocolInvariantViolation::new(
"unsupported_protocol_version",
format!(
"client connect protocol '{}' is unsupported; expected one of {:?}",
protocol, SUPPORTED_PROTOCOL_VERSIONS
),
));
}
validate_hex_id("trace_id", trace_id.as_deref(), 32)?;
validate_hex_id("span_id", span_id.as_deref(), 16)?;
validate_hex_id("parent_span_id", parent_span_id.as_deref(), 16)?;
Ok(())
}
ClientMessage::Event { event, .. } => {
if event.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_event_name",
"event message must include a non-empty event name",
));
}
Ok(())
}
ClientMessage::PatchUrl { to } => validate_internal_path("patch_url", to),
ClientMessage::Navigate { to } => validate_internal_path("navigate", to),
ClientMessage::UploadStart {
upload_id,
event,
name,
..
} => {
if upload_id.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_upload_id",
"upload_start.upload_id cannot be empty",
));
}
if event.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_upload_event",
"upload_start.event cannot be empty",
));
}
if name.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_upload_name",
"upload_start.name cannot be empty",
));
}
Ok(())
}
ClientMessage::UploadChunk { upload_id, .. }
| ClientMessage::UploadComplete { upload_id } => {
if upload_id.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_upload_id",
"upload message upload_id cannot be empty",
));
}
Ok(())
}
ClientMessage::Ping { .. } => Ok(()),
}
}
pub fn validate_server_message_invariants(
message: &ServerMessage,
) -> Result<(), ProtocolInvariantViolation> {
match message {
ServerMessage::Hello {
session_id,
target,
protocol,
revision,
server_revision,
resume_status,
resume_reason,
resume_token,
resume_expires_in_ms,
} => {
if session_id.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_session_id",
"hello.session_id cannot be empty",
));
}
if target.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_target",
"hello.target cannot be empty",
));
}
if !is_supported_protocol_version(protocol) {
return Err(ProtocolInvariantViolation::new(
"unsupported_protocol_version",
format!(
"hello.protocol '{}' is unsupported; expected one of {:?}",
protocol, SUPPORTED_PROTOCOL_VERSIONS
),
));
}
if let Some(server_revision) = server_revision {
if server_revision < revision {
return Err(ProtocolInvariantViolation::new(
"server_revision_regression",
format!(
"hello.server_revision ({server_revision}) cannot be lower than hello.revision ({revision})",
),
));
}
}
if let Some(status) = resume_status {
if matches!(status, ResumeStatus::Fallback)
&& resume_reason
.as_ref()
.map(|reason| reason.trim().is_empty())
.unwrap_or(true)
{
return Err(ProtocolInvariantViolation::new(
"missing_resume_reason",
"hello.resume_reason must be present for fallback resume status",
));
}
}
if let Some(reason) = resume_reason {
if reason.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_resume_reason",
"hello.resume_reason cannot be empty when present",
));
}
}
if resume_expires_in_ms.is_some() && resume_token.is_none() {
return Err(ProtocolInvariantViolation::new(
"missing_resume_token",
"hello.resume_token must be present when resume_expires_in_ms is set",
));
}
Ok(())
}
ServerMessage::Patch { target, .. } => validate_non_empty("patch.target", target),
ServerMessage::Diff { target, slots, .. } => {
validate_non_empty("diff.target", target)?;
let mut indices = BTreeSet::new();
for slot in slots {
if !indices.insert(slot.index) {
return Err(ProtocolInvariantViolation::new(
"duplicate_diff_slot",
format!("diff contains duplicate slot index {}", slot.index),
));
}
}
Ok(())
}
ServerMessage::StreamInsert { target, id, .. }
| ServerMessage::StreamDelete { target, id } => {
validate_non_empty("stream.target", target)?;
validate_non_empty("stream.id", id)
}
ServerMessage::StreamBatch { target, operations } => {
validate_non_empty("stream_batch.target", target)?;
if operations.is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_stream_batch",
"stream_batch.operations cannot be empty",
));
}
Ok(())
}
ServerMessage::Redirect { to }
| ServerMessage::PatchUrl { to }
| ServerMessage::Navigate { to } => validate_internal_path("server_navigation", to),
ServerMessage::UploadProgress {
upload_id,
received,
total,
} => {
validate_non_empty("upload_progress.upload_id", upload_id)?;
if received > total {
return Err(ProtocolInvariantViolation::new(
"upload_progress_out_of_bounds",
format!("upload progress received {received} cannot exceed total {total}"),
));
}
Ok(())
}
ServerMessage::UploadComplete {
upload_id, name, ..
} => {
validate_non_empty("upload_complete.upload_id", upload_id)?;
validate_non_empty("upload_complete.name", name)
}
ServerMessage::UploadError {
upload_id, message, ..
} => {
validate_non_empty("upload_error.upload_id", upload_id)?;
validate_non_empty("upload_error.message", message)
}
ServerMessage::Error { message, .. } => validate_non_empty("error.message", message),
ServerMessage::ChartSeriesAppend { chart, series, .. }
| ServerMessage::ChartSeriesAppendMany { chart, series, .. }
| ServerMessage::ChartSeriesReplace { chart, series, .. } => {
validate_non_empty("chart", chart)?;
validate_non_empty("series", series)
}
ServerMessage::ChartReset { chart } => validate_non_empty("chart", chart),
ServerMessage::ChartAnnotationUpsert { chart, annotation } => {
validate_non_empty("chart", chart)?;
validate_non_empty("annotation.id", &annotation.id)
}
ServerMessage::ChartAnnotationDelete { chart, id } => {
validate_non_empty("chart", chart)?;
validate_non_empty("annotation.id", id)
}
ServerMessage::ToastPush { toast } => validate_non_empty("toast.id", &toast.id),
ServerMessage::ToastDismiss { id } => validate_non_empty("toast.id", id),
ServerMessage::InboxUpsert { item } => validate_non_empty("inbox.id", &item.id),
ServerMessage::InboxDelete { id } => validate_non_empty("inbox.id", id),
ServerMessage::GridReplace { grid, .. } | ServerMessage::GridRowsReplace { grid, .. } => {
validate_non_empty("grid", grid)
}
ServerMessage::InteropDispatch { dispatch } => {
validate_non_empty("interop.event", &dispatch.event)
}
ServerMessage::Pong { .. } => Ok(()),
}
}
pub fn validate_server_message_sequence(
messages: &[ServerMessage],
) -> Result<(), ProtocolInvariantViolation> {
let mut seen_hello = false;
let mut last_render_revision: Option<u64> = None;
for (index, message) in messages.iter().enumerate() {
validate_server_message_invariants(message)?;
if matches!(message, ServerMessage::Hello { .. }) {
if seen_hello {
return Err(ProtocolInvariantViolation::new(
"duplicate_hello",
"server transcript cannot contain multiple hello messages",
));
}
if index != 0 {
return Err(ProtocolInvariantViolation::new(
"hello_not_first",
"hello message must be first in a server transcript",
));
}
seen_hello = true;
continue;
}
let current_render_revision = match message {
ServerMessage::Patch { revision, .. } | ServerMessage::Diff { revision, .. } => {
Some(*revision)
}
_ => None,
};
if let Some(current_render_revision) = current_render_revision {
if let Some(previous) = last_render_revision {
if current_render_revision <= previous {
return Err(ProtocolInvariantViolation::new(
"non_monotonic_revision",
format!(
"render revisions must increase monotonically (previous={previous}, next={current_render_revision})"
),
));
}
}
last_render_revision = Some(current_render_revision);
}
}
Ok(())
}
fn validate_internal_path(label: &str, path: &str) -> Result<(), ProtocolInvariantViolation> {
if path.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_path",
format!("{label} path cannot be empty"),
));
}
let normalized = path.trim();
if !normalized.starts_with('/') {
return Err(ProtocolInvariantViolation::new(
"non_internal_path",
format!("{label} path must start with '/'"),
));
}
if normalized.starts_with("//")
|| normalized.starts_with("/\\")
|| normalized.contains("://")
|| normalized.starts_with("/http")
{
return Err(ProtocolInvariantViolation::new(
"external_path",
format!("{label} path must stay internal to the current application"),
));
}
Ok(())
}
fn validate_non_empty(label: &str, value: &str) -> Result<(), ProtocolInvariantViolation> {
if value.trim().is_empty() {
return Err(ProtocolInvariantViolation::new(
"empty_field",
format!("{label} cannot be empty"),
));
}
Ok(())
}
fn validate_hex_id(
label: &str,
value: Option<&str>,
expected_len: usize,
) -> Result<(), ProtocolInvariantViolation> {
let Some(value) = value else {
return Ok(());
};
if value.len() != expected_len {
return Err(ProtocolInvariantViolation::new(
"invalid_hex_id_length",
format!("{label} must contain exactly {expected_len} hex chars"),
));
}
if !value.chars().all(|char| char.is_ascii_hexdigit()) {
return Err(ProtocolInvariantViolation::new(
"invalid_hex_id_charset",
format!("{label} must contain only ascii hex chars"),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn valid_connect() -> ClientMessage {
ClientMessage::Connect {
protocol: PROTOCOL_VERSION_V1.to_string(),
session_id: None,
last_revision: None,
resume_token: None,
tenant_id: None,
trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
span_id: Some("00f067aa0ba902b7".to_string()),
parent_span_id: Some("89abcdef01234567".to_string()),
correlation_id: None,
request_id: None,
}
}
fn valid_hello() -> ServerMessage {
ServerMessage::Hello {
session_id: "sid".to_string(),
target: "root".to_string(),
revision: 0,
protocol: PROTOCOL_VERSION_V1.to_string(),
server_revision: Some(0),
resume_status: Some(ResumeStatus::Fresh),
resume_reason: None,
resume_token: None,
resume_expires_in_ms: None,
}
}
#[test]
fn client_connect_rejects_unsupported_protocol() {
let invalid = ClientMessage::Connect {
protocol: "shelly/2".to_string(),
session_id: None,
last_revision: None,
resume_token: None,
tenant_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
};
let err = validate_client_message_invariants(&invalid).expect_err("unsupported protocol");
assert_eq!(err.code, "unsupported_protocol_version");
}
#[test]
fn client_invariants_cover_hex_event_navigation_and_upload_rules() {
assert!(validate_client_message_invariants(&valid_connect()).is_ok());
let mut invalid_trace = valid_connect();
if let ClientMessage::Connect { trace_id, .. } = &mut invalid_trace {
*trace_id = Some("abcd".to_string());
}
let err = validate_client_message_invariants(&invalid_trace).expect_err("trace length");
assert_eq!(err.code, "invalid_hex_id_length");
let mut invalid_span = valid_connect();
if let ClientMessage::Connect { span_id, .. } = &mut invalid_span {
*span_id = Some("zzzzzzzzzzzzzzzz".to_string());
}
let err = validate_client_message_invariants(&invalid_span).expect_err("span charset");
assert_eq!(err.code, "invalid_hex_id_charset");
let err = validate_client_message_invariants(&ClientMessage::Event {
event: " ".to_string(),
target: None,
value: json!({}),
metadata: serde_json::Map::new(),
})
.expect_err("empty event should fail");
assert_eq!(err.code, "empty_event_name");
let err = validate_client_message_invariants(&ClientMessage::PatchUrl {
to: "users/1".to_string(),
})
.expect_err("patch_url must stay internal");
assert_eq!(err.code, "non_internal_path");
let err = validate_client_message_invariants(&ClientMessage::Navigate {
to: "/http://evil.example".to_string(),
})
.expect_err("navigate must stay internal");
assert_eq!(err.code, "external_path");
let err = validate_client_message_invariants(&ClientMessage::UploadStart {
upload_id: "".to_string(),
event: "uploaded".to_string(),
target: None,
name: "file.txt".to_string(),
size: 1,
content_type: None,
})
.expect_err("upload id is required");
assert_eq!(err.code, "empty_upload_id");
let err = validate_client_message_invariants(&ClientMessage::UploadStart {
upload_id: "u1".to_string(),
event: " ".to_string(),
target: None,
name: "file.txt".to_string(),
size: 1,
content_type: None,
})
.expect_err("upload event is required");
assert_eq!(err.code, "empty_upload_event");
let err = validate_client_message_invariants(&ClientMessage::UploadStart {
upload_id: "u1".to_string(),
event: "uploaded".to_string(),
target: None,
name: " ".to_string(),
size: 1,
content_type: None,
})
.expect_err("upload name is required");
assert_eq!(err.code, "empty_upload_name");
let err = validate_client_message_invariants(&ClientMessage::UploadChunk {
upload_id: " ".to_string(),
offset: 0,
data: "AA==".to_string(),
})
.expect_err("upload chunk id is required");
assert_eq!(err.code, "empty_upload_id");
let err = validate_client_message_invariants(&ClientMessage::UploadComplete {
upload_id: " ".to_string(),
})
.expect_err("upload complete id is required");
assert_eq!(err.code, "empty_upload_id");
assert!(validate_client_message_invariants(&ClientMessage::Ping { nonce: None }).is_ok());
}
#[test]
fn server_hello_invariants_cover_resume_and_protocol_rules() {
assert!(validate_server_message_invariants(&valid_hello()).is_ok());
let mut message = valid_hello();
if let ServerMessage::Hello { session_id, .. } = &mut message {
*session_id = " ".to_string();
}
let err = validate_server_message_invariants(&message).expect_err("session id required");
assert_eq!(err.code, "empty_session_id");
let mut message = valid_hello();
if let ServerMessage::Hello { target, .. } = &mut message {
*target = " ".to_string();
}
let err = validate_server_message_invariants(&message).expect_err("target required");
assert_eq!(err.code, "empty_target");
let mut message = valid_hello();
if let ServerMessage::Hello { protocol, .. } = &mut message {
*protocol = "shelly/9".to_string();
}
let err = validate_server_message_invariants(&message).expect_err("protocol unsupported");
assert_eq!(err.code, "unsupported_protocol_version");
let mut message = valid_hello();
if let ServerMessage::Hello {
revision,
server_revision,
..
} = &mut message
{
*revision = 3;
*server_revision = Some(2);
}
let err = validate_server_message_invariants(&message)
.expect_err("server revision cannot regress below revision");
assert_eq!(err.code, "server_revision_regression");
let mut message = valid_hello();
if let ServerMessage::Hello {
resume_status,
resume_reason,
..
} = &mut message
{
*resume_status = Some(ResumeStatus::Fallback);
*resume_reason = None;
}
let err =
validate_server_message_invariants(&message).expect_err("fallback reason is required");
assert_eq!(err.code, "missing_resume_reason");
let mut message = valid_hello();
if let ServerMessage::Hello {
resume_status,
resume_reason,
..
} = &mut message
{
*resume_status = Some(ResumeStatus::Fresh);
*resume_reason = Some(" ".to_string());
}
let err = validate_server_message_invariants(&message)
.expect_err("resume reason cannot be blank when present");
assert_eq!(err.code, "empty_resume_reason");
let mut message = valid_hello();
if let ServerMessage::Hello {
resume_token,
resume_expires_in_ms,
..
} = &mut message
{
*resume_token = None;
*resume_expires_in_ms = Some(60_000);
}
let err = validate_server_message_invariants(&message)
.expect_err("resume expiry requires resume token");
assert_eq!(err.code, "missing_resume_token");
}
#[test]
fn server_message_invariants_cover_render_stream_upload_and_navigation() {
let err = validate_server_message_invariants(&ServerMessage::Patch {
target: "".to_string(),
html: "<p>x</p>".to_string(),
revision: 1,
})
.expect_err("patch target is required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::Diff {
target: "root".to_string(),
revision: 1,
slots: vec![
crate::DynamicSlotPatch {
index: 1,
html: "a".to_string(),
},
crate::DynamicSlotPatch {
index: 1,
html: "b".to_string(),
},
],
})
.expect_err("duplicate diff slots should fail");
assert_eq!(err.code, "duplicate_diff_slot");
let err = validate_server_message_invariants(&ServerMessage::StreamInsert {
target: "root".to_string(),
id: "".to_string(),
html: "<li>x</li>".to_string(),
at: crate::StreamPosition::Append,
})
.expect_err("stream id is required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::StreamBatch {
target: "root".to_string(),
operations: vec![],
})
.expect_err("stream batch must not be empty");
assert_eq!(err.code, "empty_stream_batch");
let err = validate_server_message_invariants(&ServerMessage::Redirect {
to: "/http://evil.example".to_string(),
})
.expect_err("redirect must stay internal");
assert_eq!(err.code, "external_path");
let err = validate_server_message_invariants(&ServerMessage::UploadProgress {
upload_id: "u1".to_string(),
received: 2,
total: 1,
})
.expect_err("upload progress bounds");
assert_eq!(err.code, "upload_progress_out_of_bounds");
let err = validate_server_message_invariants(&ServerMessage::UploadComplete {
upload_id: "u1".to_string(),
name: "".to_string(),
size: 1,
content_type: None,
})
.expect_err("upload name required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::UploadError {
upload_id: "u1".to_string(),
message: "".to_string(),
code: Some("bad".to_string()),
})
.expect_err("upload error message required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::Error {
message: "".to_string(),
code: None,
})
.expect_err("error message required");
assert_eq!(err.code, "empty_field");
}
#[test]
fn server_message_invariants_cover_chart_notification_grid_and_interop() {
let err = validate_server_message_invariants(&ServerMessage::ChartSeriesAppend {
chart: "".to_string(),
series: "s1".to_string(),
point: crate::ChartPoint { x: 1.0, y: 2.0 },
})
.expect_err("chart id required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::ChartAnnotationUpsert {
chart: "chart-1".to_string(),
annotation: crate::ChartAnnotation {
id: "".to_string(),
x: 1.0,
label: "L".to_string(),
},
})
.expect_err("annotation id required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::ChartAnnotationDelete {
chart: "chart-1".to_string(),
id: "".to_string(),
})
.expect_err("annotation delete id required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::ToastPush {
toast: crate::Toast {
id: "".to_string(),
level: crate::ToastLevel::Info,
title: None,
message: "hello".to_string(),
ttl_ms: None,
},
})
.expect_err("toast id required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::InboxUpsert {
item: crate::InboxItem {
id: "".to_string(),
title: "t".to_string(),
body: "b".to_string(),
read: false,
inserted_at: None,
},
})
.expect_err("inbox id required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::GridReplace {
grid: "".to_string(),
state: crate::GridState {
columns: vec![],
rows: vec![],
total_rows: 0,
offset: 0,
limit: 20,
views: vec![],
active_view: None,
group_by: None,
query: None,
sort: None,
},
})
.expect_err("grid id required");
assert_eq!(err.code, "empty_field");
let err = validate_server_message_invariants(&ServerMessage::InteropDispatch {
dispatch: crate::JsInteropDispatch {
target: None,
event: "".to_string(),
detail: json!({}),
bubbles: true,
},
})
.expect_err("interop event required");
assert_eq!(err.code, "empty_field");
assert!(validate_server_message_invariants(&ServerMessage::Pong { nonce: None }).is_ok());
}
#[test]
fn server_sequence_requires_monotonic_revisions() {
let messages = vec![
ServerMessage::Patch {
target: "root".to_string(),
html: "<p>1</p>".to_string(),
revision: 2,
},
ServerMessage::Diff {
target: "root".to_string(),
revision: 2,
slots: vec![],
},
];
let err =
validate_server_message_sequence(&messages).expect_err("non monotonic should fail");
assert_eq!(err.code, "non_monotonic_revision");
}
#[test]
fn server_sequence_rejects_hello_order_and_duplicates() {
let err = validate_server_message_sequence(&[
ServerMessage::Patch {
target: "root".to_string(),
html: "<p>1</p>".to_string(),
revision: 1,
},
valid_hello(),
])
.expect_err("hello must be first");
assert_eq!(err.code, "hello_not_first");
let err = validate_server_message_sequence(&[valid_hello(), valid_hello()])
.expect_err("duplicate hello should fail");
assert_eq!(err.code, "duplicate_hello");
assert!(validate_server_message_sequence(&[
valid_hello(),
ServerMessage::Patch {
target: "root".to_string(),
html: "<p>1</p>".to_string(),
revision: 1,
},
ServerMessage::Diff {
target: "root".to_string(),
revision: 2,
slots: vec![],
},
])
.is_ok());
}
#[test]
fn hello_fallback_requires_reason() {
let message = ServerMessage::Hello {
session_id: "sid".to_string(),
target: "root".to_string(),
revision: 0,
protocol: PROTOCOL_VERSION_V1.to_string(),
server_revision: None,
resume_status: Some(ResumeStatus::Fallback),
resume_reason: None,
resume_token: Some("token".to_string()),
resume_expires_in_ms: Some(120_000),
};
let err =
validate_server_message_invariants(&message).expect_err("missing reason should fail");
assert_eq!(err.code, "missing_resume_reason");
}
#[test]
fn descriptors_map_to_expected_axes() {
let client = ClientMessage::Event {
event: "save".to_string(),
target: None,
value: json!({"id": 1}),
metadata: serde_json::Map::new(),
};
let server = ServerMessage::Patch {
target: "root".to_string(),
html: "<p>ok</p>".to_string(),
revision: 1,
};
let c = describe_client_message(&client);
let s = describe_server_message(&server);
assert_eq!(c.direction, ProtocolDirection::ClientToServer);
assert_eq!(c.authority, ProtocolAuthority::UntrustedClient);
assert_eq!(c.class, ProtocolInstructionClass::Event);
assert_eq!(s.direction, ProtocolDirection::ServerToClient);
assert_eq!(s.authority, ProtocolAuthority::TrustedServer);
assert_eq!(s.render_effect, ProtocolRenderEffect::Patch);
}
#[test]
fn describe_client_message_covers_every_instruction_class_branch() {
let descriptors = vec![
describe_client_message(&ClientMessage::Connect {
protocol: PROTOCOL_VERSION_V1.to_string(),
session_id: None,
last_revision: None,
resume_token: None,
tenant_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
}),
describe_client_message(&ClientMessage::Event {
event: "save".to_string(),
target: None,
value: json!({"ok": true}),
metadata: serde_json::Map::new(),
}),
describe_client_message(&ClientMessage::Ping { nonce: None }),
describe_client_message(&ClientMessage::PatchUrl {
to: "/users".to_string(),
}),
describe_client_message(&ClientMessage::Navigate {
to: "/users/1".to_string(),
}),
describe_client_message(&ClientMessage::UploadStart {
upload_id: "u1".to_string(),
event: "upload".to_string(),
target: None,
name: "avatar.png".to_string(),
size: 1,
content_type: Some("image/png".to_string()),
}),
describe_client_message(&ClientMessage::UploadChunk {
upload_id: "u1".to_string(),
offset: 0,
data: "AA==".to_string(),
}),
describe_client_message(&ClientMessage::UploadComplete {
upload_id: "u1".to_string(),
}),
];
assert!(descriptors.iter().all(|descriptor| {
descriptor.direction == ProtocolDirection::ClientToServer
&& descriptor.authority == ProtocolAuthority::UntrustedClient
&& descriptor.ordering == ProtocolOrdering::PerSessionOrdered
}));
assert!(descriptors
.iter()
.any(|descriptor| descriptor.class == ProtocolInstructionClass::Lifecycle));
assert!(descriptors
.iter()
.any(|descriptor| descriptor.class == ProtocolInstructionClass::Event));
assert!(descriptors
.iter()
.any(|descriptor| descriptor.class == ProtocolInstructionClass::Diagnostics));
assert!(descriptors
.iter()
.any(|descriptor| descriptor.class == ProtocolInstructionClass::Navigation));
assert!(descriptors
.iter()
.any(|descriptor| descriptor.class == ProtocolInstructionClass::Upload));
}
#[test]
fn describe_server_message_and_invariants_cover_remaining_variant_matrix() {
let messages = vec![
valid_hello(),
ServerMessage::Patch {
target: "root".to_string(),
html: "<p>ok</p>".to_string(),
revision: 1,
},
ServerMessage::Diff {
target: "root".to_string(),
revision: 2,
slots: vec![crate::DynamicSlotPatch {
index: 0,
html: "ok".to_string(),
}],
},
ServerMessage::StreamInsert {
target: "items".to_string(),
id: "item-1".to_string(),
html: "<li>One</li>".to_string(),
at: crate::StreamPosition::Append,
},
ServerMessage::StreamDelete {
target: "items".to_string(),
id: "item-1".to_string(),
},
ServerMessage::StreamBatch {
target: "items".to_string(),
operations: vec![
crate::StreamBatchOperation::Insert {
id: "item-2".to_string(),
html: "<li>Two</li>".to_string(),
at: crate::StreamPosition::Append,
},
crate::StreamBatchOperation::Delete {
id: "item-1".to_string(),
},
],
},
ServerMessage::ChartSeriesAppend {
chart: "throughput".to_string(),
series: "p95".to_string(),
point: crate::ChartPoint { x: 1.0, y: 2.0 },
},
ServerMessage::ChartSeriesAppendMany {
chart: "throughput".to_string(),
series: "p95".to_string(),
points: vec![crate::ChartPoint { x: 2.0, y: 3.0 }],
},
ServerMessage::ChartSeriesReplace {
chart: "throughput".to_string(),
series: "p95".to_string(),
points: vec![crate::ChartPoint { x: 3.0, y: 4.0 }],
},
ServerMessage::ChartReset {
chart: "throughput".to_string(),
},
ServerMessage::ChartAnnotationUpsert {
chart: "throughput".to_string(),
annotation: crate::ChartAnnotation {
id: "a-1".to_string(),
x: 4.0,
label: "deploy".to_string(),
},
},
ServerMessage::ChartAnnotationDelete {
chart: "throughput".to_string(),
id: "a-1".to_string(),
},
ServerMessage::ToastPush {
toast: crate::Toast {
id: "toast-1".to_string(),
level: crate::ToastLevel::Info,
title: Some("Info".to_string()),
message: "ok".to_string(),
ttl_ms: Some(2_000),
},
},
ServerMessage::ToastDismiss {
id: "toast-1".to_string(),
},
ServerMessage::InboxUpsert {
item: crate::InboxItem {
id: "inbox-1".to_string(),
title: "Ready".to_string(),
body: "Done".to_string(),
read: false,
inserted_at: None,
},
},
ServerMessage::InboxDelete {
id: "inbox-1".to_string(),
},
ServerMessage::GridReplace {
grid: "accounts".to_string(),
state: crate::GridState {
columns: vec![],
rows: vec![],
total_rows: 0,
offset: 0,
limit: 25,
views: vec![],
active_view: None,
group_by: None,
query: None,
sort: None,
},
},
ServerMessage::GridRowsReplace {
grid: "accounts".to_string(),
window: crate::GridRowsWindow {
offset: 0,
total_rows: 0,
rows: vec![],
},
},
ServerMessage::InteropDispatch {
dispatch: crate::JsInteropDispatch {
target: Some("#root".to_string()),
event: "interop:event".to_string(),
detail: json!({"k": "v"}),
bubbles: true,
},
},
ServerMessage::Pong { nonce: None },
ServerMessage::Redirect {
to: "/dashboard".to_string(),
},
ServerMessage::PatchUrl {
to: "/dashboard?page=2".to_string(),
},
ServerMessage::Navigate {
to: "/settings".to_string(),
},
ServerMessage::UploadProgress {
upload_id: "u1".to_string(),
received: 1,
total: 2,
},
ServerMessage::UploadComplete {
upload_id: "u1".to_string(),
name: "avatar.png".to_string(),
size: 2,
content_type: Some("image/png".to_string()),
},
ServerMessage::UploadError {
upload_id: "u1".to_string(),
message: "rejected".to_string(),
code: Some("too_large".to_string()),
},
ServerMessage::Error {
message: "oops".to_string(),
code: Some("runtime".to_string()),
},
];
for message in &messages {
let descriptor = describe_server_message(message);
assert_eq!(descriptor.direction, ProtocolDirection::ServerToClient);
assert_eq!(descriptor.authority, ProtocolAuthority::TrustedServer);
assert_eq!(descriptor.ordering, ProtocolOrdering::PerSessionOrdered);
assert!(validate_server_message_invariants(message).is_ok());
}
}
}