mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
41d2d7ef77
commit
c606169b01
3 files changed
+95
No files matched your search
@@ -17,6 +17,7 @@ run: build
|
|||||||
|
|
||||||
check:
|
check:
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo test
|
||||||
cd web && bun run check
|
cd web && bun run check
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@@ -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.
|
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
|
## Sharing
|
||||||
|
|
||||||
`:share` mints a 192-bit random token and copies `https://your-host/s/<token>`.
|
`: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` | `/api/me` | current user |
|
||||||
| `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create |
|
| `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create |
|
||||||
| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete |
|
| `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 |
|
| `POST`/`DELETE` | `/api/entries/{id}/share` | mint / revoke a share token |
|
||||||
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
|
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
|
||||||
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
|
| `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
|
and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
|
||||||
runtime dependencies, no build tools on the host.
|
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
|
### Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+74
@@ -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, "", ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user