//! `POST /api/review-classifier/open-pr`: put classifications written on the //! try-out page on a new branch of the repo, for the operator to open a PR //! from. //! //! The classifications are merged by trimmed name into the rules file on the //! repo's default branch: one with a name the file holds replaces that entry, //! one with a new name goes at the end, and every other entry stays. A repo //! with no rules file gets `.hyperhive/review-classifier.yml`. In a YAML file, //! the entries the merge leaves alone keep their text, comments included. //! //! The commit is made by the classifier's forge account //! ([`LOGIN`]) with its own token, on a branch of the repo itself, so that //! account needs write access to the repo. Its author is the operator: the //! `Remote-User` the gateway sets names the forge account of the same login, //! since a forge account made by an SSO login is named after the SSO user. //! The answer links to the forge's compare page for the branch, where the //! operator opens the PR as themselves. use std::fmt::Write as _; use std::future::Future; use axum::Json; use axum::extract::State; use axum::extract::rejection::JsonRejection; use axum::http::{HeaderMap, StatusCode}; use forgejo_api::{ApiErrorKind, ForgejoError}; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; use swarm_queue_client::review_classifier::{Classification, Polarity}; use utoipa::ToSchema; use super::{FileSource, RULES_PATHS, check_classifications, operator, parse_rules, split_repo}; use crate::forge::classifier_account::{BranchCommit, CommitAuthor, LOGIN}; use crate::forge::{self, BranchFile}; use crate::{AppState, error_problem}; /// Where a branch's name starts, so the forge's branch list groups them. const BRANCH_PREFIX: &str = "review-classifier/"; /// The longest a line of a folded YAML description gets, indent included. const YAML_WIDTH: usize = 78; /// Classifications to propose to a repo. #[derive(Debug, Deserialize, ToSchema)] pub(crate) struct OpenPrRequest { /// `owner/name`. repo: String, /// One to ten, each with a distinct name. classifications: Vec, } /// The branch the commit went on. #[derive(Debug, Serialize, ToSchema)] pub(crate) struct OpenPrBranch { branch: String, /// The rules file the commit wrote. path: String, /// The forge's page comparing `branch` with the default branch, where /// the PR is opened. compare_url: String, } /// Where the commit is made: the forge in production, a fake in the tests. pub(crate) trait BranchSink { /// The forge account `login` as a commit author, or `None` when the /// forge has none. fn commit_author( &self, login: &str, ) -> impl Future>> + Send; fn push( &self, owner: &str, repo: &str, commit: BranchCommit, ) -> impl Future> + Send; } impl BranchSink for forge::Client { async fn commit_author(&self, login: &str) -> anyhow::Result> { forge::Client::commit_author(self, login).await } async fn push(&self, owner: &str, repo: &str, commit: BranchCommit) -> anyhow::Result<()> { self.push_classifier_branch(owner, repo, commit).await } } /// Put classifications on a new branch, authored by the operator. #[utoipa::path( post, path = "/api/review-classifier/open-pr", request_body = OpenPrRequest, responses( (status = 201, description = "the branch is on the forge; open the PR at \ `compare_url`", body = OpenPrBranch), (status = 400, description = "the body is not this shape, or fails a check the \ try-out POST makes on its repo or classifications (problem+json)", body = String), (status = 401, description = "the request carries no `Remote-User`, so there is no \ author (problem+json)", body = String), (status = 403, description = "the forge refused the classifier's account the push: \ it is not a collaborator with write access on the repo (problem+json)", body = String), (status = 404, description = "the forge knows no such repo, or its default branch \ has no commit (problem+json)", body = String), (status = 409, description = "the forge has no account named like the operator, who \ has to log in to it once; or the default branch's rules file changed while the \ commit was made (problem+json)", body = String), (status = 422, description = "the default branch holds both rules files, or one the \ `rules` GET refuses; the rules already hold every classification as it is; or \ the merged rules fail the try-out POST's checks, such as more than ten \ (problem+json)", body = String), (status = 503, description = "this host has no forge token yet, or the classifier's \ account has none in the store; or the forge failed or did not answer within two \ minutes (problem+json)", body = String), ), tag = "review-classifier" )] pub(crate) async fn post_open_pr( State(state): State, headers: HeaderMap, body: Result, JsonRejection>, ) -> Result<(StatusCode, Json), ProblemDetails> { let operator = operator(&headers).ok_or_else(|| { error_problem( StatusCode::UNAUTHORIZED, "no Remote-User header: the gateway names the operator, and this request did not \ come through it", ) })?; let Json(req) = body.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?; let (owner, name) = split_repo(&req.repo).map_err(|d| error_problem(StatusCode::BAD_REQUEST, &d))?; check_classifications(&req.classifications) .map_err(|d| error_problem(StatusCode::BAD_REQUEST, &d))?; #[cfg(test)] if let Some(fake) = state.try_outs.fake.clone() { return open(&*fake, &operator, owner, name, req.classifications).await; } let Some(forge) = state.forge.get() else { return Err(error_problem( StatusCode::SERVICE_UNAVAILABLE, "no forge access on this host yet", )); }; open(forge.as_ref(), &operator, owner, name, req.classifications).await } /// Resolve the author, merge the rules and push the branch. async fn open( forge: &F, operator: &str, owner: &str, name: &str, rows: Vec, ) -> Result<(StatusCode, Json), ProblemDetails> { let repo = format!("{owner}/{name}"); let author = deadline(forge.commit_author(operator)) .await? .map_err(|e| forge_problem(&e, &repo))? .ok_or_else(|| { error_problem( StatusCode::CONFLICT, &format!( "the forge has no account {operator}, to name as the commit's author: log in \ to the forge once, then try again" ), ) })?; let read = deadline(forge.default_branch_files(owner, name, &RULES_PATHS)) .await? .map_err(|e| forge_problem(&e, &repo))? .ok_or_else(|| { error_problem( StatusCode::NOT_FOUND, &format!("{repo} has no commit on its default branch to branch from"), ) })?; let existing = match read.files.as_slice() { [] => None, [file] => Some(file), _ => { return Err(error_problem( StatusCode::UNPROCESSABLE_ENTITY, &format!( "{repo}'s default branch {} holds both rules files: keep one", read.branch ), )); } }; let merged = merge(existing, rows).map_err(|d| error_problem(StatusCode::UNPROCESSABLE_ENTITY, &d))?; let web_url = read.web_url.ok_or_else(|| { error_problem( StatusCode::SERVICE_UNAVAILABLE, &format!("the forge named no web page for {repo}"), ) })?; let branch = branch_name(operator).map_err(|e| error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e))?; let compare_url = format!( "{}/compare/{}...{branch}", web_url.as_str().trim_end_matches('/'), read.branch ); let commit = BranchCommit { base: read.branch, branch: branch.clone(), path: merged.path.clone(), sha: merged.sha, content: merged.content, message: merged.message, author, }; deadline(forge.push(owner, name, commit)) .await? .map_err(|e| push_problem(&e, &repo))?; tracing::info!(%repo, %branch, %operator, path = %merged.path, "review classifier branch pushed"); Ok(( StatusCode::CREATED, Json(OpenPrBranch { branch, path: merged.path, compare_url, }), )) } async fn deadline(work: impl Future) -> Result { forge::within_forge_deadline("review classifier branch", work) .await .ok_or_else(|| { error_problem( StatusCode::SERVICE_UNAVAILABLE, "the forge did not answer within the deadline", ) }) } /// A fresh branch for `operator`: [`BRANCH_PREFIX`], their name in the /// characters a ref takes without question, and eight random hex digits. fn branch_name(operator: &str) -> Result { let mut bytes = [0u8; 4]; getrandom::fill(&mut bytes).map_err(|e| format!("drawing a branch name: {e}"))?; let who: String = operator .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' } }) .collect(); let mut name = format!("{BRANCH_PREFIX}{who}-"); for b in bytes { let _ = write!(name, "{b:02x}"); } Ok(name) } /// 404 for a repo the forge doesn't know, 503 for anything else. fn forge_problem(e: &anyhow::Error, repo: &str) -> ProblemDetails { if forge_status(e) == Some(StatusCode::NOT_FOUND) { return error_problem(StatusCode::NOT_FOUND, &format!("no repo {repo}")); } let detail = format!("{e:#}"); tracing::warn!(error = %detail, %repo, "preparing a review classifier branch failed"); error_problem(StatusCode::SERVICE_UNAVAILABLE, &detail) } /// 403 when the account may not push, 409 when the rules file or the /// branch changed under the commit, 503 for anything else. fn push_problem(e: &anyhow::Error, repo: &str) -> ProblemDetails { let detail = format!("{e:#}"); tracing::warn!(error = %detail, %repo, "pushing a review classifier branch failed"); match forge_status(e) { // The forge hides a repo the account can't read behind a 404. Some(StatusCode::FORBIDDEN | StatusCode::NOT_FOUND) => error_problem( StatusCode::FORBIDDEN, &format!( "{LOGIN} may not push to {repo}: a repo admin adds it as a collaborator with \ write access ({detail})" ), ), Some(StatusCode::CONFLICT | StatusCode::UNPROCESSABLE_ENTITY) => error_problem( StatusCode::CONFLICT, &format!("the default branch changed while the branch was made: try again ({detail})"), ), _ => error_problem(StatusCode::SERVICE_UNAVAILABLE, &detail), } } /// The forge's status, when a forge answer is what failed. fn forge_status(e: &anyhow::Error) -> Option { match e.downcast_ref::()? { ForgejoError::ApiError(api) => match api.error_kind() { ApiErrorKind::Forbidden | ApiErrorKind::Unauthorized => Some(StatusCode::FORBIDDEN), ApiErrorKind::NotFound { .. } => Some(StatusCode::NOT_FOUND), ApiErrorKind::ValidationFailed => Some(StatusCode::UNPROCESSABLE_ENTITY), ApiErrorKind::Other(s) => Some(*s), _ => None, }, ForgejoError::UnexpectedStatusCode(s) => Some(*s), _ => None, } } /// The rules file a commit writes. #[derive(Debug)] struct Merged { path: String, /// The blob it replaces; `None` when the repo has no rules file. sha: Option, content: Vec, message: String, } /// `rows` merged into `existing`, or why they can't be. fn merge(existing: Option<&BranchFile>, rows: Vec) -> Result { let [yml, _] = RULES_PATHS; let (path, sha, text, current) = match existing { Some(file) => ( file.path.clone(), Some(file.sha.clone()), Some(String::from_utf8_lossy(&file.bytes).into_owned()), parse_rules(&file.path, &file.bytes)?, ), None => (yml.to_owned(), None, None, Vec::new()), }; let mut merged = current.clone(); let (mut added, mut changed) = (Vec::new(), Vec::new()); for row in rows { let row = Classification { name: row.name.trim().to_owned(), description: row.description.trim().to_owned(), ..row }; match merged.iter().position(|c| c.name.trim() == row.name) { Some(i) if merged[i] == row => {} Some(i) => { changed.push(row.name.clone()); merged[i] = row; } None => { added.push(row.name.clone()); merged.push(row); } } } if added.is_empty() && changed.is_empty() { return Err(format!( "{path} already holds every classification as it is" )); } check_classifications(&merged).map_err(|e| format!("{path} with these merged: {e}"))?; let content = if std::path::Path::new(&path) .extension() .is_some_and(|e| e == "json") { let mut json = serde_json::to_string_pretty(&serde_json::json!({ "classifications": merged, })) .map_err(|e| format!("encoding {path}: {e}"))?; json.push('\n'); json } else { yaml(text.as_deref(), ¤t, &merged) }; let mut message = String::from("review classifier:"); for (verb, names) in [("add", &added), ("update", &changed)] { if !names.is_empty() { let sep = if message.ends_with(':') { " " } else { "; " }; let _ = write!(message, "{sep}{verb} {}", names.join(", ")); } } Ok(Merged { path, sha, content: content.into_bytes(), message, }) } /// `merged` as a YAML rules file. Each entry of `text` that `merged` holds /// unchanged at the same place keeps its text, as long as the result reads /// back as `merged`; otherwise the whole file is written out. fn yaml(text: Option<&str>, current: &[Classification], merged: &[Classification]) -> String { if let Some((head, entries, indent)) = text.and_then(entry_lines) { let mut out = head; for (i, c) in merged.iter().enumerate() { match entries.get(i) { Some(kept) if current.get(i) == Some(c) => out.push_str(kept), _ => { if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } out.push_str(&yaml_entry(c, &indent)); } } } if parse_rules(".yml", out.as_bytes()).as_deref() == Ok(merged) { return out; } } let mut out = String::from("classifications:\n"); for c in merged { out.push_str(&yaml_entry(c, " ")); } out } /// `text` split into what comes before the first entry of its /// `classifications:` list, the text of each entry up to the next one, and /// the indent of their `-`. `None` when no entry follows that key. The split /// is only a guess at the layout: [`yaml`] keeps it when what it builds reads /// back as it should. fn entry_lines(text: &str) -> Option<(String, Vec, String)> { let lines: Vec<&str> = text.split_inclusive('\n').collect(); let key = lines .iter() .position(|l| l.trim_end() == "classifications:")?; let first = key + 1 + lines[key + 1..] .iter() .position(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))?; let indent: String = lines[first].chars().take_while(|c| *c == ' ').collect(); let mut entries: Vec = Vec::new(); for line in &lines[first..] { if line .strip_prefix(indent.as_str()) .is_some_and(|rest| rest.starts_with("- ")) { entries.push((*line).to_owned()); } else { entries.last_mut()?.push_str(line); } } Some((lines[..first].concat(), entries, indent)) } /// One classification as an entry of a YAML block list, its `-` at /// `indent`. fn yaml_entry(c: &Classification, indent: &str) -> String { let polarity = match c.polarity { Polarity::Positive => "positive", Polarity::Negative => "negative", }; let inner = format!("{indent} "); let mut out = format!("{indent}- name: {}\n", yaml_name(&c.name)); if foldable(&c.description) { let _ = writeln!(out, "{inner}description: >-"); let lead = format!("{inner} "); let mut line = String::new(); for word in c.description.split(' ') { if !line.is_empty() && lead.len() + line.len() + 1 + word.len() > YAML_WIDTH { let _ = writeln!(out, "{lead}{line}"); line.clear(); } if !line.is_empty() { line.push(' '); } line.push_str(word); } let _ = writeln!(out, "{lead}{line}"); } else { let _ = writeln!(out, "{inner}description: {}", json_string(&c.description)); } let _ = writeln!(out, "{inner}polarity: {polarity}"); let _ = writeln!(out, "{inner}threshold: {}", c.threshold); out } /// `name` bare when it can't read as anything but that string, quoted /// otherwise. fn yaml_name(name: &str) -> String { let bare = name.starts_with(|c: char| c.is_ascii_lowercase()) && name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') && !matches!( name, "y" | "n" | "yes" | "no" | "on" | "off" | "true" | "false" | "null" ); if bare { name.to_owned() } else { json_string(name) } } /// Whether a folded block scalar reads back as exactly `s`, which is trimmed /// and not empty: one line of printable text, words split by single spaces. fn foldable(s: &str) -> bool { !s.contains(" ") && !s.chars().any(char::is_control) } /// `s` as a double-quoted scalar; JSON's escapes are YAML's too. fn json_string(s: &str) -> String { serde_json::Value::from(s).to_string() } #[cfg(test)] mod tests { use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode}; use swarm_queue_client::review_classifier::{Overall, Reply}; use tower::ServiceExt as _; use super::super::tests::Fake; use super::*; fn rule(name: &str, description: &str) -> Classification { Classification { name: name.to_owned(), description: description.to_owned(), polarity: Polarity::Negative, threshold: 70, } } fn file(path: &str, text: &str) -> BranchFile { BranchFile { path: path.to_owned(), sha: "b1".to_owned(), bytes: text.as_bytes().to_vec(), } } const OWN: &str = include_str!("../../../.hyperhive/review-classifier.yml"); #[test] fn a_new_rule_leaves_every_existing_entry_as_it_was_written() { let [yml, _] = RULES_PATHS; let before = parse_rules(yml, OWN.as_bytes()).expect("this repo's rules parse"); let new = rule( "no-todo", "The diff adds a TODO or FIXME comment without a tracker reference next to it, \ which leaves the follow-up with nobody to own it.", ); let merged = merge(Some(&file(yml, OWN)), vec![new.clone()]).expect("merges"); let text = String::from_utf8(merged.content).expect("utf-8"); assert!(text.starts_with(OWN), "{text}"); assert_eq!(merged.sha.as_deref(), Some("b1")); assert_eq!(merged.message, "review classifier: add no-todo"); let after = parse_rules(yml, text.as_bytes()).expect("parses"); assert_eq!(after, [before, vec![new.clone()]].concat()); assert!(text.lines().all(|l| l.len() <= YAML_WIDTH), "{text}"); let unterminated = OWN.trim_end(); let merged = merge(Some(&file(yml, unterminated)), vec![new]).expect("merges"); let text = String::from_utf8(merged.content).expect("utf-8"); assert!( text.starts_with(&format!("{unterminated}\n - name: no-todo\n")), "{text}" ); } #[test] fn a_rule_with_a_known_name_replaces_that_entry_in_place() { let [yml, json] = RULES_PATHS; let mut before = parse_rules(yml, OWN.as_bytes()).expect("this repo's rules parse"); let mut edited = before[1].clone(); edited.threshold = 90; let padded = Classification { name: format!(" {} ", edited.name), ..edited.clone() }; let merged = merge(Some(&file(yml, OWN)), vec![padded.clone()]).expect("merges"); let after = parse_rules(yml, &merged.content).expect("parses"); before[1] = edited.clone(); assert_eq!(after, before); assert_eq!( merged.message, format!("review classifier: update {}", edited.name) ); let as_json = serde_json::json!({ "classifications": [&before[0], &before[2]] }); let merged = merge( Some(&file(json, &as_json.to_string())), vec![padded, rule("x", "y")], ) .expect("merges"); let after = parse_rules(json, &merged.content).expect("parses"); assert_eq!( after, [ before[0].clone(), before[2].clone(), edited.clone(), rule("x", "y") ] ); assert_eq!( merged.message, format!("review classifier: add {}, x", edited.name) ); } #[test] fn no_rules_file_creates_the_yaml_one() { let rules = vec![ rule("no-ads", "No ads."), rule("null", "N."), rule("Yes: #1", "A \"quote\"."), // A full line, then a double space where the wrap falls. rule("spaced", &format!("{} y", "x".repeat(72))), rule("broken", "A\nnewline."), ]; let merged = merge(None, rules.clone()).expect("merges"); let [yml, _] = RULES_PATHS; assert_eq!(merged.path, yml); assert_eq!(merged.sha, None); let after = parse_rules(yml, &merged.content).expect("parses"); assert_eq!(after, rules); } #[test] fn a_list_the_splice_cant_follow_is_written_out_whole() { let [yml, _] = RULES_PATHS; let flow = "classifications: [{name: a, description: b, polarity: negative, threshold: 70}]"; let merged = merge(Some(&file(yml, flow)), vec![rule("c", "d")]).expect("merges"); let after = parse_rules(yml, &merged.content).expect("parses"); assert_eq!(after, [rule("a", "b"), rule("c", "d")]); // Kept as it is, `b` would point at an anchor the new `a` lacks. let aliased = "\ classifications: - name: a description: &shared Shared. polarity: negative threshold: 70 - name: b description: *shared polarity: negative threshold: 70 "; let mut a = rule("a", "Its own."); a.threshold = 90; let merged = merge(Some(&file(yml, aliased)), vec![a.clone()]).expect("merges"); let after = parse_rules(yml, &merged.content).expect("parses"); assert_eq!(after, [a, rule("b", "Shared.")]); } #[test] fn nothing_new_or_too_many_or_a_bad_file_is_refused() { let [yml, _] = RULES_PATHS; let before = parse_rules(yml, OWN.as_bytes()).expect("this repo's rules parse"); let err = merge(Some(&file(yml, OWN)), before.clone()).expect_err("nothing to change"); assert!(err.contains("already holds"), "{err}"); let many: Vec<_> = (0..=10 - before.len()) .map(|i| rule(&format!("extra-{i}"), "d")) .collect(); let err = merge(Some(&file(yml, OWN)), many).expect_err("eleven"); assert!(err.contains("11 classifications"), "{err}"); let err = merge(Some(&file(yml, "nope: 1")), vec![rule("a", "b")]).expect_err("bad"); assert!(err.contains(yml), "{err}"); } #[test] fn a_branch_name_is_a_plain_ref_under_the_prefix() { let name = branch_name("Alice Smith/../x").expect("a name"); let (who, hex) = name .strip_prefix(BRANCH_PREFIX) .and_then(|r| r.rsplit_once('-')) .expect("prefix and suffix"); assert_eq!(who, "alice-smith----x"); assert_eq!(hex.len(), 8); assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()), "{name}"); } fn app(fake: Fake) -> axum::Router { let (mut state, _) = crate::tests::state_with_roster(); state.try_outs.fake = Some(Arc::new(fake)); crate::build_app(state) } fn fake() -> Fake { Fake::new( Reply::Done { rows: Vec::new(), overall: Overall::Approve, }, vec![super::super::tests::rules_json()], ) } fn body(repo: &str) -> String { serde_json::json!({ "repo": repo, "classifications": [{ "name": "no-ads", "description": "No ads.", "polarity": "negative", "threshold": 80, }], }) .to_string() } async fn post( app: &axum::Router, user: Option<&str>, body: String, ) -> (StatusCode, serde_json::Value) { let mut req = Request::post("/api/review-classifier/open-pr") .header("content-type", "application/json"); if let Some(user) = user { req = req.header("remote-user", user); } let res = app .clone() .oneshot(req.body(Body::from(body)).expect("a request")) .await .expect("infallible"); let status = res.status(); let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) .await .expect("a body"); (status, serde_json::from_slice(&bytes).unwrap_or_default()) } #[tokio::test] async fn a_branch_is_pushed_as_the_operator_and_linked_for_the_pr() { let fake = fake(); let pushed = Arc::clone(&fake.pushed); let (status, answer) = post(&app(fake), Some("alice"), body("o/r")).await; assert_eq!(status, StatusCode::CREATED, "{answer}"); let branch = answer["branch"].as_str().expect("a branch"); assert!(branch.starts_with("review-classifier/alice-"), "{branch}"); assert_eq!( answer["compare_url"], format!("https://forge.test/o/r/compare/main...{branch}") ); let [_, json] = RULES_PATHS; assert_eq!(answer["path"], json); let pushed = pushed.lock().expect("unpoisoned"); let [(repo, commit)] = pushed.as_slice() else { panic!("one push expected: {pushed:?}"); }; assert_eq!(repo, "o/r"); assert_eq!( commit.author, CommitAuthor { name: "Alice".to_owned(), email: "alice@example.test".to_owned(), } ); assert_eq!(commit.base, "main"); assert_eq!(commit.branch, branch); assert_eq!(commit.sha.as_deref(), Some(format!("sha-{json}").as_str())); let names: Vec<_> = parse_rules(json, &commit.content) .expect("parses") .into_iter() .map(|c| c.name) .collect(); assert_eq!(names, ["datetime", "no-ads"]); } #[tokio::test] async fn the_author_is_the_session_user_not_one_in_the_body() { let fake = fake(); let pushed = Arc::clone(&fake.pushed); let mut body: serde_json::Value = serde_json::from_str(&body("o/r")).expect("json"); body["author"] = "alice".into(); let (status, answer) = post(&app(fake), Some("bob"), body.to_string()).await; assert_eq!(status, StatusCode::CREATED, "{answer}"); let pushed = pushed.lock().expect("unpoisoned"); assert_eq!(pushed[0].1.author.email, "bob@example.test"); } #[tokio::test] async fn no_session_user_or_no_forge_account_pushes_nothing() { for (user, want, says) in [ (None, StatusCode::UNAUTHORIZED, "Remote-User"), (Some(" "), StatusCode::UNAUTHORIZED, "Remote-User"), ( Some("mallory"), StatusCode::CONFLICT, "log in to the forge once", ), ] { let fake = fake(); let pushed = Arc::clone(&fake.pushed); let (status, problem) = post(&app(fake), user, body("o/r")).await; assert_eq!(status, want, "{user:?}: {problem}"); let detail = problem["detail"].as_str().expect("a detail"); assert!(detail.contains(says), "{user:?}: {detail}"); assert!(pushed.lock().expect("unpoisoned").is_empty(), "{user:?}"); } } #[tokio::test] async fn a_refused_push_names_the_missing_access() { for (refusal, want, says) in [ (StatusCode::FORBIDDEN, StatusCode::FORBIDDEN, "write access"), (StatusCode::NOT_FOUND, StatusCode::FORBIDDEN, "write access"), (StatusCode::CONFLICT, StatusCode::CONFLICT, "try again"), ( StatusCode::INTERNAL_SERVER_ERROR, StatusCode::SERVICE_UNAVAILABLE, "500", ), ] { let mut fake = fake(); fake.push_refusal = Some(refusal); let (status, problem) = post(&app(fake), Some("alice"), body("o/r")).await; assert_eq!(status, want, "{refusal}: {problem}"); let detail = problem["detail"].as_str().expect("a detail"); assert!(detail.contains(says), "{refusal}: {detail}"); } } #[tokio::test] async fn a_bad_body_or_repo_is_refused_before_the_forge() { for (body, want) in [ ("{".to_owned(), StatusCode::BAD_REQUEST), (body("o"), StatusCode::BAD_REQUEST), ( serde_json::json!({ "repo": "o/r", "classifications": [] }).to_string(), StatusCode::BAD_REQUEST, ), (body("o/elsewhere"), StatusCode::NOT_FOUND), (body("o/empty"), StatusCode::NOT_FOUND), (body("o/down"), StatusCode::SERVICE_UNAVAILABLE), ] { let (status, problem) = post(&app(fake()), Some("alice"), body.clone()).await; assert_eq!(status, want, "{body}: {problem}"); } } }