Skip to main content

max / makenotwork

6.8 KB · 217 lines History Blame Raw
1 //! Security A+ (ultra-fuzz Run 7): owner-scoping (Sec-M2 defense in depth) on
2 //! the guard-dependent mutating helpers, plus coverage for the previously
3 //! untested admin moderation handlers.
4
5 use crate::harness::TestHarness;
6 use makenotwork::db;
7
8 // ── Owner-scoped mutations (Sec-M2): the SQL itself rejects cross-owner writes ──
9
10 async fn creator_with_project(h: &TestHarness, tag: &str) -> (db::UserId, db::ProjectId) {
11 let user: db::UserId = sqlx::query_scalar(
12 "INSERT INTO users (username, email, password_hash, email_verified) \
13 VALUES ($1, $2, 'x', true) RETURNING id",
14 )
15 .bind(format!("idor_{tag}"))
16 .bind(format!("idor_{tag}@test.com"))
17 .fetch_one(&h.db)
18 .await
19 .unwrap();
20 let project: db::ProjectId = sqlx::query_scalar(
21 "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id",
22 )
23 .bind(user)
24 .bind(format!("idorproj_{tag}"))
25 .fetch_one(&h.db)
26 .await
27 .unwrap();
28 (user, project)
29 }
30
31 #[tokio::test]
32 async fn delete_blog_post_is_owner_scoped() {
33 let h = TestHarness::new().await;
34 let (owner_a, _proj_a) = creator_with_project(&h, "blog_a").await;
35 let (owner_b, proj_b) = creator_with_project(&h, "blog_b").await;
36
37 let post_b: db::BlogPostId = sqlx::query_scalar(
38 "INSERT INTO blog_posts (project_id, author_id, title, slug) \
39 VALUES ($1, $2, 'B post', 'b-post') RETURNING id",
40 )
41 .bind(proj_b)
42 .bind(owner_b)
43 .fetch_one(&h.db)
44 .await
45 .unwrap();
46
47 // Owner A cannot delete owner B's post, the SQL scope matches no row.
48 let cross = db::blog_posts::delete_blog_post(&h.db, post_b, owner_a)
49 .await
50 .unwrap();
51 assert!(!cross, "a cross-owner delete must report no row deleted");
52 let still_there: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blog_posts WHERE id = $1")
53 .bind(post_b)
54 .fetch_one(&h.db)
55 .await
56 .unwrap();
57 assert_eq!(
58 still_there, 1,
59 "the post must survive a cross-owner delete attempt"
60 );
61
62 // The real owner deletes it.
63 let owned = db::blog_posts::delete_blog_post(&h.db, post_b, owner_b)
64 .await
65 .unwrap();
66 assert!(owned, "the owner's delete must succeed");
67 }
68
69 #[tokio::test]
70 async fn set_item_listed_is_owner_scoped() {
71 let h = TestHarness::new().await;
72 let (owner_a, _proj_a) = creator_with_project(&h, "item_a").await;
73 let (owner_b, proj_b) = creator_with_project(&h, "item_b").await;
74
75 let item_b: db::ItemId = sqlx::query_scalar(
76 "INSERT INTO items (project_id, title, item_type, price_cents, slug, listed) \
77 VALUES ($1, 'B item', 'digital', 0, 'b-item', true) RETURNING id",
78 )
79 .bind(proj_b)
80 .fetch_one(&h.db)
81 .await
82 .unwrap();
83
84 // Owner A cannot toggle owner B's item.
85 let cross = db::bundles::set_item_listed(&h.db, item_b, false, owner_a)
86 .await
87 .unwrap();
88 assert!(
89 !cross,
90 "a cross-owner listed-toggle must report no row updated"
91 );
92 let listed: bool = sqlx::query_scalar("SELECT listed FROM items WHERE id = $1")
93 .bind(item_b)
94 .fetch_one(&h.db)
95 .await
96 .unwrap();
97 assert!(
98 listed,
99 "the item's listed flag must be unchanged by a cross-owner attempt"
100 );
101
102 // The real owner can.
103 let owned = db::bundles::set_item_listed(&h.db, item_b, false, owner_b)
104 .await
105 .unwrap();
106 assert!(owned, "the owner's toggle must succeed");
107 }
108
109 // ── Admin moderation handlers (previously uncovered) ──
110
111 #[tokio::test]
112 async fn admin_remove_and_restore_item() {
113 let (mut h, _admin) = TestHarness::with_admin().await;
114 let setup = h.create_creator_with_item("modtarget", "text", 0).await;
115 h.publish_project_and_item(&setup.project_id, &setup.item_id)
116 .await;
117
118 h.client.post_form("/logout", "").await;
119 h.login("admin", "password123").await;
120
121 let resp = h
122 .client
123 .post_form(
124 &format!("/api/admin/items/{}/remove", setup.item_id),
125 "reason=Infringing+content",
126 )
127 .await;
128 assert!(
129 resp.status.is_success(),
130 "remove failed: {} {}",
131 resp.status,
132 resp.text
133 );
134
135 let (removed, public): (bool, bool) =
136 sqlx::query_as("SELECT removed_by_admin, is_public FROM items WHERE id = $1::uuid")
137 .bind(&setup.item_id)
138 .fetch_one(&h.db)
139 .await
140 .unwrap();
141 assert!(removed, "item must be flagged removed_by_admin");
142 assert!(!public, "removed item must be hidden");
143
144 let resp = h
145 .client
146 .post_form(&format!("/api/admin/items/{}/restore", setup.item_id), "")
147 .await;
148 assert!(
149 resp.status.is_success(),
150 "restore failed: {} {}",
151 resp.status,
152 resp.text
153 );
154
155 let removed_after: bool =
156 sqlx::query_scalar("SELECT removed_by_admin FROM items WHERE id = $1::uuid")
157 .bind(&setup.item_id)
158 .fetch_one(&h.db)
159 .await
160 .unwrap();
161 assert!(!removed_after, "restore must clear removed_by_admin");
162 }
163
164 #[tokio::test]
165 async fn admin_decide_appeal_denied_records_decision() {
166 let (mut h, _admin) = TestHarness::with_admin().await;
167 let target = h
168 .signup("appellant", "appellant@test.com", "password123")
169 .await;
170
171 // Suspend the user and have them submit an appeal.
172 sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'spam' WHERE id = $1")
173 .bind(target)
174 .execute(&h.db)
175 .await
176 .unwrap();
177 db::users::submit_appeal(&h.db, target, "Please reconsider")
178 .await
179 .unwrap();
180
181 let pending = db::users::get_pending_appeals(&h.db).await.unwrap();
182 assert!(
183 pending.iter().any(|u| u.id == target),
184 "appeal must be pending before decision"
185 );
186
187 h.client.post_form("/logout", "").await;
188 h.login("admin", "password123").await;
189 let resp = h
190 .client
191 .post_form(
192 &format!("/api/admin/appeals/{target}/decide"),
193 "decision=denied&response=Upheld+after+review",
194 )
195 .await;
196 assert!(
197 resp.status.is_success(),
198 "decide failed: {} {}",
199 resp.status,
200 resp.text
201 );
202
203 // Decided appeals drop out of the pending queue; denial keeps the suspension.
204 let pending_after = db::users::get_pending_appeals(&h.db).await.unwrap();
205 assert!(
206 !pending_after.iter().any(|u| u.id == target),
207 "decided appeal must leave the queue"
208 );
209 let still_suspended: bool =
210 sqlx::query_scalar("SELECT suspended_at IS NOT NULL FROM users WHERE id = $1")
211 .bind(target)
212 .fetch_one(&h.db)
213 .await
214 .unwrap();
215 assert!(still_suspended, "a denied appeal must keep the suspension");
216 }
217