678 lines
23 KiB
Rust
678 lines
23 KiB
Rust
//! Black-box tests for multi-space mode: login on the unified `/.spaces`
|
|
//! surface, space CRUD over the admin API nested at `/.spaces/api/admin`,
|
|
//! shared sessions, per-space authorization, and the boot-detection error
|
|
//! cases around a provisioned (`spaces.json`) root.
|
|
//!
|
|
//! Each test provisions a temp root by spawning the compiled `silverbullet`
|
|
//! binary's `setup` subcommand (the same code path the setup wizard uses
|
|
//! under the hood, but exercised here through the real CLI surface) and then
|
|
//! spawns the binary again as a subprocess against the provisioned root,
|
|
//! driving its HTTP surface with `reqwest::blocking`. The runtime API is
|
|
//! disabled (`SB_RUNTIME_API=0`) so no headless Chrome launches.
|
|
//!
|
|
//! Readiness is probed via `/.spaces/api/admin/spaces` returning 401 (server
|
|
//! up, the spaces router mounted, gating active), keeping the assertions
|
|
//! independent of browser-side rendering.
|
|
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use silverbullet_server::multi::setup::FirstSpace;
|
|
|
|
const ADMIN_USER: &str = "admin";
|
|
const ADMIN_PASSWORD: &str = "adminpw1";
|
|
|
|
/// Wraps the spawned server process and kills + reaps it on drop.
|
|
struct Server(Child);
|
|
impl Drop for Server {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
|
|
mod common;
|
|
use common::free_port;
|
|
|
|
/// Provision `root` the same way an operator scripting a deployment would:
|
|
/// by running the real `silverbullet setup` subcommand as a subprocess
|
|
/// (writing `users.json` with a single admin account and `spaces.json`,
|
|
/// optionally seeded with one first space).
|
|
fn provision(root: &std::path::Path, space: Option<FirstSpace>) {
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_silverbullet"));
|
|
cmd.arg("setup")
|
|
.arg(root)
|
|
.arg("--admin")
|
|
.arg(format!("{ADMIN_USER}:{ADMIN_PASSWORD}"));
|
|
if let Some(first_space) = &space {
|
|
cmd.arg("--space")
|
|
.arg(&first_space.name)
|
|
.arg("--at")
|
|
.arg(&first_space.prefix);
|
|
if !first_space.folder.is_empty() {
|
|
cmd.arg("--space-folder").arg(&first_space.folder);
|
|
}
|
|
}
|
|
let output = cmd.output().expect("spawn silverbullet setup");
|
|
assert!(
|
|
output.status.success(),
|
|
"silverbullet setup failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
/// `silverbullet setup` on a fresh temp dir provisions it (exit 0, both
|
|
/// config files written) and refuses to run a second time against the same
|
|
/// now-configured root (non-zero exit, nothing clobbered).
|
|
#[test]
|
|
fn setup_subcommand_provisions_root_then_refuses_a_second_run() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
|
|
let first = Command::new(env!("CARGO_BIN_EXE_silverbullet"))
|
|
.arg("setup")
|
|
.arg(root.path())
|
|
.arg("--admin")
|
|
.arg("admin:adminpw1")
|
|
.arg("--space")
|
|
.arg("Notes")
|
|
.output()
|
|
.expect("spawn silverbullet setup");
|
|
assert!(
|
|
first.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&first.stderr)
|
|
);
|
|
let stdout = String::from_utf8_lossy(&first.stdout);
|
|
assert!(stdout.contains("Setup complete"), "{stdout}");
|
|
assert!(root.path().join("users.json").exists());
|
|
assert!(root.path().join("spaces.json").exists());
|
|
|
|
let second = Command::new(env!("CARGO_BIN_EXE_silverbullet"))
|
|
.arg("setup")
|
|
.arg(root.path())
|
|
.arg("--admin")
|
|
.arg("someoneelse:otherpw1")
|
|
.output()
|
|
.expect("spawn silverbullet setup");
|
|
assert!(!second.status.success());
|
|
let stderr = String::from_utf8_lossy(&second.stderr);
|
|
assert!(stderr.contains("already configured"), "{stderr}");
|
|
}
|
|
|
|
/// Provision a fresh temp root (admin account, no first space) and spawn the
|
|
/// binary against it with no boot-mode env switches at all — `boot::detect`
|
|
/// must pick multi-space mode purely from `spaces.json`/`users.json` being on
|
|
/// disk. Waits for the admin API to answer (401 on the gated spaces list).
|
|
fn start_multi(extra_env: &[(&str, &str)]) -> (Server, tempfile::TempDir, String) {
|
|
let root = tempfile::tempdir().unwrap();
|
|
provision(root.path(), None);
|
|
let port = free_port();
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_silverbullet"));
|
|
cmd.arg(root.path())
|
|
.arg("-p")
|
|
.arg(port.to_string())
|
|
.arg("-L")
|
|
.arg("127.0.0.1")
|
|
.env_remove("SB_MULTI_SPACE")
|
|
.env_remove("SB_USER")
|
|
.env("SB_RUNTIME_API", "0")
|
|
.env("SB_DISABLE_SERVICE_WORKER", "1")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
for (k, v) in extra_env {
|
|
cmd.env(k, v);
|
|
}
|
|
let child = cmd.spawn().expect("spawn silverbullet");
|
|
let server = Server(child);
|
|
let base = format!("http://127.0.0.1:{port}");
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
if let Ok(resp) = client
|
|
.get(format!("{base}/.spaces/api/admin/spaces"))
|
|
.send()
|
|
{
|
|
if resp.status().as_u16() == 401 {
|
|
break;
|
|
}
|
|
}
|
|
assert!(Instant::now() < deadline, "server did not boot in time");
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
(server, root, base)
|
|
}
|
|
|
|
fn admin_client(base: &str) -> reqwest::blocking::Client {
|
|
let client = reqwest::blocking::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap();
|
|
let resp = client
|
|
.post(format!("{base}/.spaces/api/login"))
|
|
.json(&serde_json::json!({ "username": ADMIN_USER, "password": ADMIN_PASSWORD }))
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success());
|
|
assert_eq!(resp.json::<serde_json::Value>().unwrap()["status"], "ok");
|
|
client
|
|
}
|
|
|
|
#[test]
|
|
fn boots_empty_root_with_authenticated_space_index() {
|
|
let (_srv, _root, base) = start_multi(&[]);
|
|
|
|
// With no space bound at `/`, the root points the browser at the one
|
|
// logged-in surface. (What that surface *renders* is asserted by the e2e
|
|
// suite, not here.)
|
|
let no_redirect = reqwest::blocking::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.unwrap();
|
|
let resp = no_redirect.get(format!("{base}/")).send().unwrap();
|
|
assert_eq!(resp.status().as_u16(), 307);
|
|
assert_eq!(resp.headers()["location"], "/.spaces");
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp = client
|
|
.get(format!("{base}/.spaces/api/spaces"))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 401);
|
|
let resp = client
|
|
.get(format!("{base}/.spaces/api/session"))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 401);
|
|
|
|
let client = reqwest::blocking::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap();
|
|
let resp = client
|
|
.post(format!("{base}/.spaces/api/login"))
|
|
.json(&serde_json::json!({ "username": ADMIN_USER, "password": ADMIN_PASSWORD }))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(resp.json::<serde_json::Value>().unwrap()["status"], "ok");
|
|
|
|
// One login, both halves of the surface: the session reports admin-ness
|
|
// and the admin-only API accepts the very same cookie.
|
|
let session = client
|
|
.get(format!("{base}/.spaces/api/session"))
|
|
.send()
|
|
.unwrap()
|
|
.json::<serde_json::Value>()
|
|
.unwrap();
|
|
assert_eq!(session["username"], ADMIN_USER);
|
|
assert_eq!(session["admin"], true);
|
|
|
|
// The space list is the visible-space array itself, with no envelope.
|
|
let body = client
|
|
.get(format!("{base}/.spaces/api/spaces"))
|
|
.send()
|
|
.unwrap()
|
|
.json::<serde_json::Value>()
|
|
.unwrap();
|
|
assert_eq!(body, serde_json::json!([]));
|
|
|
|
assert!(client
|
|
.get(format!("{base}/.spaces/api/admin/spaces"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.is_success());
|
|
}
|
|
|
|
/// `boot::detect` errors when a provisioned root (`spaces.json` present) is
|
|
/// booted with the legacy `SB_USER` credential env var set — accounts now
|
|
/// live in `users.json`, so this must exit non-zero rather than silently
|
|
/// ignoring the account data already on disk.
|
|
#[test]
|
|
fn spaces_json_with_sb_user_refuses_to_boot() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
provision(root.path(), None);
|
|
let out = Command::new(env!("CARGO_BIN_EXE_silverbullet"))
|
|
.arg(root.path())
|
|
.arg("-p")
|
|
.arg(free_port().to_string())
|
|
.env("SB_USER", "admin:adminpw1")
|
|
.output()
|
|
.unwrap();
|
|
assert!(!out.status.success());
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(stderr.contains("users.json"), "{stderr}");
|
|
assert!(stderr.contains("SB_USER"), "{stderr}");
|
|
}
|
|
|
|
/// Same provisioned-root gate, but for the legacy `SB_AUTH_TOKEN` env var:
|
|
/// API tokens now live in `users.json` and are managed through the admin UI,
|
|
/// so a provisioned root booted with `SB_AUTH_TOKEN` set must refuse rather
|
|
/// than silently ignore it.
|
|
#[test]
|
|
fn spaces_json_with_sb_auth_token_refuses_to_boot() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
provision(root.path(), None);
|
|
let out = Command::new(env!("CARGO_BIN_EXE_silverbullet"))
|
|
.arg(root.path())
|
|
.arg("-p")
|
|
.arg(free_port().to_string())
|
|
.env_remove("SB_USER")
|
|
.env("SB_AUTH_TOKEN", "sometoken")
|
|
.output()
|
|
.unwrap();
|
|
assert!(!out.status.success());
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(stderr.contains("SB_AUTH_TOKEN"), "{stderr}");
|
|
}
|
|
|
|
/// Same provisioned-root gate, but for `--single` instead of `SB_USER`: a
|
|
/// folder with `spaces.json` cannot also be forced into single-space mode.
|
|
#[test]
|
|
fn spaces_json_with_single_flag_refuses_to_boot() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
provision(root.path(), None);
|
|
let out = Command::new(env!("CARGO_BIN_EXE_silverbullet"))
|
|
.arg(root.path())
|
|
.arg("-p")
|
|
.arg(free_port().to_string())
|
|
.arg("--single")
|
|
.env_remove("SB_USER")
|
|
.output()
|
|
.unwrap();
|
|
assert!(!out.status.success());
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(stderr.contains("--single"), "{stderr}");
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_folder_serves_setup_and_hot_swaps_into_multi() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let port = free_port();
|
|
// No env switch, no --single, empty folder: `boot::detect` picks setup mode.
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_silverbullet"));
|
|
cmd.arg(root.path())
|
|
.arg("-p")
|
|
.arg(port.to_string())
|
|
.arg("-L")
|
|
.arg("127.0.0.1")
|
|
.env_remove("SB_MULTI_SPACE")
|
|
.env_remove("SB_USER")
|
|
.env("SB_RUNTIME_API", "0")
|
|
.env("SB_DISABLE_SERVICE_WORKER", "1")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
let child = cmd.spawn().expect("spawn silverbullet");
|
|
let _server = Server(child);
|
|
let base = format!("http://127.0.0.1:{port}");
|
|
|
|
let no_redirect = reqwest::blocking::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.unwrap();
|
|
|
|
// Wait for the setup wizard to come up: the root redirects to /.setup/.
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
if let Ok(resp) = no_redirect.get(format!("{base}/")).send() {
|
|
if resp.status().as_u16() == 307
|
|
&& resp
|
|
.headers()
|
|
.get("location")
|
|
.map(|v| v == "/.setup/")
|
|
.unwrap_or(false)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"setup wizard did not boot in time"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
|
|
// The admin API isn't mounted yet: it falls through to the setup redirect,
|
|
// not the gated 401 the live multi stack returns.
|
|
let resp = no_redirect
|
|
.get(format!("{base}/.spaces/api/admin/spaces"))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 307, "admin API absent pre-setup");
|
|
|
|
// Complete setup: admin account + a root-bound first space.
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp = client
|
|
.post(format!("{base}/.setup/api/complete"))
|
|
.json(&serde_json::json!({
|
|
"adminUsername": "admin", "adminPassword": "adminpw123",
|
|
"space": { "name": "Notes", "prefix": "/", "folder": "" }
|
|
}))
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success(), "{}", resp.text().unwrap());
|
|
|
|
// Provisioning wrote the config to disk.
|
|
assert!(root.path().join("users.json").exists());
|
|
assert!(root.path().join("spaces.json").exists());
|
|
|
|
// Within a few seconds the same port serves the multi stack: the admin API
|
|
// now exists and is gated (401 instead of the pre-setup 307 redirect).
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
if let Ok(resp) = no_redirect
|
|
.get(format!("{base}/.spaces/api/admin/spaces"))
|
|
.send()
|
|
{
|
|
if resp.status().as_u16() == 401 {
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"multi stack did not take over the port after setup"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn create_spaces_and_verify_routing_and_auth_isolation() {
|
|
let (_srv, root, base) = start_multi(&[]);
|
|
let admin = admin_client(&base);
|
|
|
|
// Open (public) space at /open.
|
|
let resp = admin
|
|
.post(format!("{base}/.spaces/api/admin/spaces"))
|
|
.json(&serde_json::json!({ "name": "Open", "binding": { "prefix": "/open" }, "public": true }))
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success(), "{}", resp.text().unwrap());
|
|
|
|
// Private (default) space at /locked — no `public`, no `members`: only
|
|
// the admin account (via users.json) can authenticate against it.
|
|
admin
|
|
.post(format!("{base}/.spaces/api/admin/spaces"))
|
|
.json(&serde_json::json!({ "name": "Locked", "binding": { "prefix": "/locked" } }))
|
|
.send()
|
|
.unwrap();
|
|
|
|
let anon = reqwest::blocking::Client::new();
|
|
// Open space serves reads and writes.
|
|
assert!(anon
|
|
.get(format!("{base}/open/.ping"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.is_success());
|
|
assert!(anon
|
|
.put(format!("{base}/open/.fs/note.md"))
|
|
.body("hello")
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.is_success());
|
|
assert!(anon
|
|
.get(format!("{base}/open/.fs/note.md"))
|
|
.send()
|
|
.unwrap()
|
|
.text()
|
|
.unwrap()
|
|
.contains("hello"));
|
|
|
|
// Locked space 401s anonymously.
|
|
assert_eq!(
|
|
anon.get(format!("{base}/locked/.fs"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.as_u16(),
|
|
401
|
|
);
|
|
// The session established under /.spaces is server-wide, so the same
|
|
// client reaches every private space the admin is authorized for.
|
|
assert!(admin
|
|
.get(format!("{base}/locked/.fs"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.is_success());
|
|
|
|
// The direction is symmetric: an admin who logs in through a space is
|
|
// already authenticated for the server-level admin API.
|
|
let space_admin = reqwest::blocking::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap();
|
|
assert!(space_admin
|
|
.post(format!("{base}/locked/.auth"))
|
|
.form(&[
|
|
("username", ADMIN_USER),
|
|
("password", ADMIN_PASSWORD),
|
|
("rememberMe", ""),
|
|
("from", ""),
|
|
])
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.is_success());
|
|
assert!(space_admin
|
|
.get(format!("{base}/.spaces/api/admin/spaces"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.is_success());
|
|
|
|
// Config persisted under the root.
|
|
assert!(root.path().join("spaces.json").exists());
|
|
// Index seeded in the default folder.
|
|
let spaces_dir = std::fs::read_dir(root.path().join("spaces"))
|
|
.unwrap()
|
|
.count();
|
|
assert_eq!(spaces_dir, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn login_in_one_prefix_is_shared_and_password_change_revokes_only_that_user() {
|
|
let (_srv, _root, base) = start_multi(&[]);
|
|
let admin = admin_client(&base);
|
|
|
|
let resp = admin
|
|
.post(format!("{base}/.spaces/api/admin/users"))
|
|
.json(&serde_json::json!({
|
|
"username": "alice",
|
|
"password": "alicepw1",
|
|
"admin": false
|
|
}))
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success(), "{}", resp.text().unwrap());
|
|
|
|
for prefix in ["/a", "/b"] {
|
|
let resp = admin
|
|
.post(format!("{base}/.spaces/api/admin/spaces"))
|
|
.json(&serde_json::json!({
|
|
"name": prefix,
|
|
"binding": { "prefix": prefix },
|
|
"members": { "alice": {} }
|
|
}))
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success(), "{}", resp.text().unwrap());
|
|
}
|
|
|
|
let alice = reqwest::blocking::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap();
|
|
let login = alice
|
|
.post(format!("{base}/a/.auth"))
|
|
.form(&[
|
|
("username", "alice"),
|
|
("password", "alicepw1"),
|
|
("rememberMe", ""),
|
|
("from", ""),
|
|
])
|
|
.send()
|
|
.unwrap();
|
|
assert!(login.status().is_success());
|
|
let set_cookie = login.headers()[reqwest::header::SET_COOKIE]
|
|
.to_str()
|
|
.unwrap();
|
|
assert!(set_cookie.contains("Path=/;"), "{set_cookie}");
|
|
assert!(
|
|
!set_cookie.contains("_a="),
|
|
"cookie must not contain a space prefix: {set_cookie}"
|
|
);
|
|
|
|
// A login performed through /a authenticates /b, while /b's membership
|
|
// check still decides whether this user is authorized there.
|
|
assert!(alice
|
|
.get(format!("{base}/b/.fs"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.is_success());
|
|
// 403, not 401: alice holds a valid session (she just reached /b/.fs with
|
|
// it), she simply isn't an administrator. The admin API distinguishes the
|
|
// two so a client can tell "log in again" from "this account can't do this"
|
|
// — conflating them is what previously bounced non-admins into a login loop.
|
|
assert_eq!(
|
|
alice
|
|
.get(format!("{base}/.spaces/api/admin/spaces"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.as_u16(),
|
|
403
|
|
);
|
|
|
|
let resp = admin
|
|
.post(format!("{base}/.spaces/api/admin/users/alice/password"))
|
|
.json(&serde_json::json!({ "password": "newalicepw1" }))
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success());
|
|
assert_eq!(
|
|
alice
|
|
.get(format!("{base}/b/.fs"))
|
|
.send()
|
|
.unwrap()
|
|
.status()
|
|
.as_u16(),
|
|
401,
|
|
"password changes must revoke existing sessions immediately"
|
|
);
|
|
}
|
|
|
|
/// The session-policy environment variables are documented for both modes, and
|
|
/// multi-space sessions are server-wide, so `SB_REMEMBER_ME_HOURS` must size
|
|
/// the remember-me window on both account-managed login surfaces: the unified
|
|
/// `/.spaces` JSON login and a space's own `/.auth` form post.
|
|
#[test]
|
|
fn remember_me_hours_applies_to_both_multi_space_login_surfaces() {
|
|
let (_srv, _root, base) = start_multi(&[("SB_REMEMBER_ME_HOURS", "2")]);
|
|
|
|
let admin = reqwest::blocking::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap();
|
|
let login = admin
|
|
.post(format!("{base}/.spaces/api/login"))
|
|
.json(&serde_json::json!({
|
|
"username": ADMIN_USER,
|
|
"password": ADMIN_PASSWORD,
|
|
"rememberMe": true
|
|
}))
|
|
.send()
|
|
.unwrap();
|
|
assert!(login.status().is_success());
|
|
assert!(
|
|
max_ages(&login).iter().all(|age| age == "7200"),
|
|
"/.spaces session must last SB_REMEMBER_ME_HOURS=2, got {:?}",
|
|
max_ages(&login)
|
|
);
|
|
|
|
let resp = admin
|
|
.post(format!("{base}/.spaces/api/admin/spaces"))
|
|
.json(&serde_json::json!({
|
|
"name": "Private",
|
|
"binding": { "prefix": "/private" }
|
|
}))
|
|
.send()
|
|
.unwrap();
|
|
assert!(resp.status().is_success(), "{}", resp.text().unwrap());
|
|
|
|
let via_space = reqwest::blocking::Client::builder()
|
|
.cookie_store(true)
|
|
.build()
|
|
.unwrap()
|
|
.post(format!("{base}/private/.auth"))
|
|
.form(&[
|
|
("username", ADMIN_USER),
|
|
("password", ADMIN_PASSWORD),
|
|
("rememberMe", "on"),
|
|
("from", ""),
|
|
])
|
|
.send()
|
|
.unwrap();
|
|
assert!(via_space.status().is_success());
|
|
assert!(
|
|
max_ages(&via_space).iter().all(|age| age == "7200"),
|
|
"space session must last SB_REMEMBER_ME_HOURS=2, got {:?}",
|
|
max_ages(&via_space)
|
|
);
|
|
}
|
|
|
|
/// Same contract for the lockout pair: with a limit of one, the very first bad
|
|
/// password locks the surface, so the next attempt is rejected as locked out
|
|
/// even though its credentials are correct.
|
|
#[test]
|
|
fn lockout_env_vars_apply_in_multi_space_mode() {
|
|
let (_srv, _root, base) = start_multi(&[
|
|
("SB_LOCKOUT_LIMIT", "1"),
|
|
// A long window so the wall-clock bucket can't roll between the two
|
|
// requests below (they are milliseconds apart).
|
|
("SB_LOCKOUT_TIME", "3600"),
|
|
]);
|
|
let client = reqwest::blocking::Client::new();
|
|
let attempt = |password: &str| -> String {
|
|
client
|
|
.post(format!("{base}/.spaces/api/login"))
|
|
.json(&serde_json::json!({ "username": ADMIN_USER, "password": password }))
|
|
.send()
|
|
.unwrap()
|
|
.json::<serde_json::Value>()
|
|
.unwrap()["error"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
};
|
|
assert!(attempt("wrongpw").contains("Invalid username"));
|
|
assert!(
|
|
attempt(ADMIN_PASSWORD).contains("Too many failed attempts"),
|
|
"SB_LOCKOUT_LIMIT=1 must lock the surface after a single failure"
|
|
);
|
|
}
|
|
|
|
/// Every `Max-Age` on the response's `Set-Cookie` headers. Login sets the
|
|
/// session cookie and (on remember-me, single-space style) a `refreshLogin`
|
|
/// marker; both are scoped to the same window.
|
|
fn max_ages(response: &reqwest::blocking::Response) -> Vec<String> {
|
|
let ages: Vec<String> = response
|
|
.headers()
|
|
.get_all(reqwest::header::SET_COOKIE)
|
|
.iter()
|
|
.filter_map(|value| value.to_str().ok())
|
|
.filter_map(|cookie| {
|
|
cookie
|
|
.split(';')
|
|
.map(str::trim)
|
|
.find_map(|part| part.strip_prefix("Max-Age="))
|
|
.map(str::to_string)
|
|
})
|
|
.collect();
|
|
assert!(!ages.is_empty(), "login set no Max-Age cookie");
|
|
ages
|
|
}
|