The seven linked-account handlers (matrix, forge and github link and delete, and the list) read their store from `AppState::accounts` instead of calling `store::connect()` themselves. In production that is `Accounts::Live`, which still logs in to the swarm's secret store on every request: a `SecretStore` holds a token it never renews, and a store that is down at startup must fail only the requests made while it is down. Tests set `Accounts::Fake` over the in-memory `FakeStore`. The eight handler tests that proved validation passed by expecting the store connect to fail with 500 now assert that the write or delete landed in the fake, and lose their `BAO_*`-must-be-unset guards: with `BAO_*` set they no longer reach a real store.
238 lines
9.2 KiB
Rust
238 lines
9.2 KiB
Rust
//! An agent's accounts on external forges: an operator hands us a base URL and
|
|
//! a token, we put them in the swarm's secret store.
|
|
//!
|
|
//! The agent end is `nix/agent-modules/forge-accounts.nix`, which lists the
|
|
//! agent's accounts and reads each under the agent's own certificate, and writes
|
|
//! the files `hive-forge -f <label>` reads. No hive is in the path.
|
|
|
|
use axum::Json;
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use serde::{Deserialize, Serialize};
|
|
use swarm_secret_client::forge;
|
|
use utoipa::ToSchema;
|
|
|
|
use super::linked_accounts::link;
|
|
use super::{AppState, error_problem, swarm_hive};
|
|
|
|
/// The account to store for one agent's external forge.
|
|
///
|
|
/// No `Debug` derive: this carries a token.
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct PutForgeAccountRequest {
|
|
/// The forge's base URL, `http://` or `https://`. A trailing slash is
|
|
/// dropped.
|
|
#[schema(example = "https://codeberg.org")]
|
|
url: String,
|
|
/// The access token. Never logged, and never returned by this route.
|
|
token: String,
|
|
}
|
|
|
|
/// `put_forge_account`'s success body.
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct PutForgeAccountResponse {
|
|
/// The base URL as stored.
|
|
url: String,
|
|
}
|
|
|
|
/// Store an agent's external forge account, unless one is stored under the
|
|
/// label already.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
|
|
params(
|
|
("hive" = String, Path, description = "hive the agent runs on"),
|
|
("agent" = String, Path, description = "agent the account belongs to"),
|
|
("label" = String, Path, description = "the name the agent passes to `hive-forge -f`"),
|
|
),
|
|
request_body = PutForgeAccountRequest,
|
|
responses(
|
|
(status = 200, description = "stored", body = PutForgeAccountResponse),
|
|
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
|
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
|
|
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub async fn put_forge_account(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
|
|
Json(req): Json<PutForgeAccountRequest>,
|
|
) -> Result<Json<PutForgeAccountResponse>, problem_details::ProblemDetails> {
|
|
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
|
let agent = hive_types::Ident::parse(&agent)
|
|
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
|
.into_string();
|
|
// `hive-forge` accepts only `[a-z0-9-]` after `-f`, narrower than the
|
|
// store's charset, so a label it would refuse is refused here.
|
|
let label = hive_types::Ident::parse(&label)
|
|
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
|
.into_string();
|
|
let secret_path = forge::account_path(&agent, &label)
|
|
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
|
let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?;
|
|
|
|
let store = state.accounts.open().await.map_err(|e| {
|
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
})?;
|
|
link(&store, &secret_path, &account).await.map_err(|e| {
|
|
// The path names the agent and the label; the value is not in it.
|
|
tracing::warn!(path = %secret_path, error = ?e, "linking the forge account failed");
|
|
e.problem(&existing(&agent, &label))
|
|
})?;
|
|
|
|
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
|
|
Ok(Json(PutForgeAccountResponse { url: account.url }))
|
|
}
|
|
|
|
/// The account a refused link names.
|
|
fn existing(agent: &str, label: &str) -> String {
|
|
format!("agent {agent} already has forge account {label:?}")
|
|
}
|
|
|
|
/// The request as it is stored, or why it cannot be.
|
|
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
|
|
let url = req.url.trim().trim_end_matches('/');
|
|
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
|
return Err("url must start with http:// or https://");
|
|
}
|
|
if req.token.trim().is_empty() {
|
|
return Err("token is required");
|
|
}
|
|
Ok(forge::Account {
|
|
value: req.token,
|
|
url: url.to_owned(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
|
use super::{PutForgeAccountRequest, account};
|
|
|
|
fn request(url: &str, token: &str) -> PutForgeAccountRequest {
|
|
PutForgeAccountRequest {
|
|
url: url.to_owned(),
|
|
token: token.to_owned(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_url_loses_its_trailing_slash_and_the_token_is_kept() {
|
|
let a = account(request("https://codeberg.org/ ", "t0k3n")).expect("valid");
|
|
assert_eq!(a.url, "https://codeberg.org");
|
|
assert_eq!(a.value, "t0k3n");
|
|
}
|
|
|
|
#[test]
|
|
fn a_url_that_is_not_http_is_refused() {
|
|
assert!(account(request("codeberg.org", "t")).is_err());
|
|
assert!(account(request("file:///etc/passwd", "t")).is_err());
|
|
// The control: plain http is accepted.
|
|
assert!(account(request("http://forge.lan", "t")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_token_is_refused() {
|
|
assert!(account(request("https://codeberg.org", " ")).is_err());
|
|
}
|
|
|
|
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
|
|
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
|
|
super::super::AppState {
|
|
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
|
name: "pr1ma".to_owned(),
|
|
domain: "pr1ma.example".to_owned(),
|
|
}]),
|
|
links: std::sync::Arc::new(Vec::new()),
|
|
status: None,
|
|
wanted: None,
|
|
agent_status: None,
|
|
agent_icons: None,
|
|
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
|
hive_jobq::Graph::new(),
|
|
hive_jobq::resources::ResourceTable::new(),
|
|
))),
|
|
webhook_secret: None,
|
|
config_prs: None,
|
|
swarm_name: None,
|
|
auth: None,
|
|
forge: None,
|
|
create_gate: std::sync::Arc::default(),
|
|
accounts: Accounts::Fake(Arc::clone(store)),
|
|
}
|
|
}
|
|
|
|
async fn put(
|
|
store: &Arc<FakeStore>,
|
|
label: &str,
|
|
) -> Result<axum::Json<super::PutForgeAccountResponse>, problem_details::ProblemDetails> {
|
|
super::put_forge_account(
|
|
axum::extract::State(state(store)),
|
|
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), label.to_owned())),
|
|
axum::Json(request("https://codeberg.org", "t0k3n")),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// A label `hive-forge -f` could not name is refused before the store.
|
|
#[tokio::test]
|
|
async fn a_label_hive_forge_cannot_name_is_refused_before_the_store() {
|
|
let store = Arc::default();
|
|
for bad in ["Codeberg", "code_berg", "../x"] {
|
|
let problem = put(&store, bad).await.expect_err("refused");
|
|
assert_eq!(
|
|
problem.status,
|
|
Some(axum::http::StatusCode::BAD_REQUEST),
|
|
"{bad}: {problem:?}"
|
|
);
|
|
}
|
|
assert!(store.written().is_empty());
|
|
}
|
|
|
|
/// The control: a plain label is written.
|
|
#[tokio::test]
|
|
async fn a_plain_label_reaches_the_store() {
|
|
let store = Arc::default();
|
|
let stored = put(&store, "codeberg").await.expect("nothing is stored");
|
|
assert_eq!(stored.url, "https://codeberg.org");
|
|
assert_eq!(store.written(), ["swarm/agents/atlas/forge/codeberg"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
|
|
use super::super::linked_accounts::{AccountStore, link};
|
|
use swarm_secret_client::forge;
|
|
|
|
let store = FakeStore::default();
|
|
let path = forge::account_path("atlas", "codeberg").expect("a valid path");
|
|
let first = account(request("https://codeberg.org", "t0k3n-first")).expect("valid");
|
|
let second = account(request("https://forge.lan", "t0k3n-second")).expect("valid");
|
|
|
|
link(&store, &path, &first)
|
|
.await
|
|
.expect("nothing is stored");
|
|
let problem = link(&store, &path, &second)
|
|
.await
|
|
.expect_err("an account is stored")
|
|
.problem(&super::existing("atlas", "codeberg"));
|
|
|
|
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
|
let detail = problem.detail.expect("a detail");
|
|
assert!(
|
|
detail.contains(r#"agent atlas already has forge account "codeberg""#),
|
|
"{detail}"
|
|
);
|
|
assert_eq!(store.written(), std::slice::from_ref(&path));
|
|
let kept: forge::Account = store
|
|
.read_optional(&path)
|
|
.await
|
|
.expect("store answers")
|
|
.expect("still stored");
|
|
assert_eq!(kept.url, "https://codeberg.org");
|
|
assert_eq!(kept.value, "t0k3n-first");
|
|
}
|
|
}
|