From 5570011817e33c9a992571267c950cd8dd80de46 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 3 Nov 2025 14:06:03 +0100 Subject: [PATCH 1/4] database/trustpub: Implement `GitLabConfig::count_for_crate()` fn --- .../src/models/trustpub/gitlab_config.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/crates_io_database/src/models/trustpub/gitlab_config.rs b/crates/crates_io_database/src/models/trustpub/gitlab_config.rs index c100f53d2e..26fede5094 100644 --- a/crates/crates_io_database/src/models/trustpub/gitlab_config.rs +++ b/crates/crates_io_database/src/models/trustpub/gitlab_config.rs @@ -17,6 +17,16 @@ pub struct GitLabConfig { pub environment: Option, } +impl GitLabConfig { + pub async fn count_for_crate(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult { + trustpub_configs_gitlab::table + .filter(trustpub_configs_gitlab::crate_id.eq(crate_id)) + .count() + .get_result(conn) + .await + } +} + #[derive(Debug, Insertable)] #[diesel(table_name = trustpub_configs_gitlab, check_for_backend(diesel::pg::Pg))] pub struct NewGitLabConfig<'a> { From e04371886b159b351cb04215c971c95395584a11 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 24 Sep 2025 14:40:24 +0200 Subject: [PATCH 2/4] trustpub: Implement basic GitLab validation fns --- crates/crates_io_trustpub/src/gitlab/mod.rs | 1 + .../src/gitlab/validation.rs | 199 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 crates/crates_io_trustpub/src/gitlab/validation.rs diff --git a/crates/crates_io_trustpub/src/gitlab/mod.rs b/crates/crates_io_trustpub/src/gitlab/mod.rs index d2b5fd4ec3..686eea1d0f 100644 --- a/crates/crates_io_trustpub/src/gitlab/mod.rs +++ b/crates/crates_io_trustpub/src/gitlab/mod.rs @@ -1,6 +1,7 @@ mod claims; #[cfg(any(test, feature = "test-helpers"))] pub mod test_helpers; +pub mod validation; mod workflows; pub use self::claims::GitLabClaims; diff --git a/crates/crates_io_trustpub/src/gitlab/validation.rs b/crates/crates_io_trustpub/src/gitlab/validation.rs new file mode 100644 index 0000000000..e663c121c5 --- /dev/null +++ b/crates/crates_io_trustpub/src/gitlab/validation.rs @@ -0,0 +1,199 @@ +//! Validation functions for GitLab Trusted Publishing configuration fields. +//! +//! This module performs basic validation of user input for GitLab CI/CD trusted publishing +//! configurations. The validation rules are intentionally permissive: they accept all valid +//! GitLab values while rejecting obviously invalid input. This approach is enough for our +//! purposes since GitLab's JWT claims will only contain valid values anyway. +//! +//! See +//! and . + +use std::sync::LazyLock; + +const MAX_FIELD_LENGTH: usize = 255; + +#[derive(Debug, thiserror::Error)] +pub enum ValidationError { + #[error("GitLab namespace may not be empty")] + NamespaceEmpty, + #[error("GitLab namespace is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + NamespaceTooLong, + #[error("Invalid GitLab namespace")] + NamespaceInvalid, + #[error("GitLab namespace cannot end with .atom or .git")] + NamespaceInvalidSuffix, + + #[error("GitLab project name may not be empty")] + ProjectEmpty, + #[error("GitLab project name is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + ProjectTooLong, + #[error("Invalid GitLab project name")] + ProjectInvalid, + #[error("GitLab project name cannot end with .atom or .git")] + ProjectInvalidSuffix, + + #[error("Workflow filepath may not be empty")] + WorkflowFilepathEmpty, + #[error("Workflow filepath is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + WorkflowFilepathTooLong, + #[error("Workflow filepath must end with `.yml` or `.yaml`")] + WorkflowFilepathMissingSuffix, + #[error("Workflow filepath cannot start with /")] + WorkflowFilepathStartsWithSlash, + #[error("Workflow filepath cannot end with /")] + WorkflowFilepathEndsWithSlash, + + #[error("Environment name may not be empty (use `null` to omit)")] + EnvironmentEmptyString, + #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + EnvironmentTooLong, + #[error("Environment name contains invalid characters")] + EnvironmentInvalidChars, +} + +pub fn validate_namespace(namespace: &str) -> Result<(), ValidationError> { + static RE_VALID_NAMESPACE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"^[a-zA-Z0-9](?:[a-zA-Z0-9_.\-/]*[a-zA-Z0-9])?$").unwrap() + }); + + if namespace.is_empty() { + Err(ValidationError::NamespaceEmpty) + } else if namespace.len() > MAX_FIELD_LENGTH { + Err(ValidationError::NamespaceTooLong) + } else if namespace.ends_with(".atom") || namespace.ends_with(".git") { + Err(ValidationError::NamespaceInvalidSuffix) + } else if !RE_VALID_NAMESPACE.is_match(namespace) { + Err(ValidationError::NamespaceInvalid) + } else { + Ok(()) + } +} + +pub fn validate_project(project: &str) -> Result<(), ValidationError> { + static RE_VALID_PROJECT: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"^[a-zA-Z0-9](?:[a-zA-Z0-9_.\-]*[a-zA-Z0-9])?$").unwrap() + }); + + if project.is_empty() { + Err(ValidationError::ProjectEmpty) + } else if project.len() > MAX_FIELD_LENGTH { + Err(ValidationError::ProjectTooLong) + } else if project.ends_with(".atom") || project.ends_with(".git") { + Err(ValidationError::ProjectInvalidSuffix) + } else if !RE_VALID_PROJECT.is_match(project) { + Err(ValidationError::ProjectInvalid) + } else { + Ok(()) + } +} + +pub fn validate_workflow_filepath(filepath: &str) -> Result<(), ValidationError> { + if filepath.is_empty() { + Err(ValidationError::WorkflowFilepathEmpty) + } else if filepath.len() > MAX_FIELD_LENGTH { + Err(ValidationError::WorkflowFilepathTooLong) + } else if filepath.starts_with('/') { + Err(ValidationError::WorkflowFilepathStartsWithSlash) + } else if filepath.ends_with('/') { + Err(ValidationError::WorkflowFilepathEndsWithSlash) + } else if !filepath.ends_with(".yml") && !filepath.ends_with(".yaml") { + Err(ValidationError::WorkflowFilepathMissingSuffix) + } else { + Ok(()) + } +} + +pub fn validate_environment(env: &str) -> Result<(), ValidationError> { + // see https://docs.gitlab.com/ci/yaml/#environment + + static RE_VALID_ENVIRONMENT: LazyLock = + LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9 \-_/${}]+$").unwrap()); + + if env.is_empty() { + Err(ValidationError::EnvironmentEmptyString) + } else if env.len() > MAX_FIELD_LENGTH { + Err(ValidationError::EnvironmentTooLong) + } else if !RE_VALID_ENVIRONMENT.is_match(env) { + Err(ValidationError::EnvironmentInvalidChars) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::{assert_err, assert_ok}; + use insta::assert_snapshot; + + #[test] + fn test_validate_namespace() { + assert_snapshot!(assert_err!(validate_namespace("")), @"GitLab namespace may not be empty"); + assert_snapshot!(assert_err!(validate_namespace(&"x".repeat(256))), @"GitLab namespace is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_namespace("-")), @"Invalid GitLab namespace"); + assert_snapshot!(assert_err!(validate_namespace("_")), @"Invalid GitLab namespace"); + assert_snapshot!(assert_err!(validate_namespace("-start")), @"Invalid GitLab namespace"); + assert_snapshot!(assert_err!(validate_namespace("end-")), @"Invalid GitLab namespace"); + assert_snapshot!(assert_err!(validate_namespace("invalid@chars")), @"Invalid GitLab namespace"); + assert_snapshot!(assert_err!(validate_namespace("foo+bar")), @"Invalid GitLab namespace"); + assert_snapshot!(assert_err!(validate_namespace("foo.atom")), @"GitLab namespace cannot end with .atom or .git"); + assert_snapshot!(assert_err!(validate_namespace("foo.git")), @"GitLab namespace cannot end with .atom or .git"); + + assert_ok!(validate_namespace("a")); + assert_ok!(validate_namespace("foo")); + assert_ok!(validate_namespace("foo-bar")); + assert_ok!(validate_namespace("foo_bar")); + assert_ok!(validate_namespace("foo.bar")); + assert_ok!(validate_namespace("foo/bar")); + assert_ok!(validate_namespace("foo/bar/baz")); + } + + #[test] + fn test_validate_project() { + assert_snapshot!(assert_err!(validate_project("")), @"GitLab project name may not be empty"); + assert_snapshot!(assert_err!(validate_project(&"x".repeat(256))), @"GitLab project name is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_project("-")), @"Invalid GitLab project name"); + assert_snapshot!(assert_err!(validate_project("_")), @"Invalid GitLab project name"); + assert_snapshot!(assert_err!(validate_project("-start")), @"Invalid GitLab project name"); + assert_snapshot!(assert_err!(validate_project("end-")), @"Invalid GitLab project name"); + assert_snapshot!(assert_err!(validate_project("invalid/chars")), @"Invalid GitLab project name"); + assert_snapshot!(assert_err!(validate_project("foo.atom")), @"GitLab project name cannot end with .atom or .git"); + assert_snapshot!(assert_err!(validate_project("foo.git")), @"GitLab project name cannot end with .atom or .git"); + + assert_ok!(validate_project("a")); + assert_ok!(validate_project("foo")); + assert_ok!(validate_project("foo-bar")); + assert_ok!(validate_project("foo_bar")); + assert_ok!(validate_project("foo.bar")); + } + + #[test] + fn test_validate_workflow_filepath() { + assert_snapshot!(assert_err!(validate_workflow_filepath("")), @"Workflow filepath may not be empty"); + assert_snapshot!(assert_err!(validate_workflow_filepath(&"x".repeat(256))), @"Workflow filepath is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_workflow_filepath("/starts-with-slash.yml")), @"Workflow filepath cannot start with /"); + assert_snapshot!(assert_err!(validate_workflow_filepath("ends-with-slash/")), @"Workflow filepath cannot end with /"); + assert_snapshot!(assert_err!(validate_workflow_filepath("no-suffix")), @"Workflow filepath must end with `.yml` or `.yaml`"); + + assert_ok!(validate_workflow_filepath(".gitlab-ci.yml")); + assert_ok!(validate_workflow_filepath(".gitlab-ci.yaml")); + assert_ok!(validate_workflow_filepath("publish.yml")); + assert_ok!(validate_workflow_filepath(".gitlab/ci/publish.yml")); + assert_ok!(validate_workflow_filepath("ci/publish.yaml")); + } + + #[test] + fn test_validate_environment() { + assert_snapshot!(assert_err!(validate_environment("")), @"Environment name may not be empty (use `null` to omit)"); + assert_snapshot!(assert_err!(validate_environment(&"x".repeat(256))), @"Environment name is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_environment("invalid@chars")), @"Environment name contains invalid characters"); + assert_snapshot!(assert_err!(validate_environment("invalid.dot")), @"Environment name contains invalid characters"); + + assert_ok!(validate_environment("production")); + assert_ok!(validate_environment("staging")); + assert_ok!(validate_environment("prod-us-east")); + assert_ok!(validate_environment("env_name")); + assert_ok!(validate_environment("path/to/env")); + assert_ok!(validate_environment("with space")); + } +} From 92192614e8d077757941b23edbc32930e0ee88ce Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 24 Sep 2025 14:57:16 +0200 Subject: [PATCH 3/4] util/errors: Implement GitLab validation error to HTTP response conversion --- src/util/errors.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/util/errors.rs b/src/util/errors.rs index ad312d83c6..9957b67e07 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -238,6 +238,12 @@ impl From for BoxedAppE } } +impl From for BoxedAppError { + fn from(error: crates_io_trustpub::gitlab::validation::ValidationError) -> Self { + bad_request(error) + } +} + // ============================================================================= // Internal error for use with `chain_error` From 499e630b048d80dbc0b9658fbaf7db3a889c0f0f Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 24 Sep 2025 16:47:47 +0200 Subject: [PATCH 4/4] trustpub: Implement `POST /api/v1/trusted_publishing/gitlab_configs` API endpoint --- crates/crates_io_api_types/src/trustpub.rs | 37 ++ .../trustpub/gitlab_configs/create.rs | 148 ++++++ .../trustpub/gitlab_configs/json.rs | 21 + .../trustpub/gitlab_configs/mod.rs | 2 + src/controllers/trustpub/mod.rs | 1 + src/router.rs | 3 + .../routes/trustpub/gitlab_configs/create.rs | 481 ++++++++++++++++++ .../routes/trustpub/gitlab_configs/mod.rs | 1 + ..._gitlab_configs__create__happy_path-2.snap | 16 + ..._gitlab_configs__create__happy_path-3.snap | 23 + ...create__happy_path_with_environment-2.snap | 16 + ..._configs__create__legacy_token_auth-2.snap | 16 + ..._auth_with_trusted_publishing_scope-2.snap | 16 + ...oken_auth_with_wildcard_crate_scope-2.snap | 16 + src/tests/routes/trustpub/mod.rs | 1 + ...egration__openapi__openapi_snapshot-2.snap | 140 +++++ 16 files changed, 938 insertions(+) create mode 100644 src/controllers/trustpub/gitlab_configs/create.rs create mode 100644 src/controllers/trustpub/gitlab_configs/json.rs create mode 100644 src/controllers/trustpub/gitlab_configs/mod.rs create mode 100644 src/tests/routes/trustpub/gitlab_configs/create.rs create mode 100644 src/tests/routes/trustpub/gitlab_configs/mod.rs create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-2.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-3.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path_with_environment-2.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__legacy_token_auth-2.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_trusted_publishing_scope-2.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_wildcard_crate_scope-2.snap diff --git a/crates/crates_io_api_types/src/trustpub.rs b/crates/crates_io_api_types/src/trustpub.rs index 626de5e0f0..44d7cf90f7 100644 --- a/crates/crates_io_api_types/src/trustpub.rs +++ b/crates/crates_io_api_types/src/trustpub.rs @@ -37,3 +37,40 @@ pub struct NewGitHubConfig { #[schema(example = json!(null))] pub environment: Option, } + +#[derive(Debug, Serialize, utoipa::ToSchema)] +#[schema(as = GitLabConfig)] +pub struct GitLabConfig { + #[schema(example = 42)] + pub id: i32, + #[schema(example = "regex")] + #[serde(rename = "crate")] + pub krate: String, + #[schema(example = "rust-lang")] + pub namespace: String, + #[schema(example = json!(null))] + pub namespace_id: Option, + #[schema(example = "regex")] + pub project: String, + #[schema(example = ".gitlab-ci.yml")] + pub workflow_filepath: String, + #[schema(example = json!(null))] + pub environment: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +#[schema(as = NewGitLabConfig)] +pub struct NewGitLabConfig { + #[schema(example = "regex")] + #[serde(rename = "crate")] + pub krate: String, + #[schema(example = "rust-lang")] + pub namespace: String, + #[schema(example = "regex")] + pub project: String, + #[schema(example = ".gitlab-ci.yml")] + pub workflow_filepath: String, + #[schema(example = json!(null))] + pub environment: Option, +} diff --git a/src/controllers/trustpub/gitlab_configs/create.rs b/src/controllers/trustpub/gitlab_configs/create.rs new file mode 100644 index 0000000000..8e8f9ad10b --- /dev/null +++ b/src/controllers/trustpub/gitlab_configs/create.rs @@ -0,0 +1,148 @@ +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::krate::load_crate; +use crate::controllers::trustpub::emails::{ConfigCreatedEmail, ConfigType}; +use crate::controllers::trustpub::gitlab_configs::json; +use crate::util::errors::{AppResult, bad_request, custom, forbidden}; +use anyhow::Context; +use axum::Json; +use crates_io_database::models::OwnerKind; +use crates_io_database::models::token::EndpointScope; +use crates_io_database::models::trustpub::{GitLabConfig, NewGitLabConfig}; +use crates_io_database::schema::{crate_owners, emails, users}; +use crates_io_trustpub::gitlab::validation::{ + validate_environment, validate_namespace, validate_project, validate_workflow_filepath, +}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::request::Parts; +use tracing::warn; + +const MAX_CONFIGS_PER_CRATE: usize = 5; + +#[utoipa::path( + post, + path = "/api/v1/trusted_publishing/gitlab_configs", + security(("cookie" = []), ("api_token" = [])), + request_body = inline(json::CreateRequest), + tag = "trusted_publishing", + responses((status = 200, description = "Successful Response", body = inline(json::CreateResponse))), +)] +pub async fn create_trustpub_gitlab_config( + state: AppState, + parts: Parts, + json: json::CreateRequest, +) -> AppResult> { + let json_config = json.gitlab_config; + + validate_namespace(&json_config.namespace)?; + validate_project(&json_config.project)?; + validate_workflow_filepath(&json_config.workflow_filepath)?; + if let Some(env) = &json_config.environment { + validate_environment(env)?; + } + + let mut conn = state.db_write().await?; + + let auth = AuthCheck::default() + .with_endpoint_scope(EndpointScope::TrustedPublishing) + .for_crate(&json_config.krate) + .check(&parts, &mut conn) + .await?; + let auth_user = auth.user(); + + let krate = load_crate(&mut conn, &json_config.krate).await?; + + // Check if the crate has reached the maximum number of configs + let config_count = GitLabConfig::count_for_crate(&mut conn, krate.id).await?; + if config_count >= MAX_CONFIGS_PER_CRATE as i64 { + let message = format!( + "This crate already has the maximum number of GitLab Trusted Publishing configurations ({})", + MAX_CONFIGS_PER_CRATE + ); + return Err(custom(http::StatusCode::CONFLICT, message)); + } + + let user_owners = crate_owners::table + .filter(crate_owners::crate_id.eq(krate.id)) + .filter(crate_owners::deleted.eq(false)) + .filter(crate_owners::owner_kind.eq(OwnerKind::User)) + .inner_join(users::table) + .inner_join(emails::table.on(users::id.eq(emails::user_id))) + .select((users::id, users::gh_login, emails::email, emails::verified)) + .load::<(i32, String, String, bool)>(&mut conn) + .await?; + + let (_, _, _, email_verified) = user_owners + .iter() + .find(|(id, _, _, _)| *id == auth_user.id) + .ok_or_else(|| bad_request("You are not an owner of this crate"))?; + + if !email_verified { + let message = "You must verify your email address to create a Trusted Publishing config"; + return Err(forbidden(message)); + } + + // Save the new GitLab OIDC config to the database + + let new_config = NewGitLabConfig { + crate_id: krate.id, + namespace: &json_config.namespace, + project: &json_config.project, + workflow_filepath: &json_config.workflow_filepath, + environment: json_config.environment.as_deref(), + }; + + let saved_config = new_config.insert(&mut conn).await?; + + // Send notification emails to crate owners + + let recipients = user_owners + .into_iter() + .filter(|(_, _, _, verified)| *verified) + .map(|(_, login, email, _)| (login, email)) + .collect::>(); + + for (recipient, email_address) in &recipients { + let saved_config = ConfigType::GitLab(&saved_config); + + let context = ConfigCreatedEmail { + recipient, + auth_user, + krate: &krate, + saved_config, + }; + + if let Err(err) = send_notification_email(&state, email_address, context).await { + warn!("Failed to send trusted publishing notification to {email_address}: {err}"); + } + } + + let gitlab_config = json::GitLabConfig { + id: saved_config.id, + krate: krate.name, + namespace: saved_config.namespace, + namespace_id: saved_config.namespace_id, + project: saved_config.project, + workflow_filepath: saved_config.workflow_filepath, + environment: saved_config.environment, + created_at: saved_config.created_at, + }; + + Ok(Json(json::CreateResponse { gitlab_config })) +} + +async fn send_notification_email( + state: &AppState, + email_address: &str, + context: ConfigCreatedEmail<'_>, +) -> anyhow::Result<()> { + let email = context.render(); + let email = email.context("Failed to render email template")?; + + state + .emails + .send(email_address, email) + .await + .context("Failed to send email") +} diff --git a/src/controllers/trustpub/gitlab_configs/json.rs b/src/controllers/trustpub/gitlab_configs/json.rs new file mode 100644 index 0000000000..239506f0a4 --- /dev/null +++ b/src/controllers/trustpub/gitlab_configs/json.rs @@ -0,0 +1,21 @@ +use axum::Json; +use axum::extract::FromRequest; +use serde::{Deserialize, Serialize}; + +pub use crate::views::trustpub::{GitLabConfig, NewGitLabConfig}; + +#[derive(Debug, Deserialize, FromRequest, utoipa::ToSchema)] +#[from_request(via(Json))] +pub struct CreateRequest { + pub gitlab_config: NewGitLabConfig, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct CreateResponse { + pub gitlab_config: GitLabConfig, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ListResponse { + pub gitlab_configs: Vec, +} diff --git a/src/controllers/trustpub/gitlab_configs/mod.rs b/src/controllers/trustpub/gitlab_configs/mod.rs new file mode 100644 index 0000000000..7b1b719cbe --- /dev/null +++ b/src/controllers/trustpub/gitlab_configs/mod.rs @@ -0,0 +1,2 @@ +pub mod create; +pub mod json; diff --git a/src/controllers/trustpub/mod.rs b/src/controllers/trustpub/mod.rs index 461bd02043..9095014d36 100644 --- a/src/controllers/trustpub/mod.rs +++ b/src/controllers/trustpub/mod.rs @@ -1,3 +1,4 @@ pub mod emails; pub mod github_configs; +pub mod gitlab_configs; pub mod tokens; diff --git a/src/router.rs b/src/router.rs index 91d598f0cb..fc63a87550 100644 --- a/src/router.rs +++ b/src/router.rs @@ -98,6 +98,9 @@ pub fn build_axum_router(state: AppState) -> Router<()> { trustpub::github_configs::delete::delete_trustpub_github_config, trustpub::github_configs::list::list_trustpub_github_configs, )) + .routes(routes!( + trustpub::gitlab_configs::create::create_trustpub_gitlab_config, + )) .split_for_parts(); let mut router = router diff --git a/src/tests/routes/trustpub/gitlab_configs/create.rs b/src/tests/routes/trustpub/gitlab_configs/create.rs new file mode 100644 index 0000000000..ae0ec8320f --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/create.rs @@ -0,0 +1,481 @@ +use crate::builders::CrateBuilder; +use crate::util::{RequestHelper, Response, TestApp}; +use bytes::Bytes; +use crates_io_database::models::token::{CrateScope, EndpointScope}; +use crates_io_database::schema::{emails, trustpub_configs_gitlab}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use insta::{assert_json_snapshot, assert_snapshot}; +use serde_json::json; + +const URL: &str = "/api/v1/trusted_publishing/gitlab_configs"; + +const CRATE_NAME: &str = "foo"; + +async fn run_test(payload: impl Into) -> (TestApp, Response<()>) { + async fn inner(payload: Bytes) -> (TestApp, Response<()>) { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await + .unwrap(); + + (app, cookie_client.post::<()>(URL, payload).await) + } + + inner(payload.into()).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let (app, response) = run_test(body).await; + assert_snapshot!(response.status(), @"200 OK"); + assert_json_snapshot!(response.json(), { ".gitlab_config.created_at" => "[datetime]" }); + + assert_snapshot!(app.emails_snapshot().await); + + let mut conn = app.db_conn().await; + let config_ids = trustpub_configs_gitlab::table + .select(trustpub_configs_gitlab::id) + .get_results::(&mut conn) + .await?; + + assert_eq!(config_ids.len(), 1); + assert_eq!(config_ids[0], 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_environment() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": "production", + } + }))?; + + let (_app, response) = run_test(body).await; + assert_snapshot!(response.status(), @"200 OK"); + assert_json_snapshot!(response.json(), { ".gitlab_config.created_at" => "[datetime]" }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_empty_body() -> anyhow::Result<()> { + let (_app, response) = run_test("").await; + assert_snapshot!(response.status(), @"415 Unsupported Media Type"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Expected request with `Content-Type: application/json`"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_empty_json_object() -> anyhow::Result<()> { + let (_app, response) = run_test("{}").await; + assert_snapshot!(response.status(), @"422 Unprocessable Entity"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize the JSON body into the target type: missing field `gitlab_config` at line 1 column 2"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_namespace() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "ยง$%&", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let (_app, response) = run_test(body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid GitLab namespace"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_project() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "@foo", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let (_app, response) = run_test(body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid GitLab project name"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_workflow_filepath() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": "ci.json", + "environment": null, + } + }))?; + + let (_app, response) = run_test(body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Workflow filepath must end with `.yml` or `.yaml`"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_environment() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": "", + } + }))?; + + let (_app, response) = run_test(body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Environment name may not be empty (use `null` to omit)"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unauthenticated() -> anyhow::Result<()> { + let (app, client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_legacy_token_auth() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full().with_token().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = token_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + assert_json_snapshot!(response.json(), { ".gitlab_config.created_at" => "[datetime]" }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_with_trusted_publishing_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + ) + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = token_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + assert_json_snapshot!(response.json(), { ".gitlab_config.created_at" => "[datetime]" }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_without_trusted_publishing_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]), + Some(vec![EndpointScope::PublishUpdate]), + ) + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = token_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_with_wrong_crate_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from("other-crate").unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + ) + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = token_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_with_wildcard_crate_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from("*").unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + ) + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = token_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + assert_json_snapshot!(response.json(), { ".gitlab_config.created_at" => "[datetime]" }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_crate() -> anyhow::Result<()> { + let (_app, _client, cookie_client) = TestApp::full().with_user().await; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = cookie_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"404 Not Found"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"crate `foo` does not exist"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_non_owner() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let other_client = app.db_new_user("other_user").await; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = other_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unverified_email() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + diesel::update(emails::table.filter(emails::user_id.eq(cookie_client.as_model().id))) + .set(emails::verified.eq(false)) + .execute(&mut conn) + .await?; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = cookie_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You must verify your email address to create a Trusted Publishing config"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_too_many_configs() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + // Create 5 configurations (the maximum) + for i in 0..5 { + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": format!("foo-rs-{}", i), + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = cookie_client.post::<()>(URL, body).await; + assert_eq!(response.status(), 200); + } + + // Try to create a 6th configuration + let body = serde_json::to_vec(&json!({ + "gitlab_config": { + "crate": CRATE_NAME, + "namespace": "rust-lang", + "project": "foo-rs-6", + "workflow_filepath": ".gitlab-ci.yml", + "environment": null, + } + }))?; + + let response = cookie_client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"409 Conflict"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"This crate already has the maximum number of GitLab Trusted Publishing configurations (5)"}]}"#); + + Ok(()) +} diff --git a/src/tests/routes/trustpub/gitlab_configs/mod.rs b/src/tests/routes/trustpub/gitlab_configs/mod.rs new file mode 100644 index 0000000000..0f562a43b2 --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/mod.rs @@ -0,0 +1 @@ +mod create; diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-2.snap new file mode 100644 index 0000000000..6d5d57439f --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-2.snap @@ -0,0 +1,16 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/create.rs +expression: response.json() +--- +{ + "gitlab_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 1, + "namespace": "rust-lang", + "namespace_id": null, + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml" + } +} diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-3.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-3.snap new file mode 100644 index 0000000000..1d763d2dc0 --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path-3.snap @@ -0,0 +1,23 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/create.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configuration added to foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +You added a new "Trusted Publishing" configuration for GitLab CI to your crate "foo". Trusted publishers act as trusted users and can publish new versions of the crate automatically. + +This configuration allows the workflow file at https://gitlab.com/rust-lang/foo-rs/-/blob/HEAD/.gitlab-ci.yml to publish new versions of this crate. + +If you did not make this change and you think it was made maliciously, you can remove the configuration from the crate via the "Settings" tab on the crate's page. + +If you are unable to revert the change and need to do so, you can email help@crates.io for assistance. + +-- +The crates.io Team diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path_with_environment-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path_with_environment-2.snap new file mode 100644 index 0000000000..f01ddaf61e --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__happy_path_with_environment-2.snap @@ -0,0 +1,16 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/create.rs +expression: response.json() +--- +{ + "gitlab_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": "production", + "id": 1, + "namespace": "rust-lang", + "namespace_id": null, + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml" + } +} diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__legacy_token_auth-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__legacy_token_auth-2.snap new file mode 100644 index 0000000000..6d5d57439f --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__legacy_token_auth-2.snap @@ -0,0 +1,16 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/create.rs +expression: response.json() +--- +{ + "gitlab_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 1, + "namespace": "rust-lang", + "namespace_id": null, + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml" + } +} diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_trusted_publishing_scope-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_trusted_publishing_scope-2.snap new file mode 100644 index 0000000000..6d5d57439f --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_trusted_publishing_scope-2.snap @@ -0,0 +1,16 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/create.rs +expression: response.json() +--- +{ + "gitlab_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 1, + "namespace": "rust-lang", + "namespace_id": null, + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml" + } +} diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_wildcard_crate_scope-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_wildcard_crate_scope-2.snap new file mode 100644 index 0000000000..6d5d57439f --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__create__token_auth_with_wildcard_crate_scope-2.snap @@ -0,0 +1,16 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/create.rs +expression: response.json() +--- +{ + "gitlab_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 1, + "namespace": "rust-lang", + "namespace_id": null, + "project": "foo-rs", + "workflow_filepath": ".gitlab-ci.yml" + } +} diff --git a/src/tests/routes/trustpub/mod.rs b/src/tests/routes/trustpub/mod.rs index 579b8ed584..c512a36f52 100644 --- a/src/tests/routes/trustpub/mod.rs +++ b/src/tests/routes/trustpub/mod.rs @@ -1,2 +1,3 @@ mod github_configs; +mod gitlab_configs; mod tokens; diff --git a/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap b/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap index 4848238b29..4b013cd257 100644 --- a/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap +++ b/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap @@ -650,6 +650,58 @@ expression: response.json() ], "type": "object" }, + "GitLabConfig": { + "properties": { + "crate": { + "example": "regex", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "environment": { + "example": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "example": 42, + "format": "int32", + "type": "integer" + }, + "namespace": { + "example": "rust-lang", + "type": "string" + }, + "namespace_id": { + "example": null, + "type": [ + "string", + "null" + ] + }, + "project": { + "example": "regex", + "type": "string" + }, + "workflow_filepath": { + "example": ".gitlab-ci.yml", + "type": "string" + } + }, + "required": [ + "id", + "crate", + "namespace", + "project", + "workflow_filepath", + "created_at" + ], + "type": "object" + }, "Keyword": { "properties": { "crates_cnt": { @@ -771,6 +823,40 @@ expression: response.json() ], "type": "object" }, + "NewGitLabConfig": { + "properties": { + "crate": { + "example": "regex", + "type": "string" + }, + "environment": { + "example": null, + "type": [ + "string", + "null" + ] + }, + "namespace": { + "example": "rust-lang", + "type": "string" + }, + "project": { + "example": "regex", + "type": "string" + }, + "workflow_filepath": { + "example": ".gitlab-ci.yml", + "type": "string" + } + }, + "required": [ + "crate", + "namespace", + "project", + "workflow_filepath" + ], + "type": "object" + }, "Owner": { "properties": { "avatar": { @@ -4392,6 +4478,60 @@ expression: response.json() ] } }, + "/api/v1/trusted_publishing/gitlab_configs": { + "post": { + "operationId": "create_trustpub_gitlab_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "gitlab_config": { + "$ref": "#/components/schemas/NewGitLabConfig" + } + }, + "required": [ + "gitlab_config" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "gitlab_config": { + "$ref": "#/components/schemas/GitLabConfig" + } + }, + "required": [ + "gitlab_config" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + }, + { + "api_token": [] + } + ], + "tags": [ + "trusted_publishing" + ] + } + }, "/api/v1/trusted_publishing/tokens": { "delete": { "description": "The access token is expected to be passed in the `Authorization` header\nas a `Bearer` token, similar to how it is used in the publish endpoint.",