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

769 lines
26 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! The public API's gate without a database (ADR-0195): what a scoped token reaches and what it
//! does not, the open routes' CORS and limits, the now-playing stream, and the events the hub's
//! and playout's changes make. The tokens are put straight into the live set; making them
//! through the routes is in `tokens_db.rs`.
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tower::ServiceExt;
use uuid::Uuid;
use weavesignal_control::api::{Api, ApiConfig, layer};
use weavesignal_control::apikey::ApiKey;
use weavesignal_control::app::{AppState, router};
use weavesignal_control::carts::CartsLink;
use weavesignal_control::config::{CartsConfig, JoinConfig};
use weavesignal_control::desk::{DeskClient, DeskStatus};
use weavesignal_control::events;
use weavesignal_control::hub::Hub;
use weavesignal_control::onair::{Links, NowPlaying, OnAir};
use weavesignal_control::playout::PlayoutClient;
use weavesignal_control::shows::{self, ShowsState};
use weavesignal_control::staff::Staff;
use weavesignal_control::studio::Studio;
use weavesignal_control::zone::StationZone;
use weavesignal_model::tokens::{ApiToken, StoredToken};
use weavesignal_model::{Station, StationId};
const KEY: &str = "api-test-station-key";
const TOKEN_KEY: &str = "0123456789abcdef0123456789abcdef";
struct Plane {
app: Router,
staff: Arc<Staff>,
hub: Arc<Hub>,
station: Station,
}
fn config() -> ApiConfig {
ApiConfig {
public_per_minute: 0,
token_per_minute: 0,
streams_per_address: 0,
..ApiConfig::default()
}
}
fn plane(config: ApiConfig, gated: bool) -> Plane {
let station = Station::new(StationId::new_random(), "Kettle FM").unwrap();
let hub = Hub::new(
&station.name,
Studio::default(),
DeskClient::new(None),
None,
Duration::from_millis(250),
CartsLink::new(CartsConfig::default(), None),
);
let api = Api::new(
None,
station.id,
Some(ApiKey::parse(TOKEN_KEY).unwrap()),
config,
true,
);
let staff = Arc::new(Staff::new(None).with_api(Arc::new(api)));
let app = router(Arc::new(AppState {
station: station.clone(),
db: None,
room: None,
admin_token: Some(KEY.into()),
staff: staff.clone(),
hub: hub.clone(),
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: None,
admin_token: Some(KEY.into()),
staff: staff.clone(),
publishing: Default::default(),
zone: Arc::new(StationZone::new("Europe/London")),
})));
let app = if gated {
layer(app, staff.clone())
} else {
app
};
Plane {
app,
staff,
hub,
station,
}
}
/// A live token with these scopes; its secret.
fn token(p: &Plane, scopes: &[&str], expires_ms: Option<i64>) -> (Uuid, String) {
let secret = format!("rsa_{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let id = Uuid::new_v4();
p.staff.api.add(StoredToken {
token: ApiToken {
id,
name: "Stream deck".into(),
scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
prefix: secret[..8].to_owned(),
created_by: "morag".into(),
created_ms: 0,
expires_ms,
last_used_ms: None,
revoked_ms: None,
},
secret_hash: ApiKey::parse(TOKEN_KEY).unwrap().token_hash(&secret),
});
(id, secret)
}
async fn call(
app: &Router,
method: &str,
uri: &str,
bearer: Option<&str>,
extra: &[(&str, &str)],
body: Value,
) -> Response<Body> {
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}"));
}
for (k, v) in extra {
req = req.header(*k, *v);
}
let body = if body.is_null() {
Body::empty()
} else {
Body::from(body.to_string())
};
app.clone().oneshot(req.body(body).unwrap()).await.unwrap()
}
/// A body the route accepts, so the answer comes from the credential check and not from the
/// body being refused first.
fn body_for(method: &str, uri: &str) -> Value {
match (method, uri) {
("POST", "/api/v1/carts/commands") => json!({"op": "fade_all"}),
("POST", "/api/v1/desk/commands") => json!({"op": "on_air", "identity": "x", "on": true}),
("POST", "/api/v1/screener/commands") => json!({"op": "screen", "identity": "x"}),
("PUT", "/api/v1/carts/banks/1") => json!({"name": "News", "colour_slot": 1}),
("POST", "/api/v1/staff") => {
json!({"username": "x", "name": "X", "role": "admin", "password": "a long enough phrase"})
}
("POST", "/api/v1/tokens") => json!({"name": "x", "scopes": ["carts:read"]}),
("POST", "/api/v1/webhooks") => {
json!({"name": "x", "url": "https://hooks.example/x", "events": ["on_air.changed"]})
}
("POST", "/api/v1/room/tokens") => json!({"identity": "x", "role": "presenter"}),
("POST", "/api/v1/invites") => json!({"role": "guest"}),
("PUT", "/api/v1/shows/kettle") => json!({"name": "Kettle", "colour_slot": 1}),
("PUT", "/api/v1/playout/settings") => {
json!({"time_zone": "UTC", "artist_separation_min": 60,
"title_separation_min": 180, "album_separation_min": 60, "mode": "live", "default_clock": null})
}
("POST", "/api/v1/retention/consents/1/erase") => json!({"by": "x"}),
_ => Value::Null,
}
}
async fn status(
app: &Router,
method: &str,
uri: &str,
bearer: Option<&str>,
) -> (StatusCode, Value) {
let res = call(app, method, uri, bearer, &[], body_for(method, uri)).await;
let code = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(code, serde_json::from_slice(&bytes).unwrap_or_default())
}
/// Past the credential check: not refused as unauthorised or forbidden.
fn passed(code: StatusCode) -> bool {
code != StatusCode::UNAUTHORIZED && code != StatusCode::FORBIDDEN
}
#[tokio::test]
async fn a_token_passes_only_the_routes_whose_scope_it_holds() {
let p = plane(config(), true);
let (_, secret) = token(&p, &["carts:read", "desk:control"], None);
// What it holds.
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(&secret)).await;
assert_eq!(code, StatusCode::OK);
let (code, _) = status(&p.app, "POST", "/api/v1/desk/commands", Some(&secret)).await;
assert!(passed(code), "{code}");
// The same resource, another kind of use: the refusal names the scope.
let (code, v) = status(&p.app, "POST", "/api/v1/carts/commands", Some(&secret)).await;
assert_eq!(code, StatusCode::FORBIDDEN);
assert!(v["error"].as_str().unwrap().contains("carts:fire"), "{v}");
// Another resource, in the shows' router as well as the studio's.
for (m, u) in [
("GET", "/api/v1/shows"),
("PUT", "/api/v1/shows/kettle"),
("GET", "/api/v1/schedule/episodes?from_ms=1&to_ms=2"),
("GET", "/api/v1/studio"),
("GET", "/api/v1/schedule/week"),
("POST", "/api/v1/screener/commands"),
("PUT", "/api/v1/carts/banks/1"),
] {
let (code, _) = status(&p.app, m, u, Some(&secret)).await;
assert_eq!(code, StatusCode::FORBIDDEN, "{m} {u}");
}
}
#[tokio::test]
async fn the_shows_routes_take_tokens_by_their_scopes_too() {
let p = plane(config(), true);
let (_, read) = token(&p, &["shows:read", "schedule:read"], None);
let (_, write) = token(&p, &["shows:write"], None);
// Past the credential check: the shows need the database, which this plane has not.
for (m, u) in [
("GET", "/api/v1/shows"),
("GET", "/api/v1/schedule/episodes?from_ms=1&to_ms=2"),
] {
let (code, _) = status(&p.app, m, u, Some(&read)).await;
assert!(passed(code), "{m} {u} {code}");
}
let (code, v) = status(&p.app, "PUT", "/api/v1/shows/kettle", Some(&read)).await;
assert_eq!(code, StatusCode::FORBIDDEN);
assert!(v["error"].as_str().unwrap().contains("shows:write"), "{v}");
let (code, _) = status(&p.app, "GET", "/api/v1/shows", Some(&write)).await;
assert_eq!(code, StatusCode::FORBIDDEN, "write does not imply read");
// People are personal data: an admin's.
for t in [&read, &write] {
let (code, _) = status(&p.app, "GET", "/api/v1/people", Some(t)).await;
assert_eq!(code, StatusCode::FORBIDDEN);
}
}
#[tokio::test]
async fn a_token_never_reaches_an_admin_route_whatever_its_scopes() {
let p = plane(config(), true);
// Every scope there is.
let all: Vec<&str> = weavesignal_control::scopes::SCOPES
.iter()
.map(|(n, _)| *n)
.collect();
let (_, secret) = token(&p, &all, None);
for (m, u) in [
("GET", "/api/v1/staff"),
("POST", "/api/v1/staff"),
("GET", "/api/v1/tokens"),
("POST", "/api/v1/tokens"),
("GET", "/api/v1/webhooks"),
("POST", "/api/v1/webhooks"),
("GET", "/api/v1/retention"),
("GET", "/api/v1/retention/holds"),
("POST", "/api/v1/retention/consents/1/erase"),
("GET", "/api/v1/people"),
("GET", "/api/v1/playout/settings"),
("PUT", "/api/v1/playout/settings"),
("GET", "/api/v1/sessions/me"),
("POST", "/api/v1/room/tokens"),
("POST", "/api/v1/invites"),
] {
let (code, _) = status(&p.app, m, u, Some(&secret)).await;
// Refused by the credential check, except where no route answers to the method.
assert!(
code == StatusCode::FORBIDDEN
|| code == StatusCode::METHOD_NOT_ALLOWED
|| (code == StatusCode::UNPROCESSABLE_ENTITY && body_for(m, u).is_null()),
"{m} {u} gave {code}"
);
}
// Making a token is an admin's: not even with `tokens` in the body.
let (code, v) = status(&p.app, "POST", "/api/v1/tokens", Some(&secret)).await;
assert_eq!(code, StatusCode::FORBIDDEN, "{v}");
}
#[tokio::test]
async fn unknown_revoked_and_expired_tokens_are_not_tokens() {
let p = plane(config(), true);
let (id, secret) = token(&p, &["carts:read"], None);
let (_, expired) = token(&p, &["carts:read"], Some(1));
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(&secret)).await;
assert_eq!(code, StatusCode::OK);
for bad in [
"rsa_00000000000000000000000000000000".to_owned(),
format!("{secret}x"),
expired,
] {
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(&bad)).await;
assert_eq!(code, StatusCode::UNAUTHORIZED, "{bad}");
}
p.staff.api.remove(id);
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(&secret)).await;
assert_eq!(code, StatusCode::UNAUTHORIZED, "revoked at once");
}
#[tokio::test]
async fn without_the_gate_no_token_is_accepted() {
let p = plane(config(), false);
let (_, secret) = token(&p, &["carts:read"], None);
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(&secret)).await;
assert_eq!(code, StatusCode::UNAUTHORIZED, "the check fails closed");
}
#[tokio::test]
async fn a_station_without_a_key_has_no_tokens() {
let station = Station::new(StationId::new_random(), "Kettle FM").unwrap();
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(None));
let app = layer(
router(Arc::new(AppState {
station,
db: None,
room: None,
admin_token: Some(KEY.into()),
staff: staff.clone(),
hub,
join: JoinConfig::default(),
video: Default::default(),
joins: Default::default(),
turn_tls_port: 5349,
steering: Default::default(),
})),
staff,
);
let (code, v) = status(&app, "GET", "/api/v1/carts", Some("rsa_whatever")).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
assert!(
v["error"]
.as_str()
.unwrap()
.contains("WEAVESIGNAL_CONTROL_TOKEN_KEY"),
"{v}"
);
}
#[tokio::test]
async fn the_station_key_and_the_other_bearers_are_as_they_were() {
let p = plane(config(), true);
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(KEY)).await;
assert_eq!(code, StatusCode::OK);
let (code, _) = status(&p.app, "GET", "/api/v1/carts", None).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some("rss_notasession")).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, _) = status(&p.app, "GET", "/api/v1/retention", Some(KEY)).await;
assert_ne!(code, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn a_token_is_limited_per_minute() {
let p = plane(
ApiConfig {
token_per_minute: 3,
..config()
},
true,
);
let (_, one) = token(&p, &["carts:read"], None);
let (_, two) = token(&p, &["carts:read"], None);
for _ in 0..3 {
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(&one)).await;
assert_eq!(code, StatusCode::OK);
}
let res = call(&p.app, "GET", "/api/v1/carts", Some(&one), &[], Value::Null).await;
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers().get("retry-after").is_some());
// Another token, and the station key, are not held back by it.
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(&two)).await;
assert_eq!(code, StatusCode::OK);
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(KEY)).await;
assert_eq!(code, StatusCode::OK);
}
fn from(addr: &str) -> [(&'static str, &str); 1] {
[("x-forwarded-for", addr)]
}
#[tokio::test]
async fn the_open_routes_are_limited_per_address_by_the_last_forwarded_entry() {
let p = plane(
ApiConfig {
public_per_minute: 2,
..config()
},
true,
);
let get = |a: &'static str| {
let app = p.app.clone();
async move {
call(
&app,
"GET",
"/api/v1/public/station",
None,
&from(a),
Value::Null,
)
.await
.status()
}
};
// A client forging the first entry does not get a new allowance each time.
assert_ne!(
get("1.1.1.1, 203.0.113.9").await,
StatusCode::TOO_MANY_REQUESTS
);
assert_ne!(
get("2.2.2.2, 203.0.113.9").await,
StatusCode::TOO_MANY_REQUESTS
);
let res = call(
&p.app,
"GET",
"/api/v1/public/station",
None,
&from("3.3.3.3, 203.0.113.9"),
Value::Null,
)
.await;
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
res.headers().get("access-control-allow-origin").unwrap(),
"*",
"a browser can read the refusal"
);
// Another address has its own.
assert_ne!(get("203.0.113.10").await, StatusCode::TOO_MANY_REQUESTS);
// Routes that are not open are not counted against the address.
for _ in 0..5 {
let (code, _) = status(&p.app, "GET", "/api/v1/carts", Some(KEY)).await;
assert_eq!(code, StatusCode::OK);
}
}
fn header(res: &Response<Body>, name: &str) -> Option<String> {
res.headers()
.get(name)
.map(|v| v.to_str().unwrap().to_owned())
}
#[tokio::test]
async fn only_the_open_routes_allow_any_origin() {
let p = plane(config(), true);
let origin = [("origin", "https://kettle.example")];
for uri in [
"/api/v1/now-playing",
"/api/v1/now-playing/events",
"/api/v1/public/station",
"/api/v1/public/schedule",
"/api/v1/public/episodes",
] {
let res = call(&p.app, "GET", uri, None, &origin, Value::Null).await;
assert_eq!(
header(&res, "access-control-allow-origin").as_deref(),
Some("*"),
"{uri}"
);
assert_eq!(
header(&res, "access-control-allow-credentials"),
None,
"{uri}"
);
// A browser's preflight is answered, and offers reading only.
let pre = call(
&p.app,
"OPTIONS",
uri,
None,
&[
("origin", "https://kettle.example"),
("access-control-request-method", "GET"),
],
Value::Null,
)
.await;
assert_eq!(pre.status(), StatusCode::NO_CONTENT, "{uri}");
assert_eq!(
header(&pre, "access-control-allow-origin").as_deref(),
Some("*")
);
assert_eq!(
header(&pre, "access-control-allow-methods").as_deref(),
Some("GET, HEAD, OPTIONS")
);
}
for uri in [
"/api/v1/carts",
"/api/v1/studio",
"/api/v1/player",
"/api/v1/tokens",
"/api/v1/staff",
"/health",
] {
let res = call(&p.app, "GET", uri, Some(KEY), &origin, Value::Null).await;
assert_eq!(header(&res, "access-control-allow-origin"), None, "{uri}");
let pre = call(&p.app, "OPTIONS", uri, None, &origin, Value::Null).await;
assert_eq!(header(&pre, "access-control-allow-origin"), None, "{uri}");
assert_ne!(pre.status(), StatusCode::NO_CONTENT, "{uri}");
}
// A write on an open path is not offered to other origins.
let res = call(
&p.app,
"POST",
"/api/v1/public/station",
None,
&origin,
json!({}),
)
.await;
assert_eq!(header(&res, "access-control-allow-origin"), None);
}
#[tokio::test]
async fn open_answers_follow_the_listing_setting_and_the_rest_of_the_api_never_is() {
for listed in [false, true] {
let p = plane(config(), true);
let app = p.app.layer(axum::middleware::from_fn_with_state(
listed,
weavesignal_control::web::robots,
));
for uri in ["/api/v1/public/station", "/api/v1/now-playing"] {
let res = call(&app, "GET", uri, None, &[], Value::Null).await;
assert_eq!(
header(&res, "x-robots-tag").as_deref(),
(!listed).then_some("noindex, nofollow"),
"{uri} listed={listed}"
);
}
let res = call(&app, "GET", "/api/v1/player", None, &[], Value::Null).await;
assert_eq!(
header(&res, "x-robots-tag").as_deref(),
Some("noindex, nofollow"),
"/api/v1/player listed={listed}"
);
}
}
#[tokio::test]
async fn the_station_facts_are_the_players_public_ones_and_nobody_else() {
let p = plane(config(), true);
let (code, v) = status(&p.app, "GET", "/api/v1/public/station", None).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(v["station"], "Kettle FM");
assert_eq!(v["live"], false);
assert_eq!(v["call_in_open"], false);
let keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
for k in ["presenter", "consent_text", "relay", "callers", "chat"] {
assert!(!keys.contains(&k), "{k} leaked into {keys:?}");
}
}
fn announce(p: &Plane, on_air: &str, title: &str) -> Arc<OnAir> {
let onair = p
.hub
.onair
.get()
.cloned()
.unwrap_or_else(|| panic!("no playout layer"));
onair.announce(NowPlaying {
on_air: on_air.into(),
now: Some(weavesignal_control::onair::Current {
kind: "track".into(),
title: title.into(),
artist: "Fraser Macleod".into(),
album: String::new(),
started_at_ms: 0,
}),
next: None,
up_next_shown: false,
});
onair
}
fn with_onair(p: &Plane) {
// A playout layer that is not running: nothing polls, and `announce` drives it.
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://nobody@127.0.0.1:1/none")
.unwrap();
let onair = OnAir::new(
p.station.id,
pool,
Links {
playout: PlayoutClient::new(None),
listeners: PlayoutClient::new(None),
status_url: String::new(),
library_dir: "/nonexistent".into(),
logger_dir: "/nonexistent".into(),
listener_delay: Duration::ZERO,
library_lufs: -16.0,
library_max_seconds: 600.0,
},
&p.hub,
);
p.hub.onair.set(onair).ok().expect("set once");
}
/// The next chunk of a streaming body, as text, within a few seconds.
async fn next_chunk(body: &mut Body) -> String {
let frame = tokio::time::timeout(Duration::from_secs(5), body.frame())
.await
.expect("a chunk in time")
.expect("the stream is open")
.expect("no error");
String::from_utf8_lossy(frame.into_data().unwrap().as_ref()).into_owned()
}
#[tokio::test]
async fn the_now_playing_stream_is_open_and_delivers_a_change() {
let p = plane(config(), true);
with_onair(&p);
let res = call(
&p.app,
"GET",
"/api/v1/now-playing/events",
None,
&[],
Value::Null,
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
header(&res, "content-type").as_deref(),
Some("text/event-stream")
);
assert_eq!(
header(&res, "access-control-allow-origin").as_deref(),
Some("*")
);
let mut body = res.into_body();
// The state as it is now first...
let first = next_chunk(&mut body).await;
assert!(
first.contains("event: now_playing") && first.contains("\"unknown\""),
"{first}"
);
// ...then each change.
announce(&p, "automation", "The Kettle Song");
let change = next_chunk(&mut body).await;
assert!(
change.contains("\"on_air\":\"automation\"") && change.contains("The Kettle Song"),
"{change}"
);
}
#[tokio::test]
async fn one_address_holds_a_few_streams_open_and_no_more() {
let p = plane(
ApiConfig {
streams_per_address: 2,
..config()
},
true,
);
with_onair(&p);
let open = |a: &'static str| {
let app = p.app.clone();
async move {
call(
&app,
"GET",
"/api/v1/now-playing/events",
None,
&from(a),
Value::Null,
)
.await
}
};
let one = open("198.51.100.7").await;
let two = open("198.51.100.7").await;
assert_eq!(
(one.status(), two.status()),
(StatusCode::OK, StatusCode::OK)
);
assert_eq!(
open("198.51.100.7").await.status(),
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(open("198.51.100.8").await.status(), StatusCode::OK);
// A stream that ends gives its place back.
drop(one);
assert_eq!(open("198.51.100.7").await.status(), StatusCode::OK);
}
#[tokio::test]
async fn changes_at_the_station_become_events() {
let p = plane(config(), true);
with_onair(&p);
let mut rx = p.staff.api.events.subscribe();
events::spawn_sources(&p.hub, &p.staff, None, p.station.id);
tokio::task::yield_now().await;
// Playout's now-playing.
announce(&p, "desk", "Live from the studio");
let mut seen = Vec::new();
for _ in 0..2 {
let e = tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
seen.push((e.kind, e.data.clone()));
}
let kinds: Vec<_> = seen.iter().map(|(k, _)| *k).collect();
assert!(
kinds.contains(&"now_playing.changed") && kinds.contains(&"on_air.changed"),
"{kinds:?}"
);
let on_air = seen.iter().find(|(k, _)| *k == "on_air.changed").unwrap();
assert_eq!(on_air.1["on_air"], "desk");
assert_eq!(on_air.1["previous"], "unknown");
assert_eq!(on_air.1["live"], true);
// The same change again is no change.
announce(&p, "desk", "Live from the studio");
// A caller joining the queue.
let desk: DeskStatus = serde_json::from_value(json!({ "people": [
{"identity": "presenter-iain", "role": "presenter", "on_air": true, "talk": false, "present": true, "linked": true},
{"identity": "caller-7", "role": "caller", "on_air": false, "talk": false, "present": true, "linked": true},
]}))
.unwrap();
p.hub.with_studio(|s, now| s.sync(&desk, now));
let e = tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(e.kind, "caller.queued");
assert_eq!(e.data["identity"], "caller-7");
assert_eq!(e.data["ordinal"], 1);
assert!(
e.data.get("name").is_none()
&& e.data.get("topic").is_none()
&& e.data.get("consent").is_none(),
"{}",
e.data
);
// Seeing them again is not another arrival.
p.hub.with_studio(|s, now| s.sync(&desk, now + 1000));
assert!(
tokio::time::timeout(Duration::from_millis(300), rx.recv())
.await
.is_err()
);
}