//! 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); } // --- Personal annotations, indexed by (user, target oid) --- // // The other direction from everything above: the note lives in the reader's own // annotation repository and targets a commit in somebody else's, so the commit // page's question is about an account rather than about a repository. These // assert the index answers it, and that the index is only ever a projection of // what the annotation repository holds. /// The account's annotation repository row: its id and its owner's id. async fn annotation_repo_row( h: &TestHarness, username: &str, ) -> (makenotwork::db::GitRepoId, makenotwork::db::UserId) { sqlx::query_as::<_, (makenotwork::db::GitRepoId, makenotwork::db::UserId)>( "SELECT r.id, r.user_id FROM git_repos r JOIN users u ON u.id = r.user_id WHERE u.username = $1 AND r.kind = 'annotations'", ) .bind(username) .fetch_one(&h.db) .await .expect("the annotation repository row exists") } async fn user_id(h: &TestHarness, username: &str) -> makenotwork::db::UserId { sqlx::query_scalar::<_, makenotwork::db::UserId>("SELECT id FROM users WHERE username = $1") .bind(username) .fetch_one(&h.db) .await .unwrap() } /// Write a note straight into a bare repository's `refs/notes/commits`, the way /// a push leaves it. `push_notes` is hard-wired to `testowner/testrepo`; this is /// the same thing against an arbitrary repository directory. fn push_notes_into(repo_dir: &std::path::Path, target: &str, body: &str) { use makenotwork::git::notes::{self, GixEngine, NoteObjects, NoteWrites, Oid, Signature}; let repo = gix::open(repo_dir).unwrap(); let engine = GixEngine::new(&repo); let who = Signature { name: "Annotator".into(), email: "annotator@users.makenot.work".into(), time: chrono::Utc::now(), }; let full_ref = "refs/notes/commits"; 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(); } /// Sign up a second account and have it annotate `sha` in `testowner/testrepo`. /// Leaves that account logged in. async fn annotate_as_stranger(h: &mut TestHarness, sha: &str, body: &str) { h.signup("annotator", "annotator@example.com", "password123") .await; let resp = h .client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/annotate"), &format!("content={}", urlencoding::encode(body)), ) .await; assert_eq!(resp.status, 303, "{}", resp.text); } #[tokio::test] async fn an_annotation_on_somebody_elses_commit_is_found_by_target_alone() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; annotate_as_stranger(&mut h, &sha, "this is the commit that broke it").await; let annotator = user_id(&h, "annotator").await; let found = makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, annotator, &sha) .await .unwrap(); assert_eq!( found.len(), 1, "one annotation, keyed by the viewer and the hash" ); assert_eq!(found[0].content, "this is the commit that broke it\n"); assert_eq!( found[0].namespace, "annotations/testowner/testrepo", "the namespace records the repository the annotation was written against" ); assert_eq!(found[0].target_oid, sha); assert_eq!( found[0].repo_name, makenotwork::constants::ANNOTATION_REPO_NAME, "the row names the annotation repository, not the repository browsed" ); // The lookup is scoped by ownership and nothing else, so the person whose // commit it is sees nothing of it. let owner = user_id(&h, "testowner").await; let theirs = makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, owner, &sha) .await .unwrap(); assert!( theirs.is_empty(), "somebody else's private annotation reached the commit's owner" ); // The count path the log page uses agrees with the list path. let counts = makenotwork::db::git_notes::annotation_counts_for_user( &h.db, annotator, std::slice::from_ref(&sha), ) .await .unwrap(); assert_eq!(counts.get(&sha), Some(&1)); assert!( makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, annotator) .await .unwrap(), "an annotation was written, so the index is warm and an empty read would mean empty" ); assert!( !makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, owner) .await .unwrap(), "an account with no annotation repository has a cold index, not an empty one" ); } #[tokio::test] async fn an_ordinary_repositorys_notes_never_answer_the_personal_lookup() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; h.login("testowner", "password123").await; let resp = h .client .post_form( &format!("/git/testowner/testrepo/commit/{sha}/notes"), "namespace=commits&content=a+repo+note", ) .await; assert_eq!(resp.status, 303, "{}", resp.text); // The note is indexed, against the source repository. let repo_id = sqlx::query_scalar::<_, makenotwork::db::GitRepoId>( "SELECT id FROM git_repos WHERE name = 'testrepo'", ) .fetch_one(&h.db) .await .unwrap(); let counts = makenotwork::db::git_notes::annotation_counts(&h.db, repo_id, std::slice::from_ref(&sha)) .await .unwrap(); assert_eq!(counts.get(&sha), Some(&1), "the repo note is in the index"); // A repo note is not a personal annotation. The predicate is the repository's // kind, so an owner's notes on their own repository never leak into it. let owner = user_id(&h, "testowner").await; let personal = makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, owner, &sha) .await .unwrap(); assert!( personal.is_empty(), "a note in a source repository answered the personal lookup" ); assert_eq!( makenotwork::db::git_notes::count_user_annotations(&h.db, owner) .await .unwrap(), 0 ); } #[tokio::test] async fn an_annotation_whose_target_no_repository_serves_is_still_returned() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; annotate_as_stranger(&mut h, &sha, "still here").await; let (repo_id, annotator) = annotation_repo_row(&h, "annotator").await; let orphan = "0123456789abcdef0123456789abcdef01234567"; let repo_dir = tmp.path().join("annotator").join(format!( "{}.git", makenotwork::constants::ANNOTATION_REPO_NAME )); push_notes_into(&repo_dir, orphan, "a commit nobody serves any more\n"); makenotwork::routes::git::notes_index::reindex_repo( &h.db, h.config(), repo_id, "annotator", makenotwork::constants::ANNOTATION_REPO_NAME, ) .await .unwrap(); let found = makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, annotator, orphan) .await .unwrap(); assert_eq!( found.len(), 1, "an annotation is not deleted because its target went away" ); assert_eq!(found[0].content, "a commit nobody serves any more\n"); assert_eq!( makenotwork::db::git_notes::count_user_annotations(&h.db, annotator) .await .unwrap(), 2, "orphans are listed with everything else, because nothing collects them" ); let all = makenotwork::db::git_notes::user_annotations(&h.db, annotator, 50, 0) .await .unwrap(); assert!(all.iter().any(|a| a.target_oid == orphan)); assert!(all.iter().any(|a| a.target_oid == sha)); } #[tokio::test] async fn the_personal_index_rebuilds_from_the_annotation_repository() { let tmp = tempfile::TempDir::new().unwrap(); let (mut h, sha) = setup(&tmp).await; annotate_as_stranger(&mut h, &sha, "the one on a live commit").await; let (repo_id, annotator) = annotation_repo_row(&h, "annotator").await; let orphan = "0123456789abcdef0123456789abcdef01234567"; let repo_dir = tmp.path().join("annotator").join(format!( "{}.git", makenotwork::constants::ANNOTATION_REPO_NAME )); push_notes_into(&repo_dir, orphan, "the one on a commit nobody serves\n"); let reindex = || async { makenotwork::routes::git::notes_index::reindex_repo( &h.db, h.config(), repo_id, "annotator", makenotwork::constants::ANNOTATION_REPO_NAME, ) .await .unwrap(); }; reindex().await; let mut before = makenotwork::db::git_notes::user_annotations(&h.db, annotator, 50, 0) .await .unwrap(); before.sort_by(|a, b| a.target_oid.cmp(&b.target_oid)); assert_eq!(before.len(), 2); // The load-bearing rule: Postgres holds nothing the repositories do not. sqlx::query("DELETE FROM git_notes") .execute(&h.db) .await .unwrap(); sqlx::query("DELETE FROM git_notes_index_state") .execute(&h.db) .await .unwrap(); assert!( !makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, annotator) .await .unwrap(), "a dropped index reads as cold, which is what stops an empty result being believed" ); reindex().await; let mut after = makenotwork::db::git_notes::user_annotations(&h.db, annotator, 50, 0) .await .unwrap(); after.sort_by(|a, b| a.target_oid.cmp(&b.target_oid)); assert_eq!(after.len(), 2, "the rebuild found both, orphan included"); for (was, now) in before.iter().zip(after.iter()) { assert_eq!(was.target_oid, now.target_oid); assert_eq!(was.blob_oid, now.blob_oid); assert_eq!(was.content, now.content); assert_eq!(was.namespace, now.namespace); } assert!( makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, annotator) .await .unwrap() ); }