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>
352 lines
13 KiB
Rust
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())
|
|
}
|