|
1 |
+ |
//! DB-layer contract tests for `db::items::bulk` — batch item operations.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Audit Run 16 flagged `bulk` as a Testing cold spot. Every bulk mutation is
|
|
4 |
+ |
//! ownership-scoped via `project_id IN (SELECT id FROM projects WHERE user_id =
|
|
5 |
+ |
//! $n)` — an IDOR seal that was only exercised through the HTTP surface. These
|
|
6 |
+ |
//! pin that seal at the DB layer (a non-owner's bulk op touches zero rows), plus
|
|
7 |
+ |
//! the soft-delete idempotency, the tag ON CONFLICT dedup, `move_item`'s
|
|
8 |
+ |
//! sort-order swap, and `duplicate_item`'s savepoint slug-collision retry (the
|
|
9 |
+ |
//! Phase 3 TOCTOU fix): two concurrent duplicates of one source get distinct
|
|
10 |
+ |
//! slugs and both succeed.
|
|
11 |
+ |
|
|
12 |
+ |
use crate::harness::db::TestDb;
|
|
13 |
+ |
use makenotwork::db::items;
|
|
14 |
+ |
use makenotwork::db::{ItemId, PriceCents, ProjectId, TagId, UserId};
|
|
15 |
+ |
|
|
16 |
+ |
async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId {
|
|
17 |
+ |
let hash = makenotwork::auth::hash_password("password123").expect("hash");
|
|
18 |
+ |
sqlx::query_scalar::<_, UserId>(
|
|
19 |
+ |
"INSERT INTO users (username, email, password_hash, email_verified)
|
|
20 |
+ |
VALUES ($1, $2, $3, true) RETURNING id",
|
|
21 |
+ |
)
|
|
22 |
+ |
.bind(username)
|
|
23 |
+ |
.bind(format!("{username}@test.com"))
|
|
24 |
+ |
.bind(&hash)
|
|
25 |
+ |
.fetch_one(pool)
|
|
26 |
+ |
.await
|
|
27 |
+ |
.expect("seed user")
|
|
28 |
+ |
}
|
|
29 |
+ |
|
|
30 |
+ |
async fn seed_project(pool: &sqlx::PgPool, user: UserId, slug: &str) -> ProjectId {
|
|
31 |
+ |
sqlx::query_scalar::<_, ProjectId>(
|
|
32 |
+ |
"INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id",
|
|
33 |
+ |
)
|
|
34 |
+ |
.bind(user)
|
|
35 |
+ |
.bind(slug)
|
|
36 |
+ |
.fetch_one(pool)
|
|
37 |
+ |
.await
|
|
38 |
+ |
.expect("seed project")
|
|
39 |
+ |
}
|
|
40 |
+ |
|
|
41 |
+ |
/// Seed an item with an explicit sort_order and a unique slug.
|
|
42 |
+ |
async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str, sort: i32) -> ItemId {
|
|
43 |
+ |
// Explicit is_public = false: the column defaults to true, but these tests
|
|
44 |
+ |
// assert publish/unpublish transitions, so items start unpublished.
|
|
45 |
+ |
sqlx::query_scalar::<_, ItemId>(
|
|
46 |
+ |
"INSERT INTO items (project_id, title, item_type, price_cents, slug, sort_order, is_public)
|
|
47 |
+ |
VALUES ($1, $2, 'digital', 1000, $3, $4, false) RETURNING id",
|
|
48 |
+ |
)
|
|
49 |
+ |
.bind(project)
|
|
50 |
+ |
.bind(format!("Item {slug}"))
|
|
51 |
+ |
.bind(slug)
|
|
52 |
+ |
.bind(sort)
|
|
53 |
+ |
.fetch_one(pool)
|
|
54 |
+ |
.await
|
|
55 |
+ |
.expect("seed item")
|
|
56 |
+ |
}
|
|
57 |
+ |
|
|
58 |
+ |
async fn seed_tag(pool: &sqlx::PgPool, slug: &str) -> TagId {
|
|
59 |
+ |
sqlx::query_scalar::<_, TagId>(
|
|
60 |
+ |
"INSERT INTO tags (name, slug, path) VALUES ($1, $2, $2) RETURNING id",
|
|
61 |
+ |
)
|
|
62 |
+ |
.bind(slug)
|
|
63 |
+ |
.bind(slug)
|
|
64 |
+ |
.fetch_one(pool)
|
|
65 |
+ |
.await
|
|
66 |
+ |
.expect("seed tag")
|
|
67 |
+ |
}
|
|
68 |
+ |
|
|
69 |
+ |
async fn is_public(pool: &sqlx::PgPool, item: ItemId) -> bool {
|
|
70 |
+ |
sqlx::query_scalar::<_, bool>("SELECT is_public FROM items WHERE id = $1")
|
|
71 |
+ |
.bind(item)
|
|
72 |
+ |
.fetch_one(pool)
|
|
73 |
+ |
.await
|
|
74 |
+ |
.unwrap()
|
|
75 |
+ |
}
|
|
76 |
+ |
|
|
77 |
+ |
async fn sort_order(pool: &sqlx::PgPool, item: ItemId) -> i32 {
|
|
78 |
+ |
sqlx::query_scalar::<_, i32>("SELECT sort_order FROM items WHERE id = $1")
|
|
79 |
+ |
.bind(item)
|
|
80 |
+ |
.fetch_one(pool)
|
|
81 |
+ |
.await
|
|
82 |
+ |
.unwrap()
|
|
83 |
+ |
}
|
|
84 |
+ |
|
|
85 |
+ |
async fn price_of(pool: &sqlx::PgPool, item: ItemId) -> i32 {
|
|
86 |
+ |
sqlx::query_scalar::<_, i32>("SELECT price_cents FROM items WHERE id = $1")
|
|
87 |
+ |
.bind(item)
|
|
88 |
+ |
.fetch_one(pool)
|
|
89 |
+ |
.await
|
|
90 |
+ |
.unwrap()
|
|
91 |
+ |
}
|
|
92 |
+ |
|
|
93 |
+ |
// ── ownership scoping (IDOR seal) ────────────────────────────────────────────
|
|
94 |
+ |
|
|
95 |
+ |
#[tokio::test]
|
|
96 |
+ |
async fn bulk_publish_affects_only_owned_items_and_clears_schedule() {
|
|
97 |
+ |
let db = TestDb::new().await;
|
|
98 |
+ |
let owner = seed_user(&db.pool, "blk_pub_owner").await;
|
|
99 |
+ |
let attacker = seed_user(&db.pool, "blk_pub_attacker").await;
|
|
100 |
+ |
let project = seed_project(&db.pool, owner, "blk-pub").await;
|
|
101 |
+ |
let a = seed_item(&db.pool, project, "pa", 0).await;
|
|
102 |
+ |
let b = seed_item(&db.pool, project, "pb", 1).await;
|
|
103 |
+ |
// Give one a scheduled publish_at to prove bulk_publish clears it.
|
|
104 |
+ |
sqlx::query("UPDATE items SET publish_at = NOW() + INTERVAL '1 day' WHERE id = $1")
|
|
105 |
+ |
.bind(a).execute(&db.pool).await.unwrap();
|
|
106 |
+ |
|
|
107 |
+ |
// A non-owner cannot publish the items — zero rows, nothing changes.
|
|
108 |
+ |
let hijack = items::bulk_publish(&db.pool, &[a, b], project, attacker).await.unwrap();
|
|
109 |
+ |
assert_eq!(hijack, 0, "a non-owner bulk_publish must touch no rows");
|
|
110 |
+ |
assert!(!is_public(&db.pool, a).await);
|
|
111 |
+ |
|
|
112 |
+ |
// The owner publishes both and the schedule is cleared.
|
|
113 |
+ |
let n = items::bulk_publish(&db.pool, &[a, b], project, owner).await.unwrap();
|
|
114 |
+ |
assert_eq!(n, 2);
|
|
115 |
+ |
assert!(is_public(&db.pool, a).await && is_public(&db.pool, b).await);
|
|
116 |
+ |
let sched: Option<chrono::DateTime<chrono::Utc>> =
|
|
117 |
+ |
sqlx::query_scalar("SELECT publish_at FROM items WHERE id = $1").bind(a).fetch_one(&db.pool).await.unwrap();
|
|
118 |
+ |
assert!(sched.is_none(), "bulk_publish clears any scheduled publish_at");
|
|
119 |
+ |
}
|
|
120 |
+ |
|
|
121 |
+ |
#[tokio::test]
|
|
122 |
+ |
async fn bulk_unpublish_is_owner_scoped() {
|
|
123 |
+ |
let db = TestDb::new().await;
|
|
124 |
+ |
let owner = seed_user(&db.pool, "blk_unpub_owner").await;
|
|
125 |
+ |
let attacker = seed_user(&db.pool, "blk_unpub_attacker").await;
|
|
126 |
+ |
let project = seed_project(&db.pool, owner, "blk-unpub").await;
|
|
127 |
+ |
let item = seed_item(&db.pool, project, "ua", 0).await;
|
|
128 |
+ |
items::bulk_publish(&db.pool, &[item], project, owner).await.unwrap();
|
|
129 |
+ |
|
|
130 |
+ |
assert_eq!(items::bulk_unpublish(&db.pool, &[item], project, attacker).await.unwrap(), 0);
|
|
131 |
+ |
assert!(is_public(&db.pool, item).await, "a non-owner cannot unpublish");
|
|
132 |
+ |
assert_eq!(items::bulk_unpublish(&db.pool, &[item], project, owner).await.unwrap(), 1);
|
|
133 |
+ |
assert!(!is_public(&db.pool, item).await);
|
|
134 |
+ |
}
|
|
135 |
+ |
|
|
136 |
+ |
#[tokio::test]
|
|
137 |
+ |
async fn bulk_delete_soft_deletes_once_and_is_owner_scoped() {
|
|
138 |
+ |
let db = TestDb::new().await;
|
|
139 |
+ |
let owner = seed_user(&db.pool, "blk_del_owner").await;
|
|
140 |
+ |
let attacker = seed_user(&db.pool, "blk_del_attacker").await;
|
|
141 |
+ |
let project = seed_project(&db.pool, owner, "blk-del").await;
|
|
142 |
+ |
let item = seed_item(&db.pool, project, "da", 0).await;
|
|
143 |
+ |
|
|
144 |
+ |
assert_eq!(items::bulk_delete(&db.pool, &[item], project, attacker).await.unwrap(), 0,
|
|
145 |
+ |
"a non-owner cannot delete");
|
|
146 |
+ |
|
|
147 |
+ |
// First delete soft-deletes; the second is a no-op (deleted_at IS NULL guard).
|
|
148 |
+ |
assert_eq!(items::bulk_delete(&db.pool, &[item], project, owner).await.unwrap(), 1);
|
|
149 |
+ |
assert_eq!(items::bulk_delete(&db.pool, &[item], project, owner).await.unwrap(), 0,
|
|
150 |
+ |
"re-deleting an already-deleted item is a no-op");
|
|
151 |
+ |
let (deleted, public): (bool, bool) =
|
|
152 |
+ |
sqlx::query_as("SELECT deleted_at IS NOT NULL, is_public FROM items WHERE id = $1")
|
|
153 |
+ |
.bind(item).fetch_one(&db.pool).await.unwrap();
|
|
154 |
+ |
assert!(deleted && !public, "soft-delete stamps deleted_at and unpublishes");
|
|
155 |
+ |
}
|
|
156 |
+ |
|
|
157 |
+ |
#[tokio::test]
|
|
158 |
+ |
async fn bulk_update_price_is_owner_scoped() {
|
|
159 |
+ |
let db = TestDb::new().await;
|
|
160 |
+ |
let owner = seed_user(&db.pool, "blk_price_owner").await;
|
|
161 |
+ |
let attacker = seed_user(&db.pool, "blk_price_attacker").await;
|
|
162 |
+ |
let project = seed_project(&db.pool, owner, "blk-price").await;
|
|
163 |
+ |
let item = seed_item(&db.pool, project, "pra", 0).await;
|
|
164 |
+ |
|
|
165 |
+ |
let new_price = PriceCents::new(4200).expect("valid price");
|
|
166 |
+ |
assert_eq!(items::bulk_update_price(&db.pool, &[item], project, attacker, new_price).await.unwrap(), 0);
|
|
167 |
+ |
assert_eq!(price_of(&db.pool, item).await, 1000, "a non-owner cannot reprice");
|
|
168 |
+ |
|
|
169 |
+ |
assert_eq!(items::bulk_update_price(&db.pool, &[item], project, owner, new_price).await.unwrap(), 1);
|
|
170 |
+ |
assert_eq!(price_of(&db.pool, item).await, 4200);
|
|
171 |
+ |
}
|
|
172 |
+ |
|
|
173 |
+ |
#[tokio::test]
|
|
174 |
+ |
async fn bulk_add_tag_dedups_and_is_owner_scoped() {
|
|
175 |
+ |
let db = TestDb::new().await;
|
|
176 |
+ |
let owner = seed_user(&db.pool, "blk_tag_owner").await;
|
|
177 |
+ |
let attacker = seed_user(&db.pool, "blk_tag_attacker").await;
|
|
178 |
+ |
let project = seed_project(&db.pool, owner, "blk-tag").await;
|
|
179 |
+ |
let a = seed_item(&db.pool, project, "ta", 0).await;
|
|
180 |
+ |
let b = seed_item(&db.pool, project, "tb", 1).await;
|
|
181 |
+ |
let tag = seed_tag(&db.pool, "genre").await;
|
|
182 |
+ |
|
|
183 |
+ |
assert_eq!(items::bulk_add_tag(&db.pool, &[a, b], project, attacker, tag).await.unwrap(), 0,
|
|
184 |
+ |
"a non-owner cannot tag");
|
|
185 |
+ |
|
|
186 |
+ |
// First tag both; a repeat is a no-op via ON CONFLICT (item_id, tag_id).
|
|
187 |
+ |
assert_eq!(items::bulk_add_tag(&db.pool, &[a, b], project, owner, tag).await.unwrap(), 2);
|
|
188 |
+ |
assert_eq!(items::bulk_add_tag(&db.pool, &[a, b], project, owner, tag).await.unwrap(), 0,
|
|
189 |
+ |
"re-tagging is idempotent");
|
|
190 |
+ |
}
|
|
191 |
+ |
|
|
192 |
+ |
// ── move_item reorder ────────────────────────────────────────────────────────
|
|
193 |
+ |
|
|
194 |
+ |
#[tokio::test]
|
|
195 |
+ |
async fn move_item_swaps_sort_order_within_the_project() {
|
|
196 |
+ |
let db = TestDb::new().await;
|
|
197 |
+ |
let owner = seed_user(&db.pool, "blk_move_owner").await;
|
|
198 |
+ |
let project = seed_project(&db.pool, owner, "blk-move").await;
|
|
199 |
+ |
let first = seed_item(&db.pool, project, "m0", 0).await;
|
|
200 |
+ |
let middle = seed_item(&db.pool, project, "m1", 1).await;
|
|
201 |
+ |
let last = seed_item(&db.pool, project, "m2", 2).await;
|
|
202 |
+ |
|
|
203 |
+ |
// Move the middle item up: it swaps with the first.
|
|
204 |
+ |
items::move_item(&db.pool, project, owner, middle, "up").await.unwrap();
|
|
205 |
+ |
assert_eq!(sort_order(&db.pool, middle).await, 0);
|
|
206 |
+ |
assert_eq!(sort_order(&db.pool, first).await, 1);
|
|
207 |
+ |
assert_eq!(sort_order(&db.pool, last).await, 2, "the untouched item keeps its slot");
|
|
208 |
+ |
|
|
209 |
+ |
// Moving the top item up again is a no-op (already first).
|
|
210 |
+ |
items::move_item(&db.pool, project, owner, middle, "up").await.unwrap();
|
|
211 |
+ |
assert_eq!(sort_order(&db.pool, middle).await, 0);
|
|
212 |
+ |
}
|
|
213 |
+ |
|
|
214 |
+ |
// ── duplicate_item — metadata copy + slug-collision retry ────────────────────
|
|
215 |
+ |
|
|
216 |
+ |
#[tokio::test]
|
|
217 |
+ |
async fn duplicate_item_makes_a_draft_copy_with_metadata() {
|
|
218 |
+ |
let db = TestDb::new().await;
|
|
219 |
+ |
let owner = seed_user(&db.pool, "blk_dup_owner").await;
|
|
220 |
+ |
let attacker = seed_user(&db.pool, "blk_dup_attacker").await;
|
|
221 |
+ |
let project = seed_project(&db.pool, owner, "blk-dup").await;
|
|
222 |
+ |
let source = seed_item(&db.pool, project, "orig", 0).await;
|
|
223 |
+ |
let tag = seed_tag(&db.pool, "dup-tag").await;
|
|
224 |
+ |
sqlx::query("INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)").bind(source).bind(tag).execute(&db.pool).await.unwrap();
|
|
225 |
+ |
sqlx::query("UPDATE items SET is_public = true WHERE id = $1").bind(source).execute(&db.pool).await.unwrap();
|
|
226 |
+ |
|
|
227 |
+ |
// A non-owner cannot duplicate (ownership verified via project subquery).
|
|
228 |
+ |
assert!(items::duplicate_item(&db.pool, source, attacker).await.is_err());
|
|
229 |
+ |
|
|
230 |
+ |
let copy = items::duplicate_item(&db.pool, source, owner).await.unwrap();
|
|
231 |
+ |
assert!(copy.title.starts_with("Copy of "), "the copy is titled 'Copy of ...'");
|
|
232 |
+ |
assert!(!copy.is_public, "the copy is a private draft regardless of the source");
|
|
233 |
+ |
let tag_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM item_tags WHERE item_id = $1")
|
|
234 |
+ |
.bind(copy.id).fetch_one(&db.pool).await.unwrap();
|
|
235 |
+ |
assert_eq!(tag_count, 1, "tags are carried onto the copy");
|
|
236 |
+ |
}
|
|
237 |
+ |
|
|
238 |
+ |
/// The Phase 3 slug-TOCTOU fix: two duplicates of the same source race for the
|
|
239 |
+ |
/// base slug. The savepoint retry makes the unique index the arbiter, so both
|
|
240 |
+ |
/// succeed with distinct slugs rather than one 500-ing on a raw 23505.
|
|
241 |
+ |
#[tokio::test]
|
|
242 |
+ |
async fn concurrent_duplicates_get_distinct_slugs() {
|
|
243 |
+ |
let db = TestDb::new().await;
|
|
244 |
+ |
let owner = seed_user(&db.pool, "blk_dupr_owner").await;
|
|
245 |
+ |
let project = seed_project(&db.pool, owner, "blk-dupr").await;
|
|
246 |
+ |
let source = seed_item(&db.pool, project, "racey", 0).await;
|
|
247 |
+ |
|
|
248 |
+ |
let p1 = db.pool.clone();
|
|
249 |
+ |
let p2 = db.pool.clone();
|
|
250 |
+ |
let (a, b) = tokio::join!(
|
|
251 |
+ |
tokio::spawn(async move { items::duplicate_item(&p1, source, owner).await }),
|
|
252 |
+ |
tokio::spawn(async move { items::duplicate_item(&p2, source, owner).await }),
|
|
253 |
+ |
);
|
|
254 |
+ |
let a = a.unwrap().expect("first duplicate succeeds");
|
|
255 |
+ |
let b = b.unwrap().expect("second duplicate succeeds under contention");
|
|
256 |
+ |
|
|
257 |
+ |
let slug_a: String = sqlx::query_scalar("SELECT slug FROM items WHERE id = $1").bind(a.id).fetch_one(&db.pool).await.unwrap();
|
|
258 |
+ |
let slug_b: String = sqlx::query_scalar("SELECT slug FROM items WHERE id = $1").bind(b.id).fetch_one(&db.pool).await.unwrap();
|
|
259 |
+ |
assert_ne!(slug_a, slug_b, "concurrent duplicates must land on distinct slugs");
|
|
260 |
+ |
assert_ne!(a.id, b.id);
|
|
261 |
+ |
}
|