Skip to content

Add GET /api/v1/trusted_publishing/github_configs API endpoint #11230

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/controllers/trustpub/github_configs/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHubConfig>,
}
85 changes: 85 additions & 0 deletions src/controllers/trustpub/github_configs/list/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Json<ListResponse>> {
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, &params.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::<bool>(&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::<GitHubConfig>(&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 }))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: src/controllers/trustpub/github_configs/list/tests.rs
expression: response.json()
---
{
"github_configs": []
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
153 changes: 153 additions & 0 deletions src/controllers/trustpub/github_configs/list/tests.rs
Original file line number Diff line number Diff line change
@@ -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<GitHubConfig> {
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(())
}
1 change: 1 addition & 0 deletions src/controllers/trustpub/github_configs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pub mod create;
pub mod delete;
pub mod emails;
pub mod json;
pub mod list;
1 change: 1 addition & 0 deletions src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
46 changes: 46 additions & 0 deletions src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down