Files
crank/crates/crank-registry/tests/integration/operations_artifacts.rs
T

624 lines
20 KiB
Rust

#![allow(dead_code, unused_imports)]
use super::common::*;
use std::collections::BTreeMap;
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, AuthConfig,
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, Secret, SecretId, SecretKind,
SecretStatus, Target, ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState,
Workspace, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_schema::{Schema, SchemaKind};
use serde_json::json;
use sqlx::{Executor, PgPool, postgres::PgPoolOptions};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crank_registry::{
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DescriptorKind, DescriptorMetadata, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest,
PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, WorkspaceRecord,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
fn test_workspace_id() -> WorkspaceId {
WorkspaceId::new("ws_default")
}
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
async fn create_test_secret(registry: &PostgresRegistry, id: &SecretId, name: &str) {
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &timestamp("2026-03-25T12:00:00Z"),
})
.await
.unwrap();
let secret = Secret {
id: id.clone(),
workspace_id: test_workspace_id(),
name: name.to_owned(),
kind: SecretKind::Token,
status: SecretStatus::Active,
current_version: 1,
created_at: timestamp("2026-03-25T12:00:00Z"),
updated_at: timestamp("2026-03-25T12:00:00Z"),
last_used_at: None,
};
registry
.create_secret(CreateSecretRequest {
secret: &secret,
ciphertext: "test-ciphertext",
key_version: "test-key-v1",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap();
}
#[tokio::test]
async fn stores_versions_and_published_operations() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation_v1 = test_operation("op_rest_01", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation_v1, Some("alice"))
.await
.unwrap();
let operation_v2 = test_operation("op_rest_01", 2, OperationStatus::Draft);
registry
.create_version(CreateVersionRequest {
workspace_id: &test_workspace_id(),
snapshot: &operation_v2,
change_note: Some("add output mapping"),
created_by: Some("alice"),
})
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation_v2.id,
version: operation_v2.version,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
let summary = registry
.get_operation_summary(&test_workspace_id(), &operation_v2.id)
.await
.unwrap()
.unwrap();
let versions = registry
.list_operation_versions(&test_workspace_id(), &operation_v2.id)
.await
.unwrap();
let published = registry
.get_published_operation(&operation_v2.id)
.await
.unwrap()
.unwrap();
let published_list = registry.list_published_operations().await.unwrap();
assert_eq!(summary.current_draft_version, 2);
assert_eq!(summary.latest_published_version, Some(2));
assert_eq!(summary.status, OperationStatus::Published);
assert_eq!(versions.len(), 2);
assert_eq!(
versions[1].change_note.as_deref(),
Some("add output mapping")
);
assert_eq!(
versions[1]
.snapshot
.wizard_state
.as_ref()
.unwrap()
.test_input,
Some(json!({ "email": "test-v2@example.com" }))
);
assert_eq!(published.version, 2);
assert_eq!(
published.wizard_state.as_ref().unwrap().output_sample,
Some(json!({ "id": "lead_2" }))
);
assert!(published.is_published());
assert_eq!(published_list, vec![published.clone()]);
database.cleanup().await;
}
#[tokio::test]
async fn rejects_out_of_order_versions() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_rest_02", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
let invalid = test_operation("op_rest_02", 3, OperationStatus::Draft);
let error = registry
.create_version(CreateVersionRequest {
workspace_id: &test_workspace_id(),
snapshot: &invalid,
change_note: None,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
error,
RegistryError::OperationStaleVersion {
expected: 2,
actual: 3,
..
}
));
database.cleanup().await;
}
#[tokio::test]
async fn update_operation_draft_persists_optional_json_columns_as_sql_null() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let mut operation = test_operation("op_rest_02b", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
operation.generated_draft = None;
operation.samples = None;
operation.config_export = None;
operation.wizard_state = None;
operation.updated_at = timestamp("2026-03-25T12:34:00Z");
registry
.update_operation_draft(&test_workspace_id(), &operation)
.await
.unwrap();
let stored = registry
.get_operation_version(&test_workspace_id(), &operation.id, operation.version + 1)
.await
.unwrap()
.unwrap();
assert_eq!(stored.snapshot.generated_draft, None);
assert_eq!(stored.snapshot.samples, None);
assert_eq!(stored.snapshot.config_export, None);
assert_eq!(stored.snapshot.wizard_state, None);
database.cleanup().await;
}
#[tokio::test]
async fn published_version_is_not_rewritten_by_a_later_save() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_immutable_publish", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
let published_before = registry
.get_published_operation(&operation.id)
.await
.unwrap()
.unwrap();
let mut changed = operation.clone();
changed.display_name = "MUTATED AFTER PUBLISH".to_owned();
changed.target = Target::Rest(RestTarget {
base_url: "https://mutated.example.com".to_owned(),
method: HttpMethod::Post,
path_template: "/mutated".to_owned(),
static_headers: BTreeMap::new(),
});
changed.updated_at = timestamp("2026-03-25T12:20:00Z");
registry
.update_operation_draft(&test_workspace_id(), &changed)
.await
.unwrap();
let published_after = registry
.get_published_operation(&operation.id)
.await
.unwrap()
.unwrap();
assert_eq!(published_after, published_before);
assert_eq!(
registry
.get_operation_summary(&test_workspace_id(), &operation.id)
.await
.unwrap()
.unwrap()
.current_draft_version,
2,
"saving after publish must append a new Draft revision"
);
database.cleanup().await;
}
#[tokio::test]
async fn database_guard_rejects_published_update_and_parent_cascade_delete() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_db_immutable", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
let update = sqlx::query(
"update operation_versions set display_name = 'tampered'
where operation_id = $1 and version = 1",
)
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(update.is_err());
let rewind = sqlx::query("update operations set latest_published_version = null where id = $1")
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(rewind.is_err());
let pointer_delete = sqlx::query("delete from published_operations where operation_id = $1")
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(pointer_delete.is_err());
let delete = sqlx::query("delete from operations where id = $1")
.bind(operation.id.as_str())
.execute(registry.pool())
.await;
assert!(delete.is_err());
assert!(
registry
.get_published_operation(&operation.id)
.await
.unwrap()
.is_some()
);
database.cleanup().await;
}
#[tokio::test]
async fn concurrent_saves_from_one_base_have_one_winner_and_no_version_gap() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_concurrent_save", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
let mut tasks = Vec::new();
for contender in 0..32_u32 {
let registry = registry.clone();
let mut candidate = operation.clone();
candidate.display_name = format!("Contender {contender}");
candidate.updated_at = timestamp("2026-03-25T12:20:00Z");
tasks.push(tokio::spawn(async move {
registry
.update_operation_draft(&test_workspace_id(), &candidate)
.await
}));
}
let mut successes = 0;
let mut stale = 0;
for task in tasks {
match task.await.unwrap() {
Ok(()) => successes += 1,
Err(RegistryError::OperationStaleVersion { .. }) => stale += 1,
Err(error) => panic!("unexpected contender error: {error}"),
}
}
assert_eq!(successes, 1);
assert_eq!(stale, 31);
let versions = registry
.list_operation_versions(&test_workspace_id(), &operation.id)
.await
.unwrap();
assert_eq!(
versions
.iter()
.map(|record| record.version)
.collect::<Vec<_>>(),
vec![1, 2]
);
database.cleanup().await;
}
#[tokio::test]
async fn concurrent_same_name_create_maps_unique_loser_to_typed_conflict() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let first = test_operation("op_same_name_first", 1, OperationStatus::Draft);
let mut second = test_operation("op_same_name_second", 1, OperationStatus::Draft);
second.name = first.name.clone();
let first_registry = registry.clone();
let second_registry = registry.clone();
let workspace = test_workspace_id();
let first_workspace = workspace.clone();
let second_workspace = workspace.clone();
let (first_result, second_result) = tokio::join!(
async move {
first_registry
.create_operation(&first_workspace, &first, Some("alice"))
.await
},
async move {
second_registry
.create_operation(&second_workspace, &second, Some("alice"))
.await
}
);
let results = [first_result, second_result];
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(
results
.iter()
.filter(|result| matches!(result, Err(RegistryError::OperationAlreadyExists { .. })))
.count(),
1
);
database.cleanup().await;
}
#[tokio::test]
async fn archive_preserves_published_version_and_delete_is_restricted_to_unused_draft() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let published = test_operation("op_archive_preserve", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &published, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &published.id,
version: 1,
published_at: &timestamp("2026-03-25T12:10:00Z"),
published_by: Some("alice"),
})
.await
.unwrap();
registry
.archive_operation(
&test_workspace_id(),
&published.id,
&timestamp("2026-03-25T12:20:00Z"),
)
.await
.unwrap();
registry
.archive_operation(
&test_workspace_id(),
&published.id,
&timestamp("2026-03-25T12:21:00Z"),
)
.await
.unwrap();
assert!(matches!(
registry
.delete_operation(&test_workspace_id(), &published.id)
.await,
Err(RegistryError::OperationDeleteForbidden { .. })
));
assert_eq!(
registry
.get_published_operation(&published.id)
.await
.unwrap()
.unwrap()
.status,
OperationStatus::Published
);
let draft = test_operation("op_delete_unused", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &draft, Some("alice"))
.await
.unwrap();
registry
.delete_operation(&test_workspace_id(), &draft.id)
.await
.unwrap();
assert!(
registry
.get_operation_summary(&test_workspace_id(), &draft.id)
.await
.unwrap()
.is_none()
);
database.cleanup().await;
}
#[tokio::test]
async fn stores_auth_profiles_and_artifact_metadata() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let operation = test_operation("op_rest_03", 1, OperationStatus::Draft);
registry
.create_operation(&test_workspace_id(), &operation, None)
.await
.unwrap();
create_test_secret(
&registry,
&SecretId::new("secret_crank_api_key"),
"Crank API key",
)
.await;
let auth_profile = AuthProfile {
id: "auth_crank".into(),
workspace_id: test_workspace_id(),
name: "Crank API key".to_owned(),
kind: AuthKind::ApiKeyHeader,
config: AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
header_name: "X-Api-Key".to_owned(),
secret_id: SecretId::new("secret_crank_api_key"),
}),
created_at: timestamp("2026-03-25T12:00:00Z"),
updated_at: timestamp("2026-03-25T12:00:00Z"),
};
registry
.save_auth_profile(SaveAuthProfileRequest {
workspace_id: &test_workspace_id(),
profile: &auth_profile,
})
.await
.unwrap();
let input_sample = OperationSampleMetadata {
id: "sample_input".into(),
operation_id: operation.id.clone(),
version: 1,
sample_kind: SampleKind::InputJson,
storage_ref: "file:///tmp/input.json".to_owned(),
content_type: "application/json".to_owned(),
file_name: Some("input.json".to_owned()),
created_at: timestamp("2026-03-25T12:01:00Z"),
};
let descriptor = DescriptorMetadata {
id: "descriptor_01".into(),
operation_id: Some(operation.id.clone()),
version: Some(1),
descriptor_kind: DescriptorKind::DescriptorSet,
storage_ref: "file:///tmp/schema.desc".to_owned(),
source_name: Some("schema.desc".to_owned()),
package_index: Some(json!({ "crm.v1": ["LeadService"] })),
created_at: timestamp("2026-03-25T12:02:00Z"),
};
registry
.save_sample_metadata(SaveSampleMetadataRequest {
sample: &input_sample,
})
.await
.unwrap();
registry
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
descriptor: &descriptor,
})
.await
.unwrap();
let auth_profiles = registry
.list_auth_profiles(&test_workspace_id())
.await
.unwrap();
let samples = registry
.list_sample_metadata(&operation.id, 1)
.await
.unwrap();
let descriptors = registry
.list_descriptor_metadata(&operation.id, 1)
.await
.unwrap();
assert_eq!(auth_profiles, vec![auth_profile]);
assert_eq!(samples, vec![input_sample]);
assert_eq!(descriptors, vec![descriptor]);
database.cleanup().await;
}
#[tokio::test]
async fn lists_auth_profiles_referencing_secret() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let primary_secret_id = SecretId::new("secret_primary");
let secondary_secret_id = SecretId::new("secret_secondary");
create_test_secret(&registry, &primary_secret_id, "Primary secret").await;
create_test_secret(&registry, &secondary_secret_id, "Secondary secret").await;
let profile = AuthProfile {
id: "auth_crank".into(),
workspace_id: test_workspace_id(),
name: "Crank basic auth".to_owned(),
kind: AuthKind::Basic,
config: AuthConfig::Basic(crank_core::BasicAuthConfig {
username_secret_id: primary_secret_id.clone(),
password_secret_id: secondary_secret_id.clone(),
}),
created_at: timestamp("2026-03-25T12:00:00Z"),
updated_at: timestamp("2026-03-25T12:00:00Z"),
};
registry
.save_auth_profile(SaveAuthProfileRequest {
workspace_id: &test_workspace_id(),
profile: &profile,
})
.await
.unwrap();
let profiles = registry
.list_auth_profiles_referencing_secret(&test_workspace_id(), &primary_secret_id)
.await
.unwrap();
assert_eq!(profiles, vec![profile]);
database.cleanup().await;
}