diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f9be77f..5cabe9a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -214,7 +214,31 @@ jobs: - name: Run Playwright e2e working-directory: apps/ui - run: npx playwright test + run: | + mkdir -p ../../.tmp + rm -f ../../.tmp/openapi-playwright.json ../../.tmp/openapi-ui-evidence.json + PLAYWRIGHT_JSON_OUTPUT=../../.tmp/openapi-playwright.json npx playwright test + + - name: Collect sanitized OpenAPI UI evidence + working-directory: apps/ui + run: | + trap 'rm -f ../../.tmp/openapi-playwright.json' EXIT + python3 ../../scripts/collect-capability-baseline.py playwright \ + --report ../../.tmp/openapi-playwright.json \ + --output ../../.tmp/openapi-ui-evidence.json \ + --source-revision "$(git -C ../.. rev-parse HEAD)" \ + --environment-class ci \ + --flow-id openapi-upload-ui \ + --required-test 'operations page imports OpenAPI methods as drafts' \ + --required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \ + --required-test 'OpenAPI upload recovers from pagehide and a preview server error' \ + --required-test 'OpenAPI upload invalidates active draft creation after language or workspace changes' \ + --required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \ + --required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers' + python3 ../../scripts/validate-capability-run.py \ + --schema ../../docs/schemas/capability-baseline.schema.json \ + --candidate ../../.tmp/openapi-ui-evidence.json \ + --require-accepted - name: Show Playwright stack logs if: failure() diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6f15a68..b4d4199 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -121,7 +121,31 @@ jobs: - name: Run release end-to-end tests working-directory: apps/ui - run: npm run e2e + run: | + mkdir -p ../../.tmp + rm -f ../../.tmp/openapi-playwright.json ../../.tmp/openapi-ui-evidence.json + PLAYWRIGHT_JSON_OUTPUT=../../.tmp/openapi-playwright.json npm run e2e + + - name: Collect sanitized OpenAPI UI release evidence + working-directory: apps/ui + run: | + trap 'rm -f ../../.tmp/openapi-playwright.json' EXIT + python3 ../../scripts/collect-capability-baseline.py playwright \ + --report ../../.tmp/openapi-playwright.json \ + --output ../../.tmp/openapi-ui-evidence.json \ + --source-revision "$(git -C ../.. rev-parse HEAD)" \ + --environment-class release \ + --flow-id openapi-upload-ui \ + --required-test 'operations page imports OpenAPI methods as drafts' \ + --required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \ + --required-test 'OpenAPI upload recovers from pagehide and a preview server error' \ + --required-test 'OpenAPI upload invalidates active draft creation after language or workspace changes' \ + --required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \ + --required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers' + python3 ../../scripts/validate-capability-run.py \ + --schema ../../docs/schemas/capability-baseline.schema.json \ + --candidate ../../.tmp/openapi-ui-evidence.json \ + --require-accepted - name: Validate deployment manifests run: | @@ -132,9 +156,12 @@ jobs: - name: Package release artifacts run: | mkdir -p dist/release + source_revision="$(git rev-parse HEAD)" + evidence_artifact="dist/crank-openapi-ui-evidence-${IMAGE_TAG}-${source_revision}.json" cp target/release/admin-api dist/release/admin-api cp target/release/crank-migrate dist/release/crank-migrate cp target/release/mcp-server dist/release/mcp-server + cp .tmp/openapi-ui-evidence.json "$evidence_artifact" tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api crank-migrate tar -C dist/release -czf dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz mcp-server tar -C apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz . @@ -142,6 +169,7 @@ jobs: dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \ dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \ dist/crank-community-ui-${IMAGE_TAG}.tar.gz \ + "$evidence_artifact" \ > dist/crank-community-${IMAGE_TAG}-checksums.txt - name: Generate SBOM diff --git a/Cargo.lock b/Cargo.lock index 284d804..b50df08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,6 +261,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -521,9 +522,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1110,6 +1111,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -2138,6 +2148,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2158,6 +2178,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -2858,6 +2895,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -3473,9 +3511,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -4126,6 +4164,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 66477f5..a1a9acc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ publish = false [workspace.dependencies] aes-gcm = "0.10" argon2 = "0.5" -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } axum-extra = { version = "0.12", features = ["cookie"] } base64 = "0.22" hkdf = "0.12" @@ -43,7 +43,7 @@ opentelemetry_sdk = { version = "0.32.1", default-features = false, features = [ percent-encoding = "2" prost = "0.14" rand = "0.10" -reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "multipart", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index e190032..d54bc84 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -4,6 +4,7 @@ use axum::{ middleware, routing::{delete, get, post}, }; +use crank_artifacts::MAX_ARTIFACT_BYTES; use crate::{ auth::{ @@ -48,7 +49,11 @@ use crate::{ pub fn build_app(state: AppState) -> Router { let workspace_router = Router::new() .route("/operations", get(list_operations).post(create_operation)) - .route("/imports/openapi/preview", post(preview_openapi_import)) + .route( + "/imports/openapi/preview", + post(preview_openapi_import) + .layer(DefaultBodyLimit::max(MAX_ARTIFACT_BYTES + 32 * 1024)), + ) .route( "/imports/openapi/{job_id}/create", post(create_openapi_import), diff --git a/apps/admin-api/src/dto.rs b/apps/admin-api/src/dto.rs index 5f77466..9295358 100644 --- a/apps/admin-api/src/dto.rs +++ b/apps/admin-api/src/dto.rs @@ -524,9 +524,17 @@ pub struct LegacyYamlOperationDocument { pub operation: RegistryOperation, } -#[derive(Clone, Debug, Deserialize)] -pub struct OpenApiImportPreviewPayload { - pub document: String, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OpenApiUploadLocale { + En, + Ru, +} + +#[derive(Clone, Debug)] +pub struct OpenApiUpload { + pub bytes: Vec, + pub mime_type: String, + pub locale: OpenApiUploadLocale, } #[derive(Clone, Debug, Serialize)] diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index a8904b3..8e8b1fb 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use thiserror::Error; use tracing::{error, warn}; +use crate::dto::OpenApiUploadLocale; use crate::storage::StorageError; #[derive(Debug, Error)] @@ -96,6 +97,112 @@ impl ApiError { } } + pub(crate) fn openapi_upload(locale: OpenApiUploadLocale, code: &'static str) -> Self { + let russian = locale == OpenApiUploadLocale::Ru; + let message = match (russian, code) { + (_, "file_too_large") => { + if russian { + "файл OpenAPI превышает лимит 256 КиБ" + } else { + "OpenAPI file exceeds the 256 KiB limit" + } + } + (_, "empty_file") => { + if russian { + "файл OpenAPI не должен быть пустым" + } else { + "OpenAPI file must not be empty" + } + } + (_, "invalid_utf8") => { + if russian { + "файл OpenAPI должен быть в UTF-8" + } else { + "OpenAPI file must be UTF-8" + } + } + (_, "invalid_media_type") => { + if russian { + "тип файла OpenAPI не поддерживается" + } else { + "OpenAPI file type is not supported" + } + } + (_, "invalid_document") => { + if russian { + "некорректный или неподдерживаемый документ OpenAPI" + } else { + "OpenAPI document is invalid or unsupported" + } + } + (_, "no_methods") => { + if russian { + "документ OpenAPI не содержит поддерживаемых методов" + } else { + "OpenAPI document contains no supported methods" + } + } + (_, "source_integrity") => { + if russian { + "проверка целостности источника OpenAPI не пройдена" + } else { + "OpenAPI source integrity verification failed" + } + } + (_, "source_unavailable") => { + if russian { + "источник OpenAPI недоступен" + } else { + "OpenAPI source is unavailable" + } + } + (_, "parser_unavailable") | (_, "storage_unavailable") => { + if russian { + "обработка OpenAPI временно недоступна" + } else { + "OpenAPI processing is temporarily unavailable" + } + } + _ => { + if russian { + "некорректная multipart-загрузка OpenAPI" + } else { + "invalid OpenAPI multipart upload" + } + } + }; + let context = json!({ "error_code": format!("openapi_upload.{code}") }); + if code == "file_too_large" { + Self::PayloadTooLarge { + message: message.to_owned(), + context: Some(context), + } + } else if matches!(code, "storage_unavailable" | "parser_unavailable") { + Self::Internal { + message: message.to_owned(), + context: Some(context), + } + } else if matches!(code, "source_integrity" | "source_unavailable") { + Self::Unprocessable { + message: message.to_owned(), + context: Some(context), + } + } else { + Self::Validation { + message: message.to_owned(), + context: Some(context), + } + } + } + + pub(crate) fn source_unavailable() -> Self { + Self::openapi_upload(OpenApiUploadLocale::En, "source_unavailable") + } + + pub(crate) fn source_integrity() -> Self { + Self::openapi_upload(OpenApiUploadLocale::En, "source_integrity") + } + pub(crate) fn rate_limited_with_context(message: impl Into, context: Value) -> Self { Self::RateLimited { message: message.into(), @@ -588,17 +695,15 @@ impl From for ApiError { format!("import job {job_id} was already applied with different parameters"), json!({ "job_id": job_id }), ), - RegistryError::SourceNotFound { source_id } => Self::not_found_with_context( + RegistryError::SourceNotFound { .. } => Self::not_found_with_context( "artifact source was not found", json!({ - "source_id": source_id, "error_code": "artifact_source_not_found" }), ), - RegistryError::SourceConflict { source_id } => Self::conflict_with_context( + RegistryError::SourceConflict { .. } => Self::conflict_with_context( "artifact source metadata or lifecycle conflicts with the request", json!({ - "source_id": source_id, "error_code": "artifact_source_conflict", "recovery": "reload" }), diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 7882089..30a91c0 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -204,6 +204,7 @@ async fn run( verified_startup_secret_crypto(®istry, config.runtime.master_key.expose_secret()) .await?; let artifact_store = open_reconciliation_store(config.storage_root.clone()).await?; + registry.delete_expired_import_jobs().await?; let outbound_http_policy = crank_runtime::OutboundHttpPolicy::try_new_with_limits( config.runtime.outbound.allowed_hosts.clone(), config.runtime.outbound.denied_hosts.clone(), @@ -224,6 +225,7 @@ async fn run( secret_crypto, runtime, ) + .with_artifact_store(artifact_store.clone()) .with_public_base_url(base_url) .with_outbound_http_policy(outbound_http_policy) .with_identity_provider(std::sync::Arc::new(identity_provider)) diff --git a/apps/admin-api/src/routes/imports.rs b/apps/admin-api/src/routes/imports.rs index 8916635..de24ec6 100644 --- a/apps/admin-api/src/routes/imports.rs +++ b/apps/admin-api/src/routes/imports.rs @@ -1,13 +1,16 @@ use axum::{ Json, - extract::{Path, State}, + extract::{Multipart, Path, State, multipart::MultipartRejection}, + http::HeaderMap, + response::IntoResponse, }; +use crank_artifacts::MAX_ARTIFACT_BYTES; use serde::Deserialize; use serde_json::{Value, json}; use crate::{ error::ApiError, - service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload}, + service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale}, state::AppState, }; @@ -25,15 +28,118 @@ pub struct WorkspaceImportPath { pub async fn preview_openapi_import( Path(path): Path, State(state): State, - Json(payload): Json, + headers: HeaderMap, + multipart: Result, ) -> Result, ApiError> { + let locale = openapi_upload_locale(&headers); + let upload = parse_openapi_upload( + multipart.map_err(|rejection| multipart_rejection(locale, rejection))?, + locale, + ) + .await?; let preview = state .service - .preview_openapi_import(&path.workspace_id.as_str().into(), payload) + .preview_openapi_import(&path.workspace_id.as_str().into(), upload) .await?; Ok(Json(json!(preview))) } +fn multipart_rejection(locale: OpenApiUploadLocale, rejection: MultipartRejection) -> ApiError { + if rejection.into_response().status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE { + ApiError::openapi_upload(locale, "file_too_large") + } else { + ApiError::openapi_upload(locale, "malformed_multipart") + } +} + +async fn parse_openapi_upload( + mut multipart: Multipart, + locale: OpenApiUploadLocale, +) -> Result { + let mut upload = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))? + { + if upload.is_some() || field.name() != Some("file") { + return Err(ApiError::openapi_upload(locale, "malformed_multipart")); + } + let filename = field + .file_name() + .ok_or_else(|| ApiError::openapi_upload(locale, "invalid_filename"))?; + let mime_type = field + .content_type() + .map(ToString::to_string) + .ok_or_else(|| ApiError::openapi_upload(locale, "invalid_media_type"))?; + if !valid_upload_type(filename, &mime_type) { + return Err(ApiError::openapi_upload(locale, "invalid_media_type")); + } + let mut bytes = Vec::with_capacity(8 * 1024); + let mut field = field; + while let Some(chunk) = field + .chunk() + .await + .map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))? + { + if bytes.len().saturating_add(chunk.len()) > MAX_ARTIFACT_BYTES { + return Err(ApiError::openapi_upload(locale, "file_too_large")); + } + bytes.extend_from_slice(&chunk); + } + if bytes.is_empty() { + return Err(ApiError::openapi_upload(locale, "empty_file")); + } + if std::str::from_utf8(&bytes).is_err() { + return Err(ApiError::openapi_upload(locale, "invalid_utf8")); + } + upload = Some(OpenApiUpload { + bytes, + mime_type, + locale, + }); + } + upload.ok_or_else(|| ApiError::openapi_upload(locale, "missing_file")) +} + +fn valid_upload_type(filename: &str, mime_type: &str) -> bool { + let filename = filename.to_ascii_lowercase(); + let mime_type = mime_type.to_ascii_lowercase(); + match filename.rsplit_once('.') { + Some((_, "yaml" | "yml")) => matches!( + mime_type.as_str(), + "application/yaml" + | "application/x-yaml" + | "text/yaml" + | "text/x-yaml" + | "application/octet-stream" + ), + Some((_, "json")) => matches!( + mime_type.as_str(), + "application/json" | "application/openapi+json" | "application/octet-stream" + ), + _ => false, + } +} + +fn openapi_upload_locale(headers: &HeaderMap) -> OpenApiUploadLocale { + let prefers_russian = headers + .get("accept-language") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value.split(',').any(|range| { + let language = range.split(';').next().unwrap_or_default().trim(); + language.eq_ignore_ascii_case("ru") + || language.to_ascii_lowercase().starts_with("ru-") + }) + }); + if prefers_russian { + OpenApiUploadLocale::Ru + } else { + OpenApiUploadLocale::En + } +} + pub async fn create_openapi_import( Path(path): Path, State(state): State, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 1912ebe..e906410 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use crank_artifacts::ArtifactStore; use crank_core::{ AuditActor, AuditEvent, AuditEventId, AuditSink, AuditTarget, AuditTargetKind, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, CorrelationContext, EditionCapabilities, @@ -57,6 +58,7 @@ use operation_validation::{ #[derive(Clone)] pub struct AdminService { registry: PostgresRegistry, + artifact_store: Arc, runtime: RuntimeExecutor, storage: LocalArtifactStorage, auth_settings: AuthSettings, @@ -72,6 +74,7 @@ pub struct AdminService { pub struct AdminServiceBuilder { registry: PostgresRegistry, storage_root: PathBuf, + artifact_store: Option, auth_settings: AuthSettings, secret_crypto: SecretCrypto, runtime: RuntimeExecutor, @@ -201,6 +204,7 @@ impl AdminServiceBuilder { Self { registry, storage_root, + artifact_store: None, auth_settings, secret_crypto, runtime, @@ -223,6 +227,14 @@ impl AdminServiceBuilder { self } + /// Reuses the process-wide immutable artifact authority for OpenAPI + /// ingress and reconciliation. Tests may omit this and use their private + /// storage root instead. + pub fn with_artifact_store(mut self, artifact_store: ArtifactStore) -> Self { + self.artifact_store = Some(artifact_store); + self + } + pub fn with_public_base_url(mut self, public_base_url: String) -> Self { self.public_base_url = public_base_url.trim_end_matches('/').to_owned(); self @@ -252,6 +264,10 @@ impl AdminServiceBuilder { pub fn build(self) -> AdminService { AdminService { registry: self.registry, + artifact_store: Arc::new( + self.artifact_store + .unwrap_or_else(|| ArtifactStore::new(&self.storage_root)), + ), runtime: self.runtime, storage: LocalArtifactStorage::new(self.storage_root), auth_settings: self.auth_settings, diff --git a/apps/admin-api/src/service/imports.rs b/apps/admin-api/src/service/imports.rs index c171657..3a4026e 100644 --- a/apps/admin-api/src/service/imports.rs +++ b/apps/admin-api/src/service/imports.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; +use crank_artifacts::{ArtifactError, MAX_ARTIFACT_BYTES}; use crank_core::{ ExecutionConfig, OperationSecurityLevel, Protocol, ToolQualityFinding, ToolQualitySeverity, WorkspaceId, @@ -8,8 +9,10 @@ use crank_import::rest::{ ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate, }; use crank_registry::{ - ApplyImportJobRequest, CreateImportJobRequest, ImportConflictMode, ImportJobId, ImportJobKind, - ImportJobStatus, ImportOperationDraft, + ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity, + CreateArtifactSourceRequest, CreateImportJobRequest, DetachArtifactSourceRequest, + ImportConflictMode, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope, + ImportJobStatus, ImportOperationDraft, RegistryError, }; use serde_json::json; use sha2::{Digest, Sha256}; @@ -20,30 +23,83 @@ use crate::{ error::ApiError, service::{ AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse, - OpenApiImportCreatedOperation, OpenApiImportPreviewPayload, OpenApiImportPreviewResponse, - OpenApiImportSkippedOperation, OperationPayload, new_prefixed_id, + OpenApiImportCreatedOperation, OpenApiImportPreviewResponse, OpenApiImportSkippedOperation, + OpenApiUpload, OpenApiUploadLocale, OperationPayload, new_prefixed_id, }, }; const IMPORT_JOB_TTL_HOURS: i64 = 24; impl AdminService { - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))] + #[instrument(skip(self, upload), fields(workspace_id = %workspace_id.as_str()))] pub async fn preview_openapi_import( &self, workspace_id: &WorkspaceId, - payload: OpenApiImportPreviewPayload, + upload: OpenApiUpload, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; - let _ = self.registry.delete_expired_import_jobs().await; + self.registry.delete_expired_import_jobs().await?; - let preview = crank_import::rest::preview_document(&payload.document) - .map_err(|error| ApiError::validation(error.to_string()))?; + validate_openapi_upload(&upload)?; + let OpenApiUpload { + bytes, + mime_type, + locale, + } = upload; + let store = self.artifact_store.clone(); + let artifact = tokio::task::spawn_blocking(move || store.put_registered(&bytes)) + .await + .map_err(|_| ApiError::openapi_upload(locale, "storage_unavailable"))? + .map_err(|error| artifact_error(locale, error))?; let now = OffsetDateTime::now_utc(); let expires_at = now + Duration::hours(IMPORT_JOB_TTL_HOURS); let job_id = ImportJobId::new(new_prefixed_id("imp")); - let preview_payload = serde_json::to_value(&preview) + let source_id = ArtifactSourceId::new(new_prefixed_id("src_openapi")); + let source = self + .registry + .create_artifact_source(CreateArtifactSourceRequest { + workspace_id, + source_id: &source_id, + artifact: &artifact, + mime_type: &mime_type, + sensitivity: ArtifactSourceSensitivity::Internal, + created_at: now, + }) + .await?; + let mut detach_guard = SourceDetachGuard::new( + self.registry.clone(), + workspace_id.clone(), + source_id.clone(), + source.updated_at, + ); + let verified = self + .registry + .read_artifact_source(&self.artifact_store, workspace_id, &source_id) + .await?; + if verified.source.blob.artifact_ref != *artifact.artifact_ref() { + return Err(ApiError::openapi_upload(locale, "source_integrity")); + } + let preview = parse_verified_preview(verified.bytes, locale).await?; + if preview + .groups + .iter() + .all(|group| group.operations.is_empty()) + { + return Err(ApiError::openapi_upload(locale, "no_methods")); + } + let source_envelope = ImportJobSourceEnvelope { + source_id, + digest: artifact.artifact_ref().clone(), + }; + let preview_value = serde_json::to_value(&preview) .map_err(|error| ApiError::internal(error.to_string()))?; + let preview_payload = json!({ + "source": { + "source_id": source_envelope.source_id.as_str(), + "digest": source_envelope.digest.as_str(), + }, + "preview": preview_value, + }); self.registry .create_import_job(CreateImportJobRequest { @@ -53,11 +109,13 @@ impl AdminService { source_format: &preview.source.format, source_version: preview.source.version.as_deref(), status: ImportJobStatus::Pending, + source: &source_envelope, preview_payload: &preview_payload, created_at: &now, expires_at: &expires_at, }) .await?; + detach_guard.disarm(); Ok(OpenApiImportPreviewResponse { job_id: job_id.as_str().to_owned(), @@ -76,7 +134,7 @@ impl AdminService { payload: OpenApiImportCreatePayload, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; - let _ = self.registry.delete_expired_import_jobs().await; + self.registry.delete_expired_import_jobs().await?; if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") { return Err(ApiError::validation( @@ -101,13 +159,6 @@ impl AdminService { return Err(ApiError::validation("import job kind is not openapi")); } - let stored_preview = job - .preview_payload - .get("preview") - .cloned() - .unwrap_or_else(|| job.preview_payload.clone()); - let preview: crank_import::rest::ImportPreview = serde_json::from_value(stored_preview) - .map_err(|error| ApiError::internal(error.to_string()))?; let selected = payload .selected_operation_keys .iter() @@ -118,7 +169,38 @@ impl AdminService { "selected_operation_keys must contain at least one operation", )); } + let finished_at = OffsetDateTime::now_utc(); + let application_key = openapi_application_key(&payload)?; + let conflict_mode = if payload.conflict_mode == "skip" { + ImportConflictMode::Skip + } else { + ImportConflictMode::Rename + }; + if job.status == ImportJobStatus::Completed { + let applied = self + .registry + .apply_import_job(ApplyImportJobRequest { + id: job_id, + workspace_id, + application_key: &application_key, + conflict_mode, + operations: &[], + finished_at: &finished_at, + }) + .await?; + return Ok(openapi_import_response(applied, Vec::new())); + } + let source = import_job_source(&job.preview_payload)?; + let verified = self + .registry + .read_artifact_source(&self.artifact_store, workspace_id, &source.source_id) + .await + .map_err(openapi_source_error)?; + if verified.source.blob.artifact_ref != source.digest { + return Err(ApiError::source_integrity()); + } + let preview = parse_verified_preview(verified.bytes, OpenApiUploadLocale::En).await?; let mut candidates = BTreeMap::new(); for group in &preview.groups { for operation in &group.operations { @@ -171,13 +253,6 @@ impl AdminService { }); } - let finished_at = OffsetDateTime::now_utc(); - let application_key = openapi_application_key(&payload)?; - let conflict_mode = if payload.conflict_mode == "skip" { - ImportConflictMode::Skip - } else { - ImportConflictMode::Rename - }; let applied = self .registry .apply_import_job(ApplyImportJobRequest { @@ -190,20 +265,31 @@ impl AdminService { }) .await?; - let created = applied - .created - .iter() - .map(|operation| OpenApiImportCreatedOperation { - operation_id: operation.operation_id.as_str().to_owned(), - name: operation.name.clone(), - version: operation.version, - }) - .collect::>(); - let mut findings = applied - .created - .iter() - .filter_map(|operation| { - operation.renamed_from.as_ref().map(|previous_name| ImportFinding { + Ok(openapi_import_response(applied, skipped)) + } +} + +fn openapi_import_response( + applied: ImportJobApplyResult, + mut skipped: Vec, +) -> OpenApiImportCreateResponse { + let created = applied + .created + .iter() + .map(|operation| OpenApiImportCreatedOperation { + operation_id: operation.operation_id.as_str().to_owned(), + name: operation.name.clone(), + version: operation.version, + }) + .collect::>(); + let mut findings = applied + .created + .iter() + .filter_map(|operation| { + operation + .renamed_from + .as_ref() + .map(|previous_name| ImportFinding { code: "operation_name_renamed".to_owned(), severity: ImportFindingSeverity::Info, message: format!( @@ -212,36 +298,171 @@ impl AdminService { ), operation_key: Some(operation.operation_key.clone()), }) - }) - .collect::>(); - for operation in applied.skipped { - skipped.push(OpenApiImportSkippedOperation { - operation_key: operation.operation_key.clone(), - name: operation.name.clone(), - reason: "operation with this name already exists".to_owned(), - }); - findings.push(ImportFinding { - code: operation.reason, - severity: ImportFindingSeverity::Warning, - message: format!( - "Операция {} уже существует и была пропущена.", - operation.name - ), - operation_key: Some(operation.operation_key), + }) + .collect::>(); + for operation in applied.skipped { + skipped.push(OpenApiImportSkippedOperation { + operation_key: operation.operation_key.clone(), + name: operation.name.clone(), + reason: "operation with this name already exists".to_owned(), + }); + findings.push(ImportFinding { + code: operation.reason, + severity: ImportFindingSeverity::Warning, + message: format!( + "Операция {} уже существует и была пропущена.", + operation.name + ), + operation_key: Some(operation.operation_key), + }); + } + info!( + name: "admin.openapi_import.completed", + created = created.len(), + skipped = skipped.len(), + "openapi import created drafts" + ); + OpenApiImportCreateResponse { + created, + skipped, + findings, + } +} + +fn validate_openapi_upload(upload: &OpenApiUpload) -> Result<(), ApiError> { + if upload.bytes.is_empty() { + return Err(ApiError::openapi_upload(upload.locale, "empty_file")); + } + if upload.bytes.len() > MAX_ARTIFACT_BYTES { + return Err(ApiError::openapi_upload(upload.locale, "file_too_large")); + } + if !matches!( + upload.mime_type.as_str(), + "application/yaml" + | "application/x-yaml" + | "text/yaml" + | "text/x-yaml" + | "application/json" + | "application/openapi+json" + | "application/octet-stream" + ) { + return Err(ApiError::openapi_upload( + upload.locale, + "invalid_media_type", + )); + } + if std::str::from_utf8(&upload.bytes).is_err() { + return Err(ApiError::openapi_upload(upload.locale, "invalid_utf8")); + } + Ok(()) +} + +async fn parse_verified_preview( + bytes: Vec, + locale: OpenApiUploadLocale, +) -> Result { + tokio::task::spawn_blocking(move || { + let document = std::str::from_utf8(&bytes) + .map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?; + crank_import::rest::preview_document(document) + .map_err(|_| ApiError::openapi_upload(locale, "invalid_document")) + }) + .await + .map_err(|_| ApiError::openapi_upload(locale, "parser_unavailable"))? +} + +fn artifact_error(locale: OpenApiUploadLocale, error: ArtifactError) -> ApiError { + match error { + ArtifactError::EmptySource => ApiError::openapi_upload(locale, "empty_file"), + ArtifactError::SourceTooLarge => ApiError::openapi_upload(locale, "file_too_large"), + ArtifactError::Integrity => ApiError::openapi_upload(locale, "source_integrity"), + ArtifactError::Storage + | ArtifactError::NotFound + | ArtifactError::InvalidReference + | ArtifactError::UnsafeRoot => ApiError::openapi_upload(locale, "storage_unavailable"), + } +} + +fn openapi_source_error(error: RegistryError) -> ApiError { + match error { + RegistryError::SourceNotFound { .. } | RegistryError::SourceUnavailable => { + ApiError::source_unavailable() + } + RegistryError::SourceIntegrity => ApiError::source_integrity(), + other => ApiError::from(other), + } +} + +fn import_job_source(payload: &serde_json::Value) -> Result { + let source = payload + .get("source") + .ok_or_else(ApiError::source_unavailable)?; + let source_id = source + .get("source_id") + .and_then(serde_json::Value::as_str) + .filter(|value| value.len() <= 132) + .ok_or_else(ApiError::source_unavailable)?; + let digest = source + .get("digest") + .and_then(serde_json::Value::as_str) + .and_then(|value| value.parse().ok()) + .ok_or_else(ApiError::source_integrity)?; + Ok(ImportJobSourceEnvelope { + source_id: ArtifactSourceId::new(source_id), + digest, + }) +} + +struct SourceDetachGuard { + registry: crank_registry::PostgresRegistry, + workspace_id: WorkspaceId, + source_id: ArtifactSourceId, + expected_updated_at: OffsetDateTime, + armed: bool, +} + +impl SourceDetachGuard { + fn new( + registry: crank_registry::PostgresRegistry, + workspace_id: WorkspaceId, + source_id: ArtifactSourceId, + expected_updated_at: OffsetDateTime, + ) -> Self { + Self { + registry, + workspace_id, + source_id, + expected_updated_at, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for SourceDetachGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let registry = self.registry.clone(); + let workspace_id = self.workspace_id.clone(); + let source_id = self.source_id.clone(); + let expected_updated_at = Some(self.expected_updated_at); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let _ = registry + .detach_artifact_source(DetachArtifactSourceRequest { + workspace_id: &workspace_id, + source_id: &source_id, + expected_updated_at, + detached_at: OffsetDateTime::now_utc(), + }) + .await; }); } - info!( - name: "admin.openapi_import.completed", - created = created.len(), - skipped = skipped.len(), - "openapi import created drafts" - ); - - Ok(OpenApiImportCreateResponse { - created, - skipped, - findings, - }) } } diff --git a/apps/admin-api/tests/integration.rs b/apps/admin-api/tests/integration.rs index 8c9936a..eb3215b 100644 --- a/apps/admin-api/tests/integration.rs +++ b/apps/admin-api/tests/integration.rs @@ -7,6 +7,7 @@ mod integration { mod logs_usage; mod onboarding; mod openapi_import; + mod openapi_source; mod operation_lifecycle; mod operations_agents; mod request_context; diff --git a/apps/admin-api/tests/integration/common.rs b/apps/admin-api/tests/integration/common.rs index 3e3f32f..c04ad7e 100644 --- a/apps/admin-api/tests/integration/common.rs +++ b/apps/admin-api/tests/integration/common.rs @@ -328,14 +328,18 @@ pub(super) async fn test_registry() -> PostgresRegistry { } pub(super) fn test_storage_root(name: &str) -> std::path::PathBuf { - env::temp_dir().join(format!( + let root = env::temp_dir().join(format!( "crank_admin_api_{name}_{}_{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() - )) + )); + std::fs::create_dir_all(&root).unwrap(); + #[cfg(unix)] + std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap(); + root } pub(super) fn test_auth_settings() -> AuthSettings { diff --git a/apps/admin-api/tests/integration/openapi_import.rs b/apps/admin-api/tests/integration/openapi_import.rs index 24a6085..bfe2687 100644 --- a/apps/admin-api/tests/integration/openapi_import.rs +++ b/apps/admin-api/tests/integration/openapi_import.rs @@ -1,4 +1,4 @@ -use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload}; +use admin_api::service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale}; use crank_core::WorkspaceId; use crank_registry::ImportJobStatus; use serial_test::serial; @@ -51,12 +51,7 @@ async fn previews_openapi_and_creates_draft_operations() { let workspace_id = WorkspaceId::new("ws_default"); let preview = service - .preview_openapi_import( - &workspace_id, - OpenApiImportPreviewPayload { - document: OPENAPI3.to_owned(), - }, - ) + .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); @@ -89,6 +84,24 @@ async fn previews_openapi_and_creates_draft_operations() { 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 @@ -120,12 +133,7 @@ async fn previews_openapi_and_creates_draft_operations() { ); let skip_preview = service - .preview_openapi_import( - &workspace_id, - OpenApiImportPreviewPayload { - document: OPENAPI3.to_owned(), - }, - ) + .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let skipped = service @@ -147,12 +155,7 @@ async fn previews_openapi_and_creates_draft_operations() { assert_eq!(skipped.findings[0].code, "operation_name_conflict"); let rename_preview = service - .preview_openapi_import( - &workspace_id, - OpenApiImportPreviewPayload { - document: OPENAPI3.to_owned(), - }, - ) + .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let renamed = service @@ -185,12 +188,7 @@ async fn concurrent_openapi_import_replays_the_same_atomic_result() { ); let workspace_id = WorkspaceId::new("ws_default"); let preview = service - .preview_openapi_import( - &workspace_id, - OpenApiImportPreviewPayload { - document: OPENAPI3.to_owned(), - }, - ) + .preview_openapi_import(&workspace_id, openapi_upload()) .await .unwrap(); let job_id = preview.job_id.as_str().into(); @@ -232,3 +230,11 @@ async fn concurrent_openapi_import_replays_the_same_atomic_result() { .await; assert!(conflicting_replay.is_err()); } + +fn openapi_upload() -> OpenApiUpload { + OpenApiUpload { + bytes: OPENAPI3.as_bytes().to_vec(), + mime_type: "application/yaml".to_owned(), + locale: OpenApiUploadLocale::En, + } +} diff --git a/apps/admin-api/tests/integration/openapi_source.rs b/apps/admin-api/tests/integration/openapi_source.rs new file mode 100644 index 0000000..dd95f2f --- /dev/null +++ b/apps/admin-api/tests/integration/openapi_source.rs @@ -0,0 +1,937 @@ +use std::{ + io, + sync::{Arc, Mutex}, +}; + +use admin_api::service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale}; +use crank_artifacts::MAX_ARTIFACT_BYTES; +use crank_core::{Workspace, WorkspaceId, WorkspaceStatus}; +use crank_registry::{ArtifactSourceId, ArtifactSourceLifecycle, CreateWorkspaceRequest}; +use metrics_util::debugging::DebuggingRecorder; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::{ + error::OTelSdkResult, + trace::{SdkTracerProvider, SpanData, SpanExporter}, +}; +use reqwest::multipart::{Form, Part}; +use serde_json::Value; +use serial_test::serial; +use time::{Duration, OffsetDateTime}; +use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt}; + +use super::common::{ + authorized_client, build_test_app, spawn_admin_api, test_auth_settings, test_registry, + test_secret_crypto, test_service, test_storage_root, +}; + +mod apply_failures; + +const OPENAPI: &str = r#" +openapi: 3.0.3 +info: { title: Source authority } +servers: + - url: https://example.test +paths: + /health: + get: + operationId: sourceHealth + responses: + '200': { description: OK } +"#; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn multipart_accepts_valid_yaml_and_json_through_the_outer_router() { + let registry = test_registry().await; + let app = build_test_app(registry, test_storage_root("openapi_valid_multipart")); + let server = spawn_admin_api(app).await; + let client = authorized_client(&server).await; + + for (document, filename, mime_type) in [ + (OPENAPI.as_bytes(), "openapi.yaml", "application/yaml"), + ( + br#"{"openapi":"3.0.3","info":{"title":"JSON"},"paths":{"/health":{"get":{"responses":{"200":{"description":"OK"}}}}}}"# + .as_slice(), + "openapi.json", + "application/json", + ), + ] { + let response = client + .post(format!("{server}/imports/openapi/preview")) + .multipart(Form::new().part( + "file", + file_part(document, filename, mime_type), + )) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + assert_eq!(status, reqwest::StatusCode::OK, "{body}"); + assert!(body["job_id"].is_string()); + assert!(!body.to_string().contains("source_id")); + assert!(!body.to_string().contains("digest")); + } +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn multipart_requires_an_owner_membership_before_reading_the_file() { + let registry = test_registry().await; + let now = OffsetDateTime::now_utc(); + let foreign_workspace = WorkspaceId::new("ws_openapi_unauthorized"); + registry + .create_workspace(CreateWorkspaceRequest { + workspace: &Workspace { + id: foreign_workspace.clone(), + slug: "openapi-unauthorized".to_owned(), + display_name: "OpenAPI Unauthorized".to_owned(), + status: WorkspaceStatus::Active, + settings: serde_json::json!({}), + created_at: now, + updated_at: now, + }, + }) + .await + .unwrap(); + let app = build_test_app( + registry.clone(), + test_storage_root("openapi_upload_authorization"), + ); + let server = spawn_admin_api(app).await; + let endpoint = format!("{server}/imports/openapi/preview"); + + let anonymous = reqwest::Client::new() + .post(&endpoint) + .header("content-type", "multipart/form-data; boundary=broken") + .body("this body must not be parsed before authentication") + .send() + .await + .unwrap(); + assert_eq!(anonymous.status(), reqwest::StatusCode::UNAUTHORIZED); + + let authorized = authorized_client(&server).await; + let foreign_endpoint = endpoint.replace("ws_default", foreign_workspace.as_str()); + let forbidden = authorized + .post(foreign_endpoint) + .header("content-type", "multipart/form-data; boundary=broken") + .body("this body must not be parsed before workspace authorization") + .send() + .await + .unwrap(); + assert_eq!(forbidden.status(), reqwest::StatusCode::FORBIDDEN); + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from artifact_sources") + .fetch_one(registry.pool()) + .await + .unwrap(), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from import_jobs") + .fetch_one(registry.pool()) + .await + .unwrap(), + 0 + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn parser_canary_never_reaches_diagnostics_logs_traces_or_metrics() { + const CANARY: &str = "openapi-telemetry-secret-canary"; + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + recorder + .install() + .expect("isolated integration test metrics recorder"); + + let writer = SharedLogWriter::default(); + let exported = Arc::new(Mutex::new(Vec::new())); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(CapturingExporter(Arc::clone(&exported))) + .build(); + let tracer = provider.tracer("admin-openapi-source-test"); + let subscriber = tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer().with_writer(writer.clone())) + .with(tracing_opentelemetry::layer().with_tracer(tracer)); + let dispatch = tracing::Dispatch::new(subscriber); + tracing::dispatcher::set_global_default(dispatch) + .expect("isolated integration test tracing subscriber"); + + let registry = test_registry().await; + let app = build_test_app(registry, test_storage_root("openapi_telemetry_canary")); + let server = spawn_admin_api(app).await; + let client = authorized_client(&server).await; + let document = format!( + "openapi: 3.0.3\ninfo: {{ title: Canary }}\npaths:\n /broken:\n get:\n description: {CANARY}\n responses: [" + ); + let response = client + .post(format!("{server}/imports/openapi/preview")) + .multipart(Form::new().part( + "file", + file_part(document.as_bytes(), "openapi.yaml", "application/yaml"), + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let trace_id = response + .headers() + .get("x-trace-id") + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let body = response.text().await.unwrap(); + assert!(!body.contains(CANARY)); + let diagnostics: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(diagnostics["error"]["trace_id"], trace_id); + + provider.force_flush().unwrap(); + let logs = writer.output(); + assert!(!logs.contains(CANARY)); + assert!(logs.contains(&trace_id)); + let spans = exported.lock().unwrap(); + let rendered_spans = format!("{spans:?}"); + assert!(!rendered_spans.contains(CANARY)); + drop(spans); + for (key, _, _, _) in snapshotter.snapshot().into_vec() { + assert!(!key.key().name().contains(CANARY)); + assert!( + !key.key() + .labels() + .any(|label| { label.key().contains(CANARY) || label.value().contains(CANARY) }) + ); + } + provider.shutdown().unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn multipart_boundary_rejections_are_localized_and_leave_no_entities() { + let registry = test_registry().await; + let app = build_test_app( + registry.clone(), + test_storage_root("openapi_multipart_boundary"), + ); + let server = spawn_admin_api(app).await; + let client = authorized_client(&server).await; + + assert_rejected( + &client, + &server, + Form::new().part( + "file", + file_part(b"sensitive-openapi-body", "openapi.txt", "text/plain"), + ), + "ru-RU", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.invalid_media_type", + Some("тип"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new().part( + "file", + file_part(OPENAPI.as_bytes(), "openapi.txt", "application/yaml"), + ), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.invalid_media_type", + Some("not supported"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new().part( + "file", + file_part(OPENAPI.as_bytes(), "openapi.yaml", "application/json"), + ), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.invalid_media_type", + Some("not supported"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new(), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.malformed_multipart", + Some("multipart"), + ) + .await; + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from import_jobs") + .fetch_one(registry.pool()) + .await + .unwrap(), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from operations") + .fetch_one(registry.pool()) + .await + .unwrap(), + 0 + ); + assert_rejected( + &client, + &server, + Form::new().part( + "other", + file_part(OPENAPI.as_bytes(), "openapi.yaml", "application/yaml"), + ), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.malformed_multipart", + Some("multipart"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new() + .part( + "file", + file_part(OPENAPI.as_bytes(), "one.yaml", "application/yaml"), + ) + .part( + "file", + file_part(OPENAPI.as_bytes(), "two.yaml", "application/yaml"), + ), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.malformed_multipart", + Some("multipart"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new().part("file", file_part(b"", "openapi.yaml", "application/yaml")), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.empty_file", + Some("empty"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new().part( + "file", + file_part([0xff, 0xfe], "openapi.yaml", "application/yaml"), + ), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.invalid_utf8", + Some("UTF-8"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new().part( + "file", + file_part( + vec![b'x'; MAX_ARTIFACT_BYTES + 1], + "openapi.yaml", + "application/yaml", + ), + ), + "en-US", + reqwest::StatusCode::PAYLOAD_TOO_LARGE, + "openapi_upload.file_too_large", + Some("256 KiB"), + ) + .await; + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from import_jobs") + .fetch_one(registry.pool()) + .await + .unwrap(), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from artifact_sources") + .fetch_one(registry.pool()) + .await + .unwrap(), + 0 + ); + + assert_rejected( + &client, + &server, + Form::new().part( + "file", + file_part( + b"openapi: 3.0.3\ninfo: { title: Empty }\npaths: {}\n", + "openapi.yaml", + "application/yaml", + ), + ), + "en-US", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.no_methods", + Some("no supported methods"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new().part("file", file_part(b"", "openapi.yaml", "application/yaml")), + "ru-RU", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.empty_file", + Some("пуст"), + ) + .await; + assert_rejected( + &client, + &server, + Form::new().part( + "file", + file_part(b"openapi: [", "openapi.yaml", "application/yaml"), + ), + "ru-RU", + reqwest::StatusCode::BAD_REQUEST, + "openapi_upload.invalid_document", + Some("документ"), + ) + .await; + wait_for_source_lifecycle(®istry, ArtifactSourceLifecycle::Detached).await; + + let mut exact_limit = OPENAPI.as_bytes().to_vec(); + let padding = MAX_ARTIFACT_BYTES + .checked_sub(exact_limit.len()) + .expect("OpenAPI fixture must fit inside the exact-size boundary"); + exact_limit.extend(std::iter::repeat_n(b'#', padding)); + let response = client + .post(format!("{server}/imports/openapi/preview")) + .multipart(Form::new().part( + "file", + file_part(&exact_limit, "openapi.yaml", "application/yaml"), + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body = response.json::().await.unwrap(); + let rendered = body.to_string(); + assert!(body["job_id"].is_string()); + assert!(!rendered.contains("source_id")); + assert!(!rendered.contains("digest")); +} + +async fn wait_for_source_lifecycle( + registry: &crank_registry::PostgresRegistry, + lifecycle: ArtifactSourceLifecycle, +) { + let expected = match lifecycle { + ArtifactSourceLifecycle::Active => "active", + ArtifactSourceLifecycle::Detached => "detached", + }; + for _ in 0..100 { + let found = sqlx::query_scalar::<_, bool>( + "select exists(select 1 from artifact_sources where lifecycle = $1)", + ) + .bind(expected) + .fetch_one(registry.pool()) + .await + .unwrap(); + if found { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("artifact source did not reach {expected}"); +} + +fn file_part(bytes: impl AsRef<[u8]>, filename: &str, mime_type: &str) -> Part { + Part::bytes(bytes.as_ref().to_vec()) + .file_name(filename.to_owned()) + .mime_str(mime_type) + .unwrap() +} + +async fn assert_rejected( + client: &reqwest::Client, + server: &impl AsRef, + form: Form, + language: &str, + expected_status: reqwest::StatusCode, + expected_code: &str, + message_fragment: Option<&str>, +) { + let response = client + .post(format!("{}/imports/openapi/preview", server.as_ref())) + .header("accept-language", language) + .multipart(form) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + assert_eq!( + status, expected_status, + "unexpected status while checking {expected_code}: {body}" + ); + assert_eq!(body["error"]["code"], expected_code); + assert!(body["error"]["request_id"].is_string()); + assert!(body["error"]["trace_id"].is_string()); + if let Some(fragment) = message_fragment { + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains(fragment) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn apply_rereads_the_scoped_verified_source_and_expiry_detaches_it() { + let registry = test_registry().await; + let service = test_service( + registry.clone(), + test_storage_root("openapi_verified_source"), + test_auth_settings(), + test_secret_crypto(), + ); + let service = service.clone(); + let workspace_id = WorkspaceId::new("ws_default"); + let preview = service + .preview_openapi_import( + &workspace_id, + OpenApiUpload { + bytes: OPENAPI.as_bytes().to_vec(), + mime_type: "application/yaml".to_owned(), + locale: OpenApiUploadLocale::En, + }, + ) + .await + .unwrap(); + let job_id = preview.job_id.as_str().into(); + let job = registry + .get_import_job(&workspace_id, &job_id) + .await + .unwrap() + .unwrap(); + let source_id = job.preview_payload["source"]["source_id"] + .as_str() + .unwrap() + .to_owned(); + let source = registry + .get_artifact_source(&workspace_id, &source_id.as_str().into()) + .await + .unwrap(); + assert_eq!(source.lifecycle, ArtifactSourceLifecycle::Active); + + // The stored preview is presentation data only. A forged candidate cannot + // create a Draft because apply reparses the source bytes. + sqlx::query("update import_jobs set preview_payload = jsonb_set(preview_payload, '{preview}', '{\"groups\":[]}') where id = $1") + .bind(job_id.as_str()) + .execute(registry.pool()) + .await + .unwrap(); + let imported = service + .create_openapi_import( + &workspace_id, + &job_id, + OpenApiImportCreatePayload { + selected_operation_keys: vec!["GET /health".to_owned()], + server_url: None, + conflict_mode: "skip".to_owned(), + }, + ) + .await + .unwrap(); + assert_eq!(imported.created.len(), 1); + let detached = registry + .get_artifact_source(&workspace_id, &source_id.as_str().into()) + .await + .unwrap(); + assert_eq!(detached.lifecycle, ArtifactSourceLifecycle::Detached); + + let second_preview = service + .preview_openapi_import( + &workspace_id, + OpenApiUpload { + bytes: OPENAPI.as_bytes().to_vec(), + mime_type: "application/yaml".to_owned(), + locale: OpenApiUploadLocale::En, + }, + ) + .await + .unwrap(); + let second_job_id = second_preview.job_id.as_str().into(); + let second_job = registry + .get_import_job(&workspace_id, &second_job_id) + .await + .unwrap() + .unwrap(); + let second_source_id = second_job.preview_payload["source"]["source_id"] + .as_str() + .unwrap() + .to_owned(); + sqlx::query("update import_jobs set expires_at = $1 where id = $2") + .bind(OffsetDateTime::now_utc() - Duration::minutes(1)) + .bind(second_job_id.as_str()) + .execute(registry.pool()) + .await + .unwrap(); + registry.delete_expired_import_jobs().await.unwrap(); + let expired_job_source = registry + .get_artifact_source(&workspace_id, &second_source_id.as_str().into()) + .await + .unwrap(); + assert_eq!( + expired_job_source.lifecycle, + ArtifactSourceLifecycle::Detached + ); + + let legacy_preview = service + .preview_openapi_import( + &workspace_id, + OpenApiUpload { + bytes: format!("{OPENAPI}\n# legacy-upgrade").into_bytes(), + mime_type: "application/yaml".to_owned(), + locale: OpenApiUploadLocale::En, + }, + ) + .await + .unwrap(); + let legacy_job_id: crank_registry::ImportJobId = legacy_preview.job_id.as_str().into(); + let legacy_job = registry + .get_import_job(&workspace_id, &legacy_job_id) + .await + .unwrap() + .unwrap(); + let legacy_source_id = legacy_job.preview_payload["source"]["source_id"] + .as_str() + .unwrap() + .to_owned(); + sqlx::query( + "update import_jobs + set preview_payload = jsonb_set( + preview_payload, + '{source}', + '{\"format\":\"openapi\",\"version\":\"3.0.3\"}'::jsonb + ), + expires_at = now() - interval '1 minute' + where id = $1", + ) + .bind(legacy_job_id.as_str()) + .execute(registry.pool()) + .await + .unwrap(); + sqlx::query( + "update artifact_sources + set created_at = now() - interval '10 minutes', + updated_at = now() - interval '10 minutes' + where workspace_id = $1 and source_id = $2", + ) + .bind(workspace_id.as_str()) + .bind(&legacy_source_id) + .execute(registry.pool()) + .await + .unwrap(); + + registry.delete_expired_import_jobs().await.unwrap(); + assert!( + registry + .get_import_job(&workspace_id, &legacy_job_id) + .await + .unwrap() + .is_none() + ); + assert_eq!( + registry + .get_artifact_source(&workspace_id, &legacy_source_id.as_str().into()) + .await + .unwrap() + .lifecycle, + ArtifactSourceLifecycle::Detached + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn cancellation_detaches_and_restart_cleanup_recovers_a_dangling_source() { + let registry = test_registry().await; + let service = test_service( + registry.clone(), + test_storage_root("openapi_cancel_restart"), + test_auth_settings(), + test_secret_crypto(), + ); + let schema = sqlx::query_scalar::<_, String>("select current_schema()") + .fetch_one(registry.pool()) + .await + .unwrap(); + let observer = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(crank_test_support::postgres_database_url().await) + .await + .unwrap(); + let lock_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(crank_test_support::postgres_database_url().await) + .await + .unwrap(); + sqlx::query("select set_config('search_path', $1, false)") + .bind(&schema) + .execute(&observer) + .await + .unwrap(); + sqlx::query("select set_config('search_path', $1, false)") + .bind(&schema) + .execute(&lock_pool) + .await + .unwrap(); + sqlx::query( + "create function block_openapi_import_insert() returns trigger language plpgsql as $$ + begin + perform pg_advisory_xact_lock(2147483001); + return new; + end $$", + ) + .execute(registry.pool()) + .await + .unwrap(); + sqlx::query( + "create trigger block_openapi_import_insert + before insert on import_jobs + for each row execute function block_openapi_import_insert()", + ) + .execute(registry.pool()) + .await + .unwrap(); + let mut lock_connection = lock_pool.acquire().await.unwrap(); + sqlx::query("select pg_advisory_lock(2147483001)") + .execute(&mut *lock_connection) + .await + .unwrap(); + + let workspace_id = WorkspaceId::new("ws_default"); + let preview_task = tokio::spawn({ + let service = service.clone(); + let workspace_id = workspace_id.clone(); + async move { + service + .preview_openapi_import( + &workspace_id, + OpenApiUpload { + bytes: OPENAPI.as_bytes().to_vec(), + mime_type: "application/yaml".to_owned(), + locale: OpenApiUploadLocale::En, + }, + ) + .await + } + }); + let cancelled_source_id = + wait_for_source_lifecycle_in_pool(&observer, ArtifactSourceLifecycle::Active).await; + assert!(!preview_task.is_finished()); + preview_task.abort(); + let cancellation = preview_task.await.unwrap_err(); + assert!(cancellation.is_cancelled()); + wait_for_blocked_import_insert_to_stop(&observer).await; + sqlx::query("select pg_advisory_unlock(2147483001)") + .execute(&mut *lock_connection) + .await + .unwrap(); + wait_for_specific_source_lifecycle( + ®istry, + &cancelled_source_id, + ArtifactSourceLifecycle::Detached, + ) + .await; + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from import_jobs") + .fetch_one(registry.pool()) + .await + .unwrap(), + 0 + ); + + sqlx::query("drop trigger block_openapi_import_insert on import_jobs") + .execute(registry.pool()) + .await + .unwrap(); + let preview = service + .preview_openapi_import( + &workspace_id, + OpenApiUpload { + bytes: format!("{OPENAPI}\n# restart-window").into_bytes(), + mime_type: "application/yaml".to_owned(), + locale: OpenApiUploadLocale::En, + }, + ) + .await + .unwrap(); + let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into(); + let job = registry + .get_import_job(&workspace_id, &job_id) + .await + .unwrap() + .unwrap(); + let source_id = job.preview_payload["source"]["source_id"] + .as_str() + .unwrap() + .to_owned(); + sqlx::query("delete from import_jobs where id = $1") + .bind(job_id.as_str()) + .execute(registry.pool()) + .await + .unwrap(); + sqlx::query( + "update artifact_sources + set created_at = now() - interval '10 minutes', + updated_at = now() - interval '10 minutes' + where workspace_id = $1 and source_id = $2", + ) + .bind(workspace_id.as_str()) + .bind(&source_id) + .execute(registry.pool()) + .await + .unwrap(); + + registry.delete_expired_import_jobs().await.unwrap(); + let recovered = registry + .get_artifact_source(&workspace_id, &source_id.as_str().into()) + .await + .unwrap(); + assert_eq!(recovered.lifecycle, ArtifactSourceLifecycle::Detached); +} + +async fn wait_for_source_lifecycle_in_pool( + pool: &sqlx::PgPool, + lifecycle: ArtifactSourceLifecycle, +) -> ArtifactSourceId { + let expected = match lifecycle { + ArtifactSourceLifecycle::Active => "active", + ArtifactSourceLifecycle::Detached => "detached", + }; + for _ in 0..100 { + let found = sqlx::query_scalar::<_, Option>( + "select min(source_id) from artifact_sources where lifecycle = $1", + ) + .bind(expected) + .fetch_one(pool) + .await + .unwrap(); + if let Some(source_id) = found { + return ArtifactSourceId::new(source_id); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("artifact source did not reach {expected}"); +} + +async fn wait_for_blocked_import_insert_to_stop(pool: &sqlx::PgPool) { + for _ in 0..100 { + let active = sqlx::query_scalar::<_, bool>( + "select exists( + select 1 + from pg_stat_activity + where state = 'active' + and query like 'insert into import_jobs%' + )", + ) + .fetch_one(pool) + .await + .unwrap(); + if !active { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("cancelled import insert remained active in PostgreSQL"); +} + +async fn wait_for_specific_source_lifecycle( + registry: &crank_registry::PostgresRegistry, + source_id: &ArtifactSourceId, + lifecycle: ArtifactSourceLifecycle, +) { + let expected = match lifecycle { + ArtifactSourceLifecycle::Active => "active", + ArtifactSourceLifecycle::Detached => "detached", + }; + for _ in 0..100 { + let found = sqlx::query_scalar::<_, bool>( + "select exists( + select 1 + from artifact_sources + where source_id = $1 and lifecycle = $2 + )", + ) + .bind(source_id.as_str()) + .bind(expected) + .fetch_one(registry.pool()) + .await + .unwrap(); + if found { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("artifact source {source_id:?} did not reach {expected}"); +} + +#[derive(Clone, Default)] +struct SharedLogWriter { + buffer: Arc>>, +} + +impl SharedLogWriter { + fn output(&self) -> String { + String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap() + } +} + +impl<'a> MakeWriter<'a> for SharedLogWriter { + type Writer = SharedLogGuard; + + fn make_writer(&'a self) -> Self::Writer { + SharedLogGuard { + buffer: Arc::clone(&self.buffer), + } + } +} + +struct SharedLogGuard { + buffer: Arc>>, +} + +impl io::Write for SharedLogGuard { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.buffer.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct CapturingExporter(Arc>>); + +impl SpanExporter for CapturingExporter { + async fn export(&self, batch: Vec) -> OTelSdkResult { + self.0.lock().unwrap().extend(batch); + Ok(()) + } +} diff --git a/apps/admin-api/tests/integration/openapi_source/apply_failures.rs b/apps/admin-api/tests/integration/openapi_source/apply_failures.rs new file mode 100644 index 0000000..589fe5a --- /dev/null +++ b/apps/admin-api/tests/integration/openapi_source/apply_failures.rs @@ -0,0 +1,253 @@ +use crank_artifacts::ArtifactRef; + +use super::*; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn public_http_contract_is_safe_for_source_failures() { + const SOURCE_CANARY: &str = "openapi-apply-source-secret-canary"; + + let registry = test_registry().await; + let storage_root = test_storage_root("openapi_apply_source_errors"); + let app = build_test_app(registry.clone(), storage_root.clone()); + let server = spawn_admin_api(app).await; + let client = authorized_client(&server).await; + let workspace_id = WorkspaceId::new("ws_default"); + + for (marker, expected_code, corrupt) in [ + ("missing", "openapi_upload.source_unavailable", false), + ("corrupt", "openapi_upload.source_integrity", true), + ] { + let document = format!("{OPENAPI}\n# {marker} {SOURCE_CANARY}"); + let preview_response = client + .post(format!("{server}/imports/openapi/preview")) + .multipart(Form::new().part( + "file", + file_part(document.as_bytes(), "openapi.yaml", "application/yaml"), + )) + .send() + .await + .unwrap(); + assert_eq!(preview_response.status(), reqwest::StatusCode::OK); + let preview = preview_response.json::().await.unwrap(); + let job_id: crank_registry::ImportJobId = preview["job_id"].as_str().unwrap().into(); + let job = registry + .get_import_job(&workspace_id, &job_id) + .await + .unwrap() + .unwrap(); + let artifact_ref: ArtifactRef = job.preview_payload["source"]["digest"] + .as_str() + .unwrap() + .parse() + .unwrap(); + let path = artifact_path(&storage_root, &artifact_ref); + if corrupt { + std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o600)) + .unwrap(); + std::fs::write(&path, b"corrupt").unwrap(); + std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o400)) + .unwrap(); + } else { + std::fs::remove_file(path).unwrap(); + } + + let response = client + .post(format!( + "{server}/imports/openapi/{}/create", + job_id.as_str() + )) + .json(&serde_json::json!({ + "selected_operation_keys": ["GET /health"], + "conflict_mode": "skip" + })) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + assert_eq!(status, reqwest::StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["error"]["code"], expected_code); + assert!(body["error"]["request_id"].is_string()); + assert!(body["error"]["trace_id"].is_string()); + let rendered = body.to_string(); + assert!(!rendered.contains(SOURCE_CANARY)); + assert!(!rendered.contains("source_id")); + assert!(!rendered.contains("digest")); + } + + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from operations where workspace_id = $1") + .bind(workspace_id.as_str()) + .fetch_one(registry.pool()) + .await + .unwrap(), + 0, + "source failures must not create operations" + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn missing_corrupt_changed_and_foreign_sources_create_no_drafts() { + let registry = test_registry().await; + let storage_root = test_storage_root("openapi_source_fail_closed"); + let service = test_service( + registry.clone(), + storage_root.clone(), + test_auth_settings(), + test_secret_crypto(), + ); + let workspace_id = WorkspaceId::new("ws_default"); + + let (missing_job, _, missing_ref) = + preview_job_source(®istry, &service, &workspace_id, "missing").await; + std::fs::remove_file(artifact_path(&storage_root, &missing_ref)).unwrap(); + assert!( + apply_health_operation(&service, &workspace_id, &missing_job) + .await + .is_err() + ); + + let (corrupt_job, _, corrupt_ref) = + preview_job_source(®istry, &service, &workspace_id, "corrupt").await; + let corrupt_path = artifact_path(&storage_root, &corrupt_ref); + std::fs::set_permissions( + &corrupt_path, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .unwrap(); + std::fs::write(&corrupt_path, b"corrupt-openapi-canary").unwrap(); + std::fs::set_permissions( + &corrupt_path, + std::os::unix::fs::PermissionsExt::from_mode(0o400), + ) + .unwrap(); + assert!( + apply_health_operation(&service, &workspace_id, &corrupt_job) + .await + .is_err() + ); + + let (changed_job, _, _) = + preview_job_source(®istry, &service, &workspace_id, "changed").await; + sqlx::query( + "update import_jobs + set preview_payload = jsonb_set( + preview_payload, + '{source,digest}', + to_jsonb($1::text) + ) + where id = $2", + ) + .bind(format!("sha256:{}", "0".repeat(64))) + .bind(changed_job.as_str()) + .execute(registry.pool()) + .await + .unwrap(); + assert!( + apply_health_operation(&service, &workspace_id, &changed_job) + .await + .is_err() + ); + + let now = OffsetDateTime::now_utc(); + let foreign_workspace = WorkspaceId::new("ws_openapi_foreign"); + registry + .create_workspace(CreateWorkspaceRequest { + workspace: &Workspace { + id: foreign_workspace.clone(), + slug: "openapi-foreign".to_owned(), + display_name: "OpenAPI Foreign".to_owned(), + status: WorkspaceStatus::Active, + settings: serde_json::json!({}), + created_at: now, + updated_at: now, + }, + }) + .await + .unwrap(); + let (foreign_job, _, _) = + preview_job_source(®istry, &service, &workspace_id, "foreign").await; + sqlx::query("update import_jobs set workspace_id = $1 where id = $2") + .bind(foreign_workspace.as_str()) + .bind(foreign_job.as_str()) + .execute(registry.pool()) + .await + .unwrap(); + assert!( + apply_health_operation(&service, &foreign_workspace, &foreign_job) + .await + .is_err() + ); + + assert!( + service + .list_operations(&workspace_id) + .await + .unwrap() + .is_empty() + ); + assert!( + service + .list_operations(&foreign_workspace) + .await + .unwrap() + .is_empty() + ); +} + +async fn preview_job_source( + registry: &crank_registry::PostgresRegistry, + service: &admin_api::service::AdminService, + workspace_id: &WorkspaceId, + marker: &str, +) -> (crank_registry::ImportJobId, ArtifactSourceId, ArtifactRef) { + let preview = service + .preview_openapi_import( + workspace_id, + OpenApiUpload { + bytes: format!("{OPENAPI}\n# {marker}").into_bytes(), + mime_type: "application/yaml".to_owned(), + locale: OpenApiUploadLocale::En, + }, + ) + .await + .unwrap(); + let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into(); + let job = registry + .get_import_job(workspace_id, &job_id) + .await + .unwrap() + .unwrap(); + let source = &job.preview_payload["source"]; + ( + job_id, + ArtifactSourceId::new(source["source_id"].as_str().unwrap()), + source["digest"].as_str().unwrap().parse().unwrap(), + ) +} + +fn artifact_path(root: &std::path::Path, artifact_ref: &ArtifactRef) -> std::path::PathBuf { + root.join("sha256") + .join(&artifact_ref.digest_hex()[..2]) + .join(artifact_ref.digest_hex()) +} + +async fn apply_health_operation( + service: &admin_api::service::AdminService, + workspace_id: &WorkspaceId, + job_id: &crank_registry::ImportJobId, +) -> Result { + service + .create_openapi_import( + workspace_id, + job_id, + OpenApiImportCreatePayload { + selected_operation_keys: vec!["GET /health".to_owned()], + server_url: None, + conflict_mode: "skip".to_owned(), + }, + ) + .await +} diff --git a/apps/ui/css/openapi-import.css b/apps/ui/css/openapi-import.css index bf12041..bbaf990 100644 --- a/apps/ui/css/openapi-import.css +++ b/apps/ui/css/openapi-import.css @@ -87,20 +87,6 @@ font-weight: 600; } -#openapi-import-document { - min-height: 132px; - max-height: 34vh; - resize: vertical; - padding: 14px; - border: 1px solid var(--border); - border-radius: 14px; - background: #0d1117; - color: var(--text-primary); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - line-height: 1.55; -} - #openapi-import-file { position: absolute; width: 1px; @@ -154,6 +140,20 @@ color: var(--red); } +.openapi-import-status:focus-visible, +.openapi-file-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +@media (prefers-reduced-motion: reduce) { + .openapi-import-modal, + .openapi-import-dialog { + scroll-behavior: auto; + transition: none; + } +} + .openapi-import-preview { display: grid; gap: 16px; diff --git a/apps/ui/index.html b/apps/ui/index.html index 89b95e8..a0af1ba 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -92,7 +92,7 @@
-
-
+
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index f060eab..5e1003b 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -409,13 +409,29 @@ } return result; }, - previewOpenApiImport: function(workspaceId, documentText) { - return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', { - document: documentText, - }); + previewOpenApiImport: function(workspaceId, file, options) { + var form = new FormData(); + form.append('file', file, file.name); + return request( + API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', + { + method: 'POST', + // Do not set Content-Type here: the browser owns the multipart boundary. + headers: headers({ + 'Accept-Language': localStorage.getItem('crank_lang') === 'ru' ? 'ru' : 'en', + }), + body: form, + signal: options && options.signal, + } + ); }, - createOpenApiImport: function(workspaceId, jobId, payload) { - return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', payload); + createOpenApiImport: function(workspaceId, jobId, payload, options) { + return request(API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + signal: options && options.signal, + }); }, listAgents: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents'); diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 6f95d71..40813b6 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -2068,7 +2068,86 @@ var TRANSLATIONS = { }; Object.assign(TRANSLATIONS.en, { - + 'openapi.trigger': 'Import OpenAPI', + 'openapi.title': 'Import OpenAPI', + 'openapi.subtitle': 'Upload one OpenAPI/Swagger file, select methods, and create MCP tool drafts.', + 'openapi.close': 'Close', + 'openapi.file.label': 'OpenAPI/Swagger file', + 'openapi.file.choose': 'Choose file', + 'openapi.file.none': 'No file selected', + 'openapi.preview': 'Preview document', + 'openapi.cancel': 'Cancel request', + 'openapi.retry': 'Retry', + 'openapi.reset': 'Reset', + 'openapi.server.label': 'Base URL', + 'openapi.server.custom_aria': 'Custom Base URL', + 'openapi.server.placeholder': 'Or provide a base URL, for example https://api.example.com', + 'openapi.server.later': 'Specify later', + 'openapi.conflict.label': 'If an operation already exists', + 'openapi.conflict.rename': 'Create a copy with a new name', + 'openapi.conflict.skip': 'Skip it', + 'openapi.search.label': 'Search methods', + 'openapi.search.placeholder': 'Name, operationId, or path', + 'openapi.method.label': 'HTTP method', + 'openapi.method.all': 'All methods', + 'openapi.select_visible': 'Select visible', + 'openapi.clear_visible': 'Clear visible', + 'openapi.create': 'Create drafts', + 'openapi.selection.empty': 'Selected: 0', + 'openapi.selection': 'Selected: {selected} of {total}, shown: {visible}', + 'openapi.group.selection': 'selected {selected} of {total}, shown {visible}', + 'openapi.group.select': 'Select group', + 'openapi.operation.fields': 'inputs: {input} · outputs: {output}', + 'openapi.mapping.path': 'Path', + 'openapi.mapping.query': 'Query', + 'openapi.mapping.header': 'Header', + 'openapi.mapping.body': 'Body', + 'openapi.mapping.response': 'Response', + 'openapi.source.summary': '{name} · {size} · methods: {count}', + 'openapi.status.file_ready': 'File is ready for preview.', + 'openapi.status.previewing': 'Uploading and parsing the document…', + 'openapi.status.preview_ready': 'Document is ready. Select methods to import.', + 'openapi.status.creating': 'Creating drafts…', + 'openapi.status.created': 'Created: {created}; skipped: {skipped}.', + 'openapi.status.cancelled': 'Request cancelled.', + 'openapi.status.context_changed': 'The request was cancelled because the context changed.', + 'openapi.error.file_required': 'Choose an OpenAPI/Swagger file first.', + 'openapi.error.file_type': 'Choose one .yaml, .yml, or .json file with a matching type.', + 'openapi.error.file_empty': 'The selected file is empty.', + 'openapi.error.file_large': 'The selected file is larger than 256 KiB.', + 'openapi.error.no_methods': 'The document does not contain importable methods.', + 'openapi.error.workspace': 'No workspace is selected.', + 'openapi.error.preview': 'The document could not be previewed.', + 'openapi.error.create': 'Drafts could not be created.', + 'openapi.error.selection': 'Select at least one method.', + 'openapi.error.preview_required': 'Preview the current file first.', + 'openapi.error.server': 'Provide a base URL for the drafts.', + 'openapi.error.malformed_multipart': 'The upload request is malformed. Choose the file again and retry.', + 'openapi.error.invalid_utf8': 'The document must use UTF-8 encoding.', + 'openapi.error.invalid_document': 'The selected file is not a valid OpenAPI document.', + 'openapi.error.parser_unavailable': 'Document parsing is temporarily unavailable. Retry shortly.', + 'openapi.error.storage_unavailable': 'Temporary import storage is unavailable. Retry shortly.', + 'openapi.error.source_integrity': 'The uploaded document could not be verified. Choose the file again.', + 'openapi.error.source_unavailable': 'The uploaded document is no longer available. Choose it again.', + 'openapi.finding.missing_servers': 'No base URL was found in the document.', + 'openapi.finding.multiple_servers': 'Several base URLs are available; choose one before creating drafts.', + 'openapi.finding.unsupported_request_body': 'This request body needs review in the wizard.', + 'openapi.finding.operation_name_renamed': 'The operation name was adjusted to keep it unique.', + 'openapi.finding.operation_name_conflict': 'An operation with this name already exists.', + 'openapi.finding.weak_tool_description': 'The tool description is too short.', + 'openapi.finding.generic': 'This imported item needs review in the wizard.', + 'openapi.correlation.request_id': 'Request ID: {id}', + 'openapi.correlation.trace_id': 'Trace ID: {id}', + 'openapi.result.title': 'Import result', + 'openapi.result.open_first': 'Open first draft in the wizard', + 'openapi.result.draft': 'Draft', + 'openapi.result.findings': 'Findings', + 'openapi.result.action': 'Action', + 'openapi.result.no_findings': 'No critical findings', + 'openapi.result.more': '+{count} more', + 'openapi.result.fix': 'Fix in wizard', + 'openapi.result.open': 'Open in wizard', + 'openapi.result.skipped': 'Skipped', }); @@ -2103,6 +2182,86 @@ Object.assign(TRANSLATIONS.ru, { 'execution.error.idempotency_in_progress': 'Операция с этим ключом уже выполняется.', 'execution.error.idempotency_conflict': 'Ключ идемпотентности использован с другими параметрами.', 'execution.error.idempotency_outcome_unknown': 'Результат предыдущего выполнения неизвестен; автоматический повтор запрещён.', + 'openapi.trigger': 'Импорт OpenAPI', + 'openapi.title': 'Импорт OpenAPI', + 'openapi.subtitle': 'Загрузите один OpenAPI/Swagger файл, выберите методы и создайте черновики MCP-инструментов.', + 'openapi.close': 'Закрыть', + 'openapi.file.label': 'Файл OpenAPI/Swagger', + 'openapi.file.choose': 'Выбрать файл', + 'openapi.file.none': 'Файл не выбран', + 'openapi.preview': 'Разобрать документ', + 'openapi.cancel': 'Отменить запрос', + 'openapi.retry': 'Повторить', + 'openapi.reset': 'Сбросить', + 'openapi.server.label': 'Base URL', + 'openapi.server.custom_aria': 'Свой Base URL', + 'openapi.server.placeholder': 'Или укажите свой base URL, например https://api.example.com', + 'openapi.server.later': 'Указать позже', + 'openapi.conflict.label': 'Если операция уже существует', + 'openapi.conflict.rename': 'Создать копию с новым именем', + 'openapi.conflict.skip': 'Пропустить', + 'openapi.search.label': 'Поиск методов', + 'openapi.search.placeholder': 'Название, operationId или путь', + 'openapi.method.label': 'HTTP-метод', + 'openapi.method.all': 'Все методы', + 'openapi.select_visible': 'Выбрать видимые', + 'openapi.clear_visible': 'Снять видимые', + 'openapi.create': 'Создать черновики', + 'openapi.selection.empty': 'Выбрано: 0', + 'openapi.selection': 'Выбрано: {selected} из {total}, показано: {visible}', + 'openapi.group.selection': 'выбрано {selected} из {total}, показано {visible}', + 'openapi.group.select': 'Выбрать группу', + 'openapi.operation.fields': 'входов: {input} · выходов: {output}', + 'openapi.mapping.path': 'Path', + 'openapi.mapping.query': 'Query', + 'openapi.mapping.header': 'Header', + 'openapi.mapping.body': 'Body', + 'openapi.mapping.response': 'Ответ', + 'openapi.source.summary': '{name} · {size} · методов: {count}', + 'openapi.status.file_ready': 'Файл готов к разбору.', + 'openapi.status.previewing': 'Загружаю и разбираю документ…', + 'openapi.status.preview_ready': 'Документ готов. Выберите методы для импорта.', + 'openapi.status.creating': 'Создаю черновики…', + 'openapi.status.created': 'Создано: {created}; пропущено: {skipped}.', + 'openapi.status.cancelled': 'Запрос отменён.', + 'openapi.status.context_changed': 'Запрос отменён из-за изменения контекста.', + 'openapi.error.file_required': 'Сначала выберите файл OpenAPI/Swagger.', + 'openapi.error.file_type': 'Выберите один файл .yaml, .yml или .json с подходящим типом.', + 'openapi.error.file_empty': 'Выбранный файл пуст.', + 'openapi.error.file_large': 'Размер выбранного файла больше 256 KiB.', + 'openapi.error.no_methods': 'В документе нет методов для импорта.', + 'openapi.error.workspace': 'Не выбран workspace.', + 'openapi.error.preview': 'Не удалось разобрать документ.', + 'openapi.error.create': 'Не удалось создать черновики.', + 'openapi.error.selection': 'Выберите хотя бы один метод.', + 'openapi.error.preview_required': 'Сначала разберите текущий файл.', + 'openapi.error.server': 'Укажите base URL для черновиков.', + 'openapi.error.malformed_multipart': 'Запрос загрузки повреждён. Выберите файл заново и повторите попытку.', + 'openapi.error.invalid_utf8': 'Документ должен быть в кодировке UTF-8.', + 'openapi.error.invalid_document': 'Выбранный файл не является корректным документом OpenAPI.', + 'openapi.error.parser_unavailable': 'Разбор документа временно недоступен. Повторите попытку позже.', + 'openapi.error.storage_unavailable': 'Временное хранилище импорта недоступно. Повторите попытку позже.', + 'openapi.error.source_integrity': 'Не удалось проверить загруженный документ. Выберите файл заново.', + 'openapi.error.source_unavailable': 'Загруженный документ больше недоступен. Выберите его заново.', + 'openapi.finding.missing_servers': 'В документе не найден base URL.', + 'openapi.finding.multiple_servers': 'В документе несколько base URL; выберите один перед созданием черновиков.', + 'openapi.finding.unsupported_request_body': 'Тело этого запроса нужно проверить в мастере.', + 'openapi.finding.operation_name_renamed': 'Имя операции изменено, чтобы сохранить уникальность.', + 'openapi.finding.operation_name_conflict': 'Операция с таким именем уже существует.', + 'openapi.finding.weak_tool_description': 'Описание инструмента слишком короткое.', + 'openapi.finding.generic': 'Этот импортированный элемент нужно проверить в мастере.', + 'openapi.correlation.request_id': 'Request ID: {id}', + 'openapi.correlation.trace_id': 'Trace ID: {id}', + 'openapi.result.title': 'Результат импорта', + 'openapi.result.open_first': 'Открыть первый черновик в мастере', + 'openapi.result.draft': 'Черновик', + 'openapi.result.findings': 'Замечания', + 'openapi.result.action': 'Действие', + 'openapi.result.no_findings': 'Критичных замечаний нет', + 'openapi.result.more': '+{count} еще', + 'openapi.result.fix': 'Исправить в мастере', + 'openapi.result.open': 'Открыть в мастере', + 'openapi.result.skipped': 'Пропущено', }); diff --git a/apps/ui/js/openapi-import.js b/apps/ui/js/openapi-import.js index 5aaf0ee..38b6748 100644 --- a/apps/ui/js/openapi-import.js +++ b/apps/ui/js/openapi-import.js @@ -1,27 +1,79 @@ (function() { + var MAX_FILE_BYTES = 256 * 1024; var state = { workspaceId: null, onImported: null, + file: null, jobId: null, preview: null, filterQuery: '', filterMethod: '', + revision: 0, + previewController: null, + applyController: null, + applyInFlight: false, + restoreFocus: null, }; function qs(id) { return document.getElementById(id); } - function setStatus(message, isError) { + function tKey(key) { + return window.t ? window.t(key) : key; + } + + function tfKey(key, vars) { + return window.tf ? window.tf(key, vars) : tKey(key); + } + + function locale() { + return localStorage.getItem('crank_lang') === 'ru' ? 'ru' : 'en'; + } + + function isCurrent(revision, workspaceId, language) { + return revision === state.revision + && workspaceId === state.workspaceId + && language === locale() + && !qs('openapi-import-modal').hidden; + } + + function setStatus(message, isError, focus) { var node = qs('openapi-import-status'); if (!node) return; + node.textContent = message || ''; node.classList.toggle('error', !!isError); + + if (focus && message) { + node.focus(); + } + } + + function boundedFileName(file) { + var name = String(file && file.name || ''); + return name.length > 128 ? name.slice(0, 127) + '…' : name; + } + + function formatSize(bytes) { + var language = locale() === 'ru' ? 'ru-RU' : 'en-US'; + return new Intl.NumberFormat(language).format(bytes) + ' B'; + } + + function renderFileName() { + var node = qs('openapi-import-file-name'); + + if (!node) return; + node.textContent = state.file + ? boundedFileName(state.file) + ' · ' + formatSize(state.file.size) + : tKey('openapi.file.none'); } function selectedKeys() { return Array.from(document.querySelectorAll('[data-openapi-operation]:checked')) - .map(function(input) { return input.value; }); + .map(function(input) { + return input.value; + }); } function operationRows() { @@ -29,7 +81,9 @@ } function visibleOperationRows() { - return operationRows().filter(function(row) { return !row.hidden; }); + return operationRows().filter(function(row) { + return !row.hidden; + }); } function updateSelection() { @@ -37,29 +91,42 @@ var selectedCount = selectedKeys().length; var totalCount = document.querySelectorAll('[data-openapi-operation]').length; var visibleCount = visibleOperationRows().length; - if (node) node.textContent = 'Выбрано: ' + selectedCount + ' из ' + totalCount + ', показано: ' + visibleCount; - updateGroupSelectionState(); - } - function updateGroupSelectionState() { + if (node) { + node.textContent = tfKey('openapi.selection', { + selected: selectedCount, + total: totalCount, + visible: visibleCount, + }); + } + document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) { var rows = Array.from(groupNode.querySelectorAll('.openapi-import-operation')); - var visibleRows = rows.filter(function(row) { return !row.hidden; }); - var visibleInputs = visibleRows + var visibleInputs = rows + .filter(function(row) { return !row.hidden; }) .map(function(row) { return row.querySelector('[data-openapi-operation]'); }) .filter(Boolean); - var selectedVisible = visibleInputs.filter(function(input) { return input.checked; }).length; - var selectedTotal = rows - .map(function(row) { return row.querySelector('[data-openapi-operation]'); }) - .filter(function(input) { return input && input.checked; }).length; + var selectedVisible = visibleInputs.filter(function(input) { + return input.checked; + }).length; + var selectedTotal = rows.filter(function(row) { + var input = row.querySelector('[data-openapi-operation]'); + return input && input.checked; + }).length; var checkbox = groupNode.querySelector('[data-openapi-group]'); + var counter = groupNode.querySelector('[data-openapi-group-count]'); + if (checkbox) { checkbox.indeterminate = selectedVisible > 0 && selectedVisible < visibleInputs.length; checkbox.checked = visibleInputs.length > 0 && selectedVisible === visibleInputs.length; } - var counter = groupNode.querySelector('[data-openapi-group-count]'); + if (counter) { - counter.textContent = 'выбрано ' + selectedTotal + ' из ' + rows.length + ', показано ' + visibleRows.length; + counter.textContent = tfKey('openapi.group.selection', { + selected: selectedTotal, + total: rows.length, + visible: visibleInputs.length, + }); } }); } @@ -67,17 +134,22 @@ function applyFilters() { var query = state.filterQuery.toLowerCase(); var method = state.filterMethod.toLowerCase(); + document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) { var visibleInGroup = 0; + groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) { - var rowMethod = String(row.dataset.openapiMethod || '').toLowerCase(); - var searchText = String(row.dataset.openapiSearch || '').toLowerCase(); - var visible = (!method || rowMethod === method) && (!query || searchText.indexOf(query) >= 0); + var matchesMethod = !method || String(row.dataset.openapiMethod).toLowerCase() === method; + var matchesQuery = !query || String(row.dataset.openapiSearch).toLowerCase().indexOf(query) >= 0; + var visible = matchesMethod && matchesQuery; + row.hidden = !visible; if (visible) visibleInGroup += 1; }); + groupNode.hidden = visibleInGroup === 0; }); + updateSelection(); } @@ -86,180 +158,45 @@ var input = row.querySelector('[data-openapi-operation]'); if (input) input.checked = checked; }); + updateSelection(); } - function renderPreview(response) { - state.jobId = response.job_id; - state.preview = response.preview; - state.filterQuery = ''; - state.filterMethod = ''; - var preview = response.preview; - qs('openapi-import-preview-panel').hidden = false; - qs('openapi-import-result').hidden = true; - qs('openapi-import-result').innerHTML = ''; - qs('openapi-import-source').textContent = [ - preview.source.title || 'Imported API', - preview.source.format + (preview.source.version ? ' ' + preview.source.version : ''), - 'методов: ' + preview.groups.reduce(function(sum, group) { return sum + group.operations.length; }, 0), - ].join(' · '); - - var serverSelect = qs('openapi-import-server'); - serverSelect.innerHTML = ''; - var servers = preview.source.servers && preview.source.servers.length ? preview.source.servers : ['']; - servers.forEach(function(server) { - var option = document.createElement('option'); - option.value = server; - option.textContent = server || 'Указать позже'; - serverSelect.appendChild(option); - }); - - var searchInput = qs('openapi-import-search'); - var methodFilter = qs('openapi-import-method-filter'); - if (searchInput) searchInput.value = ''; - if (methodFilter) { - methodFilter.innerHTML = ''; - Array.from(new Set(preview.groups.flatMap(function(group) { - return group.operations.map(function(operation) { return operation.method; }); - }))).sort().forEach(function(method) { - var option = document.createElement('option'); - option.value = method; - option.textContent = method; - methodFilter.appendChild(option); - }); - } - - var groupsNode = qs('openapi-import-groups'); - groupsNode.innerHTML = ''; - if (preview.findings && preview.findings.length) { - var documentFindings = document.createElement('div'); - documentFindings.className = 'openapi-import-document-findings'; - documentFindings.innerHTML = preview.findings.map(function(finding) { - return renderFinding(finding); - }).join(''); - groupsNode.appendChild(documentFindings); - } - preview.groups.forEach(function(group) { - var groupNode = document.createElement('section'); - groupNode.className = 'openapi-import-group'; - var header = document.createElement('div'); - header.className = 'openapi-import-group-header'; - header.innerHTML = '' + escapeHtml(group.title) + ' · ' + group.operations.length + '' - + '' - + ''; - groupNode.appendChild(header); - - group.operations.forEach(function(operation) { - var row = document.createElement('label'); - row.className = 'openapi-import-operation'; - row.dataset.openapiMethod = operation.method; - row.dataset.openapiSearch = [ - group.title, - operation.key, - operation.suggested_name, - operation.suggested_display_name, - operation.path, - operation.method, - ].join(' '); - row.innerHTML = [ - '', - '
' + escapeHtml(operation.suggested_display_name) + '
', - '
' + escapeHtml(operation.suggested_name) + ' · ' + escapeHtml(operation.path) + ' · входов: ' + operation.input_fields + ' · выходов: ' + operation.output_fields + '
', - '' + escapeHtml(operation.method) + '', - ].join(''); - row.appendChild(renderOperationMappingPreview(operation)); - if (operation.findings && operation.findings.length) { - var findings = document.createElement('div'); - findings.className = 'openapi-import-findings'; - findings.innerHTML = operation.findings.map(function(finding) { - return renderFinding(finding); - }).join(''); - row.appendChild(findings); - } - groupNode.appendChild(row); - }); - var groupCheckbox = groupNode.querySelector('[data-openapi-group]'); - groupCheckbox.addEventListener('change', function() { - groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) { - if (row.hidden) return; - var input = row.querySelector('[data-openapi-operation]'); - if (input) input.checked = groupCheckbox.checked; - }); - updateSelection(); - }); - groupsNode.appendChild(groupNode); - }); - groupsNode.querySelectorAll('[data-openapi-operation]').forEach(function(input) { - input.addEventListener('change', updateSelection); - }); - applyFilters(); + function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); } - function renderOperationMappingPreview(operation) { - var details = document.createElement('div'); - details.className = 'openapi-import-mapping-preview'; + function renderFinding(finding) { + var severity = String(finding && finding.severity || 'warning').toLowerCase(); + var label = severity === 'info' ? 'i' : severity === 'error' ? '×' : '!'; - var request = summarizeRequestMapping(operation); - [ - ['Path', request.path], - ['Query', request.query], - ['Header', request.headers], - ['Body', request.body], - ].forEach(function(entry) { - appendMappingGroup(details, entry[0], entry[1]); - }); - appendMappingGroup(details, 'Ответ', summarizeResponseMapping(operation)); - return details; + return '' + + '' + label + ' ' + + escapeHtml(findingMessage(finding)) + + ''; } - function summarizeRequestMapping(operation) { - var result = { - path: [], - query: [], - headers: [], - body: [], + function findingMessage(finding) { + var code = String(finding && finding.code || '').replace(/^.*\./, ''); + var known = { + missing_servers: 'openapi.finding.missing_servers', + multiple_servers: 'openapi.finding.multiple_servers', + unsupported_request_body: 'openapi.finding.unsupported_request_body', + operation_name_renamed: 'openapi.finding.operation_name_renamed', + operation_name_conflict: 'openapi.finding.operation_name_conflict', + weak_tool_description: 'openapi.finding.weak_tool_description', }; - var rules = operation - && operation.draft - && operation.draft.input_mapping - && Array.isArray(operation.draft.input_mapping.rules) - ? operation.draft.input_mapping.rules - : []; - rules.forEach(function(rule) { - var target = String(rule.target || ''); - if (target.indexOf('$.request.path.') === 0) { - result.path.push(target.replace('$.request.path.', '')); - } else if (target.indexOf('$.request.query.') === 0) { - result.query.push(target.replace('$.request.query.', '')); - } else if (target.indexOf('$.request.headers.') === 0) { - result.headers.push(target.replace('$.request.headers.', '')); - } else if (target.indexOf('$.request.body.') === 0) { - result.body.push(target.replace('$.request.body.', '')); - } else if (target === '$.request.body') { - result.body.push('body'); - } - }); - return result; - } - - function summarizeResponseMapping(operation) { - var rules = operation - && operation.draft - && operation.draft.output_mapping - && Array.isArray(operation.draft.output_mapping.rules) - ? operation.draft.output_mapping.rules - : []; - - return rules.map(function(rule) { - var target = String(rule.target || '').replace('$.output.', '').replace('$.output', 'result'); - var source = String(rule.source || '').replace('$.response.body.', '').replace('$.response.body', 'body'); - return target + ' ← ' + source; - }); + return tKey(known[code] || 'openapi.finding.generic'); } function appendMappingGroup(root, label, values) { if (!values || !values.length) return; + var group = document.createElement('div'); group.className = 'openapi-import-mapping-group'; @@ -274,42 +211,352 @@ chip.textContent = value; group.appendChild(chip); }); + if (values.length > 8) { var more = document.createElement('span'); more.className = 'openapi-import-mapping-more'; more.textContent = '+' + (values.length - 8); group.appendChild(more); } + root.appendChild(group); } - function escapeHtml(value) { - return String(value || '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + function renderOperationMappingPreview(operation) { + var details = document.createElement('div'); + var request = { path: [], query: [], headers: [], body: [] }; + var inputRules = operation + && operation.draft + && operation.draft.input_mapping + && Array.isArray(operation.draft.input_mapping.rules) + ? operation.draft.input_mapping.rules + : []; + var outputRules = operation + && operation.draft + && operation.draft.output_mapping + && Array.isArray(operation.draft.output_mapping.rules) + ? operation.draft.output_mapping.rules + : []; + + details.className = 'openapi-import-mapping-preview'; + + inputRules.forEach(function(rule) { + var target = String(rule.target || ''); + + if (target.indexOf('$.request.path.') === 0) { + request.path.push(target.slice(15)); + } else if (target.indexOf('$.request.query.') === 0) { + request.query.push(target.slice(16)); + } else if (target.indexOf('$.request.headers.') === 0) { + request.headers.push(target.slice(18)); + } else if (target.indexOf('$.request.body.') === 0) { + request.body.push(target.slice(15)); + } else if (target === '$.request.body') { + request.body.push('body'); + } + }); + + appendMappingGroup(details, tKey('openapi.mapping.path'), request.path); + appendMappingGroup(details, tKey('openapi.mapping.query'), request.query); + appendMappingGroup(details, tKey('openapi.mapping.header'), request.headers); + appendMappingGroup(details, tKey('openapi.mapping.body'), request.body); + appendMappingGroup(details, tKey('openapi.mapping.response'), outputRules.map(function(rule) { + var target = String(rule.target || '').replace('$.output.', '').replace('$.output', 'result'); + var source = String(rule.source || '').replace('$.response.body.', '').replace('$.response.body', 'body'); + return target + ' ← ' + source; + })); + + return details; } - function renderFinding(finding) { - var severity = String(finding && finding.severity || 'warning').toLowerCase(); - var label = severity === 'info' ? 'i' : severity === 'error' ? '×' : '!'; - return '' - + '' + label + ' ' - + escapeHtml(finding.message) - + ''; + function populateServers(preview) { + var serverSelect = qs('openapi-import-server'); + var servers = preview.source && preview.source.servers && preview.source.servers.length + ? preview.source.servers + : ['']; + + serverSelect.replaceChildren(); + servers.forEach(function(server) { + var option = document.createElement('option'); + option.value = server; + option.textContent = server || tKey('openapi.server.later'); + serverSelect.appendChild(option); + }); + } + + function populateMethodFilter(preview) { + var methodFilter = qs('openapi-import-method-filter'); + var all = document.createElement('option'); + var methods = Array.from(new Set((preview.groups || []).flatMap(function(group) { + return (group.operations || []).map(function(operation) { + return operation.method; + }); + }))).sort(); + + methodFilter.replaceChildren(); + all.value = ''; + all.textContent = tKey('openapi.method.all'); + methodFilter.appendChild(all); + + methods.forEach(function(method) { + var option = document.createElement('option'); + option.value = method; + option.textContent = method; + methodFilter.appendChild(option); + }); + } + + function renderPreview(response) { + var preview = response.preview; + var count = (preview.groups || []).reduce(function(sum, group) { + return sum + (group.operations || []).length; + }, 0); + var source = qs('openapi-import-source'); + var groupsNode = qs('openapi-import-groups'); + + state.jobId = response.job_id; + state.preview = preview; + state.filterQuery = ''; + state.filterMethod = ''; + qs('openapi-import-preview-panel').hidden = false; + qs('openapi-import-result').hidden = true; + qs('openapi-import-result').replaceChildren(); + source.textContent = tfKey('openapi.source.summary', { + name: boundedFileName(state.file), + size: formatSize(state.file.size), + count: count, + }); + qs('openapi-import-search').value = ''; + populateServers(preview); + populateMethodFilter(preview); + groupsNode.replaceChildren(); + + if (preview.findings && preview.findings.length) { + var documentFindings = document.createElement('div'); + documentFindings.className = 'openapi-import-document-findings'; + documentFindings.innerHTML = preview.findings.map(renderFinding).join(''); + groupsNode.appendChild(documentFindings); + } + + (preview.groups || []).forEach(function(group) { + var groupNode = document.createElement('section'); + var header = document.createElement('div'); + + groupNode.className = 'openapi-import-group'; + header.className = 'openapi-import-group-header'; + header.innerHTML = '' + escapeHtml(group.title) + ' · ' + group.operations.length + '' + + '' + + ''; + groupNode.appendChild(header); + + group.operations.forEach(function(operation) { + var row = document.createElement('label'); + + row.className = 'openapi-import-operation'; + row.dataset.openapiMethod = operation.method; + row.dataset.openapiSearch = [ + group.title, + operation.key, + operation.suggested_name, + operation.suggested_display_name, + operation.path, + operation.method, + ].join(' '); + row.innerHTML = '' + + '
' + escapeHtml(operation.suggested_display_name) + '
' + + '
' + escapeHtml(operation.suggested_name) + ' · ' + + escapeHtml(operation.path) + ' · ' + + escapeHtml(tfKey('openapi.operation.fields', { input: operation.input_fields, output: operation.output_fields })) + + '
' + escapeHtml(operation.method) + ''; + row.appendChild(renderOperationMappingPreview(operation)); + + if (operation.findings && operation.findings.length) { + var findings = document.createElement('div'); + findings.className = 'openapi-import-findings'; + findings.innerHTML = operation.findings.map(renderFinding).join(''); + row.appendChild(findings); + } + + groupNode.appendChild(row); + }); + + groupNode.querySelector('[data-openapi-group]').addEventListener('change', function(event) { + groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) { + var input = row.querySelector('[data-openapi-operation]'); + if (!row.hidden && input) input.checked = event.target.checked; + }); + updateSelection(); + }); + groupsNode.appendChild(groupNode); + }); + + groupsNode.querySelectorAll('[data-openapi-operation]').forEach(function(input) { + input.addEventListener('change', updateSelection); + }); + applyFilters(); + } + + function validateFile(file) { + if (!file) return 'openapi.error.file_required'; + + var name = String(file.name || '').toLowerCase(); + var yaml = /\.(yaml|yml)$/.test(name); + var json = /\.json$/.test(name); + + if (!yaml && !json) return 'openapi.error.file_type'; + if (!file.size) return 'openapi.error.file_empty'; + if (file.size > MAX_FILE_BYTES) return 'openapi.error.file_large'; + + var type = String(file.type || '').toLowerCase(); + if (!type || type === 'application/octet-stream') return null; + + var allowed = yaml + ? ['application/yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml'] + : ['application/json', 'application/openapi+json']; + return allowed.indexOf(type) >= 0 ? null : 'openapi.error.file_type'; + } + + function abort(controller) { + if (controller) controller.abort(); + } + + function clearPreview() { + state.jobId = null; + state.preview = null; + state.filterQuery = ''; + state.filterMethod = ''; + qs('openapi-import-preview-panel').hidden = true; + qs('openapi-import-groups').replaceChildren(); + qs('openapi-import-result').hidden = true; + qs('openapi-import-result').replaceChildren(); + updateSelection(); + } + + function clearServerSelection() { + qs('openapi-import-server-custom').value = ''; + qs('openapi-import-server').replaceChildren(); + } + + function invalidate(options) { + state.revision += 1; + abort(state.previewController); + abort(state.applyController); + state.previewController = null; + state.applyController = null; + state.applyInFlight = false; + qs('openapi-import-preview').disabled = false; + qs('openapi-import-create').disabled = false; + qs('openapi-import-cancel').hidden = true; + clearPreview(); + + if (options && options.clearFile) { + state.file = null; + qs('openapi-import-file').value = ''; + renderFileName(); + } + } + + function showRetry(show) { + qs('openapi-import-retry').hidden = !show; + } + + function cancelCurrent() { + if (!state.previewController && !state.applyController) return; + + invalidate(); + showRetry(!!state.file); + setStatus(tKey('openapi.status.cancelled')); + } + + function errorStatus(error, fallback) { + var known = { + 'openapi_upload.missing_file': 'openapi.error.file_required', + 'openapi_upload.invalid_media_type': 'openapi.error.file_type', + 'openapi_upload.empty_file': 'openapi.error.file_empty', + 'openapi_upload.file_too_large': 'openapi.error.file_large', + 'openapi_upload.no_methods': 'openapi.error.no_methods', + 'openapi_upload.malformed_multipart': 'openapi.error.malformed_multipart', + 'openapi_upload.invalid_utf8': 'openapi.error.invalid_utf8', + 'openapi_upload.invalid_document': 'openapi.error.invalid_document', + 'openapi_upload.parser_unavailable': 'openapi.error.parser_unavailable', + 'openapi_upload.storage_unavailable': 'openapi.error.storage_unavailable', + 'openapi_upload.source_integrity': 'openapi.error.source_integrity', + 'openapi_upload.source_unavailable': 'openapi.error.source_unavailable', + }; + var message = tKey(known[error && error.code] || fallback); + var identifiers = []; + + if (error && error.requestId) { + identifiers.push(tfKey('openapi.correlation.request_id', { id: error.requestId })); + } + if (error && error.traceId) { + identifiers.push(tfKey('openapi.correlation.trace_id', { id: error.traceId })); + } + + return identifiers.length ? message + ' ' + identifiers.join(' · ') : message; + } + + async function preview() { + var localError = validateFile(state.file); + if (localError) { + setStatus(tKey(localError), true, true); + showRetry(false); + return; + } + if (!state.workspaceId) { + setStatus(tKey('openapi.error.workspace'), true, true); + return; + } + + invalidate(); + var revision = state.revision; + var workspaceId = state.workspaceId; + var language = locale(); + var controller = new AbortController(); + + state.previewController = controller; + qs('openapi-import-preview').disabled = true; + qs('openapi-import-cancel').hidden = false; + showRetry(false); + setStatus(tKey('openapi.status.previewing')); + + try { + var response = await window.CrankApi.previewOpenApiImport(workspaceId, state.file, { signal: controller.signal }); + if (!isCurrent(revision, workspaceId, language)) return; + + renderPreview(response); + setStatus(tKey('openapi.status.preview_ready')); + } catch (error) { + if (!isCurrent(revision, workspaceId, language)) return; + if (error && error.name === 'AbortError') { + setStatus(tKey('openapi.status.cancelled')); + return; + } + + clearPreview(); + showRetry(true); + setStatus(errorStatus(error, 'openapi.error.preview'), true, true); + } finally { + if (isCurrent(revision, workspaceId, language)) { + state.previewController = null; + qs('openapi-import-preview').disabled = false; + qs('openapi-import-cancel').hidden = true; + } + } } function previewOperationByName(name) { - if (!state.preview || !state.preview.groups) return null; - for (var groupIndex = 0; groupIndex < state.preview.groups.length; groupIndex += 1) { - var operations = state.preview.groups[groupIndex].operations || []; + var groups = state.preview && state.preview.groups || []; + + for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { + var operations = groups[groupIndex].operations || []; for (var operationIndex = 0; operationIndex < operations.length; operationIndex += 1) { - if (operations[operationIndex].suggested_name === name) { - return operations[operationIndex]; - } + if (operations[operationIndex].suggested_name === name) return operations[operationIndex]; } } + return null; } @@ -322,206 +569,236 @@ if (!findings || !findings.length) { var empty = document.createElement('span'); empty.className = 'openapi-import-result-ok'; - empty.textContent = 'Критичных замечаний нет'; + empty.textContent = tKey('openapi.result.no_findings'); root.appendChild(empty); return; } + findings.slice(0, 4).forEach(function(finding) { var wrapper = document.createElement('div'); wrapper.innerHTML = renderFinding(finding); root.appendChild(wrapper.firstElementChild); }); + if (findings.length > 4) { var more = document.createElement('span'); more.className = 'openapi-import-result-more'; - more.textContent = '+' + (findings.length - 4) + ' еще'; + more.textContent = tfKey('openapi.result.more', { count: findings.length - 4 }); root.appendChild(more); } } - async function preview() { - if (!state.workspaceId) { - setStatus('Не выбран workspace.', true); - return; - } - var documentText = qs('openapi-import-document').value.trim(); - if (!documentText) { - setStatus('Вставьте документ OpenAPI/Swagger или выберите файл.', true); - return; - } - setStatus('Разбираю документ...'); - qs('openapi-import-preview').disabled = true; - try { - var response = await window.CrankApi.previewOpenApiImport(state.workspaceId, documentText); - renderPreview(response); - setStatus('Документ разобран. Выберите методы для импорта.'); - } catch (error) { - setStatus(error.message || 'Не удалось разобрать документ.', true); - } finally { - qs('openapi-import-preview').disabled = false; - } - } - - async function createDrafts() { - var keys = selectedKeys(); - if (!keys.length) { - setStatus('Выберите хотя бы один метод.', true); - return; - } - if (!state.jobId) { - setStatus('Сначала разберите OpenAPI документ.', true); - return; - } - var serverUrl = (qs('openapi-import-server-custom').value || qs('openapi-import-server').value || '').trim(); - if (!serverUrl) { - setStatus('Укажите base URL для создаваемых операций.', true); - return; - } - setStatus('Создаю черновики...'); - qs('openapi-import-create').disabled = true; - try { - var response = await window.CrankApi.createOpenApiImport(state.workspaceId, state.jobId, { - selected_operation_keys: keys, - server_url: serverUrl, - conflict_mode: qs('openapi-import-conflict-mode').value || 'rename', - }); - var message = 'Создано: ' + response.created.length; - if (response.skipped.length) message += ', пропущено: ' + response.skipped.length; - setStatus(message); - renderResult(response); - if (typeof state.onImported === 'function') { - await state.onImported(); - } - } catch (error) { - setStatus(error.message || 'Не удалось создать черновики.', true); - } finally { - qs('openapi-import-create').disabled = false; - } - } - function renderResult(response) { var node = qs('openapi-import-result'); - if (!node) return; - node.replaceChildren(); - var title = document.createElement('strong'); - title.textContent = 'Результат импорта'; + + node.replaceChildren(); + title.textContent = tKey('openapi.result.title'); node.appendChild(title); if (response.created && response.created.length) { - var firstHref = wizardHref(response.created[0].operation_id); var primary = document.createElement('div'); + var table = document.createElement('div'); + primary.className = 'openapi-import-primary-result'; - primary.innerHTML = 'Открыть первый черновик в мастере'; + primary.innerHTML = '' + + escapeHtml(tKey('openapi.result.open_first')) + + ''; node.appendChild(primary); - var table = document.createElement('div'); table.className = 'openapi-import-result-table'; table.setAttribute('role', 'table'); - table.innerHTML = [ - '
', - '
Черновик
', - '
Замечания
', - '
Действие
', - '
', - ].join(''); + table.innerHTML = '
' + + '
' + escapeHtml(tKey('openapi.result.draft')) + '
' + + '
' + escapeHtml(tKey('openapi.result.findings')) + '
' + + '
' + escapeHtml(tKey('openapi.result.action')) + '
' + + '
'; response.created.forEach(function(operation) { var previewOperation = previewOperationByName(operation.name); var row = document.createElement('div'); + var nameCell = document.createElement('div'); + var name = document.createElement('div'); + var meta = document.createElement('div'); + var findingsCell = document.createElement('div'); + var actionCell = document.createElement('div'); + var action = document.createElement('a'); + row.className = 'openapi-import-result-row'; row.setAttribute('role', 'row'); - - var nameCell = document.createElement('div'); nameCell.setAttribute('role', 'cell'); - var name = document.createElement('div'); name.className = 'openapi-import-result-name'; name.textContent = operation.name; - var meta = document.createElement('div'); meta.className = 'openapi-import-result-meta'; meta.textContent = previewOperation ? previewOperation.method + ' ' + previewOperation.path + ' · v' + operation.version : 'v' + operation.version; - nameCell.appendChild(name); - nameCell.appendChild(meta); - - var findingsCell = document.createElement('div'); + nameCell.append(name, meta); findingsCell.className = 'openapi-import-result-findings'; findingsCell.setAttribute('role', 'cell'); - appendFindingNodes(findingsCell, previewOperation ? previewOperation.findings : []); - - var actionCell = document.createElement('div'); + appendFindingNodes(findingsCell, previewOperation && previewOperation.findings); actionCell.setAttribute('role', 'cell'); - var action = document.createElement('a'); action.className = 'btn-secondary openapi-import-result-action'; action.href = wizardHref(operation.operation_id); action.textContent = previewOperation && previewOperation.findings && previewOperation.findings.length - ? 'Исправить в мастере' - : 'Открыть в мастере'; + ? tKey('openapi.result.fix') + : tKey('openapi.result.open'); actionCell.appendChild(action); - - row.appendChild(nameCell); - row.appendChild(findingsCell); - row.appendChild(actionCell); + row.append(nameCell, findingsCell, actionCell); table.appendChild(row); }); + node.appendChild(table); } + if (response.skipped && response.skipped.length) { - var skipped = document.createElement('div'); - skipped.className = 'openapi-import-result-skipped'; - skipped.textContent = 'Пропущено: ' + response.skipped.map(function(item) { - return (item.name || item.operation_key) + ' — ' + item.reason; + var skippedNode = document.createElement('div'); + skippedNode.className = 'openapi-import-result-skipped'; + skippedNode.textContent = tKey('openapi.result.skipped') + ': ' + response.skipped.map(function(item) { + return (item.name || item.operation_key || tKey('openapi.result.unnamed')) + ' — ' + + findingMessage({ code: item.code || item.reason_code || item.reason }); }).join('; '); - node.appendChild(skipped); + node.appendChild(skippedNode); } + if (response.findings && response.findings.length) { - var responseFindings = document.createElement('div'); - responseFindings.className = 'openapi-import-result-findings'; - response.findings.forEach(function(finding) { - var wrapper = document.createElement('div'); - wrapper.innerHTML = renderFinding(finding); - responseFindings.appendChild(wrapper.firstElementChild); - }); - node.appendChild(responseFindings); + var findings = document.createElement('div'); + findings.className = 'openapi-import-result-findings'; + findings.innerHTML = response.findings.map(renderFinding).join(''); + node.appendChild(findings); } + node.hidden = false; } + async function createDrafts() { + if (state.applyInFlight) return; + + var keys = selectedKeys(); + if (!keys.length) { + setStatus(tKey('openapi.error.selection'), true, true); + return; + } + if (!state.jobId || !state.preview) { + setStatus(tKey('openapi.error.preview_required'), true, true); + return; + } + + var serverUrl = (qs('openapi-import-server-custom').value || qs('openapi-import-server').value || '').trim(); + if (!serverUrl) { + setStatus(tKey('openapi.error.server'), true, true); + return; + } + + var revision = state.revision; + var workspaceId = state.workspaceId; + var language = locale(); + var jobId = state.jobId; + var controller = new AbortController(); + + state.applyController = controller; + state.applyInFlight = true; + qs('openapi-import-create').disabled = true; + qs('openapi-import-cancel').hidden = false; + showRetry(false); + setStatus(tKey('openapi.status.creating')); + + try { + var response = await window.CrankApi.createOpenApiImport(workspaceId, jobId, { + selected_operation_keys: keys, + server_url: serverUrl, + conflict_mode: qs('openapi-import-conflict-mode').value || 'rename', + }, { signal: controller.signal }); + if (!isCurrent(revision, workspaceId, language)) return; + + setStatus(tfKey('openapi.status.created', { + created: (response.created || []).length, + skipped: (response.skipped || []).length, + })); + renderResult(response); + if (typeof state.onImported === 'function') await state.onImported(); + } catch (error) { + if (!isCurrent(revision, workspaceId, language)) return; + if (error && error.name === 'AbortError') { + setStatus(tKey('openapi.status.cancelled')); + return; + } + + showRetry(false); + setStatus(errorStatus(error, 'openapi.error.create'), true, true); + } finally { + if (isCurrent(revision, workspaceId, language)) { + state.applyController = null; + state.applyInFlight = false; + qs('openapi-import-create').disabled = false; + qs('openapi-import-cancel').hidden = true; + } + } + } + function reset() { - state.jobId = null; - state.preview = null; - state.filterQuery = ''; - state.filterMethod = ''; - qs('openapi-import-document').value = ''; - qs('openapi-import-file').value = ''; - qs('openapi-import-file-name').textContent = 'Файл не выбран'; + invalidate({ clearFile: true }); + showRetry(false); + setStatus(''); qs('openapi-import-server-custom').value = ''; qs('openapi-import-conflict-mode').value = 'rename'; - if (qs('openapi-import-search')) qs('openapi-import-search').value = ''; - if (qs('openapi-import-method-filter')) qs('openapi-import-method-filter').innerHTML = ''; - qs('openapi-import-preview-panel').hidden = true; - qs('openapi-import-groups').innerHTML = ''; - qs('openapi-import-result').hidden = true; - qs('openapi-import-result').innerHTML = ''; - setStatus(''); - updateSelection(); } function open(options) { + if (state.workspaceId && state.workspaceId !== options.workspaceId) { + invalidate({ clearFile: true }); + } + state.workspaceId = options.workspaceId; state.onImported = options.onImported; + state.restoreFocus = document.activeElement; qs('openapi-import-modal').hidden = false; + renderFileName(); + qs('openapi-import-file-select').focus(); } function close() { + invalidate({ clearFile: true }); + showRetry(false); qs('openapi-import-modal').hidden = true; + + if (state.restoreFocus && typeof state.restoreFocus.focus === 'function') { + state.restoreFocus.focus(); + } + } + + function selectFile(file) { + clearServerSelection(); + invalidate(); + state.file = file || null; + showRetry(false); + + var localError = validateFile(state.file); + renderFileName(); + + if (localError) { + setStatus(tKey(localError), true, true); + } else if (state.file) { + setStatus(tKey('openapi.status.file_ready')); + } } document.addEventListener('DOMContentLoaded', function() { if (!qs('openapi-import-modal')) return; + + renderFileName(); + + qs('openapi-import-file-select').addEventListener('click', function(event) { + event.preventDefault(); + qs('openapi-import-file').click(); + }); + qs('openapi-import-file').addEventListener('change', function(event) { + selectFile(event.target.files && event.target.files[0]); + }); qs('openapi-import-preview').addEventListener('click', preview); + qs('openapi-import-retry').addEventListener('click', preview); qs('openapi-import-create').addEventListener('click', createDrafts); + qs('openapi-import-cancel').addEventListener('click', cancelCurrent); qs('openapi-import-reset').addEventListener('click', reset); qs('openapi-import-search').addEventListener('input', function(event) { state.filterQuery = event.target.value.trim(); @@ -537,15 +814,62 @@ qs('openapi-import-clear-visible').addEventListener('click', function() { setVisibleSelection(false); }); - qs('openapi-import-file').addEventListener('change', async function(event) { - var file = event.target.files && event.target.files[0]; - if (!file) return; - qs('openapi-import-file-name').textContent = file.name; - qs('openapi-import-document').value = await file.text(); - }); document.querySelectorAll('[data-openapi-close]').forEach(function(node) { node.addEventListener('click', close); }); + document.addEventListener('keydown', function(event) { + var modal = qs('openapi-import-modal'); + + if (event.key === 'Escape' && !qs('openapi-import-modal').hidden) { + event.preventDefault(); + close(); + } + if (event.key !== 'Tab' || modal.hidden) return; + + var focusable = Array.from(modal.querySelectorAll('button:not([disabled]):not([hidden]), input:not([disabled]), select:not([disabled]), a[href]')) + .filter(function(node) { return node.tabIndex >= 0 && !node.closest('[hidden]'); }); + if (!focusable.length) return; + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + + if (event.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }); + function invalidateForContext(options) { + if (!qs('openapi-import-modal').hidden) { + invalidate(options); + renderFileName(); + showRetry(!options || !options.clearFile ? !!state.file : false); + setStatus(tKey('openapi.status.context_changed')); + } + } + window.addEventListener('crank:langchange', function() { + invalidateForContext(); + }); + window.addEventListener('crank:workspacechange', function(event) { + if (!qs('openapi-import-modal').hidden) { + state.workspaceId = event.detail && event.detail.id || null; + invalidateForContext({ clearFile: true }); + } + }); + window.addEventListener('storage', function(event) { + if (event.key === 'crank_lang') invalidateForContext(); + }); + window.addEventListener('pagehide', function() { + invalidate({ clearFile: true }); + showRetry(false); + setStatus(''); + }); + window.addEventListener('pageshow', function(event) { + if (!event.persisted || qs('openapi-import-modal').hidden) return; + + invalidateForContext({ clearFile: true }); + }); }); window.CrankOpenApiImport = { diff --git a/apps/ui/package.json b/apps/ui/package.json index 6a3a699..ae3268f 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -5,6 +5,7 @@ "build": "node scripts/build.js", "e2e:install": "playwright install chromium", "e2e": "playwright test", + "e2e:openapi": "playwright test tests/e2e/operations.spec.js --workers=1", "e2e:headed": "playwright test --headed" }, "dependencies": { diff --git a/apps/ui/playwright.config.js b/apps/ui/playwright.config.js index 7b553a7..648eef0 100644 --- a/apps/ui/playwright.config.js +++ b/apps/ui/playwright.config.js @@ -2,6 +2,14 @@ const { defineConfig, devices } = require('@playwright/test'); const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3300'; const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === '1'; +const jsonOutput = process.env.PLAYWRIGHT_JSON_OUTPUT; +const reporters = process.env.CI + ? [['github'], ['html', { open: 'never' }]] + : [['list'], ['html', { open: 'never' }]]; + +if (jsonOutput) { + reporters.push(['json', { outputFile: jsonOutput }]); +} module.exports = defineConfig({ testDir: './tests/e2e', @@ -12,9 +20,7 @@ module.exports = defineConfig({ timeout: 10_000, }, retries: process.env.CI ? 1 : 0, - reporter: process.env.CI - ? [['github'], ['html', { open: 'never' }]] - : [['list'], ['html', { open: 'never' }]], + reporter: reporters, use: { baseURL, trace: 'on-first-retry', diff --git a/apps/ui/scripts/playwright-stack.sh b/apps/ui/scripts/playwright-stack.sh index 49fae2f..591fef7 100644 --- a/apps/ui/scripts/playwright-stack.sh +++ b/apps/ui/scripts/playwright-stack.sh @@ -132,7 +132,8 @@ export CRANK_SESSION_TTL_HOURS="24" export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL" export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E" export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT" -mkdir -p "$CRANK_STORAGE_ROOT" +mkdir -p -m 700 "$CRANK_STORAGE_ROOT" +chmod 700 "$CRANK_STORAGE_ROOT" ( cd "$ROOT_DIR/apps/ui" diff --git a/apps/ui/tests/e2e/operations.spec.js b/apps/ui/tests/e2e/operations.spec.js index 13a2dd1..ee5ef85 100644 --- a/apps/ui/tests/e2e/operations.spec.js +++ b/apps/ui/tests/e2e/operations.spec.js @@ -1,6 +1,16 @@ const { test, expect } = require('@playwright/test'); const { login, localized, uniqueName } = require('./helpers'); +async function dismissOnboardingIfOpen(page) { + await page.locator('#crank-onboarding-panel').waitFor({ state: 'attached' }); + await page.locator('[data-testid="onboarding-dismiss"]').waitFor({ state: 'attached' }); + await page.evaluate(() => { + const dismiss = document.querySelector('[data-testid="onboarding-dismiss"]'); + if (dismiss) dismiss.click(); + }); + await expect(page.locator('#crank-onboarding-panel')).toBeHidden(); +} + test('operations page shows demo catalog and filter works', async ({ page }) => { await login(page); await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции')); @@ -15,14 +25,20 @@ test('operations page shows demo catalog and filter works', async ({ page }) => test('operations page imports OpenAPI methods as drafts', async ({ page }) => { await login(page); + await dismissOnboardingIfOpen(page); const operationId = uniqueName('get_rates'); const statusOperationId = `${operationId}_status`; - await page.getByRole('button', { name: /Импорт OpenAPI/i }).click(); + await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click(); await expect(page.locator('#openapi-import-modal')).toBeVisible(); - await page.locator('#openapi-import-document').fill(` + const sourceCanary = 'openapi-source-canary-not-for-dom'; + await page.locator('#openapi-import-file').setInputFiles({ + name: 'demo-openapi.yaml', + mimeType: 'application/octet-stream', + buffer: Buffer.from(` openapi: 3.0.3 info: title: Demo Import API + description: ${sourceCanary} servers: paths: /v1/rates/{date}: @@ -76,8 +92,13 @@ paths: type: object properties: status: { type: string } -`); +`), + }); + const previewRequest = page.waitForRequest((request) => request.method() === 'POST' + && request.url().includes('/imports/openapi/preview')); await page.locator('#openapi-import-preview').click(); + const request = await previewRequest; + expect(request.headers()['content-type']).toMatch(/^multipart\/form-data; boundary=/i); await expect(page.locator('#openapi-import-preview-panel')).toBeVisible(); await expect(page.locator('.openapi-import-document-findings')).toContainText(/base URL/i); await expect(page.locator('.openapi-import-group').filter({ hasText: 'currency' })).toBeVisible(); @@ -92,13 +113,13 @@ paths: await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('X-API-Version'); await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Body'); await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('symbols'); - await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Ответ'); - await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 2'); + await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText(localized('Response', 'Ответ')); + await expect(page.locator('#openapi-import-selection')).toContainText(localized('Selected: 2', 'Выбрано: 2')); await page.locator('#openapi-import-search').fill('status'); await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1); await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Проверить статус' })).toBeVisible(); await page.locator('#openapi-import-clear-visible').click(); - await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 1'); + await expect(page.locator('#openapi-import-selection')).toContainText(localized('Selected: 1', 'Выбрано: 1')); await page.locator('#openapi-import-search').fill(''); await page.locator('#openapi-import-method-filter').selectOption('POST'); await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1); @@ -106,15 +127,359 @@ paths: await page.locator('#openapi-import-method-filter').selectOption(''); await page.locator('#openapi-import-server-custom').fill('https://api.example.test'); await page.locator('#openapi-import-create').click(); - await expect(page.locator('#openapi-import-result')).toContainText('Результат импорта'); + await expect(page.locator('#openapi-import-result')).toContainText(localized('Import result', 'Результат импорта')); await expect(page.locator('.openapi-import-result-table')).toBeVisible(); await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('POST /v1/rates/{date}'); - await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(/Описание инструмента слишком короткое/); - await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('Исправить в мастере'); + await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(localized('tool description is too short', 'Описание инструмента слишком короткое')); + await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(localized('Fix in wizard', 'Исправить в мастере')); await expect(page.locator('.openapi-import-primary-result a')).toHaveAttribute( 'href', /\/wizard\/\?mode=edit&operationId=op_/ ); await expect(page.locator('tbody')).toContainText(new RegExp(operationId, 'i')); await expect(page.locator('tbody')).not.toContainText(new RegExp(statusOperationId, 'i')); + await expect(page.locator('body')).not.toContainText(sourceCanary); +}); + +test('OpenAPI upload rejects invalid files locally and restores focus after Escape', async ({ page }) => { + await login(page); + await dismissOnboardingIfOpen(page); + const trigger = page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }); + await trigger.focus(); + await trigger.click(); + await expect(page.locator('#openapi-import-file-select')).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(page.locator('[data-openapi-close]').last()).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(page.locator('#openapi-import-reset')).toBeFocused(); + await page.locator('#openapi-import-file-select').focus(); + await expect(page.locator('#openapi-import-status')).toHaveAttribute('role', 'status'); + await expect(page.locator('#openapi-import-status')).toHaveAttribute('aria-live', 'polite'); + let previewRequests = 0; + page.on('request', (request) => { + if (request.url().includes('/imports/openapi/preview')) previewRequests += 1; + }); + await page.locator('#openapi-import-preview').click(); + expect(previewRequests).toBe(0); + + const longFileName = `${'a'.repeat(130)}.yaml`; + await page.locator('#openapi-import-file').setInputFiles({ + name: longFileName, + mimeType: 'application/yaml', + buffer: Buffer.from('openapi: 3.0.3'), + }); + await expect(page.locator('#openapi-import-file-name')).toContainText('… · 14 B'); + await page.evaluate(() => window.setLang('en')); + await expect(page.locator('#openapi-import-file-name')).toContainText('… · 14 B'); + await expect(page.locator('#openapi-import-server-custom')).toHaveAttribute('aria-label', 'Custom Base URL'); + await page.evaluate(() => window.setLang('ru')); + await expect(page.locator('#openapi-import-file-name')).toContainText('… · 14 B'); + await expect(page.locator('#openapi-import-server-custom')).toHaveAttribute('aria-label', 'Свой Base URL'); + + const invalidFiles = [ + { + file: { name: 'wrong-extension.txt', mimeType: 'application/yaml', buffer: Buffer.from('openapi: 3.0.3') }, + message: localized('Choose one .yaml', 'Выберите один файл'), + }, + { + file: { name: 'wrong-type.yaml', mimeType: 'text/plain', buffer: Buffer.from('openapi: 3.0.3') }, + message: localized('matching type', 'подходящим типом'), + }, + { + file: { name: 'empty.yaml', mimeType: 'application/yaml', buffer: Buffer.alloc(0) }, + message: localized('file is empty', 'файл пуст'), + }, + { + file: { name: 'too-large.yaml', mimeType: 'application/yaml', buffer: Buffer.alloc(256 * 1024 + 1, 'x') }, + message: localized('larger than 256 KiB', 'больше 256 KiB'), + }, + ]; + + for (const invalid of invalidFiles) { + await page.locator('#openapi-import-file').setInputFiles(invalid.file); + await expect(page.locator('#openapi-import-status')).toContainText(invalid.message); + await expect(page.locator('#openapi-import-status')).toBeFocused(); + await page.locator('#openapi-import-preview').click(); + expect(previewRequests).toBe(0); + } + + await page.keyboard.press('Escape'); + await expect(trigger).toBeFocused(); +}); + +test('OpenAPI upload recovers from pagehide and a preview server error', async ({ page }) => { + await login(page); + await dismissOnboardingIfOpen(page); + await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click(); + + let releasePreview; + const pendingPreview = new Promise((resolve) => { + releasePreview = resolve; + }); + await page.route('**/imports/openapi/preview', async (route) => { + await pendingPreview; + try { + await route.fulfill({ status: 503, contentType: 'application/json', body: '{}' }); + } catch (_error) { + // pagehide aborts the active request before the delayed route settles. + } + }); + await page.locator('#openapi-import-file').setInputFiles({ + name: 'resume.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from('openapi: 3.0.3'), + }); + await page.locator('#openapi-import-preview').click(); + await expect(page.locator('#openapi-import-status')).toContainText(localized('Uploading and parsing', 'Загружаю и разбираю')); + + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }))); + await expect(page.locator('#openapi-import-status')).toHaveText(''); + await expect(page.locator('#openapi-import-cancel')).toBeHidden(); + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }))); + await expect(page.locator('#openapi-import-retry')).toBeHidden(); + await expect(page.locator('#openapi-import-file-name')).toContainText(localized('No file selected', 'Файл не выбран')); + await expect(page.locator('#openapi-import-status')).toContainText(localized('context changed', 'контекст изменился')); + releasePreview(); +}); + +test('OpenAPI upload invalidates active draft creation after language or workspace changes', async ({ page }) => { + await login(page); + await dismissOnboardingIfOpen(page); + await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click(); + + await page.route('**/imports/openapi/preview', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + job_id: 'job_active_apply', + preview: { + source: { servers: ['https://api.example.test'] }, + findings: [], + groups: [{ + title: 'active apply', + operations: [{ + key: 'get:/active', method: 'GET', path: '/active', suggested_name: 'active', + suggested_display_name: 'Active', input_fields: 0, output_fields: 0, + draft: { input_mapping: { rules: [] }, output_mapping: { rules: [] } }, findings: [], + }], + }], + }, + }), + }); + }); + const releases = []; + await page.route('**/imports/openapi/*/create', async (route) => { + await new Promise((resolve) => releases.push(resolve)); + try { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ created: [{ name: 'stale draft', operation_id: 'op_stale', version: 1 }], skipped: [], findings: [] }), + }); + } catch (_error) { + // The invalidation aborts a request that must not update the modal afterwards. + } + }); + + async function beginApply(name) { + await page.locator('#openapi-import-file').setInputFiles({ + name, + mimeType: 'application/yaml', + buffer: Buffer.from('openapi: 3.0.3'), + }); + await page.locator('#openapi-import-preview').click(); + await expect(page.locator('#openapi-import-preview-panel')).toBeVisible(); + await page.locator('#openapi-import-server-custom').fill('https://api.example.test'); + await page.locator('#openapi-import-create').click(); + await expect(page.locator('#openapi-import-cancel')).toBeVisible(); + await expect.poll(() => releases.length).toBeGreaterThan(0); + } + + await beginApply('language-change.yaml'); + await page.evaluate(() => { + window.dispatchEvent(new StorageEvent('storage', { key: 'crank_lang', newValue: 'en' })); + }); + releases.shift()(); + await expect(page.locator('#openapi-import-preview-panel')).toBeHidden(); + await expect(page.locator('#openapi-import-result')).toBeHidden(); + await expect(page.locator('#openapi-import-create')).toBeEnabled(); + + await beginApply('workspace-change.yaml'); + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: { id: 'workspace-after-apply' } })); + }); + releases.shift()(); + await expect(page.locator('#openapi-import-preview-panel')).toBeHidden(); + await expect(page.locator('#openapi-import-result')).toBeHidden(); + await expect(page.locator('#openapi-import-file-name')).toContainText('No file selected'); + await expect(page.locator('#openapi-import-create')).toBeEnabled(); +}); + +test('OpenAPI upload only renders the latest selected file and clears reset or close races', async ({ page }) => { + await login(page); + await dismissOnboardingIfOpen(page); + await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click(); + + await page.route('**/imports/openapi/preview', async (route) => { + const isFirst = route.request().postData().includes('first.yaml'); + await new Promise((resolve) => setTimeout(resolve, isFirst ? 180 : 10)); + try { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + job_id: isFirst ? 'job_first' : 'job_second', + preview: { + source: { servers: ['https://api.example.test'] }, + findings: [], + groups: [{ + title: isFirst ? 'first-only' : 'second-only', + operations: [{ + key: isFirst ? 'get:/first' : 'get:/second', + method: 'GET', + path: isFirst ? '/first' : '/second', + suggested_name: isFirst ? 'first' : 'second', + suggested_display_name: isFirst ? 'First only' : 'Second only', + input_fields: 0, + output_fields: 0, + draft: { input_mapping: { rules: [] }, output_mapping: { rules: [] } }, + findings: [], + }], + }], + }, + }), + }); + } catch (_error) { + // The browser intentionally aborts stale requests before this response settles. + } + }); + + await page.locator('#openapi-import-file').setInputFiles({ + name: 'first.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from('openapi: 3.0.3'), + }); + await page.locator('#openapi-import-preview').click(); + await expect(page.locator('#openapi-import-preview-panel')).toBeVisible(); + await page.locator('#openapi-import-server-custom').fill('https://stale.example.test'); + await page.locator('#openapi-import-file').setInputFiles({ + name: 'second.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from('openapi: 3.0.3'), + }); + await expect(page.locator('#openapi-import-server-custom')).toHaveValue(''); + await expect(page.locator('#openapi-import-server option')).toHaveCount(0); + await page.locator('#openapi-import-preview').click(); + await expect(page.locator('.openapi-import-group')).toContainText('second-only'); + await page.waitForTimeout(220); + await expect(page.locator('.openapi-import-group')).not.toContainText('first-only'); + + await page.locator('#openapi-import-file').setInputFiles({ + name: 'third.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from('openapi: 3.0.3'), + }); + await page.locator('#openapi-import-preview').click(); + await page.locator('#openapi-import-reset').click(); + await page.waitForTimeout(220); + await expect(page.locator('#openapi-import-preview-panel')).toBeHidden(); + await expect(page.locator('#openapi-import-file-name')).toContainText(localized('No file selected', 'Файл не выбран')); + + await page.locator('#openapi-import-file').setInputFiles({ + name: 'fourth.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from('openapi: 3.0.3'), + }); + await page.locator('#openapi-import-preview').click(); + await page.keyboard.press('Escape'); + await page.waitForTimeout(220); + await expect(page.locator('#openapi-import-modal')).toBeHidden(); +}); + +test('OpenAPI upload ignores a stale failure and renders only correlation identifiers', async ({ page }) => { + await login(page); + await dismissOnboardingIfOpen(page); + await page.getByRole('button', { name: localized('Import OpenAPI', 'Импорт OpenAPI') }).click(); + const sourceCanary = 'do-not-render-openapi-source-canary'; + await page.locator('#openapi-import-file').setInputFiles({ + name: 'safe.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from(`openapi: 3.0.3\ninfo: { title: Safe, description: ${sourceCanary} }\npaths: { /health: { get: { responses: { '200': { description: OK } } } } }\n`), + }); + let releaseStaleFailure; + const staleFailure = new Promise((resolve) => { + releaseStaleFailure = resolve; + }); + await page.route('**/imports/openapi/preview', async (route) => { + await staleFailure; + await route.fulfill({ + status: 400, + contentType: 'application/json', + headers: { + 'x-request-id': 'req_ui_evidence_1', + 'x-trace-id': '0123456789abcdef0123456789abcdef', + }, + body: JSON.stringify({ error: { code: 'openapi_upload.invalid_document', message: 'Safe upload failure' } }), + }); + }); + await page.locator('#openapi-import-preview').click(); + await expect(page.locator('#openapi-import-cancel')).toBeVisible(); + await page.locator('#openapi-import-cancel').click({ force: true }); + releaseStaleFailure(); + await page.waitForTimeout(100); + await expect(page.locator('#openapi-import-status')).toContainText(localized('Request cancelled', 'Запрос отменён')); + await expect(page.locator('body')).not.toContainText(sourceCanary); + + await page.unroute('**/imports/openapi/preview'); + await page.route('**/imports/openapi/preview', async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + headers: { + 'x-request-id': 'req_ui_evidence_2', + 'x-trace-id': 'abcdef0123456789abcdef0123456789', + }, + body: JSON.stringify({ error: { code: 'openapi_upload.invalid_document', message: 'Safe upload failure' } }), + }); + }); + await page.locator('#openapi-import-retry').click(); + await expect(page.locator('#openapi-import-status')).toContainText(localized('not a valid OpenAPI document', 'не является корректным документом OpenAPI')); + await expect(page.locator('#openapi-import-status')).toBeFocused(); + await expect(page.locator('#openapi-import-status')).toContainText('Request ID: req_ui_evidence_2'); + await expect(page.locator('#openapi-import-status')).toContainText('Trace ID: abcdef0123456789abcdef0123456789'); + + await page.evaluate(() => window.setLang('en')); + await expect(page.locator('#openapi-import-title')).toHaveText('Import OpenAPI'); + + await page.unroute('**/imports/openapi/preview'); + await page.route('**/imports/openapi/preview', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + job_id: 'job_ui_evidence', + preview: { + source: { servers: ['https://api.example.test'] }, + findings: [], + groups: [{ + title: 'evidence', + operations: [{ + key: 'get:/health', method: 'GET', path: '/health', suggested_name: 'health', + suggested_display_name: 'Health', input_fields: 0, output_fields: 0, + draft: { input_mapping: { rules: [] }, output_mapping: { rules: [] } }, findings: [], + }], + }], + }, + }), + }); + }); + await page.locator('#openapi-import-retry').click(); + await expect(page.locator('#openapi-import-preview-panel')).toBeVisible(); + let createRequests = 0; + await page.route('**/imports/openapi/*/create', async (route) => { + createRequests += 1; + await new Promise((resolve) => setTimeout(resolve, 100)); + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ created: [], skipped: [], findings: [] }) }); + }); + await page.locator('#openapi-import-create').click(); + await page.locator('#openapi-import-create').evaluate((button) => button.click()); + await expect(page.locator('#openapi-import-status')).toContainText('Created: 0; skipped: 0.'); + expect(createRequests).toBe(1); }); diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index c1e8bf9..3b3c016 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -20,14 +20,14 @@ pub mod records { ArtifactReconciliationClaim, ArtifactSourceCursor, ArtifactSourceId, ArtifactSourceLifecycle, ArtifactSourcePage, ArtifactSourceRecord, ArtifactSourceSensitivity, AuthUserRecord, DescriptorKind, DescriptorMetadata, ImportJob, - ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord, - InvocationHistoryLoss, InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, - InvocationLogRecord, InvocationRetentionOutcome, InvocationRetentionPolicy, - InvocationRetentionStatus, MasterKeyIdentityRecord, MasterKeyRotationRecord, - MasterKeyRotationStatus, MembershipRecord, OnboardingMilestoneResult, - OnboardingPresentationMilestone, OperationAgentRef, OperationSampleMetadata, - OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, - PlatformApiKeyRecord, ProductEventRecord, PublishedAgentCatalog, PublishedAgentTool, + ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope, ImportJobStatus, + InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory, + InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome, + InvocationRetentionPolicy, InvocationRetentionStatus, MasterKeyIdentityRecord, + MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord, + OnboardingMilestoneResult, OnboardingPresentationMilestone, OperationAgentRef, + OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, + Page, PlatformApiKeyRecord, ProductEventRecord, PublishedAgentCatalog, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord, SecretVersionRecord, SessionRecord, SkippedImportOperation, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageOutcomeBreakdown, UsageOutcomeGroup, UsageRollupRecord, UsageSummary, @@ -47,14 +47,14 @@ pub mod requests { CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DetachArtifactSourceRequest, ExpireApprovalRequest, - FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, ImportOperationDraft, - ListApprovalRequestsQuery, ListArtifactSourcesQuery, ListInvocationLogsQuery, - ListProductEventsQuery, MasterKeyIdentityCandidate, PublishAgentRequest, PublishRequest, - RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest, - RecoverAdminPasswordRequest, RotateSecretRequest, SaveAgentBindingsRequest, - SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UpdateAgentSummaryRequest, - UpdateWorkspaceRequest, UsageQuery, + FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, ImportJobSourceEnvelope, + ImportOperationDraft, ListApprovalRequestsQuery, ListArtifactSourcesQuery, + ListInvocationLogsQuery, ListProductEventsQuery, MasterKeyIdentityCandidate, + PublishAgentRequest, PublishRequest, RecordOnboardingCompletionRequest, + RecordOnboardingMilestoneRequest, RecoverAdminPasswordRequest, RotateSecretRequest, + SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, + UpdateAgentSummaryRequest, UpdateWorkspaceRequest, UsageQuery, }; } @@ -83,14 +83,14 @@ pub use model::{ CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata, DetachArtifactSourceRequest, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, - ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus, - ImportOperationDraft, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory, - InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome, - InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery, - ListArtifactSourcesQuery, ListInvocationLogsQuery, ListProductEventsQuery, - MASTER_KEY_CIPHER_CONTRACT, MAX_ARTIFACT_CLAIM_RECOVERY_BATCH, MAX_ARTIFACT_SOURCE_PAGE_SIZE, - MasterKeyIdentityCandidate, MasterKeyIdentityRecord, MasterKeyRotationRecord, - MasterKeyRotationStatus, MembershipRecord, OnboardingMilestoneResult, + ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope, + ImportJobStatus, ImportOperationDraft, InvitationRecord, InvocationHistoryLoss, + InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, InvocationLogRecord, + InvocationRetentionOutcome, InvocationRetentionPolicy, InvocationRetentionStatus, + ListApprovalRequestsQuery, ListArtifactSourcesQuery, ListInvocationLogsQuery, + ListProductEventsQuery, MASTER_KEY_CIPHER_CONTRACT, MAX_ARTIFACT_CLAIM_RECOVERY_BATCH, + MAX_ARTIFACT_SOURCE_PAGE_SIZE, MasterKeyIdentityCandidate, MasterKeyIdentityRecord, + MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord, OnboardingMilestoneResult, OnboardingPresentationMilestone, OperationAgentRef, OperationSampleMetadata, OperationStateExpectation, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, ProductEventRecord, PublishAgentRequest, PublishRequest, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index 7c0de8d..96e1c07 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -1,3 +1,4 @@ +use crank_artifacts::ArtifactRef; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExecutionErrorCode, @@ -584,6 +585,16 @@ pub enum ImportJobKind { OpenApi, } +/// The bounded, opaque authority retained with an OpenAPI import job. +/// It is persisted inside the existing JSONB payload, never exposed by the +/// admin response, and is rechecked against workspace-scoped metadata at +/// apply time. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImportJobSourceEnvelope { + pub source_id: ArtifactSourceId, + pub digest: ArtifactRef, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ImportJob { pub id: ImportJobId, @@ -611,6 +622,7 @@ pub struct CreateImportJobRequest<'a> { pub source_format: &'a str, pub source_version: Option<&'a str>, pub status: ImportJobStatus, + pub source: &'a ImportJobSourceEnvelope, pub preview_payload: &'a Value, pub created_at: &'a OffsetDateTime, pub expires_at: &'a OffsetDateTime, diff --git a/crates/crank-registry/src/postgres/import_job.rs b/crates/crank-registry/src/postgres/import_job.rs index 9d10aca..58e8c65 100644 --- a/crates/crank-registry/src/postgres/import_job.rs +++ b/crates/crank-registry/src/postgres/import_job.rs @@ -1,12 +1,18 @@ use super::*; +use crate::{ArtifactSourceId, ImportJobSourceEnvelope}; +use crank_artifacts::ArtifactRef; const APPLICATION_RESULT_KEY: &str = "_crank_application_result"; +const IMPORT_JOB_CLEANUP_BATCH: i64 = 128; +const DANGLING_OPENAPI_SOURCE_GRACE: time::Duration = time::Duration::minutes(5); impl PostgresRegistry { pub async fn create_import_job( &self, request: CreateImportJobRequest<'_>, ) -> Result<(), RegistryError> { + validate_source_envelope(request.source, request.preview_payload)?; + let mut transaction = self.pool.begin().await?; sqlx::query( "insert into import_jobs ( id, @@ -34,8 +40,9 @@ impl PostgresRegistry { .bind(request.preview_payload) .bind(request.created_at) .bind(request.expires_at) - .execute(&self.pool) + .execute(&mut *transaction) .await?; + transaction.commit().await?; Ok(()) } @@ -137,11 +144,79 @@ impl PostgresRegistry { } pub async fn delete_expired_import_jobs(&self) -> Result { - let result = sqlx::query("delete from import_jobs where expires_at < now()") - .execute(&self.pool) + let mut transaction = self.pool.begin().await?; + let now = sqlx::query_scalar::<_, OffsetDateTime>("select now()") + .fetch_one(&mut *transaction) .await?; + let expired = sqlx::query( + "select id, workspace_id, preview_payload + from import_jobs + where expires_at < now() + order by expires_at, id + limit $1 + for update skip locked", + ) + .bind(IMPORT_JOB_CLEANUP_BATCH) + .fetch_all(&mut *transaction) + .await?; + let mut expired_ids = Vec::with_capacity(expired.len()); + for row in &expired { + expired_ids.push(row.try_get::("id")?); + let workspace_id = WorkspaceId::new(row.try_get::("workspace_id")?); + let payload = row.try_get::("preview_payload")?; + if let Some(source) = source_from_payload(&payload)? { + detach_source_in_transaction( + &mut transaction, + &workspace_id, + &source.source_id, + now, + ) + .await?; + } + } + let deleted = if expired_ids.is_empty() { + 0 + } else { + sqlx::query("delete from import_jobs where id = any($1)") + .bind(&expired_ids) + .execute(&mut *transaction) + .await? + .rows_affected() + }; - Ok(result.rows_affected()) + let dangling_cutoff = now.checked_sub(DANGLING_OPENAPI_SOURCE_GRACE).ok_or( + RegistryError::InvalidArtifactSource { + field: "import_job_source", + }, + )?; + let dangling = sqlx::query( + "select s.workspace_id, s.source_id + from artifact_sources s + where s.lifecycle = 'active' + and left(s.source_id, 12) = 'src_openapi_' + and s.created_at < $1 + and not exists ( + select 1 + from import_jobs j + where j.workspace_id = s.workspace_id + and j.preview_payload -> 'source' ->> 'source_id' = s.source_id + ) + order by s.created_at, s.workspace_id, s.source_id + limit $2 + for update of s skip locked", + ) + .bind(dangling_cutoff) + .bind(IMPORT_JOB_CLEANUP_BATCH) + .fetch_all(&mut *transaction) + .await?; + for row in dangling { + let workspace_id = WorkspaceId::new(row.try_get::("workspace_id")?); + let source_id = ArtifactSourceId::new(row.try_get::("source_id")?); + detach_source_in_transaction(&mut transaction, &workspace_id, &source_id, now).await?; + } + transaction.commit().await?; + + Ok(deleted) } } @@ -164,6 +239,10 @@ async fn apply_import_job_transaction( })?; let status = deserialize_enum_text::(row.try_get("status")?, "status")?; let mut preview_payload = row.try_get::("preview_payload")?; + let source = + source_from_payload(&preview_payload)?.ok_or(RegistryError::InvalidArtifactSource { + field: "import_job_source", + })?; if status == ImportJobStatus::Completed && let Some(result) = stored_application_result(&preview_payload)? @@ -181,6 +260,30 @@ async fn apply_import_job_transaction( .fetch_one(&mut **tx) .await?; + let recorded_source = sqlx::query( + "select s.lifecycle, b.artifact_ref + from artifact_sources s + join artifact_blobs b on b.digest = s.blob_digest + where s.workspace_id = $1 and s.source_id = $2 + for update", + ) + .bind(request.workspace_id.as_str()) + .bind(source.source_id.as_str()) + .fetch_optional(&mut **tx) + .await?; + let source_is_current = recorded_source + .as_ref() + .map(|row| { + let lifecycle = row.try_get::("lifecycle").ok(); + let artifact_ref = row.try_get::("artifact_ref").ok(); + lifecycle.as_deref() == Some("active") + && artifact_ref.as_deref() == Some(source.digest.as_str()) + }) + .unwrap_or(false); + if !source_is_current { + return Err(RegistryError::SourceUnavailable); + } + let mut result = ImportJobApplyResult { application_key: request.application_key.to_owned(), ..ImportJobApplyResult::default() @@ -257,9 +360,91 @@ async fn apply_import_job_transaction( .execute(&mut **tx) .await?; + detach_source_in_transaction( + tx, + request.workspace_id, + &source.source_id, + *request.finished_at, + ) + .await?; + Ok(result) } +fn validate_source_envelope( + source: &ImportJobSourceEnvelope, + preview_payload: &Value, +) -> Result<(), RegistryError> { + if source.source_id.as_str().len() > 132 { + return Err(RegistryError::InvalidArtifactSource { + field: "import_job_source", + }); + } + let parsed = + source_from_payload(preview_payload)?.ok_or(RegistryError::InvalidArtifactSource { + field: "import_job_source", + })?; + if parsed != *source { + return Err(RegistryError::InvalidArtifactSource { + field: "import_job_source", + }); + } + Ok(()) +} + +fn source_from_payload(payload: &Value) -> Result, RegistryError> { + let Some(source) = payload.get("source") else { + return Ok(None); + }; + let has_source_id = source.get("source_id").is_some(); + let has_digest = source.get("digest").is_some(); + if !has_source_id && !has_digest { + // Jobs created before the verified-source envelope used `source` for + // OpenAPI format/version metadata. Expiry cleanup must remain able to + // delete those jobs during a rolling upgrade. + return Ok(None); + } + let source_id = source + .get("source_id") + .and_then(Value::as_str) + .filter(|value| value.len() <= 132) + .ok_or(RegistryError::InvalidArtifactSource { + field: "import_job_source", + })?; + let digest = source.get("digest").and_then(Value::as_str).ok_or( + RegistryError::InvalidArtifactSource { + field: "import_job_source", + }, + )?; + let digest = ArtifactRef::parse(digest).map_err(|_| RegistryError::InvalidArtifactSource { + field: "import_job_source", + })?; + Ok(Some(ImportJobSourceEnvelope { + source_id: ArtifactSourceId::new(source_id), + digest, + })) +} + +async fn detach_source_in_transaction( + transaction: &mut Transaction<'_, Postgres>, + workspace_id: &WorkspaceId, + source_id: &ArtifactSourceId, + detached_at: OffsetDateTime, +) -> Result<(), RegistryError> { + sqlx::query( + "update artifact_sources + set lifecycle = 'detached', updated_at = $1, detached_at = $1 + where workspace_id = $2 and source_id = $3 + and lifecycle = 'active'", + ) + .bind(detached_at) + .bind(workspace_id.as_str()) + .bind(source_id.as_str()) + .execute(&mut **transaction) + .await?; + Ok(()) +} + fn stored_application_result( preview_payload: &Value, ) -> Result, RegistryError> { diff --git a/docs/admin-api.md b/docs/admin-api.md index 79b7b8e..bebfeb9 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -164,6 +164,10 @@ Mutation contract revision 2 требует `If-Match` для `PATCH`, create-ve Portable export использует закрытый `format_version: "2"` contract из [`schemas/operation-export-v2.schema.json`](schemas/operation-export-v2.schema.json). Он исключает persistence IDs, lifecycle metadata, samples, wizard state и credentials. Legacy v1 принимается только при импорте и нормализуется в v2; exporter v1 не выдаёт. YAML import ограничен 256 KiB и возвращает только bounded codes `operation_yaml_too_large|operation_yaml_invalid|operation_yaml_unsupported` без raw parser text. +### OpenAPI preview upload + +`POST /api/admin/workspaces/{workspace_id}/imports/openapi/preview` принимает только `multipart/form-data` с ровно одним полем `file`. Допускаются UTF-8 `.yaml`, `.yml` и `.json` размером 1..256 KiB с MIME, согласованным с расширением (для YAML/JSON также допустим `application/octet-stream`). JSON `{document}` не является production contract. Ответы об ошибках локализуются через `Accept-Language`, не раскрывают source/digest/path и могут содержать только canonical `X-Request-ID` и `X-Trace-ID` для восстановления. + `analyze-quality` принимает payload операции и возвращает рекомендации: ```json diff --git a/docs/openapi-import.md b/docs/openapi-import.md index ce4da14..5bbaeae 100644 --- a/docs/openapi-import.md +++ b/docs/openapi-import.md @@ -6,7 +6,7 @@ Crank умеет импортировать REST API из OpenAPI 3.x и Swagger 1. Откройте раздел **Операции**. 2. Нажмите **Импорт OpenAPI** рядом с кнопкой **Новая операция**. -3. Загрузите `.yaml`, `.yml`, `.json` файл или вставьте текст спецификации. +3. Загрузите ровно один UTF-8 `.yaml`, `.yml` или `.json` файл размером от 1 B до 256 KiB. Вставка текста не поддерживается. 4. Нажмите **Разобрать документ**. 5. Проверьте preview: для каждого метода показываются поля `Path`, `Query`, `Header`, `Body` и поля ответа. 6. Выберите нужные группы и методы. @@ -16,6 +16,12 @@ Crank умеет импортировать REST API из OpenAPI 3.x и Swagger - **Пропустить** — существующие операции не будут изменены. 9. Нажмите **Создать черновики**. +## Загрузка и восстановление + +Браузер отправляет выбранный файл как `multipart/form-data`; границу multipart формирует сам браузер. Файл остаётся только в памяти текущего окна импорта. При замене файла, сбросе, закрытии окна, смене workspace/языка или уходе со страницы текущий запрос отменяется, а поздний ответ игнорируется. При `pagehide` (включая BFCache) выбранный файл и Base URL очищаются: после возврата нужно выбрать файл заново, повтор старой загрузки не предлагается. + +Для ошибки можно выбрать файл заново или нажать **Повторить**. Диагностика показывает только ограниченные имя/размер файла и, если backend их вернул, `Request ID`/`Trace ID`; содержимое спецификации, digest, source ID и внутренние пути не выводятся. Повторное нажатие **Создать черновики** во время выполнения не создаёт вторую мутацию. + ## Что создается Для каждого выбранного метода Crank создает отдельную операцию в статусе черновика: diff --git a/docs/production-checklist.md b/docs/production-checklist.md index 3f2f524..fc9d8b6 100644 --- a/docs/production-checklist.md +++ b/docs/production-checklist.md @@ -49,6 +49,12 @@ - Удаляйте или отзывайте ключи, которые больше не используются. - Используйте secrets/auth profiles для токенов внешних API. - Не вставляйте реальные токены в статические заголовки операции. + +## OpenAPI import и release evidence + +- Проверяйте реальный multipart upload из UI: один файл `.yaml|.yml|.json`, 1..256 KiB, preview и создание Draft. +- При сбое используйте только `Request ID`/`Trace ID`; не прикладывайте исходный OpenAPI-файл, digest, source ID, путь, Playwright trace/video или raw report к release evidence. +- Сохраняйте только JSON, сформированный `scripts/collect-capability-baseline.py`: он содержит revision, разрешённые flow IDs и агрегированные counts. Backup/retention исходных файлов OpenAPI координируются artifact lifecycle/reconciliation, а не UI или release-job. - Не меняйте `CRANK_MASTER_KEY` напрямую. Для смены ключа выполняйте `crank-migrate master-key preflight → rotate → verify → promote`, затем перезапускайте secret-using процессы с target key. diff --git a/scripts/collect-capability-baseline.py b/scripts/collect-capability-baseline.py index e3e919b..c432551 100644 --- a/scripts/collect-capability-baseline.py +++ b/scripts/collect-capability-baseline.py @@ -61,24 +61,25 @@ def load_report(path: Path) -> tuple[dict[str, Any], str]: return value, digest -def iter_tests(value: Any): +def iter_tests(value: Any, title: str | None = None): if isinstance(value, dict): + title = value.get("title") if isinstance(value.get("title"), str) else title tests = value.get("tests") if isinstance(tests, list): for test in tests: if isinstance(test, dict): - yield test + yield test, title for key in ("suites", "specs"): children = value.get(key) if isinstance(children, list): for child in children: - yield from iter_tests(child) + yield from iter_tests(child, title) elif isinstance(value, list): for item in value: - yield from iter_tests(item) + yield from iter_tests(item, title) -def playwright_verdict(report: dict[str, Any]) -> tuple[str, dict[str, int]]: +def playwright_verdict(report: dict[str, Any], required_titles: list[str]) -> tuple[str, dict[str, int]]: counts = {"passed": 0, "failed": 0, "flaky": 0, "skipped": 0, "not_run": 0} report_errors = report.get("errors", []) if not isinstance(report_errors, list): @@ -91,7 +92,8 @@ def playwright_verdict(report: dict[str, Any]) -> tuple[str, dict[str, int]]: if not tests: counts["not_run"] = 1 return "not_run", counts - for test in tests: + required = {title: False for title in required_titles} + for test, title in tests: status = test.get("status") results = test.get("results") if isinstance(test.get("results"), list) else [] result_statuses = [result.get("status") for result in results if isinstance(result, dict)] @@ -109,8 +111,12 @@ def playwright_verdict(report: dict[str, Any]) -> tuple[str, dict[str, int]]: counts["failed"] += 1 elif results and all(result_status == "passed" for result_status in result_statuses): counts["passed"] += 1 + if title in required: + required[title] = True else: counts["not_run"] += 1 + if not all(required.values()): + counts["failed"] += len([title for title, passed in required.items() if not passed]) if counts["failed"]: return "fail", counts if counts["flaky"]: @@ -134,7 +140,7 @@ def validate_labels(args: argparse.Namespace) -> None: def collect_playwright(args: argparse.Namespace) -> dict[str, Any]: validate_labels(args) report, digest = load_report(Path(args.report)) - verdict, counts = playwright_verdict(report) + verdict, counts = playwright_verdict(report, args.required_test) return { "accepted": verdict == "pass", "collector": "capability-baseline-collector-v1", @@ -193,6 +199,7 @@ def parser() -> argparse.ArgumentParser: playwright.add_argument("--source-revision", required=True) playwright.add_argument("--environment-class", required=True) playwright.add_argument("--flow-id", action="append", required=True) + playwright.add_argument("--required-test", action="append", default=[]) command = subparsers.add_parser("command-report") command.add_argument("--report", required=True) command.add_argument("--output", required=True) diff --git a/scripts/validate-capability-run.py b/scripts/validate-capability-run.py new file mode 100644 index 0000000..6659251 --- /dev/null +++ b/scripts/validate-capability-run.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Validate a sanitized evidence candidate against capability-baseline $defs/run.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + + +MAX_INPUT_BYTES = 65_536 + + +class ValidationError(Exception): + def __init__(self, pointer: str, message: str) -> None: + super().__init__(message) + self.pointer = pointer + self.message = message + + +def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValidationError('/', 'duplicate JSON key') + result[key] = value + return result + + +def load_json(path: Path) -> Any: + try: + raw = path.read_bytes() + except OSError as error: + raise ValidationError('/', 'cannot read input') from error + if len(raw) > MAX_INPUT_BYTES: + raise ValidationError('/', 'input exceeds 64 KiB') + try: + return json.loads(raw.decode('utf-8'), object_pairs_hook=reject_duplicates) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as error: + raise ValidationError('/', 'invalid JSON') from error + + +def pointer_child(pointer: str, key: str | int) -> str: + escaped = str(key).replace('~', '~0').replace('/', '~1') + return f'{pointer}/{escaped}' + + +def type_matches(value: Any, expected: str) -> bool: + if expected == 'object': + return isinstance(value, dict) + if expected == 'array': + return isinstance(value, list) + if expected == 'string': + return isinstance(value, str) + if expected == 'boolean': + return type(value) is bool + return False + + +def validate(value: Any, schema: dict[str, Any], pointer: str = '') -> None: + current = pointer or '/' + expected_type = schema.get('type') + if expected_type and not type_matches(value, expected_type): + raise ValidationError(current, f'expected {expected_type}') + + if 'const' in schema and value != schema['const']: + raise ValidationError(current, 'does not match const') + if 'enum' in schema and value not in schema['enum']: + raise ValidationError(current, 'does not match enum') + + if isinstance(value, str): + if len(value) < schema.get('minLength', 0): + raise ValidationError(current, 'string is too short') + if len(value) > schema.get('maxLength', sys.maxsize): + raise ValidationError(current, 'string is too long') + pattern = schema.get('pattern') + if pattern and not re.fullmatch(pattern, value): + raise ValidationError(current, 'string does not match pattern') + + if isinstance(value, list): + if len(value) < schema.get('minItems', 0): + raise ValidationError(current, 'array has too few items') + if len(value) > schema.get('maxItems', sys.maxsize): + raise ValidationError(current, 'array has too many items') + if schema.get('uniqueItems') and len({json.dumps(item, sort_keys=True) for item in value}) != len(value): + raise ValidationError(current, 'array items are not unique') + item_schema = schema.get('items') + if item_schema: + for index, item in enumerate(value): + validate(item, item_schema, pointer_child(pointer, index)) + + if isinstance(value, dict): + required = schema.get('required', []) + for key in required: + if key not in value: + raise ValidationError(current, f'missing required property {key}') + properties = schema.get('properties', {}) + if schema.get('additionalProperties') is False: + unexpected = set(value) - set(properties) + if unexpected: + raise ValidationError(current, f'unexpected property {sorted(unexpected)[0]}') + if len(value) > schema.get('maxProperties', sys.maxsize): + raise ValidationError(current, 'object has too many properties') + for key, item in value.items(): + item_schema = properties.get(key) + if item_schema: + validate(item, item_schema, pointer_child(pointer, key)) + + +def run_schema(schema: Any) -> dict[str, Any]: + if not isinstance(schema, dict): + raise ValidationError('/schema', 'schema root must be an object') + definitions = schema.get('$defs') + if not isinstance(definitions, dict): + raise ValidationError('/schema/$defs', 'definitions are missing') + definition = definitions.get('run') + if not isinstance(definition, dict): + raise ValidationError('/schema/$defs/run', 'run definition is missing') + return definition + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--schema', required=True) + parser.add_argument('--candidate', required=True) + parser.add_argument('--require-accepted', action='store_true') + args = parser.parse_args() + + try: + schema = load_json(Path(args.schema)) + candidate = load_json(Path(args.candidate)) + validate(candidate, run_schema(schema)) + if args.require_accepted and candidate.get('accepted') is not True: + raise ValidationError('/accepted', 'accepted evidence is required') + except ValidationError as error: + print(f'INVALID_CAPABILITY_RUN pointer={error.pointer} reason={error.message}', file=sys.stderr) + return 1 + + print('Capability evidence candidate matches $defs/run.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/unit/test_collect_capability_baseline.py b/tests/unit/test_collect_capability_baseline.py index b601af3..18aa4dc 100644 --- a/tests/unit/test_collect_capability_baseline.py +++ b/tests/unit/test_collect_capability_baseline.py @@ -10,14 +10,17 @@ COLLECTOR = ROOT / "scripts" / "collect-capability-baseline.py" class CapabilityBaselineCollectorTests(unittest.TestCase): - def run_playwright(self, report: object) -> tuple[subprocess.CompletedProcess[str], Path, tempfile.TemporaryDirectory[str]]: + def run_playwright(self, report: object, required: list[str] | None = None) -> tuple[subprocess.CompletedProcess[str], Path, tempfile.TemporaryDirectory[str]]: temporary = tempfile.TemporaryDirectory() root = Path(temporary.name) report_path = root / "report.json" output_path = root / "candidate.json" report_path.write_text(json.dumps(report), encoding="utf-8") + command = ["python3", str(COLLECTOR), "playwright", "--report", str(report_path), "--output", str(output_path), "--source-revision", "0" * 40, "--environment-class", "community-test", "--flow-id", "ui-operations"] + for title in required or []: + command.extend(["--required-test", title]) result = subprocess.run( - ["python3", str(COLLECTOR), "playwright", "--report", str(report_path), "--output", str(output_path), "--source-revision", "0" * 40, "--environment-class", "community-test", "--flow-id", "ui-operations"], + command, text=True, capture_output=True, check=False, @@ -51,6 +54,22 @@ class CapabilityBaselineCollectorTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["execution_verdict"], "skipped") + def test_required_openapi_tests_fail_closed_when_missing_skipped_or_flaky(self) -> None: + required = ["OpenAPI required scenario"] + reports = [ + {"suites": [{"specs": [{"title": "another scenario", "tests": [{"status": "expected", "results": [{"status": "passed", "retry": 0}]}]}]}]}, + {"suites": [{"specs": [{"title": required[0], "tests": [{"status": "skipped", "results": []}]}]}]}, + {"suites": [{"specs": [{"title": required[0], "tests": [{"status": "flaky", "results": [{"status": "failed", "retry": 0}, {"status": "passed", "retry": 1}]}]}]}]}, + ] + for report in reports: + with self.subTest(report=report): + result, output, temporary = self.run_playwright(report, required) + self.addCleanup(temporary.cleanup) + self.assertEqual(result.returncode, 0, result.stderr) + candidate = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(candidate["execution_verdict"], "fail") + self.assertFalse(candidate["accepted"]) + def test_raw_report_content_never_reaches_candidate_or_error(self) -> None: canary = "Bearer secret-canary /home/private/workspace https://private.invalid?q=secret" report = {"suites": [], "errors": [{"message": canary}], "stdout": [canary]} diff --git a/tests/unit/test_validate_capability_run.py b/tests/unit/test_validate_capability_run.py new file mode 100644 index 0000000..9aec00e --- /dev/null +++ b/tests/unit/test_validate_capability_run.py @@ -0,0 +1,99 @@ +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA = ROOT / "docs" / "schemas" / "capability-baseline.schema.json" +VALIDATOR = ROOT / "scripts" / "validate-capability-run.py" + + +def candidate() -> dict[str, object]: + return { + "id": "run-ui-playwright-0123456789ab", + "command_id": "ui-playwright", + "flow_ids": ["openapi-upload-ui"], + "execution_verdict": "pass", + "evidence_mode": "automated", + "accepted": True, + "source_report_sha256": "a" * 64, + "source_revision": "0" * 40, + "environment_class": "ci", + "collector": "capability-baseline-collector-v1", + "summary": {"passed": 1}, + } + + +class CapabilityRunValidatorTests(unittest.TestCase): + def validate(self, contents: str | bytes, require_accepted: bool = False) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as directory: + candidate_path = Path(directory) / "candidate.json" + if isinstance(contents, bytes): + candidate_path.write_bytes(contents) + else: + candidate_path.write_text(contents, encoding="utf-8") + command = ["python3", str(VALIDATOR), "--schema", str(SCHEMA), "--candidate", str(candidate_path)] + if require_accepted: + command.append("--require-accepted") + return subprocess.run( + command, + text=True, + capture_output=True, + check=False, + ) + + def test_valid_candidate_matches_run_definition(self) -> None: + result = self.validate(json.dumps(candidate())) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("matches $defs/run", result.stdout) + + def test_missing_required_property_is_rejected(self) -> None: + value = candidate() + value.pop("id") + result = self.validate(json.dumps(value)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("missing required property id", result.stderr) + + def test_extra_property_is_rejected(self) -> None: + value = candidate() + value["raw_report"] = "must-not-be-accepted" + result = self.validate(json.dumps(value)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unexpected property raw_report", result.stderr) + + def test_wrong_type_and_pattern_are_rejected(self) -> None: + type_error = candidate() + type_error["accepted"] = "true" + pattern_error = candidate() + pattern_error["source_revision"] = "not-a-revision" + for value, expected in ((type_error, "expected boolean"), (pattern_error, "string does not match pattern")): + with self.subTest(expected=expected): + result = self.validate(json.dumps(value)) + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected, result.stderr) + + def test_duplicate_json_key_is_rejected(self) -> None: + encoded = json.dumps(candidate()) + duplicate = encoded.replace('"id": "run-ui-playwright-0123456789ab",', '"id": "run-ui-playwright-0123456789ab", "id": "other",') + result = self.validate(duplicate) + self.assertNotEqual(result.returncode, 0) + self.assertIn("duplicate JSON key", result.stderr) + + def test_oversized_candidate_is_rejected(self) -> None: + result = self.validate(b" " * (65_536 + 1)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("input exceeds 64 KiB", result.stderr) + + def test_require_accepted_rejects_schema_valid_negative_evidence(self) -> None: + value = candidate() + value["accepted"] = False + value["execution_verdict"] = "fail" + result = self.validate(json.dumps(value), require_accepted=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("accepted evidence is required", result.stderr) + + +if __name__ == "__main__": + unittest.main()