Skip to main content

max / makenotwork

test-fuzz Phase 2: pin storage CAS guards + session/report negative paths Storage confirm double-failure (Phase 2.2): db-layer CAS-guard pins for the Run #9 tx port — update_item_file_cas, update_version_file, update_project_cover_cas, update_item_cover each reject a stale expected_old and leave the row untouched (no clobber, no double-credit); ownership filter pinned too. The handler-level rollback+orphan-queue on these branches only fires under true concurrency, which a sequential test can't drive; the CAS predicate is the deterministic seam. Security negative paths (Phase 2.3): touch_session/delete_user_session revocation primitive (valid->revoked, idempotent double-revoke); report dedup allows a fresh report after the prior is resolved; resolving a nonexistent report 404s. Widen db::projects and db::sessions to pub (match pub siblings users/items/versions) so the db-layer tests can reach the sealed CAS/revocation fns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-17 22:43 UTC
Commit: 752046e78647067201a7f0d561efbcd1ae85897b
Parent: f8ea7a1
4 files changed, +347 insertions, -2 deletions
@@ -10,7 +10,7 @@ mod enums;
10 10 mod models;
11 11 mod subscription_writer;
12 12 pub mod users;
13 - pub(crate) mod projects;
13 + pub mod projects;
14 14 pub mod custom_pages;
15 15 pub mod items;
16 16 pub mod versions;
@@ -32,7 +32,7 @@ pub(crate) mod follows;
32 32 pub(crate) mod subscriptions;
33 33 pub(crate) mod tags;
34 34 pub(crate) mod categories;
35 - pub(crate) mod sessions;
35 + pub mod sessions;
36 36 pub(crate) mod totp;
37 37 pub(crate) mod passkeys;
38 38 pub(crate) mod health;
@@ -466,3 +466,100 @@ async fn report_same_target_deduped_while_open() {
466 466 .unwrap();
467 467 assert_eq!(count, 1, "duplicate open report must not be stored, found {count}");
468 468 }
469 +
470 + // ---------------------------------------------------------------------------
471 + // Dedup blocks open-stacking, not recurrence (test-fuzz Phase 2.3)
472 + //
473 + // report_same_target_deduped_while_open pins that a second OPEN report on a
474 + // target is rejected. The complement — once the first report is resolved, a
475 + // legitimate re-report of a recurrence must be allowed again — was untested. A
476 + // regression that keyed the dedup on (reporter, target) regardless of status
477 + // would silently swallow every follow-up report after the first resolution.
478 + // ---------------------------------------------------------------------------
479 +
480 + #[tokio::test]
481 + async fn report_allowed_again_after_prior_resolved() {
482 + let (mut h, _admin_id) = TestHarness::with_admin().await;
483 + let setup = h.create_creator_with_item("reagcreator", "text", 0).await;
484 + h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
485 +
486 + h.client.post_form("/logout", "").await;
487 + let reporter_id = h.signup("reagreporter", "reagreporter@test.com", "password123").await;
488 + h.login("reagreporter", "password123").await;
489 +
490 + let body = format!(
491 + "target_type=item&target_id={}&report_type=spam&reason=spam",
492 + setup.item_id
493 + );
494 + let first = h.client.post_form("/api/reports", &body).await;
495 + assert!(first.status.is_success(), "first report should land: {} {}", first.status, first.text);
496 +
497 + // Admin resolves the open report.
498 + let report_id: uuid::Uuid =
499 + sqlx::query_scalar("SELECT id FROM reports WHERE reporter_user_id = $1")
500 + .bind(reporter_id)
501 + .fetch_one(&h.db)
502 + .await
503 + .unwrap();
504 + h.client.post_form("/logout", "").await;
505 + h.login("admin", "password123").await;
506 + let resolve = h
507 + .client
508 + .post_form(
509 + &format!("/api/admin/reports/{}/resolve", report_id),
510 + "decision=resolve&admin_notes=handled",
511 + )
512 + .await;
513 + assert!(resolve.status.is_success(), "resolve failed: {} {}", resolve.status, resolve.text);
514 +
515 + // The reporter can file again — nothing OPEN stands against the target, so
516 + // the dedup guard does not block a fresh recurrence report.
517 + h.client.post_form("/logout", "").await;
518 + h.login("reagreporter", "password123").await;
519 + let second = h.client.post_form("/api/reports", &body).await;
520 + assert!(
521 + second.status.is_success(),
522 + "re-report after resolution must be allowed: {} {}",
523 + second.status, second.text
524 + );
525 +
526 + let count: i64 = sqlx::query_scalar(
527 + "SELECT COUNT(*) FROM reports WHERE reporter_user_id = $1 AND target_id = $2",
528 + )
529 + .bind(reporter_id)
530 + .bind(setup.item_id.parse::<uuid::Uuid>().unwrap())
531 + .fetch_one(&h.db)
532 + .await
533 + .unwrap();
534 + assert_eq!(count, 2, "a fresh report after resolution must be stored, found {count}");
535 + }
536 +
537 + // ---------------------------------------------------------------------------
538 + // Resolving a stale/nonexistent report id 404s (test-fuzz Phase 2.3)
539 + //
540 + // resolve_report surfaces a zero-row UPDATE as NotFound rather than a false
541 + // "resolved" — the guard against an admin silently acting on a report that was
542 + // already cleared away (or never existed). Only the happy path was tested.
543 + // ---------------------------------------------------------------------------
544 +
545 + #[tokio::test]
546 + async fn admin_resolve_nonexistent_report_is_404() {
547 + let (mut h, _admin_id) = TestHarness::with_admin().await;
548 + h.client.post_form("/logout", "").await;
549 + h.login("admin", "password123").await;
550 +
551 + let bogus = uuid::Uuid::new_v4();
552 + let resp = h
553 + .client
554 + .post_form(
555 + &format!("/api/admin/reports/{}/resolve", bogus),
556 + "decision=resolve&admin_notes=nope",
557 + )
558 + .await;
559 + assert_eq!(
560 + resp.status.as_u16(),
561 + 404,
562 + "resolving a nonexistent report must 404, got: {} {}",
563 + resp.status, resp.text
564 + );
565 + }
@@ -177,3 +177,37 @@ async fn revoke_other_sessions_preserves_current_and_ignores_other_users() {
177 177 "another user's session must NOT be touched by revoke-others"
178 178 );
179 179 }
180 +
181 + // ---------------------------------------------------------------------------
182 + // The revocation primitive the auth path sits on (test-fuzz Phase 2.3)
183 + //
184 + // Web auth re-checks the session every request via `touch_session` (behind a
185 + // short TTL cache), so once a session row is revoked the dangling cookie stops
186 + // authenticating as soon as the cache lapses. Driving that through HTTP is
187 + // cache-timing-dependent; this pins the primitive directly and deterministically:
188 + // a live session touches valid, a revoke deletes it (and a second revoke is an
189 + // idempotent no-op — the shape a concurrent double-revoke takes, where the loser
190 + // must not error), and a revoked session then touches invalid.
191 + // ---------------------------------------------------------------------------
192 +
193 + #[tokio::test]
194 + async fn touch_session_and_revoke_primitive_is_idempotent() {
195 + use makenotwork::db::sessions::{delete_user_session, touch_session};
196 + use makenotwork::db::UserSessionId;
197 +
198 + let mut h = TestHarness::new().await;
199 + let user_id = h.signup("sessprim", "sessprim@test.com", "password123").await;
200 + let sid = UserSessionId::from(session_id_for(&h, user_id).await);
201 +
202 + // A live session touches as valid.
203 + assert!(touch_session(&h.db, sid).await.unwrap().valid, "a live session must touch as valid");
204 +
205 + // First revoke removes the row; the second is an idempotent no-op (a
206 + // concurrent double-revoke must not error — the loser just deletes nothing).
207 + assert!(delete_user_session(&h.db, sid, user_id).await.unwrap(), "first revoke deletes the row");
208 + assert!(!delete_user_session(&h.db, sid, user_id).await.unwrap(), "second revoke is an idempotent no-op");
209 +
210 + // A revoked session touches as invalid — the gate that ends the cookie's life
211 + // once the auth touch cache lapses.
212 + assert!(!touch_session(&h.db, sid).await.unwrap().valid, "a revoked session must touch as invalid");
213 + }
@@ -857,3 +857,217 @@ async fn confirm_idempotent_reconfirm_does_not_double_charge() {
857 857 "idempotent re-confirm must not delete the live object"
858 858 );
859 859 }
860 +
861 + // ---------------------------------------------------------------------------
862 + // CAS guard pins (test-fuzz Phase 2.2)
863 + //
864 + // The Run #9 tx port sealed every confirm-upload write behind an
865 + // `IS NOT DISTINCT FROM expected_old` compare-and-swap so a confirm that lost a
866 + // concurrent race (or whose target row was deleted/transferred mid-flight)
867 + // matches zero rows and the surrounding tx rolls back — never double-crediting
868 + // storage and never clobbering the live object the winning confirm published.
869 + //
870 + // The handler-level rollback + orphan-queue wiring on that branch can only fire
871 + // under TRUE concurrency (a sequential request always observes its own read, so
872 + // its CAS always matches — see the comment at uploads.rs around the confirm tx).
873 + // What IS deterministic, and what these pin, is the CAS predicate itself: feed
874 + // the db function a stale `expected_old` and assert it (a) reports the lost-race
875 + // outcome and (b) leaves the row untouched. A regression that dropped the guard
876 + // would make the stale write land here.
877 + // ---------------------------------------------------------------------------
878 +
879 + #[tokio::test]
880 + async fn update_item_file_cas_guards_against_stale_confirm() {
881 + use makenotwork::db::items::{update_item_file_cas, FileConfirmOutcome};
882 + use makenotwork::db::{ItemId, UserId};
883 + use makenotwork::storage::FileType;
884 +
885 + let mut h = TestHarness::with_storage().await;
886 + let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h, 0).await;
887 + let item = ItemId::from(uuid::Uuid::parse_str(&item_id).unwrap());
888 + let owner = UserId::from(uuid::Uuid::parse_str(&user_id).unwrap());
889 +
890 + // audio_s3_key is NULL, so the first confirm (expected_old = None) wins.
891 + let r = update_item_file_cas(&h.db, item, owner, FileType::Audio, None, "key_v1", 1000)
892 + .await
893 + .unwrap();
894 + assert!(matches!(r, FileConfirmOutcome::Committed));
895 +
896 + // A second confirm that still observed the pre-state (None) loses: the row
897 + // now holds key_v1, so `IS NOT DISTINCT FROM NULL` matches zero rows.
898 + let r = update_item_file_cas(&h.db, item, owner, FileType::Audio, None, "key_v2", 2000)
899 + .await
900 + .unwrap();
901 + assert!(matches!(r, FileConfirmOutcome::LostRace), "stale-None confirm must lose the CAS race");
902 +
903 + // The loser left the row untouched — key_v1/1000, not key_v2 (no clobber, no double-credit).
904 + let (k, sz): (Option<String>, Option<i64>) = sqlx::query_as(
905 + "SELECT audio_s3_key, audio_file_size_bytes FROM items WHERE id = $1::uuid",
906 + )
907 + .bind(&item_id)
908 + .fetch_one(&h.db)
909 + .await
910 + .unwrap();
911 + assert_eq!(k.as_deref(), Some("key_v1"));
912 + assert_eq!(sz, Some(1000));
913 +
914 + // A correct CAS (expected_old = the current key) commits the swap.
915 + let r = update_item_file_cas(&h.db, item, owner, FileType::Audio, Some("key_v1"), "key_v3", 3000)
916 + .await
917 + .unwrap();
918 + assert!(matches!(r, FileConfirmOutcome::Committed));
919 +
920 + // The ownership filter is part of the same predicate: a non-owner never
921 + // matches, even with the correct expected_old.
922 + let stranger = UserId::new();
923 + let r = update_item_file_cas(&h.db, item, stranger, FileType::Audio, Some("key_v3"), "key_v4", 4000)
924 + .await
925 + .unwrap();
926 + assert!(matches!(r, FileConfirmOutcome::LostRace), "a non-owner must not write the item file");
927 + let k: Option<String> = sqlx::query_scalar("SELECT audio_s3_key FROM items WHERE id = $1::uuid")
928 + .bind(&item_id)
929 + .fetch_one(&h.db)
930 + .await
931 + .unwrap();
932 + assert_eq!(k.as_deref(), Some("key_v3"), "the non-owner write must not land");
933 + }
934 +
935 + #[tokio::test]
936 + async fn update_version_file_guards_against_stale_confirm() {
937 + use makenotwork::db::versions::update_version_file;
938 + use makenotwork::db::VersionId;
939 +
940 + let mut h = TestHarness::with_storage().await;
941 + let (_user, _proj, item_id) = setup_creator_with_item(&mut h, 0).await;
942 +
943 + let resp = h
944 + .client
945 + .post_json(
946 + &format!("/api/items/{}/versions", item_id),
947 + &json!({"version_number": "1.0.0"}).to_string(),
948 + )
949 + .await;
950 + assert!(resp.status.is_success(), "create version failed: {}", resp.text);
951 + let v: Value = resp.json();
952 + let version_id = v["id"].as_str().unwrap().to_string();
953 + let vid = VersionId::from(uuid::Uuid::parse_str(&version_id).unwrap());
954 +
955 + // s3_key is NULL initially. A confirm carrying a stale non-null expected key
956 + // matches zero rows.
957 + let r = update_version_file(&h.db, vid, Some("not_the_current_key"), "vk_v1", Some(1000), Some("a.zip"))
958 + .await
959 + .unwrap();
960 + assert!(r.is_none(), "a stale expected_old key must match zero rows");
961 + let k: Option<String> = sqlx::query_scalar("SELECT s3_key FROM versions WHERE id = $1::uuid")
962 + .bind(&version_id)
963 + .fetch_one(&h.db)
964 + .await
965 + .unwrap();
966 + assert!(k.is_none(), "the row must stay unwritten after a lost CAS");
967 +
968 + // Correct CAS (expected None) commits.
969 + let r = update_version_file(&h.db, vid, None, "vk_v1", Some(1000), Some("a.zip"))
970 + .await
971 + .unwrap();
972 + assert!(r.is_some());
973 +
974 + // A replace that still observed the pre-state (None) loses to the committed key.
975 + let r = update_version_file(&h.db, vid, None, "vk_v2", Some(2000), Some("b.zip"))
976 + .await
977 + .unwrap();
978 + assert!(r.is_none(), "stale-None replace must lose once the key is set");
979 + let (k, sz): (Option<String>, Option<i64>) = sqlx::query_as(
980 + "SELECT s3_key, file_size_bytes FROM versions WHERE id = $1::uuid",
981 + )
982 + .bind(&version_id)
983 + .fetch_one(&h.db)
984 + .await
985 + .unwrap();
986 + assert_eq!(k.as_deref(), Some("vk_v1"));
987 + assert_eq!(sz, Some(1000));
988 + }
989 +
990 + #[tokio::test]
991 + async fn update_project_cover_cas_guards_against_stale_and_non_owner() {
992 + use makenotwork::db::projects::update_project_cover_cas;
993 + use makenotwork::db::{ProjectId, UserId};
994 +
995 + let mut h = TestHarness::with_storage().await;
996 + let (user_id, project_id, _item) = setup_creator_with_item(&mut h, 0).await;
997 + let pid = ProjectId::from(uuid::Uuid::parse_str(&project_id).unwrap());
998 + let owner = UserId::from(uuid::Uuid::parse_str(&user_id).unwrap());
999 +
1000 + // cover_image_url is NULL. A stale expected url matches zero rows.
1001 + let ok = update_project_cover_cas(&h.db, pid, owner, Some("stale_url"), "url_v1", 1000)
1002 + .await
1003 + .unwrap();
1004 + assert!(!ok, "a stale expected url must match zero rows");
1005 +
1006 + // Correct CAS (expected None) commits.
1007 + let ok = update_project_cover_cas(&h.db, pid, owner, None, "url_v1", 1000).await.unwrap();
1008 + assert!(ok);
1009 +
1010 + // Stale-None loses now that the cover is set.
1011 + let ok = update_project_cover_cas(&h.db, pid, owner, None, "url_v2", 2000).await.unwrap();
1012 + assert!(!ok, "stale-None confirm must lose once the cover is set");
1013 +
1014 + // A non-owner cannot write, even with the correct expected url.
1015 + let stranger = UserId::new();
1016 + let ok = update_project_cover_cas(&h.db, pid, stranger, Some("url_v1"), "url_v3", 3000)
1017 + .await
1018 + .unwrap();
1019 + assert!(!ok, "a non-owner must not write the project cover");
1020 +
1021 + let (url, sz): (Option<String>, Option<i64>) = sqlx::query_as(
1022 + "SELECT cover_image_url, cover_image_size_bytes FROM projects WHERE id = $1::uuid",
1023 + )
1024 + .bind(&project_id)
1025 + .fetch_one(&h.db)
1026 + .await
1027 + .unwrap();
1028 + assert_eq!(url.as_deref(), Some("url_v1"));
1029 + assert_eq!(sz, Some(1000));
1030 + }
1031 +
1032 + #[tokio::test]
1033 + async fn update_item_cover_guards_against_stale_confirm() {
1034 + use makenotwork::db::items::update_item_cover;
1035 + use makenotwork::db::{ItemId, UserId};
1036 +
1037 + let mut h = TestHarness::with_storage().await;
1038 + let (user_id, _proj, item_id) = setup_creator_with_item(&mut h, 0).await;
1039 + let item = ItemId::from(uuid::Uuid::parse_str(&item_id).unwrap());
1040 + let owner = UserId::from(uuid::Uuid::parse_str(&user_id).unwrap());
1041 +
1042 + // cover_s3_key is NULL. A stale expected key matches zero rows.
1043 + let ok = update_item_cover(&h.db, item, owner, Some("stale_key"), "u1", "ck_v1", 1000)
1044 + .await
1045 + .unwrap();
1046 + assert!(!ok, "a stale expected key must match zero rows");
1047 +
1048 + // Correct CAS (expected None) commits the cover key + url + size together.
1049 + let ok = update_item_cover(&h.db, item, owner, None, "u1", "ck_v1", 1000).await.unwrap();
1050 + assert!(ok);
1051 +
1052 + // Stale-None loses now that the cover key is set.
1053 + let ok = update_item_cover(&h.db, item, owner, None, "u2", "ck_v2", 2000).await.unwrap();
1054 + assert!(!ok, "stale-None confirm must lose once the cover key is set");
1055 +
1056 + // A non-owner cannot write, even with the correct expected key.
1057 + let stranger = UserId::new();
1058 + let ok = update_item_cover(&h.db, item, stranger, Some("ck_v1"), "u3", "ck_v3", 3000)
1059 + .await
1060 + .unwrap();
1061 + assert!(!ok, "a non-owner must not write the item cover");
1062 +
1063 + let (key, url, sz): (Option<String>, Option<String>, Option<i64>) = sqlx::query_as(
1064 + "SELECT cover_s3_key, cover_image_url, cover_file_size_bytes FROM items WHERE id = $1::uuid",
1065 + )
1066 + .bind(&item_id)
1067 + .fetch_one(&h.db)
1068 + .await
1069 + .unwrap();
1070 + assert_eq!(key.as_deref(), Some("ck_v1"));
1071 + assert_eq!(url.as_deref(), Some("u1"));
1072 + assert_eq!(sz, Some(1000));
1073 + }