diff --git a/src/controllers/trustpub/github_configs/json.rs b/src/controllers/trustpub/github_configs/json.rs index 4d44e303bc0..8fb0870fd40 100644 --- a/src/controllers/trustpub/github_configs/json.rs +++ b/src/controllers/trustpub/github_configs/json.rs @@ -47,3 +47,8 @@ pub struct CreateRequest { pub struct CreateResponse { pub github_config: GitHubConfig, } + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ListResponse { + pub github_configs: Vec, +} diff --git a/src/controllers/trustpub/github_configs/list/mod.rs b/src/controllers/trustpub/github_configs/list/mod.rs new file mode 100644 index 00000000000..59c08934fcd --- /dev/null +++ b/src/controllers/trustpub/github_configs/list/mod.rs @@ -0,0 +1,85 @@ +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::krate::load_crate; +use crate::controllers::trustpub::github_configs::json::{self, ListResponse}; +use crate::util::errors::{AppResult, bad_request}; +use axum::Json; +use axum::extract::{FromRequestParts, Query}; +use crates_io_database::models::OwnerKind; +use crates_io_database::models::trustpub::GitHubConfig; +use crates_io_database::schema::{crate_owners, trustpub_configs_github}; +use diesel::dsl::{exists, select}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::request::Parts; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)] +#[from_request(via(Query))] +#[into_params(parameter_in = Query)] +pub struct ListQueryParams { + /// Name of the crate to list Trusted Publishing configurations for. + #[serde(rename = "crate")] + pub krate: String, +} + +/// List Trusted Publishing configurations for GitHub Actions. +#[utoipa::path( + get, + path = "/api/v1/trusted_publishing/github_configs", + params(ListQueryParams), + security(("cookie" = [])), + tag = "trusted_publishing", + responses((status = 200, description = "Successful Response", body = inline(ListResponse))), +)] +pub async fn list_trustpub_github_configs( + state: AppState, + params: ListQueryParams, + parts: Parts, +) -> AppResult> { + let mut conn = state.db_read().await?; + + let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; + let auth_user = auth.user(); + + let krate = load_crate(&mut conn, ¶ms.krate).await?; + + // Check if the authenticated user is an owner of the crate + let is_owner = select(exists( + 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)) + .filter(crate_owners::owner_id.eq(auth_user.id)), + )) + .get_result::(&mut conn) + .await?; + + if !is_owner { + return Err(bad_request("You are not an owner of this crate")); + } + + let configs = trustpub_configs_github::table + .filter(trustpub_configs_github::crate_id.eq(krate.id)) + .select(GitHubConfig::as_select()) + .load::(&mut conn) + .await?; + + let github_configs = configs + .into_iter() + .map(|config| json::GitHubConfig { + id: config.id, + krate: krate.name.clone(), + repository_owner: config.repository_owner, + repository_owner_id: config.repository_owner_id, + repository_name: config.repository_name, + workflow_filename: config.workflow_filename, + environment: config.environment, + created_at: config.created_at, + }) + .collect(); + + Ok(Json(ListResponse { github_configs })) +} diff --git a/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__crate_with_no_configs.snap b/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__crate_with_no_configs.snap new file mode 100644 index 00000000000..9f426eb063d --- /dev/null +++ b/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__crate_with_no_configs.snap @@ -0,0 +1,7 @@ +--- +source: src/controllers/trustpub/github_configs/list/tests.rs +expression: response.json() +--- +{ + "github_configs": [] +} diff --git a/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__happy_path-2.snap b/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__happy_path-2.snap new file mode 100644 index 00000000000..494dfefd992 --- /dev/null +++ b/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__happy_path-2.snap @@ -0,0 +1,18 @@ +--- +source: src/controllers/trustpub/github_configs/list/tests.rs +expression: response.json() +--- +{ + "github_configs": [ + { + "crate": "bar", + "created_at": "[datetime]", + "environment": null, + "id": 3, + "repository_name": "BAR", + "repository_owner": "rust-lang", + "repository_owner_id": 42, + "workflow_filename": "publish.yml" + } + ] +} diff --git a/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__happy_path.snap b/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__happy_path.snap new file mode 100644 index 00000000000..755072a4dde --- /dev/null +++ b/src/controllers/trustpub/github_configs/list/snapshots/crates_io__controllers__trustpub__github_configs__list__tests__happy_path.snap @@ -0,0 +1,28 @@ +--- +source: src/controllers/trustpub/github_configs/list/tests.rs +expression: response.json() +--- +{ + "github_configs": [ + { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 1, + "repository_name": "foo-rs", + "repository_owner": "rust-lang", + "repository_owner_id": 42, + "workflow_filename": "publish.yml" + }, + { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 2, + "repository_name": "foo", + "repository_owner": "rust-lang", + "repository_owner_id": 42, + "workflow_filename": "publish.yml" + } + ] +} diff --git a/src/controllers/trustpub/github_configs/list/tests.rs b/src/controllers/trustpub/github_configs/list/tests.rs new file mode 100644 index 00000000000..7f01d4c657b --- /dev/null +++ b/src/controllers/trustpub/github_configs/list/tests.rs @@ -0,0 +1,153 @@ +use crate::tests::builders::CrateBuilder; +use crate::tests::util::{RequestHelper, TestApp}; +use crates_io_database::models::trustpub::{GitHubConfig, NewGitHubConfig}; +use diesel::prelude::*; +use diesel_async::AsyncPgConnection; +use http::StatusCode; +use insta::{assert_json_snapshot, assert_snapshot}; +use serde_json::json; + +const URL: &str = "/api/v1/trusted_publishing/github_configs"; + +async fn create_config( + conn: &mut AsyncPgConnection, + crate_id: i32, + repository_name: &str, +) -> QueryResult { + let config = NewGitHubConfig { + crate_id, + repository_owner: "rust-lang", + repository_owner_id: 42, + repository_name, + workflow_filename: "publish.yml", + environment: None, + }; + + config.insert(conn).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let foo = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + let bar = CrateBuilder::new("bar", owner_id).build(&mut conn).await?; + + create_config(&mut conn, foo.id, "foo-rs").await?; + create_config(&mut conn, foo.id, "foo").await?; + create_config(&mut conn, bar.id, "BAR").await?; + + let response = cookie_client.get_with_query::<()>(URL, "crate=foo").await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { + ".github_configs[].created_at" => "[datetime]", + }); + + let response = cookie_client.get_with_query::<()>(URL, "crate=Bar").await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { + ".github_configs[].created_at" => "[datetime]", + }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unauthorized() -> anyhow::Result<()> { + let (app, anon_client, cookie_client) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + create_config(&mut conn, krate.id, "foo-rs").await?; + + let response = anon_client.get_with_query::<()>(URL, "crate=foo").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_not_owner() -> anyhow::Result<()> { + let (app, _, cookie_client) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create a different user who will be the owner of the crate + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + create_config(&mut conn, krate.id, "foo-rs").await?; + + // The authenticated user is not an owner of the crate + let other_user = app.db_new_user("other").await; + let response = other_user.get_with_query::<()>(URL, "crate=foo").await; + assert_eq!(response.status(), StatusCode::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_team_owner() -> anyhow::Result<()> { + let (app, _) = TestApp::full().empty().await; + let mut conn = app.db_conn().await; + + let user = app.db_new_user("user-org-owner").await; + let user2 = app.db_new_user("user-one-team").await; + + let owner_id = user.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + create_config(&mut conn, krate.id, "foo-rs").await?; + + let body = json!({ "owners": ["github:test-org:all"] }).to_string(); + let response = user.put::<()>("/api/v1/crates/foo/owners", body).await; + assert_eq!(response.status(), StatusCode::OK); + + let response = user2.get_with_query::<()>(URL, "crate=foo").await; + assert_eq!(response.status(), StatusCode::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_crate_not_found() -> anyhow::Result<()> { + let (_, _, cookie_client) = TestApp::full().with_user().await; + + let response = cookie_client.get_with_query::<()>(URL, "crate=foo").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"crate `foo` does not exist"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_no_query_param() -> anyhow::Result<()> { + let (_, _, cookie_client) = TestApp::full().with_user().await; + + let response = cookie_client.get::<()>(URL).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize query string: missing field `crate`"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_crate_with_no_configs() -> anyhow::Result<()> { + let (app, _, cookie_client) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + // No configs have been created for this crate + let response = cookie_client.get_with_query::<()>(URL, "crate=foo").await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { + ".github_configs[].created_at" => "[datetime]", + }); + + Ok(()) +} diff --git a/src/controllers/trustpub/github_configs/mod.rs b/src/controllers/trustpub/github_configs/mod.rs index b74d456a4b5..8f5f21e0fc7 100644 --- a/src/controllers/trustpub/github_configs/mod.rs +++ b/src/controllers/trustpub/github_configs/mod.rs @@ -2,3 +2,4 @@ pub mod create; pub mod delete; pub mod emails; pub mod json; +pub mod list; diff --git a/src/router.rs b/src/router.rs index aacaffbbcbb..68e3fdb8f88 100644 --- a/src/router.rs +++ b/src/router.rs @@ -92,6 +92,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!( trustpub::github_configs::create::create_trustpub_github_config, trustpub::github_configs::delete::delete_trustpub_github_config, + trustpub::github_configs::list::list_trustpub_github_configs, )) .split_for_parts(); diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 9729e1cac0d..ce1fa354bba 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -4173,6 +4173,52 @@ expression: response.json() } }, "/api/v1/trusted_publishing/github_configs": { + "get": { + "operationId": "list_trustpub_github_configs", + "parameters": [ + { + "description": "Name of the crate to list Trusted Publishing configurations for.", + "in": "query", + "name": "crate", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "github_configs": { + "items": { + "$ref": "#/components/schemas/GitHubConfig" + }, + "type": "array" + } + }, + "required": [ + "github_configs" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "List Trusted Publishing configurations for GitHub Actions.", + "tags": [ + "trusted_publishing" + ] + }, "put": { "operationId": "create_trustpub_github_config", "requestBody": {