forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.7 KiB
1//! Commit status routes of the token API (documented in docs/api.md).
2//!
3//! `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}` records one status and answers 201 with it.
4//! `GET /api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` answers the combined state and the
5//! status of every context.
6
7use forge_core::Action;
8use forge_core::store::commit_status::{CommitStatus, DEFAULT_CONTEXT, StatusState, combine};
9use serde::Deserialize;
10use serde_json::{Value, json};
11use topcoat::context::{Cx, app_context};
12use topcoat::router::request::headers;
13use topcoat::router::response::Response;
14use topcoat::router::{Body, StatusCode, path_param, route};
15
16use crate::api;
17use crate::client_ip::client_ip;
18use crate::state::AppState;
19
20path_param!(owner);
21path_param!(repo);
22path_param!(sha);
23
24/// Largest accepted report body, in bytes.
25const MAX_BODY_BYTES: usize = 16 * 1024;
26
27/// Body of `POST .../statuses/{sha}`. `context` defaults to `default`.
28#[derive(Deserialize)]
29struct ReportBody {
30 state: String,
31 context: Option<String>,
32 description: Option<String>,
33 target_url: Option<String>,
34}
35
36#[route(POST "/api/v1/repos/{owner}/{repo}/statuses/{sha}")]
37async fn report_status(cx: &Cx, body: Body) -> topcoat::Result<Response> {
38 let state = app_context::<AppState>(cx);
39 let actor = match api::authenticate(state, client_ip(cx), headers(cx)).await {
40 Ok(actor) => actor,
41 Err(res) => return Ok(*res),
42 };
43 let repo = match api::load_repo(
44 state,
45 actor.as_ref(),
46 path_param::<Owner>(cx),
47 path_param::<Repo>(cx),
48 Action::Write,
49 )
50 .await
51 {
52 Ok(repo) => repo,
53 Err(res) => return Ok(*res),
54 };
55 let Some(user) = actor else {
56 return Ok(api::unauthorized());
57 };
58 let Some(sha) = api::normalize_sha(path_param::<Sha>(cx)) else {
59 return Ok(not_a_full_sha());
60 };
61 let bytes = match api::read_body(body, MAX_BODY_BYTES).await {
62 Ok(bytes) => bytes,
63 Err(res) => return Ok(*res),
64 };
65 let report: ReportBody = match serde_json::from_slice(&bytes) {
66 Ok(report) => report,
67 Err(e) => {
68 return Ok(api::error(
69 StatusCode::BAD_REQUEST,
70 &format!("invalid JSON body: {e}"),
71 ));
72 }
73 };
74 let Some(status_state) = StatusState::parse(&report.state) else {
75 return Ok(api::error(
76 StatusCode::UNPROCESSABLE_ENTITY,
77 "state must be one of pending, success, failure, error",
78 ));
79 };
80 let path = match api::repo_dir(state, &repo) {
81 Ok(path) => path,
82 Err(res) => return Ok(*res),
83 };
84 match api::commit_exists(path, sha.clone()).await {
85 Ok(true) => {}
86 Ok(false) => return Ok(commit_not_found()),
87 Err(res) => return Ok(*res),
88 }
89
90 let context = report.context.unwrap_or_else(|| DEFAULT_CONTEXT.to_owned());
91 let saved = match state
92 .store
93 .set_status(
94 &repo.id,
95 &sha,
96 &context,
97 status_state,
98 report.description.as_deref(),
99 report.target_url.as_deref(),
100 &user.id,
101 )
102 .await
103 {
104 Ok(saved) => saved,
105 Err(forge_core::Error::Invalid(message)) => {
106 return Ok(api::error(StatusCode::UNPROCESSABLE_ENTITY, &message));
107 }
108 Err(e) => return Ok(*api::internal(e)),
109 };
110 Ok(api::json(StatusCode::CREATED, &status_json(&saved)))
111}
112
113#[route(GET "/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses")]
114async fn list_statuses(cx: &Cx) -> topcoat::Result<Response> {
115 let state = app_context::<AppState>(cx);
116 let actor = match api::authenticate(state, client_ip(cx), headers(cx)).await {
117 Ok(actor) => actor,
118 Err(res) => return Ok(*res),
119 };
120 let repo = match api::load_repo(
121 state,
122 actor.as_ref(),
123 path_param::<Owner>(cx),
124 path_param::<Repo>(cx),
125 Action::Read,
126 )
127 .await
128 {
129 Ok(repo) => repo,
130 Err(res) => return Ok(*res),
131 };
132 let Some(sha) = api::normalize_sha(path_param::<Sha>(cx)) else {
133 return Ok(not_a_full_sha());
134 };
135 let path = match api::repo_dir(state, &repo) {
136 Ok(path) => path,
137 Err(res) => return Ok(*res),
138 };
139 match api::commit_exists(path, sha.clone()).await {
140 Ok(true) => {}
141 Ok(false) => return Ok(commit_not_found()),
142 Err(res) => return Ok(*res),
143 }
144
145 let statuses = match state.store.statuses_for(&repo.id, &sha).await {
146 Ok(statuses) => statuses,
147 Err(e) => return Ok(*api::internal(e)),
148 };
149 let combined = combine(statuses.iter().map(|status| status.state));
150 let body = json!({
151 "state": combined.map(StatusState::as_str),
152 "statuses": statuses.iter().map(status_json).collect::<Vec<Value>>(),
153 });
154 Ok(api::json(StatusCode::OK, &body))
155}
156
157fn status_json(status: &CommitStatus) -> Value {
158 json!({
159 "id": status.id,
160 "sha": status.sha,
161 "context": status.context,
162 "state": status.state.as_str(),
163 "description": status.description,
164 "target_url": status.target_url,
165 "created_at": status.created_at,
166 "updated_at": status.updated_at,
167 })
168}
169
170fn not_a_full_sha() -> Response {
171 api::error(
172 StatusCode::UNPROCESSABLE_ENTITY,
173 "sha must be a full 40 or 64 character hex commit id",
174 )
175}
176
177fn commit_not_found() -> Response {
178 api::error(StatusCode::NOT_FOUND, "commit not found")
179}
180
181#[cfg(test)]
182mod tests {
183 use forge_core::token::{generate_token, hash_token};
184 use forge_core::{Role, Store, Visibility};
185 use http::header::{AUTHORIZATION, CONTENT_TYPE, COOKIE};
186 use topcoat::router::request::Request;
187 use topcoat::router::{Body, Router, to_bytes};
188
189 use crate::auth::SESSION_COOKIE;
190 use crate::pages::commits::test_support::{Fixture, OWNER, REPO};
191
192 /// Sends one request through the router: `(status, json body)`.
193 async fn send(
194 router: &Router,
195 method: &str,
196 uri: &str,
197 auth: Option<&str>,
198 cookie: Option<&str>,
199 body: Option<&str>,
200 ) -> (u16, serde_json::Value) {
201 let mut builder = Request::builder().method(method).uri(uri);
202 if let Some(auth) = auth {
203 builder = builder.header(AUTHORIZATION, auth);
204 }
205 if let Some(cookie) = cookie {
206 builder = builder.header(COOKIE, cookie);
207 }
208 if body.is_some() {
209 builder = builder.header(CONTENT_TYPE, "application/json");
210 }
211 let req = builder
212 .body(Body::from(body.unwrap_or("").to_owned()))
213 .unwrap();
214 let res = router.handle(req).await;
215 let status = res.status().as_u16();
216 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
217 let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
218 (status, json)
219 }
220
221 /// A personal access token for `user_id` with the given scopes. Returns the raw token.
222 async fn token_for(store: &Store, user_id: &str, scopes: &[&str]) -> String {
223 let raw = generate_token();
224 let scopes: Vec<String> = scopes.iter().map(|s| (*s).to_owned()).collect();
225 store
226 .create_token(user_id, "test", &hash_token(&raw), &scopes, None)
227 .await
228 .expect("token");
229 raw
230 }
231
232 fn bearer(raw: &str) -> String {
233 format!("Bearer {raw}")
234 }
235
236 struct Setup {
237 fx: Fixture,
238 reader_token: String,
239 writer_token: String,
240 sha: String,
241 }
242
243 async fn setup(visibility: Visibility) -> Setup {
244 let fx = Fixture::new(visibility).await;
245 let owner = fx
246 .store
247 .user_by_username(OWNER)
248 .await
249 .unwrap()
250 .expect("owner");
251 let repo = fx
252 .store
253 .repo_by_owner_and_name(OWNER, REPO)
254 .await
255 .unwrap()
256 .expect("repo");
257 let reader = fx
258 .store
259 .create_user("bob", None, "not-a-real-hash", false)
260 .await
261 .unwrap();
262 fx.store
263 .set_collaborator(&repo.id, &reader.id, Role::Read)
264 .await
265 .unwrap();
266 let writer_token = token_for(&fx.store, &owner.id, &["repo"]).await;
267 let reader_token = token_for(&fx.store, &reader.id, &["repo"]).await;
268 Setup {
269 sha: fx.modified.clone(),
270 reader_token,
271 writer_token,
272 fx,
273 }
274 }
275
276 fn report_uri(sha: &str) -> String {
277 format!("/api/v1/repos/{OWNER}/{REPO}/statuses/{sha}")
278 }
279
280 fn list_uri(sha: &str) -> String {
281 format!("/api/v1/repos/{OWNER}/{REPO}/commits/{sha}/statuses")
282 }
283
284 const SUCCESS: &str = r#"{"state":"success","context":"ci/build","description":"all good","target_url":"https://ci.example/run/1"}"#;
285
286 #[tokio::test]
287 async fn write_token_creates_status_with_201() {
288 let s = setup(Visibility::Public).await;
289 let (status, body) = send(
290 &s.fx.router,
291 "POST",
292 &report_uri(&s.sha),
293 Some(&bearer(&s.writer_token)),
294 None,
295 Some(SUCCESS),
296 )
297 .await;
298 assert_eq!(status, 201, "{body}");
299 assert_eq!(body["state"], "success");
300 assert_eq!(body["context"], "ci/build");
301 assert_eq!(body["sha"], s.sha.as_str());
302 assert_eq!(body["target_url"], "https://ci.example/run/1");
303 }
304
305 #[tokio::test]
306 async fn context_defaults_and_latest_report_wins() {
307 let s = setup(Visibility::Public).await;
308 let auth = bearer(&s.writer_token);
309 let (status, body) = send(
310 &s.fx.router,
311 "POST",
312 &report_uri(&s.sha),
313 Some(&auth),
314 None,
315 Some(r#"{"state":"pending"}"#),
316 )
317 .await;
318 assert_eq!(status, 201);
319 assert_eq!(body["context"], "default");
320
321 let (status, _) = send(
322 &s.fx.router,
323 "POST",
324 &report_uri(&s.sha),
325 Some(&auth),
326 None,
327 Some(r#"{"state":"failure"}"#),
328 )
329 .await;
330 assert_eq!(status, 201);
331
332 let (status, body) = send(&s.fx.router, "GET", &list_uri(&s.sha), None, None, None).await;
333 assert_eq!(status, 200);
334 assert_eq!(body["state"], "failure");
335 assert_eq!(body["statuses"].as_array().map(Vec::len), Some(1));
336 }
337
338 #[tokio::test]
339 async fn read_only_token_is_forbidden_to_post() {
340 let s = setup(Visibility::Public).await;
341 let (status, body) = send(
342 &s.fx.router,
343 "POST",
344 &report_uri(&s.sha),
345 Some(&bearer(&s.reader_token)),
346 None,
347 Some(SUCCESS),
348 )
349 .await;
350 assert_eq!(status, 403, "{body}");
351 assert!(body["error"].is_string());
352 }
353
354 #[tokio::test]
355 async fn anonymous_and_bad_tokens_are_unauthorized() {
356 let s = setup(Visibility::Public).await;
357 let (status, body) = send(
358 &s.fx.router,
359 "POST",
360 &report_uri(&s.sha),
361 None,
362 None,
363 Some(SUCCESS),
364 )
365 .await;
366 assert_eq!(status, 401);
367 assert!(body["error"].is_string());
368
369 let (status, _) = send(
370 &s.fx.router,
371 "POST",
372 &report_uri(&s.sha),
373 Some("Bearer forge_not_a_real_token"),
374 None,
375 Some(SUCCESS),
376 )
377 .await;
378 assert_eq!(status, 401);
379 }
380
381 #[tokio::test]
382 async fn session_cookie_alone_is_rejected() {
383 let s = setup(Visibility::Public).await;
384 let owner =
385 s.fx.store
386 .user_by_username(OWNER)
387 .await
388 .unwrap()
389 .expect("owner");
390 let session = generate_token();
391 crate::test_support::create_session(&s.fx.store, &owner.id, &session, "csrf-value").await;
392 let cookie = format!("__Host-{SESSION_COOKIE}={session}");
393 let (status, _) = send(
394 &s.fx.router,
395 "POST",
396 &report_uri(&s.sha),
397 None,
398 Some(&cookie),
399 Some(SUCCESS),
400 )
401 .await;
402 assert_eq!(status, 401);
403 }
404
405 #[tokio::test]
406 async fn token_without_repo_scope_is_forbidden() {
407 let s = setup(Visibility::Public).await;
408 let owner =
409 s.fx.store
410 .user_by_username(OWNER)
411 .await
412 .unwrap()
413 .expect("owner");
414 let raw = token_for(&s.fx.store, &owner.id, &[]).await;
415 let (status, _) = send(
416 &s.fx.router,
417 "POST",
418 &report_uri(&s.sha),
419 Some(&bearer(&raw)),
420 None,
421 Some(SUCCESS),
422 )
423 .await;
424 assert_eq!(status, 403);
425 }
426
427 #[tokio::test]
428 async fn bad_sha_and_bad_body_are_rejected() {
429 let s = setup(Visibility::Public).await;
430 let auth = bearer(&s.writer_token);
431 let (status, _) = send(
432 &s.fx.router,
433 "POST",
434 &report_uri("abc123"),
435 Some(&auth),
436 None,
437 Some(SUCCESS),
438 )
439 .await;
440 assert_eq!(status, 422);
441
442 let (status, _) = send(
443 &s.fx.router,
444 "POST",
445 &report_uri(&s.sha),
446 Some(&auth),
447 None,
448 Some(r#"{"state":"maybe"}"#),
449 )
450 .await;
451 assert_eq!(status, 422);
452
453 let (status, _) = send(
454 &s.fx.router,
455 "POST",
456 &report_uri(&s.sha),
457 Some(&auth),
458 None,
459 Some("{not json"),
460 )
461 .await;
462 assert_eq!(status, 400);
463
464 let (status, _) = send(
465 &s.fx.router,
466 "POST",
467 &report_uri(&s.sha),
468 Some(&auth),
469 None,
470 Some(r#"{"state":"success","target_url":"javascript:alert(1)"}"#),
471 )
472 .await;
473 assert_eq!(status, 422);
474 }
475
476 #[tokio::test]
477 async fn unknown_commit_is_not_found() {
478 let s = setup(Visibility::Public).await;
479 let missing = "0".repeat(40);
480 let (status, _) = send(
481 &s.fx.router,
482 "POST",
483 &report_uri(&missing),
484 Some(&bearer(&s.writer_token)),
485 None,
486 Some(SUCCESS),
487 )
488 .await;
489 assert_eq!(status, 404);
490 let (status, _) = send(&s.fx.router, "GET", &list_uri(&missing), None, None, None).await;
491 assert_eq!(status, 404);
492 }
493
494 #[tokio::test]
495 async fn get_combines_states_and_is_public_without_token() {
496 let s = setup(Visibility::Public).await;
497 let auth = bearer(&s.writer_token);
498 for body in [
499 r#"{"state":"success","context":"unit"}"#,
500 r#"{"state":"pending","context":"lint"}"#,
501 ] {
502 let (status, _) = send(
503 &s.fx.router,
504 "POST",
505 &report_uri(&s.sha),
506 Some(&auth),
507 None,
508 Some(body),
509 )
510 .await;
511 assert_eq!(status, 201);
512 }
513 let (status, body) = send(&s.fx.router, "GET", &list_uri(&s.sha), None, None, None).await;
514 assert_eq!(status, 200);
515 assert_eq!(body["state"], "pending");
516 assert_eq!(body["statuses"].as_array().map(Vec::len), Some(2));
517
518 // Nothing reported on the root commit yet.
519 let (status, body) = send(
520 &s.fx.router,
521 "GET",
522 &list_uri(&"0".repeat(40)),
523 None,
524 None,
525 None,
526 )
527 .await;
528 assert_eq!(status, 404, "{body}");
529 }
530
531 #[tokio::test]
532 async fn private_repo_hides_from_strangers() {
533 let s = setup(Visibility::Private).await;
534 let (status, _) = send(&s.fx.router, "GET", &list_uri(&s.sha), None, None, None).await;
535 assert_eq!(status, 401);
536
537 // The reader can read the private repo.
538 let (status, _) = send(
539 &s.fx.router,
540 "GET",
541 &list_uri(&s.sha),
542 Some(&bearer(&s.reader_token)),
543 None,
544 None,
545 )
546 .await;
547 assert_eq!(status, 200);
548
549 // A signed-in user with no access sees a 404.
550 let stranger =
551 s.fx.store
552 .create_user("carol", None, "not-a-real-hash", false)
553 .await
554 .unwrap();
555 let raw = token_for(&s.fx.store, &stranger.id, &["repo"]).await;
556 let (status, _) = send(
557 &s.fx.router,
558 "GET",
559 &list_uri(&s.sha),
560 Some(&bearer(&raw)),
561 None,
562 None,
563 )
564 .await;
565 assert_eq!(status, 404);
566 }
567}