Verify qualified scenario values through HTTP and record R6 evidence
This commit is contained in:
@@ -315,3 +315,420 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_source_and_restart()
|
||||
{
|
||||
use oa_core::{
|
||||
Element, FieldStorage, MessageKind, MessageValue, OccurrenceBody, ParameterDirection,
|
||||
PortContract, RelationBody, ScenarioViewpoint, TypeRef,
|
||||
};
|
||||
fn values(model: &Project, occurrence: Id) -> &[MessageValue] {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = model.elements[&occurrence].shared()
|
||||
else {
|
||||
panic!("Expected message");
|
||||
};
|
||||
values
|
||||
}
|
||||
fn edited_values(model: &Project, occurrence: Id, next: Vec<MessageValue>) -> Element {
|
||||
let mut element = model.elements[&occurrence].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = element.shared_mut()
|
||||
else {
|
||||
panic!("Expected message");
|
||||
};
|
||||
*values = next;
|
||||
element
|
||||
}
|
||||
let mut model = documented_data();
|
||||
let operation = id(&model, "navigate");
|
||||
let coordinate = id(&model, "coordinate");
|
||||
let located = id(&model, "located");
|
||||
let latitude = id(&model, "latitude");
|
||||
let origin = id(&model, "origin");
|
||||
let destination = id(&model, "destination");
|
||||
let accepted = id(&model, "accepted");
|
||||
// The second parameter inherits the very same latitude identity.
|
||||
let Some(SharedBody::DataField { type_ref, .. }) =
|
||||
model.elements.get_mut(&destination).unwrap().shared_mut()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
*type_ref = TypeRef::Named(located);
|
||||
let next = model
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Sa,
|
||||
"Next coordinate",
|
||||
Some(coordinate),
|
||||
)
|
||||
.unwrap();
|
||||
*model.elements.get_mut(&next).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(coordinate),
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
unit: String::new(),
|
||||
default: None,
|
||||
};
|
||||
let requester = model
|
||||
.add_element(Kind::Function, Layer::Sa, "Request navigation", None)
|
||||
.unwrap();
|
||||
let provider = model
|
||||
.add_element(Kind::Function, Layer::Sa, "Provide navigation", None)
|
||||
.unwrap();
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
for (port, contract) in [
|
||||
(
|
||||
output,
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![operation],
|
||||
},
|
||||
),
|
||||
(
|
||||
input,
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![operation],
|
||||
},
|
||||
),
|
||||
] {
|
||||
let (owner, name) = if port == output {
|
||||
(requester, "Navigation output")
|
||||
} else {
|
||||
(provider, "Navigation input")
|
||||
};
|
||||
let element = oa_core::ports::make_port(&model, port, owner, name, contract).unwrap();
|
||||
model.elements.insert(port, element);
|
||||
}
|
||||
let exchange = model.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Navigation request",
|
||||
requester,
|
||||
provider,
|
||||
);
|
||||
model.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: requester,
|
||||
target: provider,
|
||||
source_port: output,
|
||||
target_port: input,
|
||||
items: vec![operation],
|
||||
};
|
||||
let scenario = model
|
||||
.add_element(Kind::Scenario, Layer::Sa, "Navigation conversation", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
model.elements.get_mut(&scenario).unwrap().shared_mut()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::Functional;
|
||||
let sender = model
|
||||
.add_element(Kind::Lifeline, Layer::Sa, "Requester line", Some(scenario))
|
||||
.unwrap();
|
||||
let receiver = model
|
||||
.add_element(Kind::Lifeline, Layer::Sa, "Provider line", Some(scenario))
|
||||
.unwrap();
|
||||
for (line, represented) in [(sender, requester), (receiver, provider)] {
|
||||
*model.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let call = model
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Sa,
|
||||
"Navigation call",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
let reply = model
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Sa,
|
||||
"Navigation reply",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
for (occurrence, message_kind, from, to, reply_to) in [
|
||||
(call, MessageKind::Call, sender, receiver, None),
|
||||
(reply, MessageKind::Reply, receiver, sender, Some(call)),
|
||||
] {
|
||||
*model
|
||||
.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(from),
|
||||
receiver: Some(to),
|
||||
message_kind,
|
||||
reply_to,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
model
|
||||
.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.extend([call, reply]);
|
||||
oa_core::validate_candidate(&model).unwrap();
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (resource, _) = server.import(&model).await;
|
||||
let before = server.get(resource).await;
|
||||
let call_values = vec![
|
||||
MessageValue {
|
||||
path: vec![origin],
|
||||
field: latitude,
|
||||
expression: " origin.latitude\n+ 0.25 ".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![destination],
|
||||
field: latitude,
|
||||
expression: "destination.latitude".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![origin, next],
|
||||
field: latitude,
|
||||
expression: "previous.latitude".into(),
|
||||
},
|
||||
];
|
||||
let reply_values = vec![MessageValue {
|
||||
path: vec![],
|
||||
field: accepted,
|
||||
expression: "true".into(),
|
||||
}];
|
||||
let response = commands(
|
||||
&server,
|
||||
&before,
|
||||
vec![
|
||||
Command::EditElement(call, edited_values(&model, call, call_values.clone())),
|
||||
Command::EditElement(reply, edited_values(&model, reply, reply_values.clone())),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let authored = server.get(resource).await;
|
||||
assert_eq!(authored.revision, before.revision + 1);
|
||||
assert_eq!(values(&authored.model, call), call_values);
|
||||
assert_eq!(values(&authored.model, reply), reply_values);
|
||||
assert_eq!(
|
||||
authored.model.elements[&latitude],
|
||||
model.elements[&latitude]
|
||||
);
|
||||
assert_pair(&authored);
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let bytes = std::fs::read(&filename).unwrap();
|
||||
for (occurrence, value, code) in [
|
||||
(
|
||||
call,
|
||||
MessageValue {
|
||||
path: vec![],
|
||||
field: accepted,
|
||||
expression: "wrong call phase".into(),
|
||||
},
|
||||
"message_parameter_phase",
|
||||
),
|
||||
(
|
||||
reply,
|
||||
MessageValue {
|
||||
path: vec![origin],
|
||||
field: latitude,
|
||||
expression: "wrong reply phase".into(),
|
||||
},
|
||||
"message_parameter_phase",
|
||||
),
|
||||
(
|
||||
call,
|
||||
MessageValue {
|
||||
path: vec![origin, accepted],
|
||||
field: latitude,
|
||||
expression: "unrelated hop".into(),
|
||||
},
|
||||
"message_value_field",
|
||||
),
|
||||
] {
|
||||
let invalid = edited_values(&authored.model, occurrence, vec![value]);
|
||||
let mut invalid_model = authored.model.clone();
|
||||
invalid_model.elements.insert(occurrence, invalid.clone());
|
||||
let response = commands(
|
||||
&server,
|
||||
&authored,
|
||||
vec![Command::EditElement(occurrence, invalid)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let problem: Value = response.json().await.unwrap();
|
||||
assert_eq!(problem["code"], "command_rejected", "{problem}");
|
||||
assert!(
|
||||
problem["detail"].as_str().unwrap().starts_with(code),
|
||||
"{problem}"
|
||||
);
|
||||
// Transactions report the first rejection; validation supplies located issues.
|
||||
let validation: Value = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{resource}/validation")))
|
||||
.json(&json!({"format":"json", "model":invalid_model}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validation["valid"], false);
|
||||
assert!(
|
||||
validation["issues"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|issue| issue["code"] == code
|
||||
&& issue["object_id"] == json!(occurrence)
|
||||
&& issue["field"] == "values.path"),
|
||||
"{problem}"
|
||||
);
|
||||
let unchanged = server.get(resource).await;
|
||||
assert_eq!(unchanged.etag(), authored.etag());
|
||||
assert_eq!(unchanged.model, authored.model);
|
||||
assert_eq!(unchanged.source_project, authored.source_project);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
|
||||
}
|
||||
|
||||
// Source transport changes one argument without conflating the shared leaf.
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let edited_source = source.replace(
|
||||
"\"destination.latitude\"",
|
||||
"\"destination.corrected_latitude\"",
|
||||
);
|
||||
assert_ne!(edited_source, source);
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{resource}/source")))
|
||||
.header("if-match", authored.etag())
|
||||
.header("content-type", "text/plain")
|
||||
.body(edited_source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let source_edited = server.get(resource).await;
|
||||
assert_eq!(source_edited.revision, authored.revision + 1);
|
||||
let mut expected_call = call_values.clone();
|
||||
expected_call[1].expression = "destination.corrected_latitude".into();
|
||||
assert_eq!(values(&source_edited.model, call), expected_call);
|
||||
assert_eq!(values(&source_edited.model, reply), reply_values);
|
||||
assert_pair(&source_edited);
|
||||
|
||||
// Reidentify every kind of enclosing hop, the shared terminal, and its base type.
|
||||
let mapping = [origin, destination, next, latitude, coordinate]
|
||||
.into_iter()
|
||||
.map(|old| (old, Id::new_v4()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let response = commands(
|
||||
&server,
|
||||
&source_edited,
|
||||
mapping
|
||||
.iter()
|
||||
.map(|(old, new)| Command::Reidentify(*old, *new))
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let changed = server.get(resource).await;
|
||||
assert_eq!(changed.revision, source_edited.revision + 1);
|
||||
for value in &mut expected_call {
|
||||
value.path = value.path.iter().map(|hop| mapping[hop]).collect();
|
||||
value.field = mapping[&value.field];
|
||||
}
|
||||
assert_eq!(values(&changed.model, call), expected_call);
|
||||
assert_eq!(values(&changed.model, reply), reply_values);
|
||||
assert_eq!(
|
||||
changed.model.elements[&mapping[&latitude]].parent,
|
||||
Some(mapping[&coordinate])
|
||||
);
|
||||
assert!(
|
||||
matches!(changed.model.elements[&located].shared(), Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: Some(base) }, ..
|
||||
}) if *base == mapping[&coordinate])
|
||||
);
|
||||
assert!(
|
||||
oa_core::data::effective_fields(&changed.model, located)
|
||||
.iter()
|
||||
.any(|field| field.id == mapping[&latitude])
|
||||
);
|
||||
for old in mapping.keys() {
|
||||
assert!(!changed.model.contains(*old));
|
||||
}
|
||||
assert!(
|
||||
matches!(changed.model.elements[&reply].shared(), Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { reply_to: Some(correlation), .. }, ..
|
||||
}) if *correlation == call)
|
||||
);
|
||||
assert_pair(&changed);
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
|
||||
let saved = std::fs::read(&filename).unwrap();
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.etag(), changed.etag());
|
||||
assert_eq!(reopened.model, changed.model);
|
||||
assert_eq!(reopened.source_project, changed.source_project);
|
||||
assert_eq!(values(&reopened.model, call), expected_call);
|
||||
assert_eq!(values(&reopened.model, reply), reply_values);
|
||||
assert_pair(&reopened);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), saved);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user