Files
figcraft/apps/figcraft-server/src/comment_link_tests.rs
T
BFlat e7ae0f4426 M9: share links can comment: a comment access level beside edit and view (#1525) (#1576)
Someone who joins with a comment link (or its join code) sees the file live and adds, replies to, reacts to and resolves comment threads, but can't change the design: their app refuses other edits and the server accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones; the Share popover offers Can edit, Can comment and Can view, and the commenter's toolbar shows a Can comment badge with Move, Hand and Comment.

Backward compatible: clients announce the comments word; one that doesn't is welcomed with a comment link as a viewer and gets comment links listed as view links, a level this build doesn't know reads as view, and an older server that can't make a comment link leaves the share without one.
2026-10-30 07:29:40 +07:00

146 lines
8.1 KiB
Rust

//! Comment links end to end over loopback: sharing online makes a comment link and code beside the
//! edit and view ones; someone who joins with it comments and everyone sees it, their app refuses
//! their other edits, and the server refuses them too for a client that skips the check; a client
//! that can't read comment links gets in as a viewer, and links listed to it read as view links.
use std::time::Duration;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
use figcraft_collab::{COMMENT_ONLY, Change, Client, Entry, Json};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Comment, Message};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// The welcome `raw` gets after saying hello with `client_version` through `link`.
fn welcome(raw: &mut Raw, link: &str, client_version: &str) -> Client {
let invite = Invite::parse(link).unwrap();
let ClientMsg::Hello(mut h) = Client::hello(&invite.file, &invite.token, "Raw", "") else { panic!("not a hello") };
h.client_version = client_version.into();
raw.send(&ClientMsg::Hello(h));
loop {
if let w @ ServerMsg::Welcome { .. } = raw.next() {
return Client::join(w).unwrap();
}
}
}
/// What the server says to the batch `raw` sends next.
fn answer(raw: &mut Raw) -> ServerMsg {
loop {
if let m @ (ServerMsg::Reject { .. } | ServerMsg::Changes(_)) = raw.next() {
return m;
}
}
}
#[test]
fn a_comment_link_comments_and_the_server_refuses_its_edits() {
let dir = TempDir::new("comment-link");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
ex(&mut a, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 80}));
let p = publish(&mut a, dir.path(), addr);
let file = Invite::parse(&p.edit).unwrap().file;
let endpoint = Endpoint::plain(addr.to_string());
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
let AdminReply::Link(made) = ask(Admin::CreateLink { file: file.clone(), access: Access::Comment, label: "Client".into() }) else { panic!() };
assert_eq!(made.link.access, Access::Comment);
let link = Invite { server: endpoint.clone(), file: file.clone(), token: made.token.clone() }.link();
// Cy joins with it and comments; Ada sees the thread.
let mut c = join(&link, "Cy");
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("comment"), &json!(true)));
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Bigger logo?"}))["id"].as_u64().unwrap();
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
ex(&mut a, "comment.reply", json!({"id": thread, "text": "On it"}));
pump(&mut [&mut a, &mut c], "the reply at Cy", |s| s[1].doc().unwrap().comment(thread).is_some_and(|t| t.replies.len() == 1));
ex(&mut c, "comment.resolve", json!({"id": thread}));
pump(&mut [&mut a, &mut c], "resolved at Ada", |s| s[0].doc().unwrap().comment(thread).is_some_and(|t| t.resolved));
// Cy's app refuses anything else before it is sent.
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
assert!(e.contains("Can comment"), "{e}");
// A client that skips the app's check: its comment goes through, its edit is refused.
let mut raw = Raw::connect(addr);
let mut client = welcome(&mut raw, &link, &figcraft_collab::message::client_version());
assert_eq!(client.access(), Access::Comment);
let mut d = client.document().clone();
let page = d.first_page().unwrap();
let id = d.new_comment_id();
d.comments.push(Comment {
id,
page,
x: 1.0,
y: 1.0,
anchor: None,
first: Message::new("Raw", "Hi", 0),
resolved: false,
replies: vec![],
read_by: None,
});
raw.send(&client.edit(&d).unwrap().unwrap());
let ServerMsg::Changes(acc) = answer(&mut raw) else { panic!("refused") };
client.receive(ServerMsg::Changes(acc), &figcraft_collab::NoRecompute).unwrap();
let mut d = client.document().clone();
d.node_mut(page).unwrap().name = "Sneaky".into();
raw.send(&client.edit(&d).unwrap().unwrap());
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
// So is a forged batch that hides an edit among comment changes.
let forged = Change::Put { entry: Entry::Comment(id), value: Json::of(&client.document().comments[0]).unwrap() };
let rename = Change::SetDoc { prop: figcraft_collab::DocProp::Name, value: Json::of("Sneaky").unwrap() };
raw.send(&ClientMsg::Changes { base_seq: 0, batch_id: 99, changes: vec![forged, rename] });
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
pump(&mut [&mut a], "the raw comment, nothing else", |s| {
let d = s[0].doc().unwrap();
d.comment(id).is_some() && d.node(page).is_some_and(|n| n.name != "Sneaky") && d.name != "Sneaky"
});
// A client from before comment links gets in as a viewer.
let mut old = Raw::connect(addr);
assert_eq!(welcome(&mut old, &link, "0.12.1 signals hosting").access(), Access::View);
// And lists the link as a view link.
let mut m = Raw::connect(addr);
m.send(&ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key(dir.path()), client_version: "0.12.1".into() }));
assert!(matches!(m.next(), ServerMsg::Admin(r) if *r == AdminReply::Ready));
m.send(&ClientMsg::Admin(Admin::Links { file: file.clone() }));
let ServerMsg::Admin(r) = m.next() else { panic!("no listing") };
let AdminReply::Links(links) = *r else { panic!("{r:?}") };
assert!(links.iter().all(|l| l.access != Access::Comment) && links.iter().any(|l| l.id == made.link.id && l.access == Access::View));
let AdminReply::Links(links) = ask(Admin::Links { file }) else { panic!() };
assert!(links.iter().any(|l| l.id == made.link.id && l.access == Access::Comment), "this build reads it");
server.stop();
}
#[test]
fn sharing_online_offers_a_comment_code() {
let dir = TempDir::new("comment-code");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/?key={}", server.addr(), key(dir.path()));
let mut a = session("Ada");
ex(&mut a, "collab.share", json!({"server": url}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live_summary()["share"]["shared"] == true);
let shared = a.live_summary()["share"].clone();
let comment = shared["commentCode"].as_str().unwrap().to_string();
assert!(comment != shared["editCode"] && comment != shared["viewCode"], "{shared}");
assert_eq!(ex(&mut a, "collab.share", json!({"access": "comment"}))["code"], comment.as_str());
// Someone types it: they can comment, not edit.
let mut c = session("Cy");
ex(&mut c, "pref.publishServer", json!({"url": format!("ws://{}/", server.addr())}));
ex(&mut c, "collab.join", json!({"code": comment}));
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(c.live_summary()["access"], "comment");
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Looks good"}))["id"].as_u64().unwrap();
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
assert!(c.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
// Stopping revokes it with the others: Cy is out.
ex(&mut a, "collab.stop", json!({}));
pump(&mut [&mut a, &mut c], "Cy out", |s| !s[1].live().status.active() && s[0].live().peers.is_empty());
server.stop();
}