weavesignal/crates/control/tests/tokens_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

1022 lines
32 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! The public API against a real PostgreSQL (ADR-0195): tokens made, used, listed, revoked and
//! read back after a restart; webhooks made, guarded, delivered with a valid signature, retried
//! and recorded; and the open routes' data, with an erased guest gone from it. Ignored unless
//! asked for (`just test-db`). Ports 23400-23499 are this file's.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::Router;
use axum::body::{Body, Bytes};
use axum::http::{HeaderMap, Request, StatusCode};
use axum::routing::post;
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tower::ServiceExt;
use weavesignal_control::api::{Api, ApiConfig, layer};
use weavesignal_control::apikey::{ApiKey, delivery_signature};
use weavesignal_control::app::{AppState, router};
use weavesignal_control::carts::CartsLink;
use weavesignal_control::config::{CartsConfig, JoinConfig, PublishingConfig};
use weavesignal_control::desk::DeskClient;
use weavesignal_control::events;
use weavesignal_control::hub::Hub;
use weavesignal_control::shows::{self, ShowsState};
use weavesignal_control::staff::Staff;
use weavesignal_control::studio::Studio;
use weavesignal_control::webhooks;
use weavesignal_control::zone::StationZone;
use weavesignal_model::{Station, StationId, ensure_station};
const KEY: &str = "tokens-db-test-station-key";
const TOKEN_KEY: &str = "0123456789abcdef0123456789abcdef";
struct Plane {
app: Router,
staff: Arc<Staff>,
}
/// A control plane as `serve` builds it (the studio's routes and the shows', the gate over
/// both); a new one over the same pool is a restart.
async fn plane(
pool: &sqlx::PgPool,
station: &Station,
key: Option<&str>,
config: ApiConfig,
dir: Option<&std::path::Path>,
) -> Plane {
let hub = Hub::new(
&station.name,
Studio::default(),
DeskClient::new(None),
None,
Duration::from_millis(250),
CartsLink::new(CartsConfig::default(), None),
);
let api = Arc::new(Api::new(
Some(pool.clone()),
station.id,
key.map(|k| ApiKey::parse(k).unwrap()),
config,
true,
));
let staff = Arc::new(Staff::new(Some(pool.clone())).with_api(api));
staff.restore(station.id).await;
staff.api.restore().await;
let publishing = match dir {
Some(d) => PublishingConfig {
public_url: "https://kettle.example".into(),
place: "Dunfermline".into(),
cards_dir: d.join("cards"),
episodes_dir: d.join("episodes"),
..PublishingConfig::default()
},
None => PublishingConfig::default(),
};
let app = router(Arc::new(AppState {
station: station.clone(),
db: Some(pool.clone()),
room: None,
admin_token: Some(KEY.into()),
staff: staff.clone(),
hub,
join: JoinConfig {
forwarded_for: true,
..JoinConfig::default()
},
video: Default::default(),
joins: Default::default(),
turn_tls_port: 5349,
steering: Default::default(),
}))
.merge(shows::router(Arc::new(ShowsState {
station: station.clone(),
db: Some(pool.clone()),
admin_token: Some(KEY.into()),
staff: staff.clone(),
publishing,
zone: Arc::new(StationZone::new("Europe/London")),
})));
Plane {
app: layer(app, staff.clone()),
staff,
}
}
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)
}
async fn send(
app: &Router,
method: &str,
uri: &str,
bearer: Option<&str>,
body: Value,
) -> (StatusCode, Value) {
let (code, _, v) = send_h(app, method, uri, bearer, body).await;
(code, v)
}
async fn send_h(
app: &Router,
method: &str,
uri: &str,
bearer: Option<&str>,
body: Value,
) -> (StatusCode, HeaderMap, 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, headers) = (res.status(), res.headers().clone());
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(
code,
headers,
serde_json::from_slice(&bytes).unwrap_or_default(),
)
}
async fn eventually<T, F, Fut>(what: &str, mut f: F) -> T
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Option<T>>,
{
for _ in 0..80 {
if let Some(v) = f().await {
return v;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("never happened: {what}");
}
fn config() -> ApiConfig {
ApiConfig {
public_per_minute: 0,
token_per_minute: 0,
streams_per_address: 0,
..ApiConfig::default()
}
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_admin_makes_a_token_it_works_in_its_scope_and_revoking_ends_it() {
let (pool, station) = setup("Tokens FM").await;
let p = plane(&pool, &station, Some(TOKEN_KEY), config(), None).await;
// The station key makes a token; its secret is in the answer, once.
let (code, made) = send(
&p.app,
"POST",
"/api/v1/tokens",
Some(KEY),
json!({"name": " Stream deck ", "scopes": ["carts:read", "carts:fire", "carts:read"], "expires_in_days": 30}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{made}");
let secret = made["secret"].as_str().unwrap().to_owned();
assert!(secret.starts_with("rsa_") && secret.len() == 68);
assert_eq!(made["token"]["name"], "Stream deck");
assert_eq!(made["token"]["scopes"], json!(["carts:fire", "carts:read"]));
assert_eq!(made["token"]["created_by"], "station key");
assert!(made["token"]["expires_ms"].is_i64());
let id = made["token"]["id"].as_str().unwrap().to_owned();
assert_eq!(made["token"]["prefix"], secret[..8]);
// The list never has a secret, nor a hash.
let (code, list) = send(&p.app, "GET", "/api/v1/tokens", Some(KEY), Value::Null).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(list["tokens"].as_array().unwrap().len(), 1);
assert!(!list.to_string().contains(&secret[4..]), "{list}");
assert!(!list.to_string().contains("secret_hash"));
assert!(list["scopes"].as_array().unwrap().len() >= 10);
// It works where it holds the scope, and not elsewhere.
let (code, _) = send(&p.app, "GET", "/api/v1/carts", Some(&secret), Value::Null).await;
assert_eq!(code, StatusCode::OK);
let (code, _) = send(&p.app, "GET", "/api/v1/studio", Some(&secret), Value::Null).await;
assert_eq!(code, StatusCode::FORBIDDEN);
// It is not an admin: not the tokens, not the staff, not even a read of the list.
for (m, u, b) in [
("GET", "/api/v1/tokens", Value::Null),
(
"POST",
"/api/v1/tokens",
json!({"name": "x", "scopes": ["carts:read"]}),
),
("DELETE", &*format!("/api/v1/tokens/{id}"), Value::Null),
("GET", "/api/v1/staff", Value::Null),
] {
let (code, _) = send(&p.app, m, u, Some(&secret), b).await;
assert_eq!(code, StatusCode::FORBIDDEN, "{m} {u}");
}
// Its last use is written.
let used = eventually("last_used_at", || async {
sqlx::query_scalar::<_, Option<i64>>(
"SELECT (extract(epoch FROM last_used_at) * 1000)::bigint FROM api_token WHERE id = $1::uuid",
)
.bind(&id)
.fetch_one(&pool)
.await
.unwrap()
})
.await;
assert!(used > 0);
// The database holds a hash, not the secret.
let kept: Vec<u8> = sqlx::query_scalar("SELECT secret_hash FROM api_token WHERE id = $1::uuid")
.bind(&id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(kept.len(), 32);
assert_ne!(kept, secret.as_bytes());
assert_eq!(kept, ApiKey::parse(TOKEN_KEY).unwrap().token_hash(&secret));
// A restart reads it back.
let again = plane(&pool, &station, Some(TOKEN_KEY), config(), None).await;
let (code, _) = send(
&again.app,
"GET",
"/api/v1/carts",
Some(&secret),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::OK, "tokens outlive a restart");
// Another key finds none.
let other = plane(
&pool,
&station,
Some("fedcba9876543210fedcba9876543210"),
config(),
None,
)
.await;
let (code, _) = send(
&other.app,
"GET",
"/api/v1/carts",
Some(&secret),
Value::Null,
)
.await;
assert_eq!(
code,
StatusCode::UNAUTHORIZED,
"a different key revokes them all"
);
// Revoked: at once, and for good.
let (code, _) = send(
&p.app,
"DELETE",
&format!("/api/v1/tokens/{id}"),
Some(KEY),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::NO_CONTENT);
let (code, _) = send(&p.app, "GET", "/api/v1/carts", Some(&secret), Value::Null).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let restarted = plane(&pool, &station, Some(TOKEN_KEY), config(), None).await;
let (code, _) = send(
&restarted.app,
"GET",
"/api/v1/carts",
Some(&secret),
Value::Null,
)
.await;
assert_eq!(
code,
StatusCode::UNAUTHORIZED,
"and a restart does not bring it back"
);
let (_, list) = send(&p.app, "GET", "/api/v1/tokens", Some(KEY), Value::Null).await;
assert!(list["tokens"][0]["revoked_ms"].is_i64(), "{list}");
let (code, _) = send(
&p.app,
"DELETE",
"/api/v1/tokens/00000000-0000-0000-0000-000000000000",
Some(KEY),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::NOT_FOUND);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn only_an_admin_makes_tokens_and_what_they_are_asked_for_is_checked() {
let (pool, station) = setup("Tokens FM").await;
let p = plane(&pool, &station, Some(TOKEN_KEY), config(), None).await;
// A presenter signs in and cannot.
let (code, v) = send(
&p.app,
"POST",
"/api/v1/staff",
Some(KEY),
json!({"username": "fraser", "name": "Fraser Macleod", "role": "presenter", "password": "the presenter's phrase"}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{v}");
let (_, s) = send(
&p.app,
"POST",
"/api/v1/sessions",
None,
json!({"username": "fraser", "password": "the presenter's phrase"}),
)
.await;
let presenter = s["token"].as_str().unwrap().to_owned();
let (code, _) = send(
&p.app,
"GET",
"/api/v1/tokens",
Some(&presenter),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::FORBIDDEN);
let (code, _) = send(
&p.app,
"POST",
"/api/v1/tokens",
Some(&presenter),
json!({"name": "x", "scopes": ["carts:read"]}),
)
.await;
assert_eq!(code, StatusCode::FORBIDDEN);
let (code, _) = send(&p.app, "GET", "/api/v1/tokens", None, Value::Null).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
// What is asked for.
for (body, field) in [
(json!({"name": "", "scopes": ["carts:read"]}), "name"),
(json!({"name": "x", "scopes": []}), "scopes"),
(json!({"name": "x", "scopes": ["admin"]}), "scopes"),
(
json!({"name": "x", "scopes": ["carts:read"], "expires_in_days": 0}),
"expires_in_days",
),
(
json!({"name": "x", "scopes": ["carts:read"], "expires_in_days": 99999}),
"expires_in_days",
),
] {
let (code, v) = send(&p.app, "POST", "/api/v1/tokens", Some(KEY), body.clone()).await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(v["field"], field, "{body}");
}
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn without_a_key_tokens_and_webhooks_are_off_and_say_which_variable() {
let (pool, station) = setup("Tokens FM").await;
let p = plane(&pool, &station, None, config(), None).await;
for (m, u, b) in [
(
"POST",
"/api/v1/tokens",
json!({"name": "x", "scopes": ["carts:read"]}),
),
("GET", "/api/v1/tokens", Value::Null),
("GET", "/api/v1/webhooks", Value::Null),
] {
let (code, v) = send(&p.app, m, u, Some(KEY), b).await;
assert_eq!(code, StatusCode::SERVICE_UNAVAILABLE, "{m} {u}");
assert!(
v["error"]
.as_str()
.unwrap()
.contains("WEAVESIGNAL_CONTROL_TOKEN_KEY"),
"{v}"
);
}
}
// ---- webhooks ----
type Hit = (HeaderMap, Vec<u8>);
/// A receiver on a loopback port: it answers with these statuses in turn, then 200, and hands
/// every request it gets to the test.
async fn receiver(port: u16, answers: &[u16]) -> mpsc::UnboundedReceiver<Hit> {
let (tx, rx) = mpsc::unbounded_channel();
let answers = Arc::new(Mutex::new(answers.iter().copied().collect::<VecDeque<_>>()));
let app = Router::new().route(
"/hook",
post(move |headers: HeaderMap, body: Bytes| {
let (tx, answers) = (tx.clone(), answers.clone());
async move {
tx.send((headers, body.to_vec())).ok();
let code = answers.lock().unwrap().pop_front().unwrap_or(200);
StatusCode::from_u16(code).unwrap()
}
}),
);
let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
rx
}
async fn hit(rx: &mut mpsc::UnboundedReceiver<Hit>) -> Hit {
tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.expect("a delivery in time")
.expect("the receiver is open")
}
async fn make_hook(app: &Router, name: &str, url: &str, events: Value) -> (String, String) {
let (code, v) = send(
app,
"POST",
"/api/v1/webhooks",
Some(KEY),
json!({"name": name, "url": url, "events": events}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{v}");
(
v["webhook"]["id"].as_str().unwrap().to_owned(),
v["secret"].as_str().unwrap().to_owned(),
)
}
async fn hook_state(pool: &sqlx::PgPool, id: &str) -> (Option<i32>, i32, Option<String>) {
sqlx::query_as("SELECT last_status, failures, last_error FROM webhook WHERE id = $1::uuid")
.bind(id)
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_event_reaches_its_webhooks_signed_and_a_failure_is_retried_and_recorded() {
let (pool, station) = setup("Hooks FM").await;
let p = plane(
&pool,
&station,
Some(TOKEN_KEY),
ApiConfig {
webhooks_allow_private: true,
..config()
},
None,
)
.await;
webhooks::spawn_worker(p.staff.clone(), vec![Duration::from_millis(100)]);
let mut good = receiver(23401, &[]).await;
let mut flaky = receiver(23402, &[500]).await;
let mut other = receiver(23403, &[]).await;
let (good_id, good_secret) = make_hook(
&p.app,
"Website",
"http://127.0.0.1:23401/hook",
json!(["on_air.changed", "caller.queued"]),
)
.await;
let (flaky_id, flaky_secret) = make_hook(
&p.app,
"Flaky",
"http://127.0.0.1:23402/hook",
json!(["on_air.changed"]),
)
.await;
let (_, _) = make_hook(
&p.app,
"Other events",
"http://127.0.0.1:23403/hook",
json!(["episode.started"]),
)
.await;
let (dead_id, _) = make_hook(
&p.app,
"Nobody home",
"http://127.0.0.1:23498/hook",
json!(["on_air.changed"]),
)
.await;
// Off, and so deaf.
let (off_id, _) = make_hook(
&p.app,
"Off",
"http://127.0.0.1:23401/hook",
json!(["on_air.changed"]),
)
.await;
let (code, _) = send(
&p.app,
"PUT",
&format!("/api/v1/webhooks/{off_id}"),
Some(KEY),
json!({"enabled": false}),
)
.await;
assert_eq!(code, StatusCode::OK);
p.staff.api.events.publish(
events::ON_AIR_CHANGED,
json!({"on_air": "desk", "previous": "automation", "live": true}),
);
// The website gets it, once, with a signature anyone with the secret can check.
let (headers, body) = hit(&mut good).await;
let event: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(event["type"], "on_air.changed");
assert_eq!(event["data"]["on_air"], "desk");
assert!(event["at_ms"].as_u64().unwrap() > 0);
assert_eq!(
headers["x-weavesignal-signature"].to_str().unwrap(),
signed(&good_secret, &headers, &body)
);
assert!(
headers["x-weavesignal-signature"]
.to_str()
.unwrap()
.starts_with("sha256=")
);
assert_ne!(
headers["x-weavesignal-signature"].to_str().unwrap(),
signed(&flaky_secret, &headers, &body),
"each webhook has its own secret"
);
assert_eq!(headers["x-weavesignal-event"], "on_air.changed");
assert_eq!(
headers["x-weavesignal-delivery"].to_str().unwrap(),
event["id"].as_str().unwrap()
);
assert_eq!(headers["content-type"], "application/json");
// The flaky one answered 500, was tried again with the same delivery id, and was
// recorded as delivered.
let (first, first_body) = hit(&mut flaky).await;
let (second, second_body) = hit(&mut flaky).await;
assert_eq!(
first["x-weavesignal-delivery"],
second["x-weavesignal-delivery"]
);
assert_eq!(first_body, second_body);
assert_eq!(
second["x-weavesignal-signature"].to_str().unwrap(),
signed(&flaky_secret, &second, &second_body)
);
eventually("flaky recorded as delivered", || async {
let (status, failures, err) = hook_state(&pool, &flaky_id).await;
(status == Some(200) && failures == 0 && err.is_none()).then_some(())
})
.await;
// A webhook nobody answers is recorded as failing, with why.
let (status, failures, err) = eventually("dead recorded", || async {
let s = hook_state(&pool, &dead_id).await;
(s.1 >= 2).then_some(s)
})
.await;
assert_eq!(status, Some(0));
assert!(failures >= 2);
assert!(err.is_some());
// The good one is recorded as delivered; the others heard nothing.
eventually("good recorded", || async {
(hook_state(&pool, &good_id).await.0 == Some(200)).then_some(())
})
.await;
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(other.try_recv().is_err(), "it did not ask for this event");
assert!(
good.try_recv().is_err(),
"once, and the off one on its port is deaf too"
);
// The list shows how each is doing, and no secret.
let (_, list) = send(&p.app, "GET", "/api/v1/webhooks", Some(KEY), Value::Null).await;
let shown = list.to_string();
assert!(
!shown.contains("whsec_") && !shown.contains("salt"),
"{shown}"
);
assert_eq!(list["events"].as_array().unwrap().len(), 5);
let flaky_row = list["webhooks"]
.as_array()
.unwrap()
.iter()
.find(|w| w["id"] == flaky_id)
.unwrap();
assert_eq!(flaky_row["last_status"], 200);
assert!(flaky_row["last_success_ms"].is_i64());
// A new secret: the old one signs no more.
let (code, v) = send(
&p.app,
"POST",
&format!("/api/v1/webhooks/{good_id}/secret"),
Some(KEY),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::OK, "{v}");
let renewed = v["secret"].as_str().unwrap().to_owned();
assert_ne!(renewed, good_secret);
p.staff
.api
.events
.publish(events::CALLER_QUEUED, json!({"identity": "caller-1"}));
let (headers, body) = hit(&mut good).await;
assert_eq!(
headers["x-weavesignal-signature"].to_str().unwrap(),
signed(&renewed, &headers, &body)
);
assert_ne!(
headers["x-weavesignal-signature"].to_str().unwrap(),
signed(&good_secret, &headers, &body)
);
// Removed, it is not told.
let (code, _) = send(
&p.app,
"DELETE",
&format!("/api/v1/webhooks/{good_id}"),
Some(KEY),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::NO_CONTENT);
p.staff
.api
.events
.publish(events::CALLER_QUEUED, json!({"identity": "caller-2"}));
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(good.try_recv().is_err());
let (code, _) = send(
&p.app,
"DELETE",
&format!("/api/v1/webhooks/{good_id}"),
Some(KEY),
Value::Null,
)
.await;
assert_eq!(code, StatusCode::NOT_FOUND);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_webhook_must_be_https_to_a_public_address_unless_the_station_allows_otherwise() {
let (pool, station) = setup("Hooks FM").await;
let p = plane(&pool, &station, Some(TOKEN_KEY), config(), None).await;
for url in [
"https://10.0.0.5/x",
"https://192.168.1.20:8443/x",
"https://127.0.0.1/x",
"https://[::1]/x",
"https://localhost/x",
"https://169.254.169.254/latest/meta-data",
"http://127.0.0.1:23401/hook",
"http://hooks.example/x",
"https://user:pass@hooks.example/x",
"not a web address",
] {
let (code, v) = send(
&p.app,
"POST",
"/api/v1/webhooks",
Some(KEY),
json!({"name": "x", "url": url, "events": ["on_air.changed"]}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{url}: {v}");
assert_eq!(v["field"], "url", "{url}");
}
// Changing an address is checked the same way.
let (code, v) = send(
&p.app,
"POST",
"/api/v1/webhooks",
Some(KEY),
json!({"name": "ok", "url": "https://93.184.216.34/hook", "events": ["on_air.changed"]}),
)
.await;
assert_eq!(code, StatusCode::CREATED, "{v}");
let id = v["webhook"]["id"].as_str().unwrap();
let (code, v) = send(
&p.app,
"PUT",
&format!("/api/v1/webhooks/{id}"),
Some(KEY),
json!({"url": "https://10.1.1.1/x"}),
)
.await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{v}");
// And the rest of what is asked.
for (body, field) in [
(
json!({"name": "", "url": "https://93.184.216.34/x", "events": ["on_air.changed"]}),
"name",
),
(
json!({"name": "x", "url": "https://93.184.216.34/x", "events": []}),
"events",
),
(
json!({"name": "x", "url": "https://93.184.216.34/x", "events": ["everything"]}),
"events",
),
] {
let (code, v) = send(&p.app, "POST", "/api/v1/webhooks", Some(KEY), body.clone()).await;
assert_eq!(code, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(v["field"], field);
}
// Admin only: a token with every scope cannot.
let all: Vec<&str> = weavesignal_control::scopes::SCOPES
.iter()
.map(|(n, _)| *n)
.collect();
let (_, t) = send(
&p.app,
"POST",
"/api/v1/tokens",
Some(KEY),
json!({"name": "all", "scopes": all}),
)
.await;
let secret = t["secret"].as_str().unwrap();
let (code, _) = send(&p.app, "GET", "/api/v1/webhooks", Some(secret), Value::Null).await;
assert_eq!(code, StatusCode::FORBIDDEN);
}
// ---- the open routes' data ----
fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn the_open_routes_show_the_coming_week_and_published_episodes_and_never_an_erased_guest() {
let (pool, station) = setup("Kettle FM").await;
let dir =
PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("open-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(dir.join("episodes")).unwrap();
let p = plane(&pool, &station, Some(TOKEN_KEY), config(), Some(&dir)).await;
let ok = |(code, body): (StatusCode, Value)| {
assert!(code.is_success(), "{code} {body}");
body
};
let tomorrow = jiff::Zoned::now()
.with_time_zone(jiff::tz::TimeZone::get("Europe/London").unwrap())
.tomorrow()
.unwrap()
.weekday()
.to_monday_one_offset();
ok(send(&p.app, "PUT", "/api/v1/shows/kettle-talk", Some(KEY), json!({
"name": "Kettle Talk", "description": "Talk from the Kettle.", "colour_slot": 2, "layout": "slate",
"slot": {"weekday": tomorrow, "start": 1200, "minutes": 60}, "hosts": ["Shona Campbell"]
})).await);
ok(send(
&p.app,
"PUT",
"/api/v1/shows/kettle-talk/series/1",
Some(KEY),
json!({"planned_episodes": 4}),
)
.await);
ok(send(
&p.app,
"POST",
"/api/v1/shows/kettle-talk/series/1/episodes",
Some(KEY),
json!({"count": 2}),
)
.await);
ok(send(
&p.app,
"PATCH",
"/api/v1/shows/kettle-talk/series/1/episodes/1",
Some(KEY),
json!({"title": "The ferries", "guests": ["Morag Ross", "Gregor Stewart"]}),
)
.await);
// No bearer, any origin: the coming week has the first episode and not the second.
let (code, headers, week) =
send_h(&p.app, "GET", "/api/v1/public/schedule", None, Value::Null).await;
assert_eq!(code, StatusCode::OK, "{week}");
assert_eq!(headers["access-control-allow-origin"], "*");
assert_eq!(week["zone"], "Europe/London");
let eps = week["episodes"].as_array().unwrap();
assert_eq!(eps.len(), 1, "{week}");
let e = &eps[0];
assert_eq!(e["show"]["slug"], "kettle-talk");
assert_eq!(e["show"]["name"], "Kettle Talk");
// Not aired: its slot only. Its words and guests are not announced until it airs.
assert_eq!(e["aired"], false);
assert_eq!(e["title"], Value::Null, "{e}");
assert_eq!(e["description"], Value::Null, "{e}");
assert_eq!(e["guests"], json!([]), "{e}");
assert_eq!(e["number"], 1);
assert_eq!(e["minutes"], 60);
assert_eq!(e["hosts"], json!(["Shona Campbell"]));
assert!(!week.to_string().contains("ferries"), "{week}");
assert!(!week.to_string().contains("Morag"), "{week}");
assert!(e["air_at"].as_str().unwrap().contains("T20:00:00"), "{e}");
assert!(e["air_at_ms"].as_u64().unwrap() > 0);
// Nothing of the studio's own, nothing that names a person by id.
let text = week.to_string();
for private in ["inherited", "frozen", "feed_guid", "person", "\"slot\""] {
assert!(!text.contains(private), "{private} in {text}");
}
let (_, two) = send(
&p.app,
"GET",
"/api/v1/public/schedule?days=14",
None,
Value::Null,
)
.await;
assert_eq!(two["episodes"].as_array().unwrap().len(), 2);
for bad in ["days=0", "days=15", "days=x"] {
let (code, _) = send(
&p.app,
"GET",
&format!("/api/v1/public/schedule?{bad}"),
None,
Value::Null,
)
.await;
assert!(code.is_client_error(), "{bad}");
}
// Nothing published yet.
let (code, v) = send(&p.app, "GET", "/api/v1/public/episodes", None, Value::Null).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(v["episodes"], json!([]));
// On air, with files: published.
ok(send(
&p.app,
"POST",
"/api/v1/shows/kettle-talk/series/1/episodes/1/air",
Some(KEY),
Value::Null,
)
.await);
std::fs::copy(
root().join("crates/feeds/tests/fixtures/tone.mp3"),
dir.join("episodes/kettle-talk-s1e1.mp3"),
)
.unwrap();
std::fs::copy(
root().join("assets/carts-demo/ding-1.opus"),
dir.join("episodes/kettle-talk-s1e1.opus"),
)
.unwrap();
ok(send(
&p.app,
"PUT",
"/api/v1/shows/kettle-talk/series/1/episodes/1/files",
Some(KEY),
json!({"enclosure": "kettle-talk-s1e1.mp3", "opus": "kettle-talk-s1e1.opus"}),
)
.await);
let (code, headers, v) = send_h(
&p.app,
"GET",
"/api/v1/public/episodes?limit=5",
None,
Value::Null,
)
.await;
assert_eq!(code, StatusCode::OK, "{v}");
assert_eq!(headers["access-control-allow-origin"], "*");
let ep = &v["episodes"][0];
assert_eq!(ep["title"], "The ferries");
assert_eq!(
ep["audio"]["url"],
"https://kettle.example/media/episodes/kettle-talk-s1e1.mp3"
);
assert_eq!(ep["audio"]["mime"], "audio/mpeg");
assert!(ep["audio_opus"]["url"].as_str().unwrap().ends_with(".opus"));
assert!(
ep["image"]
.as_str()
.unwrap()
.starts_with("https://kettle.example/cards/")
);
assert_eq!(ep["link"], "https://kettle.example/kettle-talk/s1/e1");
assert_eq!(ep["feed"], "https://kettle.example/feeds/kettle-talk.xml");
assert_eq!(ep["guests"], json!(["Morag Ross", "Gregor Stewart"]));
assert_eq!(ep["aired"], true);
// Aired, the schedule shows its words too.
let (_, two) = send(
&p.app,
"GET",
"/api/v1/public/schedule?days=14",
None,
Value::Null,
)
.await;
let first = &two["episodes"][0];
assert_eq!(
(first["title"].clone(), first["aired"].clone()),
(json!("The ferries"), json!(true)),
"{two}"
);
let (_, none) = send(
&p.app,
"GET",
"/api/v1/public/episodes?before_ms=1",
None,
Value::Null,
)
.await;
assert_eq!(none["episodes"], json!([]), "paged back past the start");
let (code, _) = send(
&p.app,
"GET",
"/api/v1/public/episodes?limit=0",
None,
Value::Null,
)
.await;
assert!(code.is_client_error());
// Erased at her request, she is in neither.
let people = ok(send(&p.app, "GET", "/api/v1/people", Some(KEY), Value::Null).await);
let morag = people
.as_array()
.unwrap()
.iter()
.find(|x| x["name"] == "Morag Ross")
.unwrap()["id"]
.as_str()
.unwrap()
.to_owned();
ok(send(
&p.app,
"POST",
&format!("/api/v1/people/{morag}/erase"),
Some(KEY),
Value::Null,
)
.await);
for uri in ["/api/v1/public/schedule?days=14", "/api/v1/public/episodes"] {
let (code, v) = send(&p.app, "GET", uri, None, Value::Null).await;
assert_eq!(code, StatusCode::OK);
assert!(!v.to_string().contains("Morag"), "{uri}: {v}");
assert!(v.to_string().contains("Gregor Stewart"), "{uri}: {v}");
}
// The station's facts.
let (code, v) = send(&p.app, "GET", "/api/v1/public/station", None, Value::Null).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(v["station"], "Kettle FM");
std::fs::remove_dir_all(&dir).ok();
}
/// The signature a delivery should carry: over its `X-WeaveSignal-Timestamp`, a full stop and the
/// body. The timestamp is this minute's (a receiver refuses an old one).
fn signed(secret: &str, headers: &axum::http::HeaderMap, body: &[u8]) -> String {
let ts: u64 = headers["x-weavesignal-timestamp"]
.to_str()
.unwrap()
.parse()
.unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(
now.abs_diff(ts) < 300,
"the timestamp is not fresh: {ts} at {now}"
);
delivery_signature(secret, ts, body)
}