//! Writing `refs/notes/*` from the browser: who may, what lands in the //! repository, and what the commit it writes says. //! //! The tree semantics are unit-tested in `src/git/notes`; nothing here re-tests //! fanout. What only exists at this layer is the authorization, the namespace //! policy, and the identity the commit carries — the last of which is a //! decision that cannot be taken back once a repository is cloned, so it is //! asserted against the object on disk rather than against a redirect. use crate::harness::{BuildOptions, TestHarness}; /// A bare repo at `{dir}/testowner/testrepo.git` with one commit on `main`. fn make_repo(dir: &std::path::Path) { use crate::harness::gitfixture::{blob, commit, init_bare, tree}; use gix::objs::tree::EntryKind; let repo = init_bare(dir, "testowner", "testrepo"); let readme = blob(&repo, b"# Test Repo\n"); let root = tree(&repo, &[("README.md", readme, EntryKind::Blob)]); commit(&repo, "Initial commit", root, Vec::new()); } async fn setup(tmp: &tempfile::TempDir) -> (TestHarness, String) { make_repo(tmp.path()); // The build trigger token is what the post-receive hook's per-repo HMAC is // derived from, so the inbox endpoint needs it configured. let mut h = TestHarness::build(BuildOptions { git_repos_path: Some(tmp.path().to_str().unwrap().to_string()), build_trigger_token: Some("test-trigger-secret".to_string()), ..Default::default() }) .await; h.signup("testowner", "testowner@example.com", "password123") .await; // The first browse auto-registers the on-disk repo in the database, which // every authorization check below reads. Only the owner may trigger that, // and signup leaves them logged in, so it happens here and the session is // then dropped: every test says for itself who it is acting as. h.client.get("/git/testowner/testrepo").await; // Auto-registration lands a repo private. Public is what makes the // authorization tests below say anything: on a private repo a stranger is // refused at the visibility check and never reaches the write check, so a // 404 would pass whether or not the write path checked anything at all. sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; let sha = crate::harness::gitfixture::main_tip_sha(tmp.path(), "testowner", "testrepo"); (h, sha) } /// The note on `namespace` as it sits in the repository, read with gix rather /// than through the page, so a rendering bug cannot make a missing note look /// present. fn note_in_repo(tmp: &tempfile::TempDir, namespace: &str, target: &str) -> Option { use makenotwork::git::notes::{self, GixEngine, Oid}; let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap(); let engine = GixEngine::new(&repo); let ns = notes::resolve_namespace(&engine, namespace).unwrap()?; let note = notes::note_for(&engine, ns.tip, Oid::from_hex(target.as_bytes()).unwrap()).unwrap()?; Some(note.content_lossy().into_owned()) } /// The committer of the tip of a notes ref. fn notes_committer(tmp: &tempfile::TempDir, namespace: &str) -> (String, String) { use makenotwork::git::notes::{self, GixEngine}; let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap(); let engine = GixEngine::new(&repo); let ns = notes::resolve_namespace(&engine, namespace) .unwrap() .expect("the namespace exists"); let meta = notes::NoteObjects::read_commit(&engine, ns.tip).unwrap(); (meta.committer.name, meta.committer.email) } /// The per-repo HMAC the post-receive hook carries. fn push_token(owner: &str, repo: &str) -> String { makenotwork::build_runner::repo_hmac("test-trigger-secret", owner, repo) } /// Write a notes commit onto `full_ref`, the way a client's push leaves it. fn push_notes(tmp: &tempfile::TempDir, full_ref: &str, target: &str, body: &str) { use makenotwork::git::notes::{self, GixEngine, NoteObjects, NoteWrites, Oid, Signature}; let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap(); let engine = GixEngine::new(&repo); let who = Signature { name: "Pusher".into(), email: "pusher@example.com".into(), time: chrono::Utc::now(), }; let existing = engine.resolve_ref(full_ref).unwrap(); let root = existing.map(|tip| engine.read_commit(tip).unwrap().tree); let blob = engine.write_blob(body.as_bytes()).unwrap(); let tree = notes::splice_note( &engine, root, Oid::from_hex(target.as_bytes()).unwrap(), Some(blob), ) .unwrap() .expect("the push changes something"); let commit = engine .write_commit(tree, existing.as_slice(), &who, &who, "notes: pushed\n") .unwrap(); engine.update_ref_cas(full_ref, existing, commit).unwrap(); } fn ref_exists(tmp: &tempfile::TempDir, full_ref: &str) -> bool { use makenotwork::git::notes::{GixEngine, NoteObjects}; let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap(); GixEngine::new(&repo) .resolve_ref(full_ref) .unwrap() .is_some() } #[tokio::test] async fn a_pushed_inbox_is_merged_into_the_namespace_and_then_deleted() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; // Someone annotated through the browser; someone else annotated the same // commit offline and pushed. Plain git rejects the second as a // non-fast-forward and tells them to run a notes merge by hand. h.login("testowner", "password123").await; h.client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/notes"), "namespace=commits&content=from+the+browser", ) .await; push_notes( &tmp, "refs/mnw/notes-inbox/commits", &sha, "from a laptop\n", ); h.client .set_bearer_token(&push_token("testowner", "testrepo")); let resp = h .client .post_json( "/api/internal/notes/merge-inbox", &serde_json::json!({ "repo_owner": "testowner", "repo_name": "testrepo", "ref_name": "refs/mnw/notes-inbox/commits", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("\"merged\":true"), "{}", resp.text); // Neither writer lost anything, and the inbox is gone so the next push // starts clean. let note = note_in_repo(&tmp, "commits", &sha).expect("a merged note"); assert!(note.contains("from the browser"), "{note}"); assert!(note.contains("from a laptop"), "{note}"); assert!(!ref_exists(&tmp, "refs/mnw/notes-inbox/commits")); } #[tokio::test] async fn merging_an_inbox_that_is_already_gone_is_not_an_error() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, _sha) = setup(&tmp).await; // The hook can fire twice for one push, and a retry after a timeout is the // expected recovery. Neither may fail: the push already landed. h.client .set_bearer_token(&push_token("testowner", "testrepo")); let resp = h .client .post_json( "/api/internal/notes/merge-inbox", &serde_json::json!({ "repo_owner": "testowner", "repo_name": "testrepo", "ref_name": "refs/mnw/notes-inbox/commits", }) .to_string(), ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!(resp.text.contains("\"merged\":false"), "{}", resp.text); } #[tokio::test] async fn the_inbox_endpoint_refuses_a_bad_token_and_the_reserved_namespace() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; push_notes(&tmp, "refs/mnw/notes-inbox/mnw/builds", &sha, "not yours\n"); let body = serde_json::json!({ "repo_owner": "testowner", "repo_name": "testrepo", "ref_name": "refs/mnw/notes-inbox/mnw/builds", }) .to_string(); // The HMAC is per repo, so one minted for a different repository must not // work here. h.client .set_bearer_token(&push_token("testowner", "otherrepo")); let resp = h .client .post_json("/api/internal/notes/merge-inbox", &body) .await; assert_eq!(resp.status, 403, "{}", resp.text); // With the right token the namespace policy still applies: pushing is not // a way around the server-owned prefix. h.client .set_bearer_token(&push_token("testowner", "testrepo")); let resp = h .client .post_json("/api/internal/notes/merge-inbox", &body) .await; assert_eq!(resp.status, 422, "{}", resp.text); assert_eq!(note_in_repo(&tmp, "mnw/builds", &sha), None); } #[test] fn both_copies_of_the_post_receive_hook_handle_the_inbox() { // The hook template exists twice, in server/src/build_runner.rs and in // mnw-cli/src/ssh/git.rs, and nothing but this makes them agree. A repo // created by one and re-hooked by the other would otherwise silently stop // merging notes pushes. let cli = std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../mnw-cli/src/ssh/git.rs"), ) .expect("mnw-cli lives beside the server in this repo"); let server = std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/build_runner.rs"), ) .unwrap(); for (name, source) in [("mnw-cli", &cli), ("server", &server)] { assert!( source.contains("refs/mnw/notes-inbox/*)"), "{name}'s hook does not case on the notes inbox" ); assert!( source.contains("/api/internal/notes/merge-inbox"), "{name}'s hook does not call the merge endpoint" ); assert!( source.contains("--max-time"), "{name}'s inbox arm is synchronous and must bound how long a push can wait" ); } } #[tokio::test] async fn the_owner_can_add_edit_and_remove_a_note() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; h.login("testowner", "password123").await; let save = format!("/git/testowner/testrepo/commit/{sha}/notes"); let resp = h .client .post_form(&save, "namespace=commits&content=reviewed+this+one") .await; assert_eq!(resp.status, 303, "{}", resp.text); assert_eq!( note_in_repo(&tmp, "commits", &sha).as_deref(), Some("reviewed this one\n"), "the note has to be in the repository, not only on the page" ); // The commit page shows it, and offers the writer the edit box. let page = h .client .get(&format!("/git/testowner/testrepo/commit/{sha}")) .await; assert_eq!(page.status, 200); assert!(page.text.contains("reviewed this one"), "note not rendered"); assert!(page.text.contains("Remove the note in commits")); let resp = h .client .post_form(&save, "namespace=commits&content=reviewed+again") .await; assert_eq!(resp.status, 303, "{}", resp.text); assert_eq!( note_in_repo(&tmp, "commits", &sha).as_deref(), Some("reviewed again\n") ); let resp = h .client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/notes/delete"), "namespace=commits", ) .await; assert_eq!(resp.status, 303, "{}", resp.text); assert_eq!(note_in_repo(&tmp, "commits", &sha), None); } #[tokio::test] async fn a_web_written_note_never_carries_the_account_email() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; h.login("testowner", "password123").await; h.client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/notes"), "namespace=commits&content=a+note", ) .await; // The decision this pins: an address in a public repo's object graph is // permanent and clonable, so the account's own email must never reach it. let (name, email) = notes_committer(&tmp, "commits"); assert_eq!(email, "testowner@users.makenot.work"); assert_eq!(name, "testowner", "no display name set, so the username"); assert!( !email.contains("example.com"), "the signup address leaked into the repository: {email}" ); } #[tokio::test] async fn the_server_owned_namespace_is_refused() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; h.login("testowner", "password123").await; // Reserved for what MNW writes itself (build results, issue links). Refused // before anyone can put a note there, because taking the prefix back later // would mean deciding what happens to the notes already under it. let resp = h .client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/notes"), "namespace=mnw%2Fbuilds&content=not+yours", ) .await; assert_eq!(resp.status, 422, "{}", resp.text); assert_eq!(note_in_repo(&tmp, "mnw/builds", &sha), None); } #[tokio::test] async fn an_empty_note_is_refused_rather_than_written() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; h.login("testowner", "password123").await; // Saving an empty box is almost always a mistake, and it would otherwise // commit a blank note rather than removing one. Delete is its own button. let resp = h .client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/notes"), "namespace=commits&content=+++", ) .await; assert_eq!(resp.status, 422, "{}", resp.text); assert_eq!(note_in_repo(&tmp, "commits", &sha), None); } #[tokio::test] async fn a_note_on_an_object_that_is_not_a_commit_here_is_a_404() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, _sha) = setup(&tmp).await; h.login("testowner", "password123").await; // Well-formed hex, no such commit. Without the check the note would be // written against an id nothing in this repository resolves, so it would be // invisible in the only place it could have been read. let absent = "a".repeat(40); let resp = h .client .post_form( &format!("/git/testowner/testrepo/commit/{absent}/notes"), "namespace=commits&content=nowhere", ) .await; assert_eq!(resp.status, 404, "{}", resp.text); } #[tokio::test] async fn only_someone_who_could_push_may_annotate() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; let save = format!("/git/testowner/testrepo/commit/{sha}/notes"); // A signed-in stranger is refused. Whether a fan may annotate a public // repository is a moderation question with its own decision; until it is // answered the write path says no. let outsider = h .signup("outsider", "outsider@example.com", "password123") .await; h.login("outsider", "password123").await; let resp = h .client .post_form(&save, "namespace=commits&content=let+me+in") .await; assert_eq!(resp.status, 403, "{}", resp.text); assert_eq!(note_in_repo(&tmp, "commits", &sha), None); // The commit page does not offer them the form either. let page = h .client .get(&format!("/git/testowner/testrepo/commit/{sha}")) .await; assert!( !page.text.contains("Add a note"), "the form was rendered for someone who cannot write" ); // A read-only collaborator is still refused: reading a private repo and // writing a commit to it are not the same permission. let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db) .await .unwrap(); sqlx::query( "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, false)", ) .bind(repo_id) .bind(outsider) .execute(&h.db) .await .unwrap(); let resp = h .client .post_form(&save, "namespace=commits&content=let+me+in") .await; assert_eq!(resp.status, 403, "{}", resp.text); // Given push, they may annotate: a note is a commit on a ref in the repo, // and they could already have pushed one over SSH. sqlx::query( "UPDATE repo_collaborators SET can_push = true WHERE repo_id = $1 AND user_id = $2", ) .bind(repo_id) .bind(outsider) .execute(&h.db) .await .unwrap(); let resp = h .client .post_form(&save, "namespace=commits&content=from+a+collaborator") .await; assert_eq!(resp.status, 303, "{}", resp.text); assert_eq!( note_in_repo(&tmp, "commits", &sha).as_deref(), Some("from a collaborator\n") ); let (_, email) = notes_committer(&tmp, "commits"); assert_eq!(email, "outsider@users.makenot.work"); } #[tokio::test] async fn an_anonymous_visitor_cannot_write_and_is_not_offered_the_form() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; let page = h .client .get(&format!("/git/testowner/testrepo/commit/{sha}")) .await; assert_eq!(page.status, 200); assert!(!page.text.contains("Add a note")); let resp = h .client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/notes"), "namespace=commits&content=anonymous", ) .await; assert_ne!(resp.status, 303, "an anonymous write must not succeed"); assert_eq!(note_in_repo(&tmp, "commits", &sha), None); }