mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
263 lines
14 KiB
Rust
263 lines
14 KiB
Rust
//! The team list (`team_files`) against a real server: off unless the operator turns it on; on,
|
|
//! it lists the files shared now (not the ones only published), hands out one team link per file
|
|
//! that joins with the list's access, and when the owner stops sharing the file leaves the list,
|
|
//! its team link stops working and whoever joined with it is disconnected. Turning the list off,
|
|
//! or changing its access, refuses the old team links at once.
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ResolveTeam, ServerMsg, TeamFile};
|
|
use figcraft_engine::live::Status;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::config::Config;
|
|
use crate::store::team::{NO_TEAM, NOT_SHARED};
|
|
use crate::test_util::{Raw, TempDir, config, join, live, publish, pump, session};
|
|
|
|
fn team(dir: &std::path::Path, access: &str) -> Config {
|
|
Config { open_publish: true, team_files: access.into(), web_url: "https://figcraft.example.com/app/".into(), ..config(dir) }
|
|
}
|
|
|
|
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
|
|
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
|
}
|
|
|
|
/// One request on an open hosting connection that names no file (anyone's).
|
|
fn ask(addr: SocketAddr, req: Admin) -> AdminReply {
|
|
let mut raw = Raw::connect(addr);
|
|
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
|
|
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
|
raw.send(&ClientMsg::Admin(req));
|
|
match raw.next() {
|
|
ServerMsg::Admin(r) => *r,
|
|
other => panic!("answered {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn listed(addr: SocketAddr) -> (String, Vec<TeamFile>) {
|
|
match ask(addr, Admin::Team) {
|
|
AdminReply::Team { web, files } => (web, files),
|
|
other => panic!("not a team list: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The team link of `file`, as a join by the team list asks for it: its token.
|
|
fn team_link(addr: SocketAddr, file: &str) -> Result<String, String> {
|
|
let mut raw = Raw::connect(addr);
|
|
raw.send(&ClientMsg::ResolveTeam(ResolveTeam { protocol: PROTOCOL, client_version: "test".into(), file: file.into() }));
|
|
match raw.next() {
|
|
ServerMsg::Resolved { file: f, token } if f == file => Ok(token),
|
|
ServerMsg::Refused { reason } => Err(reason),
|
|
other => panic!("answered {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The team link's entry in the file's links (as `figcraft-server links list` shows them).
|
|
fn team_entry(dir: &std::path::Path, file: &str) -> (Access, String) {
|
|
let links: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("files").join(file).join("links.json")).unwrap()).unwrap();
|
|
let l = links.as_array().unwrap().iter().find(|l| l["team"].is_string()).expect("a team link");
|
|
(Access::parse(l["access"].as_str().unwrap()).unwrap(), l["label"].as_str().unwrap().to_string())
|
|
}
|
|
|
|
fn invite(addr: SocketAddr, file: &str, token: &str) -> String {
|
|
format!("ws://{addr}/?file={file}&token={token}")
|
|
}
|
|
|
|
/// Ada shares a file online: the app with the file live, and the file's id.
|
|
fn share(addr: SocketAddr, name: &str) -> (figcraft_engine::Session, String) {
|
|
let mut a = session("Ada");
|
|
ex(&mut a, "pref.publishServer", json!({"url": format!("ws://{addr}/")}));
|
|
ex(&mut a, "file.rename", json!({"name": name}));
|
|
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
|
|
ex(&mut a, "collab.share", json!({}));
|
|
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some()));
|
|
let file = a.prefs.online_files[0].file.clone();
|
|
(a, file)
|
|
}
|
|
|
|
fn layers(s: &figcraft_engine::Session) -> usize {
|
|
s.doc().map_or(0, |d| d.node_count())
|
|
}
|
|
|
|
fn refused(s: &figcraft_engine::Session) -> bool {
|
|
matches!(&s.live().status, Status::Failed { reason } if reason.contains("isn't valid"))
|
|
}
|
|
|
|
#[test]
|
|
fn there_is_no_team_list_unless_the_operator_turns_it_on() {
|
|
let dir = TempDir::new("team-off");
|
|
let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap();
|
|
let (_a, file) = share(server.addr(), "Homepage");
|
|
assert_eq!(ask(server.addr(), Admin::Team), AdminReply::Failed { reason: NO_TEAM.into() });
|
|
assert_eq!(team_link(server.addr(), &file), Err(NO_TEAM.into()));
|
|
for bad in ["owner", "edit view"] {
|
|
assert!(team(dir.path(), bad).check().is_err(), "team_files = {bad:?} is refused");
|
|
}
|
|
for bad in ["ftp://x/", "https://x/app/?k=1", "https://x/app/#a", "https://x y/"] {
|
|
assert!(Config { web_url: bad.into(), ..config(dir.path()) }.check().is_err(), "web_url = {bad:?} is refused");
|
|
}
|
|
assert!(team(dir.path(), " View ").check().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_team_list_has_the_files_shared_now_and_their_team_link_joins() {
|
|
let dir = TempDir::new("team-list");
|
|
let server = crate::start(&team(dir.path(), "edit")).unwrap();
|
|
let addr = server.addr();
|
|
let (mut a, file) = share(addr, "Homepage");
|
|
// Published with the key but not shared: not on the list.
|
|
let mut c = session("Cy");
|
|
let unshared = publish(&mut c, dir.path(), addr).file;
|
|
let (web, files) = listed(addr);
|
|
assert_eq!(web, "https://figcraft.example.com/app/");
|
|
let ids: Vec<(&str, &str, Access)> = files.iter().map(|f| (f.file.id.as_str(), f.file.name.as_str(), f.access)).collect();
|
|
assert_eq!(ids, vec![(file.as_str(), "Homepage", Access::Edit)]);
|
|
assert!(team_link(addr, &unshared).is_err_and(|e| e == NOT_SHARED));
|
|
assert!(team_link(addr, "nonesuch").is_err_and(|e| e == NOT_SHARED));
|
|
// The team link: made once, the same for everyone after.
|
|
let token = team_link(addr, &file).unwrap();
|
|
assert_eq!(team_entry(dir.path(), &file), (Access::Edit, "Team (edit)".to_string()));
|
|
assert_eq!(team_link(addr, &file).unwrap(), token);
|
|
// Bob joins with it and edits; Ada sees the change.
|
|
let mut b = join(&invite(addr, &file, &token), "Bob");
|
|
pump(&mut [&mut a, &mut b], "Bob in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
|
let before = layers(&a);
|
|
ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 30, "height": 30}));
|
|
pump(&mut [&mut a, &mut b], "Bob's ellipse at Ada's", |s| layers(s[0]) == before + 1);
|
|
// The team link isn't a share of its own: the file isn't listed twice, and the owner's
|
|
// record still has just the links Ada made.
|
|
assert_eq!(listed(addr).1.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn stopping_sharing_takes_the_file_off_the_list_and_ends_the_team_link() {
|
|
let dir = TempDir::new("team-unshare");
|
|
let server = crate::start(&team(dir.path(), "edit")).unwrap();
|
|
let addr = server.addr();
|
|
let (mut a, file) = share(addr, "Homepage");
|
|
let token = team_link(addr, &file).unwrap();
|
|
let mut b = join(&invite(addr, &file, &token), "Bob");
|
|
pump(&mut [&mut a, &mut b], "Bob in", |s| live(s[1]));
|
|
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false);
|
|
// Bob is out, as people who joined with Ada's own links are.
|
|
pump(&mut [&mut a, &mut b], "Bob disconnected", |s| matches!(s[1].live().status, Status::Failed { .. } | Status::Off));
|
|
assert!(listed(addr).1.is_empty());
|
|
assert!(team_link(addr, &file).is_err_and(|e| e == NOT_SHARED));
|
|
let mut again = join(&invite(addr, &file, &token), "Bob");
|
|
pump(&mut [&mut again], "the old team link refused", |s| refused(s[0]));
|
|
// Sharing again lists it again, with a new team link.
|
|
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "share"}))["shared"], true);
|
|
assert_eq!(listed(addr).1.len(), 1);
|
|
assert_ne!(team_link(addr, &file).unwrap(), token);
|
|
}
|
|
|
|
#[test]
|
|
fn turning_the_list_off_or_changing_its_access_refuses_old_team_links() {
|
|
let dir = TempDir::new("team-config");
|
|
let mut server = crate::start(&team(dir.path(), "edit")).unwrap();
|
|
let addr = server.addr();
|
|
let (a, file) = share(addr, "Homepage");
|
|
let edit = team_link(addr, &file).unwrap();
|
|
drop(a);
|
|
server.stop();
|
|
// View only now: the edit team link no longer joins; the new one is view only.
|
|
let server = crate::start(&team(dir.path(), "view")).unwrap();
|
|
let addr = server.addr();
|
|
let mut b = join(&invite(addr, &file, &edit), "Bob");
|
|
pump(&mut [&mut b], "the edit team link refused", |s| refused(s[0]));
|
|
let view = team_link(addr, &file).unwrap();
|
|
assert_eq!(team_entry(dir.path(), &file).0, Access::View);
|
|
let mut b = join(&invite(addr, &file, &view), "Bob");
|
|
pump(&mut [&mut b], "Bob in, view only", |s| live(s[0]) && s[0].live_summary()["access"] == "view");
|
|
drop(b);
|
|
drop(server);
|
|
// Off: the list is gone and its links with it, also from the file's links.
|
|
let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap();
|
|
let mut b = join(&invite(server.addr(), &file, &view), "Bob");
|
|
pump(&mut [&mut b], "the team link refused", |s| refused(s[0]));
|
|
drop(b);
|
|
drop(server);
|
|
assert!(!std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap().contains(r#""team""#));
|
|
// On again with the old access: the old links stay refused; the next join gets a new one.
|
|
let server = crate::start(&team(dir.path(), "view")).unwrap();
|
|
let mut b = join(&invite(server.addr(), &file, &view), "Bob");
|
|
pump(&mut [&mut b], "the old view team link still refused", |s| refused(s[0]));
|
|
assert_ne!(team_link(server.addr(), &file).unwrap(), view);
|
|
}
|
|
|
|
#[test]
|
|
fn a_team_link_never_handed_out_again_stays_refused_after_the_setting_goes_back() {
|
|
let dir = TempDir::new("team-back");
|
|
let mut server = crate::start(&team(dir.path(), "edit")).unwrap();
|
|
let (a, file) = share(server.addr(), "Homepage");
|
|
let edit = team_link(server.addr(), &file).unwrap();
|
|
drop(a);
|
|
server.stop();
|
|
// View only for a while, and nobody asks for the view team link…
|
|
drop(crate::start(&team(dir.path(), "view")).unwrap());
|
|
// …then edit again: the edit team link of before doesn't come back.
|
|
let server = crate::start(&team(dir.path(), "edit")).unwrap();
|
|
let mut b = join(&invite(server.addr(), &file, &edit), "Bob");
|
|
pump(&mut [&mut b], "the old edit team link refused", |s| refused(s[0]));
|
|
assert_ne!(team_link(server.addr(), &file).unwrap(), edit);
|
|
}
|
|
|
|
#[test]
|
|
fn a_team_list_link_joins_without_a_token_while_the_file_is_shared() {
|
|
let dir = TempDir::new("team-join");
|
|
let server = crate::start(&team(dir.path(), "edit")).unwrap();
|
|
let addr = server.addr();
|
|
let (mut a, file) = share(addr, "Homepage");
|
|
// What the team list gives people to click or paste: no token in it.
|
|
let link = format!("figcraft://join?host={addr}&file={file}&team=1");
|
|
let mut b = join(&link, "Bob");
|
|
pump(&mut [&mut a, &mut b], "Bob in by the team list", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
|
assert_eq!(b.live_summary()["access"], "edit");
|
|
let failed = |s: &figcraft_engine::Session, why: &str| matches!(&s.live().status, Status::Failed { reason } if reason.contains(why));
|
|
let mut c = join(&format!("figcraft://join?host={addr}&file=0123456789abcdef&team=1"), "Cy");
|
|
pump(&mut [&mut c], "a file that isn't shared refused", |s| failed(s[0], "isn't shared now"));
|
|
// A server without a team list says so.
|
|
let dir = TempDir::new("team-join-off");
|
|
let off = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap();
|
|
let mut d = join(&format!("figcraft://join?host={}&file={file}&team=1", off.addr()), "Di");
|
|
pump(&mut [&mut d], "no team list", |s| failed(s[0], "keeps no team list"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_apps_online_files_list_the_team_list_and_open_its_files() {
|
|
let dir = TempDir::new("team-app");
|
|
let server = crate::start(&team(dir.path(), "edit")).unwrap();
|
|
let addr = server.addr();
|
|
let (mut a, file) = share(addr, "Homepage");
|
|
// Bob has the same server in Preferences and nothing of his own online.
|
|
let mut b = session("Bob");
|
|
ex(&mut b, "pref.publishServer", json!({"url": format!("ws://{addr}/")}));
|
|
assert_eq!(ex(&mut b, "collab.files", json!({}))["team"], Value::Null, "nothing asked without refresh");
|
|
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
|
|
let t = &list["team"];
|
|
assert_eq!(
|
|
(&t["state"], &t["web"], &t["files"][0]["file"], &t["files"][0]["name"]),
|
|
(&json!("listed"), &json!("https://figcraft.example.com/app/"), &json!(file), &json!("Homepage"))
|
|
);
|
|
let link = t["files"][0]["link"].as_str().unwrap().to_string();
|
|
assert_eq!(link, format!("figcraft://join?server=ws://{addr}/&file={file}&team=1"));
|
|
assert_eq!(
|
|
t["files"][0]["webLink"],
|
|
json!(format!(
|
|
"https://figcraft.example.com/app/#join=figcraft%3A%2F%2Fjoin%3Fserver%3Dws%3A%2F%2F{}%2F%26file%3D{file}%26team%3D1",
|
|
addr.to_string().replace(':', "%3A")
|
|
))
|
|
);
|
|
assert!(!list.to_string().contains("token"), "no team link token in the result: {list}");
|
|
ex(&mut b, "collab.join", json!({"link": link}));
|
|
pump(&mut [&mut a, &mut b], "Bob in from the list", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
|
// Another server in Preferences: its list isn't this one.
|
|
ex(&mut b, "pref.publishServer", json!({"url": "wss://elsewhere.example/"}));
|
|
assert_eq!(ex(&mut b, "collab.files", json!({}))["team"], Value::Null);
|
|
// A server without a team list says so.
|
|
let dir = TempDir::new("team-app-off");
|
|
let off = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap();
|
|
ex(&mut b, "pref.publishServer", json!({"url": format!("ws://{}/", off.addr())}));
|
|
assert_eq!(ex(&mut b, "collab.files", json!({"refresh": true}))["team"]["state"], "none");
|
|
}
|