feat: complete Epic 1 production foundation
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
|
||||
const WORKSPACE_ID: &str = "ws_default";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn portable_yaml_v2_is_redacted_and_semantic_replay_is_a_noop() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-yaml-v2");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "portable_v2"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap();
|
||||
|
||||
let yaml = client
|
||||
.get(format!("{base_url}/operations/{operation_id}/export"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(yaml.contains("format_version: '2'") || yaml.contains("format_version: 2"));
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
|
||||
let operation = parsed["operation"].as_mapping().unwrap();
|
||||
for forbidden in ["id", "status", "created_at", "wizard_state", "samples"] {
|
||||
assert!(!operation.contains_key(serde_yaml::Value::String(forbidden.to_owned())));
|
||||
}
|
||||
|
||||
let replay = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(yaml.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay["operation_id"], operation_id);
|
||||
assert_eq!(replay["version"], 1);
|
||||
|
||||
let changed_yaml = yaml.replace(
|
||||
"display_name: Create Lead",
|
||||
"display_name: Portable Changed",
|
||||
);
|
||||
assert_ne!(changed_yaml, yaml);
|
||||
let missing = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(changed_yaml.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED);
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing["error"]["code"], "operation_precondition_required");
|
||||
|
||||
let changed = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, operation_id).await,
|
||||
)
|
||||
.body(changed_yaml)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed.status(), reqwest::StatusCode::OK);
|
||||
let changed = changed.json::<Value>().await.unwrap();
|
||||
assert_eq!(changed["version"], 2);
|
||||
|
||||
let current_yaml = client
|
||||
.get(format!("{base_url}/operations/{operation_id}/export"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let archived = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/archive"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, operation_id).await,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(archived.status().is_success());
|
||||
let replay = client
|
||||
.post(format!("{base_url}/operations/import?mode=upsert"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(current_yaml)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT);
|
||||
assert!(replay.text().await.unwrap().contains("operation_archived"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn hostile_and_oversized_yaml_fail_before_mutation_without_echoing_canary() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-yaml-hostile");
|
||||
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let canary = "yaml-secret-canary-never-reflect";
|
||||
let hostile = format!(
|
||||
"format_version: '2'\nkind: operation\noperation:\n name: bad\n unknown_secret: {canary}\n"
|
||||
);
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(hostile)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = response.text().await.unwrap();
|
||||
assert!(!body.contains(canary));
|
||||
assert!(body.contains("operation_yaml_invalid"));
|
||||
let body: Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(body["error"]["code"], "operation_yaml_invalid");
|
||||
|
||||
let oversized = "x".repeat(256 * 1024 + 1);
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(oversized)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);
|
||||
let count = registry
|
||||
.list_operations(&crank_core::WorkspaceId::new(WORKSPACE_ID))
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
let alias = "format_version: '2'\nkind: operation\noperation: &shared\n name: alias\n";
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(alias)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("operation_yaml_unsupported")
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(vec![0xff, 0xfe, 0xfd])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("operation_yaml_invalid")
|
||||
);
|
||||
|
||||
let mut credential_payload = serde_json::to_value(test_operation_payload(
|
||||
"http://127.0.0.1:9",
|
||||
"portable_credential_header",
|
||||
))
|
||||
.unwrap();
|
||||
credential_payload["target"]["static_headers"]["X-Auth-Token"] =
|
||||
Value::String("credential-canary".to_owned());
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&credential_payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let credential_operation_id = created["operation_id"].as_str().unwrap();
|
||||
let response = client
|
||||
.get(format!(
|
||||
"{base_url}/operations/{credential_operation_id}/export"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(!response.text().await.unwrap().contains("credential-canary"));
|
||||
|
||||
let mut portable = serde_json::to_value(test_operation_payload(
|
||||
"http://127.0.0.1:9",
|
||||
"unknown_nested_yaml",
|
||||
))
|
||||
.unwrap();
|
||||
portable.as_object_mut().unwrap().remove("wizard_state");
|
||||
portable["target"]["unknown_nested"] = Value::String(canary.to_owned());
|
||||
let document = serde_yaml::to_string(&serde_json::json!({
|
||||
"format_version": "2",
|
||||
"kind": "operation",
|
||||
"operation": portable
|
||||
}))
|
||||
.unwrap();
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations/import"))
|
||||
.header("content-type", "application/yaml")
|
||||
.body(document)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(!response.text().await.unwrap().contains(canary));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn operation_mutations_require_identity_bound_etags_and_versions_are_stable() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-etag");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let first = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "etag_first"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let second = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "etag_second"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let first_id = first["operation_id"].as_str().unwrap();
|
||||
let second_id = second["operation_id"].as_str().unwrap();
|
||||
|
||||
let missing = client
|
||||
.post(format!("{base_url}/operations/{first_id}/archive"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), reqwest::StatusCode::PRECONDITION_REQUIRED);
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
assert_eq!(missing["error"]["code"], "operation_precondition_required");
|
||||
|
||||
let first_etag = operation_etag(&client, &base_url, first_id).await;
|
||||
let replay = client
|
||||
.post(format!("{base_url}/operations/{second_id}/archive"))
|
||||
.header(reqwest::header::IF_MATCH, first_etag)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::CONFLICT);
|
||||
let replay = replay.json::<Value>().await.unwrap();
|
||||
assert_eq!(replay["error"]["code"], "operation_stale_version");
|
||||
|
||||
let v1 = client
|
||||
.get(format!("{base_url}/operations/{first_id}/versions/1"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v1.status(), reqwest::StatusCode::OK);
|
||||
let v1_etag = v1.headers()[reqwest::header::ETAG]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let mut update =
|
||||
serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_first")).unwrap();
|
||||
update["display_name"] = Value::String("Changed Draft".to_owned());
|
||||
let updated = client
|
||||
.patch(format!("{base_url}/operations/{first_id}"))
|
||||
.header(
|
||||
reqwest::header::IF_MATCH,
|
||||
operation_etag(&client, &base_url, first_id).await,
|
||||
)
|
||||
.json(&update)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let v1_after = client
|
||||
.get(format!("{base_url}/operations/{first_id}/versions/1"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v1_after.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
v1_after.headers()[reqwest::header::ETAG].to_str().unwrap(),
|
||||
v1_etag
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn same_etag_concurrent_patches_have_exactly_one_winner() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-etag-race");
|
||||
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_operation_payload("http://127.0.0.1:9", "etag_race"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap();
|
||||
let etag = operation_etag(&client, &base_url, operation_id).await;
|
||||
let mut first =
|
||||
serde_json::to_value(test_operation_payload("http://127.0.0.1:9", "etag_race")).unwrap();
|
||||
first["display_name"] = Value::String("first winner candidate".to_owned());
|
||||
let mut second = first.clone();
|
||||
second["display_name"] = Value::String("second winner candidate".to_owned());
|
||||
let url = format!("{base_url}/operations/{operation_id}");
|
||||
let first_request = client
|
||||
.patch(&url)
|
||||
.header(reqwest::header::IF_MATCH, &etag)
|
||||
.json(&first)
|
||||
.send();
|
||||
let second_request = client
|
||||
.patch(&url)
|
||||
.header(reqwest::header::IF_MATCH, &etag)
|
||||
.json(&second)
|
||||
.send();
|
||||
let (first_response, second_response) = tokio::join!(first_request, second_request);
|
||||
let statuses = [
|
||||
first_response.unwrap().status(),
|
||||
second_response.unwrap().status(),
|
||||
];
|
||||
assert_eq!(
|
||||
statuses.iter().filter(|status| status.is_success()).count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
statuses
|
||||
.iter()
|
||||
.filter(|status| **status == reqwest::StatusCode::CONFLICT)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let summary = registry
|
||||
.get_operation_summary(
|
||||
&crank_core::WorkspaceId::new(WORKSPACE_ID),
|
||||
&crank_core::OperationId::new(operation_id),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(summary.current_draft_version, 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn operation_rejects_unscoped_auth_profile_reference_before_save() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation-auth-reference");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let mut payload = serde_json::to_value(test_operation_payload(
|
||||
"http://127.0.0.1:9",
|
||||
"missing_auth_profile",
|
||||
))
|
||||
.unwrap();
|
||||
payload["execution_config"]["auth_profile_ref"] =
|
||||
Value::String("auth_foreign_or_missing".to_owned());
|
||||
let response = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = response.text().await.unwrap();
|
||||
assert!(body.contains("operation_auth_profile_invalid"));
|
||||
assert!(!body.contains("auth_foreign_or_missing"));
|
||||
}
|
||||
Reference in New Issue
Block a user