d9359ba93d
- engine: live.join, live.edit, live.poll, live.peers, live.selection, live.info and live.leave over the live session client (at most 8 rooms, no token in any answer, dropping a session with open rooms returns within 2 s); server.file_meta names the last editor of a file - engine: sync.pending lists the local changes the server does not have yet (new, edited, deleted; at most 500, local comparison only) - ui-egui: a "Waiting to sync" queue sheet opened from the sync sheet, and the "You are offline" banner on the compact Notes page - tests: two sessions converge through the real server using live.* commands; stub-server, queue and banner tests Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
208 lines
7.9 KiB
Rust
208 lines
7.9 KiB
Rust
//! The `server.file_meta` command and `ServerClient::file_meta` against a stub
|
|
//! server: the request they make, the fields they read and the answer they
|
|
//! give for a file the server does not know.
|
|
|
|
mod common;
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use common::temp_vault;
|
|
use notescraft_engine::{Error, Registry, ServerClient, Session, bind_folder, register_core};
|
|
use serde_json::json;
|
|
|
|
/// The token of the bound vault; it must never reach an answer or an error.
|
|
const TOKEN: &str = "secret-token";
|
|
|
|
/// One request the stub received.
|
|
#[derive(Clone, Debug)]
|
|
struct Request {
|
|
/// The HTTP method.
|
|
method: String,
|
|
/// The path of the request line.
|
|
path: String,
|
|
/// The `Authorization` header, when there was one.
|
|
auth: Option<String>,
|
|
}
|
|
|
|
/// A stub server: it answers one canned reply and remembers what it was asked.
|
|
struct Stub {
|
|
/// The base URL of the server.
|
|
base: String,
|
|
/// Every request the stub received.
|
|
seen: Arc<Mutex<Vec<Request>>>,
|
|
/// The reply the stub gives.
|
|
reply: Arc<Mutex<(u16, String)>>,
|
|
}
|
|
|
|
impl Stub {
|
|
/// Starts a stub on an ephemeral port.
|
|
fn start() -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
|
|
let port = listener.local_addr().expect("addr").port();
|
|
let seen: Arc<Mutex<Vec<Request>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let reply: Arc<Mutex<(u16, String)>> = Arc::new(Mutex::new((200, "{}".to_owned())));
|
|
let thread_seen = Arc::clone(&seen);
|
|
let thread_reply = Arc::clone(&reply);
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(stream) = stream else { continue };
|
|
// An accepted socket inherits the mode of the listener on some
|
|
// platforms: a read that beats the request would end it.
|
|
let _ = stream.set_nonblocking(false);
|
|
answer(&thread_seen, &thread_reply, stream);
|
|
}
|
|
});
|
|
Self { base: format!("http://127.0.0.1:{port}"), seen, reply }
|
|
}
|
|
|
|
/// Every request the stub received.
|
|
fn requests(&self) -> Vec<Request> {
|
|
self.seen.lock().expect("seen").clone()
|
|
}
|
|
|
|
/// The reply the stub gives from now on.
|
|
fn set_reply(&self, status: u16, body: &str) {
|
|
*self.reply.lock().expect("reply") = (status, body.to_owned());
|
|
}
|
|
}
|
|
|
|
/// Answers one request and remembers it.
|
|
fn answer(seen: &Arc<Mutex<Vec<Request>>>, reply: &Arc<Mutex<(u16, String)>>, mut stream: TcpStream) {
|
|
let Ok(clone) = stream.try_clone() else { return };
|
|
let mut reader = BufReader::new(clone);
|
|
let mut line = String::new();
|
|
if reader.read_line(&mut line).is_err() {
|
|
return;
|
|
}
|
|
let mut parts = line.split_whitespace();
|
|
let method = parts.next().unwrap_or_default().to_owned();
|
|
let path = parts.next().unwrap_or_default().to_owned();
|
|
let mut auth = None;
|
|
loop {
|
|
let mut header = String::new();
|
|
match reader.read_line(&mut header) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(_) => {}
|
|
}
|
|
let header = header.trim_end();
|
|
if header.is_empty() {
|
|
break;
|
|
}
|
|
if let Some((name, value)) = header.split_once(':')
|
|
&& name.eq_ignore_ascii_case("authorization")
|
|
{
|
|
auth = Some(value.trim().to_owned());
|
|
}
|
|
}
|
|
seen.lock().expect("seen").push(Request { method, path, auth });
|
|
let (status, body) = reply.lock().expect("reply").clone();
|
|
let reason = match status {
|
|
200 => "OK",
|
|
404 => "Not Found",
|
|
_ => "Error",
|
|
};
|
|
let head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
|
|
let _ = stream.write_all(head.as_bytes());
|
|
let _ = stream.write_all(body.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
|
|
/// The code of a command error.
|
|
fn code(error: &Error) -> String {
|
|
match error {
|
|
Error::Command { source, .. } => source.code.clone(),
|
|
Error::InvalidParams { .. } => "invalid_params".to_owned(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A vault bound to the stub, with the registry and the session.
|
|
fn bound(name: &str, stub: &Stub) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
bind_folder(vault.path(), &stub.base, "vault-1", TOKEN, "device-1", None).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)
|
|
}
|
|
|
|
#[test]
|
|
fn the_client_reads_the_fields_of_the_file() {
|
|
let stub = Stub::start();
|
|
stub.set_reply(200, r#"{"editor_name":"Ana","editor_user":"u-1","seq":7,"mtime_ms":1234}"#);
|
|
let client = ServerClient::new(&stub.base, TOKEN);
|
|
|
|
let meta = client.file_meta("vault-1", "notes/note.md").expect("the answer").expect("a file");
|
|
assert_eq!(meta.editor_name.as_deref(), Some("Ana"));
|
|
assert_eq!(meta.editor_user.as_deref(), Some("u-1"));
|
|
assert_eq!(meta.seq, 7);
|
|
assert_eq!(meta.mtime_ms, 1234);
|
|
|
|
let seen = stub.requests();
|
|
assert_eq!(seen.len(), 1, "one request was made");
|
|
assert_eq!(seen[0].method, "GET");
|
|
assert_eq!(seen[0].path, "/v1/vaults/vault-1/files-meta/notes/note.md");
|
|
assert_eq!(seen[0].auth.as_deref(), Some("Bearer secret-token"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_the_server_does_not_know_is_unknown() {
|
|
let stub = Stub::start();
|
|
stub.set_reply(404, r#"{"error":"no such file"}"#);
|
|
let client = ServerClient::new(&stub.base, TOKEN);
|
|
|
|
let meta = client.file_meta("vault-1", "notes/note.md").expect("the answer is not an error");
|
|
assert!(meta.is_none(), "a file the server does not know is no answer");
|
|
}
|
|
|
|
#[test]
|
|
fn the_command_names_the_last_editor() {
|
|
let stub = Stub::start();
|
|
stub.set_reply(200, r#"{"editor_name":"Ana","editor_user":"u-1","seq":7,"mtime_ms":1234}"#);
|
|
let (reg, mut session, _vault) = bound("attribution", &stub);
|
|
|
|
let answer = reg.run("server.file_meta", &mut session, &json!({ "path": "notes/note.md" })).expect("the attribution");
|
|
assert_eq!(answer["editor_name"], json!("Ana"));
|
|
assert_eq!(answer["editor_user"], json!("u-1"));
|
|
assert_eq!(answer["seq"], json!(7));
|
|
assert_eq!(answer["mtime_ms"], json!(1234));
|
|
assert!(!answer.to_string().contains(TOKEN), "the token never reaches an answer");
|
|
}
|
|
|
|
#[test]
|
|
fn the_command_says_unknown_for_a_file_the_server_does_not_know() {
|
|
let stub = Stub::start();
|
|
stub.set_reply(404, r#"{"error":"no such file"}"#);
|
|
let (reg, mut session, _vault) = bound("attribution-unknown", &stub);
|
|
|
|
let answer = reg.run("server.file_meta", &mut session, &json!({ "path": "notes/note.md" })).expect("the attribution");
|
|
assert_eq!(answer["unknown"], json!(true), "the answer says the file is unknown");
|
|
assert!(answer.get("editor_name").is_none(), "{answer:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unbound_vault_cannot_ask() {
|
|
let stub = Stub::start();
|
|
let vault = temp_vault("attribution-unbound");
|
|
let mut session = Session::open(vault.path()).expect("open the vault");
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
|
|
let error = reg.run("server.file_meta", &mut session, &json!({ "path": "notes/note.md" })).expect_err("no binding");
|
|
assert_eq!(code(&error), "not_bound");
|
|
assert!(stub.requests().is_empty(), "no request was made");
|
|
}
|
|
|
|
#[test]
|
|
fn a_hostile_path_is_refused() {
|
|
let stub = Stub::start();
|
|
let (reg, mut session, _vault) = bound("attribution-hostile", &stub);
|
|
for path in ["", "/note.md", "../note.md", "notes\\note.md", "no\u{0}te.md"] {
|
|
let error = reg.run("server.file_meta", &mut session, &json!({ "path": path })).expect_err("hostile path");
|
|
assert_eq!(code(&error), "invalid_value", "{path}");
|
|
}
|
|
assert!(stub.requests().is_empty(), "no request was made");
|
|
}
|