mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
docs, help, checks: the README and the help say what the app does
README: the API table lists every route, twelve of which were missing; an SFTP section says how to turn the mount on and register a key; the keys table and the command list cover what was only in the code (digits, Tab, w, f, Alt-h/l, Ctrl-D/U, m/p, :today, :edit, :next and the rest, :search!, :set clipboard, :logout!). Three claims that were wrong are fixed: there is no board button above the list, R does not rename a space, and Esc/q close the document. Help gains the agenda keys and the board's gg/G and r. :t no longer means :today, which read as the agenda's key. make check runs cargo fmt --check, after the one formatting commit before this, which .git-blame-ignore-revs names (git config blame.ignoreRevsFile .git-blame-ignore-revs). Cargo.toml states rust-version 1.96, the floor the Proton crates set, and the build is checked on it. make build installs the frontend from the lockfile. A test reads the route table out of routes/mod.rs and asks every route without a cookie: all but the login, the health check and the share links must answer 401, including any route added later.
This commit is contained in:
1 parent
e0f0ebb646
commit
9749c500cf
9 files changed
+229
-40
No files matched your search
+2
-30
@@ -219,38 +219,10 @@ mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
use super::{credentials_match, is_same_origin, issue_token, verify_token};
|
||||
use crate::config::{BackupConfig, Config, MailConfig, SftpConfig};
|
||||
use crate::config::Config;
|
||||
|
||||
fn config(session_days: i64) -> Config {
|
||||
Config {
|
||||
username: "narl".into(),
|
||||
password: "hunter2".into(),
|
||||
secret: b"a-secret-of-at-least-16-bytes".to_vec(),
|
||||
bind: "127.0.0.1:0".parse().unwrap(),
|
||||
data_dir: ".".into(),
|
||||
session_days,
|
||||
max_upload_bytes: 1,
|
||||
secure_cookie: false,
|
||||
vault_idle: std::time::Duration::from_secs(60),
|
||||
backup: BackupConfig {
|
||||
device_name: "narl-diary".into(),
|
||||
interval: None,
|
||||
debounce: std::time::Duration::from_secs(0),
|
||||
prune: false,
|
||||
session_key_file: None,
|
||||
},
|
||||
mail: MailConfig {
|
||||
url: None,
|
||||
from: String::new(),
|
||||
to: String::new(),
|
||||
timezone: chrono_tz::UTC,
|
||||
reminder_at: None,
|
||||
card_due_at: None,
|
||||
digest_at: None,
|
||||
kinds: Vec::new(),
|
||||
},
|
||||
sftp: SftpConfig { bind: None },
|
||||
}
|
||||
Config::for_tests(session_days)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -311,6 +311,42 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Config {
|
||||
/// A configuration with nothing switched on, for tests.
|
||||
pub fn for_tests(session_days: i64) -> Self {
|
||||
Self {
|
||||
username: "narl".into(),
|
||||
password: "hunter2".into(),
|
||||
secret: b"a-secret-of-at-least-16-bytes".to_vec(),
|
||||
bind: "127.0.0.1:0".parse().unwrap(),
|
||||
data_dir: ".".into(),
|
||||
session_days,
|
||||
max_upload_bytes: 1,
|
||||
secure_cookie: false,
|
||||
vault_idle: std::time::Duration::from_secs(60),
|
||||
backup: BackupConfig {
|
||||
device_name: "narl-diary".into(),
|
||||
interval: None,
|
||||
debounce: std::time::Duration::from_secs(0),
|
||||
prune: false,
|
||||
session_key_file: None,
|
||||
},
|
||||
mail: MailConfig {
|
||||
url: None,
|
||||
from: String::new(),
|
||||
to: String::new(),
|
||||
timezone: chrono_tz::UTC,
|
||||
reminder_at: None,
|
||||
card_due_at: None,
|
||||
digest_at: None,
|
||||
kinds: Vec::new(),
|
||||
},
|
||||
sftp: SftpConfig { bind: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MailConfig {
|
||||
fn from_env() -> Result<Self> {
|
||||
let url = var("SMTP_URL");
|
||||
|
||||
@@ -89,3 +89,117 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
||||
.route("/ssh-keys/{id}", delete(ssh_keys::remove))
|
||||
.layer(DefaultBodyLimit::max(JSON_LIMIT))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{atomic::AtomicI64, Arc};
|
||||
|
||||
use axum::{body::Body, http::Request};
|
||||
use regex::Regex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{backup::Backup, config::Config, notify::Notifier, state::AppState, vault::Vault};
|
||||
|
||||
/// The routes that answer someone who has not logged in: to log in, to
|
||||
/// say the server is up, and the share links, which carry their own key.
|
||||
const PUBLIC: &[&str] = &[
|
||||
"/health",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/share/{token}/{key}",
|
||||
"/share/{token}/{key}/media/{id}",
|
||||
];
|
||||
|
||||
async fn state() -> AppState {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
||||
let config = Arc::new(Config::for_tests(30));
|
||||
AppState {
|
||||
vault: Vault::new(db.clone(), config.vault_idle),
|
||||
backup: Backup::unavailable(db.clone(), Arc::clone(&config), "tests".into()),
|
||||
notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(),
|
||||
login_throttle: Arc::default(),
|
||||
vault_throttle: Arc::default(),
|
||||
epoch: Arc::new(AtomicI64::new(0)),
|
||||
config,
|
||||
db,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every route this file registers, read from the file itself, so a
|
||||
/// route added later is covered without anyone remembering to.
|
||||
fn routes() -> Vec<(String, String)> {
|
||||
let path = Regex::new(r#"^\s*"([^"]+)""#).unwrap();
|
||||
let method = Regex::new(r"\b(get|post|put|delete)\(").unwrap();
|
||||
let source = include_str!("mod.rs");
|
||||
let source = &source[..source.find("#[cfg(test)]").unwrap()];
|
||||
let mut found = Vec::new();
|
||||
// `.route(...)` up to its matching bracket, however rustfmt wrapped it.
|
||||
for (at, _) in source.match_indices(".route(") {
|
||||
let rest = &source[at + ".route(".len()..];
|
||||
let mut depth = 1;
|
||||
let close = rest
|
||||
.char_indices()
|
||||
.find(|&(_, c)| {
|
||||
depth += match c {
|
||||
'(' => 1,
|
||||
')' => -1,
|
||||
_ => 0,
|
||||
};
|
||||
depth == 0
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.unwrap();
|
||||
let call = &rest[..close];
|
||||
let Some(caps) = path.captures(call) else {
|
||||
continue;
|
||||
};
|
||||
for m in method.captures_iter(call) {
|
||||
found.push((m[1].to_uppercase(), caps[1].to_string()));
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_route_but_the_public_ones_wants_a_session() {
|
||||
let routes = routes();
|
||||
assert!(
|
||||
routes.len() > 40,
|
||||
"the route table was not read: {routes:?}"
|
||||
);
|
||||
let app = super::api_router(1024 * 1024).with_state(state().await);
|
||||
|
||||
for (method, path) in routes {
|
||||
let uri = path
|
||||
.replace("{id}", "1")
|
||||
.replace("{token}", "t")
|
||||
.replace("{key}", "k");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method.as_str())
|
||||
.uri(&uri)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status().as_u16();
|
||||
if PUBLIC.contains(&path.as_str()) {
|
||||
assert_ne!(status, 401, "{method} {path} is public and answered 401");
|
||||
} else {
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"{method} {path} answered {status} without a session"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user