test: cover session tokens, and run the suite in make check

auth.rs was the largest untested surface and the one where a mistake is
worth the most: the tests pin the round trip, expiry, secret rotation,
forged expiries, flipped signature bytes, malformed input, and that
credentials have to match in full rather than by prefix.

Also documents autosave, :export! and the security headers in the README.
This commit is contained in:
nvrl committed 2026-09-05 09:48:24 +02:00
1 parent 41d2d7ef77
commit c606169b01
3 files changed
+95

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ run: build
check:
cargo clippy --all-targets -- -D warnings
cargo test
cd web && bun run check
clean:
+20
View File
@@ -63,6 +63,18 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
On a touch device modal editing is turned off and the same actions are buttons.
## Not losing things
Every change is written to `localStorage` as it is typed and to the server a
couple of seconds after typing stops, so `:w` is a habit rather than a
necessity. If a tab dies mid-entry, opening that entry again restores the draft
and says so — `:e!` throws it away, the way vim handles a swap file.
`:export` downloads the open entry as markdown. `:export!` downloads the whole
diary as a zip: one markdown file per entry named by its day, every embedded
file under its real name, and relative links between the two, so the archive
reads in any markdown viewer without this application.
## Sharing
`:share` mints a 192-bit random token and copies `https://your-host/s/<token>`.
@@ -81,6 +93,7 @@ Everything except the two share routes requires the session cookie.
| `GET` | `/api/me` | current user |
| `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create |
| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete |
| `GET` | `/api/export` | every entry and file, as a zip |
| `POST`/`DELETE` | `/api/entries/{id}/share` | mint / revoke a share token |
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
@@ -93,6 +106,13 @@ Put a TLS-terminating reverse proxy in front of it, point it at `DIARY_BIND`,
and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
runtime dependencies, no build tools on the host.
The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`,
`X-Content-Type-Options` and `X-Frame-Options`; a proxy that adds its own should
not weaken them. `no-referrer` matters in particular, because a share token
lives in the URL and would otherwise leak to any site a shared entry links to.
Uploaded files are only ever served as types that cannot execute — anything else
is handed back as an opaque download.
### Docker
```sh
+74
View File
@@ -94,3 +94,77 @@ impl FromRequestParts<AppState> for Session {
}
}
}
#[cfg(test)]
mod tests {
use super::{credentials_match, issue_token, verify_token};
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,
}
}
#[test]
fn a_fresh_token_verifies() {
let config = config(30);
assert!(verify_token(&config, &issue_token(&config)));
}
#[test]
fn an_expired_token_does_not() {
let config = config(-1);
assert!(!verify_token(&config, &issue_token(&config)));
}
#[test]
fn rotating_the_secret_invalidates_every_session() {
let token = issue_token(&config(30));
let mut rotated = config(30);
rotated.secret = b"a-different-secret-entirely".to_vec();
assert!(!verify_token(&rotated, &token));
}
#[test]
fn a_tampered_token_does_not_verify() {
let config = config(30);
let token = issue_token(&config);
let (payload, signature) = token.split_once('.').unwrap();
// A far-future expiry the holder signed themselves.
let forged = base64::Engine::encode(&super::B64, "99999999999");
assert!(!verify_token(&config, &format!("{forged}.{signature}")));
// The real expiry with the last byte of the signature flipped.
let mut bytes = signature.as_bytes().to_vec();
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
let flipped = String::from_utf8(bytes).unwrap();
assert!(!verify_token(&config, &format!("{payload}.{flipped}")));
}
#[test]
fn malformed_tokens_are_rejected_rather_than_panicking() {
let config = config(30);
for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] {
assert!(!verify_token(&config, token), "{token:?}");
}
}
#[test]
fn credentials_must_match_exactly() {
let config = config(30);
assert!(credentials_match(&config, "narl", "hunter2"));
assert!(!credentials_match(&config, "narl", "hunter"));
assert!(!credentials_match(&config, "narl", "hunter22"));
assert!(!credentials_match(&config, "nar", "hunter2"));
assert!(!credentials_match(&config, "", ""));
}
}