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

352 lines
13 KiB
Rust

//! A fake sync server for the publish tests: it answers the publish routes of
//! the server and serves the anonymous pages.
//!
//! It speaks the same JSON as `notescraft-sync-server`
//! (`/v1/vaults/{vault}/publications` and `/v1/vaults/{vault}/share-links`)
//! and keeps the publications and the links it was told about, so a test can
//! read a published page and see it go away when the publication is disabled.
#![allow(dead_code)] // shared test helpers: each test file uses only some of them
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{Value, json};
/// The token of the bound vault; it must never reach an answer or an error.
pub const TOKEN: &str = "secret-token";
/// The vault the tests bind to.
pub const VAULT: &str = "v-1";
/// The slug the fake publishes under.
pub const SLUG: &str = "my-vault";
/// The lifetime the fake gives a link without one, in milliseconds.
const DEFAULT_EXPIRES_MS: u64 = 604_800_000;
/// The page the fake serves for a published note and for a shared note.
const PAGE: &str = "<!doctype html><html><body><h1>Published page</h1></body></html>";
/// One request the fake received.
#[derive(Debug, Clone)]
pub struct Request {
/// The HTTP method.
pub method: String,
/// The path of the request line.
pub path: String,
/// The `Authorization` header, when there was one.
pub auth: Option<String>,
/// The body of the request.
pub body: String,
/// True when the client asked to close the connection.
close: bool,
}
/// One publication the fake holds.
struct Publication {
/// The publication id.
id: String,
/// `note` or `folder`.
kind: String,
/// The vault path it publishes.
target: String,
}
/// One share link the fake holds.
struct Link {
/// The link id.
id: String,
/// The secret of the link.
token: String,
/// The note the link opens.
path: String,
/// True once it was revoked.
revoked: bool,
}
/// What the fake remembers between requests.
#[derive(Default)]
struct State {
/// The published targets.
publications: Vec<Publication>,
/// The minted links.
links: Vec<Link>,
/// How many ids were handed out.
next: u64,
}
/// A fake sync server: it answers the publish routes and serves the pages.
pub struct Fake {
/// The base URL of the fake.
pub base: String,
/// Every request the fake received.
seen: Arc<Mutex<Vec<Request>>>,
/// Whether the listener thread should stop.
stop: Arc<AtomicBool>,
}
impl Fake {
/// Starts a fake on an ephemeral port.
pub fn spawn() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the fake");
listener.set_nonblocking(true).expect("nonblocking");
let port = listener.local_addr().expect("addr").port();
let seen: Arc<Mutex<Vec<Request>>> = Arc::new(Mutex::new(Vec::new()));
let state: Arc<Mutex<State>> = Arc::new(Mutex::new(State::default()));
let stop = Arc::new(AtomicBool::new(false));
let thread_seen = Arc::clone(&seen);
let thread_state = Arc::clone(&state);
let thread_stop = Arc::clone(&stop);
std::thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let _ = stream.set_nonblocking(false);
let Ok(mut writer) = stream.try_clone() else { continue };
let mut reader = BufReader::new(stream);
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(30)));
let seen = Arc::clone(&thread_seen);
let state = Arc::clone(&thread_state);
std::thread::spawn(move || {
while let Some(request) = read_request(&mut reader) {
seen.lock().expect("seen").push(request.clone());
let (status, kind, body) = answer(&state, &request);
write_answer(&mut writer, status, kind, &body);
if request.close {
break;
}
}
});
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
});
Self { base: format!("http://127.0.0.1:{port}"), seen, stop }
}
/// Every request the fake received.
pub fn requests(&self) -> Vec<Request> {
self.seen.lock().expect("seen").clone()
}
}
impl Drop for Fake {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// The answer of the fake for one request: `(status, content type, body)`.
fn answer(state: &Arc<Mutex<State>>, request: &Request) -> (u16, &'static str, String) {
let method = request.method.as_str();
let path = request.path.as_str();
let publications = format!("/v1/vaults/{VAULT}/publications");
let share_links = format!("/v1/vaults/{VAULT}/share-links");
let mut state = state.lock().expect("state");
if method == "POST" && path == publications {
return create_publication(&mut state, request);
}
if method == "GET" && path == publications {
return list_publications(&state);
}
if method == "POST" && path == share_links {
return create_link(&mut state, request);
}
if method == "GET" && path == share_links {
return list_links(&state);
}
if method == "DELETE" {
if let Some(id) = path.strip_prefix(&format!("{publications}/")) {
let before = state.publications.len();
state.publications.retain(|publication| publication.id != id);
return if state.publications.len() == before { not_found() } else { no_content() };
}
if let Some(id) = path.strip_prefix(&format!("{share_links}/")) {
let Some(link) = state.links.iter_mut().find(|link| link.id == id) else { return not_found() };
link.revoked = true;
return no_content();
}
}
if method == "GET" {
if let Some(page) = path.strip_prefix("/p/") {
return published_page(&state, page);
}
if let Some(token) = path.strip_prefix("/s/") {
return shared_page(&state, token);
}
}
not_found()
}
/// A new publication of the fake.
fn create_publication(state: &mut State, request: &Request) -> (u16, &'static str, String) {
let body: Value = serde_json::from_str(&request.body).unwrap_or(Value::Null);
let kind = body.get("kind").and_then(Value::as_str).unwrap_or_default().to_owned();
let target = body.get("target").and_then(Value::as_str).unwrap_or_default().to_owned();
state.next += 1;
let id = format!("pub-{}", state.next);
state.publications.push(Publication { id: id.clone(), kind: kind.clone(), target: target.clone() });
(200, "application/json", json!({ "id": id, "slug": SLUG, "url_path": public_path(&target, &kind) }).to_string())
}
/// The listing of the publications of the fake.
fn list_publications(state: &State) -> (u16, &'static str, String) {
let rows: Vec<Value> = state
.publications
.iter()
.map(|publication| {
json!({
"id": publication.id,
"kind": publication.kind,
"target": publication.target,
"url_path": public_path(&publication.target, &publication.kind),
"created_ms": 1,
})
})
.collect();
(200, "application/json", Value::Array(rows).to_string())
}
/// A new share link of the fake.
fn create_link(state: &mut State, request: &Request) -> (u16, &'static str, String) {
let body: Value = serde_json::from_str(&request.body).unwrap_or(Value::Null);
let path = body.get("path").and_then(Value::as_str).unwrap_or_default().to_owned();
let seconds = body.get("expires_in_s").and_then(Value::as_u64);
state.next += 1;
let id = format!("sl-{}", state.next);
let token = format!("{:032x}", state.next);
let expires = seconds.map_or(DEFAULT_EXPIRES_MS, |seconds| seconds.saturating_mul(1000));
state.links.push(Link { id: id.clone(), token: token.clone(), path, revoked: false });
(200, "application/json", json!({ "id": id, "url_path": format!("/s/{token}"), "expires_ms": expires }).to_string())
}
/// The listing of the share links of the fake; never a token.
fn list_links(state: &State) -> (u16, &'static str, String) {
let rows: Vec<Value> = state
.links
.iter()
.map(|link| json!({ "id": link.id, "path": link.path, "created_ms": 1, "expires_ms": DEFAULT_EXPIRES_MS, "revoked": link.revoked }))
.collect();
(200, "application/json", Value::Array(rows).to_string())
}
/// The page of a published target: HTML while it is published, 404 otherwise.
fn published_page(state: &State, page: &str) -> (u16, &'static str, String) {
let Some(page) = page.strip_prefix(&format!("{SLUG}/")) else { return gone() };
let covered = state.publications.iter().any(|publication| {
if publication.kind == "folder" {
page == publication.target || page.starts_with(&format!("{}/", publication.target))
} else {
page == publication.target.strip_suffix(".md").unwrap_or(&publication.target)
}
});
if covered { (200, "text/html; charset=utf-8", PAGE.to_owned()) } else { gone() }
}
/// The page of a shared note: HTML while the link lives, 404 otherwise.
fn shared_page(state: &State, token: &str) -> (u16, &'static str, String) {
let live = state.links.iter().any(|link| link.token == token && !link.revoked);
if live { (200, "text/html; charset=utf-8", PAGE.to_owned()) } else { gone() }
}
/// The public path of a target, as the server spells it.
fn public_path(target: &str, kind: &str) -> String {
if kind == "folder" {
return format!("/p/{SLUG}/{target}");
}
let stem = target.strip_suffix(".md").unwrap_or(target);
format!("/p/{SLUG}/{stem}")
}
/// The answer of a route the fake does not know.
fn not_found() -> (u16, &'static str, String) {
(404, "application/json", "{\"error\":\"no such route\"}".to_owned())
}
/// The answer of a request that changed nothing.
fn no_content() -> (u16, &'static str, String) {
(204, "application/json", String::new())
}
/// The answer of an anonymous page that is not there.
fn gone() -> (u16, &'static str, String) {
(404, "text/html; charset=utf-8", String::new())
}
/// Reads one request of a connection.
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Request> {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let mut parts = line.split_whitespace();
let method = parts.next()?.to_owned();
let path = parts.next()?.to_owned();
let mut auth = None;
let mut close = false;
let mut length = 0_usize;
loop {
let mut header = String::new();
if reader.read_line(&mut header).ok()? == 0 {
break;
}
let trimmed = header.trim_end();
if trimmed.is_empty() {
break;
}
if let Some((name, value)) = trimmed.split_once(':') {
let name = name.trim().to_ascii_lowercase();
let value = value.trim().to_owned();
if name == "content-length" {
length = value.parse().unwrap_or(0);
}
if name == "authorization" {
auth = Some(value.clone());
}
if name == "connection" && value.eq_ignore_ascii_case("close") {
close = true;
}
}
}
let mut body = vec![0_u8; length];
if length > 0 {
reader.read_exact(&mut body).ok()?;
}
Some(Request { method, path, auth, body: String::from_utf8_lossy(&body).into_owned(), close })
}
/// Writes one answer of the fake.
fn write_answer(stream: &mut TcpStream, status: u16, kind: &str, body: &str) {
let reason = match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
404 => "Not Found",
_ => "Status",
};
let head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n\r\n", body.len());
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body.as_bytes());
let _ = stream.flush();
}
/// A plain HTTP GET against the fake, without a token: `(status, body)`.
pub fn get(base: &str, path: &str) -> (u16, String) {
let host = base.trim_start_matches("http://").to_owned();
let mut stream = TcpStream::connect(&host).expect("connect to the fake");
stream.set_read_timeout(Some(Duration::from_secs(10))).expect("a read timeout");
let request = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).expect("write the request");
let mut answer = String::new();
stream.read_to_string(&mut answer).expect("read the answer");
let (head, body) = answer.split_once("\r\n\r\n").unwrap_or((answer.as_str(), ""));
let status = head.split_whitespace().nth(1).and_then(|code| code.parse().ok()).unwrap_or_default();
(status, body.to_owned())
}