feat: complete Epic 1 production foundation
This commit is contained in:
@@ -9,8 +9,9 @@ use crank_core::{
|
||||
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
|
||||
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
|
||||
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target,
|
||||
ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
|
||||
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};
|
||||
@@ -20,11 +21,12 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crank_registry::{
|
||||
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
||||
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
|
||||
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
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 {
|
||||
@@ -35,6 +37,39 @@ 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: ×tamp("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;
|
||||
@@ -137,7 +172,7 @@ async fn rejects_out_of_order_versions() {
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RegistryError::InvalidVersionSequence {
|
||||
RegistryError::OperationStaleVersion {
|
||||
expected: 2,
|
||||
actual: 3,
|
||||
..
|
||||
@@ -170,7 +205,7 @@ async fn update_operation_draft_persists_optional_json_columns_as_sql_null() {
|
||||
.unwrap();
|
||||
|
||||
let stored = registry
|
||||
.get_operation_version(&test_workspace_id(), &operation.id, operation.version)
|
||||
.get_operation_version(&test_workspace_id(), &operation.id, operation.version + 1)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
@@ -183,6 +218,279 @@ async fn update_operation_draft_persists_optional_json_columns_as_sql_null() {
|
||||
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: ×tamp("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: ×tamp("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: ×tamp("2026-03-25T12:10:00Z"),
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.archive_operation(
|
||||
&test_workspace_id(),
|
||||
&published.id,
|
||||
×tamp("2026-03-25T12:20:00Z"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.archive_operation(
|
||||
&test_workspace_id(),
|
||||
&published.id,
|
||||
×tamp("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;
|
||||
@@ -193,6 +501,12 @@ async fn stores_auth_profiles_and_artifact_metadata() {
|
||||
.create_operation(&test_workspace_id(), &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
create_test_secret(
|
||||
®istry,
|
||||
&SecretId::new("secret_crank_api_key"),
|
||||
"Crank API key",
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth_profile = AuthProfile {
|
||||
id: "auth_crank".into(),
|
||||
@@ -275,6 +589,8 @@ async fn lists_auth_profiles_referencing_secret() {
|
||||
let registry = database.registry().await;
|
||||
let primary_secret_id = SecretId::new("secret_primary");
|
||||
let secondary_secret_id = SecretId::new("secret_secondary");
|
||||
create_test_secret(®istry, &primary_secret_id, "Primary secret").await;
|
||||
create_test_secret(®istry, &secondary_secret_id, "Secondary secret").await;
|
||||
let profile = AuthProfile {
|
||||
id: "auth_crank".into(),
|
||||
workspace_id: test_workspace_id(),
|
||||
|
||||
Reference in New Issue
Block a user