|
@@ -0,0 +1,445 @@
|
|
|
|
|
+//! Optional web control panel (enabled by `web_port`): a minimal HTTP server with no authentication
|
|
|
|
|
+//! that listens on every network interface (`0.0.0.0`) and drives the application through `AppEvent::Web`.
|
|
|
|
|
+
|
|
|
|
|
+use std::io::Read;
|
|
|
|
|
+
|
|
|
|
|
+use anyhow::{Context, Result, anyhow};
|
|
|
|
|
+use serde::Serialize;
|
|
|
|
|
+use tiny_http::{Header, Method, Request, Response, Server};
|
|
|
|
|
+use tokio::sync::mpsc::UnboundedSender;
|
|
|
|
|
+use tokio::sync::oneshot;
|
|
|
|
|
+
|
|
|
|
|
+use crate::app::AppEvent;
|
|
|
|
|
+
|
|
|
|
|
+/// Name of the thread that serves HTTP requests.
|
|
|
|
|
+const WEB_THREAD_NAME: &str = "wh-web";
|
|
|
|
|
+/// Network address the control server binds to (all interfaces, no authentication).
|
|
|
|
|
+const BIND_ADDRESS: &str = "0.0.0.0";
|
|
|
|
|
+
|
|
|
|
|
+/// Route path of the control panel page.
|
|
|
|
|
+const PATH_PAGE: &str = "/";
|
|
|
|
|
+/// Route path that returns the current state.
|
|
|
|
|
+const PATH_STATE: &str = "/api/state";
|
|
|
|
|
+/// Route path that toggles the global autoreply.
|
|
|
|
|
+const PATH_GLOBAL: &str = "/api/global";
|
|
|
|
|
+/// Route path that toggles the autoreply of one chat.
|
|
|
|
|
+const PATH_CHAT: &str = "/api/chat";
|
|
|
|
|
+/// Route path that quits the bot.
|
|
|
|
|
+const PATH_QUIT: &str = "/api/quit";
|
|
|
|
|
+
|
|
|
|
|
+/// Maximum accepted size, in bytes, of a request body.
|
|
|
|
|
+const MAX_BODY_BYTES: usize = 1024;
|
|
|
|
|
+
|
|
|
|
|
+/// HTTP status: success.
|
|
|
|
|
+const STATUS_OK: u16 = 200;
|
|
|
|
|
+/// HTTP status: malformed request.
|
|
|
|
|
+const STATUS_BAD_REQUEST: u16 = 400;
|
|
|
|
|
+/// HTTP status: unknown route.
|
|
|
|
|
+const STATUS_NOT_FOUND: u16 = 404;
|
|
|
|
|
+/// HTTP status: the response could not be serialized.
|
|
|
|
|
+const STATUS_INTERNAL_ERROR: u16 = 500;
|
|
|
|
|
+/// HTTP status: the request lacks the mandatory custom header.
|
|
|
|
|
+const STATUS_FORBIDDEN: u16 = 403;
|
|
|
|
|
+/// HTTP status: the application cannot answer.
|
|
|
|
|
+const STATUS_UNAVAILABLE: u16 = 503;
|
|
|
|
|
+
|
|
|
|
|
+/// Name of the custom header every POST request must carry (forces a CORS preflight, never approved).
|
|
|
|
|
+const REQUEST_HEADER_NAME: &str = "X-Wh-Request";
|
|
|
|
|
+/// Required value of the custom request header.
|
|
|
|
|
+const REQUEST_HEADER_VALUE: &str = "1";
|
|
|
|
|
+
|
|
|
|
|
+/// JSON key holding the human readable status in API responses.
|
|
|
|
|
+const JSON_KEY_STATUS: &str = "status";
|
|
|
|
|
+
|
|
|
|
|
+/// Name of the content type header.
|
|
|
|
|
+const HEADER_CONTENT_TYPE: &str = "Content-Type";
|
|
|
|
|
+/// Content type of the control panel page.
|
|
|
|
|
+const CONTENT_TYPE_HTML: &str = "text/html; charset=utf-8";
|
|
|
|
|
+/// Content type of API responses.
|
|
|
|
|
+const CONTENT_TYPE_JSON: &str = "application/json";
|
|
|
|
|
+/// Content type of plain text responses.
|
|
|
|
|
+const CONTENT_TYPE_TEXT: &str = "text/plain; charset=utf-8";
|
|
|
|
|
+
|
|
|
|
|
+/// Control panel page served at `/`.
|
|
|
|
|
+const PAGE_HTML: &str = include_str!("web_page.html");
|
|
|
|
|
+
|
|
|
|
|
+/// Message returned when the bot is shutting down.
|
|
|
|
|
+const QUIT_MESSAGE: &str = "Bot in chiusura";
|
|
|
|
|
+/// Body returned when the application cannot answer.
|
|
|
|
|
+const UNAVAILABLE_MESSAGE: &str = "bot non disponibile";
|
|
|
|
|
+/// Body returned for unknown routes.
|
|
|
|
|
+const NOT_FOUND_MESSAGE: &str = "non trovato";
|
|
|
|
|
+/// Body returned when a POST request lacks the mandatory custom header.
|
|
|
|
|
+const FORBIDDEN_MESSAGE: &str = "richiesta non autorizzata";
|
|
|
|
|
+/// Body returned when the chat request body is missing, unreadable or too large.
|
|
|
|
|
+const BAD_BODY_MESSAGE: &str = "corpo della richiesta non valido";
|
|
|
|
|
+/// Body returned when the state cannot be serialized.
|
|
|
|
|
+const SERIALIZE_ERROR_MESSAGE: &str = "errore di serializzazione";
|
|
|
|
|
+
|
|
|
|
|
+/// Command sent by the web panel to the application.
|
|
|
|
|
+#[derive(Debug)]
|
|
|
|
|
+pub enum WebCommand {
|
|
|
|
|
+ /// Only read the current state.
|
|
|
|
|
+ State,
|
|
|
|
|
+ /// Toggle the global autoreply.
|
|
|
|
|
+ ToggleGlobal,
|
|
|
|
|
+ /// Toggle the autoreply of the chat with this JID.
|
|
|
|
|
+ ToggleChat(String),
|
|
|
|
|
+ /// Quit the bot.
|
|
|
|
|
+ Quit,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// One chat shown in the web panel.
|
|
|
|
|
+#[derive(Debug, Serialize)]
|
|
|
|
|
+pub struct WebChat {
|
|
|
|
|
+ /// Chat JID.
|
|
|
|
|
+ pub jid: String,
|
|
|
|
|
+ /// Display name of the chat.
|
|
|
|
|
+ pub name: String,
|
|
|
|
|
+ /// Whether the autoreply is enabled for this chat.
|
|
|
|
|
+ pub autoreply: bool,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Application state returned to the web panel.
|
|
|
|
|
+#[derive(Debug, Serialize)]
|
|
|
|
|
+pub struct WebState {
|
|
|
|
|
+ /// Whether the global autoreply is enabled.
|
|
|
|
|
+ pub global_autoreply: bool,
|
|
|
|
|
+ /// Chats known to the application.
|
|
|
|
|
+ pub chats: Vec<WebChat>,
|
|
|
|
|
+ /// Human readable status line.
|
|
|
|
|
+ pub status: String,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Request forwarded to the application through `AppEvent::Web`.
|
|
|
|
|
+#[derive(Debug)]
|
|
|
|
|
+pub struct WebRequest {
|
|
|
|
|
+ /// Command to execute.
|
|
|
|
|
+ pub command: WebCommand,
|
|
|
|
|
+ /// Channel on which the application sends the state after executing the command.
|
|
|
|
|
+ pub reply: oneshot::Sender<WebState>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Route resolved from an HTTP method and path.
|
|
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
+enum Route {
|
|
|
|
|
+ /// `GET /`.
|
|
|
|
|
+ Page,
|
|
|
|
|
+ /// `GET /api/state`.
|
|
|
|
|
+ State,
|
|
|
|
|
+ /// `POST /api/global`.
|
|
|
|
|
+ ToggleGlobal,
|
|
|
|
|
+ /// `POST /api/chat`.
|
|
|
|
|
+ ToggleChat,
|
|
|
|
|
+ /// `POST /api/quit`.
|
|
|
|
|
+ Quit,
|
|
|
|
|
+ /// Any other method/path combination.
|
|
|
|
|
+ NotFound,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl Route {
|
|
|
|
|
+ /// Purpose: tell whether the route is a state-changing POST route.
|
|
|
|
|
+ /// Inputs: none.
|
|
|
|
|
+ /// Output: `true` for the POST routes, which must carry the mandatory custom header.
|
|
|
|
|
+ /// Side effects: none.
|
|
|
|
|
+ fn requires_request_header(self) -> bool {
|
|
|
|
|
+ matches!(self, Route::ToggleGlobal | Route::ToggleChat | Route::Quit)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// HTTP response ready to be written.
|
|
|
|
|
+struct HttpReply {
|
|
|
|
|
+ /// HTTP status code.
|
|
|
|
|
+ status: u16,
|
|
|
|
|
+ /// Value of the `Content-Type` header.
|
|
|
|
|
+ content_type: &'static str,
|
|
|
|
|
+ /// Response body.
|
|
|
|
|
+ body: String,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: start the web control server.
|
|
|
|
|
+/// Inputs: `port`, the TCP port to listen on; `events`, the application event sender.
|
|
|
|
|
+/// Output: `Ok(())` once the server is bound and its thread is running, otherwise the bind or spawn error.
|
|
|
|
|
+/// Side effects: binds a TCP socket on all interfaces and spawns a detached OS thread that ends when
|
|
|
|
|
+/// the server stops or the application channel is closed.
|
|
|
|
|
+pub fn start(port: u16, events: UnboundedSender<AppEvent>) -> Result<()> {
|
|
|
|
|
+ let server = Server::http((BIND_ADDRESS, port))
|
|
|
|
|
+ .map_err(|err| anyhow!("cannot bind web server on {BIND_ADDRESS}:{port}: {err}"))?;
|
|
|
|
|
+ std::thread::Builder::new()
|
|
|
|
|
+ .name(WEB_THREAD_NAME.to_owned())
|
|
|
|
|
+ .spawn(move || serve(&server, &events))
|
|
|
|
|
+ .context("cannot spawn web thread")?;
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: handle incoming requests one at a time until the server or the application is gone.
|
|
|
|
|
+/// Inputs: `server`, the bound HTTP server; `events`, the application event sender.
|
|
|
|
|
+/// Output: none.
|
|
|
|
|
+/// Side effects: blocks on the network; forwards commands to the application and writes responses.
|
|
|
|
|
+fn serve(server: &Server, events: &UnboundedSender<AppEvent>) {
|
|
|
|
|
+ for request in server.incoming_requests() {
|
|
|
|
|
+ handle_request(request, events);
|
|
|
|
|
+ if events.is_closed() {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: route one request and answer it.
|
|
|
|
|
+/// Inputs: `request`, the incoming request; `events`, the application event sender.
|
|
|
|
|
+/// Output: none.
|
|
|
|
|
+/// Side effects: may read the request body, block until the application replies, and write the response;
|
|
|
|
|
+/// a POST route without the mandatory custom header is answered with 403 and nothing is forwarded.
|
|
|
|
|
+fn handle_request(mut request: Request, events: &UnboundedSender<AppEvent>) {
|
|
|
|
|
+ let route = resolve_route(request.method(), path_without_query(request.url()));
|
|
|
|
|
+ if route.requires_request_header() && !has_request_header(request.headers()) {
|
|
|
|
|
+ respond(request, text_reply(STATUS_FORBIDDEN, FORBIDDEN_MESSAGE));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if route == Route::Quit {
|
|
|
|
|
+ handle_quit(request, events);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let reply = build_reply(&mut request, route, events);
|
|
|
|
|
+ respond(request, reply);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: compute the response of every route except quit.
|
|
|
|
|
+/// Inputs: `request`, the incoming request (its body is read only for the chat route);
|
|
|
|
|
+/// `route`, the resolved route; `events`, the application event sender.
|
|
|
|
|
+/// Output: the response to write.
|
|
|
|
|
+/// Side effects: may read the request body and block until the application replies.
|
|
|
|
|
+fn build_reply(request: &mut Request, route: Route, events: &UnboundedSender<AppEvent>) -> HttpReply {
|
|
|
|
|
+ match route {
|
|
|
|
|
+ Route::Page => HttpReply {
|
|
|
|
|
+ status: STATUS_OK,
|
|
|
|
|
+ content_type: CONTENT_TYPE_HTML,
|
|
|
|
|
+ body: PAGE_HTML.to_owned(),
|
|
|
|
|
+ },
|
|
|
|
|
+ Route::State => forward(events, WebCommand::State),
|
|
|
|
|
+ Route::ToggleGlobal => forward(events, WebCommand::ToggleGlobal),
|
|
|
|
|
+ Route::ToggleChat => match read_limited_body(request.as_reader()) {
|
|
|
|
|
+ Some(jid) => forward(events, WebCommand::ToggleChat(jid)),
|
|
|
|
|
+ None => text_reply(STATUS_BAD_REQUEST, BAD_BODY_MESSAGE),
|
|
|
|
|
+ },
|
|
|
|
|
+ Route::Quit | Route::NotFound => text_reply(STATUS_NOT_FOUND, NOT_FOUND_MESSAGE),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: answer a quit request and then ask the application to quit.
|
|
|
|
|
+/// Inputs: `request`, the incoming request; `events`, the application event sender.
|
|
|
|
|
+/// Output: none.
|
|
|
|
|
+/// Side effects: writes the response first (the process exits right after the app handles the event),
|
|
|
|
|
+/// then sends `WebCommand::Quit` without waiting for the reply.
|
|
|
|
|
+fn handle_quit(request: Request, events: &UnboundedSender<AppEvent>) {
|
|
|
|
|
+ let body = serde_json::json!({ JSON_KEY_STATUS: QUIT_MESSAGE }).to_string();
|
|
|
|
|
+ respond(request, json_reply(STATUS_OK, body));
|
|
|
|
|
+
|
|
|
|
|
+ let (reply, _receiver) = oneshot::channel();
|
|
|
|
|
+ let quit = WebRequest {
|
|
|
|
|
+ command: WebCommand::Quit,
|
|
|
|
|
+ reply,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Ignoring a send failure is fine here: it means the app is already shutting down.
|
|
|
|
|
+ let _ = events.send(AppEvent::Web(quit));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: send a command to the application and wait for the resulting state.
|
|
|
|
|
+/// Inputs: `events`, the application event sender; `command`, the command to execute.
|
|
|
|
|
+/// Output: a JSON response with the state, 503 if the app is unreachable, 500 if serialization fails.
|
|
|
|
|
+/// Side effects: blocks the calling (non-runtime) thread until the application replies.
|
|
|
|
|
+fn forward(events: &UnboundedSender<AppEvent>, command: WebCommand) -> HttpReply {
|
|
|
|
|
+ let (reply, receiver) = oneshot::channel();
|
|
|
|
|
+ if events.send(AppEvent::Web(WebRequest { command, reply })).is_err() {
|
|
|
|
|
+ return text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE);
|
|
|
|
|
+ }
|
|
|
|
|
+ match receiver.blocking_recv() {
|
|
|
|
|
+ Ok(state) => state_reply(&state),
|
|
|
|
|
+ Err(_) => text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: serialize the application state into a response.
|
|
|
|
|
+/// Inputs: `state`, the state to serialize.
|
|
|
|
|
+/// Output: a 200 JSON response, or a 500 plain text response if serialization fails.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn state_reply(state: &WebState) -> HttpReply {
|
|
|
|
|
+ match serde_json::to_string(state) {
|
|
|
|
|
+ Ok(body) => json_reply(STATUS_OK, body),
|
|
|
|
|
+ Err(_) => text_reply(STATUS_INTERNAL_ERROR, SERIALIZE_ERROR_MESSAGE),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: build a JSON response.
|
|
|
|
|
+/// Inputs: `status`, the HTTP status code; `body`, the JSON text.
|
|
|
|
|
+/// Output: the response.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn json_reply(status: u16, body: String) -> HttpReply {
|
|
|
|
|
+ HttpReply {
|
|
|
|
|
+ status,
|
|
|
|
|
+ content_type: CONTENT_TYPE_JSON,
|
|
|
|
|
+ body,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: build a plain text response.
|
|
|
|
|
+/// Inputs: `status`, the HTTP status code; `body`, the text.
|
|
|
|
|
+/// Output: the response.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn text_reply(status: u16, body: &str) -> HttpReply {
|
|
|
|
|
+ HttpReply {
|
|
|
|
|
+ status,
|
|
|
|
|
+ content_type: CONTENT_TYPE_TEXT,
|
|
|
|
|
+ body: body.to_owned(),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: write a response to the client.
|
|
|
|
|
+/// Inputs: `request`, the request to answer; `reply`, the response to write.
|
|
|
|
|
+/// Output: none.
|
|
|
|
|
+/// Side effects: writes to the client connection; if the (constant) content type header cannot be
|
|
|
|
|
+/// built the response is sent without it rather than failing.
|
|
|
|
|
+fn respond(request: Request, reply: HttpReply) {
|
|
|
|
|
+ let mut response = Response::from_string(reply.body).with_status_code(reply.status);
|
|
|
|
|
+ if let Ok(header) = Header::from_bytes(HEADER_CONTENT_TYPE.as_bytes(), reply.content_type.as_bytes()) {
|
|
|
|
|
+ response = response.with_header(header);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Ignoring a write failure is fine here: it means the client already disconnected.
|
|
|
|
|
+ let _ = request.respond(response);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: strip the query string from a request URL.
|
|
|
|
|
+/// Inputs: `url`, the request URL as received.
|
|
|
|
|
+/// Output: the part of `url` before the first `?`.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn path_without_query(url: &str) -> &str {
|
|
|
|
|
+ url.split_once('?').map_or(url, |(path, _)| path)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: map an HTTP method and path to a route.
|
|
|
|
|
+/// Inputs: `method`, the HTTP method; `path`, the URL path without query.
|
|
|
|
|
+/// Output: the matching route, or `Route::NotFound`.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn resolve_route(method: &Method, path: &str) -> Route {
|
|
|
|
|
+ match (method, path) {
|
|
|
|
|
+ (Method::Get, PATH_PAGE) => Route::Page,
|
|
|
|
|
+ (Method::Get, PATH_STATE) => Route::State,
|
|
|
|
|
+ (Method::Post, PATH_GLOBAL) => Route::ToggleGlobal,
|
|
|
|
|
+ (Method::Post, PATH_CHAT) => Route::ToggleChat,
|
|
|
|
|
+ (Method::Post, PATH_QUIT) => Route::Quit,
|
|
|
|
|
+ _ => Route::NotFound,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: check that a request carries the custom header required on POST routes.
|
|
|
|
|
+/// Inputs: `headers`, the request headers.
|
|
|
|
|
+/// Output: `true` if a header named `REQUEST_HEADER_NAME` (name matched case-insensitively) has
|
|
|
|
|
+/// exactly the value `REQUEST_HEADER_VALUE`, otherwise `false`.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn has_request_header(headers: &[Header]) -> bool {
|
|
|
|
|
+ headers
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .any(|header| header.field.equiv(REQUEST_HEADER_NAME) && header.value.as_str() == REQUEST_HEADER_VALUE)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Purpose: read a request body as trimmed UTF-8 text, refusing oversized bodies.
|
|
|
|
|
+/// Inputs: `reader`, the body reader.
|
|
|
|
|
+/// Output: `Some(text)` for a non-empty body of at most `MAX_BODY_BYTES`, otherwise `None`
|
|
|
|
|
+/// (read error, invalid UTF-8, empty or too large).
|
|
|
|
|
+/// Side effects: consumes at most `MAX_BODY_BYTES + 1` bytes from `reader`.
|
|
|
|
|
+fn read_limited_body<R: Read>(reader: R) -> Option<String> {
|
|
|
|
|
+ let mut bytes = Vec::new();
|
|
|
|
|
+ reader
|
|
|
|
|
+ .take(MAX_BODY_BYTES as u64 + 1)
|
|
|
|
|
+ .read_to_end(&mut bytes)
|
|
|
|
|
+ .ok()?;
|
|
|
|
|
+ if bytes.len() > MAX_BODY_BYTES {
|
|
|
|
|
+ return None;
|
|
|
|
|
+ }
|
|
|
|
|
+ let text = String::from_utf8(bytes).ok()?;
|
|
|
|
|
+ let trimmed = text.trim();
|
|
|
|
|
+ (!trimmed.is_empty()).then(|| trimmed.to_owned())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[cfg(test)]
|
|
|
|
|
+mod tests {
|
|
|
|
|
+ use super::*;
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn path_without_query_strips_query_string() {
|
|
|
|
|
+ assert_eq!(path_without_query("/api/state?x=1&y=2"), "/api/state");
|
|
|
|
|
+ assert_eq!(path_without_query("/api/state"), "/api/state");
|
|
|
|
|
+ assert_eq!(path_without_query("/?"), "/");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn resolve_route_matches_known_routes() {
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Get, PATH_PAGE), Route::Page);
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Get, PATH_STATE), Route::State);
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Post, PATH_GLOBAL), Route::ToggleGlobal);
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Post, PATH_CHAT), Route::ToggleChat);
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Post, PATH_QUIT), Route::Quit);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn resolve_route_rejects_wrong_method_or_path() {
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Post, PATH_STATE), Route::NotFound);
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Get, PATH_QUIT), Route::NotFound);
|
|
|
|
|
+ assert_eq!(resolve_route(&Method::Get, "/unknown"), Route::NotFound);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Builds a header from raw name and value for the tests.
|
|
|
|
|
+ fn header(name: &str, value: &str) -> Header {
|
|
|
|
|
+ Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid test header")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn has_request_header_accepts_required_header_any_case() {
|
|
|
|
|
+ assert!(has_request_header(&[header(REQUEST_HEADER_NAME, REQUEST_HEADER_VALUE)]));
|
|
|
|
|
+ assert!(has_request_header(&[header("x-wh-request", REQUEST_HEADER_VALUE)]));
|
|
|
|
|
+ assert!(has_request_header(&[
|
|
|
|
|
+ header("Accept", "*/*"),
|
|
|
|
|
+ header("X-WH-REQUEST", REQUEST_HEADER_VALUE),
|
|
|
|
|
+ ]));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn has_request_header_rejects_missing_or_wrong_value() {
|
|
|
|
|
+ assert!(!has_request_header(&[]));
|
|
|
|
|
+ assert!(!has_request_header(&[header("Accept", "*/*")]));
|
|
|
|
|
+ assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "0")]));
|
|
|
|
|
+ assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "")]));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn only_post_routes_require_request_header() {
|
|
|
|
|
+ assert!(Route::ToggleGlobal.requires_request_header());
|
|
|
|
|
+ assert!(Route::ToggleChat.requires_request_header());
|
|
|
|
|
+ assert!(Route::Quit.requires_request_header());
|
|
|
|
|
+ assert!(!Route::Page.requires_request_header());
|
|
|
|
|
+ assert!(!Route::State.requires_request_header());
|
|
|
|
|
+ assert!(!Route::NotFound.requires_request_header());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn read_limited_body_trims_text() {
|
|
|
|
|
+ let body = read_limited_body(&b" 123@s.whatsapp.net\n"[..]);
|
|
|
|
|
+ assert_eq!(body.as_deref(), Some("123@s.whatsapp.net"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn read_limited_body_rejects_empty_and_invalid() {
|
|
|
|
|
+ assert_eq!(read_limited_body(&b" \n"[..]), None);
|
|
|
|
|
+ assert_eq!(read_limited_body(&[0xff, 0xfe][..]), None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn read_limited_body_enforces_size_limit() {
|
|
|
|
|
+ let at_limit = vec![b'a'; MAX_BODY_BYTES];
|
|
|
|
|
+ let over_limit = vec![b'a'; MAX_BODY_BYTES + 1];
|
|
|
|
|
+ assert!(read_limited_body(&at_limit[..]).is_some());
|
|
|
|
|
+ assert_eq!(read_limited_body(&over_limit[..]), None);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|