Files
notescraft/crates/engine/tests/share_attribution.rs
Marcos Paulo d9359ba93d
ci / ci (windows-latest) (push) Has been cancelled
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
feat: live.* engine commands, last-edit lookup, waiting-to-sync queue and offline banner
- 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>
2026-10-20 13:00:13 -03:00

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");
}