872 lines
28 KiB
Rust
872 lines
28 KiB
Rust
|
|
//! End-to-end tests for the TOTP auth gate.
|
||
|
|
//!
|
||
|
|
//! These drive the real router over HTTP (through `tower`'s `oneshot`) rather
|
||
|
|
//! than calling handlers directly, because the thing worth protecting is the
|
||
|
|
//! gate itself: an unauthenticated caller must never observe note content.
|
||
|
|
|
||
|
|
use axum::body::Body;
|
||
|
|
use axum::http::{header, Request, StatusCode};
|
||
|
|
use eternalibre_notes::auth::{AuthError, AuthState};
|
||
|
|
use eternalibre_notes::routes::{self, AppState};
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||
|
|
use std::sync::{Arc, OnceLock};
|
||
|
|
use tower::util::ServiceExt;
|
||
|
|
|
||
|
|
/// One shared notes directory for the whole test binary.
|
||
|
|
///
|
||
|
|
/// `ETERNALIBRE_NOTES_DIR` is process-global, so it must be set exactly once
|
||
|
|
/// before any test can race to change it.
|
||
|
|
fn shared_notes_dir() -> &'static Path {
|
||
|
|
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||
|
|
DIR.get_or_init(|| {
|
||
|
|
let mut p = std::env::temp_dir();
|
||
|
|
p.push(format!("eternalibre-it-notes-{}", std::process::id()));
|
||
|
|
let _ = std::fs::remove_dir_all(&p);
|
||
|
|
std::fs::create_dir_all(&p).unwrap();
|
||
|
|
std::env::set_var("ETERNALIBRE_NOTES_DIR", &p);
|
||
|
|
p
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn temp_secret_path(tag: &str) -> PathBuf {
|
||
|
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||
|
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||
|
|
let mut p = std::env::temp_dir();
|
||
|
|
p.push(format!(
|
||
|
|
"eternalibre-it-{}-{}-{}",
|
||
|
|
tag,
|
||
|
|
std::process::id(),
|
||
|
|
n
|
||
|
|
));
|
||
|
|
p.set_extension("secret");
|
||
|
|
let _ = std::fs::remove_file(&p);
|
||
|
|
p
|
||
|
|
}
|
||
|
|
|
||
|
|
struct Fixture {
|
||
|
|
app: axum::Router,
|
||
|
|
auth: Arc<AuthState>,
|
||
|
|
secret_file: PathBuf,
|
||
|
|
token: Option<String>,
|
||
|
|
secret: String,
|
||
|
|
base_path: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Fixture {
|
||
|
|
/// An app whose secret file does not exist yet.
|
||
|
|
///
|
||
|
|
/// TOTP enrolment happens in the terminal before the listener binds, so
|
||
|
|
/// this state is only reachable in-process. The router still handles it,
|
||
|
|
/// which is what lets these tests assert the defensive 503 path.
|
||
|
|
fn fresh(tag: &str) -> Self {
|
||
|
|
Self::build(tag, "")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// An app mounted under a reverse-proxy subdirectory.
|
||
|
|
fn with_base_path(tag: &str, base_path: &str) -> Self {
|
||
|
|
Self::build(tag, base_path)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build(tag: &str, base_path: &str) -> Self {
|
||
|
|
let secret_file = temp_secret_path(tag);
|
||
|
|
let auth = Arc::new(AuthState::with_secret_path(&secret_file));
|
||
|
|
Self {
|
||
|
|
// `routes::app`, not `routes::router`: the real binary mounts
|
||
|
|
// static assets alongside the gated routes, and a test that skips
|
||
|
|
// that assembly cannot see asset regressions.
|
||
|
|
app: routes::app(AppState {
|
||
|
|
auth: auth.clone(),
|
||
|
|
base_path: base_path.into(),
|
||
|
|
}),
|
||
|
|
auth,
|
||
|
|
secret_file,
|
||
|
|
token: None,
|
||
|
|
secret: String::new(),
|
||
|
|
base_path: base_path.to_string(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Completes enrolment against the `AuthState` directly, which is what the
|
||
|
|
/// terminal setup flow does before the server starts.
|
||
|
|
fn provision(mut self) -> Self {
|
||
|
|
let material = self.auth.begin_setup().unwrap();
|
||
|
|
self.secret = material.secret;
|
||
|
|
let code = valid_code(&self.secret);
|
||
|
|
self.token = Some(self.auth.verify_setup(&code).expect("provisioning"));
|
||
|
|
self
|
||
|
|
}
|
||
|
|
|
||
|
|
/// An app that has completed setup, with a live session cookie.
|
||
|
|
fn provisioned(tag: &str) -> Self {
|
||
|
|
Self::fresh(tag).provision()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Logs in over HTTP, exercising the real `/login` route.
|
||
|
|
///
|
||
|
|
/// A rejected code just means the 30s TOTP window rolled over between
|
||
|
|
/// enrolment and this call, so retry against the live secret rather than
|
||
|
|
/// failing on a timing coincidence.
|
||
|
|
async fn login_over_http(self) -> Self {
|
||
|
|
// Enrol first if the caller hasn't, so this can be chained onto a
|
||
|
|
// bare fixture.
|
||
|
|
let mut f = if self.secret.is_empty() {
|
||
|
|
self.provision()
|
||
|
|
} else {
|
||
|
|
self
|
||
|
|
};
|
||
|
|
for _ in 0..3 {
|
||
|
|
let code = valid_code(&f.secret);
|
||
|
|
let (status, _, cookie) = f.post_form("/login", &format!("code={code}")).await;
|
||
|
|
if status == StatusCode::SEE_OTHER {
|
||
|
|
f.token = Some(session_value(&cookie.expect("login sets a cookie")));
|
||
|
|
return f;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
panic!("could not log in over HTTP with a valid code");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The URL the app is *expected* to emit for an app-absolute path.
|
||
|
|
///
|
||
|
|
/// Requests themselves are always app-absolute: a reverse proxy strips
|
||
|
|
/// the base prefix before forwarding, so the app never sees it. The base
|
||
|
|
/// path only shows up in generated hrefs, form actions, and redirects.
|
||
|
|
fn expect(&self, app_path: &str) -> String {
|
||
|
|
format!("{}{}", self.base_path, app_path)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn cookie_header(&self) -> String {
|
||
|
|
format!("eternalibre_session={}", self.token.clone().unwrap())
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn get(&self, uri: &str) -> (StatusCode, String) {
|
||
|
|
let res = self
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
finish(res).await
|
||
|
|
}
|
||
|
|
|
||
|
|
/// GET carrying the session cookie.
|
||
|
|
async fn get_authed(&self, uri: &str) -> (StatusCode, String) {
|
||
|
|
let res = self
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.uri(uri)
|
||
|
|
.header(header::COOKIE, self.cookie_header())
|
||
|
|
.body(Body::empty())
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
finish(res).await
|
||
|
|
}
|
||
|
|
|
||
|
|
/// POST as an anonymous visitor.
|
||
|
|
async fn post_form(&self, uri: &str, body: &str) -> (StatusCode, String, Option<String>) {
|
||
|
|
let res = self
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.method("POST")
|
||
|
|
.uri(uri)
|
||
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||
|
|
.body(Body::from(body.to_string()))
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
respond(res).await
|
||
|
|
}
|
||
|
|
|
||
|
|
/// POST carrying the session cookie.
|
||
|
|
async fn post_authed(&self, uri: &str, body: &str) -> (StatusCode, String, Option<String>) {
|
||
|
|
let res = self
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.method("POST")
|
||
|
|
.uri(uri)
|
||
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||
|
|
.header(header::COOKIE, self.cookie_header())
|
||
|
|
.body(Body::from(body.to_string()))
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
respond(res).await
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Creates a note and returns its id.
|
||
|
|
async fn create_note(&self) -> String {
|
||
|
|
let (_, _, loc) = self.post_authed("/notes", "").await;
|
||
|
|
loc.expect("create redirects to the new note")
|
||
|
|
.rsplit('/')
|
||
|
|
.next()
|
||
|
|
.unwrap()
|
||
|
|
.to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// GET with an arbitrary cookie string, for spoofed-credential checks.
|
||
|
|
async fn get_with_cookie(&self, cookie: &str) -> (StatusCode, String) {
|
||
|
|
let res = self
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.uri("/")
|
||
|
|
.header(header::COOKIE, cookie)
|
||
|
|
.body(Body::empty())
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
finish(res).await
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Drop for Fixture {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
let _ = std::fs::remove_file(&self.secret_file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn finish(res: axum::response::Response) -> (StatusCode, String) {
|
||
|
|
let status = res.status();
|
||
|
|
let loc = res
|
||
|
|
.headers()
|
||
|
|
.get(header::LOCATION)
|
||
|
|
.and_then(|v| v.to_str().ok())
|
||
|
|
.map(|s| format!("LOCATION: {s}"));
|
||
|
|
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
let mut text = String::from_utf8_lossy(&bytes).into_owned();
|
||
|
|
if let Some(l) = loc {
|
||
|
|
text.push('\n');
|
||
|
|
text.push_str(&l);
|
||
|
|
}
|
||
|
|
(status, text)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn respond(res: axum::response::Response) -> (StatusCode, String, Option<String>) {
|
||
|
|
let status = res.status();
|
||
|
|
let cookie = res
|
||
|
|
.headers()
|
||
|
|
.get(header::SET_COOKIE)
|
||
|
|
.and_then(|v| v.to_str().ok())
|
||
|
|
.map(|s| s.to_string());
|
||
|
|
let loc = res
|
||
|
|
.headers()
|
||
|
|
.get(header::LOCATION)
|
||
|
|
.and_then(|v| v.to_str().ok())
|
||
|
|
.map(|s| s.to_string());
|
||
|
|
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||
|
|
(status, text, cookie.or(loc))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn session_value(set_cookie: &str) -> String {
|
||
|
|
set_cookie
|
||
|
|
.split(';')
|
||
|
|
.next()
|
||
|
|
.expect("cookie pair")
|
||
|
|
.split_once('=')
|
||
|
|
.expect("key=value")
|
||
|
|
.1
|
||
|
|
.to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// What a real authenticator app would display for this secret right now.
|
||
|
|
fn current_code(encoded_secret: &str) -> String {
|
||
|
|
use totp_rs::{Algorithm, Builder, Secret};
|
||
|
|
Builder::new()
|
||
|
|
.with_algorithm(Algorithm::SHA1)
|
||
|
|
.with_digits(6)
|
||
|
|
.with_skew(1)
|
||
|
|
.with_step_duration(30)
|
||
|
|
.with_account_name("local")
|
||
|
|
.with_issuer(Some("EternaLibre Notes"))
|
||
|
|
.with_secret(Secret::try_from_base32(encoded_secret).unwrap())
|
||
|
|
.build()
|
||
|
|
.unwrap()
|
||
|
|
.generate_current()
|
||
|
|
.to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Rejects a secret that is not a usable Base32 TOTP key, with a readable
|
||
|
|
/// message. A silently empty code would otherwise look like a TOTP mismatch.
|
||
|
|
fn valid_code(secret: &str) -> String {
|
||
|
|
assert!(
|
||
|
|
secret.len() >= 16 && !secret.is_empty(),
|
||
|
|
"test fixture has an unusable secret: {secret:?}"
|
||
|
|
);
|
||
|
|
let code = current_code(secret);
|
||
|
|
assert_eq!(
|
||
|
|
code.len(),
|
||
|
|
6,
|
||
|
|
"expected a 6-digit code, got {code:?} from a bad secret"
|
||
|
|
);
|
||
|
|
code
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- gating ----
|
||
|
|
|
||
|
|
/// Every data-bearing route must refuse an anonymous caller. This is the core
|
||
|
|
/// security assertion: note content must not leak to an unauthenticated peer.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn anonymous_cannot_read_any_note_route() {
|
||
|
|
shared_notes_dir();
|
||
|
|
let f = Fixture::provisioned("anon");
|
||
|
|
|
||
|
|
for uri in ["/", "/notes/some-id", "/about", "/export", "/preview"] {
|
||
|
|
let (status, body) = f.get(uri).await;
|
||
|
|
assert!(
|
||
|
|
status == StatusCode::SEE_OTHER || status == StatusCode::UNAUTHORIZED,
|
||
|
|
"{uri} returned {status}, expected a redirect or 401"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!body.contains("app-body"),
|
||
|
|
"{uri} leaked the app shell to an anonymous caller"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn anonymous_always_redirects_to_login() {
|
||
|
|
let f = Fixture::provisioned("anon-login");
|
||
|
|
let (status, body) = f.get("/").await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
assert!(body.contains("LOCATION: /login"), "got: {body}");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn forged_session_cookie_is_rejected() {
|
||
|
|
let f = Fixture::provisioned("forged");
|
||
|
|
|
||
|
|
let res = f
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.uri("/")
|
||
|
|
.header(header::COOKIE, "eternalibre_session=0000000000000000")
|
||
|
|
.body(Body::empty())
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
assert_eq!(res.status(), StatusCode::SEE_OTHER);
|
||
|
|
assert_eq!(f.auth.session_count(), 1, "forged token created no session");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn empty_session_cookie_is_rejected() {
|
||
|
|
let f = Fixture::provisioned("emptycookie");
|
||
|
|
let (status, _) = f.get_with_cookie("eternalibre_session=").await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------ access with ----
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn authenticated_user_can_reach_the_app() {
|
||
|
|
let f = Fixture::provisioned("ok");
|
||
|
|
let (status, body) = f.get_authed("/").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(body.contains("app-body"), "expected the app shell");
|
||
|
|
assert!(body.contains("EternaLibre Notes"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn note_content_is_invisible_until_authenticated() {
|
||
|
|
let f = Fixture::provisioned("leak");
|
||
|
|
let id = f.create_note().await;
|
||
|
|
|
||
|
|
let marker = "TOPSECRET-CANARY-9f2a";
|
||
|
|
let (status, _, _) = f
|
||
|
|
.post_authed(
|
||
|
|
&format!("/notes/{id}"),
|
||
|
|
&format!("_method=put&title=Canary&content={marker}"),
|
||
|
|
)
|
||
|
|
.await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
|
||
|
|
// The authenticated user does see it, proving the marker exists at all.
|
||
|
|
let (status, authed) = f.get_authed(&format!("/notes/{id}")).await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(authed.contains(marker), "test is not exercising real data");
|
||
|
|
|
||
|
|
// An anonymous request for the same note must not echo it back.
|
||
|
|
let (status, anon) = f.get(&format!("/notes/{id}")).await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
assert!(
|
||
|
|
!anon.contains(marker),
|
||
|
|
"note content leaked to an unauthenticated request"
|
||
|
|
);
|
||
|
|
assert!(!anon.contains("Canary"), "note title leaked");
|
||
|
|
|
||
|
|
// Nor does the index page list it.
|
||
|
|
let (_, index) = f.get("/").await;
|
||
|
|
assert!(!index.contains("Canary"), "note title leaked via index");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn preview_requires_authentication() {
|
||
|
|
let f = Fixture::provisioned("preview-gate");
|
||
|
|
|
||
|
|
let (status, body, _) = f.post_form("/preview", "content=%23+Hello").await;
|
||
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||
|
|
assert!(
|
||
|
|
!body.contains("<h1>"),
|
||
|
|
"markdown was rendered for an anonymous caller"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn preview_works_once_authenticated() {
|
||
|
|
let f = Fixture::provisioned("preview-ok");
|
||
|
|
let (status, body, _) = f.post_authed("/preview", "content=%23+Hello").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(body.contains("<h1>Hello</h1>"), "got: {body}");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn export_requires_authentication() {
|
||
|
|
let f = Fixture::provisioned("export-gate");
|
||
|
|
let (status, body) = f.get("/export").await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
assert!(!body.contains("EternaLibre Notes Export"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn write_routes_require_authentication() {
|
||
|
|
let f = Fixture::provisioned("write-gate");
|
||
|
|
|
||
|
|
// Creating a note anonymously must not create anything.
|
||
|
|
let before = std::fs::read_dir(shared_notes_dir()).unwrap().count();
|
||
|
|
let (status, _, _) = f.post_form("/notes", "").await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
let after = std::fs::read_dir(shared_notes_dir()).unwrap().count();
|
||
|
|
assert_eq!(before, after, "anonymous POST created a note");
|
||
|
|
|
||
|
|
// Theme changes are gated too.
|
||
|
|
let (status, _, _) = f.post_form("/theme", "theme=Dracula").await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn deleting_a_note_requires_authentication() {
|
||
|
|
let f = Fixture::provisioned("delete-gate");
|
||
|
|
let id = f.create_note().await;
|
||
|
|
|
||
|
|
let (status, _, _) = f.post_form(&format!("/notes/{id}"), "_method=delete").await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
|
||
|
|
// Still present, i.e. the anonymous delete did not take effect.
|
||
|
|
let (status, _) = f.get_authed(&format!("/notes/{id}")).await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ auth ----
|
||
|
|
|
||
|
|
/// There is deliberately no `/setup` route. Enrolment happens in the
|
||
|
|
/// terminal before the listener binds, so an HTTP-reachable setup endpoint
|
||
|
|
/// could be claimed by anyone who reaches the port first.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn there_is_no_web_setup_route() {
|
||
|
|
let f = Fixture::fresh("no-setup-route");
|
||
|
|
let (status, _) = f.get("/setup").await;
|
||
|
|
assert_eq!(
|
||
|
|
status,
|
||
|
|
StatusCode::NOT_FOUND,
|
||
|
|
"/setup must not exist over HTTP"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A wrong code during terminal enrolment must leave no trace: no secret file
|
||
|
|
/// written and the device still unprovisioned.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn wrong_enrolment_code_writes_nothing() {
|
||
|
|
let f = Fixture::fresh("bad-enrol");
|
||
|
|
f.auth.begin_setup().unwrap();
|
||
|
|
|
||
|
|
assert_eq!(f.auth.verify_setup("000000"), Err(AuthError::InvalidCode));
|
||
|
|
assert!(f.auth.needs_setup());
|
||
|
|
assert!(
|
||
|
|
!f.secret_file.exists(),
|
||
|
|
"a failed enrolment must not write the secret"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn login_page_is_shown_after_provisioning() {
|
||
|
|
let f = Fixture::provisioned("loginpage");
|
||
|
|
let (status, body) = f.get("/login").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(body.contains("Unlock"));
|
||
|
|
assert!(!body.contains("<svg"), "login must not show a QR code");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn wrong_login_code_is_rejected_without_cookie() {
|
||
|
|
let f = Fixture::provisioned("badlogin");
|
||
|
|
let (status, body, cookie) = f.post_form("/login", "code=111111").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(body.contains("was not accepted"));
|
||
|
|
assert!(cookie.is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn valid_login_code_issues_a_hardened_session_cookie() {
|
||
|
|
let f = Fixture::provisioned("goodlogin");
|
||
|
|
let code = valid_code(&f.secret);
|
||
|
|
let (status, _, cookie) = f.post_form("/login", &format!("code={code}")).await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
|
||
|
|
let set_cookie = cookie.expect("login should set a session");
|
||
|
|
assert!(set_cookie.contains("HttpOnly"), "cookie must be HttpOnly");
|
||
|
|
assert!(set_cookie.contains("SameSite=Strict"), "got: {set_cookie}");
|
||
|
|
assert!(
|
||
|
|
set_cookie.contains("Max-Age=3600"),
|
||
|
|
"expected a 1 hour session, got: {set_cookie}"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
set_cookie.contains("Path=/;"),
|
||
|
|
"at the root the cookie path should be the default path, got: {set_cookie}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Unprovisioned is only reachable in-process now, so the login page says so
|
||
|
|
/// instead of redirecting to a route that no longer exists.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn login_reports_unprovisioned_device() {
|
||
|
|
let f = Fixture::fresh("login-unprov");
|
||
|
|
let (status, body) = f.get("/login").await;
|
||
|
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||
|
|
assert!(body.contains("terminal"), "got: {body}");
|
||
|
|
assert!(!body.contains("/setup"), "must not point at a dead route");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn logout_invalidates_the_session() {
|
||
|
|
let f = Fixture::provisioned("logout");
|
||
|
|
let token = f.token.clone().unwrap();
|
||
|
|
assert!(f.auth.validate_session(&token));
|
||
|
|
|
||
|
|
let res = f
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.method("POST")
|
||
|
|
.uri("/logout")
|
||
|
|
.header(header::COOKIE, f.cookie_header())
|
||
|
|
.body(Body::empty())
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert_eq!(res.status(), StatusCode::SEE_OTHER);
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!f.auth.validate_session(&token),
|
||
|
|
"server must forget the token, not just clear the cookie"
|
||
|
|
);
|
||
|
|
|
||
|
|
// The now-dead cookie no longer grants access.
|
||
|
|
let res = f
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.uri("/")
|
||
|
|
.header(header::COOKIE, format!("eternalibre_session={token}"))
|
||
|
|
.body(Body::empty())
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert_eq!(res.status(), StatusCode::SEE_OTHER);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn sessions_survive_across_requests() {
|
||
|
|
let f = Fixture::provisioned("persist");
|
||
|
|
for _ in 0..3 {
|
||
|
|
let (status, _) = f.get_authed("/").await;
|
||
|
|
assert_eq!(status, StatusCode::OK, "session should persist");
|
||
|
|
}
|
||
|
|
assert_eq!(f.auth.session_count(), 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn separate_logins_get_separate_sessions() {
|
||
|
|
let f = Fixture::provisioned("multi");
|
||
|
|
let code = valid_code(&f.secret);
|
||
|
|
let (_, _, cookie) = f.post_form("/login", &format!("code={code}")).await;
|
||
|
|
let second = session_value(&cookie.unwrap());
|
||
|
|
|
||
|
|
assert_ne!(second, f.token.clone().unwrap());
|
||
|
|
assert_eq!(f.auth.session_count(), 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn theme_cookie_alongside_session_still_works() {
|
||
|
|
let f = Fixture::provisioned("themecookie");
|
||
|
|
|
||
|
|
let res = f
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.uri("/")
|
||
|
|
.header(
|
||
|
|
header::COOKIE,
|
||
|
|
format!(
|
||
|
|
"theme=Dracula; eternalibre_session={}",
|
||
|
|
f.token.clone().unwrap()
|
||
|
|
),
|
||
|
|
)
|
||
|
|
.body(Body::empty())
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
assert_eq!(res.status(), StatusCode::OK);
|
||
|
|
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert!(String::from_utf8_lossy(&bytes).contains("data-theme=\"Dracula\""));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------- base path ----
|
||
|
|
|
||
|
|
/// Every generated URL must carry the base prefix, or a browser behind the
|
||
|
|
/// proxy would resolve `/about` against the site root and 404.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn base_path_prefixes_generated_links() {
|
||
|
|
let f = Fixture::with_base_path("base-links", "/forgejo")
|
||
|
|
.login_over_http()
|
||
|
|
.await;
|
||
|
|
let (status, body) = f.get_authed("/").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
|
||
|
|
// `/static/alpine.min.js` is the only externally-loaded asset;
|
||
|
|
// `app.js` is inlined via `include_str!`, so it has no URL to rewrite.
|
||
|
|
for target in [
|
||
|
|
"/about",
|
||
|
|
"/export",
|
||
|
|
"/notes",
|
||
|
|
"/theme",
|
||
|
|
"/static/alpine.min.js",
|
||
|
|
] {
|
||
|
|
let expected = f.expect(target);
|
||
|
|
assert!(
|
||
|
|
body.contains(&format!("\"{expected}\"")) || body.contains(&format!("'{expected}'")),
|
||
|
|
"expected {expected} in generated markup"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
assert!(
|
||
|
|
!body.contains("href=\"/about\""),
|
||
|
|
"found a root-absolute link that would escape the prefix"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!body.contains("src=\"/static/"),
|
||
|
|
"found a root-absolute asset URL that would escape the prefix"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn base_path_prefixes_redirects() {
|
||
|
|
let f = Fixture::with_base_path("base-redirect", "/forgejo")
|
||
|
|
.login_over_http()
|
||
|
|
.await;
|
||
|
|
|
||
|
|
// Creating a note redirects to the new note's URL.
|
||
|
|
let (_, _, loc) = f.post_authed("/notes", "").await;
|
||
|
|
let loc = loc.expect("create redirects");
|
||
|
|
assert!(
|
||
|
|
loc.starts_with("/forgejo/notes/"),
|
||
|
|
"redirect escaped the base path: {loc}"
|
||
|
|
);
|
||
|
|
|
||
|
|
// An unauthenticated request must bounce within the prefix too.
|
||
|
|
let anon = Fixture::with_base_path("base-anon", "/forgejo");
|
||
|
|
let (status, body) = anon.get("/").await;
|
||
|
|
assert_eq!(status, StatusCode::SEE_OTHER);
|
||
|
|
assert!(
|
||
|
|
body.contains("LOCATION: /forgejo/login"),
|
||
|
|
"login redirect escaped the base path: {body}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn base_path_scopes_the_session_cookie() {
|
||
|
|
let f = Fixture::with_base_path("base-cookie", "/forgejo")
|
||
|
|
.login_over_http()
|
||
|
|
.await;
|
||
|
|
// The fixture captured only the token value, so log in again to inspect
|
||
|
|
// the raw Set-Cookie header.
|
||
|
|
let code = valid_code(&f.secret);
|
||
|
|
let (_, _, set_cookie) = f.post_form("/login", &format!("code={code}")).await;
|
||
|
|
let raw = set_cookie.expect("login sets a cookie");
|
||
|
|
assert!(
|
||
|
|
raw.contains("Path=/forgejo"),
|
||
|
|
"cookie must be scoped to the prefix, got: {raw}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn base_path_login_form_posts_to_the_prefix() {
|
||
|
|
let f = Fixture::with_base_path("base-form", "/forgejo").provision();
|
||
|
|
let (status, body) = f.get("/login").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(
|
||
|
|
body.contains(r#"action="/forgejo/login""#),
|
||
|
|
"login form action escaped the prefix"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A reverse proxy strips the base path before forwarding, so the path the
|
||
|
|
/// app actually receives for a static asset is app-absolute.
|
||
|
|
///
|
||
|
|
/// This is the assertion that catches the bug where the static nest was
|
||
|
|
/// registered at `{base}/static`: the asset 404'd, `x-data="app()"` never
|
||
|
|
/// initialised, and every Alpine directive — including the File menu
|
||
|
|
/// dropdowns — silently stopped working.
|
||
|
|
///
|
||
|
|
/// It drives `routes::app`, the same assembly `main.rs` uses, rather than
|
||
|
|
/// hand-rolling a router, so it fails if the real configuration regresses.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn alpine_is_served_app_absolute_even_with_a_base_path() {
|
||
|
|
use eternalibre_notes::routes::{self, AppState};
|
||
|
|
|
||
|
|
let secret_file = temp_secret_path("static-abs");
|
||
|
|
let auth = Arc::new(AuthState::with_secret_path(&secret_file));
|
||
|
|
let app = routes::app(AppState {
|
||
|
|
auth,
|
||
|
|
base_path: "/forgejo".into(),
|
||
|
|
});
|
||
|
|
|
||
|
|
let (status, body) = fetch(&app, "/static/alpine.min.js").await;
|
||
|
|
assert_eq!(
|
||
|
|
status,
|
||
|
|
StatusCode::OK,
|
||
|
|
"Alpine must be served at the app-absolute path; a miss here leaves \
|
||
|
|
x-data=\"app()\" uninitialised and the menus dead"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
body.contains("Alpine"),
|
||
|
|
"expected the Alpine bundle, got {} bytes",
|
||
|
|
body.len()
|
||
|
|
);
|
||
|
|
|
||
|
|
// The prefixed path is not a route on the app: the proxy is what strips
|
||
|
|
// the prefix, so the app must not also accept it.
|
||
|
|
assert_ne!(
|
||
|
|
fetch(&app, "/forgejo/static/alpine.min.js").await.0,
|
||
|
|
StatusCode::OK,
|
||
|
|
"static should not be reachable at the prefixed path"
|
||
|
|
);
|
||
|
|
|
||
|
|
let _ = std::fs::remove_file(&secret_file);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Alpine must be reachable *without* a session: it is what makes every
|
||
|
|
/// Alpine directive work, and it sits outside the auth gate.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn alpine_is_served_without_a_session() {
|
||
|
|
let f = Fixture::fresh("static-anon");
|
||
|
|
let (status, body) = f.get("/static/alpine.min.js").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(body.contains("Alpine"));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The bytes are baked in at compile time, so the response can be cached
|
||
|
|
/// indefinitely and is unaffected by the working directory.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn alpine_response_is_immutably_cacheable() {
|
||
|
|
let f = Fixture::fresh("static-cache");
|
||
|
|
let res = f
|
||
|
|
.app
|
||
|
|
.clone()
|
||
|
|
.oneshot(
|
||
|
|
Request::builder()
|
||
|
|
.uri("/static/alpine.min.js")
|
||
|
|
.body(Body::empty())
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
assert_eq!(res.status(), StatusCode::OK);
|
||
|
|
let cache = res
|
||
|
|
.headers()
|
||
|
|
.get(header::CACHE_CONTROL)
|
||
|
|
.and_then(|v| v.to_str().ok())
|
||
|
|
.unwrap_or_default();
|
||
|
|
assert!(
|
||
|
|
cache.contains("immutable"),
|
||
|
|
"expected an immutable cache header, got {cache:?}"
|
||
|
|
);
|
||
|
|
let ctype = res
|
||
|
|
.headers()
|
||
|
|
.get(header::CONTENT_TYPE)
|
||
|
|
.and_then(|v| v.to_str().ok())
|
||
|
|
.unwrap_or_default();
|
||
|
|
assert!(
|
||
|
|
ctype.contains("javascript"),
|
||
|
|
"expected a JavaScript content type, got {ctype:?}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The app must not depend on a `static/` directory existing at runtime.
|
||
|
|
/// This is the regression that bit a real deployment: the binary was moved
|
||
|
|
/// without its `static/` folder and every Alpine directive went dead.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn alpine_is_available_without_a_static_directory() {
|
||
|
|
use eternalibre_notes::routes::{self, AppState};
|
||
|
|
|
||
|
|
let secret_file = temp_secret_path("static-nodir");
|
||
|
|
let auth = Arc::new(AuthState::with_secret_path(&secret_file));
|
||
|
|
let app = routes::app(AppState {
|
||
|
|
auth,
|
||
|
|
base_path: "".into(),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Nothing is read from disk at request time; the bundle is linked in.
|
||
|
|
let (status, body) = fetch(&app, "/static/alpine.min.js").await;
|
||
|
|
assert_eq!(status, StatusCode::OK);
|
||
|
|
assert!(
|
||
|
|
body.len() > 10_000,
|
||
|
|
"bundle looks truncated: {} bytes",
|
||
|
|
body.len()
|
||
|
|
);
|
||
|
|
|
||
|
|
let _ = std::fs::remove_file(&secret_file);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// GET a path and return its status and body.
|
||
|
|
async fn fetch(app: &axum::Router, path: &str) -> (StatusCode, String) {
|
||
|
|
let res = app
|
||
|
|
.clone()
|
||
|
|
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
let status = res.status();
|
||
|
|
let bytes = axum::body::to_bytes(res.into_body(), 4 << 20)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
(status, String::from_utf8_lossy(&bytes).into_owned())
|
||
|
|
}
|