From cae0b6d2ffec4baa87fb11ce3bdc139c463a471b Mon Sep 17 00:00:00 2001 From: Blai Peidro Date: Fri, 30 Oct 2026 02:09:25 +0100 Subject: [PATCH] feat: browsable pages and the sign-in form --- Cargo.toml | 4 +- README.md | 9 +- docs/development.md | 5 + docs/usage.md | 77 +++ src/lib.rs | 9 +- src/login.rs | 533 ++++++++++++++++++ src/pages.rs | 1300 +++++++++++++++++++++++++++++++++++++++++++ src/site.rs | 22 + src/url.rs | 26 + tests/login_test.rs | 407 ++++++++++++++ tests/pages_test.rs | 216 +++++++ 11 files changed, 2602 insertions(+), 6 deletions(-) create mode 100644 src/login.rs create mode 100644 src/pages.rs create mode 100644 src/site.rs create mode 100644 tests/login_test.rs create mode 100644 tests/pages_test.rs diff --git a/Cargo.toml b/Cargo.toml index 129de15..89c35f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ chrono = { version = "0.4", default-features = false, features = ["std", "clock" serde = { version = "1", features = ["derive"] } # Content hashes: a config file's ETag, and the directory's sha2 = "0.11" +# The browsable pages read and render the JSON a handler produced +serde_json = "1" # The ServeDir/ServeFile services the web UI is served with tower = { version = "0.5", default-features = false, features = ["util"] } tower-http = { version = "0.7", features = ["fs"] } @@ -37,5 +39,3 @@ tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # A log subscriber, so the fields of an audit event are evaluated in tests tracing-subscriber = "0.3" -# The OpenAPI documents the spec tests ask about, written as JSON -serde_json = "1" diff --git a/README.md b/README.md index c059f90..60c4ba8 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ The web layer of a JSON API service, written in Rust. What a service's routes do is its own; how it answers on the web does not have to be. Web holds that part: one error shape for every failure, an audit event for every change, the web UI served -from the API's own address behind sign-in, help for each route read from the OpenAPI document, and -the configuration directory readable and writable over HTTP. +from the API's own address behind sign-in, help for each route read from the OpenAPI document, the +configuration directory readable and writable over HTTP, and the same routes as browsable pages. One crate, `web-rs`, built on axum and utoipa. Who a caller is comes from the `auth-rs` crate of the [auth](https://github.com/OpusProjects/auth) repository, so the parts that check it are generic @@ -31,6 +31,9 @@ over the service's own permissions type. - **Validated before it lands**: a change is loaded in a staged copy, refused whole if it fails - **Atomic, careful writes**: temp file and rename per file, unchanged files never rewritten - **ETags and If-Match**: a push against a directory someone else changed is refused, not merged +- **Browsable API**: a browser gets a page for any JSON route, with help, links and action buttons +- **Safe pages**: every value escaped, a strict CSP, never cached, never framed +- **Sign-in form**: an API key or a directory account becomes a session; no password stored here - **Pinned by tag**: a service depends on a `vX.Y.Z` tag of this repository, never on `main` --- @@ -39,7 +42,7 @@ over the service's own permissions type. | Document | What it covers | |---|---| -| [Usage](docs/usage.md) | Errors, audit events, the web UI, route help, and the configuration API | +| [Usage](docs/usage.md) | Errors, audit events, the web UI, route help, the configuration API, pages and sign-in | | [Development](docs/development.md) | The layout, the CI gates, the tests, and working on a service against a local copy | | [Releasing](docs/releasing.md) | How release-please cuts a version, and how a service moves to it | diff --git a/docs/development.md b/docs/development.md index a26db03..6493569 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,6 +23,9 @@ src/ ├── ui_assets.rs # UiAssets, serve, page_for_browsers ├── spec.rs # Spec: route help from the OpenAPI document ├── config/ # files (which files), store (staged, atomic), api (the routes' logic) +├── site.rs # Site: the service's name, document and cookies +├── pages.rs # the browsable API (negotiate, page) +├── login.rs # the sign-in form and its routes, SignIn └── url.rs # query_escape tests/ # integration tests: a real router, a JSON document, a log subscriber docs/ # this documentation @@ -61,6 +64,8 @@ Every line of the crate runs in a test, and the `test` check fails a pull reques | `tests/spec_test.rs` | `Spec` asked about a small OpenAPI document written as JSON | | `tests/audit_test.rs` | An audit event read back from a log subscriber | | `tests/config_store_test.rs` | `FsConfigStore` on real directories, with a stand-in loader, including what the filesystem refuses | +| `tests/pages_test.rs` | `negotiate` in a real router: pages, themes, what is left alone, bodies too big to draw | +| `tests/login_test.rs` | The sign-in routes with a test directory: keys, accounts, every refusal, sign-out | | `tests/config_api_test.rs` | Every config route against a test `ConfigHost` and a store that fails on purpose | Test code under `tests/` is not counted by `cargo llvm-cov`, which is why helpers live there. diff --git a/docs/usage.md b/docs/usage.md index c725d3d..8e1fd8f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,6 +9,9 @@ its web UI, and the route help its browsable pages read from the OpenAPI documen - [Serving the web UI](#serving-the-web-ui) - [Route help from the OpenAPI document](#route-help-from-the-openapi-document) - [The configuration API](#the-configuration-api) +- [The service's Site](#the-services-site) +- [Browsable pages](#browsable-pages) +- [The sign-in form](#the-sign-in-form) --- @@ -168,3 +171,77 @@ A service gives the module three things: `files::config_files` lists a directory the way the loader should read it: every `.yaml` and `.yml` file at any depth, hidden entries skipped, symlinks followed up to a depth cap. `is_config_path` is the rule a file name must meet to be written at all. + +--- + +## The service's Site + +`Site` is what the pages and the sign-in form know about the service, fixed for the life of the process. + +| Field | What it is | +|---|---| +| `product` | The name in every page's title and header | +| `spec` | The service's `Spec`, built once from its OpenAPI document | +| `session_cookie`, `login_cookie`, `theme_cookie` | The cookie names, so two services on one host never overwrite each other's | +| `raw_routes` | Route templates whose body is the service's product (an output it serves), never drawn as a page | + +--- + +## Browsable pages + +`pages::negotiate` is a middleware: the same route answers JSON to a client and a page to a browser. + +```rust +router.layer(middleware::from_fn_with_state(site(), web_rs::pages::negotiate)) +``` + +A request whose `Accept` names `text/html` gets the JSON drawn as a page. + +What the page shows: +- every value escaped; +- every path the document answers with a GET, as a link; +- breadcrumbs that link only real routes; +- the route's help from the document (description, parameters, responses, the methods it allows); +- for a signed-in browser, a button for each write the object's own `related` declares, with the + session's CSRF token in its form. + +Left as they are: +- `?format=json` gives the bytes back; +- a response that is not JSON passes through; +- a route in `raw_routes` passes through. + +A body too large to draw sends the browser to the first page (`?limit=200`) when the route pages, +and to a short explanation when it does not. A window the caller named is drawn whatever its size. + +Pages are served with a CSP that allows only their inline style, `Cache-Control: no-store`, and +`Vary: accept, cookie`. `?theme=` picks one of six palettes and the theme cookie remembers it. + +--- + +## The sign-in form + +`login` holds the routes behind the sign-in form: an API key or a directory account becomes a browser session. + +| Function | Route | +|---|---| +| `login_form(site, sign_in, next, headers)` | `GET /api/v1/login`: the form, with a fresh challenge | +| `login_submit(site, sign_in, keys, sessions, ttl, headers, form)` | `POST /api/v1/login`: checks it, opens the session, redirects to `next` | +| `logout(site, sessions, headers, next)` | `POST /api/v1/logout`: ends the session server-side | + +The form only accepts a submission paired with the challenge cookie it planted, so a page on another +site cannot sign a browser in under someone else's key. `next` must be a path on this origin, or +the service's home is used instead. An API key is checked against the key registry in constant time. +A directory account goes through the service's `SignIn`: + +```rust +impl SignIn for Directory<'_> { + fn ldap_enabled(&self) -> bool { ... } + fn home(&self) -> String { ... } + async fn ldap_login(&self, username: &str, password: &str) + -> Result<(String, Permissions), LoginError> { ... } +} +``` + +What an account may touch comes from the service's own access rules, which is why checking it is +the service's: `ldap_login` answers the account's name and its permissions, or one of the +`LoginError`s. Each error is shown in its own words, with a status that tells the cases apart. diff --git a/src/lib.rs b/src/lib.rs index 73292e2..cc0b98d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,10 @@ // an API answer // - `spec`: what the OpenAPI document says about a route, for the pages that // explain themselves -// - `url`: escaping a value for a query string +// - `pages`: the browsable API, a page instead of JSON when a browser asks +// - `login`: the sign-in form, by API key or directory account, and sign-out +// - `site`: what those pages know of the service (its name, its cookies) +// - `url`: escaping a value for a query string, and the local-path rule // // Who a caller is comes from auth-rs (API keys and browser sessions), so // the gate and the audit events are generic over the service's permissions @@ -22,10 +25,14 @@ pub mod audit; pub mod config; pub mod error; +pub mod login; +pub mod pages; +pub mod site; pub mod spec; pub mod ui_assets; pub mod url; pub use error::{ApiError, ErrorBody}; +pub use site::Site; pub use spec::Spec; pub use ui_assets::UiAssets; diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..03945b9 --- /dev/null +++ b/src/login.rs @@ -0,0 +1,533 @@ +use std::future::Future; +use std::time::Duration; + +use auth_rs::http::{ + ApiKeyRegistry, Permission, SessionKind, SessionStore, cookie_value, csrf_matches, match_token, + new_token, +}; +use auth_rs::login::LoginError; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +use crate::pages::{CSP, Html, THEMES, palette_css}; +use crate::site::Site; +use crate::url::{is_local_path, query_escape}; + +// The login form for the browsable API. +// +// Public routes, outside the key middleware — a page that asks for a credential +// cannot itself require one. They exist only when the UI is enabled. +// +// Two ways in, and neither is a credential store of its own. An API KEY is +// checked against the same registry and the same constant-time compare a +// header goes through. A directory account (when `server.ui.ldap` is set) is +// checked by the directory, and what it may touch comes from the configured +// access rules. There is no user table and no password stored here. + +// A short-lived token planted when the form is served and required back when +// it is submitted, so only a form THIS server rendered can log anybody in. +// +// Without it, a page on the internet can auto-submit a login form carrying the +// ATTACKER's API key: the browser navigates top-level to our origin, so the +// response is first-party and the cookie is stored (SameSite=Strict governs +// sending an existing cookie, not setting a new one). The victim then works — +// and is audit-logged — under someone else's key without noticing. +// +// The cookie is the service's `Site::login_cookie`. + +// What the sign-in routes need from the service: whether a directory account +// is a way in, where to land after signing in, and checking one. Checking is +// the service's because what an account may touch comes from its own access +// rules: it answers the account's canonical name and its permissions. +pub trait SignIn

{ + fn ldap_enabled(&self) -> bool; + // Where to go after signing in when no page sent the browser here + fn home(&self) -> String; + fn ldap_login( + &self, + username: &str, + password: &str, + ) -> impl Future> + Send; +} + +// One form, two shapes: `key` for an API key, `username` + `password` for +// LDAP. Whichever is filled in decides the path. +#[derive(Deserialize)] +pub struct LoginForm { + #[serde(default)] + pub key: Option, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub _csrf: String, +} + +#[derive(Deserialize)] +pub struct LoginQuery { + #[serde(default)] + pub next: Option, +} + +// Where to go after logging in: the page that sent the browser here, or +// `server.ui.home` when none did. Only a path on THIS origin: +// `next=https://evil.example` (or `//evil.example`, which a browser reads as +// another host) would turn the login into an open redirect that lends our +// address to a phishing link. +fn safe_next<'a>(next: Option<&'a str>, home: &'a str) -> &'a str { + match next { + Some(path) if is_local_path(path) => path, + _ => home, + } +} + +fn esc(s: &str) -> String { + Html::text(s).as_str().to_string() +} + +// Rendered by hand rather than through the JSON page: there is no resource here +// and nothing to negotiate. It borrows the same palette so the login does not +// look like a different product. +struct Page<'a> { + site: &'a Site, + theme: &'a str, + message: Option<&'a str>, + status: StatusCode, + challenge: Option<&'a str>, + ldap: bool, + // Which way in the card opens on: the one the person just tried, so a + // mistyped key does not drop them back on the directory form. + key_first: bool, + next: &'a str, +} + +fn login_page(page: Page<'_>) -> Response { + let notice = match page.message { + Some(text) => format!("

{}

", esc(text)), + None => String::new(), + }; + let challenge = esc(page.challenge.unwrap_or("")); + let next = esc(page.next); + + let hidden = format!( + r#" + "# + ); + let key_form = |autofocus: bool| { + format!( + r#"
+ {hidden} + + + +
"#, + autofocus = if autofocus { " autofocus" } else { "" }, + ) + }; + + // With LDAP, one card and a switch between the two ways in. The switch is + // a pair of radio buttons and `:has()` shows the form that matches: no + // script, so the page keeps its strict CSP. The radios sit outside both + // forms, so neither submits them. + let content = if page.ldap { + let (ldap_checked, key_checked) = if page.key_first { + ("", " checked") + } else { + (" checked", "") + }; + format!( + r#"
+ + + + +
+
+ {hidden} + + + + + +
+ {key}"#, + ldap_focus = if page.key_first { "" } else { " autofocus" }, + key = key_form(page.key_first), + ) + } else { + key_form(true) + }; + let lead = if page.ldap { + "Sign in" + } else { + "Sign in with an API key" + }; + let product = esc(page.site.product); + let forms = format!( + r#"
+

{product}

+

{lead}

+ {notice} + {content} +
"# + ); + + let body = format!( + r#" + + + + +Sign in — {product} + + + +{forms} + +"#, + palette = palette_css(page.theme), + product = esc(page.site.product), + ); + + ( + page.status, + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ), + // The same policy the rendered pages get. This page is outside the + // negotiate layer that sets it, and it is the one page that takes a + // credential — the last place to leave unframeable. + ( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CSP), + ), + ( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, private"), + ), + ], + body, + ) + .into_response() +} + +fn theme_of(site: &Site, headers: &HeaderMap) -> String { + headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|c| cookie_value(c, site.theme_cookie)) + .filter(|t| THEMES.contains(t)) + .unwrap_or("auto") + .to_string() +} + +// GET /api/v1/login: the form, with a fresh challenge. +pub fn login_form>( + site: &Site, + sign_in: &S, + next: Option<&str>, + headers: &HeaderMap, +) -> Response { + let challenge = new_token(); + let home = sign_in.home(); + let response = login_page(Page { + site, + theme: &theme_of(site, headers), + message: None, + status: StatusCode::OK, + challenge: Some(&challenge), + ldap: sign_in.ldap_enabled(), + key_first: false, + next: safe_next(next, &home), + }); + with_challenge(site, response, &challenge) +} + +// Plants the cookie half of the login challenge; the form carries the other. +fn with_challenge(site: &Site, mut response: Response, challenge: &str) -> Response { + if let Ok(value) = HeaderValue::from_str(&format!( + "{}={challenge}; Path=/api/v1/login; HttpOnly; SameSite=Strict; Max-Age=600", + site.login_cookie + )) { + response.headers_mut().append(header::SET_COOKIE, value); + } + response +} + +// POST /api/v1/login: an API key, or a directory account when the service has +// one configured, becomes a browser session. +#[allow(clippy::too_many_arguments)] +pub async fn login_submit>( + site: &Site, + sign_in: &S, + keys: &ApiKeyRegistry

