Skip to main content

pamoja_dashboard/
serve.rs

1//! An HTTP/1.1 server that serves the dashboard over a pluggable byte transport.
2//!
3//! This is the host side of the local-first dashboard: it serves the static page, the
4//! language-neutral `GET /state` snapshot, and a `GET /events` server-sent-event
5//! stream for live updates, with no web framework and no async runtime. A thread per
6//! connection keeps it simple and is ample for the handful of clients a node sees over
7//! its own hotspot. The same code backs the [`Mock`](crate::Mock) in development and a
8//! real node in the field, since both are just a [`StateSource`].
9//!
10//! ## Why HTTP/1.1, and the seam for more
11//!
12//! HTTP/1.1 is the baseline on purpose: it is exactly what a browser speaks to a
13//! device over a plain `http://` hotspot, where there is no CA-trusted certificate and
14//! so no HTTPS (and therefore no browser HTTP/2, which is only negotiated over TLS).
15//! It also fits the smallest tiers, where a TLS plus HTTP/2 stack would not. The
16//! request handling is generic over any [`Read`] + [`Write`] stream, and connections
17//! arrive through a [`Transport`], so a capable tier can later supply a TLS transport
18//! (which negotiates HTTP/2 for free in the browser) without touching the request
19//! logic. [`TcpTransport`] is the plain-TCP baseline; a `rustls`-backed transport is
20//! the intended Tier A addition.
21
22use std::io::{BufRead, BufReader, Read, Write};
23use std::net::{TcpListener, TcpStream, ToSocketAddrs};
24use std::sync::{Arc, Mutex};
25use std::thread;
26use std::time::Duration;
27
28use flate2::write::GzEncoder;
29use flate2::Compression;
30use serde::Deserialize;
31
32use crate::assets::Assets;
33use crate::auth::Auth;
34use crate::catalog::Catalog;
35use crate::command::Command;
36use crate::source::StateSource;
37
38/// How connections reach the server: the seam that keeps the byte transport pluggable.
39///
40/// The baseline is [`TcpTransport`] (plain TCP, HTTP/1.1). A capable tier can
41/// implement this over a TLS stream so the browser negotiates HTTPS, and with it
42/// HTTP/2, while the request handling above stays unchanged.
43pub trait Transport: Send + 'static {
44    /// The connection type this transport yields, a bidirectional byte stream.
45    type Conn: Read + Write + Send + 'static;
46
47    /// Blocks until the next client connects.
48    ///
49    /// # Returns
50    ///
51    /// The accepted connection.
52    ///
53    /// # Errors
54    ///
55    /// Returns the [`std::io::Error`] from the underlying accept call.
56    fn accept(&self) -> std::io::Result<Self::Conn>;
57
58    /// A human-readable address for the startup log line, such as a URL.
59    ///
60    /// # Returns
61    ///
62    /// The address to print when the server starts serving.
63    fn describe(&self) -> String;
64}
65
66/// The plain-TCP, HTTP/1.1 transport: the baseline that works on any tier.
67pub struct TcpTransport {
68    listener: TcpListener,
69}
70
71impl TcpTransport {
72    /// Binds a listener on `addr`.
73    ///
74    /// # Arguments
75    ///
76    /// * `addr` - the address to listen on, such as `"0.0.0.0:80"`.
77    ///
78    /// # Returns
79    ///
80    /// A transport ready to accept connections.
81    ///
82    /// # Errors
83    ///
84    /// Returns the [`std::io::Error`] from binding if the address is unavailable.
85    pub fn bind(addr: impl ToSocketAddrs) -> std::io::Result<Self> {
86        Ok(Self {
87            listener: TcpListener::bind(addr)?,
88        })
89    }
90}
91
92impl Transport for TcpTransport {
93    type Conn = TcpStream;
94
95    fn accept(&self) -> std::io::Result<Self::Conn> {
96        self.listener.accept().map(|(stream, _)| stream)
97    }
98
99    fn describe(&self) -> String {
100        match self.listener.local_addr() {
101            Ok(addr) => format!("http://{addr}"),
102            Err(_) => "http://?".to_owned(),
103        }
104    }
105}
106
107/// The dashboard HTTP server, generic over whatever produces its state.
108///
109/// Build one with [`Server::new`], optionally set the live-update cadence with
110/// [`Server::with_push_interval`], then block in [`Server::run`] (plain TCP) or
111/// [`Server::run_on`] (a custom `Transport`).
112///
113/// # Examples
114///
115/// ```no_run
116/// use pamoja_dashboard::{Assets, Server, State, StateSource, Status};
117///
118/// // Any StateSource works; a real node produces its live State here, and the
119/// // `mock` feature's `Mock` is the hardware-free stand-in for development.
120/// struct Node;
121/// impl StateSource for Node {
122///     fn snapshot(&mut self) -> State {
123///         State { orgs: Vec::new(), status: Status::Ok, uptime_secs: None, demo: false }
124///     }
125/// }
126///
127/// let server = Server::new(Node, Assets::Embedded);
128/// server.run("127.0.0.1:8080").expect("serve");
129/// ```
130pub struct Server<S> {
131    source: Arc<Mutex<S>>,
132    assets: Assets,
133    push_interval: Duration,
134    auth: Arc<Auth>,
135    catalog: Option<Arc<String>>,
136}
137
138impl<S: StateSource + Send + 'static> Server<S> {
139    /// Creates a server that renders `source` with `assets`.
140    ///
141    /// Control is authenticated against a freshly generated pairing secret that nobody
142    /// holds yet, so no client can issue commands until [`with_pairing_secret`] sets the
143    /// secret the device actually shows. Read-only viewing needs no pairing.
144    ///
145    /// [`with_pairing_secret`]: Server::with_pairing_secret
146    ///
147    /// # Arguments
148    ///
149    /// * `source` - the state source to serve, a real node or a `Mock`.
150    /// * `assets` - where the page files come from, embedded or a directory.
151    ///
152    /// # Returns
153    ///
154    /// A server pushing live updates once a second by default.
155    pub fn new(source: S, assets: Assets) -> Self {
156        Self {
157            source: Arc::new(Mutex::new(source)),
158            assets,
159            push_interval: Duration::from_secs(1),
160            auth: Arc::new(Auth::new(Auth::generate_secret())),
161            catalog: None,
162        }
163    }
164
165    /// Serves a presentation [`Catalog`] at `GET /catalog` so the page can show the
166    /// deployment's custom sensors, stats, and theme.
167    ///
168    /// Build the catalog from the profiles the deployment runs with
169    /// [`Catalog::from_profiles`]. A catalog with nothing custom is not worth serving;
170    /// skip this call and the page keeps its built-in defaults.
171    ///
172    /// # Arguments
173    ///
174    /// * `catalog` - the presentation catalog to serve.
175    ///
176    /// # Returns
177    ///
178    /// The server, for chaining.
179    pub fn with_catalog(mut self, catalog: Catalog) -> Self {
180        self.catalog = catalog
181            .to_json()
182            .ok()
183            .filter(|_| !catalog.is_empty())
184            .map(Arc::new);
185        self
186    }
187
188    /// Sets the pairing secret a client must know to issue commands.
189    ///
190    /// The secret is shown out of band (the device's screen, a QR code, or the dev
191    /// server's console) and never crosses the network.
192    ///
193    /// # Arguments
194    ///
195    /// * `secret` - the canonical pairing secret.
196    ///
197    /// # Returns
198    ///
199    /// The server, for chaining.
200    pub fn with_pairing_secret(mut self, secret: impl Into<String>) -> Self {
201        self.auth = Arc::new(Auth::new(secret));
202        self
203    }
204
205    /// Sets how often the `GET /events` stream pushes a fresh snapshot.
206    ///
207    /// # Arguments
208    ///
209    /// * `interval` - the delay between pushes.
210    ///
211    /// # Returns
212    ///
213    /// The server, for chaining.
214    pub fn with_push_interval(mut self, interval: Duration) -> Self {
215        self.push_interval = interval;
216        self
217    }
218
219    /// Binds `addr` over plain TCP and serves forever.
220    ///
221    /// # Arguments
222    ///
223    /// * `addr` - the address to listen on, such as `"0.0.0.0:80"`.
224    ///
225    /// # Returns
226    ///
227    /// Never returns on success; it serves until the process ends.
228    ///
229    /// # Errors
230    ///
231    /// Returns the [`std::io::Error`] from binding the listener.
232    pub fn run(self, addr: impl ToSocketAddrs) -> std::io::Result<()> {
233        let transport = TcpTransport::bind(addr)?;
234        self.run_on(transport)
235    }
236
237    /// Serves forever over a supplied `Transport`, one thread per connection.
238    ///
239    /// This is the seam for a non-default transport, such as a future TLS transport
240    /// for a capable tier.
241    ///
242    /// # Arguments
243    ///
244    /// * `transport` - the source of connections.
245    ///
246    /// # Returns
247    ///
248    /// Never returns on success; it serves until the process ends.
249    ///
250    /// # Errors
251    ///
252    /// Returns a [`std::io::Error`] only if accepting fails unrecoverably; transient
253    /// accept errors are skipped.
254    pub fn run_on<T: Transport>(self, transport: T) -> std::io::Result<()> {
255        println!("pamoja-dashboard: serving on {}", transport.describe());
256        loop {
257            let conn = match transport.accept() {
258                Ok(conn) => conn,
259                Err(_) => continue,
260            };
261            let source = Arc::clone(&self.source);
262            let assets = self.assets.clone();
263            let interval = self.push_interval;
264            let auth = Arc::clone(&self.auth);
265            let catalog = self.catalog.clone();
266            thread::spawn(move || {
267                let _ = handle(conn, source, assets, interval, auth, catalog);
268            });
269        }
270    }
271}
272
273// One parsed request line: the method, the path, the raw query string, any body, and
274// whether the client accepts a gzip-encoded response.
275struct Request {
276    method: String,
277    path: String,
278    query: String,
279    body: Vec<u8>,
280    accept_gzip: bool,
281}
282
283// A client's proof that it derived the session key during pairing.
284#[derive(Deserialize)]
285#[serde(rename_all = "camelCase")]
286struct ConfirmRequest {
287    session_id: String,
288    mac: String,
289}
290
291// An authenticated command: the session, its replay counter, the exact command string
292// that was signed, and the MAC over (counter, command).
293#[derive(Deserialize)]
294#[serde(rename_all = "camelCase")]
295struct CommandRequest {
296    session_id: String,
297    counter: u64,
298    cmd: String,
299    mac: String,
300}
301
302fn handle<S: StateSource, C: Read + Write>(
303    mut conn: C,
304    source: Arc<Mutex<S>>,
305    assets: Assets,
306    interval: Duration,
307    auth: Arc<Auth>,
308    catalog: Option<Arc<String>>,
309) -> std::io::Result<()> {
310    let request = match read_request(&mut conn)? {
311        Some(request) => request,
312        None => return Ok(()),
313    };
314
315    // A `?scenario=` parameter is a dev affordance: it asks the source to switch view.
316    if let Some(scenario) = query_value(&request.query, "scenario") {
317        if let Ok(mut source) = source.lock() {
318            source.select(&scenario);
319        }
320    }
321
322    match (request.method.as_str(), request.path.as_str()) {
323        ("GET", "/state") => {
324            let json = snapshot_json(&source);
325            write_response(
326                &mut conn,
327                200,
328                "OK",
329                "application/json; charset=utf-8",
330                json.as_bytes(),
331            )
332        }
333        ("GET", "/catalog") => match &catalog {
334            Some(json) => write_json(&mut conn, 200, "OK", json),
335            None => write_response(
336                &mut conn,
337                204,
338                "No Content",
339                "application/json; charset=utf-8",
340                b"",
341            ),
342        },
343        ("GET", "/locales") => {
344            // The locales this build actually embeds, so the page offers only the languages a
345            // Tier B firmware kept in flash. A static host has no device and the page keeps its
346            // full built-in list.
347            let tags = crate::assets::embedded_locales();
348            let json = format!(
349                "[{}]",
350                tags.iter()
351                    .map(|tag| format!("\"{tag}\""))
352                    .collect::<Vec<_>>()
353                    .join(",")
354            );
355            write_json(&mut conn, 200, "OK", &json)
356        }
357        ("GET", "/lite") => {
358            // The no-JavaScript floor: a status table built once on the device, refreshed by
359            // a meta tag. The embedded floor page bounces here when scripting is off.
360            let html = match source.lock() {
361                Ok(mut source) => crate::lite::render_lite(&source.snapshot()),
362                Err(_) => crate::lite::render_unavailable(),
363            };
364            write_response(
365                &mut conn,
366                200,
367                "OK",
368                "text/html; charset=utf-8",
369                html.as_bytes(),
370            )
371        }
372        ("GET", "/events") => stream_events(&mut conn, &source, interval),
373        ("GET", "/pair/challenge") => {
374            let challenge = auth.challenge();
375            let json = format!(
376                r#"{{"sessionId":"{}","nonce":"{}"}}"#,
377                challenge.session_id, challenge.nonce
378            );
379            write_json(&mut conn, 200, "OK", &json)
380        }
381        ("POST", "/pair/confirm") => {
382            match serde_json::from_slice::<ConfirmRequest>(&request.body) {
383                Ok(confirm) => match auth.confirm(&confirm.session_id, &confirm.mac) {
384                    Ok(()) => write_json(&mut conn, 200, "OK", "{}"),
385                    Err(error) => write_json(
386                        &mut conn,
387                        401,
388                        "Unauthorized",
389                        &format!(r#"{{"error":"{}"}}"#, error.code()),
390                    ),
391                },
392                Err(_) => write_json(&mut conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
393            }
394        }
395        ("POST", "/command") => handle_command(&mut conn, &source, &auth, &request.body),
396        ("GET", path) => match assets.get(path) {
397            Some((content_type, bytes)) => {
398                write_asset(&mut conn, content_type, &bytes, request.accept_gzip)
399            }
400            None => write_response(&mut conn, 404, "Not Found", "text/plain", b"not found"),
401        },
402        _ => write_response(
403            &mut conn,
404            405,
405            "Method Not Allowed",
406            "text/plain",
407            b"method not allowed",
408        ),
409    }
410}
411
412// Authenticates a command, dispatches it to the source, and writes the result.
413fn handle_command<S: StateSource, W: Write>(
414    conn: &mut W,
415    source: &Arc<Mutex<S>>,
416    auth: &Arc<Auth>,
417    body: &[u8],
418) -> std::io::Result<()> {
419    let request: CommandRequest = match serde_json::from_slice(body) {
420        Ok(request) => request,
421        Err(_) => return write_json(conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
422    };
423    if let Err(error) = auth.verify_command(
424        &request.session_id,
425        request.counter,
426        &request.cmd,
427        &request.mac,
428    ) {
429        return write_json(
430            conn,
431            401,
432            "Unauthorized",
433            &format!(r#"{{"error":"{}"}}"#, error.code()),
434        );
435    }
436    let command: Command = match serde_json::from_str(&request.cmd) {
437        Ok(command) => command,
438        Err(_) => return write_json(conn, 400, "Bad Request", r#"{"error":"bad_request"}"#),
439    };
440    let outcome = match source.lock() {
441        Ok(mut source) => source.command(&command),
442        Err(_) => {
443            return write_json(
444                conn,
445                500,
446                "Internal Server Error",
447                r#"{"error":"internal"}"#,
448            )
449        }
450    };
451    match outcome {
452        Ok(()) => write_json(conn, 200, "OK", "{}"),
453        Err(error) => write_json(
454            conn,
455            422,
456            "Unprocessable Entity",
457            &format!(r#"{{"error":"{}"}}"#, error.code()),
458        ),
459    }
460}
461
462// Reads and parses the request line and drains the headers and any body. Returns
463// `None` on an empty or malformed request.
464fn read_request<C: Read>(conn: &mut C) -> std::io::Result<Option<Request>> {
465    let mut reader = BufReader::new(conn);
466
467    let mut line = String::new();
468    if reader.read_line(&mut line)? == 0 {
469        return Ok(None);
470    }
471    let mut parts = line.split_whitespace();
472    let (Some(method), Some(target)) = (parts.next(), parts.next()) else {
473        return Ok(None);
474    };
475    let (path, query) = match target.split_once('?') {
476        Some((path, query)) => (path.to_owned(), query.to_owned()),
477        None => (target.to_owned(), String::new()),
478    };
479
480    // Drain the headers, noting a body length so a POST can be consumed politely and
481    // whether the client accepts a gzip-encoded response.
482    let mut content_length = 0usize;
483    let mut accept_gzip = false;
484    loop {
485        let mut header = String::new();
486        if reader.read_line(&mut header)? == 0 {
487            break;
488        }
489        let header = header.trim_end();
490        if header.is_empty() {
491            break;
492        }
493        // Header names are case-insensitive; clients send these in any case.
494        if let Some((name, value)) = header.split_once(':') {
495            if name.eq_ignore_ascii_case("content-length") {
496                content_length = value.trim().parse().unwrap_or(0);
497            } else if name.eq_ignore_ascii_case("accept-encoding") {
498                accept_gzip = value.to_ascii_lowercase().contains("gzip");
499            }
500        }
501    }
502    let mut body = Vec::new();
503    if content_length > 0 {
504        body = vec![0u8; content_length];
505        reader.read_exact(&mut body)?;
506    }
507
508    Ok(Some(Request {
509        method: method.to_owned(),
510        path,
511        query,
512        body,
513        accept_gzip,
514    }))
515}
516
517// Pulls one value out of a `key=value&...` query string.
518fn query_value(query: &str, key: &str) -> Option<String> {
519    query.split('&').find_map(|pair| {
520        let (name, value) = pair.split_once('=')?;
521        (name == key).then(|| value.to_owned())
522    })
523}
524
525fn snapshot_json<S: StateSource>(source: &Arc<Mutex<S>>) -> String {
526    match source.lock() {
527        Ok(mut source) => source
528            .snapshot()
529            .to_json()
530            .unwrap_or_else(|_| "{}".to_owned()),
531        Err(_) => "{}".to_owned(),
532    }
533}
534
535// Streams a fresh snapshot as a server-sent event on a repeating cadence until the
536// client disconnects, which surfaces as a write error.
537fn stream_events<S: StateSource, W: Write>(
538    conn: &mut W,
539    source: &Arc<Mutex<S>>,
540    interval: Duration,
541) -> std::io::Result<()> {
542    let headers = "HTTP/1.1 200 OK\r\n\
543         Content-Type: text/event-stream\r\n\
544         Cache-Control: no-cache\r\n\
545         Connection: keep-alive\r\n\r\n";
546    conn.write_all(headers.as_bytes())?;
547    conn.flush()?;
548    loop {
549        let json = snapshot_json(source);
550        if conn
551            .write_all(format!("data: {json}\n\n").as_bytes())
552            .is_err()
553        {
554            break;
555        }
556        if conn.flush().is_err() {
557            break;
558        }
559        thread::sleep(interval);
560    }
561    Ok(())
562}
563
564// Serves a static asset, gzip-encoded when the client accepts it. The one-time asset load
565// is the dominant transfer over a weak hotspot link, so compressing it is the main win; a
566// client that does not accept gzip still gets the identity bytes.
567fn write_asset<W: Write>(
568    conn: &mut W,
569    content_type: &str,
570    bytes: &[u8],
571    gzip: bool,
572) -> std::io::Result<()> {
573    if !gzip {
574        return write_response(conn, 200, "OK", content_type, bytes);
575    }
576    let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
577    encoder.write_all(bytes)?;
578    let compressed = encoder.finish()?;
579    let header = format!(
580        "HTTP/1.1 200 OK\r\n\
581         Content-Type: {content_type}\r\n\
582         Content-Encoding: gzip\r\n\
583         Content-Length: {len}\r\n\
584         Connection: close\r\n\r\n",
585        len = compressed.len(),
586    );
587    conn.write_all(header.as_bytes())?;
588    conn.write_all(&compressed)?;
589    conn.flush()
590}
591
592// Writes a JSON response with the given status.
593fn write_json<W: Write>(conn: &mut W, code: u16, reason: &str, json: &str) -> std::io::Result<()> {
594    write_response(
595        conn,
596        code,
597        reason,
598        "application/json; charset=utf-8",
599        json.as_bytes(),
600    )
601}
602
603fn write_response<W: Write>(
604    conn: &mut W,
605    code: u16,
606    reason: &str,
607    content_type: &str,
608    body: &[u8],
609) -> std::io::Result<()> {
610    let header = format!(
611        "HTTP/1.1 {code} {reason}\r\n\
612         Content-Type: {content_type}\r\n\
613         Content-Length: {len}\r\n\
614         Connection: close\r\n\r\n",
615        len = body.len(),
616    );
617    conn.write_all(header.as_bytes())?;
618    conn.write_all(body)?;
619    conn.flush()
620}
621
622// The server tests drive the handler with the demo fleet as their state source, so they
623// build only with the `mock` feature; CI runs them with `--features mock`.
624#[cfg(all(test, feature = "mock"))]
625mod tests {
626    use super::*;
627    use crate::{Mock, Scenario};
628
629    #[test]
630    fn query_value_extracts_a_named_parameter() {
631        assert_eq!(
632            query_value("scenario=alarm&locale=sw", "scenario").as_deref(),
633            Some("alarm")
634        );
635        assert_eq!(query_value("locale=sw", "scenario"), None);
636        assert_eq!(query_value("", "scenario"), None);
637    }
638
639    fn handle_request(request: &[u8], auth: Arc<Auth>) -> String {
640        let mut conn = MemConn::new(request);
641        let source = Arc::new(Mutex::new(Mock::new(Scenario::Normal)));
642        handle(
643            &mut conn,
644            source,
645            Assets::Embedded,
646            Duration::from_millis(0),
647            auth,
648            None,
649        )
650        .expect("handled");
651        String::from_utf8_lossy(&conn.output).into_owned()
652    }
653
654    #[test]
655    fn a_get_state_request_is_served_as_json_over_any_stream() {
656        // The handler runs over an in-memory stream with no socket, proving it is
657        // transport-agnostic: this is exactly what a TLS transport would plug into.
658        let mut conn = MemConn::new(b"GET /state HTTP/1.1\r\nHost: x\r\n\r\n");
659        let source = Arc::new(Mutex::new(Mock::new(Scenario::Alarm)));
660        handle(
661            &mut conn,
662            source,
663            Assets::Embedded,
664            Duration::from_millis(0),
665            Arc::new(Auth::new("secret")),
666            None,
667        )
668        .expect("handled");
669        let written = String::from_utf8_lossy(&conn.output);
670        assert!(written.contains("200 OK"));
671        assert!(written.contains("\"status\":\"alarm\""));
672    }
673
674    #[test]
675    fn an_unknown_path_is_a_404() {
676        let written = handle_request(b"GET /nope HTTP/1.1\r\n\r\n", Arc::new(Auth::new("secret")));
677        assert!(written.contains("404 Not Found"));
678    }
679
680    #[test]
681    fn a_get_catalog_serves_the_presentation_catalog_when_one_is_set() {
682        use pamoja_profile::{ElementSpec, Presentation, Profile, Viz};
683
684        let profile = Profile::well_level().with_presentation(Presentation::new().with_element(
685            ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge).with_band(0.0, 5.0),
686        ));
687        let catalog = Catalog::from_profiles(&[&profile])
688            .to_json()
689            .expect("serialize catalog");
690
691        let mut conn = MemConn::new(b"GET /catalog HTTP/1.1\r\n\r\n");
692        handle(
693            &mut conn,
694            Arc::new(Mutex::new(Mock::new(Scenario::Normal))),
695            Assets::Embedded,
696            Duration::from_millis(0),
697            Arc::new(Auth::new("s")),
698            Some(Arc::new(catalog)),
699        )
700        .expect("handled");
701
702        let written = String::from_utf8_lossy(&conn.output);
703        assert!(written.contains("200 OK"));
704        assert!(written.contains("water_turbidity"));
705        assert!(written.contains("\"viz\":\"radial\""));
706    }
707
708    #[test]
709    fn a_get_catalog_is_no_content_without_a_catalog() {
710        let written = handle_request(b"GET /catalog HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
711        assert!(written.contains("204 No Content"));
712    }
713
714    #[cfg(not(feature = "tier-c"))]
715    #[test]
716    fn a_get_locales_lists_the_embedded_locales() {
717        let written = handle_request(b"GET /locales HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
718        assert!(written.contains("200 OK"));
719        // English is always embedded, so it is always listed; the page narrows to this set.
720        assert!(written.contains("\"en\""));
721    }
722
723    #[test]
724    fn a_get_lite_serves_a_no_script_status_table() {
725        let written = handle_request(b"GET /lite HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
726        assert!(written.contains("200 OK"));
727        assert!(written.contains("text/html"));
728        assert!(written.contains("http-equiv=\"refresh\""));
729        assert!(!written.contains("<script"));
730    }
731
732    #[test]
733    fn a_challenge_then_confirm_pairs_over_http() {
734        use pamoja_session::{hkdf_sha256, hmac_sha256};
735
736        let auth = Arc::new(Auth::new("s3cret"));
737
738        // The challenge returns a session id and nonce as JSON.
739        let challenge = handle_request(b"GET /pair/challenge HTTP/1.1\r\n\r\n", Arc::clone(&auth));
740        assert!(challenge.contains("200 OK"));
741        let body = challenge.split("\r\n\r\n").nth(1).expect("body");
742        let session_id = field(body, "sessionId");
743        let nonce = field(body, "nonce");
744
745        // The client derives the key from the known secret and the nonce, then proves it.
746        let mut key = [0u8; 32];
747        hkdf_sha256(
748            nonce.as_bytes(),
749            b"s3cret",
750            b"pamoja/dashboard/cmd v1",
751            &mut key,
752        );
753        let mac = hmac_sha256(&key, format!("confirm\n{session_id}").as_bytes())
754            .iter()
755            .map(|b| format!("{b:02x}"))
756            .collect::<String>();
757        let confirm_body = format!(r#"{{"sessionId":"{session_id}","mac":"{mac}"}}"#);
758        let request = format!(
759            "POST /pair/confirm HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}",
760            confirm_body.len(),
761            confirm_body
762        );
763        let confirm = handle_request(request.as_bytes(), Arc::clone(&auth));
764        assert!(confirm.contains("200 OK"), "confirm response: {confirm}");
765    }
766
767    // Pulls a string field out of a small flat JSON object.
768    fn field(json: &str, key: &str) -> String {
769        let needle = format!("\"{key}\":\"");
770        let start = json.find(&needle).expect("key present") + needle.len();
771        let rest = &json[start..];
772        rest[..rest.find('"').expect("closing quote")].to_owned()
773    }
774
775    #[test]
776    fn an_asset_is_gzipped_when_the_client_accepts_it() {
777        use flate2::read::GzDecoder;
778        use std::io::Read as _;
779
780        // The page shell at `/` is present in every tier, so this stays tier-agnostic.
781        let mut conn = MemConn::new(b"GET / HTTP/1.1\r\nAccept-Encoding: gzip, deflate\r\n\r\n");
782        handle(
783            &mut conn,
784            Arc::new(Mutex::new(Mock::new(Scenario::Normal))),
785            Assets::Embedded,
786            Duration::from_millis(0),
787            Arc::new(Auth::new("secret")),
788            None,
789        )
790        .expect("handled");
791
792        let split = conn
793            .output
794            .windows(4)
795            .position(|w| w == b"\r\n\r\n")
796            .expect("headers end")
797            + 4;
798        let head = String::from_utf8_lossy(&conn.output[..split]);
799        assert!(head.contains("Content-Encoding: gzip"), "head: {head}");
800
801        let mut decoded = Vec::new();
802        GzDecoder::new(&conn.output[split..])
803            .read_to_end(&mut decoded)
804            .expect("gunzip");
805        let (_, original) = Assets::Embedded.get("/").expect("asset");
806        assert_eq!(decoded, original, "gunzipped body matches the source asset");
807    }
808
809    #[test]
810    fn an_asset_is_identity_without_accept_encoding() {
811        let written = handle_request(b"GET / HTTP/1.1\r\n\r\n", Arc::new(Auth::new("s")));
812        assert!(written.contains("200 OK"));
813        assert!(!written.contains("Content-Encoding"));
814    }
815
816    // An in-memory connection: reads from a fixed request, collects the response.
817    struct MemConn {
818        input: std::io::Cursor<Vec<u8>>,
819        output: Vec<u8>,
820    }
821
822    impl MemConn {
823        fn new(request: &[u8]) -> Self {
824            Self {
825                input: std::io::Cursor::new(request.to_vec()),
826                output: Vec::new(),
827            }
828        }
829    }
830
831    impl Read for MemConn {
832        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
833            self.input.read(buf)
834        }
835    }
836
837    impl Write for MemConn {
838        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
839            self.output.extend_from_slice(buf);
840            Ok(buf.len())
841        }
842
843        fn flush(&mut self) -> std::io::Result<()> {
844            Ok(())
845        }
846    }
847}