Skip to main content

max / makenotwork

28.2 KB · 789 lines History Blame Raw
1 //! Writing `refs/notes/*` from the browser: who may, what lands in the
2 //! repository, and what the commit it writes says.
3 //!
4 //! The tree semantics are unit-tested in `src/git/notes`; nothing here re-tests
5 //! fanout. What only exists at this layer is the authorization, the namespace
6 //! policy, and the identity the commit carries — the last of which is a
7 //! decision that cannot be taken back once a repository is cloned, so it is
8 //! asserted against the object on disk rather than against a redirect.
9
10 use crate::harness::{BuildOptions, TestHarness};
11
12 /// A bare repo at `{dir}/testowner/testrepo.git` with one commit on `main`.
13 fn make_repo(dir: &std::path::Path) {
14 use crate::harness::gitfixture::{blob, commit, init_bare, tree};
15 use gix::objs::tree::EntryKind;
16
17 let repo = init_bare(dir, "testowner", "testrepo");
18 let readme = blob(&repo, b"# Test Repo\n");
19 let root = tree(&repo, &[("README.md", readme, EntryKind::Blob)]);
20 commit(&repo, "Initial commit", root, Vec::new());
21 }
22
23 async fn setup(tmp: &tempfile::TempDir) -> (TestHarness, String) {
24 make_repo(tmp.path());
25 // The build trigger token is what the post-receive hook's per-repo HMAC is
26 // derived from, so the inbox endpoint needs it configured.
27 let mut h = TestHarness::build(BuildOptions {
28 git_repos_path: Some(tmp.path().to_str().unwrap().to_string()),
29 build_trigger_token: Some("test-trigger-secret".to_string()),
30 ..Default::default()
31 })
32 .await;
33 h.signup("testowner", "testowner@example.com", "password123")
34 .await;
35 // The first browse auto-registers the on-disk repo in the database, which
36 // every authorization check below reads. Only the owner may trigger that,
37 // and signup leaves them logged in, so it happens here and the session is
38 // then dropped: every test says for itself who it is acting as.
39 h.client.get("/git/testowner/testrepo").await;
40
41 // Auto-registration lands a repo private. Public is what makes the
42 // authorization tests below say anything: on a private repo a stranger is
43 // refused at the visibility check and never reaches the write check, so a
44 // 404 would pass whether or not the write path checked anything at all.
45 sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'")
46 .execute(&h.db)
47 .await
48 .unwrap();
49
50 h.client.post_form("/logout", "").await;
51 let sha = crate::harness::gitfixture::main_tip_sha(tmp.path(), "testowner", "testrepo");
52 (h, sha)
53 }
54
55 /// The note on `namespace` as it sits in the repository, read with gix rather
56 /// than through the page, so a rendering bug cannot make a missing note look
57 /// present.
58 fn note_in_repo(tmp: &tempfile::TempDir, namespace: &str, target: &str) -> Option<String> {
59 use makenotwork::git::notes::{self, GixEngine, Oid};
60
61 let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap();
62 let engine = GixEngine::new(&repo);
63 let ns = notes::resolve_namespace(&engine, namespace).unwrap()?;
64 let note =
65 notes::note_for(&engine, ns.tip, Oid::from_hex(target.as_bytes()).unwrap()).unwrap()?;
66 Some(note.content_lossy().into_owned())
67 }
68
69 /// The committer of the tip of a notes ref.
70 fn notes_committer(tmp: &tempfile::TempDir, namespace: &str) -> (String, String) {
71 use makenotwork::git::notes::{self, GixEngine};
72
73 let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap();
74 let engine = GixEngine::new(&repo);
75 let ns = notes::resolve_namespace(&engine, namespace)
76 .unwrap()
77 .expect("the namespace exists");
78 let meta = notes::NoteObjects::read_commit(&engine, ns.tip).unwrap();
79 (meta.committer.name, meta.committer.email)
80 }
81
82 /// The per-repo HMAC the post-receive hook carries.
83 fn push_token(owner: &str, repo: &str) -> String {
84 makenotwork::build_runner::repo_hmac("test-trigger-secret", owner, repo)
85 }
86
87 /// Write a notes commit onto `full_ref`, the way a client's push leaves it.
88 fn push_notes(tmp: &tempfile::TempDir, full_ref: &str, target: &str, body: &str) {
89 use makenotwork::git::notes::{self, GixEngine, NoteObjects, NoteWrites, Oid, Signature};
90
91 let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap();
92 let engine = GixEngine::new(&repo);
93 let who = Signature {
94 name: "Pusher".into(),
95 email: "pusher@example.com".into(),
96 time: chrono::Utc::now(),
97 };
98
99 let existing = engine.resolve_ref(full_ref).unwrap();
100 let root = existing.map(|tip| engine.read_commit(tip).unwrap().tree);
101 let blob = engine.write_blob(body.as_bytes()).unwrap();
102 let tree = notes::splice_note(
103 &engine,
104 root,
105 Oid::from_hex(target.as_bytes()).unwrap(),
106 Some(blob),
107 )
108 .unwrap()
109 .expect("the push changes something");
110 let commit = engine
111 .write_commit(tree, existing.as_slice(), &who, &who, "notes: pushed\n")
112 .unwrap();
113 engine.update_ref_cas(full_ref, existing, commit).unwrap();
114 }
115
116 fn ref_exists(tmp: &tempfile::TempDir, full_ref: &str) -> bool {
117 use makenotwork::git::notes::{GixEngine, NoteObjects};
118
119 let repo = gix::open(tmp.path().join("testowner").join("testrepo.git")).unwrap();
120 GixEngine::new(&repo)
121 .resolve_ref(full_ref)
122 .unwrap()
123 .is_some()
124 }
125
126 #[tokio::test]
127 async fn a_pushed_inbox_is_merged_into_the_namespace_and_then_deleted() {
128 let tmp = tempfile::TempDir::new().unwrap();
129 let (mut h, sha) = setup(&tmp).await;
130
131 // Someone annotated through the browser; someone else annotated the same
132 // commit offline and pushed. Plain git rejects the second as a
133 // non-fast-forward and tells them to run a notes merge by hand.
134 h.login("testowner", "password123").await;
135 h.client
136 .post_form(
137 &format!("/git/testowner/testrepo/commit/{sha}/notes"),
138 "namespace=commits&content=from+the+browser",
139 )
140 .await;
141 push_notes(
142 &tmp,
143 "refs/mnw/notes-inbox/commits",
144 &sha,
145 "from a laptop\n",
146 );
147
148 h.client
149 .set_bearer_token(&push_token("testowner", "testrepo"));
150 let resp = h
151 .client
152 .post_json(
153 "/api/internal/notes/merge-inbox",
154 &serde_json::json!({
155 "repo_owner": "testowner",
156 "repo_name": "testrepo",
157 "ref_name": "refs/mnw/notes-inbox/commits",
158 })
159 .to_string(),
160 )
161 .await;
162 assert_eq!(resp.status, 200, "{}", resp.text);
163 assert!(resp.text.contains("\"merged\":true"), "{}", resp.text);
164
165 // Neither writer lost anything, and the inbox is gone so the next push
166 // starts clean.
167 let note = note_in_repo(&tmp, "commits", &sha).expect("a merged note");
168 assert!(note.contains("from the browser"), "{note}");
169 assert!(note.contains("from a laptop"), "{note}");
170 assert!(!ref_exists(&tmp, "refs/mnw/notes-inbox/commits"));
171 }
172
173 #[tokio::test]
174 async fn merging_an_inbox_that_is_already_gone_is_not_an_error() {
175 let tmp = tempfile::TempDir::new().unwrap();
176 let (mut h, _sha) = setup(&tmp).await;
177
178 // The hook can fire twice for one push, and a retry after a timeout is the
179 // expected recovery. Neither may fail: the push already landed.
180 h.client
181 .set_bearer_token(&push_token("testowner", "testrepo"));
182 let resp = h
183 .client
184 .post_json(
185 "/api/internal/notes/merge-inbox",
186 &serde_json::json!({
187 "repo_owner": "testowner",
188 "repo_name": "testrepo",
189 "ref_name": "refs/mnw/notes-inbox/commits",
190 })
191 .to_string(),
192 )
193 .await;
194 assert_eq!(resp.status, 200, "{}", resp.text);
195 assert!(resp.text.contains("\"merged\":false"), "{}", resp.text);
196 }
197
198 #[tokio::test]
199 async fn the_inbox_endpoint_refuses_a_bad_token_and_the_reserved_namespace() {
200 let tmp = tempfile::TempDir::new().unwrap();
201 let (mut h, sha) = setup(&tmp).await;
202 push_notes(&tmp, "refs/mnw/notes-inbox/mnw/builds", &sha, "not yours\n");
203
204 let body = serde_json::json!({
205 "repo_owner": "testowner",
206 "repo_name": "testrepo",
207 "ref_name": "refs/mnw/notes-inbox/mnw/builds",
208 })
209 .to_string();
210
211 // The HMAC is per repo, so one minted for a different repository must not
212 // work here.
213 h.client
214 .set_bearer_token(&push_token("testowner", "otherrepo"));
215 let resp = h
216 .client
217 .post_json("/api/internal/notes/merge-inbox", &body)
218 .await;
219 assert_eq!(resp.status, 403, "{}", resp.text);
220
221 // With the right token the namespace policy still applies: pushing is not
222 // a way around the server-owned prefix.
223 h.client
224 .set_bearer_token(&push_token("testowner", "testrepo"));
225 let resp = h
226 .client
227 .post_json("/api/internal/notes/merge-inbox", &body)
228 .await;
229 assert_eq!(resp.status, 422, "{}", resp.text);
230 assert_eq!(note_in_repo(&tmp, "mnw/builds", &sha), None);
231 }
232
233 #[test]
234 fn both_copies_of_the_post_receive_hook_handle_the_inbox() {
235 // The hook template exists twice, in server/src/build_runner.rs and in
236 // mnw-cli/src/ssh/git.rs, and nothing but this makes them agree. A repo
237 // created by one and re-hooked by the other would otherwise silently stop
238 // merging notes pushes.
239 let cli = std::fs::read_to_string(
240 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../mnw-cli/src/ssh/git.rs"),
241 )
242 .expect("mnw-cli lives beside the server in this repo");
243 let server = std::fs::read_to_string(
244 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/build_runner.rs"),
245 )
246 .unwrap();
247
248 for (name, source) in [("mnw-cli", &cli), ("server", &server)] {
249 assert!(
250 source.contains("refs/mnw/notes-inbox/*)"),
251 "{name}'s hook does not case on the notes inbox"
252 );
253 assert!(
254 source.contains("/api/internal/notes/merge-inbox"),
255 "{name}'s hook does not call the merge endpoint"
256 );
257 assert!(
258 source.contains("--max-time"),
259 "{name}'s inbox arm is synchronous and must bound how long a push can wait"
260 );
261 }
262 }
263
264 #[tokio::test]
265 async fn the_owner_can_add_edit_and_remove_a_note() {
266 let tmp = tempfile::TempDir::new().unwrap();
267 let (mut h, sha) = setup(&tmp).await;
268 h.login("testowner", "password123").await;
269
270 let save = format!("/git/testowner/testrepo/commit/{sha}/notes");
271 let resp = h
272 .client
273 .post_form(&save, "namespace=commits&content=reviewed+this+one")
274 .await;
275 assert_eq!(resp.status, 303, "{}", resp.text);
276 assert_eq!(
277 note_in_repo(&tmp, "commits", &sha).as_deref(),
278 Some("reviewed this one\n"),
279 "the note has to be in the repository, not only on the page"
280 );
281
282 // The commit page shows it, and offers the writer the edit box.
283 let page = h
284 .client
285 .get(&format!("/git/testowner/testrepo/commit/{sha}"))
286 .await;
287 assert_eq!(page.status, 200);
288 assert!(page.text.contains("reviewed this one"), "note not rendered");
289 assert!(page.text.contains("Remove the note in commits"));
290
291 let resp = h
292 .client
293 .post_form(&save, "namespace=commits&content=reviewed+again")
294 .await;
295 assert_eq!(resp.status, 303, "{}", resp.text);
296 assert_eq!(
297 note_in_repo(&tmp, "commits", &sha).as_deref(),
298 Some("reviewed again\n")
299 );
300
301 let resp = h
302 .client
303 .post_form(
304 &format!("/git/testowner/testrepo/commit/{sha}/notes/delete"),
305 "namespace=commits",
306 )
307 .await;
308 assert_eq!(resp.status, 303, "{}", resp.text);
309 assert_eq!(note_in_repo(&tmp, "commits", &sha), None);
310 }
311
312 #[tokio::test]
313 async fn a_web_written_note_never_carries_the_account_email() {
314 let tmp = tempfile::TempDir::new().unwrap();
315 let (mut h, sha) = setup(&tmp).await;
316 h.login("testowner", "password123").await;
317
318 h.client
319 .post_form(
320 &format!("/git/testowner/testrepo/commit/{sha}/notes"),
321 "namespace=commits&content=a+note",
322 )
323 .await;
324
325 // The decision this pins: an address in a public repo's object graph is
326 // permanent and clonable, so the account's own email must never reach it.
327 let (name, email) = notes_committer(&tmp, "commits");
328 assert_eq!(email, "testowner@users.makenot.work");
329 assert_eq!(name, "testowner", "no display name set, so the username");
330 assert!(
331 !email.contains("example.com"),
332 "the signup address leaked into the repository: {email}"
333 );
334 }
335
336 #[tokio::test]
337 async fn the_server_owned_namespace_is_refused() {
338 let tmp = tempfile::TempDir::new().unwrap();
339 let (mut h, sha) = setup(&tmp).await;
340 h.login("testowner", "password123").await;
341
342 // Reserved for what MNW writes itself (build results, issue links). Refused
343 // before anyone can put a note there, because taking the prefix back later
344 // would mean deciding what happens to the notes already under it.
345 let resp = h
346 .client
347 .post_form(
348 &format!("/git/testowner/testrepo/commit/{sha}/notes"),
349 "namespace=mnw%2Fbuilds&content=not+yours",
350 )
351 .await;
352 assert_eq!(resp.status, 422, "{}", resp.text);
353 assert_eq!(note_in_repo(&tmp, "mnw/builds", &sha), None);
354 }
355
356 #[tokio::test]
357 async fn an_empty_note_is_refused_rather_than_written() {
358 let tmp = tempfile::TempDir::new().unwrap();
359 let (mut h, sha) = setup(&tmp).await;
360 h.login("testowner", "password123").await;
361
362 // Saving an empty box is almost always a mistake, and it would otherwise
363 // commit a blank note rather than removing one. Delete is its own button.
364 let resp = h
365 .client
366 .post_form(
367 &format!("/git/testowner/testrepo/commit/{sha}/notes"),
368 "namespace=commits&content=+++",
369 )
370 .await;
371 assert_eq!(resp.status, 422, "{}", resp.text);
372 assert_eq!(note_in_repo(&tmp, "commits", &sha), None);
373 }
374
375 #[tokio::test]
376 async fn a_note_on_an_object_that_is_not_a_commit_here_is_a_404() {
377 let tmp = tempfile::TempDir::new().unwrap();
378 let (mut h, _sha) = setup(&tmp).await;
379 h.login("testowner", "password123").await;
380
381 // Well-formed hex, no such commit. Without the check the note would be
382 // written against an id nothing in this repository resolves, so it would be
383 // invisible in the only place it could have been read.
384 let absent = "a".repeat(40);
385 let resp = h
386 .client
387 .post_form(
388 &format!("/git/testowner/testrepo/commit/{absent}/notes"),
389 "namespace=commits&content=nowhere",
390 )
391 .await;
392 assert_eq!(resp.status, 404, "{}", resp.text);
393 }
394
395 #[tokio::test]
396 async fn only_someone_who_could_push_may_annotate() {
397 let tmp = tempfile::TempDir::new().unwrap();
398 let (mut h, sha) = setup(&tmp).await;
399 let save = format!("/git/testowner/testrepo/commit/{sha}/notes");
400
401 // A signed-in stranger is refused. Whether a fan may annotate a public
402 // repository is a moderation question with its own decision; until it is
403 // answered the write path says no.
404 let outsider = h
405 .signup("outsider", "outsider@example.com", "password123")
406 .await;
407 h.login("outsider", "password123").await;
408 let resp = h
409 .client
410 .post_form(&save, "namespace=commits&content=let+me+in")
411 .await;
412 assert_eq!(resp.status, 403, "{}", resp.text);
413 assert_eq!(note_in_repo(&tmp, "commits", &sha), None);
414
415 // The commit page does not offer them the form either.
416 let page = h
417 .client
418 .get(&format!("/git/testowner/testrepo/commit/{sha}"))
419 .await;
420 assert!(
421 !page.text.contains("Add a note"),
422 "the form was rendered for someone who cannot write"
423 );
424
425 // A read-only collaborator is still refused: reading a private repo and
426 // writing a commit to it are not the same permission.
427 let repo_id: uuid::Uuid =
428 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
429 .fetch_one(&h.db)
430 .await
431 .unwrap();
432 sqlx::query(
433 "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, false)",
434 )
435 .bind(repo_id)
436 .bind(outsider)
437 .execute(&h.db)
438 .await
439 .unwrap();
440 let resp = h
441 .client
442 .post_form(&save, "namespace=commits&content=let+me+in")
443 .await;
444 assert_eq!(resp.status, 403, "{}", resp.text);
445
446 // Given push, they may annotate: a note is a commit on a ref in the repo,
447 // and they could already have pushed one over SSH.
448 sqlx::query(
449 "UPDATE repo_collaborators SET can_push = true WHERE repo_id = $1 AND user_id = $2",
450 )
451 .bind(repo_id)
452 .bind(outsider)
453 .execute(&h.db)
454 .await
455 .unwrap();
456 let resp = h
457 .client
458 .post_form(&save, "namespace=commits&content=from+a+collaborator")
459 .await;
460 assert_eq!(resp.status, 303, "{}", resp.text);
461 assert_eq!(
462 note_in_repo(&tmp, "commits", &sha).as_deref(),
463 Some("from a collaborator\n")
464 );
465 let (_, email) = notes_committer(&tmp, "commits");
466 assert_eq!(email, "outsider@users.makenot.work");
467 }
468
469 #[tokio::test]
470 async fn an_anonymous_visitor_cannot_write_and_is_not_offered_the_form() {
471 let tmp = tempfile::TempDir::new().unwrap();
472 let (mut h, sha) = setup(&tmp).await;
473
474 let page = h
475 .client
476 .get(&format!("/git/testowner/testrepo/commit/{sha}"))
477 .await;
478 assert_eq!(page.status, 200);
479 assert!(!page.text.contains("Add a note"));
480
481 let resp = h
482 .client
483 .post_form(
484 &format!("/git/testowner/testrepo/commit/{sha}/notes"),
485 "namespace=commits&content=anonymous",
486 )
487 .await;
488 assert_ne!(resp.status, 303, "an anonymous write must not succeed");
489 assert_eq!(note_in_repo(&tmp, "commits", &sha), None);
490 }
491
492 // --- Personal annotations, indexed by (user, target oid) ---
493 //
494 // The other direction from everything above: the note lives in the reader's own
495 // annotation repository and targets a commit in somebody else's, so the commit
496 // page's question is about an account rather than about a repository. These
497 // assert the index answers it, and that the index is only ever a projection of
498 // what the annotation repository holds.
499
500 /// The account's annotation repository row: its id and its owner's id.
501 async fn annotation_repo_row(
502 h: &TestHarness,
503 username: &str,
504 ) -> (makenotwork::db::GitRepoId, makenotwork::db::UserId) {
505 sqlx::query_as::<_, (makenotwork::db::GitRepoId, makenotwork::db::UserId)>(
506 "SELECT r.id, r.user_id FROM git_repos r JOIN users u ON u.id = r.user_id
507 WHERE u.username = $1 AND r.kind = 'annotations'",
508 )
509 .bind(username)
510 .fetch_one(&h.db)
511 .await
512 .expect("the annotation repository row exists")
513 }
514
515 async fn user_id(h: &TestHarness, username: &str) -> makenotwork::db::UserId {
516 sqlx::query_scalar::<_, makenotwork::db::UserId>("SELECT id FROM users WHERE username = $1")
517 .bind(username)
518 .fetch_one(&h.db)
519 .await
520 .unwrap()
521 }
522
523 /// Write a note straight into a bare repository's `refs/notes/commits`, the way
524 /// a push leaves it. `push_notes` is hard-wired to `testowner/testrepo`; this is
525 /// the same thing against an arbitrary repository directory.
526 fn push_notes_into(repo_dir: &std::path::Path, target: &str, body: &str) {
527 use makenotwork::git::notes::{self, GixEngine, NoteObjects, NoteWrites, Oid, Signature};
528
529 let repo = gix::open(repo_dir).unwrap();
530 let engine = GixEngine::new(&repo);
531 let who = Signature {
532 name: "Annotator".into(),
533 email: "annotator@users.makenot.work".into(),
534 time: chrono::Utc::now(),
535 };
536 let full_ref = "refs/notes/commits";
537 let existing = engine.resolve_ref(full_ref).unwrap();
538 let root = existing.map(|tip| engine.read_commit(tip).unwrap().tree);
539 let blob = engine.write_blob(body.as_bytes()).unwrap();
540 let tree = notes::splice_note(
541 &engine,
542 root,
543 Oid::from_hex(target.as_bytes()).unwrap(),
544 Some(blob),
545 )
546 .unwrap()
547 .expect("the push changes something");
548 let commit = engine
549 .write_commit(tree, existing.as_slice(), &who, &who, "notes: pushed\n")
550 .unwrap();
551 engine.update_ref_cas(full_ref, existing, commit).unwrap();
552 }
553
554 /// Sign up a second account and have it annotate `sha` in `testowner/testrepo`.
555 /// Leaves that account logged in.
556 async fn annotate_as_stranger(h: &mut TestHarness, sha: &str, body: &str) {
557 h.signup("annotator", "annotator@example.com", "password123")
558 .await;
559 let resp = h
560 .client
561 .post_form(
562 &format!("/git/testowner/testrepo/commit/{sha}/annotate"),
563 &format!("content={}", urlencoding::encode(body)),
564 )
565 .await;
566 assert_eq!(resp.status, 303, "{}", resp.text);
567 }
568
569 #[tokio::test]
570 async fn an_annotation_on_somebody_elses_commit_is_found_by_target_alone() {
571 let tmp = tempfile::TempDir::new().unwrap();
572 let (mut h, sha) = setup(&tmp).await;
573 annotate_as_stranger(&mut h, &sha, "this is the commit that broke it").await;
574
575 let annotator = user_id(&h, "annotator").await;
576 let found = makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, annotator, &sha)
577 .await
578 .unwrap();
579 assert_eq!(
580 found.len(),
581 1,
582 "one annotation, keyed by the viewer and the hash"
583 );
584 assert_eq!(found[0].content, "this is the commit that broke it\n");
585 assert_eq!(
586 found[0].namespace, "annotations/testowner/testrepo",
587 "the namespace records the repository the annotation was written against"
588 );
589 assert_eq!(found[0].target_oid, sha);
590 assert_eq!(
591 found[0].repo_name,
592 makenotwork::constants::ANNOTATION_REPO_NAME,
593 "the row names the annotation repository, not the repository browsed"
594 );
595
596 // The lookup is scoped by ownership and nothing else, so the person whose
597 // commit it is sees nothing of it.
598 let owner = user_id(&h, "testowner").await;
599 let theirs = makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, owner, &sha)
600 .await
601 .unwrap();
602 assert!(
603 theirs.is_empty(),
604 "somebody else's private annotation reached the commit's owner"
605 );
606
607 // The count path the log page uses agrees with the list path.
608 let counts = makenotwork::db::git_notes::annotation_counts_for_user(
609 &h.db,
610 annotator,
611 std::slice::from_ref(&sha),
612 )
613 .await
614 .unwrap();
615 assert_eq!(counts.get(&sha), Some(&1));
616 assert!(
617 makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, annotator)
618 .await
619 .unwrap(),
620 "an annotation was written, so the index is warm and an empty read would mean empty"
621 );
622 assert!(
623 !makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, owner)
624 .await
625 .unwrap(),
626 "an account with no annotation repository has a cold index, not an empty one"
627 );
628 }
629
630 #[tokio::test]
631 async fn an_ordinary_repositorys_notes_never_answer_the_personal_lookup() {
632 let tmp = tempfile::TempDir::new().unwrap();
633 let (mut h, sha) = setup(&tmp).await;
634 h.login("testowner", "password123").await;
635 let resp = h
636 .client
637 .post_form(
638 &format!("/git/testowner/testrepo/commit/{sha}/notes"),
639 "namespace=commits&content=a+repo+note",
640 )
641 .await;
642 assert_eq!(resp.status, 303, "{}", resp.text);
643
644 // The note is indexed, against the source repository.
645 let repo_id = sqlx::query_scalar::<_, makenotwork::db::GitRepoId>(
646 "SELECT id FROM git_repos WHERE name = 'testrepo'",
647 )
648 .fetch_one(&h.db)
649 .await
650 .unwrap();
651 let counts =
652 makenotwork::db::git_notes::annotation_counts(&h.db, repo_id, std::slice::from_ref(&sha))
653 .await
654 .unwrap();
655 assert_eq!(counts.get(&sha), Some(&1), "the repo note is in the index");
656
657 // A repo note is not a personal annotation. The predicate is the repository's
658 // kind, so an owner's notes on their own repository never leak into it.
659 let owner = user_id(&h, "testowner").await;
660 let personal = makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, owner, &sha)
661 .await
662 .unwrap();
663 assert!(
664 personal.is_empty(),
665 "a note in a source repository answered the personal lookup"
666 );
667 assert_eq!(
668 makenotwork::db::git_notes::count_user_annotations(&h.db, owner)
669 .await
670 .unwrap(),
671 0
672 );
673 }
674
675 #[tokio::test]
676 async fn an_annotation_whose_target_no_repository_serves_is_still_returned() {
677 let tmp = tempfile::TempDir::new().unwrap();
678 let (mut h, sha) = setup(&tmp).await;
679 annotate_as_stranger(&mut h, &sha, "still here").await;
680
681 let (repo_id, annotator) = annotation_repo_row(&h, "annotator").await;
682 let orphan = "0123456789abcdef0123456789abcdef01234567";
683 let repo_dir = tmp.path().join("annotator").join(format!(
684 "{}.git",
685 makenotwork::constants::ANNOTATION_REPO_NAME
686 ));
687 push_notes_into(&repo_dir, orphan, "a commit nobody serves any more\n");
688 makenotwork::routes::git::notes_index::reindex_repo(
689 &h.db,
690 h.config(),
691 repo_id,
692 "annotator",
693 makenotwork::constants::ANNOTATION_REPO_NAME,
694 )
695 .await
696 .unwrap();
697
698 let found =
699 makenotwork::db::git_notes::annotations_by_user_for_target(&h.db, annotator, orphan)
700 .await
701 .unwrap();
702 assert_eq!(
703 found.len(),
704 1,
705 "an annotation is not deleted because its target went away"
706 );
707 assert_eq!(found[0].content, "a commit nobody serves any more\n");
708 assert_eq!(
709 makenotwork::db::git_notes::count_user_annotations(&h.db, annotator)
710 .await
711 .unwrap(),
712 2,
713 "orphans are listed with everything else, because nothing collects them"
714 );
715 let all = makenotwork::db::git_notes::user_annotations(&h.db, annotator, 50, 0)
716 .await
717 .unwrap();
718 assert!(all.iter().any(|a| a.target_oid == orphan));
719 assert!(all.iter().any(|a| a.target_oid == sha));
720 }
721
722 #[tokio::test]
723 async fn the_personal_index_rebuilds_from_the_annotation_repository() {
724 let tmp = tempfile::TempDir::new().unwrap();
725 let (mut h, sha) = setup(&tmp).await;
726 annotate_as_stranger(&mut h, &sha, "the one on a live commit").await;
727
728 let (repo_id, annotator) = annotation_repo_row(&h, "annotator").await;
729 let orphan = "0123456789abcdef0123456789abcdef01234567";
730 let repo_dir = tmp.path().join("annotator").join(format!(
731 "{}.git",
732 makenotwork::constants::ANNOTATION_REPO_NAME
733 ));
734 push_notes_into(&repo_dir, orphan, "the one on a commit nobody serves\n");
735 let reindex = || async {
736 makenotwork::routes::git::notes_index::reindex_repo(
737 &h.db,
738 h.config(),
739 repo_id,
740 "annotator",
741 makenotwork::constants::ANNOTATION_REPO_NAME,
742 )
743 .await
744 .unwrap();
745 };
746 reindex().await;
747
748 let mut before = makenotwork::db::git_notes::user_annotations(&h.db, annotator, 50, 0)
749 .await
750 .unwrap();
751 before.sort_by(|a, b| a.target_oid.cmp(&b.target_oid));
752 assert_eq!(before.len(), 2);
753
754 // The load-bearing rule: Postgres holds nothing the repositories do not.
755 sqlx::query("DELETE FROM git_notes")
756 .execute(&h.db)
757 .await
758 .unwrap();
759 sqlx::query("DELETE FROM git_notes_index_state")
760 .execute(&h.db)
761 .await
762 .unwrap();
763 assert!(
764 !makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, annotator)
765 .await
766 .unwrap(),
767 "a dropped index reads as cold, which is what stops an empty result being believed"
768 );
769
770 reindex().await;
771
772 let mut after = makenotwork::db::git_notes::user_annotations(&h.db, annotator, 50, 0)
773 .await
774 .unwrap();
775 after.sort_by(|a, b| a.target_oid.cmp(&b.target_oid));
776 assert_eq!(after.len(), 2, "the rebuild found both, orphan included");
777 for (was, now) in before.iter().zip(after.iter()) {
778 assert_eq!(was.target_oid, now.target_oid);
779 assert_eq!(was.blob_oid, now.blob_oid);
780 assert_eq!(was.content, now.content);
781 assert_eq!(was.namespace, now.namespace);
782 }
783 assert!(
784 makenotwork::db::git_notes::user_annotation_index_is_warm(&h.db, annotator)
785 .await
786 .unwrap()
787 );
788 }
789