, + sessions: &SessionStore

, + ttl: Duration, + headers: &HeaderMap, + form: LoginForm, +) -> Response { + let theme = theme_of(site, headers); + let ldap = sign_in.ldap_enabled(); + let home = sign_in.home(); + let next = safe_next(form.next.as_deref(), &home).to_string(); + // A refused login re-renders the form with a FRESH challenge. Without + // one, the page that says "wrong password" carried no token, and the + // corrected attempt was rejected as an expired form. + let key_first = form.username.is_none(); + let refuse = |message: &str, status: StatusCode| { + let challenge = new_token(); + with_challenge( + site, + login_page(Page { + site, + theme: &theme, + message: Some(message), + status, + challenge: Some(&challenge), + ldap, + key_first, + next: &next, + }), + &challenge, + ) + }; + // The form must be one this server handed out. Both halves of the pair + // have to agree: the cookie planted when the form was rendered, and the + // hidden field inside it. A cross-site form has neither. + let challenge = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|c| cookie_value(c, site.login_cookie)); + + let matched = challenge + .is_some_and(|expected| !form._csrf.is_empty() && csrf_matches(expected, &form._csrf)); + + if !matched { + return refuse( + "this login form has expired — reload the page and try again", + StatusCode::FORBIDDEN, + ); + } + + let (name, kind, key_id, permissions) = if let Some(username) = form.username.as_deref() { + if !ldap { + return refuse("LDAP login is not configured", StatusCode::BAD_REQUEST); + } + let password = form.password.as_deref().unwrap_or(""); + match sign_in.ldap_login(username, password).await { + Ok((username, permissions)) => ( + format!("ldap:{username}"), + SessionKind::Directory, + None, + permissions, + ), + Err(LoginError::InvalidCredentials) => { + // Already slowed to a fixed total time by the throttle, so an + // unknown user and a wrong password look alike. + return refuse("invalid username or password", StatusCode::UNAUTHORIZED); + } + Err(LoginError::TooManyAttempts) => { + return refuse( + "too many failed attempts for this account — try again in 15 minutes, \ + or sign in with an API key", + StatusCode::TOO_MANY_REQUESTS, + ); + } + Err(LoginError::Busy) => { + return refuse( + "too many sign-ins at once — try again in a moment", + StatusCode::SERVICE_UNAVAILABLE, + ); + } + Err(LoginError::NotAuthorized) => { + // The password was right, so saying so leaks nothing a + // guesser could use, and saves the person a ticket. + return refuse( + "your account is valid but has no access to this instance — ask an \ + administrator to add you or your group to server.ui.ldap.access", + StatusCode::FORBIDDEN, + ); + } + Err(LoginError::Unavailable(reason)) => { + tracing::error!(%reason, "LDAP login failed: directory unavailable"); + return refuse( + "the directory could not be reached — try again shortly, or sign in \ + with an API key", + StatusCode::SERVICE_UNAVAILABLE, + ); + } + } + } else { + let registry = keys.load(); + + // An open API has nothing to log into: every caller is already admin, and a + // form that accepts anything would suggest otherwise. + if registry.is_empty() { + // Deliberately the same answer a wrong key gets. Saying "this instance + // has no authentication" to an unauthenticated caller is a banner for + // anyone scanning; the real reason goes to the log, where an operator + // can see it and a scanner cannot. + tracing::warn!("login attempted on an instance with no API keys configured"); + return refuse("invalid API key", StatusCode::UNAUTHORIZED); + } + + let Some(key) = match_token(form.key.as_deref().unwrap_or("").trim(), ®istry) else { + // No detail, deliberately: "no such key" and "wrong key" are the same + // sentence to someone guessing. + return refuse("invalid API key", StatusCode::UNAUTHORIZED); + }; + ( + key.name.clone(), + SessionKind::ApiKey, + Some(key.id.clone()), + key.permissions.clone(), + ) + }; + + let issued = sessions.create(name.clone(), kind, key_id, permissions, ttl); + + tracing::info!(key_name = %name, "browser session opened"); + + // Secure is NOT set here: the console is reachable over plain HTTP in a + // port-forward or a local run, and a Secure cookie would silently never be + // stored there — a login that appears to work and does nothing. In a real + // deployment this sits behind TLS at the ingress, and the attributes that + // do the security work are HttpOnly (no script can read it; there are no + // scripts anyway) and SameSite=Strict (the browser will not attach it to a + // request another site caused), backed by the CSRF token on every write. + let cookie = format!( + "{}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", + site.session_cookie, + issued.token, + ttl.as_secs() + ); + + let mut response = axum::response::Redirect::to(&next).into_response(); + if let Ok(value) = HeaderValue::from_str(&cookie) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response +} + +// A sign-out form may say where the next sign-in should land (the UI posts +// next=/), so signing straight back in returns to the UI rather than to the +// service's home. Optional: a bare POST, with no form at all, still signs out. +pub fn logout( + site: &Site, + sessions: &SessionStore

, + headers: &HeaderMap, + next: Option, +) -> Response { + if let Some(token) = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|c| cookie_value(c, site.session_cookie)) + { + // Dropped server-side, not merely forgotten by the browser: a cookie + // someone already copied must stop working too. + sessions.remove(token); + } + + let login = match next { + Some(next) if is_local_path(&next) => { + format!("/api/v1/login?next={}", query_escape(&next)) + } + _ => "/api/v1/login".to_string(), + }; + let mut response = axum::response::Redirect::to(&login).into_response(); + if let Ok(value) = HeaderValue::from_str(&format!( + "{}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", + site.session_cookie + )) { + response.headers_mut().insert(header::SET_COOKIE, value); + } + response +} + +// The session lifetime, injected so it can be read from configuration once and +// not threaded through every handler. +#[derive(Clone, Copy)] +pub struct SessionTtl(pub Duration); + +impl Default for SessionTtl { + fn default() -> Self { + SessionTtl(Duration::from_secs(3600)) + } +} diff --git a/src/pages.rs b/src/pages.rs new file mode 100644 index 0000000..0518ed9 --- /dev/null +++ b/src/pages.rs @@ -0,0 +1,1300 @@ +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use serde_json::Value; + +use crate::site::Site; +use crate::spec::{Action, RouteDoc, Spec}; + +// Anything bigger than this is not rendered as a tree. A dataset is megabytes +// of JSON and the indented markup for it is several times that, which is a hung +// tab rather than a page. The browser gets a page that says so and links to the +// bytes; `?limit=` is how you actually look at a big source. +const MAX_RENDERED_BYTES: usize = 1024 * 1024; + +// Big enough for any response this API produces as JSON (the body is already in +// memory by the time it reaches here — handlers serialize into a Vec). +const MAX_BODY_BYTES: usize = 64 * 1024 * 1024; + +// The policy every page this module serves is sent under. +// +// `default-src 'none'` means script-src falls back to none, so even an injected +// tag could not run. `frame-ancestors 'none'` is separate and NOT covered by +// that fallback, and it is the one that protects the action buttons: without +// it another site can put this page in an invisible iframe, line its Evict +// button up under a decoy, and collect a click. The form inside that frame is +// ours, submitting to our origin, carrying the victim's real CSRF token — so +// the token model is bypassed entirely and only this directive stops it. +pub const CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; \ + form-action 'self'; base-uri 'none'; frame-ancestors 'none'"; + +// The window a browser is sent to when it asked for something too big without +// saying how much it wanted. +const DEFAULT_PAGE: usize = 200; + +// Did the caller choose their own window? Any of the paging parameters counts: +// naming one means the size of the response is their decision, not ours. +fn asked_for_a_window(query: Option<&str>) -> bool { + query.is_some_and(|q| { + q.split('&') + .any(|p| p.starts_with("limit=") || p.starts_with("offset=")) + }) +} + +// Rendering the API's own JSON as a browsable page. +// +// The whole module rests on one type. `Html` is a String that is known to be +// safe to put in a page, and the ONLY way to make one out of arbitrary text is +// `Html::text`, which escapes. A `&str` from a hostvar cannot reach the output +// by accident, because a `&str` is not an `Html` and the compiler says so. +// +// This matters more here than it looks. Swagger UI is safe by construction: it +// hands a response to the DOM through `textContent`, so a hostvar containing +// `"#); + assert_eq!( + escaped.as_str(), + "<script>alert("x") & 'y'</script>" + ); + } + + // The case this module exists for: a connector's data reaching the page. + #[test] + fn injected_markup_in_a_value_renders_inert() { + let headers = HeaderMap::new(); + let value = json!({ "description": "" }); + let html = page(&ctx("/api/v1/sources/src-d42/dataset", &headers), &value); + assert!( + !html.contains("x": 1 }); + let html = page(&ctx("/api/v1/sources", &headers), &value); + assert!(!html.contains("")); + assert!(html.contains("<script>x</script>")); + } + + #[test] + fn our_own_paths_become_links() { + let headers = HeaderMap::new(); + let value = json!({ "dataset": "/api/v1/sources/src-d42/dataset" }); + let html = page(&ctx("/api/v1/sources", &headers), &value); + assert!(html.contains(r#""#)); + } + + // A hostvar is free to contain a URL. It stays text. + // The 405 trap, inside the JSON this time: `related.sync` is a POST route. + // A view answers on the source routes but refuses every write, so its page + // must offer no write buttons — the object's own `related` is what says so. + #[test] + fn a_view_gets_no_action_buttons() { + let headers = HeaderMap::new(); + let mut c = ctx("/api/v1/sources/vw-facts", &headers); + c.csrf = Some("tok"); + c.who = Some("Admin"); + + let view = json!({ + "kind": "view", + "related": { + "dataset": "/api/v1/sources/vw-facts/dataset", + "status": "/api/v1/sources/vw-facts/status", + } + }); + assert!( + !page(&c, &view).contains("Sync<")); + assert!(html.contains("Evict cache")); + } + + #[test] + fn a_post_only_route_is_shown_but_not_linked() { + let headers = HeaderMap::new(); + let value = json!({ + "sync": "/api/v1/sources/src-d42/sync", + "status": "/api/v1/sources/src-d42/status", + }); + let html = page(&ctx("/api/v1/sources/src-d42", &headers), &value); + + assert!( + !html.contains(r#"href="/api/v1/sources/src-d42/sync""#), + "a POST route must not be rendered as a link" + ); + assert!( + html.contains("/api/v1/sources/src-d42/sync"), + "but it must still be visible in the JSON" + ); + assert!( + html.contains(r#"href="/api/v1/sources/src-d42/status""#), + "a GET route alongside it stays clickable" + ); + } + + #[test] + fn foreign_urls_never_become_links() { + for hostile in [ + "javascript:alert(1)", + "https://evil.example/pwn", + "//evil.example/pwn", + "/api/v1/../../etc/passwd", + "http://x/api/v1/sources", + ] { + assert!(!is_internal_path(hostile), "must not link: {hostile}"); + } + } + + #[test] + fn internal_paths_are_recognised() { + for ours in [ + "/api/v1/sources", + "/api/v1/sources/src-d42/status", + "/healthz", + "/metrics", + ] { + assert!(is_internal_path(ours), "must link: {ours}"); + } + } + + #[test] + fn breadcrumbs_link_only_real_routes() { + let crumbs = breadcrumbs(spec(), "/api/v1/sources/src-d42/status"); + assert!(crumbs.contains(r#"href="/api/v1/sources""#)); + assert!(crumbs.contains(r#"href="/api/v1/sources/src-d42""#)); + assert!(crumbs.contains(r#"href="/api/v1/sources/src-d42/status""#)); + + // The 405 trap: sync is a POST, so the trail shows it without linking. + let crumbs = breadcrumbs(spec(), "/api/v1/sources/src-d42/sync"); + assert!(crumbs.contains(r#"sync"#)); + assert!(!crumbs.contains(r#"href="/api/v1/sources/src-d42/sync""#)); + } + + // The help panel comes from the handler attributes, so this asserts the + // wiring rather than the wording. + #[test] + fn the_help_panel_carries_the_specs_documentation() { + let headers = HeaderMap::new(); + let page_html = page(&ctx("/api/v1/sources/src-d42/status", &headers), &json!({})); + assert!(page_html.contains("what is this route")); + assert!( + page_html.contains("/api/v1/sources/{id}/status"), + "the route template belongs in the help" + ); + assert!(page_html.contains("Parameters")); + } + + #[test] + fn the_header_block_shows_which_methods_a_route_allows() { + let headers = HeaderMap::new(); + let html = page(&ctx("/api/v1/sources/src-d42", &headers), &json!({})); + assert!(html.contains("Allow:")); + assert!(html.contains("DELETE")); + } + + // Pressing Sync now lands on a POST-only path; the theme links there must + // not be GETs at it. + #[test] + fn theme_links_from_an_action_result_go_to_the_resource() { + assert_eq!( + nearest_readable(spec(), "/api/v1/sources/src-d42/sync"), + "/api/v1/sources/src-d42" + ); + assert_eq!( + nearest_readable(spec(), "/api/v1/sources/src-d42"), + "/api/v1/sources/src-d42" + ); + assert_eq!( + nearest_readable(spec(), "/api/v1/enrichers/enr-a/run"), + "/api/v1/enrichers" + ); + + let picker = theme_picker(spec(), "/api/v1/sources/src-d42/sync", None, "auto"); + assert!( + !picker.contains("/sync?theme="), + "a theme link must never point at a POST-only route" + ); + assert!(picker.contains("/api/v1/sources/src-d42?theme=dracula")); + } + + // An output endpoint emits application/json for `output: ansible`, so it + // looks like one of ours. It is not: it is the inventory the consumer came + // for, and it must survive Accept: text/html untouched. + // Clickjacking defeats the CSRF token entirely if another site can frame + // us: the form inside that frame is ours, submitting to our origin, with + // the victim's real token in it. frame-ancestors is the only thing that + // stops it, and it does NOT fall back to default-src. + #[test] + fn the_policy_forbids_framing() { + assert!(CSP.contains("frame-ancestors 'none'")); + assert!(CSP.contains("default-src 'none'")); + assert!(CSP.contains("form-action 'self'")); + } + + #[test] + fn an_output_endpoint_is_never_rendered_as_a_page() { + assert!(is_product_route( + site(), + "/api/v1/endpoints/ep-ansible-full" + )); + // Our own metadata about endpoints is ours to render + assert!(!is_product_route(site(), "/api/v1/endpoints")); + assert!(!is_product_route(site(), "/api/v1/sources/src-d42/dataset")); + } + + #[test] + fn an_explicit_window_is_the_callers_decision() { + assert!(asked_for_a_window(Some("limit=900"))); + assert!(asked_for_a_window(Some("offset=500"))); + assert!(asked_for_a_window(Some("theme=nord&limit=50"))); + // No window named: the page size is ours to choose + assert!(!asked_for_a_window(None)); + assert!(!asked_for_a_window(Some("theme=nord"))); + assert!(!asked_for_a_window(Some("fields=rack"))); + } + + #[test] + fn a_theme_link_keeps_the_page_and_its_query() { + let url = with_param("/api/v1/sources", Some("limit=10"), "theme", "nord"); + assert_eq!(url, "/api/v1/sources?limit=10&theme=nord"); + // and replaces a previous choice rather than stacking + let url = with_param("/api/v1/sources", Some("theme=dark"), "theme", "nord"); + assert_eq!(url, "/api/v1/sources?theme=nord"); + } + + #[test] + fn a_page_names_its_product() { + let headers = HeaderMap::new(); + let html = page(&ctx("/api/v1/sources", &headers), &json!([])); + assert!(html.contains(""), "{html}"); + assert!(html.contains("test-service")); + } + + #[test] + fn raw_markup_is_taken_as_it_is() { + assert_eq!(Html::raw("<b>x</b>".to_string()).as_str(), "<b>x</b>"); + } + + // Nothing above the path answers GET: the API's root is the way back. + #[test] + fn a_path_with_no_readable_ancestor_leads_to_the_root() { + assert_eq!(nearest_readable(spec(), "/nowhere"), "/api/v1/"); + assert_eq!(nearest_readable(spec(), "/api/zzz/deep"), "/api/v1/"); + } + + #[test] + fn every_kind_of_value_is_drawn() { + let headers = HeaderMap::new(); + let value = json!({ "none": null, "yes": true, "list": [1, [2, 3], []] }); + let html = page(&ctx("/api/v1/sources", &headers), &value); + assert!(html.contains(r#"<span class="null">null</span>"#)); + assert!(html.contains(r#"<span class="bool">true</span>"#)); + assert!(html.contains(r#"<span class="punct">[]</span>"#)); + assert!(html.contains(r#"<span class="punct">,</span>"#)); + } + + // A path the document does not know, answered with an error: no Allow + // line to show, and the status drawn as a failure. + #[test] + fn an_unknown_route_and_a_failure_are_drawn_honestly() { + let headers = HeaderMap::new(); + let mut c = ctx("/api/v1/unknown", &headers); + c.status = 404; + let html = page(&c, &json!({ "error": "no" })); + assert!(!html.contains("Allow:")); + assert!(html.contains("404")); + + // A GET at a POST-only route: its help, but no responses to list + let html = page(&ctx("/api/v1/sources/src-d42/sync", &headers), &json!({})); + assert!(html.contains("POST")); + } + + // Labels for the writes the spec offers: a named child POST reads as its + // name, a POST on the resource itself as POST, any other method as itself. + #[test] + fn every_action_gets_a_label() { + let headers = HeaderMap::new(); + let mut c = ctx("/api/v1/jobs/j1", &headers); + c.csrf = Some("tok"); + let value = json!({ "related": { + "self": "/api/v1/jobs/j1", + "run": "/api/v1/jobs/j1/run", + }}); + let html = page(&c, &value); + assert!(html.contains(">Run<"), "{html}"); + assert!(html.contains(">POST<"), "{html}"); + assert!(html.contains(">PUT <"), "{html}"); + } + + #[test] + fn every_theme_defines_the_whole_palette() { + for theme in THEMES { + let css = palette_css(theme); + for var in [ + "--bg", "--fg", "--dim", "--key", "--str", "--num", "--link", "--rule", "--panel", + ] { + assert!(css.contains(var), "{theme} is missing {var}"); + } + } + } +} diff --git a/src/site.rs b/src/site.rs new file mode 100644 index 0000000..db629a4 --- /dev/null +++ b/src/site.rs @@ -0,0 +1,22 @@ +use crate::spec::Spec; + +// What the pages need to know about the service they are part of. Fixed for +// the life of the process, so it is all `'static`: a service builds its Spec +// once and hands out the reference. +#[derive(Clone, Copy)] +pub struct Site { + // The product name, in every page's title and header ("unified-api") + pub product: &'static str, + // The service's OpenAPI document, for each page's help, links and actions + pub spec: &'static Spec, + // The browser session cookie, as set by the sign-in form + pub session_cookie: &'static str, + // The cookie that pairs a sign-in form with the server that served it + pub login_cookie: &'static str, + // The cookie that remembers the theme a browser picked + pub theme_cookie: &'static str, + // Route templates whose body is the service's product rather than one of + // its own resources (an output endpoint's inventory): a browser gets + // their bytes as they are, never a page about them. + pub raw_routes: &'static [&'static str], +} diff --git a/src/url.rs b/src/url.rs index 2eb56b1..afaac72 100644 --- a/src/url.rs +++ b/src/url.rs @@ -13,10 +13,36 @@ pub fn query_escape(value: &str) -> String { .collect() } +// A path on THIS origin, safe to redirect to: `next=https://evil.example` (or +// `//evil.example`, which a browser reads as another host) would turn a +// sign-in into an open redirect that lends the service's address to a +// phishing link. +pub fn is_local_path(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.contains('\\') + && !path.chars().any(|c| c.is_control() || c.is_whitespace()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn only_a_path_on_this_origin_is_local() { + assert!(is_local_path("/sources?x=1")); + for bad in [ + "https://evil.example", + "//evil.example", + "/a\\b", + "/a b", + "/a\nb", + "x", + ] { + assert!(!is_local_path(bad), "{bad:?}"); + } + } + #[test] fn a_path_with_its_own_query_survives_as_one_value() { assert_eq!(query_escape("/sources/a-1_b.c~"), "/sources/a-1_b.c~"); diff --git a/tests/login_test.rs b/tests/login_test.rs new file mode 100644 index 0000000..6c76554 --- /dev/null +++ b/tests/login_test.rs @@ -0,0 +1,407 @@ +// The sign-in form and its routes, with a test service: one API key, and a +// directory whose answer each test picks. +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use auth_rs::http::{ApiKeyRegistry, Permission, ResolvedApiKey, SessionKind, SessionStore}; +use auth_rs::login::LoginError; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::Response; +use serde_json::json; +use web_rs::login::{LoginForm, SessionTtl, SignIn, login_form, login_submit, logout}; +use web_rs::{Site, Spec}; + +#[derive(Clone, Debug, PartialEq)] +enum Perm { + Admin, + Reader, +} + +impl Permission for Perm { + fn admin() -> Self { + Perm::Admin + } +} + +fn site() -> &'static Site { + static SITE: OnceLock<Site> = OnceLock::new(); + static SPEC: OnceLock<Spec> = OnceLock::new(); + SITE.get_or_init(|| Site { + product: "test-service", + spec: SPEC.get_or_init(|| { + let doc = json!({ "openapi": "3.1.0", "info": { "title": "t", "version": "0" }, "paths": {} }); + Spec::new(serde_json::from_value(doc).unwrap()) + }), + session_cookie: "test_session", + login_cookie: "test_login", + theme_cookie: "test_theme", + raw_routes: &[], + }) +} + +// What the directory answers next, or None when the service has no directory +type Answer = Result<(String, Perm), LoginError>; + +struct Directory(Option<Mutex<Option<Answer>>>); + +impl SignIn<Perm> for Directory { + fn ldap_enabled(&self) -> bool { + self.0.is_some() + } + fn home(&self) -> String { + "/home".to_string() + } + async fn ldap_login( + &self, + username: &str, + _password: &str, + ) -> Result<(String, Perm), LoginError> { + assert_eq!(username, "jdoe"); + self.0.as_ref().unwrap().lock().unwrap().take().unwrap() + } +} + +fn answering(answer: Answer) -> Directory { + Directory(Some(Mutex::new(Some(answer)))) +} + +fn keys(present: bool) -> ApiKeyRegistry<Perm> { + ApiKeyRegistry::new(if present { + vec![ResolvedApiKey { + id: "ops".to_string(), + name: "Operations".to_string(), + secret: "s3cret".to_string(), + permissions: Perm::Admin, + }] + } else { + Vec::new() + }) +} + +// The challenge pair a rendered form carries: the cookie half and the field half +fn challenged(cookie: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("test_theme=nord; test_login={cookie}")).unwrap(), + ); + headers +} + +fn form(key: Option<&str>, username: Option<&str>, next: Option<&str>, csrf: &str) -> LoginForm { + LoginForm { + key: key.map(str::to_string), + username: username.map(str::to_string), + password: username.map(|_| "pw".to_string()), + next: next.map(str::to_string), + _csrf: csrf.to_string(), + } +} + +async fn body(response: Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +async fn submit( + directory: &Directory, + keys: &ApiKeyRegistry<Perm>, + sessions: &SessionStore<Perm>, + form: LoginForm, +) -> Response { + login_submit( + site(), + directory, + keys, + sessions, + Duration::from_secs(60), + &challenged("chal"), + form, + ) + .await +} + +#[tokio::test] +async fn the_form_plants_its_challenge_and_offers_what_is_configured() { + let response = login_form( + site(), + &Directory(None), + Some("https://evil.example"), + &HeaderMap::new(), + ); + assert_eq!(response.status(), StatusCode::OK); + let cookie = response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .to_string(); + assert!(cookie.starts_with("test_login="), "{cookie}"); + let html = body(response).await; + assert!(html.contains("test-service")); + assert!(html.contains("Sign in with an API key")); + // A next on another origin is replaced by the service's home + assert!(html.contains(r#"value="/home""#), "{html}"); + + let response = login_form( + site(), + &answering(Err(LoginError::Busy)), + Some("/sources"), + &challenged("x"), + ); + let html = body(response).await; + assert!(html.contains("Username"), "a directory sign-in is offered"); + assert!(html.contains(r#"value="/sources""#)); +} + +#[tokio::test] +async fn a_form_this_server_did_not_serve_is_refused() { + let sessions = SessionStore::new(); + for csrf in ["", "other"] { + let response = submit( + &Directory(None), + &keys(true), + &sessions, + form(Some("s3cret"), None, None, csrf), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(body(response).await.contains("expired")); + } + // No challenge cookie at all + let response = login_submit( + site(), + &Directory(None), + &keys(true), + &sessions, + Duration::from_secs(60), + &HeaderMap::new(), + form(Some("s3cret"), None, None, "chal"), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(sessions.is_empty()); +} + +#[tokio::test] +async fn an_api_key_opens_a_session_as_that_key() { + let sessions = SessionStore::new(); + let response = submit( + &Directory(None), + &keys(true), + &sessions, + form(Some(" s3cret "), None, Some("/sources"), "chal"), + ) + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers()[header::LOCATION], "/sources"); + let cookie = response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .to_string(); + assert!( + cookie.starts_with("test_session=") && cookie.contains("Max-Age=60"), + "{cookie}" + ); + let token = cookie + .trim_start_matches("test_session=") + .split(';') + .next() + .unwrap(); + let session = sessions.get(token).expect("a session"); + assert_eq!(session.key_name, "Operations"); + assert_eq!(session.kind, SessionKind::ApiKey); + + // A wrong key, and an instance with no keys at all, read the same + for (registry, key) in [(keys(true), "wrong"), (keys(false), "s3cret")] { + let response = submit( + &Directory(None), + ®istry, + &sessions, + form(Some(key), None, None, "chal"), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(body(response).await.contains("invalid API key")); + } + // No key field at all is an empty key + let response = submit( + &Directory(None), + &keys(true), + &sessions, + form(None, None, None, "chal"), + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn a_directory_account_opens_a_session_with_what_the_rules_grant() { + let sessions = SessionStore::new(); + let directory = answering(Ok(("jdoe".to_string(), Perm::Reader))); + let response = submit( + &directory, + &keys(true), + &sessions, + form(None, Some("jdoe"), None, "chal"), + ) + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers()[header::LOCATION], "/home"); + let cookie = response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .to_string(); + let token = cookie + .trim_start_matches("test_session=") + .split(';') + .next() + .unwrap(); + let session = sessions.get(token).unwrap(); + assert_eq!(session.key_name, "ldap:jdoe"); + assert_eq!(session.kind, SessionKind::Directory); + assert_eq!(session.permissions, Perm::Reader); +} + +#[tokio::test] +async fn every_directory_refusal_has_its_status_and_its_words() { + let cases = [ + ( + LoginError::InvalidCredentials, + StatusCode::UNAUTHORIZED, + "invalid username or password", + ), + ( + LoginError::TooManyAttempts, + StatusCode::TOO_MANY_REQUESTS, + "too many failed attempts", + ), + ( + LoginError::Busy, + StatusCode::SERVICE_UNAVAILABLE, + "too many sign-ins at once", + ), + ( + LoginError::NotAuthorized, + StatusCode::FORBIDDEN, + "no access to this instance", + ), + ( + LoginError::Unavailable("down".to_string()), + StatusCode::SERVICE_UNAVAILABLE, + "could not be reached", + ), + ]; + for (error, status, words) in cases { + let sessions = SessionStore::new(); + let response = submit( + &answering(Err(error)), + &keys(true), + &sessions, + form(None, Some("jdoe"), None, "chal"), + ) + .await; + assert_eq!(response.status(), status); + let html = body(response).await; + assert!(html.contains(words), "{words}: {html}"); + assert!(sessions.is_empty()); + } + + // A username where no directory is configured + let response = submit( + &Directory(None), + &keys(true), + &SessionStore::new(), + form(None, Some("jdoe"), None, "chal"), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn signing_out_ends_the_session_and_returns_to_the_form() { + let sessions = SessionStore::new(); + let issued = sessions.create( + "k".into(), + SessionKind::ApiKey, + Some("k".into()), + Perm::Admin, + Duration::from_secs(60), + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("test_session={}", issued.token)).unwrap(), + ); + + let response = logout(site(), &sessions, &headers, Some("/".to_string())); + assert!(sessions.get(&issued.token).is_none(), "ended server-side"); + assert_eq!(response.headers()[header::LOCATION], "/api/v1/login?next=/"); + assert!( + response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .contains("Max-Age=0") + ); + + // No session, and a next on another origin: just the form + let response = logout( + site(), + &sessions, + &HeaderMap::new(), + Some("//evil".to_string()), + ); + assert_eq!(response.headers()[header::LOCATION], "/api/v1/login"); + assert_eq!(SessionTtl::default().0, Duration::from_secs(3600)); +} + +// A site whose cookie names cannot be header values: no cookie is set, and +// nothing else fails. (A service names its cookies in code; this is the +// guard against one that got it wrong.) +fn broken_site() -> &'static Site { + static SITE: OnceLock<Site> = OnceLock::new(); + SITE.get_or_init(|| Site { + session_cookie: "bad\ncookie", + login_cookie: "bad\ncookie", + ..*site() + }) +} + +#[tokio::test] +async fn a_cookie_that_cannot_be_set_is_left_out() { + let response = login_form(broken_site(), &Directory(None), None, &HeaderMap::new()); + assert!(response.headers().get(header::SET_COOKIE).is_none()); + + let sessions = SessionStore::new(); + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, HeaderValue::from_static("x=1")); + let response = login_submit( + broken_site(), + &Directory(None), + &keys(true), + &sessions, + Duration::from_secs(60), + &headers, + form(Some("s3cret"), None, None, "chal"), + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = logout(broken_site(), &sessions, &HeaderMap::new(), None); + assert!(response.headers().get(header::SET_COOKIE).is_none()); +} + +// A wrong key on a form that offers both ways in reopens on the key form. +#[tokio::test] +async fn a_refused_key_reopens_on_the_key_form() { + let directory = answering(Err(LoginError::Busy)); + let response = submit( + &directory, + &keys(true), + &SessionStore::new(), + form(Some("wrong"), None, None, "chal"), + ) + .await; + let html = body(response).await; + assert!(html.contains(r#"id="mode-key" checked"#), "{html}"); +} diff --git a/tests/pages_test.rs b/tests/pages_test.rs new file mode 100644 index 0000000..a8d04ed --- /dev/null +++ b/tests/pages_test.rs @@ -0,0 +1,216 @@ +// The browsable API through a real router: the same route answers JSON to a +// client and a page to a browser, and leaves alone what is not its to draw. +use std::sync::OnceLock; + +use auth_rs::http::BrowserIdentity; +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use serde_json::json; +use tower::ServiceExt; +use web_rs::pages::negotiate; +use web_rs::{Site, Spec}; + +fn site() -> Site { + static SPEC: OnceLock<Spec> = OnceLock::new(); + let spec = SPEC.get_or_init(|| { + let ok = json!({ "200": { "description": "ok" } }); + let doc = json!({ + "openapi": "3.1.0", + "info": { "title": "test", "version": "0" }, + "paths": { + "/api/v1/things": { "get": { "responses": ok } }, + "/api/v1/things/{id}": { "get": { "responses": ok } }, + "/api/v1/paged": { "get": { "parameters": [ + { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } } + ], "responses": ok } }, + "/api/v1/whole": { "get": { "responses": ok } }, + "/api/v1/output/{id}": { "get": { "responses": ok } } + } + }); + Spec::new(serde_json::from_value(doc).unwrap()) + }); + Site { + product: "test-service", + spec, + session_cookie: "test_session", + login_cookie: "test_login", + theme_cookie: "test_theme", + raw_routes: &["/api/v1/output/{id}"], + } +} + +// A JSON body of about `bytes` bytes +fn big(bytes: usize) -> String { + format!(r#"{{"blob":"{}"}}"#, "x".repeat(bytes)) +} + +fn app() -> Router { + Router::new() + .route( + "/api/v1/things", + get(|| async { + let mut response = ( + [(header::ETAG, "\"json-etag\"")], + axum::Json(json!({"name": "a"})), + ) + .into_response(); + response.extensions_mut().insert(BrowserIdentity { + key_name: "ops".to_string(), + csrf: "tok".to_string(), + }); + response + }), + ) + .route( + "/api/v1/output/{id}", + get(|| async { axum::Json(json!({"all": {}})) }), + ) + .route("/api/v1/text", get(|| async { "plain text" })) + .route( + "/api/v1/broken", + get(|| async { ([(header::CONTENT_TYPE, "application/json")], "{not json") }), + ) + .route( + "/api/v1/paged", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + big(2 * 1024 * 1024), + ) + }), + ) + .route( + "/api/v1/whole", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + big(2 * 1024 * 1024), + ) + }), + ) + .route( + "/api/v1/huge", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + big(65 * 1024 * 1024), + ) + }), + ) + .layer(axum::middleware::from_fn_with_state(site(), negotiate)) +} + +async fn ask(path: &str, headers: &[(&str, &str)]) -> Response { + let mut req = Request::builder().uri(path); + for (name, value) in headers { + req = req.header(*name, *value); + } + app() + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap() +} + +async fn text(response: Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8_lossy(&bytes).to_string() +} + +const BROWSER: (&str, &str) = ("accept", "text/html,application/xhtml+xml"); + +#[tokio::test] +async fn a_client_gets_json_and_a_browser_gets_a_page() { + let resp = ask("/api/v1/things", &[]).await; + assert_eq!(resp.headers()[header::VARY], "accept"); + assert_eq!(text(resp).await, r#"{"name":"a"}"#); + + let resp = ask("/api/v1/things", &[BROWSER]).await; + assert_eq!(resp.status(), StatusCode::OK); + let headers = resp.headers().clone(); + assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8"); + assert!( + headers[header::CONTENT_SECURITY_POLICY] + .to_str() + .unwrap() + .contains("frame-ancestors") + ); + assert_eq!(headers[header::CACHE_CONTROL], "no-store, private"); + assert!( + headers.get(header::ETAG).is_none(), + "the JSON's ETag does not fit the page" + ); + let html = text(resp).await; + assert!(html.contains("test-service")); + // The session the auth layer published: who, and the token forms carry + assert!(html.contains("ops")); + + // ?format=json is the way back to the bytes from a browser + let resp = ask("/api/v1/things?format=json", &[BROWSER]).await; + assert_eq!(text(resp).await, r#"{"name":"a"}"#); +} + +#[tokio::test] +async fn a_theme_is_picked_by_link_and_remembered_by_cookie() { + let resp = ask("/api/v1/things?theme=nord", &[BROWSER]).await; + let cookie = resp.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .to_string(); + assert!(cookie.starts_with("test_theme=nord;"), "{cookie}"); + + let resp = ask( + "/api/v1/things", + &[BROWSER, ("cookie", "test_theme=dracula")], + ) + .await; + assert!(resp.headers().get(header::SET_COOKIE).is_none()); + assert!(text(resp).await.contains("dracula")); + + // A name that is not a theme is ignored + let resp = ask( + "/api/v1/things?theme=evil", + &[BROWSER, ("cookie", "test_theme=evil")], + ) + .await; + assert!(resp.headers().get(header::SET_COOKIE).is_none()); +} + +#[tokio::test] +async fn what_is_not_ours_to_draw_is_left_alone() { + // The product's own output, even though it is JSON + let resp = ask("/api/v1/output/ep-a", &[BROWSER]).await; + assert_eq!(text(resp).await, r#"{"all":{}}"#); + // A body that is not JSON + let resp = ask("/api/v1/text", &[BROWSER]).await; + assert_eq!(text(resp).await, "plain text"); + // JSON that does not parse: the bytes are more useful than a page about them + let resp = ask("/api/v1/broken", &[BROWSER]).await; + assert_eq!(text(resp).await, "{not json"); +} + +#[tokio::test] +async fn a_body_too_big_to_draw_is_paged_or_explained() { + // A route that pages: the browser is sent to the first page + let resp = ask("/api/v1/paged", &[BROWSER]).await; + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!(resp.headers()[header::LOCATION], "/api/v1/paged?limit=200"); + + // A route that does not: the size, and the way to the bytes + let resp = ask("/api/v1/whole", &[BROWSER]).await; + assert_eq!(resp.status(), StatusCode::OK); + assert!(text(resp).await.contains("too large to render")); + + // A window the caller named is rendered, however large + let resp = ask("/api/v1/paged?limit=5000", &[BROWSER]).await; + assert_eq!(resp.status(), StatusCode::OK); + assert!(!text(resp).await.contains("too large to render")); + + // Past what the renderer will even read: an error, not a truncated page + let resp = ask("/api/v1/huge", &[BROWSER]).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); +}