c750fd2aa8
POST/GET /v1/vaults/{v}/activity and POST .../activity/read: per-member events with unread counts, viewers cannot post, removed members get 403, comment bodies never stored or logged, purge triggers on vault delete (comments spec task 7). Built by MiniMax M3.1 Flash (OpenCode Zen free route); verified from a clean checkout of the staged files: notescraft-sync-server tests 128 pass, clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
272 lines
11 KiB
Rust
272 lines
11 KiB
Rust
//! 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<String> = 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}");
|
|
}
|