Skip to main content

ironflow_api/routes/
events.rs

1//! SSE endpoint for real-time event streaming.
2
3use std::convert::Infallible;
4use std::str::FromStr;
5use std::time::Duration;
6
7use axum::extract::{Query, State};
8use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
9use futures_util::stream::{Stream, StreamExt};
10use serde::Deserialize;
11use serde::de::{self, Deserializer};
12use serde_json::json;
13use tokio::sync::broadcast;
14use tokio_stream::wrappers::BroadcastStream;
15use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
16use uuid::Uuid;
17
18use crate::authorization::AuthorizeExt;
19use crate::error::ApiError;
20use crate::state::AppState;
21use ironflow_auth::extractor::Authenticated;
22use ironflow_engine::notify::Event;
23use ironflow_store::entities::ApiKeyScope;
24
25pub use ironflow_store::entities::EventKind;
26
27/// Deserialize a comma-separated string into `Option<Vec<EventKind>>`.
28fn deserialize_comma_event_kinds<'de, D>(
29    deserializer: D,
30) -> Result<Option<Vec<EventKind>>, D::Error>
31where
32    D: Deserializer<'de>,
33{
34    let opt: Option<String> = Option::deserialize(deserializer)?;
35    match opt {
36        None => Ok(None),
37        Some(raw) => {
38            let kinds: Result<Vec<EventKind>, _> = raw
39                .split(',')
40                .map(|s| s.trim())
41                .filter(|s| !s.is_empty())
42                .map(EventKind::from_str)
43                .collect();
44            kinds.map(Some).map_err(de::Error::custom)
45        }
46    }
47}
48
49/// Query parameters for the SSE events endpoint.
50///
51/// Both fields are optional. When set, only matching events are streamed.
52///
53/// # Examples
54///
55/// ```
56/// use ironflow_api::routes::events::EventsQuery;
57///
58/// let query = EventsQuery {
59///     run_id: None,
60///     types: None,
61/// };
62/// ```
63#[derive(Debug, Deserialize)]
64pub struct EventsQuery {
65    /// Only stream events related to this run.
66    pub run_id: Option<Uuid>,
67    /// Comma-separated list of event types to include (e.g. `?types=run_status_changed,step_completed`).
68    #[serde(default, deserialize_with = "deserialize_comma_event_kinds")]
69    pub types: Option<Vec<EventKind>>,
70}
71
72/// Whether a caller that is not an admin may receive `event`.
73///
74/// An allow-list of run events. The match has no wildcard, so a new event
75/// kind does not compile until it is classified here.
76fn visible_to_non_admin(event: &Event) -> bool {
77    match event {
78        Event::RunCreated(_)
79        | Event::RunStatusChanged(_)
80        | Event::RunFailed(_)
81        | Event::RunBudgetExceeded(_)
82        | Event::RetryForced(_)
83        | Event::StepCompleted(_)
84        | Event::StepFailed(_)
85        | Event::ApprovalRequested(_)
86        | Event::ApprovalGranted(_)
87        | Event::ApprovalRejected(_)
88        | Event::ApprovalEscalated(_)
89        | Event::LogLine(_)
90        | Event::SignalAwaited(_)
91        | Event::SignalReceived(_) => true,
92        Event::UserSignedIn(_)
93        | Event::UserSignedUp(_)
94        | Event::UserSignedOut(_)
95        | Event::ProviderAccountUpdated(_)
96        | Event::ProviderAccountUsageUpdated(_)
97        | Event::ScheduleOccurrencesMissed(_)
98        | Event::WorkflowPaused(_)
99        | Event::WorkflowResumed(_) => false,
100    }
101}
102
103/// `GET /api/v1/events` -- Server-Sent Events stream.
104///
105/// Streams domain events in real time. Supports optional filtering:
106/// - `?run_id=<uuid>` -- only events for that run
107/// - `?types=run_status_changed,step_completed` -- only those event types
108///
109/// Each SSE message has:
110/// - `event:` set to the event type (e.g. `run_status_changed`)
111/// - `data:` JSON-serialized event payload
112///
113/// A keep-alive comment is sent every 30 seconds.
114///
115/// A caller that is not an admin (a member session, a key owned by a member,
116/// or any key without the `admin` scope) only receives run events:
117/// `run_created`, `run_status_changed`, `run_failed`, `run_budget_exceeded`,
118/// `retry_forced`, `step_completed`, `step_failed`, `approval_requested`,
119/// `approval_granted`, `approval_rejected`, `approval_escalated`, `log_line`,
120/// `signal_awaited` and `signal_received`. User sign-in, sign-up and sign-out,
121/// Provider Account, schedule and workflow pause/resume events reach only an admin session, or an
122/// API key holding the `admin` scope whose owner is an admin.
123///
124/// A client that reads slower than events are published loses the oldest
125/// ones. It then receives a `lagged` event, whatever its filters, with
126/// `data: {"missed": n}`: the number of events it missed. Its view is stale
127/// and it should reload its state.
128///
129/// # Errors
130///
131/// - 401 if the request is not authenticated
132/// - 403 `INSUFFICIENT_SCOPE` if the API key lacks `runs_read`
133/// - 429 `SSE_CONNECTION_LIMIT` if the caller (user or API key owner) or the
134///   server already holds as many event streams as
135///   [`SseLimits`](crate::sse_limiter::SseLimits) allows
136pub async fn events(
137    auth: Authenticated,
138    State(state): State<AppState>,
139    Query(query): Query<EventsQuery>,
140) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
141    auth.require(ApiKeyScope::RunsRead, false)?;
142    let connection = state.sse_limiter.try_acquire(auth.user_id)?;
143    let receiver = state.event_sender.subscribe();
144    // Account and user events describe admin-only resources: only an admin
145    // session, or an `admin`-scope key owned by an admin, sees them.
146    let sees_every_event = auth.require(ApiKeyScope::Admin, true).is_ok();
147    let stream = event_stream(receiver, query.run_id, query.types, sees_every_event);
148    // The stream owns the slot: axum drops it when the client disconnects.
149    let stream = stream.map(move |item| {
150        let _held = &connection;
151        item
152    });
153
154    let sse = Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(30)));
155    Ok(sse)
156}
157
158/// SSE event name sent to a client that fell behind and missed events.
159const LAGGED_EVENT: &str = "lagged";
160
161/// Turn the broadcast `receiver` into the SSE stream a client asked for.
162///
163/// A receiver that falls behind the channel capacity loses the oldest
164/// events: the client gets a [`LAGGED_EVENT`] carrying how many it missed.
165/// That event bypasses the filters, since which events were lost is unknown.
166fn event_stream(
167    receiver: broadcast::Receiver<Event>,
168    run_id_filter: Option<Uuid>,
169    type_filter: Option<Vec<EventKind>>,
170    sees_every_event: bool,
171) -> impl Stream<Item = Result<SseEvent, Infallible>> {
172    BroadcastStream::new(receiver).filter_map(move |result: Result<Event, _>| {
173        let type_filter = type_filter.clone();
174        async move {
175            let event = match result {
176                Ok(event) => event,
177                Err(BroadcastStreamRecvError::Lagged(missed)) => {
178                    let data = json!({ "missed": missed }).to_string();
179                    let sse_event = SseEvent::default().event(LAGGED_EVENT).data(data);
180                    return Some(Ok(sse_event));
181                }
182            };
183
184            if !sees_every_event && !visible_to_non_admin(&event) {
185                return None;
186            }
187
188            if let Some(ref rid) = run_id_filter
189                && event.run_id() != Some(*rid)
190            {
191                return None;
192            }
193
194            if let Some(ref kinds) = type_filter {
195                let event_type = event.event_type();
196                if !kinds.iter().any(|k| k.as_str() == event_type) {
197                    return None;
198                }
199            }
200
201            let data = serde_json::to_string(&event).ok()?;
202            let sse_event = SseEvent::default().event(event.event_type()).data(data);
203
204            Some(Ok::<_, Infallible>(sse_event))
205        }
206    })
207}
208
209#[cfg(test)]
210mod tests {
211    use std::collections::HashMap;
212    use std::sync::Arc;
213    use std::time::Duration;
214
215    use axum::Router;
216    use axum::body::Body;
217    use axum::response::IntoResponse;
218    use axum::response::sse::Sse;
219    use axum::routing::get;
220    use chrono::Utc;
221    use http_body_util::BodyExt;
222    use ironflow_auth::extractor::{API_KEY_PREFIX, API_KEY_SUFFIX_LEN};
223    use ironflow_auth::password;
224    use ironflow_core::providers::claude::ClaudeCodeProvider;
225    use ironflow_engine::engine::Engine;
226    use ironflow_engine::notify::{
227        Event, ProviderAccountChange, ProviderAccountUpdatedEvent, RunStatusChangedEvent,
228        UserSignedInEvent, UserSignedOutEvent, UserSignedUpEvent,
229    };
230    use ironflow_store::entities::{ApiKeyScope, NewApiKey, NewUser};
231    use ironflow_store::memory::InMemoryStore;
232    use ironflow_store::models::RunStatus;
233    use rust_decimal::Decimal;
234    use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
235    use tokio::net::{TcpListener, TcpStream};
236    use tokio::sync::broadcast;
237    use tokio::time::{sleep, timeout};
238    use uuid::Uuid;
239
240    use super::{EventKind, event_stream, events, visible_to_non_admin};
241    use crate::routes::test_helpers::create_user_auth_header;
242    use crate::sse_limiter::SseLimits;
243    use crate::state::AppState;
244
245    fn test_state() -> AppState {
246        let store = Arc::new(InMemoryStore::new());
247        let provider = Arc::new(ClaudeCodeProvider::new());
248        let engine = Arc::new(Engine::new(store.clone(), provider));
249        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
250            secret: "test-secret".to_string(),
251            access_token_ttl_secs: 900,
252            refresh_token_ttl_secs: 604800,
253            cookie_domain: None,
254            cookie_secure: false,
255        });
256        let (event_sender, _) = broadcast::channel::<Event>(16);
257        AppState::new(
258            store,
259            engine,
260            jwt_config,
261            "test-worker-token".to_string(),
262            event_sender,
263        )
264    }
265
266    fn sample_run_event(run_id: Uuid) -> Event {
267        Event::RunStatusChanged(RunStatusChangedEvent {
268            run_id,
269            workflow_name: "deploy".to_string(),
270            from: RunStatus::Running,
271            to: RunStatus::Completed,
272            error: None,
273            cost_usd: Decimal::ZERO,
274            duration_ms: 1000,
275            labels: HashMap::new(),
276            at: Utc::now(),
277        })
278    }
279
280    fn sample_user_event() -> Event {
281        Event::UserSignedIn(UserSignedInEvent {
282            user_id: Uuid::now_v7(),
283            username: "alice".to_string(),
284            at: Utc::now(),
285        })
286    }
287
288    fn sample_user_signed_up_event() -> Event {
289        Event::UserSignedUp(UserSignedUpEvent {
290            user_id: Uuid::now_v7(),
291            username: "bob".to_string(),
292            at: Utc::now(),
293        })
294    }
295
296    fn sample_user_signed_out_event() -> Event {
297        Event::UserSignedOut(UserSignedOutEvent {
298            user_id: Uuid::now_v7(),
299            at: Utc::now(),
300        })
301    }
302
303    fn sample_provider_account_event() -> Event {
304        Event::ProviderAccountUpdated(ProviderAccountUpdatedEvent {
305            account_id: Uuid::now_v7(),
306            name: "acct".into(),
307            change: ProviderAccountChange::Updated,
308            at: Utc::now(),
309        })
310    }
311
312    /// A `Bearer` header for an API key holding `scopes`, owned by a fresh
313    /// user who is an admin when `owner_is_admin` is set.
314    ///
315    /// The key is written to the store directly, so a member may hold scopes
316    /// the `/api-keys` route would refuse them.
317    async fn api_key_header(
318        state: &AppState,
319        owner_is_admin: bool,
320        scopes: Vec<ApiKeyScope>,
321    ) -> String {
322        let suffix = Uuid::now_v7().simple().to_string();
323        let owner = state
324            .store
325            .create_user(NewUser {
326                email: format!("owner-{suffix}@example.com"),
327                username: format!("owner-{suffix}"),
328                password_hash: "argon2hash".to_string(),
329                is_admin: Some(owner_is_admin),
330            })
331            .await
332            .unwrap();
333        let raw_key = format!("irfl_{}rest-of-secret-key", &suffix[24..]);
334        let prefix = &raw_key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN];
335        state
336            .store
337            .create_api_key(NewApiKey {
338                user_id: owner.id,
339                name: format!("key-{suffix}"),
340                key_hash: password::hash(&raw_key).unwrap(),
341                key_prefix: prefix.to_string(),
342                scopes,
343                expires_at: None,
344                rate_limit_override: None,
345            })
346            .await
347            .unwrap();
348        format!("Bearer {raw_key}")
349    }
350
351    /// Start a real TCP server and return (address, sender, auth header).
352    async fn start_sse_server(state: AppState) -> (String, broadcast::Sender<Event>, String) {
353        let sender = state.event_sender.clone();
354        let auth = create_user_auth_header(&state, "testuser", false).await;
355        let app = Router::new()
356            .route("/events", get(events))
357            .with_state(state);
358
359        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
360        let addr = listener.local_addr().unwrap().to_string();
361        tokio::spawn(async move {
362            axum::serve(listener, app).await.unwrap();
363        });
364        (addr, sender, auth)
365    }
366
367    /// Connect to the SSE endpoint with auth and return a line reader.
368    async fn connect_sse(addr: &str, query: &str, auth: &str) -> BufReader<TcpStream> {
369        let stream = TcpStream::connect(addr).await.unwrap();
370        let (reader, mut writer) = stream.into_split();
371
372        use tokio::io::AsyncWriteExt;
373        writer
374            .write_all(
375                format!(
376                    "GET /events{query} HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\nAuthorization: {auth}\r\n\r\n"
377                )
378                .as_bytes(),
379            )
380            .await
381            .unwrap();
382
383        BufReader::new(reader.reunite(writer).unwrap())
384    }
385
386    /// Read all available data from the SSE stream until `needle` is found
387    /// in the accumulated text, or timeout.
388    async fn read_until_contains(
389        reader: &mut BufReader<TcpStream>,
390        needle: &str,
391        dur: Duration,
392    ) -> String {
393        let mut accumulated = String::new();
394        let result = timeout(dur, async {
395            loop {
396                let mut line = String::new();
397                let n = reader.read_line(&mut line).await.unwrap();
398                if n == 0 {
399                    break;
400                }
401                accumulated.push_str(&line);
402                if accumulated.contains(needle) {
403                    break;
404                }
405            }
406        })
407        .await;
408        if result.is_err() {
409            panic!("timeout waiting for '{needle}' in SSE stream. Data so far:\n{accumulated}");
410        }
411        accumulated
412    }
413
414    /// Wait until the SSE handler has subscribed to the broadcast channel.
415    ///
416    /// Sending before a receiver exists makes `broadcast::Sender::send` return
417    /// `SendError`, so a fixed sleep is racy on slow runners. Poll the receiver
418    /// count instead.
419    async fn wait_for_subscriber(sender: &broadcast::Sender<Event>) {
420        let ready = timeout(Duration::from_secs(5), async {
421            while sender.receiver_count() == 0 {
422                sleep(Duration::from_millis(5)).await;
423            }
424        })
425        .await;
426        assert!(ready.is_ok(), "SSE handler did not subscribe in time");
427    }
428
429    #[tokio::test]
430    async fn sse_stream_receives_events() {
431        let state = test_state();
432        let (addr, sender, auth) = start_sse_server(state).await;
433        let mut reader = connect_sse(&addr, "", &auth).await;
434
435        wait_for_subscriber(&sender).await;
436
437        let run_id = Uuid::now_v7();
438        sender.send(sample_run_event(run_id)).unwrap();
439
440        let text =
441            read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
442
443        assert!(text.contains("run_status_changed"));
444        assert!(text.contains(&run_id.to_string()));
445    }
446
447    #[tokio::test]
448    async fn sse_filters_by_run_id() {
449        let state = test_state();
450        let (addr, sender, auth) = start_sse_server(state).await;
451
452        let target_run = Uuid::now_v7();
453        let other_run = Uuid::now_v7();
454
455        let mut reader = connect_sse(&addr, &format!("?run_id={target_run}"), &auth).await;
456        wait_for_subscriber(&sender).await;
457
458        sender.send(sample_run_event(other_run)).unwrap();
459        sender.send(sample_run_event(target_run)).unwrap();
460
461        let text =
462            read_until_contains(&mut reader, &target_run.to_string(), Duration::from_secs(5)).await;
463
464        assert!(text.contains(&target_run.to_string()));
465        assert!(!text.contains(&other_run.to_string()));
466    }
467
468    #[tokio::test]
469    async fn sse_filters_by_event_type() {
470        let state = test_state();
471        // User events only reach an admin.
472        let admin = create_user_auth_header(&state, "admin", true).await;
473        let (addr, sender, _member) = start_sse_server(state).await;
474
475        let mut reader = connect_sse(&addr, "?types=user_signed_in", &admin).await;
476        wait_for_subscriber(&sender).await;
477
478        let run_id = Uuid::now_v7();
479        sender.send(sample_run_event(run_id)).unwrap();
480        sender.send(sample_user_event()).unwrap();
481
482        let text = read_until_contains(&mut reader, "user_signed_in", Duration::from_secs(5)).await;
483
484        assert!(text.contains("user_signed_in"));
485        assert!(!text.contains("run_status_changed"));
486    }
487
488    #[tokio::test]
489    async fn sse_returns_correct_content_type() {
490        let state = test_state();
491        let (addr, _sender, auth) = start_sse_server(state).await;
492        let mut reader = connect_sse(&addr, "", &auth).await;
493
494        let text =
495            read_until_contains(&mut reader, "text/event-stream", Duration::from_secs(5)).await;
496
497        assert!(text.contains("text/event-stream"));
498    }
499
500    #[tokio::test]
501    async fn sse_rejects_unauthenticated() {
502        let state = test_state();
503        let (addr, _sender, _auth) = start_sse_server(state).await;
504        // Connect without auth header
505        let stream = TcpStream::connect(&addr).await.unwrap();
506        let (reader, mut writer) = stream.into_split();
507
508        use tokio::io::AsyncWriteExt;
509        writer
510            .write_all(
511                format!(
512                    "GET /events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\n\r\n"
513                )
514                .as_bytes(),
515            )
516            .await
517            .unwrap();
518
519        let mut buf_reader = BufReader::new(reader.reunite(writer).unwrap());
520        let text = read_until_contains(&mut buf_reader, "401", Duration::from_secs(5)).await;
521
522        assert!(text.contains("401"));
523    }
524
525    /// Stream a provider account event, then a run event, to a client
526    /// authenticated with `auth`, and assert only the run event arrives.
527    ///
528    /// Both events go through the same channel in order: had the provider
529    /// account event not been filtered out, it would arrive first.
530    async fn assert_provider_account_events_hidden(state: AppState, auth: &str) {
531        let (addr, sender, _jwt) = start_sse_server(state).await;
532        let mut reader = connect_sse(&addr, "", auth).await;
533        wait_for_subscriber(&sender).await;
534
535        let run_id = Uuid::now_v7();
536        sender.send(sample_provider_account_event()).unwrap();
537        sender.send(sample_run_event(run_id)).unwrap();
538
539        let text =
540            read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
541
542        assert!(!text.contains("provider_account"));
543    }
544
545    #[tokio::test]
546    async fn sse_scope_hides_provider_account_events_from_a_runs_read_key() {
547        let state = test_state();
548        let auth = api_key_header(&state, true, vec![ApiKeyScope::RunsRead]).await;
549        assert_provider_account_events_hidden(state, &auth).await;
550    }
551
552    #[tokio::test]
553    async fn sse_scope_hides_provider_account_events_from_an_admin_scope_key_of_a_member() {
554        let state = test_state();
555        let auth = api_key_header(&state, false, vec![ApiKeyScope::Admin]).await;
556        assert_provider_account_events_hidden(state, &auth).await;
557    }
558
559    #[tokio::test]
560    async fn sse_scope_streams_provider_account_events_to_an_admin_scope_key() {
561        let state = test_state();
562        let auth = api_key_header(&state, true, vec![ApiKeyScope::Admin]).await;
563        let (addr, sender, _jwt) = start_sse_server(state).await;
564        let mut reader = connect_sse(&addr, "", &auth).await;
565        wait_for_subscriber(&sender).await;
566
567        sender.send(sample_provider_account_event()).unwrap();
568
569        let text =
570            read_until_contains(&mut reader, "provider_account", Duration::from_secs(5)).await;
571
572        assert!(text.contains("provider_account"));
573    }
574
575    #[tokio::test]
576    async fn sse_scope_rejects_a_key_without_runs_read() {
577        let state = test_state();
578        let auth = api_key_header(&state, true, vec![ApiKeyScope::StatsRead]).await;
579        let (addr, _sender, _jwt) = start_sse_server(state).await;
580        let mut reader = connect_sse(&addr, "", &auth).await;
581
582        let text = read_until_contains(&mut reader, "\r\n", Duration::from_secs(5)).await;
583
584        assert!(text.starts_with("HTTP/1.1 403"), "status line: {text}");
585    }
586
587    /// Stream the three user events, then a run event, to a client
588    /// authenticated with `auth`, and assert only the run event arrives.
589    async fn assert_user_events_hidden(state: AppState, auth: Option<&str>) {
590        let (addr, sender, member) = start_sse_server(state).await;
591        let mut reader = connect_sse(&addr, "", auth.unwrap_or(&member)).await;
592        wait_for_subscriber(&sender).await;
593
594        let run_id = Uuid::now_v7();
595        sender.send(sample_user_event()).unwrap();
596        sender.send(sample_user_signed_up_event()).unwrap();
597        sender.send(sample_user_signed_out_event()).unwrap();
598        sender.send(sample_run_event(run_id)).unwrap();
599
600        let text =
601            read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
602
603        assert!(!text.contains("user_signed_in"), "stream: {text}");
604        assert!(!text.contains("user_signed_up"), "stream: {text}");
605        assert!(!text.contains("user_signed_out"), "stream: {text}");
606        assert!(text.contains("run_status_changed"));
607    }
608
609    #[tokio::test]
610    async fn sse_hides_user_sign_in_sign_up_and_sign_out_events_from_a_member() {
611        assert_user_events_hidden(test_state(), None).await;
612    }
613
614    #[tokio::test]
615    async fn sse_hides_user_events_from_a_runs_read_key_of_an_admin() {
616        let state = test_state();
617        let auth = api_key_header(&state, true, vec![ApiKeyScope::RunsRead]).await;
618        assert_user_events_hidden(state, Some(&auth)).await;
619    }
620
621    #[tokio::test]
622    async fn sse_streams_user_events_to_an_admin() {
623        let state = test_state();
624        let admin = create_user_auth_header(&state, "admin", true).await;
625        let (addr, sender, _member) = start_sse_server(state).await;
626        let mut reader = connect_sse(&addr, "", &admin).await;
627        wait_for_subscriber(&sender).await;
628
629        sender.send(sample_user_event()).unwrap();
630
631        let text = read_until_contains(&mut reader, "user_signed_in", Duration::from_secs(5)).await;
632
633        assert!(text.contains("user_signed_in"));
634    }
635
636    #[test]
637    fn visible_to_non_admin_allows_only_run_events() {
638        assert!(visible_to_non_admin(&sample_run_event(Uuid::now_v7())));
639        assert!(!visible_to_non_admin(&sample_user_event()));
640        assert!(!visible_to_non_admin(&sample_user_signed_up_event()));
641        assert!(!visible_to_non_admin(&sample_user_signed_out_event()));
642        assert!(!visible_to_non_admin(&sample_provider_account_event()));
643    }
644
645    /// A channel of capacity 2 whose receiver already missed 3 of the 5
646    /// events sent: the lag is deterministic, the stream is not polled yet.
647    fn lagging_receiver() -> (broadcast::Sender<Event>, broadcast::Receiver<Event>) {
648        let (sender, receiver) = broadcast::channel::<Event>(2);
649        for _ in 0..5 {
650            sender.send(sample_run_event(Uuid::now_v7())).unwrap();
651        }
652        (sender, receiver)
653    }
654
655    /// Read the SSE body until `needle` shows up, or time out.
656    async fn read_body_until(body: &mut Body, needle: &str) -> String {
657        let mut accumulated = String::new();
658        let result = timeout(Duration::from_secs(5), async {
659            while !accumulated.contains(needle) {
660                let Some(frame) = body.frame().await else {
661                    break;
662                };
663                if let Ok(data) = frame.unwrap().into_data() {
664                    accumulated.push_str(&String::from_utf8_lossy(&data));
665                }
666            }
667        })
668        .await;
669        assert!(
670            result.is_ok(),
671            "timeout waiting for '{needle}'. Data so far:\n{accumulated}"
672        );
673        accumulated
674    }
675
676    #[tokio::test]
677    async fn lagged_event_is_sent_when_the_subscriber_falls_behind() {
678        let (sender, receiver) = lagging_receiver();
679        let response = Sse::new(event_stream(receiver, None, None, false)).into_response();
680        let mut body = response.into_body();
681
682        let text = read_body_until(&mut body, "\"missed\"").await;
683        assert!(text.contains("lagged"), "got {text}");
684        assert!(text.contains("\"missed\":3"), "got {text}");
685
686        // The stream goes on after the notice.
687        let run_id = Uuid::now_v7();
688        sender.send(sample_run_event(run_id)).unwrap();
689        let text = read_body_until(&mut body, &run_id.to_string()).await;
690        assert!(text.contains("run_status_changed"), "got {text}");
691    }
692
693    #[tokio::test]
694    async fn lagged_event_bypasses_the_run_and_type_filters() {
695        let (_sender, receiver) = lagging_receiver();
696        let stream = event_stream(
697            receiver,
698            Some(Uuid::now_v7()),
699            Some(vec![EventKind::UserSignedIn]),
700            false,
701        );
702        let mut body = Sse::new(stream).into_response().into_body();
703
704        let text = read_body_until(&mut body, "\"missed\"").await;
705        assert!(text.contains("lagged"), "got {text}");
706        assert!(!text.contains("run_status_changed"), "got {text}");
707    }
708
709    /// Read the HTTP status line of a response.
710    async fn status_line(reader: &mut BufReader<TcpStream>) -> String {
711        read_until_contains(reader, "\r\n", Duration::from_secs(5)).await
712    }
713
714    /// Read the headers left after the status line, then the body they announce.
715    async fn read_body(reader: &mut BufReader<TcpStream>) -> String {
716        timeout(Duration::from_secs(5), async {
717            let mut content_length = 0;
718            loop {
719                let mut line = String::new();
720                reader.read_line(&mut line).await.unwrap();
721                if line.is_empty() || line == "\r\n" {
722                    break;
723                }
724                if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
725                    content_length = value.trim().parse().unwrap();
726                }
727            }
728            let mut body = vec![0; content_length];
729            reader.read_exact(&mut body).await.unwrap();
730            String::from_utf8(body).unwrap()
731        })
732        .await
733        .expect("timeout reading the response body")
734    }
735
736    fn limited_state(per_principal: usize) -> AppState {
737        test_state().with_sse_limits(SseLimits {
738            per_principal: Some(per_principal),
739            global: None,
740        })
741    }
742
743    #[tokio::test]
744    async fn events_returns_429_past_the_per_principal_limit() {
745        let state = limited_state(2);
746        let other_auth = create_user_auth_header(&state, "otheruser", false).await;
747        let (addr, _sender, auth) = start_sse_server(state).await;
748
749        let mut first = connect_sse(&addr, "", &auth).await;
750        assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
751        let mut second = connect_sse(&addr, "", &auth).await;
752        assert!(status_line(&mut second).await.starts_with("HTTP/1.1 200"));
753
754        let mut third = connect_sse(&addr, "", &auth).await;
755        let status = status_line(&mut third).await;
756        assert!(status.starts_with("HTTP/1.1 429"), "status line: {status}");
757        let body = read_body(&mut third).await;
758        assert!(body.contains("SSE_CONNECTION_LIMIT"), "body: {body}");
759
760        let mut other = connect_sse(&addr, "", &other_auth).await;
761        let status = status_line(&mut other).await;
762        assert!(
763            status.starts_with("HTTP/1.1 200"),
764            "another user keeps its own quota: {status}"
765        );
766    }
767
768    #[tokio::test]
769    async fn events_frees_the_slot_when_the_client_disconnects() {
770        let state = limited_state(1);
771        let (addr, _sender, auth) = start_sse_server(state).await;
772
773        let mut first = connect_sse(&addr, "", &auth).await;
774        assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
775        let mut refused = connect_sse(&addr, "", &auth).await;
776        assert!(status_line(&mut refused).await.starts_with("HTTP/1.1 429"));
777
778        drop(first);
779
780        let reopened = timeout(Duration::from_secs(5), async {
781            loop {
782                let mut retry = connect_sse(&addr, "", &auth).await;
783                if status_line(&mut retry).await.starts_with("HTTP/1.1 200") {
784                    return retry;
785                }
786                sleep(Duration::from_millis(20)).await;
787            }
788        })
789        .await;
790        assert!(reopened.is_ok(), "the slot was not freed after disconnect");
791    }
792}