//! The activity feed over real HTTP: what is stored, who sees it, who may post //! and what the server never writes down. mod common; use common::Server; use serde_json::json; /// A vault with three members: the owner, an editor and a viewer. struct Team { vault: String, owner: String, owner_id: String, editor: String, editor_id: String, viewer: String, viewer_id: String, } /// Builds a vault with an owner, an editor and a viewer. fn team(server: &Server, name: &str) -> Team { let (vault, owner, owner_id) = server.owned_vault(name); let (_, editor, editor_id) = server.owned_vault(name); let (_, viewer, viewer_id) = server.owned_vault(name); for (token, role) in [(&editor, "editor"), (&viewer, "viewer")] { let (status, code) = server.invite(&vault, &owner, role); assert_eq!(status, 200); assert_eq!(server.accept(token, &code).0, 200); } Team { vault, owner, owner_id, editor, editor_id, viewer, viewer_id } } /// The feed of `token`, as `(status, body)`. fn feed(server: &Server, vault: &str, token: &str) -> (u16, serde_json::Value) { server.json("GET", &format!("/v1/vaults/{vault}/activity"), Some(token), &[], None) } /// Posts one event of `kind` and answers the body of the server. fn post(server: &Server, vault: &str, token: &str, kind: &str, extra: &serde_json::Value) -> (u16, serde_json::Value) { let mut body = json!({ "kind": kind }); if let (Some(target), Some(source)) = (body.as_object_mut(), extra.as_object()) { for (key, value) in source { target.insert(key.clone(), value.clone()); } } server.json("POST", &format!("/v1/vaults/{vault}/activity"), Some(token), &[], Some(&body)) } #[test] fn an_event_reaches_the_other_members_and_not_the_actor() { let server = Server::start_temp("activity-members"); let team = team(&server, "members"); let (status, _) = server.put(&team.vault, &team.owner, "note.md", "# Note\n", 0); assert_eq!(status, 201); let (status, reply) = post(&server, &team.vault, &team.owner, "comment", &json!({ "note": "note.md", "thread_id": "c-1a2b" })); assert_eq!(status, 200, "{reply}"); assert_eq!(reply["kind"], "comment"); let id = reply["id"].as_u64().expect("id"); assert_eq!(reply["head"], id); // The actor does not see their own event. let (status, own) = feed(&server, &team.vault, &team.owner); assert_eq!(status, 200); assert_eq!(own["events"].as_array().expect("events").len(), 0); assert_eq!(own["unread"], 0); // The other members do. for token in [&team.editor, &team.viewer] { let (status, reply) = feed(&server, &team.vault, token.as_str()); assert_eq!(status, 200); let events = reply["events"].as_array().expect("events"); assert_eq!(events.len(), 1, "{reply}"); assert_eq!(events[0]["kind"], "comment"); assert_eq!(events[0]["note"], "note.md"); assert_eq!(events[0]["thread_id"], "c-1a2b"); assert_eq!(events[0]["actor"], team.owner_id); assert_eq!(events[0]["read"], false); assert_eq!(reply["unread"], 1); assert_eq!(reply["head"], id); } } #[test] fn a_mention_reaches_only_the_members_it_names() { let server = Server::start_temp("activity-mention"); let team = team(&server, "mention"); let (status, _) = server.put(&team.vault, &team.owner, "note.md", "# Note\n", 0); assert_eq!(status, 201); let (status, reply) = post(&server, &team.vault, &team.owner, "mention", &json!({ "note": "note.md", "mentions": [team.editor_id] })); assert_eq!(status, 200, "{reply}"); assert_eq!(reply["recipients"], 1); let (status, reply) = feed(&server, &team.vault, &team.editor); assert_eq!(status, 200); let events = reply["events"].as_array().expect("events"); assert_eq!(events.len(), 1, "{reply}"); assert_eq!(events[0]["kind"], "mention"); assert_eq!(reply["unread"], 1); // The viewer was not mentioned: nothing reaches them. let (status, reply) = feed(&server, &team.vault, &team.viewer); assert_eq!(status, 200); assert_eq!(reply["events"].as_array().expect("events").len(), 0); assert_eq!(reply["unread"], 0); } #[test] fn the_unread_count_follows_the_read_marking() { let server = Server::start_temp("activity-read"); let team = team(&server, "read"); let (status, first) = post(&server, &team.vault, &team.owner, "comment", &json!({})); assert_eq!(status, 200, "{first}"); let (status, second) = post(&server, &team.vault, &team.owner, "comment", &json!({})); assert_eq!(status, 200, "{second}"); let first_id = first["id"].as_u64().expect("id"); let (status, reply) = feed(&server, &team.vault, &team.editor); assert_eq!(status, 200); assert_eq!(reply["unread"], 2); // Marking one event read leaves the other one unread. let (status, marked) = server.json("POST", &format!("/v1/vaults/{}/activity/read", team.vault), Some(&team.editor), &[], Some(&json!({ "ids": [first_id] }))); assert_eq!(status, 200, "{marked}"); assert_eq!(marked["unread"], 1); // Marking them all read empties the badge; the body may be empty. let (status, body) = server.send("POST", &format!("/v1/vaults/{}/activity/read", team.vault), Some(&team.editor), &[], None); assert_eq!(status, 200); let marked: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(marked["unread"], 0); let (status, reply) = feed(&server, &team.vault, &team.editor); assert_eq!(status, 200); assert_eq!(reply["unread"], 0); for event in reply["events"].as_array().expect("events") { assert_eq!(event["read"], true, "{event}"); } } #[test] fn the_cursor_walks_the_feed() { let server = Server::start_temp("activity-cursor"); let team = team(&server, "cursor"); let mut ids = Vec::new(); for _ in 0..3 { let (status, reply) = post(&server, &team.vault, &team.owner, "comment", &json!({})); assert_eq!(status, 200, "{reply}"); ids.push(reply["id"].as_u64().expect("id")); } let (status, reply) = feed(&server, &team.vault, &team.editor); assert_eq!(status, 200); assert_eq!(reply["events"].as_array().expect("events").len(), 3); assert_eq!(reply["head"], ids[2]); assert_eq!(reply["more"], false); // The cursor skips the events the client already has. let (status, reply) = server.json("GET", &format!("/v1/vaults/{}/activity?since={}", team.vault, ids[0]), Some(&team.editor), &[], None); assert_eq!(status, 200); assert_eq!(reply["events"].as_array().expect("events").len(), 2); // A page holds a few events and says that more are waiting. let (status, reply) = server.json("GET", &format!("/v1/vaults/{}/activity?limit=1", team.vault), Some(&team.editor), &[], None); assert_eq!(status, 200); assert_eq!(reply["events"].as_array().expect("events").len(), 1); assert_eq!(reply["more"], true); } #[test] fn a_viewer_and_a_removed_member_are_refused() { let server = Server::start_temp("activity-roles"); let team = team(&server, "roles"); // A viewer reads the feed but posts no event. let (status, _) = feed(&server, &team.vault, &team.viewer); assert_eq!(status, 200); let (status, reply) = post(&server, &team.vault, &team.viewer, "comment", &json!({})); assert_eq!(status, 403, "{reply}"); // The owner removes the editor. let (status, _) = server.send("DELETE", &format!("/v1/vaults/{}/members/{}", team.vault, team.editor_id), Some(&team.owner), &[], None); assert_eq!(status, 204); // The removed member reads nothing and posts nothing. let (status, _) = feed(&server, &team.vault, &team.editor); assert_eq!(status, 403); let (status, _) = post(&server, &team.vault, &team.editor, "comment", &json!({})); assert_eq!(status, 403); // The feed of the other members did not change. let (status, reply) = feed(&server, &team.vault, &team.viewer); assert_eq!(status, 200); assert_eq!(reply["events"].as_array().expect("events").len(), 0); assert_eq!(reply["unread"], 0); // Only the editor left the vault. let (status, members) = server.json("GET", &format!("/v1/vaults/{}/members", team.vault), Some(&team.owner), &[], None); assert_eq!(status, 200); let ids: Vec = members.as_array().expect("members").iter().map(|row| row["user_id"].as_str().expect("user id").to_owned()).collect(); assert!(ids.contains(&team.viewer_id), "{ids:?}"); assert!(!ids.contains(&team.editor_id), "{ids:?}"); } #[test] fn the_actor_is_the_user_of_the_token() { let server = Server::start_temp("activity-actor"); let team = team(&server, "actor"); // A client cannot speak for somebody else. let (status, reply) = post(&server, &team.vault, &team.owner, "comment", &json!({ "actor": team.editor_id })); assert_eq!(status, 403, "{reply}"); // The actor of the token is the actor of the event. let (status, reply) = post(&server, &team.vault, &team.owner, "comment", &json!({ "actor": team.owner_id })); assert_eq!(status, 200, "{reply}"); let (status, reply) = feed(&server, &team.vault, &team.editor); assert_eq!(status, 200); let events = reply["events"].as_array().expect("events"); assert_eq!(events.len(), 1); assert_eq!(events[0]["actor"], team.owner_id); } #[test] fn a_bad_event_is_refused() { let server = Server::start_temp("activity-bad"); let team = team(&server, "bad"); // A kind the feed does not carry. let (status, reply) = post(&server, &team.vault, &team.owner, "gossip", &json!({})); assert_eq!(status, 400, "{reply}"); // A mention of somebody who is not a member. let (status, reply) = post(&server, &team.vault, &team.owner, "mention", &json!({ "mentions": ["u_stranger"] })); assert_eq!(status, 400, "{reply}"); // A mention that names nobody. let (status, reply) = post(&server, &team.vault, &team.owner, "mention", &json!({ "mentions": [] })); assert_eq!(status, 400, "{reply}"); // A note that is not in the vault. let (status, reply) = post(&server, &team.vault, &team.owner, "comment", &json!({ "note": "missing.md" })); assert_eq!(status, 404, "{reply}"); // A path that leaves the vault. let (status, reply) = post(&server, &team.vault, &team.owner, "comment", &json!({ "note": "../escape.md" })); assert_eq!(status, 400, "{reply}"); } #[test] fn the_server_never_writes_a_comment_body_to_the_log() { let server = Server::start_temp("activity-body"); let team = team(&server, "body"); let (status, _) = server.put(&team.vault, &team.owner, "note.md", "# Note\n", 0); assert_eq!(status, 201); let body = "the secret body of the comment"; let (status, reply) = post(&server, &team.vault, &team.owner, "comment", &json!({ "note": "note.md", "body": body })); assert_eq!(status, 200, "{reply}"); let audit = serde_json::to_string(&server.audit()).expect("audit"); assert!(!audit.contains(body), "{audit}"); assert!(audit.contains("activity_posted"), "{audit}"); let (status, reply) = feed(&server, &team.vault, &team.editor); assert_eq!(status, 200); let listing = reply.to_string(); assert!(!listing.contains(body), "{listing}"); }