2085 lines
72 KiB
Rust
2085 lines
72 KiB
Rust
use oa_core::{Command, Id, Kind, Layer, Project, RelationKind};
|
|
use oa_server::{
|
|
ServerConfig, app,
|
|
store::{Store, StoredProject},
|
|
};
|
|
use reqwest::{Client, StatusCode};
|
|
use serde_json::{Value, json};
|
|
use std::{path::Path, sync::Arc};
|
|
use tokio::{sync::oneshot, task::JoinHandle};
|
|
|
|
#[path = "http/data_contracts.rs"]
|
|
mod data_contracts;
|
|
#[path = "http/port_anchors.rs"]
|
|
mod port_anchors;
|
|
#[path = "http/source_projects.rs"]
|
|
mod source_projects;
|
|
#[path = "http/semantic_transitions.rs"]
|
|
mod semantic_transitions;
|
|
|
|
fn v2_missing_ports() -> Value {
|
|
let mut old: Value = serde_json::from_slice(include_bytes!(
|
|
"../../oa-core/tests/fixtures/v2-coastal-rescue.json"
|
|
))
|
|
.unwrap();
|
|
for relation in old["relations"].as_object_mut().unwrap().values_mut() {
|
|
if let Some(exchange) = relation["body"].get_mut("FunctionalExchange") {
|
|
exchange["source_port"] = Value::Null;
|
|
exchange["target_port"] = Value::Null;
|
|
}
|
|
}
|
|
old
|
|
}
|
|
|
|
struct TestServer {
|
|
base: String,
|
|
client: Client,
|
|
stop: oneshot::Sender<()>,
|
|
task: JoinHandle<()>,
|
|
}
|
|
|
|
impl TestServer {
|
|
async fn start(directory: &Path, token: Option<String>) -> Self {
|
|
let directory = directory.to_owned();
|
|
let store = Arc::new(
|
|
tokio::task::spawn_blocking(move || Store::open(directory))
|
|
.await
|
|
.unwrap()
|
|
.unwrap(),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
let (stop, done) = oneshot::channel();
|
|
let router = app(store, token);
|
|
let task = tokio::spawn(async move {
|
|
axum::serve(listener, router)
|
|
.with_graceful_shutdown(async {
|
|
let _ = done.await;
|
|
})
|
|
.await
|
|
.unwrap();
|
|
});
|
|
Self {
|
|
base,
|
|
client: Client::new(),
|
|
stop,
|
|
task,
|
|
}
|
|
}
|
|
|
|
fn url(&self, path: &str) -> String {
|
|
format!("{}/api/v1{path}", self.base)
|
|
}
|
|
|
|
async fn create(&self) -> (Id, String) {
|
|
let response = self
|
|
.client
|
|
.post(self.url("/projects"))
|
|
.json(&json!({"name":"Test mission"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::CREATED);
|
|
let etag = response.headers()["etag"].to_str().unwrap().to_owned();
|
|
let summary: Value = response.json().await.unwrap();
|
|
(
|
|
summary["resource_id"].as_str().unwrap().parse().unwrap(),
|
|
etag,
|
|
)
|
|
}
|
|
|
|
async fn import(&self, project: &Project) -> (Id, String) {
|
|
let response = self
|
|
.client
|
|
.post(self.url("/projects/import"))
|
|
.json(project)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let etag = response
|
|
.headers()
|
|
.get("etag")
|
|
.map(|value| value.to_str().unwrap().to_owned());
|
|
let body: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED, "{body}");
|
|
(
|
|
body["resource_id"].as_str().unwrap().parse().unwrap(),
|
|
etag.unwrap(),
|
|
)
|
|
}
|
|
|
|
async fn get(&self, id: Id) -> StoredProject {
|
|
self.client
|
|
.get(self.url(&format!("/projects/{id}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn stop(self) {
|
|
let _ = self.stop.send(());
|
|
drop(self.client);
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), self.task)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
fn graph() -> Project {
|
|
let mut model = Project::empty();
|
|
model.name = "Harbor assistance".into();
|
|
let actor = model
|
|
.add_element(Kind::Actor, Layer::Oa, "Vessel", None)
|
|
.unwrap();
|
|
let first = model
|
|
.add_element(Kind::Activity, Layer::Oa, "Report distress", None)
|
|
.unwrap();
|
|
let second = model
|
|
.add_element(Kind::Activity, Layer::Oa, "Assess distress", None)
|
|
.unwrap();
|
|
model.relate(RelationKind::Allocation, "Reports", actor, first);
|
|
model.relate(RelationKind::Interaction, "Distress signal", first, second);
|
|
model
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn edge_route_transactions_source_and_json_share_defaults_etags_and_atomic_rejection() {
|
|
let model = oa_core::language::parse(
|
|
r#"arcadia 3; project routing "Routing" {
|
|
oa_activity a "Report" {}
|
|
oa_activity b "Assess" {}
|
|
interaction signal "Signal" { source @a; target @b; }
|
|
diagram architecture "Architecture" {}
|
|
diagram review "Review" {}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
let find = |alias: &str| {
|
|
model
|
|
.identities()
|
|
.into_iter()
|
|
.find(|(_, candidate)| *candidate == alias)
|
|
.unwrap()
|
|
.0
|
|
};
|
|
let diagram = find("architecture");
|
|
let other = find("review");
|
|
let edge = find("signal");
|
|
let element = find("a");
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let (id, etag) = server.import(&model).await;
|
|
let url = server.url(&format!("/projects/{id}/transactions"));
|
|
// The second route intentionally omits both optional fields, exercising
|
|
// the public JSON defaults rather than serializing a pre-hydrated struct.
|
|
let commands = json!([
|
|
{"SetEdgeRoute":{"diagram":diagram,"route":{
|
|
"edge":edge,"waypoints":[{"x":420.0,"y":260.0},{"x":690.0,"y":260.0}],
|
|
"label":{"fraction":0.55,"offset":{"x":0.0,"y":-16.0}}
|
|
}}},
|
|
{"SetEdgeRoute":{"diagram":other,"route":{"edge":edge}}}
|
|
]);
|
|
let typed: Vec<Command> = serde_json::from_value(commands.clone()).unwrap();
|
|
let expected = oa_core::execute_batch(&model, &typed).unwrap();
|
|
let preview = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"commands":commands,"dry_run":true}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(preview.status(), StatusCode::OK);
|
|
let preview: Value = preview.json().await.unwrap();
|
|
assert_eq!(
|
|
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
|
|
expected
|
|
);
|
|
assert_eq!(server.get(id).await.model, model);
|
|
|
|
let commit = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"commands":commands}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(commit.status(), StatusCode::OK);
|
|
let new_etag = commit.headers()["etag"].to_str().unwrap().to_owned();
|
|
let stored = server.get(id).await;
|
|
assert_eq!(stored.revision, 2);
|
|
assert_eq!(stored.model, expected);
|
|
assert!(stored.model.diagrams[&other].routes[0].waypoints.is_empty());
|
|
assert!(stored.model.diagrams[&other].routes[0].label.is_none());
|
|
let source = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/source")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(source.contains("edge @signal;"));
|
|
assert_eq!(oa_core::language::parse(&source).unwrap(), expected);
|
|
let imported = server
|
|
.client
|
|
.post(server.url("/projects/import"))
|
|
.header("content-type", "text/plain")
|
|
.body(source.clone())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(imported.status(), StatusCode::CREATED);
|
|
let imported: Value = imported.json().await.unwrap();
|
|
assert_eq!(
|
|
server
|
|
.get(imported["resource_id"].as_str().unwrap().parse().unwrap())
|
|
.await
|
|
.model,
|
|
expected
|
|
);
|
|
|
|
let reset = json!({"RemoveEdgeRoute":{"diagram":diagram,"edge":edge}});
|
|
for stale_command in [commands[0].clone(), reset.clone()] {
|
|
let response = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"commands":[stale_command]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
|
|
}
|
|
let no_op = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &new_etag)
|
|
.json(&json!({"commands":commands}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(no_op.status(), StatusCode::OK);
|
|
assert_eq!(no_op.headers()["etag"], new_etag);
|
|
assert_eq!(no_op.json::<Value>().await.unwrap()["changed"], false);
|
|
|
|
let invalid_routes = [
|
|
(json!({"edge":element}), StatusCode::UNPROCESSABLE_ENTITY),
|
|
(
|
|
json!({"edge":edge,"waypoints":vec![json!({"x":0,"y":0});129]}),
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
),
|
|
(
|
|
json!({"edge":edge,"waypoints":[{"x":1000001,"y":0}]}),
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
),
|
|
(
|
|
json!({"edge":edge,"label":{"fraction":2,"offset":{"x":0,"y":0}}}),
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
),
|
|
(
|
|
json!({"edge":edge,"label":{"fraction":0.5}}),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
(json!({"edge":edge,"unknown":true}), StatusCode::BAD_REQUEST),
|
|
];
|
|
for (route, status) in invalid_routes {
|
|
let response = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &new_etag)
|
|
.json(&json!({"commands":[
|
|
Command::Rename(element, "Must roll back".into()),
|
|
{"SetEdgeRoute":{"diagram":diagram,"route":route}}
|
|
]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
status,
|
|
"{route}: {}",
|
|
response.text().await.unwrap()
|
|
);
|
|
}
|
|
let mut duplicated = expected.clone();
|
|
let route = duplicated.diagrams[&diagram].routes[0].clone();
|
|
duplicated
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.routes
|
|
.push(route);
|
|
let rejected = server
|
|
.client
|
|
.put(server.url(&format!("/projects/{id}/model")))
|
|
.header("if-match", &new_etag)
|
|
.json(&duplicated)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(server.get(id).await.model, stored.model);
|
|
assert_eq!(server.get(id).await.revision, stored.revision);
|
|
|
|
// The source write path hydrates the same metadata, and an equal source
|
|
// application must not create a revision or erase an explicit empty route.
|
|
let source_no_op = server
|
|
.client
|
|
.put(server.url(&format!("/projects/{id}/source")))
|
|
.header("if-match", &new_etag)
|
|
.header("content-type", "text/plain")
|
|
.body(source)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(source_no_op.status(), StatusCode::OK);
|
|
assert_eq!(source_no_op.headers()["etag"], new_etag);
|
|
|
|
let removed = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &new_etag)
|
|
.json(&json!({"commands":[reset]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(removed.status(), StatusCode::OK);
|
|
let removed_etag = removed.headers()["etag"].to_str().unwrap().to_owned();
|
|
let remaining = server.get(id).await;
|
|
assert!(remaining.model.diagrams[&diagram].routes.is_empty());
|
|
assert_eq!(remaining.model.diagrams[&other], expected.diagrams[&other]);
|
|
let removed_again = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &removed_etag)
|
|
.json(&json!({"commands":[reset]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(removed_again.status(), StatusCode::OK);
|
|
assert_eq!(removed_again.headers()["etag"], removed_etag);
|
|
assert_eq!(
|
|
removed_again.json::<Value>().await.unwrap()["changed"],
|
|
false
|
|
);
|
|
server.stop().await;
|
|
let reopened = TestServer::start(directory.path(), None).await;
|
|
assert_eq!(reopened.get(id).await.model, remaining.model);
|
|
assert_eq!(reopened.get(id).await.revision, remaining.revision);
|
|
reopened.stop().await;
|
|
}
|
|
|
|
#[test]
|
|
fn route_openapi_describes_the_serde_command_shapes_and_nested_defaults() {
|
|
let spec: Value = serde_json::from_str(include_str!("../../../docs/openapi.json")).unwrap();
|
|
let schemas = &spec["components"]["schemas"];
|
|
assert_eq!(
|
|
schemas["Diagram"]["properties"]["routes"]["default"],
|
|
json!([])
|
|
);
|
|
assert!(
|
|
!schemas["Diagram"]["required"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("routes"))
|
|
);
|
|
assert_eq!(schemas["EdgeRoute"]["required"], json!(["edge"]));
|
|
assert_eq!(
|
|
schemas["EdgeRoute"]["properties"]["waypoints"]["maxItems"],
|
|
oa_core::MAX_ROUTE_POINTS
|
|
);
|
|
assert_eq!(
|
|
schemas["EdgeRoute"]["properties"]["waypoints"]["default"],
|
|
json!([])
|
|
);
|
|
assert_eq!(
|
|
schemas["EdgeLabel"]["required"],
|
|
json!(["fraction", "offset"])
|
|
);
|
|
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["minimum"], 0);
|
|
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["maximum"], 1);
|
|
let commands = schemas["Command"]["oneOf"].as_array().unwrap();
|
|
for (name, fields) in [
|
|
("SetEdgeRoute", json!(["diagram", "route"])),
|
|
("RemoveEdgeRoute", json!(["diagram", "edge"])),
|
|
] {
|
|
let schema = commands
|
|
.iter()
|
|
.find(|schema| schema["properties"].get(name).is_some())
|
|
.unwrap();
|
|
assert_eq!(schema["required"], json!([name]));
|
|
assert_eq!(schema["properties"][name]["required"], fields);
|
|
assert_eq!(schema["properties"][name]["additionalProperties"], false);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let (existing, etag) = server.create().await;
|
|
let before = server.get(existing).await;
|
|
let mut source =
|
|
String::from("arcadia 2; project oversized \"Oversized\" { oa_activity a0 \"Root\" {}\n");
|
|
let mut model = Project::empty();
|
|
for i in 0..12_000u128 {
|
|
if i > 0 {
|
|
source.push_str(&format!(
|
|
"oa_activity a{i} \"Activity {i}\" {{ parent @a{}; }}\n",
|
|
i - 1
|
|
));
|
|
}
|
|
let id = Id::from_u128(i + 1);
|
|
model.elements.insert(
|
|
id,
|
|
oa_core::Element {
|
|
id,
|
|
alias: format!("a{i}"),
|
|
name: format!("Activity {i}"),
|
|
description: String::new(),
|
|
parent: (i > 0).then(|| Id::from_u128(i)),
|
|
body: oa_core::ElementBody::Oa(oa_core::OaBody::Activity),
|
|
},
|
|
);
|
|
}
|
|
source.push('}');
|
|
let native_json = serde_json::to_vec(&model).unwrap();
|
|
let mut legacy_elements = serde_json::Map::new();
|
|
for (id, e) in &model.elements {
|
|
legacy_elements.insert(id.to_string(), json!({"id":id,"kind":"Activity","name":e.name,"description":"","position":{"x":0,"y":0},"steps":[]}));
|
|
}
|
|
let legacy_json = serde_json::to_vec(&json!({"schema_version":1,"id":model.id,"name":"Oversized","elements":legacy_elements,"relations":{}})).unwrap();
|
|
for (media, body) in [
|
|
("text/plain", source.as_bytes().to_vec()),
|
|
("application/json", native_json.clone()),
|
|
("application/json", legacy_json),
|
|
] {
|
|
let client = server.client.clone();
|
|
let url = server.url("/projects/import");
|
|
let upload = tokio::spawn(async move {
|
|
client
|
|
.post(url)
|
|
.header("content-type", media)
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
});
|
|
// The large request is real transport traffic. This deliberately generous
|
|
// liveness bound catches the former multi-second quadratic validation;
|
|
// exact wall times belong to the separate release measurement record.
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
let read = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
server.client.get(server.url("/projects")).send(),
|
|
)
|
|
.await
|
|
.expect("catalog stalled by rejected import")
|
|
.unwrap();
|
|
assert_eq!(read.status(), StatusCode::OK);
|
|
let rejected = tokio::time::timeout(std::time::Duration::from_secs(5), upload)
|
|
.await
|
|
.expect("oversized import not rejected promptly")
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(
|
|
rejected.json::<Value>().await.unwrap()["code"],
|
|
"model_limit"
|
|
);
|
|
assert_eq!(server.get(existing).await.model, before.model);
|
|
}
|
|
// The same budgets apply to replacement and non-mutating candidate checks;
|
|
// neither endpoint may run expensive structural validation before admission.
|
|
for (suffix, media, body) in [
|
|
("source", "text/plain", source.as_bytes().to_vec()),
|
|
("model", "application/json", native_json),
|
|
] {
|
|
let rejected = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
server
|
|
.client
|
|
.put(server.url(&format!("/projects/{existing}/{suffix}")))
|
|
.header("if-match", &etag)
|
|
.header("content-type", media)
|
|
.body(body)
|
|
.send(),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(
|
|
rejected.json::<Value>().await.unwrap()["code"],
|
|
"model_limit"
|
|
);
|
|
}
|
|
for candidate in [
|
|
json!({"format":"arcadia", "source":source}),
|
|
json!({"format":"json", "model":model}),
|
|
] {
|
|
let checked = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
server
|
|
.client
|
|
.post(server.url(&format!("/projects/{existing}/validation")))
|
|
.json(&candidate)
|
|
.send(),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(checked.status(), StatusCode::OK);
|
|
let checked: Value = checked.json().await.unwrap();
|
|
assert_eq!(checked["valid"], false);
|
|
assert_eq!(checked["issues"][0]["code"], "model_limit");
|
|
}
|
|
assert_eq!(server.get(existing).await.model, before.model);
|
|
assert_eq!(server.get(existing).await.revision, before.revision);
|
|
let catalog: Value = server
|
|
.client
|
|
.get(server.url("/projects"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(catalog["items"].as_array().unwrap().len(), 1);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accepted_slow_source_does_not_lock_catalog_and_loses_to_a_newer_http_revision() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let (id, etag) = server.create().await;
|
|
let mut source = String::from(
|
|
"arcadia 2; project prepared \"Slow proposed head\" { oa_activity a0 \"Root\" {}\n",
|
|
);
|
|
// This is within all admission budgets. A nested engineering graph forces
|
|
// real validation work after the source route's initial revision snapshot.
|
|
for i in 1..2_000 {
|
|
source.push_str(&format!(
|
|
"oa_activity a{i} \"Activity {i}\" {{ parent @a{}; }}\n",
|
|
i - 1
|
|
));
|
|
}
|
|
source.push('}');
|
|
let client = server.client.clone();
|
|
let url = server.url(&format!("/projects/{id}/source"));
|
|
let stale_etag = etag.clone();
|
|
let proposed = tokio::spawn(async move {
|
|
client
|
|
.put(url)
|
|
.header("if-match", stale_etag)
|
|
.header("content-type", "text/plain")
|
|
.body(source)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
});
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
assert!(
|
|
!proposed.is_finished(),
|
|
"fixture must overlap actual candidate preparation"
|
|
);
|
|
let catalog = tokio::time::timeout(
|
|
std::time::Duration::from_secs(2),
|
|
server.client.get(server.url("/projects")).send(),
|
|
)
|
|
.await
|
|
.expect("catalog locked during valid candidate preparation")
|
|
.unwrap();
|
|
assert_eq!(catalog.status(), StatusCode::OK);
|
|
let winner = tokio::time::timeout(
|
|
std::time::Duration::from_secs(2),
|
|
server
|
|
.client
|
|
.patch(server.url(&format!("/projects/{id}")))
|
|
.header("if-match", &etag)
|
|
.json(&json!({"name":"Concurrent accepted review"}))
|
|
.send(),
|
|
)
|
|
.await
|
|
.expect("unrelated preparation held the commit lock")
|
|
.unwrap();
|
|
assert_eq!(winner.status(), StatusCode::OK);
|
|
let loser = tokio::time::timeout(std::time::Duration::from_secs(25), proposed)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(loser.status(), StatusCode::PRECONDITION_FAILED);
|
|
let failure: Value = loser.json().await.unwrap();
|
|
assert_eq!(failure["code"], "revision_conflict");
|
|
assert_eq!(failure["current_revision"], 2);
|
|
let committed = server.get(id).await;
|
|
assert_eq!(committed.revision, 2);
|
|
assert_eq!(committed.model.name, "Concurrent accepted review");
|
|
assert!(committed.model.elements.is_empty());
|
|
let installed: StoredProject = serde_json::from_slice(
|
|
&std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(installed.model, committed.model);
|
|
assert_eq!(installed.revision, 2);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mission_and_contextual_chain_records_round_trip_and_edit_through_http() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let source = r#"arcadia 2; project release_example "Release modeling" {
|
|
oa_entity team "Rescue team" {}
|
|
oa_mission need "Protect mariners" {}
|
|
oa_capability required "Assist mariners" {}
|
|
oa_capability routine "Routine assistance" {}
|
|
oa_capability extended "Extended assistance" {}
|
|
mission_involvement participant "Mission participant" { source @team; target @need; }
|
|
mission_capability mission_support "Mission support" { source @need; target @required; }
|
|
capability_include included "Required assistance" { source @required; target @routine; }
|
|
capability_extend extra "Conditional assistance" { source @extended; target @required; condition "sea_state > 4"; }
|
|
capability_generalization specialized "Specialized assistance" { source @extended; target @routine; }
|
|
sa_system system_of_interest "Rescue support" {}
|
|
sa_mission response "Support rescue" {}
|
|
trace mission_mapping "Mission realization" { oa @need; sa @response; kind Mission; }
|
|
sa_function assess "Assess conditions" {}
|
|
sa_function notify_operator "Notify operator" {}
|
|
sa_functional_chain routine_chain "Routine chain" { steps [@routine_action]; }
|
|
sa_occurrence routine_action "Routine notification" { parent @routine_chain; body { Action { action @notify_operator; lifeline none; }; }; }
|
|
sa_functional_chain chain "Rescue decision" { steps [@entry, @choice, @routine_path, @extended_path, @merge, @reused, @finish]; }
|
|
sa_occurrence entry "Assess" { parent @chain; body { Action { action @assess; lifeline none; }; }; }
|
|
sa_occurrence choice "Select response" { parent @chain; body { Control { kind Choice; }; }; }
|
|
sa_occurrence routine_path "Routine response" { parent @chain; body { Action { action @notify_operator; lifeline none; }; }; }
|
|
sa_occurrence extended_path "Review conditions" { parent @chain; body { Action { action @assess; lifeline none; }; }; }
|
|
sa_occurrence merge "Continue response" { parent @chain; body { Control { kind Merge; }; }; }
|
|
sa_occurrence reused "Reused notification" { parent @chain; body { SequenceReference { sequence @routine_chain; }; }; }
|
|
sa_occurrence finish "Finish" { parent @chain; body { Action { action @notify_operator; lifeline none; }; }; }
|
|
sequence_link start_choice "Choose" { source @entry; target @choice; }
|
|
sequence_link ordinary "Ordinary path" { source @choice; target @routine_path; guard "sea_state <= 4"; }
|
|
sequence_link exceptional "Extended path" { source @choice; target @extended_path; guard "else"; }
|
|
sequence_link first_merge "Continue" { source @routine_path; target @merge; }
|
|
sequence_link second_merge "Continue" { source @extended_path; target @merge; }
|
|
sequence_link call_routine "Reuse notification" { source @merge; target @reused; }
|
|
sequence_link conclude "Conclude" { source @reused; target @finish; }
|
|
}"#;
|
|
let imported = server
|
|
.client
|
|
.post(server.url("/projects/import"))
|
|
.header("content-type", "text/plain")
|
|
.body(source)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(imported.status(), StatusCode::CREATED);
|
|
let etag = imported.headers()["etag"].to_str().unwrap().to_owned();
|
|
let id: Id = imported.json::<Value>().await.unwrap()["resource_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
let baseline = server.get(id).await;
|
|
let mut changed = baseline
|
|
.model
|
|
.relations
|
|
.values()
|
|
.find(|r| r.alias == "ordinary")
|
|
.unwrap()
|
|
.clone();
|
|
if let oa_core::RelationBody::SequenceLink { guard, .. } = &mut changed.body {
|
|
*guard = "sea_state <= 4 && operator == \"Ready\"".into();
|
|
} else {
|
|
panic!("wrong persisted relation body");
|
|
}
|
|
let commit = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{id}/transactions")))
|
|
.header("if-match", etag)
|
|
.json(&json!({"commands":[Command::EditRelation(changed.id, changed.clone())]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(commit.status(), StatusCode::OK);
|
|
let current = server.get(id).await;
|
|
assert_eq!(current.revision, 2);
|
|
assert_eq!(current.model.relations[&changed.id], changed);
|
|
let exported = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/source")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(exported.contains("sequence @routine_chain;"));
|
|
assert_eq!(oa_core::language::parse(&exported).unwrap(), current.model);
|
|
let filtered: Value = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/relations?kind=SequenceLink")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(filtered["items"].as_array().unwrap().len(), 7);
|
|
server.stop().await;
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
assert_eq!(server.get(id).await.model, current.model);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_v2_scenarios_and_typed_message_edits_use_the_same_http_model() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let legacy_json = include_bytes!("../../oa-core/tests/fixtures/v2-coastal-rescue.json");
|
|
let imported = server
|
|
.client
|
|
.post(server.url("/projects/import"))
|
|
.header("content-type", "application/json")
|
|
.body(legacy_json.to_vec())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(imported.status(), StatusCode::CREATED);
|
|
let legacy_id: Id = imported.json::<Value>().await.unwrap()["resource_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
assert_eq!(
|
|
server.get(legacy_id).await.model,
|
|
oa_core::persistence::decode(legacy_json).unwrap()
|
|
);
|
|
let source = r#"arcadia 2; project messages "Synchronous request" {
|
|
sa_system controller "Controller" {}
|
|
sa_function request "Request" {}
|
|
sa_function respond "Respond" {}
|
|
sa_exchange_item payload "Payload" {}
|
|
sa_data_field value_field "Requested value" { parent @payload; }
|
|
functional_exchange request_data "Request data" { source @request; target @respond; items [@payload]; }
|
|
sa_scenario story "Request and response" { viewpoint Functional; steps [@called, @replied]; }
|
|
sa_lifeline requester "Requester" { parent @story; represented @request; }
|
|
sa_lifeline responder "Responder" { parent @story; represented @respond; }
|
|
sa_occurrence called "Call" { parent @story; body { Exchange { exchange @request_data; sender @requester; receiver @responder; message_kind Call; reply_to none; values [{ field @value_field; expression "requested_value"; }]; }; }; }
|
|
sa_occurrence replied "Reply" { parent @story; body { Exchange { exchange @request_data; sender @responder; receiver @requester; receiver @requester; message_kind Reply; reply_to @called; values []; }; }; }
|
|
}"#;
|
|
// Source errors remain atomic even with the richer typed constructors.
|
|
let rejected = server
|
|
.client
|
|
.put(server.url(&format!("/projects/{legacy_id}/source")))
|
|
.header("if-match", server.get(legacy_id).await.etag())
|
|
.header("content-type", "text/plain")
|
|
.body(source)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(server.get(legacy_id).await.revision, 1);
|
|
let source = source.replace(
|
|
"receiver @requester; receiver @requester;",
|
|
"receiver @requester;",
|
|
);
|
|
let model = oa_core::language::parse(&source).unwrap();
|
|
let (id, etag) = server.import(&model).await;
|
|
let mut message = model
|
|
.elements
|
|
.values()
|
|
.find(|element| element.alias == "called")
|
|
.unwrap()
|
|
.clone();
|
|
if let Some(oa_core::SharedBody::Occurrence {
|
|
body: oa_core::OccurrenceBody::Exchange { values, .. },
|
|
..
|
|
}) = message.shared_mut()
|
|
{
|
|
values[0].expression = "requested_value + \"checked\"".into();
|
|
} else {
|
|
panic!("expected call payload");
|
|
}
|
|
let accepted = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{id}/transactions")))
|
|
.header("if-match", etag)
|
|
.json(&json!({"commands":[Command::EditElement(message.id, message.clone())]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(accepted.status(), StatusCode::OK);
|
|
let committed = server.get(id).await;
|
|
assert_eq!(committed.model.elements[&message.id], message);
|
|
let exported = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/source")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(exported.contains("message_kind Call;"));
|
|
assert!(exported.contains("reply_to @called;"));
|
|
assert!(exported.contains("field @value_field;"));
|
|
assert_eq!(
|
|
oa_core::language::parse(&exported).unwrap(),
|
|
committed.model
|
|
);
|
|
server.stop().await;
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
assert_eq!(server.get(id).await.model, committed.model);
|
|
assert_eq!(server.get(legacy_id).await.revision, 1);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn typed_state_events_batch_reidentification_and_deletion_use_real_http() {
|
|
use oa_core::{EventBody, RelationBody, SharedBody, TimeEventKind, TransitionKind};
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let source = r#"arcadia 2; project recovery "Typed recovery" {
|
|
oa_entity team "Operational team" {}
|
|
oa_activity operational_work "Operational assessment" {}
|
|
oa_event operational_event "Operational change" { body { Change { expression "visibility > 100"; }; }; }
|
|
sa_system controller "Controller" {}
|
|
sa_function assess "Assess recovery" {}
|
|
sa_exchange_item report_data "Report data" {}
|
|
sa_event report "Report arrived" { body { Signal { items [@report_data]; }; }; }
|
|
sa_event deadline "Recovery timeout" { body { Time { kind After; expression "30 s"; }; }; }
|
|
sa_state_machine machine "Recovery" { parent @controller; }
|
|
sa_region region "Main region" { parent @machine; }
|
|
sa_state idle "Idle" { parent @region; }
|
|
sa_state active "Active" { parent @region; }
|
|
state_transition recover "Recover" { source @idle; target @active; triggers [@deadline]; effects [@assess]; trigger ""; guard "operator == \"Ready\""; effect "Record assessment\nНе выполнять автоматически"; }
|
|
}"#;
|
|
let imported = server
|
|
.client
|
|
.post(server.url("/projects/import"))
|
|
.header("content-type", "text/plain")
|
|
.body(source)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(imported.status(), StatusCode::CREATED);
|
|
let first_etag = imported.headers()["etag"].to_str().unwrap().to_owned();
|
|
let id: Id = imported.json::<Value>().await.unwrap()["resource_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
let original = server.get(id).await;
|
|
let find = |alias: &str| {
|
|
original
|
|
.model
|
|
.elements
|
|
.values()
|
|
.find(|e| e.alias == alias)
|
|
.unwrap()
|
|
.id
|
|
};
|
|
let deadline = find("deadline");
|
|
let work = find("assess");
|
|
let payload = find("report_data");
|
|
let signal = find("report");
|
|
let mut change = original.model.elements[&deadline].clone();
|
|
change.id = Id::new_v4();
|
|
change.alias = "recovery_changed".into();
|
|
change.name = "Recovery accepted".into();
|
|
*change.shared_mut().unwrap() = SharedBody::Event {
|
|
body: EventBody::Change {
|
|
expression: "pressure > 4 && status == \"Ready\"".into(),
|
|
},
|
|
};
|
|
let mut transition = original
|
|
.model
|
|
.relations
|
|
.values()
|
|
.find(|r| r.alias == "recover")
|
|
.unwrap()
|
|
.clone();
|
|
let RelationBody::StateTransition {
|
|
triggers,
|
|
transition_kind,
|
|
..
|
|
} = &mut transition.body
|
|
else {
|
|
panic!("expected transition")
|
|
};
|
|
*triggers = vec![deadline, change.id, work];
|
|
*transition_kind = TransitionKind::External;
|
|
let changed_deadline = Id::new_v4();
|
|
let changed_payload = Id::new_v4();
|
|
let commands = vec![
|
|
Command::CreateElement(change.clone()),
|
|
Command::EditRelation(transition.id, transition.clone()),
|
|
Command::Reidentify(deadline, changed_deadline),
|
|
Command::Reidentify(payload, changed_payload),
|
|
Command::Rename(work, "Assess accepted recovery".into()),
|
|
];
|
|
let url = server.url(&format!("/projects/{id}/transactions"));
|
|
let accepted = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &first_etag)
|
|
.json(&json!({"commands":commands}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
accepted.status(),
|
|
StatusCode::OK,
|
|
"{}",
|
|
accepted.text().await.unwrap()
|
|
);
|
|
let committed = server.get(id).await;
|
|
assert_eq!(committed.revision, 2);
|
|
assert_eq!(
|
|
committed.model,
|
|
oa_core::execute_batch(&original.model, &commands).unwrap()
|
|
);
|
|
assert_eq!(committed.model.elements[&change.id], change);
|
|
assert!(!committed.model.elements.contains_key(&deadline));
|
|
let Some(SharedBody::Event {
|
|
body: EventBody::Time { kind, expression },
|
|
}) = committed.model.elements[&changed_deadline].shared()
|
|
else {
|
|
panic!("expected time event")
|
|
};
|
|
assert_eq!((*kind, expression.as_str()), (TimeEventKind::After, "30 s"));
|
|
let Some(SharedBody::Event {
|
|
body: EventBody::Signal { items },
|
|
}) = committed.model.elements[&signal].shared()
|
|
else {
|
|
panic!("expected signal")
|
|
};
|
|
assert_eq!(items, &[changed_payload]);
|
|
let RelationBody::StateTransition {
|
|
triggers,
|
|
effects,
|
|
guard,
|
|
effect,
|
|
..
|
|
} = &committed.model.relations[&transition.id].body
|
|
else {
|
|
panic!("expected transition")
|
|
};
|
|
assert_eq!(triggers, &[changed_deadline, change.id, work]);
|
|
assert_eq!(effects, &[work]);
|
|
assert_eq!(guard, "operator == \"Ready\"");
|
|
assert_eq!(effect, "Record assessment\nНе выполнять автоматически");
|
|
let stale = server
|
|
.client
|
|
.post(&url)
|
|
.header("if-match", &first_etag)
|
|
.json(&json!({"commands":[Command::Delete(change.id)]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
|
|
let mut invalid = committed.model.relations[&transition.id].clone();
|
|
let RelationBody::StateTransition { triggers, .. } = &mut invalid.body else {
|
|
unreachable!()
|
|
};
|
|
*triggers = vec![find("operational_event")];
|
|
let rejected = server.client.post(&url).header("if-match", committed.etag())
|
|
.json(&json!({"commands":[Command::Rename(work, "Must roll back".into()), Command::EditRelation(invalid.id, invalid)]}))
|
|
.send().await.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(server.get(id).await.model, committed.model);
|
|
assert_eq!(server.get(id).await.etag(), committed.etag());
|
|
let exported = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/source")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(exported.contains("triggers [@deadline, @recovery_changed, @assess];"));
|
|
assert!(exported.contains("effects [@assess];"));
|
|
assert_eq!(
|
|
oa_core::language::parse(&exported).unwrap(),
|
|
committed.model
|
|
);
|
|
let events: Value = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/elements?kind=Event")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(events["items"].as_array().unwrap().len(), 4);
|
|
let deleted = server.client.post(&url).header("if-match", committed.etag())
|
|
.json(&json!({"commands":[Command::Delete(changed_deadline),Command::Delete(change.id),Command::Delete(work),Command::Delete(changed_payload)]}))
|
|
.send().await.unwrap();
|
|
assert_eq!(
|
|
deleted.status(),
|
|
StatusCode::OK,
|
|
"{}",
|
|
deleted.text().await.unwrap()
|
|
);
|
|
let remaining = server.get(id).await;
|
|
assert_eq!(remaining.revision, 3);
|
|
let RelationBody::StateTransition {
|
|
triggers,
|
|
effects,
|
|
trigger,
|
|
guard: retained_guard,
|
|
effect: retained_effect,
|
|
..
|
|
} = &remaining.model.relations[&transition.id].body
|
|
else {
|
|
panic!("expected transition")
|
|
};
|
|
assert!(triggers.is_empty() && effects.is_empty());
|
|
assert!(
|
|
!trigger.trim().is_empty(),
|
|
"deleting the last trigger must not introduce completion semantics"
|
|
);
|
|
assert_eq!((retained_guard, retained_effect), (guard, effect));
|
|
let Some(SharedBody::Event {
|
|
body: EventBody::Signal { items },
|
|
}) = remaining.model.elements[&signal].shared()
|
|
else {
|
|
panic!("expected signal")
|
|
};
|
|
assert!(items.is_empty());
|
|
assert_eq!(
|
|
oa_core::language::parse(&oa_core::language::format(&remaining.model)).unwrap(),
|
|
remaining.model
|
|
);
|
|
server.stop().await;
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
assert_eq!(server.get(id).await.model, remaining.model);
|
|
assert_eq!(server.get(id).await.etag(), remaining.etag());
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn conditional_writes_are_atomic_and_survive_restart() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let (id, etag) = server.create().await;
|
|
let url = server.url(&format!("/projects/{id}"));
|
|
let a = server
|
|
.client
|
|
.patch(&url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"name":"Writer A"}))
|
|
.send();
|
|
let b = server
|
|
.client
|
|
.patch(&url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"name":"Writer B"}))
|
|
.send();
|
|
let (a, b) = tokio::join!(a, b);
|
|
let statuses = [a.unwrap().status(), b.unwrap().status()];
|
|
assert_eq!(
|
|
statuses
|
|
.iter()
|
|
.filter(|status| **status == StatusCode::OK)
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
statuses
|
|
.iter()
|
|
.filter(|status| **status == StatusCode::PRECONDITION_FAILED)
|
|
.count(),
|
|
1
|
|
);
|
|
let committed = server.get(id).await;
|
|
assert_eq!(committed.revision, 2);
|
|
let stale = server
|
|
.client
|
|
.delete(&url)
|
|
.header("if-match", &etag)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
|
|
let conflict: Value = stale.json().await.unwrap();
|
|
assert_eq!(conflict["current_revision"], 2);
|
|
server.stop().await;
|
|
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let restored = server.get(id).await;
|
|
assert_eq!(restored.model, committed.model);
|
|
assert_eq!(restored.revision, 2);
|
|
let cached = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}")))
|
|
.header("if-none-match", restored.etag())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
|
|
let deleted = server
|
|
.client
|
|
.delete(server.url(&format!("/projects/{id}")))
|
|
.header("if-match", restored.etag())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
|
assert!(!directory.path().join(format!("{id}.json")).exists());
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.delete(server.url(&format!("/projects/{id}")))
|
|
.header("if-match", restored.etag())
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_candidates_and_bad_headers_leave_snapshot_unchanged() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let (id, etag) = server.import(&graph()).await;
|
|
let original = server.get(id).await;
|
|
let file = directory.path().join(format!("{id}.json"));
|
|
let original_bytes = std::fs::read(&file).unwrap();
|
|
let model_url = server.url(&format!("/projects/{id}/model"));
|
|
let mut invalid = original.model.clone();
|
|
invalid.alias = "project".into();
|
|
let rejected = server
|
|
.client
|
|
.put(&model_url)
|
|
.header("if-match", &etag)
|
|
.json(&invalid)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(
|
|
rejected.headers()["content-type"],
|
|
"application/problem+json"
|
|
);
|
|
assert!(
|
|
!rejected.json::<Value>().await.unwrap()["issues"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
|
|
let malformed = server
|
|
.client
|
|
.put(server.url(&format!("/projects/{id}/source")))
|
|
.header("if-match", &etag)
|
|
.header("content-type", "text/plain")
|
|
.body("arcadia garbage")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(malformed.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert!(malformed.json::<Value>().await.unwrap()["issues"][0]["line"].is_number());
|
|
let missing = server
|
|
.client
|
|
.put(&model_url)
|
|
.json(&original.model)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(missing.status(), StatusCode::PRECONDITION_REQUIRED);
|
|
for bad in ["\"", "*", "W/\"old\"", "\"first\",\"second\""] {
|
|
let rejected = server
|
|
.client
|
|
.put(&model_url)
|
|
.header("if-match", bad)
|
|
.json(&original.model)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
assert_eq!(server.get(id).await.model, original.model);
|
|
assert_eq!(std::fs::read(file).unwrap(), original_bytes);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batches_dry_run_noop_and_source_share_the_core() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let (id, etag) = server.import(&graph()).await;
|
|
let original = server.get(id).await;
|
|
let first = original
|
|
.model
|
|
.elements
|
|
.values()
|
|
.find(|element| element.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
let commands = vec![
|
|
Command::Rename(first, "Updated activity".into()),
|
|
Command::Describe(first, "Reviewed by API".into()),
|
|
];
|
|
let transaction_url = server.url(&format!("/projects/{id}/transactions"));
|
|
let rejected = server.client.post(&transaction_url).header("if-match", &etag)
|
|
.json(&json!({"commands":[Command::Rename(first, "Must roll back".into()), Command::Delete(Id::new_v4())]})).send().await.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(server.get(id).await.model, original.model);
|
|
let excessive = Command::Batch(vec![Command::Rename(first, "Never applied".into()); 200]);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.post(&transaction_url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"commands":[excessive]}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::BAD_REQUEST
|
|
);
|
|
let preview = server
|
|
.client
|
|
.post(&transaction_url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"commands":commands,"dry_run":true}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(preview.status(), StatusCode::OK);
|
|
let preview: Value = preview.json().await.unwrap();
|
|
assert_eq!(preview["dry_run"], true);
|
|
assert_eq!(server.get(id).await.model, original.model);
|
|
|
|
let commit = server
|
|
.client
|
|
.post(&transaction_url)
|
|
.header("if-match", &etag)
|
|
.json(&json!({"commands":commands}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(commit.status(), StatusCode::OK);
|
|
let new_etag = commit.headers()["etag"].to_str().unwrap().to_owned();
|
|
let stored = server.get(id).await;
|
|
assert_eq!(stored.revision, 2);
|
|
assert_eq!(
|
|
stored.model,
|
|
oa_core::execute_batch(&original.model, &commands).unwrap()
|
|
);
|
|
let no_op = server
|
|
.client
|
|
.post(&transaction_url)
|
|
.header("if-match", &new_etag)
|
|
.json(&json!({"commands":commands}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(no_op.status(), StatusCode::OK);
|
|
assert_eq!(no_op.json::<Value>().await.unwrap()["changed"], false);
|
|
|
|
let source = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/source")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/source?revision=1")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::BAD_REQUEST
|
|
);
|
|
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
|
|
let imported = server
|
|
.client
|
|
.post(server.url("/projects/import"))
|
|
.header("content-type", "text/plain; charset=utf-8")
|
|
.body(source)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(imported.status(), StatusCode::CREATED);
|
|
let imported: Value = imported.json().await.unwrap();
|
|
let copy = server
|
|
.get(imported["resource_id"].as_str().unwrap().parse().unwrap())
|
|
.await;
|
|
assert_ne!(copy.resource_id, stored.resource_id);
|
|
assert_eq!(copy.model, stored.model);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn graph_filters_pagination_and_bounded_neighborhoods() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let model = graph();
|
|
let (id, _) = server.import(&model).await;
|
|
let page = server
|
|
.client
|
|
.get(server.url(&format!(
|
|
"/projects/{id}/elements?kind=Activity&layer=Oa&limit=1"
|
|
)))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(page.status(), StatusCode::OK);
|
|
let page: Value = page.json().await.unwrap();
|
|
assert_eq!(page["items"].as_array().unwrap().len(), 1);
|
|
let after = page["next_after"].as_str().unwrap();
|
|
let next: Value = server
|
|
.client
|
|
.get(server.url(&format!(
|
|
"/projects/{id}/elements?kind=Activity&layer=Oa&limit=1&after={after}&revision=1"
|
|
)))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(page["items"][0]["id"], next["items"][0]["id"]);
|
|
let actor = model
|
|
.elements
|
|
.values()
|
|
.find(|element| element.kind() == Kind::Actor)
|
|
.unwrap()
|
|
.id;
|
|
let url = server.url(&format!("/projects/{id}/query"));
|
|
let limited: Value = server
|
|
.client
|
|
.post(&url)
|
|
.json(&json!({"roots":[actor],"max_depth":4,"max_objects":2}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(limited["truncated"], true);
|
|
assert_eq!(limited["elements"].as_array().unwrap().len(), 1);
|
|
assert!(limited["relations"].as_array().unwrap().is_empty());
|
|
let complete: Value = server
|
|
.client
|
|
.post(&url)
|
|
.json(&json!({"roots":[actor],"max_depth":4,"max_objects":100}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(complete["truncated"], false);
|
|
assert_eq!(complete["elements"].as_array().unwrap().len(), 3);
|
|
assert_eq!(complete["relations"].as_array().unwrap().len(), 2);
|
|
let root_relation = *model.relations.keys().next().unwrap();
|
|
let relation_root: Value = server
|
|
.client
|
|
.post(&url)
|
|
.json(&json!({"roots":[root_relation],"max_depth":0,"max_objects":100}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(relation_root["relations"].as_array().unwrap().len(), 1);
|
|
assert_eq!(relation_root["elements"].as_array().unwrap().len(), 2);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/elements?revision=2")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::CONFLICT
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.post(url)
|
|
.json(&json!({"roots":[actor],"max_depth":99}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::BAD_REQUEST
|
|
);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_origins_media_and_body_limits_are_enforced_over_http() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let token = "test-only-0123456789abcdef0123456789abcdef";
|
|
let server = TestServer::start(directory.path(), Some(token.into())).await;
|
|
let url = server.url("/projects");
|
|
assert_eq!(
|
|
server.client.get(&url).send().await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(&url)
|
|
.bearer_auth("wrong-token")
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(&url)
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::OK
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(format!("{}/health", server.base))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::OK
|
|
);
|
|
let openapi: Value = server
|
|
.client
|
|
.get(server.url("/openapi.json"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(openapi["openapi"], "3.1.0");
|
|
// Public automation vocabulary must expose every accepted engineering kind.
|
|
let schemas = &openapi["components"]["schemas"];
|
|
for kind in Kind::ALL {
|
|
assert!(
|
|
schemas["Kind"]["enum"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&serde_json::to_value(kind).unwrap())
|
|
);
|
|
}
|
|
for kind in RelationKind::ALL {
|
|
let name = serde_json::to_value(kind).unwrap();
|
|
assert!(
|
|
schemas["RelationKind"]["enum"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&name)
|
|
);
|
|
assert!(
|
|
schemas["RelationBody"]["oneOf"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|variant| variant["properties"].get(name.as_str().unwrap()).is_some())
|
|
);
|
|
}
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.post(&url)
|
|
.bearer_auth(token)
|
|
.header("origin", "https://unrelated.example")
|
|
.json(&json!({"name":"bad"}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.post(&url)
|
|
.bearer_auth(token)
|
|
.header("content-type", "text/plain")
|
|
.body("{}")
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNSUPPORTED_MEDIA_TYPE
|
|
);
|
|
// Send the oversized Content-Length without uploading its rejected payload.
|
|
// Continuing an 8 MiB upload after an early 413 can produce a Windows TCP reset.
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let mut socket = tokio::net::TcpStream::connect(server.base.trim_start_matches("http://"))
|
|
.await
|
|
.unwrap();
|
|
socket.write_all(format!("POST /api/v1/projects HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", 8 * 1024 * 1024 + 1).as_bytes()).await.unwrap();
|
|
let mut reply = String::new();
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
socket.read_to_string(&mut reply),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(reply.starts_with("HTTP/1.1 413"), "{reply}");
|
|
assert!(reply.contains("application/problem+json"), "{reply}");
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(server.url("/missing"))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_atomic_replacement_preserves_the_accepted_model() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let (id, etag) = server.create().await;
|
|
let before = server.get(id).await;
|
|
let envelope = directory.path().join(format!("{id}.json"));
|
|
let bytes = std::fs::read(&envelope).unwrap();
|
|
// Deliberately make the atomic rename target unusable in this isolated store.
|
|
std::fs::remove_file(&envelope).unwrap();
|
|
std::fs::create_dir(&envelope).unwrap();
|
|
let failed = server
|
|
.client
|
|
.patch(server.url(&format!("/projects/{id}")))
|
|
.header("if-match", etag)
|
|
.json(&json!({"name":"Must not be adopted"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(failed.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let after = server.get(id).await;
|
|
assert_eq!(after.revision, before.revision);
|
|
assert_eq!(after.model, before.model);
|
|
assert_eq!(after.source_project, before.source_project);
|
|
std::fs::remove_dir(&envelope).unwrap();
|
|
std::fs::write(&envelope, bytes).unwrap();
|
|
server.stop().await;
|
|
}
|
|
|
|
#[test]
|
|
fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let first = Store::open(directory.path()).unwrap();
|
|
assert!(Store::open(directory.path()).is_err());
|
|
drop(first);
|
|
let filename = Id::new_v4();
|
|
let path = directory.path().join(format!("{filename}.json"));
|
|
std::fs::write(&path, "{broken").unwrap();
|
|
assert!(Store::open(directory.path()).is_err());
|
|
let model = Project::empty();
|
|
let mismatched = StoredProject {
|
|
migration: None,
|
|
resource_id: Id::new_v4(),
|
|
revision: 1,
|
|
source_project: oa_core::source_project::format_bundle(&model),
|
|
model,
|
|
};
|
|
std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap();
|
|
assert!(Store::open(directory.path()).is_err());
|
|
let model = Project::empty();
|
|
let invalid = StoredProject {
|
|
migration: None,
|
|
resource_id: filename,
|
|
revision: 0,
|
|
source_project: oa_core::source_project::format_bundle(&model),
|
|
model,
|
|
};
|
|
std::fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
|
assert!(Store::open(directory.path()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn non_loopback_requires_a_token() {
|
|
let mut config = ServerConfig {
|
|
bind: "0.0.0.0:8787".parse().unwrap(),
|
|
data_dir: "unused".into(),
|
|
bearer_token: None,
|
|
};
|
|
assert!(config.validate().is_err());
|
|
config.bearer_token = Some("test-only-0123456789abcdef0123456789abcdef".into());
|
|
assert!(config.validate().is_ok());
|
|
config.bearer_token = None;
|
|
config.bind = "[::1]:8787".parse().unwrap();
|
|
assert!(config.validate().is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn transition_preview_apply_and_artifact_links_preserve_revision_boundaries() {
|
|
use oa_core::transitions::{TransitionDirection, TransitionRequest};
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let model = graph();
|
|
let source = model
|
|
.elements
|
|
.values()
|
|
.find(|element| element.kind() == Kind::Activity)
|
|
.unwrap()
|
|
.id;
|
|
let (id, etag) = server.import(&model).await;
|
|
let request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
|
|
let preview_url = server.url(&format!("/projects/{id}/transitions/preview"));
|
|
let preview = server
|
|
.client
|
|
.post(&preview_url)
|
|
.header("if-match", &etag)
|
|
.json(&request)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = preview.status();
|
|
let plan: Value = preview.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{plan}");
|
|
assert!(
|
|
plan["plan"]["conflicts"].as_array().unwrap().is_empty(),
|
|
"{plan}"
|
|
);
|
|
assert_eq!(server.get(id).await.model, model);
|
|
let apply_url = server.url(&format!("/projects/{id}/transitions/apply"));
|
|
let applied = server
|
|
.client
|
|
.post(&apply_url)
|
|
.header("if-match", &etag)
|
|
.json(&plan)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = applied.status();
|
|
let result: Value = applied.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{result}");
|
|
let current = server.get(id).await;
|
|
assert_eq!(current.revision, 2);
|
|
assert!(
|
|
current
|
|
.model
|
|
.elements
|
|
.values()
|
|
.any(|element| element.kind() == Kind::Function)
|
|
);
|
|
assert_eq!(current.model.traces.len(), 1);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.post(&apply_url)
|
|
.header("if-match", &etag)
|
|
.json(&plan)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::PRECONDITION_FAILED
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.post(&apply_url)
|
|
.header("if-match", current.etag())
|
|
.json(&plan)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::PRECONDITION_FAILED
|
|
);
|
|
|
|
let artifact = oa_core::ArtifactLink {
|
|
id: Id::new_v4(),
|
|
alias: "evidence_report".into(),
|
|
name: "Evidence report".into(),
|
|
description: "Produced by an external tool".into(),
|
|
owner: source,
|
|
relation: oa_core::ArtifactRelation::Verifies,
|
|
uri: "https://example.invalid/report".into(),
|
|
revision: Some("external:revision-42".into()),
|
|
fragment: Some("section-7".into()),
|
|
};
|
|
let committed = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{id}/transactions")))
|
|
.header("if-match", current.etag())
|
|
.json(&json!({"commands":[Command::CreateArtifact(artifact.clone())]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(committed.status(), StatusCode::OK);
|
|
let records: Value = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/artifact-links?object={source}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
records["items"][0],
|
|
serde_json::to_value(&artifact).unwrap()
|
|
);
|
|
let traces: Value = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/traces?object={source}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(traces["items"].as_array().unwrap().len(), 1);
|
|
server.stop().await;
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
assert_eq!(
|
|
server.get(id).await.model.artifact_links[&artifact.id],
|
|
artifact
|
|
);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fixed_store_capacity_is_enforced_on_http_and_restart() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let mut last = None;
|
|
for _ in 0..oa_server::store::MAX_PROJECTS {
|
|
let model = Project::empty();
|
|
let envelope = StoredProject {
|
|
migration: None,
|
|
resource_id: Id::new_v4(),
|
|
revision: 1,
|
|
source_project: oa_core::source_project::format_bundle(&model),
|
|
model,
|
|
};
|
|
std::fs::write(
|
|
directory
|
|
.path()
|
|
.join(format!("{}.json", envelope.resource_id)),
|
|
serde_json::to_vec(&envelope).unwrap(),
|
|
)
|
|
.unwrap();
|
|
last = Some(envelope);
|
|
}
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let rejected = server
|
|
.client
|
|
.post(server.url("/projects"))
|
|
.json(&json!({"name":"One too many"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::INSUFFICIENT_STORAGE);
|
|
let last = last.unwrap();
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.delete(server.url(&format!("/projects/{}", last.resource_id)))
|
|
.header("if-match", last.etag())
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::NO_CONTENT
|
|
);
|
|
server.create().await;
|
|
server.stop().await;
|
|
std::fs::write(
|
|
directory.path().join(format!("{}.json", last.resource_id)),
|
|
serde_json::to_vec(&last).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(Store::open(directory.path()).is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn chunked_upload_limits_and_loopback_host_check_use_the_real_transport() {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
assert_eq!(
|
|
server
|
|
.client
|
|
.get(server.url("/projects"))
|
|
.header("host", "untrusted.example")
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
let socket = tokio::net::TcpStream::connect(server.base.trim_start_matches("http://"))
|
|
.await
|
|
.unwrap();
|
|
let (mut reader, mut writer) = socket.into_split();
|
|
let upload = tokio::spawn(async move {
|
|
writer.write_all(b"POST /api/v1/projects HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await?;
|
|
let chunk = vec![b' '; 65_536];
|
|
for _ in 0..128 {
|
|
writer.write_all(b"10000\r\n").await?;
|
|
writer.write_all(&chunk).await?;
|
|
writer.write_all(b"\r\n").await?;
|
|
}
|
|
writer.write_all(b"1\r\nx\r\n0\r\n\r\n").await
|
|
});
|
|
let mut reply = Vec::new();
|
|
let read = tokio::time::timeout(
|
|
std::time::Duration::from_secs(10),
|
|
reader.read_to_end(&mut reply),
|
|
)
|
|
.await;
|
|
assert!(
|
|
read.is_ok(),
|
|
"Server did not reject oversized chunked body promptly."
|
|
);
|
|
// TCP can reset after a rejection while a writer is still sending its tail.
|
|
// Any delivered response still must carry the explicit body-limit status.
|
|
let _ = upload.await.unwrap();
|
|
let reply = String::from_utf8_lossy(&reply);
|
|
assert!(reply.starts_with("HTTP/1.1 413"), "{reply}");
|
|
assert!(reply.contains("application/problem+json"), "{reply}");
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_json_import_and_model_reidentification_keep_the_service_address() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let model_id = Id::new_v4();
|
|
let legacy = json!({"schema_version":1,"id":model_id,"name":"Migrated mission","elements":{},"relations":{}});
|
|
let imported = server
|
|
.client
|
|
.post(server.url("/projects/import"))
|
|
.json(&legacy)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = imported.status();
|
|
let value: Value = imported.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED, "{value}");
|
|
let resource_id: Id = value["resource_id"].as_str().unwrap().parse().unwrap();
|
|
let current = server.get(resource_id).await;
|
|
assert_eq!(current.model.id, model_id);
|
|
assert_eq!(current.model.schema_version, oa_core::SCHEMA_VERSION);
|
|
let new_model_id = Id::new_v4();
|
|
let changed = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{resource_id}/transactions")))
|
|
.header("if-match", current.etag())
|
|
.json(&json!({"commands":[Command::Reidentify(model_id,new_model_id)]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(changed.status(), StatusCode::OK);
|
|
let after = server.get(resource_id).await;
|
|
assert_eq!(after.resource_id, resource_id);
|
|
assert_eq!(after.model.id, new_model_id);
|
|
assert!(
|
|
directory
|
|
.path()
|
|
.join(format!("{resource_id}.json"))
|
|
.is_file()
|
|
);
|
|
assert!(
|
|
!directory
|
|
.path()
|
|
.join(format!("{new_model_id}.json"))
|
|
.exists()
|
|
);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let old = v2_missing_ports();
|
|
let expected =
|
|
oa_core::persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
|
let imported = server
|
|
.client
|
|
.post(server.url("/projects/import"))
|
|
.json(&old)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(imported.status(), StatusCode::CREATED);
|
|
let etag = imported.headers()["etag"].to_str().unwrap().to_owned();
|
|
let summary: Value = imported.json().await.unwrap();
|
|
assert_eq!(summary["schema_version"], oa_core::SCHEMA_VERSION);
|
|
assert_eq!(
|
|
summary["migration"],
|
|
serde_json::to_value(&expected.migration).unwrap()
|
|
);
|
|
let id: Id = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
|
let stored = server.get(id).await;
|
|
assert_eq!(stored.model, expected.project);
|
|
assert_eq!(stored.migration, expected.migration);
|
|
let filename = directory.path().join(format!("{id}.json"));
|
|
let before = std::fs::read(&filename).unwrap();
|
|
let envelope: Value = serde_json::from_slice(&before).unwrap();
|
|
assert!(envelope.get("migration").is_none());
|
|
assert_eq!(envelope["model"]["schema_version"], oa_core::SCHEMA_VERSION);
|
|
let validation = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{id}/validation")))
|
|
.json(&json!({"format":"json", "model":old}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(validation.status(), StatusCode::OK);
|
|
let validation: Value = validation.json().await.unwrap();
|
|
assert_eq!(validation["valid"], true);
|
|
assert_eq!(validation["migration"], summary["migration"]);
|
|
let mut invalid = stored
|
|
.model
|
|
.relations
|
|
.values()
|
|
.find(|r| r.kind() == RelationKind::FunctionalExchange)
|
|
.unwrap()
|
|
.clone();
|
|
if let oa_core::RelationBody::FunctionalExchange { source_port, .. } = &mut invalid.body {
|
|
*source_port = Id::nil();
|
|
}
|
|
let rejected = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{id}/transactions")))
|
|
.header("if-match", &etag)
|
|
.json(&json!({"commands":[Command::EditRelation(invalid.id, invalid)]}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_eq!(server.get(id).await.revision, 1);
|
|
assert_eq!(std::fs::read(&filename).unwrap(), before);
|
|
let source = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{id}/source")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
assert!(source.starts_with("arcadia 5;"));
|
|
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
|
|
let no_op = server
|
|
.client
|
|
.put(server.url(&format!("/projects/{id}/source")))
|
|
.header("content-type", "text/plain")
|
|
.header("if-match", &etag)
|
|
.body(source)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(no_op.status(), StatusCode::OK);
|
|
assert_eq!(no_op.headers()["etag"].to_str().unwrap(), etag);
|
|
assert_eq!(std::fs::read(&filename).unwrap(), before);
|
|
server.stop().await;
|
|
let restarted = TestServer::start(directory.path(), None).await;
|
|
let reopened = restarted.get(id).await;
|
|
assert_eq!(reopened.model, stored.model);
|
|
assert_eq!(reopened.revision, 1);
|
|
assert!(reopened.migration.is_none());
|
|
let no_op_legacy = restarted
|
|
.client
|
|
.put(restarted.url(&format!("/projects/{id}/model")))
|
|
.header("if-match", &etag)
|
|
.json(&old)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(no_op_legacy.status(), StatusCode::OK);
|
|
assert_eq!(no_op_legacy.headers()["etag"].to_str().unwrap(), etag);
|
|
let no_op_legacy: Value = no_op_legacy.json().await.unwrap();
|
|
assert_eq!(no_op_legacy["changed"], false);
|
|
assert_eq!(no_op_legacy["migration"], summary["migration"]);
|
|
assert_eq!(std::fs::read(&filename).unwrap(), before);
|
|
restarted.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_revision() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let id = Id::new_v4();
|
|
let filename = directory.path().join(format!("{id}.json"));
|
|
let old = v2_missing_ports();
|
|
let original = serde_json::to_vec(&json!({"resource_id":id,"revision":7,"model":old})).unwrap();
|
|
std::fs::write(&filename, &original).unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let migrated = server.get(id).await;
|
|
assert_eq!(migrated.resource_id, id);
|
|
assert_eq!(migrated.revision, 7);
|
|
assert_eq!(migrated.model.schema_version, oa_core::SCHEMA_VERSION);
|
|
assert_eq!(
|
|
migrated
|
|
.migration
|
|
.as_ref()
|
|
.unwrap()
|
|
.generated_endpoints
|
|
.len(),
|
|
2
|
|
);
|
|
assert_eq!(std::fs::read(&filename).unwrap(), original);
|
|
let rename = server
|
|
.client
|
|
.patch(server.url(&format!("/projects/{id}")))
|
|
.header("if-match", migrated.etag())
|
|
.json(&json!({"name":"Reviewed migrated model"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rename.status(), StatusCode::OK);
|
|
let current = server.get(id).await;
|
|
assert_eq!(current.revision, 8);
|
|
assert_eq!(current.model.name, "Reviewed migrated model");
|
|
let saved: Value = serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap();
|
|
assert_eq!(saved["revision"], 8);
|
|
assert_eq!(saved["model"]["schema_version"], oa_core::SCHEMA_VERSION);
|
|
assert!(saved.get("migration").is_none());
|
|
server.stop().await;
|
|
}
|