148 lines
7.7 KiB
Rust
148 lines
7.7 KiB
Rust
//! Team libraries end to end over loopback, with the server in-process on a temporary data
|
|
//! directory: a file publishes its library to the server, another file enables it with a view
|
|
//! link, places a component and overrides it; the library is republished with a change, the other
|
|
//! file's app notices in the background and accepts the update (the override stays); usage counts;
|
|
//! view links can't publish; junk library requests are refused and the server keeps serving.
|
|
|
|
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, OpenLibrary, PROTOCOL, ServerMsg, Usage};
|
|
use figcraft_collab::sim::Rng;
|
|
use figcraft_collab_net::Invite;
|
|
use figcraft_collab_net::hub::Backend;
|
|
use figcraft_doc::NodeId;
|
|
use figcraft_engine::Session;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::store::Store;
|
|
use crate::test_util::{Raw, TempDir, config, publish, pump, session};
|
|
|
|
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
|
match s.execute(cmd, &p) {
|
|
Ok(v) => v,
|
|
Err(e) => panic!("{cmd} {p}: {e}"),
|
|
}
|
|
}
|
|
|
|
fn id(v: &Value) -> NodeId {
|
|
NodeId(v["id"].as_u64().unwrap())
|
|
}
|
|
|
|
fn fill(s: &Session, id: NodeId) -> String {
|
|
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
|
}
|
|
|
|
fn open(link: &Invite) -> ClientMsg {
|
|
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: link.file.clone(), token: link.token.clone(), client_version: String::new() })
|
|
}
|
|
|
|
fn answer(raw: &mut Raw) -> LibraryReply {
|
|
match raw.next() {
|
|
ServerMsg::Library(r) => *r,
|
|
other => panic!("not a library answer: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn publish_enable_update_and_usage() {
|
|
let dir = TempDir::new("library");
|
|
let cfg = config(dir.path());
|
|
let mut server = crate::start(&cfg).unwrap();
|
|
let addr = server.addr();
|
|
// Ada's library file, kept on the server: a "Button" with a "Label".
|
|
let mut a = session("Ada");
|
|
let f = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
|
|
ex(&mut a, "text.create", json!({"text": "OK", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
|
|
ex(&mut a, "node.set", json!({"id": f, "props": {"fill": "#0000ff"}}));
|
|
let button = id(&ex(&mut a, "component.create", json!({"ids": [f]})));
|
|
let p = publish(&mut a, dir.path(), addr);
|
|
let mut b = session("Bo");
|
|
let e = b.execute("library.add", &json!({"path": p.view})).unwrap_err().to_string();
|
|
assert!(e.contains("no library on the server"), "{e}");
|
|
let r = ex(&mut a, "library.publishToServer", json!({}));
|
|
assert_eq!(r["version"], 1);
|
|
|
|
// Bo enables it with the view link and places the Button, overriding its label.
|
|
let r = ex(&mut b, "library.add", json!({"path": p.view}));
|
|
let lid = r["id"].as_str().unwrap().to_string();
|
|
assert_eq!(r["components"].as_array().map(Vec::len), Some(1));
|
|
let inst = id(&ex(&mut b, "library.place", json!({"library": lid, "component": "Button", "x": 0, "y": 0})));
|
|
let label = b.doc().unwrap().node(inst).unwrap().children[0];
|
|
ex(&mut b, "text.set", json!({"id": label, "characters": "Buy"}));
|
|
assert_eq!(fill(&b, inst), "0000FF");
|
|
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["server"], true);
|
|
|
|
// Ada changes the Button and publishes again; nothing more to publish after that.
|
|
ex(&mut a, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}}));
|
|
assert_eq!(ex(&mut a, "library.publishToServer", json!({}))["version"], 2);
|
|
let e = a.execute("library.publishToServer", &json!({})).unwrap_err().to_string();
|
|
assert!(e.contains("no changes"), "{e}");
|
|
|
|
// Bo's app notices in the background ("Library updates available") and takes the update.
|
|
b.refresh_server_libraries();
|
|
pump(&mut [&mut b], "the new version", |s| s[0].libraries.cached(&p.view).is_some_and(|l| l.is_ok_and(|l| l.manifest.version == 2)));
|
|
assert_eq!(ex(&mut b, "library.updates", json!({}))["count"], 1);
|
|
assert_eq!(ex(&mut b, "library.acceptUpdates", json!({}))["components"], 1);
|
|
assert_eq!(fill(&b, inst), "00FF00", "the change arrived");
|
|
let label = b.doc().unwrap().node(inst).unwrap().children[0];
|
|
assert_eq!(b.doc().unwrap().node(label).unwrap().kind.text().map(|t| t.characters.as_str()), Some("Buy"), "the override stayed");
|
|
|
|
// Usage: what Bo's app reported, seen by Ada (her file's library) and by Bo (by the library).
|
|
for (s, args) in [(&mut a, json!({})), (&mut b, json!({"library": lid}))] {
|
|
let u = ex(s, "library.usage", args);
|
|
assert_eq!(u["instances"], 1, "{u}");
|
|
assert_eq!(u["files"].as_array().map(Vec::len), Some(1), "{u}");
|
|
assert_eq!(u["components"][0]["name"], "Button", "{u}");
|
|
assert_eq!(u["components"][0]["files"], 1, "{u}");
|
|
}
|
|
|
|
// A view link can't publish, whatever the app does.
|
|
let mine = id(&ex(&mut b, "frame.create", json!({"x": 300, "y": 0, "width": 10, "height": 10, "name": "Mine"})));
|
|
ex(&mut b, "component.create", json!({"ids": [mine]}));
|
|
let e = b.execute("library.publishToServer", &json!({"server": p.view})).unwrap_err().to_string();
|
|
assert!(e.contains("only view"), "{e}");
|
|
let view = Invite::parse(&p.view).unwrap();
|
|
let mut raw = Raw::connect(addr);
|
|
raw.send(&open(&view));
|
|
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::View });
|
|
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
|
|
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
|
|
|
|
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
|
|
let edit = Invite::parse(&p.edit).unwrap();
|
|
let mut raw = Raw::connect(addr);
|
|
raw.send(&open(&edit));
|
|
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
|
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
|
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
|
let mut rng = Rng::new(5);
|
|
for round in 0..12 {
|
|
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
|
for _ in 0..1 + rng.below(8) {
|
|
let i = rng.below(junk.len());
|
|
junk[i] = rng.next_u64() as u8;
|
|
}
|
|
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
|
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
|
}
|
|
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
|
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
|
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
|
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
|
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
|
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
|
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
|
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
|
let mut raw = Raw::connect(addr);
|
|
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
|
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
|
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
|
|
|
|
// Kept on disk: the library is there after the server stops.
|
|
server.stop();
|
|
drop(server);
|
|
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
|
assert!(matches!(
|
|
store.library(std::net::Ipv4Addr::LOCALHOST.into(), &edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
|
|
LibraryReply::Library { version: 2, .. }
|
|
));
|
|
}
|