The commit author's email was the forge account's own address, read with the site-admin token, which put an email the account may keep private into the target repo's history. It is now the address Forgejo itself uses for such an account's web commits, `<lowercased login>@<NO_REPLY_ADDRESS>` (models/user/user.go GetPlaceholderEmail). NO_REPLY_ADDRESS defaults to `noreply.<DOMAIN>` (modules/setting/service.go) and the forge module leaves it unset, with DOMAIN set from services.hyperhive.swarm.forge.domain, the same option SWARM_CONTROLLER_FORGE_URL is built from; so the domain is that URL's host. The account is still read, to refuse a user with no forge account, but its email no longer is. Refs #5259
585 lines
23 KiB
Rust
585 lines
23 KiB
Rust
//! The review classifier's forge account, [`LOGIN`]: created here with the
|
|
//! admin API and given one token, stored at
|
|
//! `swarm_secret_client::review_classifier::forge_token_path`. Requesting
|
|
//! this account as a reviewer starts a classifier run, and this daemon posts
|
|
//! the result as the account's review (`crate::review_classifier::review`).
|
|
//! This daemon also pushes the try-out page's branches as the account
|
|
//! (`crate::review_classifier::open_pr`). The classifier itself never holds
|
|
//! the token.
|
|
//!
|
|
//! The account gets no repo access here. A repo admin adds it to a repo by
|
|
//! hand: read to request it as a reviewer, write (or a place on the branch
|
|
//! protection's approvals whitelist) for its approval to count, and write to
|
|
//! push a branch.
|
|
//!
|
|
//! The token decision is [`super::agent_token`]'s, under this module's name
|
|
//! and scopes, so a stored token the forge still lists with the same last
|
|
//! eight characters and scopes is kept, and a pass whose reads fail queues
|
|
//! nothing.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use forgejo_api::structs::{
|
|
ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreatePullReviewOptions,
|
|
Identity, RepoSearchQuery,
|
|
};
|
|
use forgejo_api::{Auth, Forgejo};
|
|
use swarm_secret_client::forge;
|
|
use swarm_secret_client::review_classifier::forge_token_path;
|
|
|
|
use super::Client;
|
|
use super::agent_token::{Decision, Observed, classify_named, is_not_found};
|
|
|
|
/// The classifier's forge login: the reviewer someone requests.
|
|
pub const LOGIN: &str = "review-classifier";
|
|
|
|
/// The forge's name for the account's one token.
|
|
pub const TOKEN_NAME: &str = "swarm-review-classifier";
|
|
|
|
/// What the token may do: post reviews and push branches, both of which the
|
|
/// forge files under the repository scope. Which repos is the account's access, granted by hand.
|
|
pub const TOKEN_SCOPES: &str = "write:repository";
|
|
|
|
/// How often [`spawn`] re-checks the account and its token.
|
|
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
|
|
|
/// The email this module creates the account with, and the one it requires
|
|
/// before acting on an account named [`LOGIN`]. Under its own domain, so it
|
|
/// equals no agent's or hive's account email.
|
|
const EMAIL: &str = "review-classifier@services.hyperhive.local";
|
|
|
|
/// Whether a pass that found `seen` queues a `MintClassifierForgeToken` job.
|
|
/// [`Observed::Unknown`] never does, so a forge or store outage can't rotate
|
|
/// the token, and neither does a [`Decision::Conflict`], which the job would
|
|
/// refuse.
|
|
pub fn needs_job(seen: Observed) -> bool {
|
|
matches!(
|
|
seen,
|
|
Observed::NoForgeUser | Observed::Decided(Decision::Mint(_))
|
|
)
|
|
}
|
|
|
|
/// One review as the classifier's account posts it.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Review {
|
|
/// `APPROVED`, `REQUEST_CHANGES` or `COMMENT`.
|
|
pub event: &'static str,
|
|
pub body: String,
|
|
}
|
|
|
|
/// Who a commit names as its author.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CommitAuthor {
|
|
pub name: String,
|
|
pub email: String,
|
|
}
|
|
|
|
/// One file written as one commit on a new branch.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct BranchCommit {
|
|
/// The branch the new one starts from, at its head.
|
|
pub base: String,
|
|
/// Must not exist yet: the forge refuses the commit when it does.
|
|
pub branch: String,
|
|
pub path: String,
|
|
/// The blob sha of the file on `base` it replaces; `None` creates it.
|
|
/// The forge refuses the commit when `base` no longer holds that blob.
|
|
pub sha: Option<String>,
|
|
pub content: Vec<u8>,
|
|
pub message: String,
|
|
pub author: CommitAuthor,
|
|
}
|
|
|
|
impl Client {
|
|
/// Make sure [`LOGIN`] exists and holds a live [`TOKEN_NAME`] token in
|
|
/// the store, minting one when it does not. The whole job of the
|
|
/// `MintClassifierForgeToken` node.
|
|
///
|
|
/// # Errors
|
|
/// When the store or the forge refuses a step, when an account named
|
|
/// [`LOGIN`] exists with another email, or when the read-back does not
|
|
/// show the new token.
|
|
pub async fn ensure_classifier_forge_token(&self) -> Result<()> {
|
|
let path = forge_token_path()?;
|
|
self.ensure_classifier_user().await?;
|
|
let store = crate::store::connect()
|
|
.await
|
|
.context("logging in to the swarm secret store")?;
|
|
let (stored, listed) = self.read_classifier_token(&store, &path).await?;
|
|
let reason = match classify_named(TOKEN_NAME, TOKEN_SCOPES, stored.as_ref(), &listed) {
|
|
Decision::Keep => {
|
|
tracing::debug!("review classifier forge token is current; left as it is");
|
|
return Ok(());
|
|
}
|
|
Decision::Conflict => bail!(
|
|
"the forge lists more than one {TOKEN_NAME:?} token for {LOGIN:?}; delete them \
|
|
by id and re-run"
|
|
),
|
|
Decision::Mint(reason) => reason,
|
|
};
|
|
let credential = self
|
|
.replace_token(&store, LOGIN, TOKEN_NAME, TOKEN_SCOPES, &listed, &path)
|
|
.await?;
|
|
|
|
Forgejo::new(Auth::Token(&credential.value), self.url.clone())
|
|
.context("building a forge client with the new token")?
|
|
.repo_search(RepoSearchQuery::default())
|
|
.await
|
|
.with_context(|| format!("using {LOGIN}'s new forge token"))?;
|
|
let (stored, listed) = self.read_classifier_token(&store, &path).await?;
|
|
let now = classify_named(TOKEN_NAME, TOKEN_SCOPES, stored.as_ref(), &listed);
|
|
if now != Decision::Keep {
|
|
bail!("{LOGIN}'s new forge token does not read back as current: {now:?}");
|
|
}
|
|
tracing::info!(?reason, %path, "review classifier forge token minted and stored");
|
|
Ok(())
|
|
}
|
|
|
|
/// Post `review` on PR `number` of `owner/repo` as [`LOGIN`], on
|
|
/// `commit` when one is given. Reads the token from the store on every
|
|
/// call. The posted review's URL, when the forge names one.
|
|
///
|
|
/// # Errors
|
|
/// When the store holds no token yet, or the forge refuses the review.
|
|
pub async fn post_classifier_review(
|
|
&self,
|
|
owner: &str,
|
|
repo: &str,
|
|
number: i64,
|
|
commit: Option<&str>,
|
|
review: Review,
|
|
) -> Result<Option<String>> {
|
|
self.as_classifier()
|
|
.await?
|
|
.repo_create_pull_review(
|
|
owner,
|
|
repo,
|
|
number,
|
|
CreatePullReviewOptions {
|
|
body: Some(review.body),
|
|
comments: None,
|
|
commit_id: commit.map(str::to_owned),
|
|
event: Some(review.event.to_owned()),
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| format!("posting {LOGIN}'s review on PR {number} of {owner}/{repo}"))
|
|
.map(|posted| posted.html_url.map(String::from))
|
|
}
|
|
|
|
/// Make `commit` as [`LOGIN`]: one commit on a new branch, from the head
|
|
/// of its base. Reads the token from the store on every call.
|
|
///
|
|
/// # Errors
|
|
/// When the store holds no token yet, or the forge refuses the commit;
|
|
/// a [`forgejo_api::ForgejoError`] in the chain carries the forge's
|
|
/// status.
|
|
pub async fn push_classifier_branch(
|
|
&self,
|
|
owner: &str,
|
|
repo: &str,
|
|
commit: BranchCommit,
|
|
) -> Result<()> {
|
|
push_branch(&self.as_classifier().await?, owner, repo, commit).await
|
|
}
|
|
|
|
/// The forge account `login` as a commit author: its full name, or its
|
|
/// login when it has none, and its [`noreply_address`], so the commit
|
|
/// links to the account without showing its email. `None` when the
|
|
/// forge has no such account.
|
|
///
|
|
/// # Errors
|
|
/// When the forge refuses the read, or this daemon's forge URL has no
|
|
/// host.
|
|
pub async fn commit_author(&self, login: &str) -> Result<Option<CommitAuthor>> {
|
|
let user = match self.api.call(|api| api.user_get(login).into_future()).await {
|
|
Ok(user) => user,
|
|
Err(e) if is_not_found(&e) => return Ok(None),
|
|
Err(e) => return Err(e).with_context(|| format!("read forge user {login}")),
|
|
};
|
|
let login = user.login.as_deref().unwrap_or(login);
|
|
let email = noreply_address(&self.url, login)?;
|
|
let name = user
|
|
.full_name
|
|
.map(|n| n.trim().to_owned())
|
|
.filter(|n| !n.is_empty())
|
|
.unwrap_or_else(|| login.to_owned());
|
|
Ok(Some(CommitAuthor { name, email }))
|
|
}
|
|
|
|
/// A forge client acting as [`LOGIN`], with the token the store holds.
|
|
async fn as_classifier(&self) -> Result<Forgejo> {
|
|
let path = forge_token_path()?;
|
|
let store = crate::store::connect()
|
|
.await
|
|
.context("logging in to the swarm secret store")?;
|
|
let Some(credential) = store
|
|
.read_optional::<forge::Credential>(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path}"))?
|
|
else {
|
|
bail!("nothing at {path}: the classifier's forge token is not minted yet");
|
|
};
|
|
Forgejo::new(Auth::Token(&credential.value), self.url.clone())
|
|
.context("building a forge client with the classifier's token")
|
|
}
|
|
|
|
/// The stored token at `path` and every token the forge lists for
|
|
/// [`LOGIN`], which must exist.
|
|
async fn read_classifier_token(
|
|
&self,
|
|
store: &swarm_secret_client::SecretStore,
|
|
path: &str,
|
|
) -> Result<(
|
|
Option<forge::Credential>,
|
|
Vec<forgejo_api::structs::AccessToken>,
|
|
)> {
|
|
let stored = store
|
|
.read_optional(path)
|
|
.await
|
|
.with_context(|| format!("reading {path}"))?;
|
|
let Some(listed) = self.list_agent_tokens(LOGIN).await? else {
|
|
bail!("the forge has no user {LOGIN:?} after this job created it");
|
|
};
|
|
Ok((stored, listed))
|
|
}
|
|
|
|
/// Create [`LOGIN`] if there is none, refuse one with another email, and
|
|
/// lock it out of creating repos.
|
|
async fn ensure_classifier_user(&self) -> Result<()> {
|
|
self.create_user(LOGIN, EMAIL).await?;
|
|
let user = self
|
|
.api
|
|
.call(|api| api.user_get(LOGIN).into_future())
|
|
.await
|
|
.with_context(|| format!("read forge user {LOGIN}"))?;
|
|
if user.email.as_deref() != Some(EMAIL) {
|
|
bail!(
|
|
"forge account `{LOGIN}` exists but is not the review classifier's (email {:?}, \
|
|
expected `{EMAIL}`); refusing to mint a token for it",
|
|
user.email
|
|
);
|
|
}
|
|
self.api
|
|
.call(|api| {
|
|
api.admin_edit_user(LOGIN, super::repo_creation_lockdown(LOGIN))
|
|
.into_future()
|
|
})
|
|
.await
|
|
.with_context(|| format!("disable repo creation for forge user {LOGIN}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What the store and the forge say about the account's token.
|
|
async fn observe_classifier(&self) -> Result<Observed> {
|
|
let path = forge_token_path()?;
|
|
let store = crate::store::connect()
|
|
.await
|
|
.context("logging in to the swarm secret store")?;
|
|
let stored = match store.read_optional::<forge::Credential>(&path).await {
|
|
Ok(stored) => stored,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "review classifier forge token: store read failed");
|
|
return Ok(Observed::Unknown);
|
|
}
|
|
};
|
|
Ok(match self.list_agent_tokens(LOGIN).await {
|
|
Ok(Some(listed)) => Observed::Decided(classify_named(
|
|
TOKEN_NAME,
|
|
TOKEN_SCOPES,
|
|
stored.as_ref(),
|
|
&listed,
|
|
)),
|
|
Ok(None) => Observed::NoForgeUser,
|
|
Err(e) => {
|
|
tracing::warn!(error = %format!("{e:#}"), "review classifier forge token: forge read failed");
|
|
Observed::Unknown
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The address the forge itself puts on a web commit by `login` when the
|
|
/// account keeps its email private: the lowercased login at
|
|
/// `NO_REPLY_ADDRESS`. The forge module leaves that at its default,
|
|
/// `noreply.<DOMAIN>`, and sets `DOMAIN` from
|
|
/// `services.hyperhive.swarm.forge.domain`, the option this daemon's forge
|
|
/// URL `forge` is built from, so the domain is that URL's host.
|
|
fn noreply_address(forge: &url::Url, login: &str) -> Result<String> {
|
|
let domain = forge
|
|
.host_str()
|
|
.with_context(|| format!("the forge URL {forge} names no host"))?;
|
|
Ok(format!("{}@noreply.{domain}", login.to_lowercase()))
|
|
}
|
|
|
|
/// Make `commit` with `forge`'s identity as committer, through the contents
|
|
/// API.
|
|
async fn push_branch(forge: &Forgejo, owner: &str, repo: &str, commit: BranchCommit) -> Result<()> {
|
|
use base64::Engine as _;
|
|
|
|
let branch = commit.branch.clone();
|
|
forge
|
|
.repo_change_files(
|
|
owner,
|
|
repo,
|
|
ChangeFilesOptions {
|
|
author: Some(Identity {
|
|
name: Some(commit.author.name),
|
|
email: Some(commit.author.email),
|
|
}),
|
|
branch: Some(commit.base),
|
|
committer: None,
|
|
dates: None,
|
|
files: vec![ChangeFileOperation {
|
|
content: Some(
|
|
base64::engine::general_purpose::STANDARD.encode(&commit.content),
|
|
),
|
|
from_path: None,
|
|
operation: if commit.sha.is_some() {
|
|
ChangeFileOperationOperation::Update
|
|
} else {
|
|
ChangeFileOperationOperation::Create
|
|
},
|
|
path: commit.path,
|
|
sha: commit.sha,
|
|
}],
|
|
force_overwrite_new_branch: Some(false),
|
|
message: Some(commit.message),
|
|
new_branch: Some(commit.branch),
|
|
signoff: None,
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| format!("pushing {LOGIN}'s branch {branch} to {owner}/{repo}"))?;
|
|
Ok(())
|
|
}
|
|
/// Check the account now and every [`RECONCILE_INTERVAL`] after, and call
|
|
/// `enqueue`, which inserts a `MintClassifierForgeToken` node, when it needs
|
|
/// a job. A pass that fails is logged and retried on the next tick.
|
|
pub fn spawn(client: Arc<Client>, enqueue: impl Fn() + Send + 'static) {
|
|
tokio::spawn(async move {
|
|
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
|
loop {
|
|
ticker.tick().await;
|
|
let Some(outcome) = super::within_forge_deadline(
|
|
"review classifier forge token",
|
|
client.observe_classifier(),
|
|
)
|
|
.await
|
|
else {
|
|
continue;
|
|
};
|
|
match outcome {
|
|
Ok(seen) if needs_job(seen) => {
|
|
tracing::info!(?seen, "review classifier forge token: queueing a job");
|
|
enqueue();
|
|
}
|
|
Ok(seen) => tracing::debug!(?seen, "review classifier forge token: no job"),
|
|
Err(e) => tracing::warn!(
|
|
error = %format!("{e:#}"),
|
|
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
|
"review classifier forge token: pass failed; retrying next tick"
|
|
),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::agent_token::MintReason;
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_token_writes_repositories_and_nothing_else() {
|
|
assert_eq!(TOKEN_SCOPES, "write:repository");
|
|
assert_ne!(TOKEN_NAME, super::super::agent_token::AGENT_TOKEN_NAME);
|
|
assert_ne!(TOKEN_NAME, super::super::hive_token::HIVE_TOKEN_NAME);
|
|
}
|
|
|
|
#[test]
|
|
fn a_mint_or_a_missing_account_queues_and_nothing_else_does() {
|
|
assert!(needs_job(Observed::NoForgeUser));
|
|
assert!(needs_job(Observed::Decided(Decision::Mint(
|
|
MintReason::NotStored
|
|
))));
|
|
assert!(!needs_job(Observed::Decided(Decision::Keep)));
|
|
assert!(!needs_job(Observed::Decided(Decision::Conflict)));
|
|
assert!(!needs_job(Observed::Unknown));
|
|
}
|
|
|
|
#[test]
|
|
fn the_accounts_email_is_no_agents() {
|
|
assert_ne!(EMAIL, super::super::agent_email(LOGIN));
|
|
assert_ne!(EMAIL, super::super::legacy_agent_email(LOGIN));
|
|
}
|
|
|
|
/// An existing account named like the classifier with an agent's email
|
|
/// is refused before the lockdown, so no token is minted for it.
|
|
#[tokio::test]
|
|
async fn the_job_refuses_an_account_that_is_not_the_classifiers() {
|
|
use super::super::tests::{lockdown_patch, stub_forge};
|
|
|
|
let (client, seen) = stub_forge(
|
|
(409, r#"{"message":"user already exists"}"#),
|
|
(200, r#"{"email":"review-classifier@hyperhive.local"}"#),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
let err = client
|
|
.ensure_classifier_user()
|
|
.await
|
|
.expect_err("an agent's account is not the classifier's");
|
|
assert!(format!("{err:#}").contains("not the review classifier's"));
|
|
assert!(lockdown_patch(&seen, LOGIN).is_none());
|
|
}
|
|
|
|
/// The one request `push_branch` sends for `sha`, as a stand-in forge
|
|
/// saw it: method, path and JSON body.
|
|
async fn pushed(sha: Option<&str>) -> (String, String, serde_json::Value) {
|
|
use axum::http::{Method, StatusCode, Uri, header::CONTENT_TYPE};
|
|
|
|
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let recorded = Arc::clone(&seen);
|
|
let app = axum::Router::new().fallback(
|
|
move |method: Method, uri: Uri, body: axum::body::Bytes| {
|
|
let recorded = Arc::clone(&recorded);
|
|
async move {
|
|
recorded.lock().unwrap().push((
|
|
method.to_string(),
|
|
uri.path().to_owned(),
|
|
serde_json::from_slice(&body).unwrap_or_default(),
|
|
));
|
|
(
|
|
StatusCode::CREATED,
|
|
[(CONTENT_TYPE, "application/json")],
|
|
r#"{"commit":{"sha":"c1"}}"#,
|
|
)
|
|
}
|
|
},
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
|
let forge = Forgejo::new(Auth::Token("stub-token"), url).unwrap();
|
|
let commit = BranchCommit {
|
|
base: "main".to_owned(),
|
|
branch: "review-classifier/alice-0a1b2c3d".to_owned(),
|
|
path: ".hyperhive/review-classifier.yml".to_owned(),
|
|
sha: sha.map(str::to_owned),
|
|
content: b"classifications: []\n".to_vec(),
|
|
message: "review classifier: add no-ads".to_owned(),
|
|
author: CommitAuthor {
|
|
name: "Alice".to_owned(),
|
|
email: "alice@example.test".to_owned(),
|
|
},
|
|
};
|
|
push_branch(&forge, "o", "r", commit).await.expect("pushed");
|
|
let mut seen = seen.lock().unwrap();
|
|
assert_eq!(seen.len(), 1, "{seen:?}");
|
|
seen.remove(0)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_push_is_one_commit_on_a_new_branch_authored_by_the_operator() {
|
|
let (method, path, body) = pushed(Some("b1")).await;
|
|
assert_eq!(method, "POST");
|
|
assert_eq!(path, "/api/v1/repos/o/r/contents");
|
|
assert_eq!(body["branch"], "main");
|
|
assert_eq!(body["new_branch"], "review-classifier/alice-0a1b2c3d");
|
|
assert_eq!(body["force_overwrite_new_branch"], false);
|
|
assert_eq!(body["message"], "review classifier: add no-ads");
|
|
assert_eq!(
|
|
body["author"],
|
|
serde_json::json!({ "name": "Alice", "email": "alice@example.test" })
|
|
);
|
|
assert_eq!(body["committer"], serde_json::Value::Null);
|
|
assert_eq!(
|
|
body["files"],
|
|
serde_json::json!([{
|
|
"operation": "update",
|
|
"path": ".hyperhive/review-classifier.yml",
|
|
"sha": "b1",
|
|
// base64 of "classifications: []\n"
|
|
"content": "Y2xhc3NpZmljYXRpb25zOiBbXQo=",
|
|
"from_path": null,
|
|
}])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_push_without_a_blob_sha_creates_the_file() {
|
|
let (_, _, body) = pushed(None).await;
|
|
assert_eq!(body["files"][0]["operation"], "create");
|
|
assert_eq!(body["files"][0]["sha"], serde_json::Value::Null);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_commit_author_is_the_accounts_name_at_its_noreply_address_or_none() {
|
|
use super::super::tests::stub_forge;
|
|
|
|
let (client, _) = stub_forge(
|
|
(404, "{}"),
|
|
(
|
|
200,
|
|
r#"{"login":"Alice","full_name":" Alice A ","email":"private@x.test"}"#,
|
|
),
|
|
(404, "{}"),
|
|
)
|
|
.await;
|
|
let domain = client.url.host_str().expect("a host").to_owned();
|
|
assert_eq!(
|
|
client.commit_author("alice").await.expect("read"),
|
|
Some(CommitAuthor {
|
|
name: "Alice A".to_owned(),
|
|
email: format!("alice@noreply.{domain}"),
|
|
})
|
|
);
|
|
let (client, _) = stub_forge(
|
|
(404, "{}"),
|
|
(200, r#"{"login":"bob","full_name":"","email":""}"#),
|
|
(404, "{}"),
|
|
)
|
|
.await;
|
|
let bob = client.commit_author("bob").await.expect("read");
|
|
assert_eq!(bob.map(|a| a.name).as_deref(), Some("bob"));
|
|
let (client, _) = stub_forge(
|
|
(404, "{}"),
|
|
(404, r#"{"message":"user does not exist"}"#),
|
|
(404, "{}"),
|
|
)
|
|
.await;
|
|
assert_eq!(client.commit_author("nobody").await.expect("read"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_noreply_address_is_the_lowercased_login_at_noreply_dot_the_forge_host() {
|
|
let forge = url::Url::parse("https://Forge.Example:8443/").expect("a URL");
|
|
assert_eq!(
|
|
noreply_address(&forge, "Alice-B").expect("a host"),
|
|
"alice-b@noreply.forge.example"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_job_locks_down_the_classifiers_own_account() {
|
|
use super::super::tests::{lockdown_patch, stub_forge};
|
|
|
|
let (client, seen) = stub_forge(
|
|
(409, r#"{"message":"user already exists"}"#),
|
|
(
|
|
200,
|
|
r#"{"email":"review-classifier@services.hyperhive.local"}"#,
|
|
),
|
|
(200, "{}"),
|
|
)
|
|
.await;
|
|
client
|
|
.ensure_classifier_user()
|
|
.await
|
|
.expect("the classifier's own account");
|
|
let body = lockdown_patch(&seen, LOGIN).expect("no lockdown PATCH sent");
|
|
assert_eq!(body["max_repo_creation"], 0);
|
|
}
|
|
}
|