|
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 |
+ |
}
|