Skip to content

Commit 86a2b20

Browse files
authored
Add GET /api/v1/trusted_publishing/github_configs API endpoint (#11230)
1 parent b98df52 commit 86a2b20

9 files changed

+344
-0
lines changed

src/controllers/trustpub/github_configs/json.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,8 @@ pub struct CreateRequest {
4747
pub struct CreateResponse {
4848
pub github_config: GitHubConfig,
4949
}
50+
51+
#[derive(Debug, Serialize, utoipa::ToSchema)]
52+
pub struct ListResponse {
53+
pub github_configs: Vec<GitHubConfig>,
54+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
use crate::app::AppState;
2+
use crate::auth::AuthCheck;
3+
use crate::controllers::krate::load_crate;
4+
use crate::controllers::trustpub::github_configs::json::{self, ListResponse};
5+
use crate::util::errors::{AppResult, bad_request};
6+
use axum::Json;
7+
use axum::extract::{FromRequestParts, Query};
8+
use crates_io_database::models::OwnerKind;
9+
use crates_io_database::models::trustpub::GitHubConfig;
10+
use crates_io_database::schema::{crate_owners, trustpub_configs_github};
11+
use diesel::dsl::{exists, select};
12+
use diesel::prelude::*;
13+
use diesel_async::RunQueryDsl;
14+
use http::request::Parts;
15+
16+
#[cfg(test)]
17+
mod tests;
18+
19+
#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)]
20+
#[from_request(via(Query))]
21+
#[into_params(parameter_in = Query)]
22+
pub struct ListQueryParams {
23+
/// Name of the crate to list Trusted Publishing configurations for.
24+
#[serde(rename = "crate")]
25+
pub krate: String,
26+
}
27+
28+
/// List Trusted Publishing configurations for GitHub Actions.
29+
#[utoipa::path(
30+
get,
31+
path = "/api/v1/trusted_publishing/github_configs",
32+
params(ListQueryParams),
33+
security(("cookie" = [])),
34+
tag = "trusted_publishing",
35+
responses((status = 200, description = "Successful Response", body = inline(ListResponse))),
36+
)]
37+
pub async fn list_trustpub_github_configs(
38+
state: AppState,
39+
params: ListQueryParams,
40+
parts: Parts,
41+
) -> AppResult<Json<ListResponse>> {
42+
let mut conn = state.db_read().await?;
43+
44+
let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?;
45+
let auth_user = auth.user();
46+
47+
let krate = load_crate(&mut conn, &params.krate).await?;
48+
49+
// Check if the authenticated user is an owner of the crate
50+
let is_owner = select(exists(
51+
crate_owners::table
52+
.filter(crate_owners::crate_id.eq(krate.id))
53+
.filter(crate_owners::deleted.eq(false))
54+
.filter(crate_owners::owner_kind.eq(OwnerKind::User))
55+
.filter(crate_owners::owner_id.eq(auth_user.id)),
56+
))
57+
.get_result::<bool>(&mut conn)
58+
.await?;
59+
60+
if !is_owner {
61+
return Err(bad_request("You are not an owner of this crate"));
62+
}
63+
64+
let configs = trustpub_configs_github::table
65+
.filter(trustpub_configs_github::crate_id.eq(krate.id))
66+
.select(GitHubConfig::as_select())
67+
.load::<GitHubConfig>(&mut conn)
68+
.await?;
69+
70+
let github_configs = configs
71+
.into_iter()
72+
.map(|config| json::GitHubConfig {
73+
id: config.id,
74+
krate: krate.name.clone(),
75+
repository_owner: config.repository_owner,
76+
repository_owner_id: config.repository_owner_id,
77+
repository_name: config.repository_name,
78+
workflow_filename: config.workflow_filename,
79+
environment: config.environment,
80+
created_at: config.created_at,
81+
})
82+
.collect();
83+
84+
Ok(Json(ListResponse { github_configs }))
85+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: src/controllers/trustpub/github_configs/list/tests.rs
3+
expression: response.json()
4+
---
5+
{
6+
"github_configs": []
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
source: src/controllers/trustpub/github_configs/list/tests.rs
3+
expression: response.json()
4+
---
5+
{
6+
"github_configs": [
7+
{
8+
"crate": "bar",
9+
"created_at": "[datetime]",
10+
"environment": null,
11+
"id": 3,
12+
"repository_name": "BAR",
13+
"repository_owner": "rust-lang",
14+
"repository_owner_id": 42,
15+
"workflow_filename": "publish.yml"
16+
}
17+
]
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
source: src/controllers/trustpub/github_configs/list/tests.rs
3+
expression: response.json()
4+
---
5+
{
6+
"github_configs": [
7+
{
8+
"crate": "foo",
9+
"created_at": "[datetime]",
10+
"environment": null,
11+
"id": 1,
12+
"repository_name": "foo-rs",
13+
"repository_owner": "rust-lang",
14+
"repository_owner_id": 42,
15+
"workflow_filename": "publish.yml"
16+
},
17+
{
18+
"crate": "foo",
19+
"created_at": "[datetime]",
20+
"environment": null,
21+
"id": 2,
22+
"repository_name": "foo",
23+
"repository_owner": "rust-lang",
24+
"repository_owner_id": 42,
25+
"workflow_filename": "publish.yml"
26+
}
27+
]
28+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
use crate::tests::builders::CrateBuilder;
2+
use crate::tests::util::{RequestHelper, TestApp};
3+
use crates_io_database::models::trustpub::{GitHubConfig, NewGitHubConfig};
4+
use diesel::prelude::*;
5+
use diesel_async::AsyncPgConnection;
6+
use http::StatusCode;
7+
use insta::{assert_json_snapshot, assert_snapshot};
8+
use serde_json::json;
9+
10+
const URL: &str = "/api/v1/trusted_publishing/github_configs";
11+
12+
async fn create_config(
13+
conn: &mut AsyncPgConnection,
14+
crate_id: i32,
15+
repository_name: &str,
16+
) -> QueryResult<GitHubConfig> {
17+
let config = NewGitHubConfig {
18+
crate_id,
19+
repository_owner: "rust-lang",
20+
repository_owner_id: 42,
21+
repository_name,
22+
workflow_filename: "publish.yml",
23+
environment: None,
24+
};
25+
26+
config.insert(conn).await
27+
}
28+
29+
#[tokio::test(flavor = "multi_thread")]
30+
async fn test_happy_path() -> anyhow::Result<()> {
31+
let (app, _client, cookie_client) = TestApp::full().with_user().await;
32+
let mut conn = app.db_conn().await;
33+
34+
let owner_id = cookie_client.as_model().id;
35+
let foo = CrateBuilder::new("foo", owner_id).build(&mut conn).await?;
36+
let bar = CrateBuilder::new("bar", owner_id).build(&mut conn).await?;
37+
38+
create_config(&mut conn, foo.id, "foo-rs").await?;
39+
create_config(&mut conn, foo.id, "foo").await?;
40+
create_config(&mut conn, bar.id, "BAR").await?;
41+
42+
let response = cookie_client.get_with_query::<()>(URL, "crate=foo").await;
43+
assert_eq!(response.status(), StatusCode::OK);
44+
assert_json_snapshot!(response.json(), {
45+
".github_configs[].created_at" => "[datetime]",
46+
});
47+
48+
let response = cookie_client.get_with_query::<()>(URL, "crate=Bar").await;
49+
assert_eq!(response.status(), StatusCode::OK);
50+
assert_json_snapshot!(response.json(), {
51+
".github_configs[].created_at" => "[datetime]",
52+
});
53+
54+
Ok(())
55+
}
56+
57+
#[tokio::test(flavor = "multi_thread")]
58+
async fn test_unauthorized() -> anyhow::Result<()> {
59+
let (app, anon_client, cookie_client) = TestApp::full().with_user().await;
60+
let mut conn = app.db_conn().await;
61+
62+
let owner_id = cookie_client.as_model().id;
63+
let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?;
64+
create_config(&mut conn, krate.id, "foo-rs").await?;
65+
66+
let response = anon_client.get_with_query::<()>(URL, "crate=foo").await;
67+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
68+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
69+
70+
Ok(())
71+
}
72+
73+
#[tokio::test(flavor = "multi_thread")]
74+
async fn test_not_owner() -> anyhow::Result<()> {
75+
let (app, _, cookie_client) = TestApp::full().with_user().await;
76+
let mut conn = app.db_conn().await;
77+
78+
// Create a different user who will be the owner of the crate
79+
let owner_id = cookie_client.as_model().id;
80+
let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?;
81+
create_config(&mut conn, krate.id, "foo-rs").await?;
82+
83+
// The authenticated user is not an owner of the crate
84+
let other_user = app.db_new_user("other").await;
85+
let response = other_user.get_with_query::<()>(URL, "crate=foo").await;
86+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
87+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#);
88+
89+
Ok(())
90+
}
91+
92+
#[tokio::test(flavor = "multi_thread")]
93+
async fn test_team_owner() -> anyhow::Result<()> {
94+
let (app, _) = TestApp::full().empty().await;
95+
let mut conn = app.db_conn().await;
96+
97+
let user = app.db_new_user("user-org-owner").await;
98+
let user2 = app.db_new_user("user-one-team").await;
99+
100+
let owner_id = user.as_model().id;
101+
let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?;
102+
create_config(&mut conn, krate.id, "foo-rs").await?;
103+
104+
let body = json!({ "owners": ["github:test-org:all"] }).to_string();
105+
let response = user.put::<()>("/api/v1/crates/foo/owners", body).await;
106+
assert_eq!(response.status(), StatusCode::OK);
107+
108+
let response = user2.get_with_query::<()>(URL, "crate=foo").await;
109+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
110+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#);
111+
112+
Ok(())
113+
}
114+
115+
#[tokio::test(flavor = "multi_thread")]
116+
async fn test_crate_not_found() -> anyhow::Result<()> {
117+
let (_, _, cookie_client) = TestApp::full().with_user().await;
118+
119+
let response = cookie_client.get_with_query::<()>(URL, "crate=foo").await;
120+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
121+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"crate `foo` does not exist"}]}"#);
122+
123+
Ok(())
124+
}
125+
126+
#[tokio::test(flavor = "multi_thread")]
127+
async fn test_no_query_param() -> anyhow::Result<()> {
128+
let (_, _, cookie_client) = TestApp::full().with_user().await;
129+
130+
let response = cookie_client.get::<()>(URL).await;
131+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
132+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize query string: missing field `crate`"}]}"#);
133+
134+
Ok(())
135+
}
136+
137+
#[tokio::test(flavor = "multi_thread")]
138+
async fn test_crate_with_no_configs() -> anyhow::Result<()> {
139+
let (app, _, cookie_client) = TestApp::full().with_user().await;
140+
let mut conn = app.db_conn().await;
141+
142+
let owner_id = cookie_client.as_model().id;
143+
CrateBuilder::new("foo", owner_id).build(&mut conn).await?;
144+
145+
// No configs have been created for this crate
146+
let response = cookie_client.get_with_query::<()>(URL, "crate=foo").await;
147+
assert_eq!(response.status(), StatusCode::OK);
148+
assert_json_snapshot!(response.json(), {
149+
".github_configs[].created_at" => "[datetime]",
150+
});
151+
152+
Ok(())
153+
}

src/controllers/trustpub/github_configs/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ pub mod create;
22
pub mod delete;
33
pub mod emails;
44
pub mod json;
5+
pub mod list;

src/router.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
9292
.routes(routes!(
9393
trustpub::github_configs::create::create_trustpub_github_config,
9494
trustpub::github_configs::delete::delete_trustpub_github_config,
95+
trustpub::github_configs::list::list_trustpub_github_configs,
9596
))
9697
.split_for_parts();
9798

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4173,6 +4173,52 @@ expression: response.json()
41734173
}
41744174
},
41754175
"/api/v1/trusted_publishing/github_configs": {
4176+
"get": {
4177+
"operationId": "list_trustpub_github_configs",
4178+
"parameters": [
4179+
{
4180+
"description": "Name of the crate to list Trusted Publishing configurations for.",
4181+
"in": "query",
4182+
"name": "crate",
4183+
"required": true,
4184+
"schema": {
4185+
"type": "string"
4186+
}
4187+
}
4188+
],
4189+
"responses": {
4190+
"200": {
4191+
"content": {
4192+
"application/json": {
4193+
"schema": {
4194+
"properties": {
4195+
"github_configs": {
4196+
"items": {
4197+
"$ref": "#/components/schemas/GitHubConfig"
4198+
},
4199+
"type": "array"
4200+
}
4201+
},
4202+
"required": [
4203+
"github_configs"
4204+
],
4205+
"type": "object"
4206+
}
4207+
}
4208+
},
4209+
"description": "Successful Response"
4210+
}
4211+
},
4212+
"security": [
4213+
{
4214+
"cookie": []
4215+
}
4216+
],
4217+
"summary": "List Trusted Publishing configurations for GitHub Actions.",
4218+
"tags": [
4219+
"trusted_publishing"
4220+
]
4221+
},
41764222
"put": {
41774223
"operationId": "create_trustpub_github_config",
41784224
"requestBody": {

0 commit comments

Comments
 (0)