weavesignal/crates/control/tests/staff_db.rs
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

1033 lines
36 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! Staff accounts and sign-in through the control plane's routes against a real PostgreSQL
//! (ADR-0127): the station key makes the first admin; each person signs in as themselves; only
//! an admin reaches retention, personal data and the staff list; an account turned off, a
//! password set by an admin or a sign-out ends sessions at once; sessions outlive a restart;
//! repeated failures are refused for a while; the emailed code signs staff in once, says the same
//! to anyone who asks, and is spent by tries, a new code, a new address, a new password and
//! turning the account off. Ignored unless asked for (`just test-db`).
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tower::ServiceExt;
use weavesignal_control::app::{AppState, router};
use weavesignal_control::carts::CartsLink;
use weavesignal_control::config::{CartsConfig, JoinConfig};
use weavesignal_control::config::{MailConfig, MailMode};
use weavesignal_control::desk::DeskClient;
use weavesignal_control::hub::Hub;
use weavesignal_control::mailkey::MailKey;
use weavesignal_control::staff::{Mailer, Staff};
use weavesignal_control::studio::Studio;
use weavesignal_model::{Station, StationId, ensure_station};
const KEY: &str = "staff-db-test-station-key";
/// A control plane as `serve` builds it; a new one over the same database is a restart.
async fn control_plane(pool: &sqlx::PgPool, station: &Station) -> Router {
control_plane_with(pool, station, None, false).await
}
/// As [`control_plane`], with mail (an outbox directory of its own) and, if asked, the
/// forwarded address counted as the peer (so a test can speak from several addresses).
async fn control_plane_with(
pool: &sqlx::PgPool,
station: &Station,
mailer: Option<Mailer>,
forwarded_for: bool,
) -> Router {
let hub = Hub::new(
&station.name,
Studio::default(),
DeskClient::new(None),
None,
Duration::from_millis(250),
CartsLink::new(CartsConfig::default(), None),
);
let staff = Arc::new(Staff::new(Some(pool.clone())).with_mailer(mailer));
staff.restore(station.id).await;
router(Arc::new(AppState {
station: station.clone(),
db: Some(pool.clone()),
room: None,
admin_token: Some(KEY.into()),
staff,
hub,
join: JoinConfig {
forwarded_for,
..JoinConfig::default()
},
video: Default::default(),
joins: Default::default(),
turn_tls_port: 5349,
steering: Default::default(),
}))
}
async fn send(
app: &Router,
method: &str,
uri: &str,
bearer: Option<&str>,
body: Value,
) -> (StatusCode, Value) {
let mut req = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json");
if let Some(b) = bearer {
req = req.header("authorization", format!("Bearer {b}"));
}
let body = if body.is_null() {
Body::empty()
} else {
Body::from(body.to_string())
};
let res = app.clone().oneshot(req.body(body).unwrap()).await.unwrap();
let code = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(code, serde_json::from_slice(&bytes).unwrap_or_default())
}
async fn sign_in(app: &Router, username: &str, password: &str) -> (StatusCode, Value) {
send(
app,
"POST",
"/api/v1/sessions",
None,
json!({"username": username, "password": password}),
)
.await
}
async fn setup(name: &str) -> (sqlx::PgPool, Station) {
let url =
std::env::var("WEAVESIGNAL_TEST_DATABASE_URL").expect("WEAVESIGNAL_TEST_DATABASE_URL");
let pool = sqlx::PgPool::connect(&url).await.expect("connect");
let station = ensure_station(&pool, &Station::new(StationId::new_random(), name).unwrap())
.await
.unwrap();
(pool, station)
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn staff_sign_in_as_themselves_and_only_admins_do_admin_things() {
let (pool, station) = setup("Staff FM").await;
let app = control_plane(&pool, &station).await;
// No accounts yet: nobody signs in, and the station key makes the first admin.
let (code, _) = sign_in(&app, "morag", "a long enough phrase").await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, morag) = send(
&app,
"POST",
"/api/v1/staff",
Some(KEY),
json!({"username": " Morag ", "name": "Morag Campbell", "role": "admin", "password": "a long enough phrase"}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{morag}");
assert_eq!(morag["username"], "morag");
assert!(morag.get("password_hash").is_none());
// The rules: length only, and the username is taken once.
let (code, p) = send(
&app,
"POST",
"/api/v1/staff",
Some(KEY),
json!({"username": "fraser", "name": "Fraser", "role": "presenter", "password": "short"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(p["field"], "password");
let (code, p) = send(
&app,
"POST",
"/api/v1/staff",
Some(KEY),
json!({"username": "morag", "name": "Another", "role": "screener", "password": "a long enough phrase"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(p["field"], "username");
// Morag signs in and makes a presenter.
let (code, s) = sign_in(&app, "MORAG", "a long enough phrase").await;
assert_eq!(code, StatusCode::CREATED, "{s}");
let admin = s["token"].as_str().unwrap().to_owned();
assert!(admin.starts_with("rss_"));
let (code, me) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&admin),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::OK);
assert_eq!(me["kind"], "staff");
assert_eq!(me["role"], "admin");
assert_eq!(me["account"]["name"], "Morag Campbell");
let (code, fraser) = send(
&app,
"POST",
"/api/v1/staff",
Some(&admin),
json!({"username": "fraser", "name": "Fraser Macleod", "role": "presenter", "password": "the presenter's phrase"}),
)
.await;
assert_eq!(code, StatusCode::CREATED);
let fraser_id = fraser["id"].as_str().unwrap().to_owned();
// Fraser runs the studio but not retention, personal data or the staff list.
let (_, s) = sign_in(&app, "fraser", "the presenter's phrase").await;
let presenter = s["token"].as_str().unwrap().to_owned();
let (code, _) = send(&app, "GET", "/api/v1/carts", Some(&presenter), Value::Null).await;
assert_ne!(code, StatusCode::UNAUTHORIZED);
assert_ne!(code, StatusCode::FORBIDDEN);
for (method, uri) in [
("GET", "/api/v1/retention"),
("GET", "/api/v1/retention/holds"),
("GET", "/api/v1/staff"),
] {
let (code, p) = send(&app, method, uri, Some(&presenter), Value::Null).await;
assert_eq!(code, StatusCode::FORBIDDEN, "{uri}: {p}");
}
let (code, _) = send(&app, "GET", "/api/v1/retention", Some(&admin), Value::Null).await;
assert_eq!(code, StatusCode::OK);
let (code, list) = send(&app, "GET", "/api/v1/staff", Some(&admin), Value::Null).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(list["accounts"].as_array().unwrap().len(), 2);
// An admin cannot turn themselves off or down by a slip.
let morag_id = morag["id"].as_str().unwrap();
let (code, _) = send(
&app,
"PUT",
&format!("/api/v1/staff/{morag_id}"),
Some(&admin),
json!({"role": "producer"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
// A role change lands at once: Fraser made an admin reaches the staff list.
let (code, _) = send(
&app,
"PUT",
&format!("/api/v1/staff/{fraser_id}"),
Some(&admin),
json!({"role": "admin"}),
)
.await;
assert_eq!(code, StatusCode::OK);
let (code, _) = send(&app, "GET", "/api/v1/staff", Some(&presenter), Value::Null).await;
assert_eq!(code, StatusCode::OK);
// Turned off: Fraser's session ends now, and he cannot sign in again.
let (code, off) = send(
&app,
"PUT",
&format!("/api/v1/staff/{fraser_id}"),
Some(&admin),
json!({"disabled": true, "role": "presenter"}),
)
.await;
assert_eq!(code, StatusCode::OK);
assert!(off["disabled_ms"].is_number());
let (code, _) = send(&app, "GET", "/api/v1/carts", Some(&presenter), Value::Null).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, _) = sign_in(&app, "fraser", "the presenter's phrase").await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
// Back on, with a password an admin set; the old one no longer works.
send(
&app,
"PUT",
&format!("/api/v1/staff/{fraser_id}"),
Some(&admin),
json!({"disabled": false}),
)
.await;
let (code, _) = send(
&app,
"POST",
&format!("/api/v1/staff/{fraser_id}/password"),
Some(&admin),
json!({"password": "a new presenter phrase"}),
)
.await;
assert_eq!(code, StatusCode::NO_CONTENT);
let (code, _) = sign_in(&app, "fraser", "the presenter's phrase").await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, s) = sign_in(&app, "fraser", "a new presenter phrase").await;
assert_eq!(code, StatusCode::CREATED);
let presenter = s["token"].as_str().unwrap().to_owned();
// Fraser changes his own: his other sessions end, this one stays.
let (_, s) = sign_in(&app, "fraser", "a new presenter phrase").await;
let other = s["token"].as_str().unwrap().to_owned();
let (code, p) = send(
&app,
"POST",
"/api/v1/sessions/me/password",
Some(&presenter),
json!({"current": "wrong", "new": "fraser's own phrase"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(p["field"], "current");
let (code, _) = send(
&app,
"POST",
"/api/v1/sessions/me/password",
Some(&presenter),
json!({"current": "a new presenter phrase", "new": "fraser's own phrase"}),
)
.await;
assert_eq!(code, StatusCode::NO_CONTENT);
let (code, _) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&other),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, _) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&presenter),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::OK);
// A restart keeps live sessions, and not the ended ones.
let app = control_plane(&pool, &station).await;
let (code, _) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&presenter),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::OK);
let (code, _) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&other),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
// Signing out ends the session, here and across a restart.
let (code, _) = send(
&app,
"DELETE",
"/api/v1/sessions/me",
Some(&presenter),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::NO_CONTENT);
let (code, _) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&presenter),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let app = control_plane(&pool, &station).await;
let (code, _) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&presenter),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
// The station key is still the station's: an admin, with nothing to sign out of.
let (code, me) = send(&app, "GET", "/api/v1/sessions/me", Some(KEY), Value::Null).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(me["kind"], "station");
let (code, _) = send(
&app,
"DELETE",
"/api/v1/sessions/me",
Some(KEY),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn repeated_failures_are_refused_for_a_while_even_with_the_right_password() {
let (pool, station) = setup("Lockout FM").await;
let app = control_plane(&pool, &station).await;
let (code, _) = send(
&app,
"POST",
"/api/v1/staff",
Some(KEY),
json!({"username": "ailsa", "name": "Ailsa", "role": "screener", "password": "the screener's phrase"}),
)
.await;
assert_eq!(code, StatusCode::CREATED);
for _ in 0..5 {
let (code, _) = sign_in(&app, "ailsa", "a wrong guess here").await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
}
let (code, _) = sign_in(&app, "ailsa", "the screener's phrase").await;
assert_eq!(code, StatusCode::TOO_MANY_REQUESTS);
// An unknown username answers as a wrong password does.
let (code, p) = sign_in(&app, "nobody", "a wrong guess here").await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
assert_eq!(
p["error"],
"That username and password do not match an account here."
);
}
// ---- the emailed code (ADR-0127) ----
const MAIL_KEY: &str = "staff-db-test-mail-key-0123456789abcdef";
/// A mailer that writes to an outbox of its own under the target directory.
fn outbox_mailer(station: &Station) -> (Mailer, std::path::PathBuf) {
let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
.join(format!("staff-outbox-{}", uuid::Uuid::new_v4().simple()));
let mailer = Mailer {
config: MailConfig {
mode: MailMode::Outbox,
from: "studio@station.example".into(),
outbox_dir: dir.clone(),
..MailConfig::default()
},
key: MailKey::parse(MAIL_KEY).unwrap(),
station: station.name.clone(),
};
(mailer, dir)
}
/// The emails written so far, oldest first.
fn outbox(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<_> = std::fs::read_dir(dir)
.map(|d| d.filter_map(|e| e.ok()).map(|e| e.path()).collect())
.unwrap_or_default();
names.sort();
names
.iter()
.map(|p| std::fs::read_to_string(p).unwrap())
.collect()
}
/// The code in the newest email, once there are `n` emails (the send happens after the answer).
async fn code_from(dir: &std::path::Path, n: usize) -> String {
for _ in 0..100 {
let mails = outbox(dir);
if mails.len() >= n {
let subject = mails[n - 1]
.lines()
.find(|l| l.starts_with("Subject: "))
.expect("a subject")
.to_owned();
let code = subject["Subject: ".len()..]
.split_whitespace()
.next()
.unwrap()
.to_owned();
assert_eq!(code.len(), 6, "{subject}");
return code;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("no email {n} in the outbox");
}
/// Long enough for a send that was going to happen to have happened.
async fn settle() {
tokio::time::sleep(Duration::from_millis(500)).await;
}
async fn ask(app: &Router, username: &str, email: &str) -> (StatusCode, Value) {
send(
app,
"POST",
"/api/v1/sessions/code",
None,
json!({"username": username, "email": email}),
)
.await
}
async fn finish(app: &Router, username: &str, email: &str, code: &str) -> (StatusCode, Value) {
send(
app,
"POST",
"/api/v1/sessions/code/finish",
Some("sign-in"),
json!({"username": username, "email": email, "code": code}),
)
.await
}
async fn make_account(app: &Router, username: &str, role: &str, password: &str) -> String {
let (code, a) = send(
app,
"POST",
"/api/v1/staff",
Some(KEY),
json!({"username": username, "name": username, "role": role, "password": password}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{a}");
a["id"].as_str().unwrap().to_owned()
}
async fn set_address(app: &Router, id: &str, email: Value) -> (StatusCode, Value) {
send(
app,
"PUT",
&format!("/api/v1/staff/{id}/email"),
Some(KEY),
json!({ "email": email }),
)
.await
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_emailed_code_signs_staff_in_once_and_the_address_is_never_kept() {
let (pool, station) = setup("Code FM").await;
let (mailer, dir) = outbox_mailer(&station);
let app = control_plane_with(&pool, &station, Some(mailer), false).await;
let id = make_account(&app, "morag", "producer", "a long enough phrase").await;
let (code, a) = set_address(&app, &id, json!("Morag.Campbell@Mail.example")).await;
assert_eq!(code, StatusCode::OK, "{a}");
assert_eq!(a["has_email"], true);
assert!(!a.to_string().contains("mail.example"), "{a}");
// The database holds a keyed hash, not the address and not the plain SHA-256 of it.
let (hash,): (Vec<u8>,) =
sqlx::query_as("SELECT email_hash FROM staff_account WHERE id = $1::uuid")
.bind(&id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(hash.len(), 32);
use sha2::{Digest, Sha256};
assert_ne!(
hash,
Sha256::digest(b"morag.campbell@mail.example").to_vec()
);
// Asking: 202, then one email to the address typed, with a code in its subject.
let (code, asked) = ask(&app, "Morag", " morag.campbell@MAIL.example ").await;
assert_eq!(code, StatusCode::ACCEPTED, "{asked}");
let digits = code_from(&dir, 1).await;
let mail = &outbox(&dir)[0];
assert!(
mail.contains("To: morag.campbell@mail.example\r\n"),
"{mail}"
);
let (stored,): (Vec<u8>,) = sqlx::query_as(
"SELECT code_hash FROM staff_signin WHERE account_id = $1::uuid AND NOT used",
)
.bind(&id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored.len(), 32, "a keyed hash, not the digits");
assert_ne!(stored, Sha256::digest(digits.as_bytes()).to_vec());
// A wrong code, then the right one: a session that works as a password's does.
let wrong = if digits == "000000" {
"000001"
} else {
"000000"
};
let (code, p) = finish(&app, "morag", "morag.campbell@mail.example", wrong).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
assert_eq!(
p["error"],
"That code does not match, or has run out. Ask for a new one."
);
// The right code with another address does not sign in either.
let (code, _) = finish(&app, "morag", "someone.else@mail.example", &digits).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, signed) = finish(&app, "morag", "Morag.Campbell@mail.example", &digits).await;
assert_eq!(code, StatusCode::CREATED, "{signed}");
let token = signed["token"].as_str().unwrap();
assert!(token.starts_with("rss_"));
assert_eq!(signed["account"]["username"], "morag");
let (code, me) = send(&app, "GET", "/api/v1/sessions/me", Some(token), Value::Null).await;
assert_eq!(code, StatusCode::OK, "{me}");
assert_eq!(me["role"], "producer");
// Once: the same code again is refused with the same words.
let (code, p) = finish(&app, "morag", "morag.campbell@mail.example", &digits).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
assert_eq!(
p["error"],
"That code does not match, or has run out. Ask for a new one."
);
// The password still works beside it.
assert_eq!(
sign_in(&app, "morag", "a long enough phrase").await.0,
StatusCode::CREATED
);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn asking_for_a_code_tells_a_stranger_nothing() {
let (pool, station) = setup("Quiet FM").await;
let (mailer, dir) = outbox_mailer(&station);
let app = control_plane_with(&pool, &station, Some(mailer), false).await;
let morag = make_account(&app, "morag", "admin", "a long enough phrase").await;
let ailsa = make_account(&app, "ailsa", "screener", "the screener's phrase").await;
set_address(&app, &morag, json!("morag@mail.example")).await;
set_address(&app, &ailsa, json!("ailsa@mail.example")).await;
// Ailsa leaves.
let (code, _) = send(
&app,
"PUT",
&format!("/api/v1/staff/{ailsa}"),
Some(KEY),
json!({"disabled": true}),
)
.await;
assert_eq!(code, StatusCode::OK);
let answers = [
ask(&app, "morag", "morag@mail.example").await,
// The right username, another address; another username, the right address.
ask(&app, "morag", "nobody@mail.example").await,
ask(&app, "ailsa", "morag@mail.example").await,
// An unknown username; a username that is not one; an account that is off.
ask(&app, "nobody", "nobody@mail.example").await,
ask(&app, "no body!", "nobody@mail.example").await,
ask(&app, "ailsa", "ailsa@mail.example").await,
];
for (code, body) in &answers {
assert_eq!(*code, StatusCode::ACCEPTED, "{body}");
assert_eq!(body, &answers[0].1);
}
settle().await;
assert_eq!(
outbox(&dir).len(),
1,
"one email, to the one account that matched"
);
assert!(outbox(&dir)[0].contains("To: morag@mail.example"));
// A malformed address is the same sentence for everyone, and sends nothing.
let (code, p) = ask(&app, "morag", "not an address").await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(p["field"], "email");
// Finishing: an unknown username, an off account and a wrong code say the same words.
let said = finish(&app, "morag", "morag@mail.example", "123456").await;
assert_eq!(said.0, StatusCode::UNAUTHORIZED);
for (u, e) in [
("nobody", "nobody@mail.example"),
("ailsa", "ailsa@mail.example"),
] {
assert_eq!(finish(&app, u, e, "123456").await, said);
}
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_station_without_mail_offers_no_code() {
let (pool, station) = setup("Offline FM").await;
let app = control_plane(&pool, &station).await;
let id = make_account(&app, "morag", "admin", "a long enough phrase").await;
let (code, p) = ask(&app, "morag", "morag@mail.example").await;
assert_eq!(code, StatusCode::SERVICE_UNAVAILABLE);
assert!(p["error"].as_str().unwrap().contains("password"), "{p}");
assert_eq!(
finish(&app, "morag", "morag@mail.example", "123456")
.await
.0,
StatusCode::SERVICE_UNAVAILABLE
);
// No address can be set without the key that hides it; one can always be taken away.
let (code, _) = set_address(&app, &id, json!("morag@mail.example")).await;
assert_eq!(code, StatusCode::SERVICE_UNAVAILABLE);
let (code, a) = set_address(&app, &id, Value::Null).await;
assert_eq!(code, StatusCode::OK, "{a}");
// The password is untouched.
assert_eq!(
sign_in(&app, "morag", "a long enough phrase").await.0,
StatusCode::CREATED
);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn codes_are_limited_by_account_by_address_and_by_wrong_guesses() {
let (pool, station) = setup("Limits FM").await;
let (mailer, dir) = outbox_mailer(&station);
let app = control_plane_with(&pool, &station, Some(mailer), true).await;
let id = make_account(&app, "morag", "admin", "a long enough phrase").await;
set_address(&app, &id, json!("morag@mail.example")).await;
// Per account: three codes in ten minutes, the fourth ask is answered alike and sends none.
for _ in 0..4 {
let (code, _) = ask(&app, "morag", "morag@mail.example").await;
assert_eq!(code, StatusCode::ACCEPTED);
settle().await;
}
assert_eq!(outbox(&dir).len(), 3);
// Only the newest code is live: the first cannot be used after the second was made.
let newest = code_from(&dir, 3).await;
// Per address: ten asks, the eleventh is refused whoever it asks for. (Four are counted.)
let from = |n: u8| format!("203.0.113.{n}");
let ask_from = |addr: String| {
let app = app.clone();
async move {
let req = Request::builder()
.method("POST")
.uri("/api/v1/sessions/code")
.header("content-type", "application/json")
.header("x-forwarded-for", addr)
.body(Body::from(
json!({"username": "nobody", "email": "nobody@mail.example"}).to_string(),
))
.unwrap();
app.oneshot(req).await.unwrap().status()
}
};
for _ in 0..10 {
assert_eq!(ask_from(from(7)).await, StatusCode::ACCEPTED);
}
assert_eq!(ask_from(from(7)).await, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(ask_from(from(8)).await, StatusCode::ACCEPTED);
// Wrong guesses: five lock the username's emailed code out, the right one included,
// while its password is not touched.
let wrong = if newest == "000000" {
"000001"
} else {
"000000"
};
for _ in 0..5 {
let (code, _) = finish(&app, "morag", "morag@mail.example", wrong).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
}
let (code, _) = finish(&app, "morag", "morag@mail.example", &newest).await;
assert_eq!(code, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
sign_in(&app, "morag", "a long enough phrase").await.0,
StatusCode::CREATED
);
// ...and the other way: wrong passwords do not lock the emailed code.
let (_, other) = (
0,
make_account(&app, "fraser", "presenter", "the presenter's phrase").await,
);
set_address(&app, &other, json!("fraser@mail.example")).await;
for _ in 0..5 {
sign_in(&app, "fraser", "a wrong guess here").await;
}
assert_eq!(
sign_in(&app, "fraser", "the presenter's phrase").await.0,
StatusCode::TOO_MANY_REQUESTS
);
let (code, _) = ask(&app, "fraser", "fraser@mail.example").await;
assert_eq!(code, StatusCode::ACCEPTED);
let fraser_code = code_from(&dir, 4).await;
let (code, v) = finish(&app, "fraser", "fraser@mail.example", &fraser_code).await;
assert_eq!(code, StatusCode::CREATED, "{v}");
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_code_is_spent_by_a_new_address_a_new_password_and_turning_the_account_off() {
let (pool, station) = setup("Spent FM").await;
let (mailer, dir) = outbox_mailer(&station);
let app = control_plane_with(&pool, &station, Some(mailer), false).await;
let id = make_account(&app, "morag", "admin", "a long enough phrase").await;
set_address(&app, &id, json!("morag@mail.example")).await;
// A new address: the code sent to the old one is no use, even for the old address.
ask(&app, "morag", "morag@mail.example").await;
let c1 = code_from(&dir, 1).await;
set_address(&app, &id, json!("morag.new@mail.example")).await;
assert_eq!(
finish(&app, "morag", "morag@mail.example", &c1).await.0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
finish(&app, "morag", "morag.new@mail.example", &c1).await.0,
StatusCode::UNAUTHORIZED
);
// The old address no longer names her.
ask(&app, "morag", "morag@mail.example").await;
settle().await;
assert_eq!(outbox(&dir).len(), 1);
// A new password (by an admin).
ask(&app, "morag", "morag.new@mail.example").await;
let c2 = code_from(&dir, 2).await;
let (code, _) = send(
&app,
"POST",
&format!("/api/v1/staff/{id}/password"),
Some(KEY),
json!({"password": "another long phrase"}),
)
.await;
assert_eq!(code, StatusCode::NO_CONTENT);
assert_eq!(
finish(&app, "morag", "morag.new@mail.example", &c2).await.0,
StatusCode::UNAUTHORIZED
);
// Turning the account off: the code, and the session it would have made.
ask(&app, "morag", "morag.new@mail.example").await;
let c3 = code_from(&dir, 3).await;
let (code, _) = send(
&app,
"PUT",
&format!("/api/v1/staff/{id}"),
Some(KEY),
json!({"disabled": true}),
)
.await;
assert_eq!(code, StatusCode::OK);
assert_eq!(
finish(&app, "morag", "morag.new@mail.example", &c3).await.0,
StatusCode::UNAUTHORIZED
);
// Taking the address away leaves no way in by code. (Morag has had her three codes for
// these ten minutes, so this is another account's.)
let ailsa = make_account(&app, "ailsa", "screener", "the screener's phrase").await;
set_address(&app, &ailsa, json!("ailsa@mail.example")).await;
ask(&app, "ailsa", "ailsa@mail.example").await;
let c4 = code_from(&dir, 4).await;
let (code, a) = set_address(&app, &ailsa, Value::Null).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(a["has_email"], false);
assert_eq!(
finish(&app, "ailsa", "ailsa@mail.example", &c4).await.0,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn who_may_set_an_address_and_one_address_is_one_account() {
let (pool, station) = setup("Address FM").await;
let (mailer, _dir) = outbox_mailer(&station);
let app = control_plane_with(&pool, &station, Some(mailer), false).await;
let morag = make_account(&app, "morag", "admin", "a long enough phrase").await;
let fraser = make_account(&app, "fraser", "presenter", "the presenter's phrase").await;
let (_, signed) = sign_in(&app, "fraser", "the presenter's phrase").await;
let fraser_token = signed["token"].as_str().unwrap().to_owned();
// Someone who is not an admin cannot set another's address.
let (code, _) = send(
&app,
"PUT",
&format!("/api/v1/staff/{morag}/email"),
Some(&fraser_token),
json!({"email": "x@mail.example"}),
)
.await;
assert_eq!(code, StatusCode::FORBIDDEN);
// Their own needs their password, and a malformed address is refused under its field.
let own = |current: &str, email: Value| {
let app = app.clone();
let t = fraser_token.clone();
let current = current.to_owned();
async move {
send(
&app,
"PUT",
"/api/v1/sessions/me/email",
Some(&t),
json!({"current": current, "email": email}),
)
.await
}
};
let (code, p) = own("not my password", json!("fraser@mail.example")).await;
assert_eq!(
(code, p["field"].as_str()),
(StatusCode::UNPROCESSABLE_ENTITY, Some("current"))
);
let (code, p) = own("the presenter's phrase", json!("fraser at mail")).await;
assert_eq!(
(code, p["field"].as_str()),
(StatusCode::UNPROCESSABLE_ENTITY, Some("email"))
);
let (code, p) = own("the presenter's phrase", json!(5)).await;
assert_eq!(
(code, p["field"].as_str()),
(StatusCode::UNPROCESSABLE_ENTITY, Some("email"))
);
let (code, a) = own("the presenter's phrase", json!("fraser@mail.example")).await;
assert_eq!(code, StatusCode::OK, "{a}");
assert_eq!(a["has_email"], true);
// The signed-in session sees it at once.
let (_, me) = send(
&app,
"GET",
"/api/v1/sessions/me",
Some(&fraser_token),
Value::Null,
)
.await;
assert_eq!(me["account"]["has_email"], true);
// One address, one account (compared without regard to case).
let (code, p) = set_address(&app, &morag, json!("Fraser@Mail.example")).await;
assert_eq!(
(code, p["field"].as_str()),
(StatusCode::UNPROCESSABLE_ENTITY, Some("email")),
"{p}"
);
// The station key has no address of its own to set.
let (code, _) = send(
&app,
"PUT",
"/api/v1/sessions/me/email",
Some(KEY),
json!({"current": "x", "email": null}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY);
let _ = fraser;
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_code_takes_five_tries_one_use_and_the_newest_code_alone() {
use weavesignal_model::staff::{CodeCheck, check_code, issue_code};
let (pool, station) = setup("Tries FM").await;
let app = control_plane(&pool, &station).await;
let id: uuid::Uuid = make_account(&app, "morag", "admin", "a long enough phrase")
.await
.parse()
.unwrap();
let (h1, h2) = (vec![1u8; 32], vec![2u8; 32]);
// Five guesses at a code, right or wrong, and then it is spent.
assert!(issue_code(&pool, station.id, id, &h1, 3).await.unwrap());
for _ in 0..5 {
assert_eq!(
check_code(&pool, station.id, id, |_| false).await.unwrap(),
CodeCheck::Wrong
);
}
assert_eq!(
check_code(&pool, station.id, id, |w| w == h1)
.await
.unwrap(),
CodeCheck::Spent
);
// A new code spends the older one: only the newest is ever live.
assert!(issue_code(&pool, station.id, id, &h1, 3).await.unwrap());
assert!(issue_code(&pool, station.id, id, &h2, 3).await.unwrap());
assert_eq!(
check_code(&pool, station.id, id, |w| w == h1)
.await
.unwrap(),
CodeCheck::Wrong
);
// A third ask in the window is the last the account gets; a fourth is not kept.
assert!(!issue_code(&pool, station.id, id, &h1, 3).await.unwrap());
// Two requests with the right code at once: one signs in.
let (a, b) = tokio::join!(
check_code(&pool, station.id, id, |w| w == h2),
check_code(&pool, station.id, id, |w| w == h2)
);
let mut got = [a.unwrap(), b.unwrap()];
got.sort_by_key(|c| format!("{c:?}"));
assert_eq!(
got.iter().filter(|c| **c == CodeCheck::Good).count(),
1,
"{got:?}"
);
assert_eq!(
check_code(&pool, station.id, id, |w| w == h2)
.await
.unwrap(),
CodeCheck::Spent
);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_hold_names_the_signed_in_admin_not_the_name_typed() {
// Review 0028, C4: since staff sign in as themselves, a retention record names them from
// their session; only the station key, which is no one in particular, names someone in `by`.
let (pool, station) = setup("Hold Names FM").await;
let app = control_plane(&pool, &station).await;
let (code, made) = send(
&app,
"POST",
"/api/v1/staff",
Some(KEY),
json!({"username": "eilidh", "name": "Eilidh Ross", "role": "admin", "password": "a long enough phrase"}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{made}");
let (_, s) = sign_in(&app, "eilidh", "a long enough phrase").await;
let session = s["token"].as_str().unwrap().to_owned();
let window = json!({"reason": "A listener's complaint", "by": "Somebody Else", "from_ms": 1_793_000_000_000u64, "to_ms": 1_793_000_600_000u64, "records": []});
let (code, h) = send(
&app,
"POST",
"/api/v1/retention/holds",
Some(&session),
window.clone(),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{h}");
assert_eq!(h["placed_by"], "eilidh", "the session names who placed it");
let id = h["id"].as_i64().unwrap();
let (code, h) = send(
&app,
"POST",
&format!("/api/v1/retention/holds/{id}/release"),
Some(&session),
json!({"by": "Somebody Else", "reason": "The complaint is closed"}),
)
.await;
assert_eq!(code, StatusCode::OK, "{h}");
assert_eq!(h["released_by"], "eilidh");
// The station key is no one: the name it gives is the one kept.
let (code, h) = send(&app, "POST", "/api/v1/retention/holds", Some(KEY), window).await;
assert_eq!(code, StatusCode::CREATED, "{h}");
assert_eq!(h["placed_by"], "Somebody Else");
}