//! Commit status routes of the token API (documented in docs/api.md). //! //! `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}` records one status and answers 201 with it. //! `GET /api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` answers the combined state and the //! status of every context. use forge_core::Action; use forge_core::store::commit_status::{CommitStatus, DEFAULT_CONTEXT, StatusState, combine}; use serde::Deserialize; use serde_json::{Value, json}; use topcoat::context::{Cx, app_context}; use topcoat::router::request::headers; use topcoat::router::response::Response; use topcoat::router::{Body, StatusCode, path_param, route}; use crate::api; use crate::client_ip::client_ip; use crate::state::AppState; path_param!(owner); path_param!(repo); path_param!(sha); /// Largest accepted report body, in bytes. const MAX_BODY_BYTES: usize = 16 * 1024; /// Body of `POST .../statuses/{sha}`. `context` defaults to `default`. #[derive(Deserialize)] struct ReportBody { state: String, context: Option, description: Option, target_url: Option, } #[route(POST "/api/v1/repos/{owner}/{repo}/statuses/{sha}")] async fn report_status(cx: &Cx, body: Body) -> topcoat::Result { let state = app_context::(cx); let actor = match api::authenticate(state, client_ip(cx), headers(cx)).await { Ok(actor) => actor, Err(res) => return Ok(*res), }; let repo = match api::load_repo( state, actor.as_ref(), path_param::(cx), path_param::(cx), Action::Write, ) .await { Ok(repo) => repo, Err(res) => return Ok(*res), }; let Some(user) = actor else { return Ok(api::unauthorized()); }; let Some(sha) = api::normalize_sha(path_param::(cx)) else { return Ok(not_a_full_sha()); }; let bytes = match api::read_body(body, MAX_BODY_BYTES).await { Ok(bytes) => bytes, Err(res) => return Ok(*res), }; let report: ReportBody = match serde_json::from_slice(&bytes) { Ok(report) => report, Err(e) => { return Ok(api::error( StatusCode::BAD_REQUEST, &format!("invalid JSON body: {e}"), )); } }; let Some(status_state) = StatusState::parse(&report.state) else { return Ok(api::error( StatusCode::UNPROCESSABLE_ENTITY, "state must be one of pending, success, failure, error", )); }; let path = match api::repo_dir(state, &repo) { Ok(path) => path, Err(res) => return Ok(*res), }; match api::commit_exists(path, sha.clone()).await { Ok(true) => {} Ok(false) => return Ok(commit_not_found()), Err(res) => return Ok(*res), } let context = report.context.unwrap_or_else(|| DEFAULT_CONTEXT.to_owned()); let saved = match state .store .set_status( &repo.id, &sha, &context, status_state, report.description.as_deref(), report.target_url.as_deref(), &user.id, ) .await { Ok(saved) => saved, Err(forge_core::Error::Invalid(message)) => { return Ok(api::error(StatusCode::UNPROCESSABLE_ENTITY, &message)); } Err(e) => return Ok(*api::internal(e)), }; Ok(api::json(StatusCode::CREATED, &status_json(&saved))) } #[route(GET "/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses")] async fn list_statuses(cx: &Cx) -> topcoat::Result { let state = app_context::(cx); let actor = match api::authenticate(state, client_ip(cx), headers(cx)).await { Ok(actor) => actor, Err(res) => return Ok(*res), }; let repo = match api::load_repo( state, actor.as_ref(), path_param::(cx), path_param::(cx), Action::Read, ) .await { Ok(repo) => repo, Err(res) => return Ok(*res), }; let Some(sha) = api::normalize_sha(path_param::(cx)) else { return Ok(not_a_full_sha()); }; let path = match api::repo_dir(state, &repo) { Ok(path) => path, Err(res) => return Ok(*res), }; match api::commit_exists(path, sha.clone()).await { Ok(true) => {} Ok(false) => return Ok(commit_not_found()), Err(res) => return Ok(*res), } let statuses = match state.store.statuses_for(&repo.id, &sha).await { Ok(statuses) => statuses, Err(e) => return Ok(*api::internal(e)), }; let combined = combine(statuses.iter().map(|status| status.state)); let body = json!({ "state": combined.map(StatusState::as_str), "statuses": statuses.iter().map(status_json).collect::>(), }); Ok(api::json(StatusCode::OK, &body)) } fn status_json(status: &CommitStatus) -> Value { json!({ "id": status.id, "sha": status.sha, "context": status.context, "state": status.state.as_str(), "description": status.description, "target_url": status.target_url, "created_at": status.created_at, "updated_at": status.updated_at, }) } fn not_a_full_sha() -> Response { api::error( StatusCode::UNPROCESSABLE_ENTITY, "sha must be a full 40 or 64 character hex commit id", ) } fn commit_not_found() -> Response { api::error(StatusCode::NOT_FOUND, "commit not found") } #[cfg(test)] mod tests { use forge_core::token::{generate_token, hash_token}; use forge_core::{Role, Store, Visibility}; use http::header::{AUTHORIZATION, CONTENT_TYPE, COOKIE}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::pages::commits::test_support::{Fixture, OWNER, REPO}; /// Sends one request through the router: `(status, json body)`. async fn send( router: &Router, method: &str, uri: &str, auth: Option<&str>, cookie: Option<&str>, body: Option<&str>, ) -> (u16, serde_json::Value) { let mut builder = Request::builder().method(method).uri(uri); if let Some(auth) = auth { builder = builder.header(AUTHORIZATION, auth); } if let Some(cookie) = cookie { builder = builder.header(COOKIE, cookie); } if body.is_some() { builder = builder.header(CONTENT_TYPE, "application/json"); } let req = builder .body(Body::from(body.unwrap_or("").to_owned())) .unwrap(); let res = router.handle(req).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); (status, json) } /// A personal access token for `user_id` with the given scopes. Returns the raw token. async fn token_for(store: &Store, user_id: &str, scopes: &[&str]) -> String { let raw = generate_token(); let scopes: Vec = scopes.iter().map(|s| (*s).to_owned()).collect(); store .create_token(user_id, "test", &hash_token(&raw), &scopes, None) .await .expect("token"); raw } fn bearer(raw: &str) -> String { format!("Bearer {raw}") } struct Setup { fx: Fixture, reader_token: String, writer_token: String, sha: String, } async fn setup(visibility: Visibility) -> Setup { let fx = Fixture::new(visibility).await; let owner = fx .store .user_by_username(OWNER) .await .unwrap() .expect("owner"); let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .expect("repo"); let reader = fx .store .create_user("bob", None, "not-a-real-hash", false) .await .unwrap(); fx.store .set_collaborator(&repo.id, &reader.id, Role::Read) .await .unwrap(); let writer_token = token_for(&fx.store, &owner.id, &["repo"]).await; let reader_token = token_for(&fx.store, &reader.id, &["repo"]).await; Setup { sha: fx.modified.clone(), reader_token, writer_token, fx, } } fn report_uri(sha: &str) -> String { format!("/api/v1/repos/{OWNER}/{REPO}/statuses/{sha}") } fn list_uri(sha: &str) -> String { format!("/api/v1/repos/{OWNER}/{REPO}/commits/{sha}/statuses") } const SUCCESS: &str = r#"{"state":"success","context":"ci/build","description":"all good","target_url":"https://ci.example/run/1"}"#; #[tokio::test] async fn write_token_creates_status_with_201() { let s = setup(Visibility::Public).await; let (status, body) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&bearer(&s.writer_token)), None, Some(SUCCESS), ) .await; assert_eq!(status, 201, "{body}"); assert_eq!(body["state"], "success"); assert_eq!(body["context"], "ci/build"); assert_eq!(body["sha"], s.sha.as_str()); assert_eq!(body["target_url"], "https://ci.example/run/1"); } #[tokio::test] async fn context_defaults_and_latest_report_wins() { let s = setup(Visibility::Public).await; let auth = bearer(&s.writer_token); let (status, body) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&auth), None, Some(r#"{"state":"pending"}"#), ) .await; assert_eq!(status, 201); assert_eq!(body["context"], "default"); let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&auth), None, Some(r#"{"state":"failure"}"#), ) .await; assert_eq!(status, 201); let (status, body) = send(&s.fx.router, "GET", &list_uri(&s.sha), None, None, None).await; assert_eq!(status, 200); assert_eq!(body["state"], "failure"); assert_eq!(body["statuses"].as_array().map(Vec::len), Some(1)); } #[tokio::test] async fn read_only_token_is_forbidden_to_post() { let s = setup(Visibility::Public).await; let (status, body) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&bearer(&s.reader_token)), None, Some(SUCCESS), ) .await; assert_eq!(status, 403, "{body}"); assert!(body["error"].is_string()); } #[tokio::test] async fn anonymous_and_bad_tokens_are_unauthorized() { let s = setup(Visibility::Public).await; let (status, body) = send( &s.fx.router, "POST", &report_uri(&s.sha), None, None, Some(SUCCESS), ) .await; assert_eq!(status, 401); assert!(body["error"].is_string()); let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some("Bearer forge_not_a_real_token"), None, Some(SUCCESS), ) .await; assert_eq!(status, 401); } #[tokio::test] async fn session_cookie_alone_is_rejected() { let s = setup(Visibility::Public).await; let owner = s.fx.store .user_by_username(OWNER) .await .unwrap() .expect("owner"); let session = generate_token(); crate::test_support::create_session(&s.fx.store, &owner.id, &session, "csrf-value").await; let cookie = format!("__Host-{SESSION_COOKIE}={session}"); let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), None, Some(&cookie), Some(SUCCESS), ) .await; assert_eq!(status, 401); } #[tokio::test] async fn token_without_repo_scope_is_forbidden() { let s = setup(Visibility::Public).await; let owner = s.fx.store .user_by_username(OWNER) .await .unwrap() .expect("owner"); let raw = token_for(&s.fx.store, &owner.id, &[]).await; let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&bearer(&raw)), None, Some(SUCCESS), ) .await; assert_eq!(status, 403); } #[tokio::test] async fn bad_sha_and_bad_body_are_rejected() { let s = setup(Visibility::Public).await; let auth = bearer(&s.writer_token); let (status, _) = send( &s.fx.router, "POST", &report_uri("abc123"), Some(&auth), None, Some(SUCCESS), ) .await; assert_eq!(status, 422); let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&auth), None, Some(r#"{"state":"maybe"}"#), ) .await; assert_eq!(status, 422); let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&auth), None, Some("{not json"), ) .await; assert_eq!(status, 400); let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&auth), None, Some(r#"{"state":"success","target_url":"javascript:alert(1)"}"#), ) .await; assert_eq!(status, 422); } #[tokio::test] async fn unknown_commit_is_not_found() { let s = setup(Visibility::Public).await; let missing = "0".repeat(40); let (status, _) = send( &s.fx.router, "POST", &report_uri(&missing), Some(&bearer(&s.writer_token)), None, Some(SUCCESS), ) .await; assert_eq!(status, 404); let (status, _) = send(&s.fx.router, "GET", &list_uri(&missing), None, None, None).await; assert_eq!(status, 404); } #[tokio::test] async fn get_combines_states_and_is_public_without_token() { let s = setup(Visibility::Public).await; let auth = bearer(&s.writer_token); for body in [ r#"{"state":"success","context":"unit"}"#, r#"{"state":"pending","context":"lint"}"#, ] { let (status, _) = send( &s.fx.router, "POST", &report_uri(&s.sha), Some(&auth), None, Some(body), ) .await; assert_eq!(status, 201); } let (status, body) = send(&s.fx.router, "GET", &list_uri(&s.sha), None, None, None).await; assert_eq!(status, 200); assert_eq!(body["state"], "pending"); assert_eq!(body["statuses"].as_array().map(Vec::len), Some(2)); // Nothing reported on the root commit yet. let (status, body) = send( &s.fx.router, "GET", &list_uri(&"0".repeat(40)), None, None, None, ) .await; assert_eq!(status, 404, "{body}"); } #[tokio::test] async fn private_repo_hides_from_strangers() { let s = setup(Visibility::Private).await; let (status, _) = send(&s.fx.router, "GET", &list_uri(&s.sha), None, None, None).await; assert_eq!(status, 401); // The reader can read the private repo. let (status, _) = send( &s.fx.router, "GET", &list_uri(&s.sha), Some(&bearer(&s.reader_token)), None, None, ) .await; assert_eq!(status, 200); // A signed-in user with no access sees a 404. let stranger = s.fx.store .create_user("carol", None, "not-a-real-hash", false) .await .unwrap(); let raw = token_for(&s.fx.store, &stranger.id, &["repo"]).await; let (status, _) = send( &s.fx.router, "GET", &list_uri(&s.sha), Some(&bearer(&raw)), None, None, ) .await; assert_eq!(status, 404); } }