feat(v1): harden typed runtime boundaries
Elect one canonical EffectBatch codec, remove the parallel postcard API, and strengthen fail-closed host, form, sync, WASM, macro, generated-contract, and test-harness proofs with mutation-driven coverage. req: wire/008 req: wire/009 req: wire/010 req: push/008 req: client_local/015 req: client_local/016 req: client_local/017 req: client_local/018 req: client_local/019 req: sync/024 req: sync/025 req: sync/026 req: sync/027 req: sync/028 req: sync/029 req: test/020 req: test/021
This commit is contained in:
+244
-4
@@ -411,7 +411,6 @@ impl SyncEffect {
|
||||
}
|
||||
|
||||
pub fn send_patch(patch: FlatPatch) -> Self {
|
||||
patch.validate().expect("FlatPatch must remain valid");
|
||||
Self(vec![Effect::Emit {
|
||||
name: PATCH_EVENT.to_owned(),
|
||||
payload: patch.payload(),
|
||||
@@ -426,7 +425,6 @@ impl SyncEffect {
|
||||
projection: impl IntoEffect,
|
||||
fingerprint: BuildFingerprint,
|
||||
) -> Self {
|
||||
patch.validate().expect("FlatPatch must remain valid");
|
||||
let projection = projection.into_batch(fingerprint);
|
||||
let projection_wire = projection
|
||||
.to_wire()
|
||||
@@ -469,9 +467,30 @@ mod tests {
|
||||
.into_batch(hemx_core::BuildFingerprint(7));
|
||||
assert_eq!(batch.ops.len(), 2);
|
||||
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
|
||||
assert!(
|
||||
matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction"))
|
||||
let Effect::Emit { name, payload } = &batch.ops[1] else {
|
||||
panic!("durable sync must end with its patch event");
|
||||
};
|
||||
assert_eq!(name, PATCH_EVENT);
|
||||
let payload: serde_json::Value = serde_json::from_str(payload).unwrap();
|
||||
let expected_projection = EffectBatch {
|
||||
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
|
||||
fingerprint: BuildFingerprint(7),
|
||||
ops: vec![Effect::Emit {
|
||||
name: "projected".into(),
|
||||
payload: "card:1".into(),
|
||||
}],
|
||||
}
|
||||
.to_wire();
|
||||
assert_eq!(
|
||||
payload["projection"],
|
||||
serde_json::Value::Array(
|
||||
expected_projection
|
||||
.into_iter()
|
||||
.map(serde_json::Value::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
assert_eq!(payload["patch"]["idempotencyKey"], INTERACTION_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -549,6 +568,227 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_boundary_and_errors_are_explicit() {
|
||||
let valid = format!("a{}", "x".repeat(127));
|
||||
assert_eq!(Channel::new(&valid).unwrap().as_str(), valid);
|
||||
for (value, expected, message) in [
|
||||
(
|
||||
"".to_owned(),
|
||||
ChannelError::Empty,
|
||||
"sync channel must not be empty",
|
||||
),
|
||||
(
|
||||
format!("a{}", "x".repeat(128)),
|
||||
ChannelError::TooLong,
|
||||
"sync channel is too long",
|
||||
),
|
||||
(
|
||||
"board/alpha".to_owned(),
|
||||
ChannelError::InvalidCharacter,
|
||||
"sync channel contains an invalid character",
|
||||
),
|
||||
] {
|
||||
let error = Channel::new(value).expect_err("invalid channel must fail closed");
|
||||
assert_eq!(error, expected);
|
||||
assert_eq!(error.to_string(), message);
|
||||
}
|
||||
// req: sync/024 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_leave_and_projection_preserve_observable_state() {
|
||||
let channel = Channel::new("board").unwrap();
|
||||
let mut tracker = PresenceTracker::default();
|
||||
assert_eq!(
|
||||
tracker.leave(&channel, &"missing"),
|
||||
PresenceChange {
|
||||
changed: false,
|
||||
count: 0,
|
||||
}
|
||||
);
|
||||
tracker.join(channel.clone(), "ada");
|
||||
tracker.join(channel.clone(), "grace");
|
||||
assert_eq!(tracker.count(&channel), 2);
|
||||
assert_eq!(
|
||||
tracker.leave(&channel, &"ada"),
|
||||
PresenceChange {
|
||||
changed: true,
|
||||
count: 1,
|
||||
}
|
||||
);
|
||||
assert_eq!(tracker.count(&channel), 1);
|
||||
assert_eq!(tracker.leave(&channel, &"grace").count, 0);
|
||||
assert_eq!(tracker.count(&channel), 0);
|
||||
assert!(!tracker.members.contains_key(&channel));
|
||||
|
||||
let effect = Effect::Emit {
|
||||
name: "presence".into(),
|
||||
payload: "joined".into(),
|
||||
};
|
||||
let projection = PresenceProjection::new(channel, effect.clone());
|
||||
assert_eq!(projection.into_batch(BuildFingerprint(1)).ops, vec![effect]);
|
||||
// test req: sync/003 req: sync/005
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_patch_enforces_identifier_key_and_value_boundaries() {
|
||||
let valid_identifier = format!("a{}", "x".repeat(127));
|
||||
let valid_key = format!("a{}", "x".repeat(63));
|
||||
let patch = FlatPatch::new(
|
||||
&valid_identifier,
|
||||
&valid_identifier,
|
||||
&valid_key,
|
||||
PatchValue::String("x".repeat(4096)),
|
||||
)
|
||||
.expect("documented patch limits are inclusive");
|
||||
assert_eq!(patch.validate(), Ok(()));
|
||||
|
||||
assert_eq!(
|
||||
FlatPatch::new("", "operation", "field", PatchValue::Boolean(true)),
|
||||
Err(PatchError::Empty("idempotency_key"))
|
||||
);
|
||||
assert_eq!(
|
||||
FlatPatch::new("actor", "", "field", PatchValue::Boolean(true)),
|
||||
Err(PatchError::Empty("operation_id"))
|
||||
);
|
||||
assert_eq!(
|
||||
FlatPatch::new(
|
||||
"actor",
|
||||
"operation",
|
||||
format!("a{}", "x".repeat(64)),
|
||||
PatchValue::Boolean(true),
|
||||
),
|
||||
Err(PatchError::TooLong("key"))
|
||||
);
|
||||
let cases = [
|
||||
FlatPatch::new(INTERACTION_ID, "", "field", PatchValue::Boolean(true)),
|
||||
FlatPatch::new("", INTERACTION_ID, "field", PatchValue::Boolean(true)),
|
||||
FlatPatch::new("actor", "", "field", PatchValue::Boolean(true)),
|
||||
FlatPatch::new(
|
||||
format!("a{}", "x".repeat(128)),
|
||||
"operation",
|
||||
"field",
|
||||
PatchValue::Boolean(true),
|
||||
),
|
||||
FlatPatch::new("actor", "bad operation", "field", PatchValue::Boolean(true)),
|
||||
FlatPatch::new(
|
||||
"actor",
|
||||
format!("a{}", "x".repeat(128)),
|
||||
"field",
|
||||
PatchValue::Boolean(true),
|
||||
),
|
||||
FlatPatch::new("actor", "operation", "", PatchValue::Boolean(true)),
|
||||
FlatPatch::new(
|
||||
"actor",
|
||||
"operation",
|
||||
format!("a{}", "x".repeat(64)),
|
||||
PatchValue::Boolean(true),
|
||||
),
|
||||
FlatPatch::new("actor", "operation", "1field", PatchValue::Boolean(true)),
|
||||
FlatPatch::new("actor", "operation", "field.dot", PatchValue::Boolean(true)),
|
||||
FlatPatch::new(
|
||||
"actor",
|
||||
"operation",
|
||||
"field",
|
||||
PatchValue::String("x".repeat(4097)),
|
||||
),
|
||||
FlatPatch::new(
|
||||
"actor",
|
||||
"operation",
|
||||
"field",
|
||||
PatchValue::Integer(9_007_199_254_740_992),
|
||||
),
|
||||
FlatPatch::new(
|
||||
"actor",
|
||||
"operation",
|
||||
"field",
|
||||
PatchValue::Integer(-9_007_199_254_740_992),
|
||||
),
|
||||
];
|
||||
for result in cases {
|
||||
assert!(result.is_err(), "invalid patch boundary must fail closed");
|
||||
}
|
||||
assert!(FlatPatch::new(
|
||||
"actor",
|
||||
"operation",
|
||||
"field",
|
||||
PatchValue::Integer(-9_007_199_254_740_991),
|
||||
)
|
||||
.is_ok());
|
||||
assert_eq!(
|
||||
FlatPatch::for_interaction("", PatchValue::Boolean(true)),
|
||||
Err(PatchError::Empty("key"))
|
||||
);
|
||||
assert_eq!(
|
||||
FlatPatch::for_interaction("field", PatchValue::String("x".repeat(4097)),),
|
||||
Err(PatchError::ValueTooLong)
|
||||
);
|
||||
assert_eq!(PatchError::ReservedKey.to_string(), "patch key is reserved");
|
||||
assert_eq!(
|
||||
PatchError::ValueTooLong.to_string(),
|
||||
"patch string value is too long"
|
||||
);
|
||||
// req: sync/017 test req: sync/025 test req: sync/026 test req: sync/027 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_patch_json_round_trips_escaped_values_and_rejects_invalid_input() {
|
||||
let escaped = "quote:\" slash:\\ newline:\n return:\r tab:\t control:\u{1f}";
|
||||
let patch = FlatPatch::new(
|
||||
"actor:1",
|
||||
"operation-1",
|
||||
"field_name",
|
||||
PatchValue::String(escaped.into()),
|
||||
)
|
||||
.unwrap();
|
||||
let payload = patch.payload();
|
||||
let decoded: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
||||
assert_eq!(decoded["value"], escaped);
|
||||
assert_eq!(serde_json::from_str::<FlatPatch>(&payload).unwrap(), patch);
|
||||
|
||||
for (json, expected) in [
|
||||
(
|
||||
r#"{"schemaVersion":2,"idempotencyKey":"actor","operationId":"op","key":"field","value":true}"#,
|
||||
"unsupported patch schema version 2",
|
||||
),
|
||||
(
|
||||
r#"{"schemaVersion":1,"idempotencyKey":"actor","operationId":"op","key":"field","value":true,"extra":1}"#,
|
||||
"unknown field `extra`",
|
||||
),
|
||||
(
|
||||
r#"{"schemaVersion":1,"idempotencyKey":"actor","operationId":"op","key":"1field","value":true}"#,
|
||||
"key contains an invalid character",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_str::<FlatPatch>(json)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains(expected),
|
||||
"invalid JSON must report {expected}"
|
||||
);
|
||||
}
|
||||
// req: sync/014 test req: sync/028 test req: sync/029 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_patch_emits_the_canonical_payload() {
|
||||
let patch = FlatPatch::for_interaction("done", PatchValue::Boolean(true)).unwrap();
|
||||
assert_eq!(patch.validate(), Ok(()));
|
||||
let expected = patch.payload();
|
||||
assert_eq!(
|
||||
SyncEffect::send_patch(patch)
|
||||
.into_batch(BuildFingerprint(4))
|
||||
.ops,
|
||||
vec![Effect::Emit {
|
||||
name: PATCH_EVENT.into(),
|
||||
payload: expected,
|
||||
}]
|
||||
);
|
||||
// test req: sync/002 req: sync/009
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_is_flat_and_rejects_reserved_keys() {
|
||||
let patch = FlatPatch::new(
|
||||
|
||||
Reference in New Issue
Block a user