92ca84d293
publish.list|enable|disable and share.link.create|list|revoke over the bound server, token only in the minted URL, read-only and unbound sessions refused. Built by MiniMax M3.1 Flash (OpenCode Zen free route); verified from a clean checkout of the staged files: notescraft-engine tests 729 pass, clippy -D warnings clean. Integration tests use a stub server speaking the server's JSON. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
11 KiB
Rust
238 lines
11 KiB
Rust
//! The `publish.*` and `share.link.*` commands against a fake server: the
|
|
//! requests they make, the pages they publish, the links they mint and the
|
|
//! secrets they keep.
|
|
//!
|
|
//! The fake server lives in [`publish_stub`]: it speaks the JSON of the sync
|
|
//! server and serves the anonymous pages (`/p/<slug>/<path>` and
|
|
//! `/s/<token>`), so the test reads a published page and sees it go away when
|
|
//! the publication is disabled.
|
|
|
|
mod common;
|
|
mod publish_stub;
|
|
|
|
use common::temp_vault;
|
|
use notescraft_engine::{Error, Registry, Session, adversarial_params, bind_folder, register_core};
|
|
use publish_stub::{Fake, Request, SLUG, TOKEN, VAULT, get};
|
|
use serde_json::{Value, json};
|
|
|
|
/// A vault bound to the fake, with the registry and the session.
|
|
fn bound(name: &str, fake: &Fake) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
bind_folder(vault.path(), &fake.base, VAULT, TOKEN, "d-1", Some("u-1")).expect("bind the vault");
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
(reg, session, vault)
|
|
}
|
|
|
|
/// The code of a command error.
|
|
fn code(result: &Result<Value, Error>) -> String {
|
|
match result {
|
|
Err(Error::Command { source, .. }) => source.code.clone(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The message of a command error.
|
|
fn message(result: &Result<Value, Error>) -> String {
|
|
match result {
|
|
Err(Error::Command { source, .. }) => source.message.clone(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A published note is a page anyone can read, until it is unpublished.
|
|
#[test]
|
|
fn a_published_note_is_a_page_until_it_is_unpublished() {
|
|
let fake = Fake::spawn();
|
|
let (reg, mut session, _vault) = bound("publish", &fake);
|
|
|
|
let answer = reg.run("publish.enable", &mut session, &json!({ "path": "notes/a.md", "kind": "note" })).expect("publish.enable");
|
|
assert_eq!(answer.get("id"), Some(&json!("pub-1")));
|
|
assert_eq!(answer.get("kind"), Some(&json!("note")));
|
|
assert_eq!(answer.get("url"), Some(&json!(format!("{}/p/{SLUG}/notes/a", fake.base))));
|
|
|
|
// The page is served to anyone, without a token.
|
|
let (status, body) = get(&fake.base, &format!("/p/{SLUG}/notes/a"));
|
|
assert_eq!(status, 200);
|
|
assert!(body.contains("Published page"), "{body}");
|
|
|
|
// The request the server got.
|
|
let requests = fake.requests();
|
|
let api: Vec<&Request> = requests.iter().filter(|request| request.path.starts_with("/v1/")).collect();
|
|
assert_eq!(api.len(), 1);
|
|
let request = api.first().expect("a request");
|
|
assert_eq!(request.method, "POST");
|
|
assert_eq!(request.path, format!("/v1/vaults/{VAULT}/publications"));
|
|
assert_eq!(request.auth.as_deref(), Some(format!("Bearer {TOKEN}").as_str()));
|
|
let body: Value = serde_json::from_str(&request.body).expect("a JSON body");
|
|
assert_eq!(body, json!({ "target": "notes/a.md", "kind": "note" }));
|
|
|
|
// Unpublishing takes the page away.
|
|
let disabled = reg.run("publish.disable", &mut session, &json!({ "id": "pub-1" })).expect("publish.disable");
|
|
assert_eq!(disabled.get("disabled"), Some(&json!(true)));
|
|
assert_eq!(get(&fake.base, &format!("/p/{SLUG}/notes/a")).0, 404);
|
|
}
|
|
|
|
/// A published folder serves the notes under it and nothing else.
|
|
#[test]
|
|
fn a_published_folder_serves_the_notes_under_it() {
|
|
let fake = Fake::spawn();
|
|
let (reg, mut session, _vault) = bound("folder", &fake);
|
|
|
|
let answer = reg.run("publish.enable", &mut session, &json!({ "path": "docs", "kind": "folder" })).expect("publish.enable");
|
|
assert_eq!(answer.get("url"), Some(&json!(format!("{}/p/{SLUG}/docs", fake.base))));
|
|
|
|
assert_eq!(get(&fake.base, &format!("/p/{SLUG}/docs/a")).0, 200);
|
|
assert_eq!(get(&fake.base, &format!("/p/{SLUG}/other/a")).0, 404);
|
|
}
|
|
|
|
/// The listing names every publication of the vault.
|
|
#[test]
|
|
fn the_listing_names_every_publication() {
|
|
let fake = Fake::spawn();
|
|
let (reg, mut session, _vault) = bound("list", &fake);
|
|
reg.run("publish.enable", &mut session, &json!({ "path": "notes/a.md", "kind": "note" })).expect("publish.enable");
|
|
reg.run("publish.enable", &mut session, &json!({ "path": "docs", "kind": "folder" })).expect("publish.enable");
|
|
|
|
let answer = reg.run("publish.list", &mut session, &Value::Null).expect("publish.list");
|
|
let publications = answer.get("publications").and_then(Value::as_array).expect("the publications");
|
|
assert_eq!(publications.len(), 2);
|
|
assert_eq!(publications.first().and_then(|row| row.get("id")), Some(&json!("pub-1")));
|
|
assert_eq!(publications.first().and_then(|row| row.get("target")), Some(&json!("notes/a.md")));
|
|
assert_eq!(publications.get(1).and_then(|row| row.get("kind")), Some(&json!("folder")));
|
|
assert_eq!(answer.get("vault_id"), Some(&json!(VAULT)));
|
|
}
|
|
|
|
/// A share link opens one note, until it is revoked.
|
|
#[test]
|
|
fn a_share_link_opens_a_note_until_it_is_revoked() {
|
|
let fake = Fake::spawn();
|
|
let (reg, mut session, _vault) = bound("share", &fake);
|
|
|
|
let answer = reg.run("share.link.create", &mut session, &json!({ "path": "notes/a.md", "expires_in_hours": 2 })).expect("share.link.create");
|
|
assert_eq!(answer.get("id"), Some(&json!("sl-1")));
|
|
let url = answer.get("url").and_then(Value::as_str).expect("the link url").to_owned();
|
|
let token = url.rsplit('/').next().expect("the token").to_owned();
|
|
assert_eq!(token.len(), 32, "the token is the last part of the url");
|
|
assert!(answer.get("expires_ms").and_then(Value::as_u64).is_some_and(|ms| ms > 0));
|
|
|
|
// The link opens the note, without a token.
|
|
let (status, body) = get(&fake.base, &format!("/s/{token}"));
|
|
assert_eq!(status, 200);
|
|
assert!(body.contains("Published page"), "{body}");
|
|
|
|
// The lifetime the command asked for reached the server.
|
|
let requests = fake.requests();
|
|
let body: Value = serde_json::from_str(&requests.first().expect("a request").body).expect("a JSON body");
|
|
assert_eq!(body, json!({ "path": "notes/a.md", "expires_in_s": 7200 }));
|
|
|
|
// Revoking it closes the page.
|
|
let revoked = reg.run("share.link.revoke", &mut session, &json!({ "id": "sl-1" })).expect("share.link.revoke");
|
|
assert_eq!(revoked.get("revoked"), Some(&json!(true)));
|
|
assert_eq!(get(&fake.base, &format!("/s/{token}")).0, 404);
|
|
}
|
|
|
|
/// The token of a link lives in its url and nowhere else.
|
|
#[test]
|
|
fn the_token_of_a_link_stays_in_its_url() {
|
|
let fake = Fake::spawn();
|
|
let (reg, mut session, _vault) = bound("token", &fake);
|
|
let answer = reg.run("share.link.create", &mut session, &json!({ "path": "notes/a.md" })).expect("share.link.create");
|
|
let url = answer.get("url").and_then(Value::as_str).expect("the link url").to_owned();
|
|
let token = url.rsplit('/').next().expect("the token").to_owned();
|
|
|
|
let links = reg.run("share.link.list", &mut session, &Value::Null).expect("share.link.list");
|
|
assert!(!links.to_string().contains(&token), "the listing never carries a token");
|
|
assert!(links.to_string().contains("notes/a.md"), "{links}");
|
|
|
|
let publications = reg.run("publish.list", &mut session, &Value::Null).expect("publish.list");
|
|
assert!(!publications.to_string().contains(&token), "the listing never carries a token");
|
|
|
|
// An error never carries it either.
|
|
let failed = reg.run("share.link.revoke", &mut session, &json!({ "id": "sl-9" }));
|
|
assert_eq!(code(&failed), "share");
|
|
assert!(!message(&failed).contains(&token), "{}", message(&failed));
|
|
}
|
|
|
|
/// A read-only session refuses every write.
|
|
#[test]
|
|
fn a_read_only_session_refuses_the_writes() {
|
|
let fake = Fake::spawn();
|
|
let vault = temp_vault("readonly");
|
|
bind_folder(vault.path(), &fake.base, VAULT, TOKEN, "d-1", Some("u-1")).expect("bind the vault");
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let mut session = Session::open_read_only(vault.path()).expect("open read-only");
|
|
|
|
for (id, params) in [
|
|
("publish.enable", json!({ "path": "notes/a.md", "kind": "note" })),
|
|
("publish.disable", json!({ "id": "pub-1" })),
|
|
("share.link.create", json!({ "path": "notes/a.md" })),
|
|
("share.link.revoke", json!({ "id": "sl-1" })),
|
|
] {
|
|
let result = reg.run(id, &mut session, ¶ms);
|
|
assert_eq!(code(&result), "read_only", "{id}");
|
|
}
|
|
assert!(fake.requests().is_empty(), "no request was made");
|
|
}
|
|
|
|
/// A vault that is not bound says so instead of asking the server.
|
|
#[test]
|
|
fn an_unbound_vault_says_not_bound() {
|
|
let fake = Fake::spawn();
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let vault = temp_vault("unbound");
|
|
let mut session = Session::open(vault.path()).expect("open the vault");
|
|
|
|
for (id, params) in [("publish.list", Value::Null), ("publish.enable", json!({ "path": "a.md", "kind": "note" }))] {
|
|
let result = reg.run(id, &mut session, ¶ms);
|
|
assert_eq!(code(&result), "not_bound", "{id}");
|
|
}
|
|
assert!(fake.requests().is_empty(), "no request was made");
|
|
}
|
|
|
|
/// Hostile params are refused before the server sees them.
|
|
#[test]
|
|
fn hostile_params_never_reach_the_server() {
|
|
let fake = Fake::spawn();
|
|
let (reg, mut session, _vault) = bound("hostile", &fake);
|
|
let long = "a".repeat(2000);
|
|
|
|
for path in ["../escape.md", "notes/../a.md", ".obsidian/x.md", ".notescraft/x.md", "notes//a.md", "with\u{0}nul.md", long.as_str()] {
|
|
let result = reg.run("publish.enable", &mut session, &json!({ "path": path, "kind": "note" }));
|
|
assert_eq!(code(&result), "invalid_value", "{path}");
|
|
let result = reg.run("share.link.create", &mut session, &json!({ "path": path }));
|
|
assert_eq!(code(&result), "invalid_value", "{path}");
|
|
}
|
|
for kind in ["page", "Note", ""] {
|
|
let result = reg.run("publish.enable", &mut session, &json!({ "path": "a.md", "kind": kind }));
|
|
assert_eq!(code(&result), "invalid_value", "{kind}");
|
|
}
|
|
for hours in [0, -1, 721, 100_000] {
|
|
let result = reg.run("share.link.create", &mut session, &json!({ "path": "a.md", "expires_in_hours": hours }));
|
|
assert_eq!(code(&result), "invalid_value", "{hours}");
|
|
}
|
|
assert!(fake.requests().is_empty(), "no request was made");
|
|
}
|
|
|
|
/// No param of any shape panics a publish command.
|
|
#[test]
|
|
fn adversarial_params_never_panic_a_publish_command() {
|
|
let fake = Fake::spawn();
|
|
let (reg, mut session, _vault) = bound("adversarial", &fake);
|
|
let previous = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(|_| {}));
|
|
let mut panicked = Vec::new();
|
|
for id in ["publish.list", "publish.enable", "publish.disable", "share.link.create", "share.link.list", "share.link.revoke"] {
|
|
for params in adversarial_params() {
|
|
if let Err(Error::Panicked(other)) = reg.run(id, &mut session, ¶ms) {
|
|
panicked.push(other);
|
|
}
|
|
}
|
|
}
|
|
std::panic::set_hook(previous);
|
|
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
|
|
}
|