Skip to main content

max / makenotwork

server: fix quarantine purge project-cover gap + cover db/scanning (A5, B-Sto1) purge_cdn_image_rows_by_key cleared the item cover but silently omitted the projects cover, while its read-side counterpart set_cdn_image_scan_status_by_key (and promote_cdn_image_by_key) both touch projects. So a quarantined project cover stayed rendered from the CDN AND — now that covers live in the public bucket — its object stayed un-reapable behind the is_s3_key_live guard (the projects.cover_s3_key ref kept it "live"). Clear the projects cover too, in the same transaction, restoring purge/stamp/promote symmetry across all five CDN-image surfaces. Add db_scanning_layer integration tests (db/scanning.rs previously had zero direct coverage): purge de-references every surface incl. the project cover, purge NULLs cover columns without deleting the item/project row, stamp touches the same surfaces as purge (symmetry), and quarantined_s3_keys filters by verdict. db::scanning is now pub for the test crate (mirrors db::scan_jobs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 22:30 UTC
Commit: d070e8f4bd76cca7d5bd5670d0a51c5c27a7bb21
Parent: 7af46fb
4 files changed, +172 insertions, -10 deletions
@@ -37,7 +37,7 @@ pub(crate) mod totp;
37 37 pub mod passkeys; // pub so the integration test crate can exercise the layer directly
38 38 pub(crate) mod health;
39 39 pub(crate) mod monitor;
40 - pub(crate) mod scanning;
40 + pub mod scanning; // pub so the integration test crate can exercise the quarantine purge/stamp layer directly
41 41 pub mod scan_jobs; // pub so the integration test crate can exercise the reaper/heartbeat layer directly
42 42 pub(crate) mod scan_admin_actions;
43 43 pub(crate) mod content_insertions;
@@ -73,17 +73,23 @@ pub async fn purge_cdn_image_rows_by_key(db: &PgPool, s3_key: &str) -> Result<u6
73 73 ] {
74 74 removed += sqlx::query(sql).bind(s3_key).execute(&mut *tx).await?.rows_affected();
75 75 }
76 - // Item cover: NULL the columns in place, keeping the track. Matches on the
77 - // exact key in `cover_s3_key` (the cover_image_url suffix is cleared in the
78 - // same statement so neither reference keeps the object live).
79 - removed += sqlx::query(
76 + // Item + project covers: NULL the columns in place, keeping the row (a track
77 + // or a project must not be deleted along with its quarantined thumbnail).
78 + // Matches on the exact `cover_s3_key`; the cover_image_url suffix is cleared
79 + // in the same statement so neither reference keeps the object live. BOTH the
80 + // items and projects cover surfaces must be cleared — the read-side
81 + // counterpart `set_cdn_image_scan_status_by_key` stamps both, and
82 + // `promote_cdn_image_by_key` promotes both, so a purge that skipped projects
83 + // would leave a quarantined project cover rendered AND its (public-bucket)
84 + // object un-reapable behind the `is_s3_key_live` guard.
85 + for sql in [
80 86 "UPDATE items SET cover_s3_key = NULL, cover_image_url = NULL, \
81 87 cover_file_size_bytes = NULL, updated_at = NOW() WHERE cover_s3_key = $1",
82 - )
83 - .bind(s3_key)
84 - .execute(&mut *tx)
85 - .await?
86 - .rows_affected();
88 + "UPDATE projects SET cover_s3_key = NULL, cover_image_url = NULL, \
89 + updated_at = NOW() WHERE cover_s3_key = $1",
90 + ] {
91 + removed += sqlx::query(sql).bind(s3_key).execute(&mut *tx).await?.rows_affected();
92 + }
87 93 tx.commit().await?;
88 94 Ok(removed)
89 95 }
@@ -0,0 +1,155 @@
1 + //! DB-layer contract tests for `db/scanning.rs`: the quarantine purge, the
2 + //! read-side status stamp, and their symmetry across every CDN-served image
3 + //! surface.
4 + //!
5 + //! Audit A5 / B-Sto1: these functions had no direct coverage (only indirect
6 + //! exercise through the end-to-end scan worker), and the purge silently omitted
7 + //! the `projects` cover — so a quarantined project cover stayed rendered AND its
8 + //! (public-bucket) object stayed un-reapable behind the `is_s3_key_live` guard,
9 + //! while the symmetric stamp DID touch projects. These pin that the purge and
10 + //! the stamp cover the SAME five surfaces, and that `quarantined_s3_keys`
11 + //! filters by verdict.
12 +
13 + use crate::harness::TestHarness;
14 + use makenotwork::db::scanning;
15 + use makenotwork::db::FileScanStatus;
16 +
17 + /// Seed a project + item cover and one gallery image per parent, all pointing at
18 + /// distinct known keys. Returns the (project_id, item_id) as strings.
19 + async fn seed_cdn_image_surfaces(
20 + h: &mut TestHarness,
21 + ) -> (String, String) {
22 + let setup = h.create_creator_with_item("scanlayer", "digital", 0).await;
23 +
24 + // Item + project covers.
25 + sqlx::query("UPDATE items SET cover_s3_key = 'k/itemcover', cover_image_url = 'https://cdn/x/itemcover' WHERE id = $1::uuid")
26 + .bind(&setup.item_id)
27 + .execute(&h.db)
28 + .await
29 + .unwrap();
30 + sqlx::query("UPDATE projects SET cover_s3_key = 'k/projcover', cover_image_url = 'https://cdn/x/projcover' WHERE id = $1::uuid")
31 + .bind(&setup.project_id)
32 + .execute(&h.db)
33 + .await
34 + .unwrap();
35 +
36 + // Gallery images (item_images / project_images).
37 + sqlx::query(
38 + "INSERT INTO item_images (item_id, s3_key, image_url, alt, position, file_size_bytes) \
39 + VALUES ($1::uuid, 'k/itemimg', 'https://cdn/x/itemimg', '', 0, 10)",
40 + )
41 + .bind(&setup.item_id)
42 + .execute(&h.db)
43 + .await
44 + .unwrap();
45 + sqlx::query(
46 + "INSERT INTO project_images (project_id, s3_key, image_url, alt, position, file_size_bytes) \
47 + VALUES ($1::uuid, 'k/projimg', 'https://cdn/x/projimg', '', 0, 10)",
48 + )
49 + .bind(&setup.project_id)
50 + .execute(&h.db)
51 + .await
52 + .unwrap();
53 +
54 + (setup.project_id, setup.item_id)
55 + }
56 +
57 + /// Whether any live row still references `key` across the CDN image surfaces.
58 + async fn key_still_referenced(h: &TestHarness, key: &str) -> bool {
59 + let n: i64 = sqlx::query_scalar(
60 + "SELECT \
61 + (SELECT COUNT(*) FROM item_images WHERE s3_key = $1) \
62 + + (SELECT COUNT(*) FROM project_images WHERE s3_key = $1) \
63 + + (SELECT COUNT(*) FROM items WHERE cover_s3_key = $1) \
64 + + (SELECT COUNT(*) FROM projects WHERE cover_s3_key = $1)",
65 + )
66 + .bind(key)
67 + .fetch_one(&h.db)
68 + .await
69 + .unwrap();
70 + n > 0
71 + }
72 +
73 + #[tokio::test]
74 + async fn purge_clears_every_cdn_image_surface_including_project_cover() {
75 + let mut h = TestHarness::new().await;
76 + let _ = seed_cdn_image_surfaces(&mut h).await;
77 +
78 + // Each surface's key must be fully de-referenced by the purge — otherwise the
79 + // quarantined object stays rendered and un-reapable.
80 + for key in ["k/itemcover", "k/projcover", "k/itemimg", "k/projimg"] {
81 + assert!(key_still_referenced(&h, key).await, "precondition: {key} is referenced");
82 + let removed = scanning::purge_cdn_image_rows_by_key(&h.db, key).await.unwrap();
83 + assert_eq!(removed, 1, "purge of {key} should clear exactly one surface");
84 + assert!(!key_still_referenced(&h, key).await, "purge must fully de-reference {key}");
85 + }
86 + }
87 +
88 + #[tokio::test]
89 + async fn purge_keeps_the_item_and_project_rows() {
90 + let mut h = TestHarness::new().await;
91 + let (project_id, item_id) = seed_cdn_image_surfaces(&mut h).await;
92 +
93 + scanning::purge_cdn_image_rows_by_key(&h.db, "k/itemcover").await.unwrap();
94 + scanning::purge_cdn_image_rows_by_key(&h.db, "k/projcover").await.unwrap();
95 +
96 + // Purging a quarantined cover must NULL the columns, never delete the row.
97 + let item_exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM items WHERE id = $1::uuid AND cover_s3_key IS NULL)")
98 + .bind(&item_id)
99 + .fetch_one(&h.db)
100 + .await
101 + .unwrap();
102 + assert!(item_exists, "item row must survive cover purge with a NULL cover");
103 + let project_exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1::uuid AND cover_s3_key IS NULL)")
104 + .bind(&project_id)
105 + .fetch_one(&h.db)
106 + .await
107 + .unwrap();
108 + assert!(project_exists, "project row must survive cover purge with a NULL cover");
109 + }
110 +
111 + #[tokio::test]
112 + async fn stamp_and_purge_cover_the_same_surfaces() {
113 + let mut h = TestHarness::new().await;
114 + let _ = seed_cdn_image_surfaces(&mut h).await;
115 +
116 + // The read-side stamp must touch each surface the purge touches (symmetry):
117 + // one row affected per key, for all four.
118 + for key in ["k/itemcover", "k/projcover", "k/itemimg", "k/projimg"] {
119 + let stamped = scanning::set_cdn_image_scan_status_by_key(&h.db, key, FileScanStatus::Clean)
120 + .await
121 + .unwrap();
122 + assert_eq!(stamped, 1, "stamp of {key} should affect exactly one surface");
123 + }
124 + }
125 +
126 + #[tokio::test]
127 + async fn quarantined_s3_keys_filters_by_verdict() {
128 + let mut h = TestHarness::new().await;
129 + let _ = seed_cdn_image_surfaces(&mut h).await;
130 +
131 + // Two scan results: one quarantined, one clean.
132 + for (key, status) in [("k/itemcover", "quarantined"), ("k/itemimg", "clean")] {
133 + sqlx::query(
134 + "INSERT INTO file_scan_results (s3_key, scan_status, scan_layers) \
135 + VALUES ($1, $2, '[]'::jsonb)",
136 + )
137 + .bind(key)
138 + .bind(status)
139 + .execute(&h.db)
140 + .await
141 + .unwrap();
142 + }
143 +
144 + let quarantined = scanning::quarantined_s3_keys(
145 + &h.db,
146 + &["k/itemcover".to_string(), "k/itemimg".to_string(), "k/never-scanned".to_string()],
147 + )
148 + .await
149 + .unwrap();
150 +
151 + assert!(quarantined.contains("k/itemcover"), "quarantined key must be returned");
152 + assert!(!quarantined.contains("k/itemimg"), "clean key must not be returned");
153 + assert!(!quarantined.contains("k/never-scanned"), "unscanned key must not be returned");
154 + assert_eq!(quarantined.len(), 1);
155 + }
@@ -100,6 +100,7 @@ mod idempotency;
100 100 mod db_imports_layer;
101 101 mod db_items_layer;
102 102 mod db_scan_jobs_layer;
103 + mod db_scanning_layer;
103 104 mod db_payments_layer;
104 105 mod db_synckit_billing_layer;
105 106 mod db_transactions_layer;