use admin_api::service::{ AdminServiceBuilder, OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale, }; use crank_core::WorkspaceId; use crank_registry::ImportJobStatus; use serial_test::serial; use super::common::{ test_auth_settings, test_registry, test_secret_crypto, test_service, test_storage_root, }; mod external_references; const OPENAPI3: &str = r#" openapi: 3.0.3 info: title: Frankfurter API servers: - url: https://api.frankfurter.dev paths: /v2/latest: get: operationId: latestRates summary: Получить последние курсы валют description: Курсы. tags: [currency] parameters: - name: base in: query required: true schema: { type: string } responses: '200': description: OK content: application/json: schema: type: object properties: base: { type: string } rates: type: object "#; #[tokio::test] #[serial] async fn previews_openapi_and_creates_draft_operations() { let registry = test_registry().await; let service = test_service( registry.clone(), test_storage_root("openapi_import"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); assert_eq!(preview.preview.groups.len(), 1); assert_eq!( preview.preview.groups[0].operations[0].suggested_name, "latest_rates" ); let public_response = serde_json::to_string(&preview).unwrap(); assert!(!public_response.contains("normalizer_version")); assert!(!public_response.contains("projection_version")); assert!(!public_response.contains("ir_fingerprint")); assert!(!public_response.contains("source_identity")); let preview_job = registry .get_import_job(&workspace_id, &preview.job_id.as_str().into()) .await .unwrap() .unwrap(); assert_eq!(preview_job.status, ImportJobStatus::Pending); assert_eq!( preview_job.preview_payload["normalization"]["normalizer_version"], "normalized-ir-v3" ); assert_eq!( preview_job.preview_payload["normalization"]["projection_version"], "preview-v3" ); assert!(preview_job.preview_payload["normalization"]["ir_fingerprint"].is_string()); let created = service .create_openapi_import( &workspace_id, &preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "skip".to_owned(), }, ) .await .unwrap(); assert_eq!(created.created.len(), 1); assert_eq!(created.created[0].operation_key, "GET /v2/latest"); assert_eq!(created.created[0].name, "latest_rates"); assert!(created.skipped.is_empty()); let replayed = service .create_openapi_import( &workspace_id, &preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "skip".to_owned(), }, ) .await .unwrap(); assert_eq!(replayed.created.len(), 1); assert_eq!( replayed.created[0].operation_id, created.created[0].operation_id ); let operations = service.list_operations(&workspace_id).await.unwrap(); assert!( operations .iter() .any(|operation| operation.name == "latest_rates") ); let imported = operations .iter() .find(|operation| operation.name == "latest_rates") .unwrap(); let version = service .get_operation_version( &workspace_id, &imported.id.as_str().into(), imported.current_draft_version, ) .await .unwrap(); let import_findings = &version .snapshot .wizard_state .as_ref() .unwrap() .import_findings; assert!( import_findings .iter() .any(|finding| finding.code == "openapi_import.weak_tool_description") ); let skip_preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let skipped = service .create_openapi_import( &workspace_id, &skip_preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "skip".to_owned(), }, ) .await .unwrap(); assert!(skipped.created.is_empty()); assert_eq!(skipped.skipped.len(), 1); assert_eq!(skipped.skipped[0].name, "latest_rates"); assert_eq!(skipped.findings[0].code, "operation_name_conflict"); let rename_preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let renamed = service .create_openapi_import( &workspace_id, &rename_preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "rename".to_owned(), }, ) .await .unwrap(); assert_eq!(renamed.created.len(), 1); assert_eq!(renamed.created[0].name, "latest_rates_2"); assert_eq!(renamed.findings[0].code, "operation_name_renamed"); } #[tokio::test] #[serial] async fn unknown_selected_operations_are_persisted_in_the_canonical_replay() { let registry = test_registry().await; let service = test_service( registry, test_storage_root("openapi_import_unknown_replay"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let payload = OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned(), "GET /missing".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "skip".to_owned(), }; let first = service .create_openapi_import( &workspace_id, &preview.job_id.as_str().into(), payload.clone(), ) .await .unwrap(); let replayed = service .create_openapi_import(&workspace_id, &preview.job_id.as_str().into(), payload) .await .unwrap(); assert_eq!(first.created, replayed.created); assert_eq!(first.skipped, replayed.skipped); assert_eq!(first.skipped.len(), 1); assert_eq!(first.skipped[0].operation_key, "GET /missing"); } #[tokio::test] #[serial] async fn concurrent_openapi_import_replays_the_same_atomic_result() { let registry = test_registry().await; let service = test_service( registry.clone(), test_storage_root("openapi_import_replay"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let job_id = preview.job_id.as_str().into(); let payload = OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "rename".to_owned(), }; let (first, second) = tokio::join!( service.create_openapi_import(&workspace_id, &job_id, payload.clone()), service.create_openapi_import(&workspace_id, &job_id, payload), ); let first = first.unwrap(); let second = second.unwrap(); assert_eq!(first.created.len(), 1); assert_eq!(second.created.len(), 1); assert_eq!( first.created[0].operation_id, second.created[0].operation_id ); assert_eq!(first.created[0].name, second.created[0].name); assert_eq!( service.list_operations(&workspace_id).await.unwrap().len(), 1 ); let completed_before = registry .get_import_job(&workspace_id, &job_id) .await .unwrap() .unwrap(); let empty = serde_json::json!([]); let failed_at = time::OffsetDateTime::now_utc(); registry .finish_import_job(crank_registry::FinishImportJobRequest { id: &job_id, status: ImportJobStatus::Failed, created_operation_ids: &empty, error_text: Some("late_verification_failure"), finished_at: &failed_at, }) .await .unwrap(); let completed_after = registry .get_import_job(&workspace_id, &job_id) .await .unwrap() .unwrap(); assert_eq!(completed_after.status, ImportJobStatus::Completed); assert_eq!(completed_after.error_text, completed_before.error_text); assert_eq!(completed_after.finished_at, completed_before.finished_at); let conflicting_replay = service .create_openapi_import( &workspace_id, &job_id, OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "skip".to_owned(), }, ) .await; assert!(conflicting_replay.is_err()); } #[tokio::test] #[serial] async fn apply_fails_closed_when_the_preview_parser_contract_drifts() { let registry = test_registry().await; let service = test_service( registry.clone(), test_storage_root("openapi_import_parser_drift"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into(); sqlx::query( "update import_jobs set preview_payload = jsonb_set(preview_payload, '{preview_digest}', to_jsonb($1::text)) where id = $2", ) .bind("0".repeat(64)) .bind(job_id.as_str()) .execute(registry.pool()) .await .unwrap(); let result = service .create_openapi_import( &workspace_id, &job_id, OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "rename".to_owned(), }, ) .await; assert!(result.is_err()); assert_failed_job_and_detached_sources( ®istry, &workspace_id, &job_id, "import_replay_verification_failed", ) .await; assert!( service .list_operations(&workspace_id) .await .unwrap() .is_empty() ); } #[tokio::test] #[serial] async fn apply_fails_closed_for_each_normalization_contract_field_and_digest_shape() { let registry = test_registry().await; let service = test_service( registry.clone(), test_storage_root("openapi_import_contract_fields"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); for case in [ "normalizer_version", "projection_version", "ir_fingerprint", "missing_preview_digest", "non_string_preview_digest", ] { let preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into(); let query = match case { "normalizer_version" => sqlx::query( "update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,normalizer_version}', to_jsonb('future'::text)) where id = $1", ), "projection_version" => sqlx::query( "update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,projection_version}', to_jsonb('future'::text)) where id = $1", ), "ir_fingerprint" => sqlx::query( "update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,ir_fingerprint}', to_jsonb('bad'::text)) where id = $1", ), "missing_preview_digest" => sqlx::query( "update import_jobs set preview_payload = preview_payload - 'preview_digest' where id = $1", ), "non_string_preview_digest" => sqlx::query( "update import_jobs set preview_payload = jsonb_set(preview_payload, '{preview_digest}', '42'::jsonb) where id = $1", ), _ => unreachable!(), }; query .bind(job_id.as_str()) .execute(registry.pool()) .await .unwrap(); assert!( service .create_openapi_import( &workspace_id, &job_id, OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "rename".to_owned(), } ) .await .is_err() ); assert_failed_job_and_detached_sources( ®istry, &workspace_id, &job_id, "import_replay_verification_failed", ) .await; assert!( service .list_operations(&workspace_id) .await .unwrap() .is_empty() ); } } #[tokio::test] #[serial] async fn legacy_job_without_normalization_contract_replays_until_expiry() { let registry = test_registry().await; let service = test_service( registry.clone(), test_storage_root("openapi_import_legacy_replay"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let preview = service .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into(); sqlx::query( "update import_jobs set preview_payload = preview_payload - 'normalization' where id = $1", ) .bind(job_id.as_str()) .execute(registry.pool()) .await .unwrap(); let result = service .create_openapi_import( &workspace_id, &job_id, OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /v2/latest".to_owned()], server_url: Some("https://api.frankfurter.dev".to_owned()), conflict_mode: "skip".to_owned(), }, ) .await .unwrap(); assert_eq!(result.created.len(), 1); } #[tokio::test] #[serial] async fn blocker_finding_keeps_valid_preview_but_prevents_draft_mutation() { let registry = test_registry().await; let service = test_service( registry.clone(), test_storage_root("openapi_import_blocker"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let upload = OpenApiUpload { bytes: br#" openapi: 3.0.3 info: { title: Partial } paths: /valid: get: responses: { '200': { description: ok } } /broken: not-a-path-item "# .to_vec(), mime_type: "application/yaml".to_owned(), locale: OpenApiUploadLocale::En, }; let preview = service .preview_openapi_import(&workspace_id, upload) .await .unwrap(); assert_eq!(preview.preview.groups[0].operations.len(), 1); assert!( preview .preview .findings .iter() .any(|finding| finding.severity == crank_import::rest::ImportFindingSeverity::Error) ); assert!( service .create_openapi_import( &workspace_id, &preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /valid".to_owned()], server_url: None, conflict_mode: "skip".to_owned(), }, ) .await .is_err() ); assert!( service .list_operations(&workspace_id) .await .unwrap() .is_empty() ); assert_failed_job_and_detached_sources( ®istry, &workspace_id, &preview.job_id.as_str().into(), "reference_resolution_blocked", ) .await; } #[tokio::test] #[serial] async fn operation_blockers_apply_only_to_selected_keys_and_full_selection_fails_atomically() { let registry = test_registry().await; let service = test_service( registry.clone(), test_storage_root("openapi_import_selected_blockers"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let upload = OpenApiUpload { bytes: br#" openapi: 3.1.0 info: { title: Selected blockers } servers: [{ url: https://api.example.test }] paths: /valid: get: operationId: validOperation responses: { '200': { description: ok } } /broken: get: operationId: brokenOperation responses: '200': description: unresolved external schema content: application/json: schema: { $ref: 'https://schemas.example.test/missing.yaml#/Result' } "# .to_vec(), mime_type: "application/yaml".to_owned(), locale: OpenApiUploadLocale::En, }; let valid_preview = service .preview_openapi_import(&workspace_id, upload.clone()) .await .unwrap(); assert!( valid_preview.preview.findings.iter().all(|finding| { finding.severity != crank_import::rest::ImportFindingSeverity::Error }) ); let broken = valid_preview .preview .groups .iter() .flat_map(|group| &group.operations) .find(|operation| operation.key == "GET /broken") .unwrap(); assert!( broken.findings.iter().any(|finding| { finding.severity == crank_import::rest::ImportFindingSeverity::Error }) ); let valid = service .create_openapi_import( &workspace_id, &valid_preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /valid".to_owned()], server_url: None, conflict_mode: "skip".to_owned(), }, ) .await .unwrap(); assert_eq!(valid.created.len(), 1); let full_preview = service .preview_openapi_import(&workspace_id, upload) .await .unwrap(); let result = service .create_openapi_import( &workspace_id, &full_preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /valid".to_owned(), "GET /broken".to_owned()], server_url: None, conflict_mode: "rename".to_owned(), }, ) .await; assert!(result.is_err()); assert_eq!( service.list_operations(&workspace_id).await.unwrap().len(), 1, "the full selection must not create either selected Draft" ); let failed = registry .get_import_job(&workspace_id, &full_preview.job_id.as_str().into()) .await .unwrap() .unwrap(); assert_eq!(failed.status, ImportJobStatus::Failed); assert_eq!( failed.error_text.as_deref(), Some("reference_resolution_blocked") ); assert!(failed.finished_at.is_some()); } #[tokio::test] #[serial] async fn local_operation_reference_resolves_and_creates_draft() { let registry = test_registry().await; let service = test_service( registry, test_storage_root("openapi_import_operation_blocker"), test_auth_settings(), test_secret_crypto(), ); let workspace_id = WorkspaceId::new("ws_default"); let upload = OpenApiUpload { bytes: br#" openapi: 3.0.3 info: { title: References } servers: [{ url: https://api.example.test }] paths: /referenced: get: operationId: referenced responses: '200': description: ok content: application/json: schema: { $ref: '#/components/schemas/Result' } components: schemas: Result: { type: object, properties: { id: { type: string } } } "# .to_vec(), mime_type: "application/yaml".to_owned(), locale: OpenApiUploadLocale::En, }; let preview = service .preview_openapi_import(&workspace_id, upload) .await .unwrap(); assert!( preview.preview.findings.iter().all(|finding| { finding.severity != crank_import::rest::ImportFindingSeverity::Error }) ); assert!( preview.preview.groups[0].operations[0] .findings .iter() .all(|finding| finding.code != "unresolved_reference") ); assert_eq!(preview.preview.groups[0].operations[0].output_fields, 1); let created = service .create_openapi_import( &workspace_id, &preview.job_id.as_str().into(), OpenApiImportCreatePayload { selected_operation_keys: vec!["GET /referenced".to_owned()], server_url: None, conflict_mode: "skip".to_owned(), }, ) .await .unwrap(); assert_eq!(created.created.len(), 1); assert_eq!( service.list_operations(&workspace_id).await.unwrap().len(), 1 ); } fn openapi_upload() -> OpenApiUpload { OpenApiUpload { bytes: OPENAPI3.as_bytes().to_vec(), mime_type: "application/yaml".to_owned(), locale: OpenApiUploadLocale::En, } } async fn assert_failed_job_and_detached_sources( registry: &crank_registry::PostgresRegistry, workspace_id: &WorkspaceId, job_id: &crank_registry::ImportJobId, expected_error: &str, ) { let job = registry .get_import_job(workspace_id, job_id) .await .unwrap() .unwrap(); assert_eq!(job.status, ImportJobStatus::Failed); assert!(job.finished_at.is_some()); assert_eq!(job.error_text.as_deref(), Some(expected_error)); let mut source_ids = vec![ job.preview_payload["source"]["source_id"] .as_str() .unwrap() .to_owned(), ]; source_ids.extend( job.preview_payload["dependencies"] .as_array() .into_iter() .flatten() .map(|dependency| dependency["source_id"].as_str().unwrap().to_owned()), ); for source_id in source_ids { let lifecycle: String = sqlx::query_scalar( "select lifecycle from artifact_sources where workspace_id = $1 and source_id = $2", ) .bind(workspace_id.as_str()) .bind(source_id) .fetch_one(registry.pool()) .await .unwrap(); assert_eq!(lifecycle, "detached"); } }