Files
Marcos Paulo 92ca84d293
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: engine commands for publications and read-only share links
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>
2026-10-20 18:28:17 -03:00

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, &params);
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, &params);
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, &params) {
panicked.push(other);
}
}
}
std::panic::set_hook(previous);
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
}