Skip to main content

max / makenotwork

6.7 KB · 214 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_eq!(
129 resp.status, 200,
130 "remove failed: {} {}",
131 resp.status, resp.text
132 );
133
134 let (removed, public): (bool, bool) =
135 sqlx::query_as("SELECT removed_by_admin, is_public FROM items WHERE id = $1::uuid")
136 .bind(&setup.item_id)
137 .fetch_one(&h.db)
138 .await
139 .unwrap();
140 assert!(removed, "item must be flagged removed_by_admin");
141 assert!(!public, "removed item must be hidden");
142
143 let resp = h
144 .client
145 .post_form(&format!("/api/admin/items/{}/restore", setup.item_id), "")
146 .await;
147 assert_eq!(
148 resp.status, 200,
149 "restore failed: {} {}",
150 resp.status, resp.text
151 );
152
153 let removed_after: bool =
154 sqlx::query_scalar("SELECT removed_by_admin FROM items WHERE id = $1::uuid")
155 .bind(&setup.item_id)
156 .fetch_one(&h.db)
157 .await
158 .unwrap();
159 assert!(!removed_after, "restore must clear removed_by_admin");
160 }
161
162 #[tokio::test]
163 async fn admin_decide_appeal_denied_records_decision() {
164 let (mut h, _admin) = TestHarness::with_admin().await;
165 let target = h
166 .signup("appellant", "appellant@test.com", "password123")
167 .await;
168
169 // Suspend the user and have them submit an appeal.
170 sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'spam' WHERE id = $1")
171 .bind(target)
172 .execute(&h.db)
173 .await
174 .unwrap();
175 db::users::submit_appeal(&h.db, target, "Please reconsider")
176 .await
177 .unwrap();
178
179 let pending = db::users::get_pending_appeals(&h.db).await.unwrap();
180 assert!(
181 pending.iter().any(|u| u.id == target),
182 "appeal must be pending before decision"
183 );
184
185 h.client.post_form("/logout", "").await;
186 h.login("admin", "password123").await;
187 let resp = h
188 .client
189 .post_form(
190 &format!("/api/admin/appeals/{target}/decide"),
191 "decision=denied&response=Upheld+after+review",
192 )
193 .await;
194 assert_eq!(
195 resp.status, 200,
196 "decide failed: {} {}",
197 resp.status, resp.text
198 );
199
200 // Decided appeals drop out of the pending queue; denial keeps the suspension.
201 let pending_after = db::users::get_pending_appeals(&h.db).await.unwrap();
202 assert!(
203 !pending_after.iter().any(|u| u.id == target),
204 "decided appeal must leave the queue"
205 );
206 let still_suspended: bool =
207 sqlx::query_scalar("SELECT suspended_at IS NOT NULL FROM users WHERE id = $1")
208 .bind(target)
209 .fetch_one(&h.db)
210 .await
211 .unwrap();
212 assert!(still_suspended, "a denied appeal must keep the suspension");
213 }
214