Skip to main content

max / makenotwork

14.2 KB · 432 lines History Blame Raw
1 //! Integration tests for the user-pages host (`u.makenot.work`, `u.localhost`
2 //! in tests): custom-page rendering, sanitization on render, item-inherits-
3 //! project CSS, moderation lockout, and the strict, cookieless security posture.
4
5 use crate::harness::TestHarness;
6
7 const HOST: &str = "u.localhost";
8
9 /// GET a path on the user-pages host.
10 async fn get_u(h: &mut TestHarness, path: &str) -> crate::harness::client::TestResponse {
11 h.client
12 .request_with_headers("GET", path, None, &[("Host", HOST)])
13 .await
14 }
15
16 async fn set_user_custom(h: &TestHarness, user_id: makenotwork::db::UserId, html: &str, css: &str) {
17 sqlx::query(
18 "UPDATE users SET custom_html=$2, custom_css=$3, custom_pages_updated_at=now() WHERE id=$1",
19 )
20 .bind(user_id)
21 .bind(html)
22 .bind(css)
23 .execute(&h.db)
24 .await
25 .expect("set user custom");
26 }
27
28 async fn set_project_custom(h: &TestHarness, project_id: &str, html: &str, css: &str) {
29 sqlx::query(
30 "UPDATE projects SET custom_html=$2, custom_css=$3, custom_pages_updated_at=now() \
31 WHERE id=$1::uuid",
32 )
33 .bind(project_id)
34 .bind(html)
35 .bind(css)
36 .execute(&h.db)
37 .await
38 .expect("set project custom");
39 }
40
41 async fn item_slug(h: &TestHarness, item_id: &str) -> String {
42 let row: (String,) = sqlx::query_as("SELECT slug FROM items WHERE id = $1::uuid")
43 .bind(item_id)
44 .fetch_one(&h.db)
45 .await
46 .unwrap();
47 row.0
48 }
49
50 #[tokio::test]
51 async fn user_page_renders_sanitized_custom_html_and_css() {
52 let mut h = TestHarness::new().await;
53 let setup = h.create_creator_with_item("alice", "digital", 500).await;
54
55 set_user_custom(
56 &h,
57 setup.user_id,
58 "<h1>Welcome</h1><script>alert(1)</script><img src=\"https://evil.com/a.png\">",
59 "body { background: red } .hero { color: blue } \
60 .x { background: url(https://evil.com/y.png) } @import url(https://evil.com/z.css);",
61 )
62 .await;
63
64 let resp = get_u(&mut h, "/alice").await;
65 assert_eq!(resp.status, 200, "body: {}", resp.text);
66
67 // Platform chrome is present (outside the canvas).
68 assert!(resp.text.contains("makenot.work"));
69 // Canvas is scoped to the owner id.
70 assert!(resp.text.contains(&format!("uc-{}", setup.user_id)));
71 // Allowed content survives.
72 assert!(resp.text.contains("Welcome"));
73 // Script + off-platform references are gone.
74 assert!(!resp.text.contains("alert(1)"));
75 assert!(!resp.text.contains("<script"));
76 assert!(!resp.text.contains("evil.com"));
77 // CSS got scoped and the reduced-motion guard injected.
78 assert!(
79 resp.text
80 .contains(&format!(".user-canvas#uc-{}", setup.user_id))
81 );
82 assert!(resp.text.contains("prefers-reduced-motion"));
83 assert!(!resp.text.contains("@import"));
84 }
85
86 #[tokio::test]
87 async fn project_page_scopes_css_and_shows_system_slots() {
88 let mut h = TestHarness::new().await;
89 let setup = h.create_creator_with_item("bob", "digital", 1299).await;
90 h.publish_project_and_item(&setup.project_id, &setup.item_id)
91 .await;
92
93 // Give the project itself a buy-once price (the helper price went to the item).
94 sqlx::query("UPDATE projects SET pricing_model='buy_once', price_cents=1299 WHERE id=$1::uuid")
95 .bind(&setup.project_id)
96 .execute(&h.db)
97 .await
98 .unwrap();
99
100 set_project_custom(
101 &h,
102 &setup.project_id,
103 "<h2>My storefront</h2>",
104 ".mnw-buy { display: none } header { color: red }",
105 )
106 .await;
107
108 let resp = get_u(&mut h, "/bob/bob-proj").await;
109 assert_eq!(resp.status, 200, "body: {}", resp.text);
110
111 // Creator HTML + project-scoped CSS.
112 assert!(resp.text.contains("My storefront"));
113 assert!(
114 resp.text
115 .contains(&format!(".user-canvas#uc-{}", setup.project_id))
116 );
117 // System slots present and the price rendered.
118 assert!(resp.text.contains("mnw-buy"));
119 assert!(resp.text.contains("$12.99"));
120 // The file list links the published item back to the apex.
121 assert!(resp.text.contains("Test Item"));
122 assert!(resp.text.contains("/i/"));
123 // The creator's attempt to hide the buy slot was stripped.
124 assert!(!resp.text.replace(' ', "").contains("display:none"));
125 }
126
127 #[tokio::test]
128 async fn item_inherits_project_css_rescoped_to_item_canvas() {
129 let mut h = TestHarness::new().await;
130 let setup = h.create_creator_with_item("carol", "digital", 0).await;
131 h.publish_project_and_item(&setup.project_id, &setup.item_id)
132 .await;
133 // Use a length value (survives verbatim; lightningcss normalizes color names).
134 set_project_custom(
135 &h,
136 &setup.project_id,
137 "<p>store</p>",
138 ".hero { padding: 17px }",
139 )
140 .await;
141
142 let slug = item_slug(&h, &setup.item_id).await;
143 let resp = get_u(&mut h, &format!("/carol/carol-proj/{slug}")).await;
144 assert_eq!(resp.status, 200, "body: {}", resp.text);
145
146 // Item canvas keyed on the parent project id; project CSS re-scoped to it.
147 assert!(resp.text.contains(&format!("ic-{}", setup.project_id)));
148 assert!(
149 resp.text
150 .contains(&format!(".item-canvas#ic-{} .hero", setup.project_id))
151 );
152 assert!(resp.text.contains("17px"));
153 // Item slot + default layout (no creator HTML on item pages).
154 assert!(resp.text.contains("mnw-item"));
155 assert!(resp.text.contains("Test Item"));
156 assert!(resp.text.contains("Free"));
157 }
158
159 #[tokio::test]
160 async fn unstyled_project_item_is_plain_default() {
161 let mut h = TestHarness::new().await;
162 let setup = h.create_creator_with_item("dave", "digital", 500).await;
163 h.publish_project_and_item(&setup.project_id, &setup.item_id)
164 .await;
165 // No custom CSS set on the project.
166
167 let slug = item_slug(&h, &setup.item_id).await;
168 let resp = get_u(&mut h, &format!("/dave/dave-proj/{slug}")).await;
169 assert_eq!(resp.status, 200, "body: {}", resp.text);
170
171 // Default layout renders, but with no creator CSS (only the reduced-motion
172 // guard is conditional on there being CSS, so it's absent here).
173 assert!(resp.text.contains("mnw-item"));
174 assert!(!resp.text.contains(".item-canvas#ic-"));
175 }
176
177 #[tokio::test]
178 async fn locked_user_renders_chrome_only() {
179 let mut h = TestHarness::new().await;
180 let setup = h.create_creator_with_item("erin", "digital", 500).await;
181 set_user_custom(
182 &h,
183 setup.user_id,
184 "<h1>SecretLayout</h1>",
185 ".hero{color:red}",
186 )
187 .await;
188 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
189 .bind(setup.user_id)
190 .execute(&h.db)
191 .await
192 .unwrap();
193
194 let resp = get_u(&mut h, "/erin").await;
195 assert_eq!(resp.status, 200, "body: {}", resp.text);
196 // Chrome present, but the creator content is withheld while locked.
197 assert!(resp.text.contains("makenot.work"));
198 assert!(!resp.text.contains("SecretLayout"));
199 }
200
201 #[tokio::test]
202 async fn strict_csp_and_no_session_cookie() {
203 let mut h = TestHarness::new().await;
204 let setup = h.create_creator_with_item("frank", "digital", 500).await;
205 set_user_custom(&h, setup.user_id, "<p>hi</p>", "p{color:red}").await;
206
207 let resp = get_u(&mut h, "/frank").await;
208 assert_eq!(resp.status, 200);
209
210 let csp = resp
211 .headers
212 .get("content-security-policy")
213 .and_then(|v| v.to_str().ok())
214 .unwrap_or("");
215 assert!(csp.contains("default-src 'none'"), "csp: {csp}");
216 assert!(csp.contains("frame-ancestors 'none'"), "csp: {csp}");
217 // No script source is allowed at all.
218 assert!(!csp.contains("script-src"), "csp: {csp}");
219
220 // The user-pages host must never set a session cookie.
221 assert!(
222 resp.headers.get(axum::http::header::SET_COOKIE).is_none(),
223 "u. host leaked a Set-Cookie"
224 );
225 }
226
227 #[tokio::test]
228 async fn unknown_handle_is_404() {
229 let mut h = TestHarness::new().await;
230 let resp = get_u(&mut h, "/nobody-here").await;
231 assert_eq!(resp.status, 404);
232 }
233
234 #[tokio::test]
235 async fn bare_host_redirects_to_apex() {
236 let mut h = TestHarness::new().await;
237 let resp = get_u(&mut h, "/").await;
238 // Temporary redirect to the apex.
239 assert!(resp.status.is_redirection(), "status: {}", resp.status);
240 }
241
242 // ── Editor (apex, authenticated) ─────────────────────────────────────────────
243
244 fn form_body(pairs: &[(&str, &str)]) -> String {
245 url::form_urlencoded::Serializer::new(String::new())
246 .extend_pairs(pairs)
247 .finish()
248 }
249
250 #[tokio::test]
251 async fn editor_get_renders() {
252 let mut h = TestHarness::new().await;
253 h.create_creator("ed1").await;
254 let resp = h.client.get("/dashboard/custom-page").await;
255 assert_eq!(resp.status, 200, "body: {}", resp.text);
256 assert!(resp.text.contains("name=\"custom_html\""));
257 assert!(resp.text.contains("name=\"custom_css\""));
258 assert!(resp.text.contains("id=\"cp-preview\""));
259 assert!(resp.text.contains("/preview/"));
260 }
261
262 #[tokio::test]
263 async fn save_persists_and_renders_on_u_host() {
264 let mut h = TestHarness::new().await;
265 let uid = h.create_creator("ed2").await;
266 let body = form_body(&[
267 ("custom_html", "<h1>MyHero</h1>"),
268 ("custom_css", ".intro { color: red }"),
269 ]);
270 let resp = h.client.post_form("/dashboard/custom-page", &body).await;
271 assert_eq!(
272 resp.status, 200,
273 "save status {}: {}",
274 resp.status, resp.text
275 );
276
277 let live = get_u(&mut h, "/ed2").await;
278 assert_eq!(live.status, 200, "body: {}", live.text);
279 assert!(live.text.contains("MyHero"));
280 assert!(live.text.contains(&format!(".user-canvas#uc-{uid}")));
281 }
282
283 #[tokio::test]
284 async fn autosave_surfaces_blocked_references() {
285 let mut h = TestHarness::new().await;
286 h.create_creator("ed3").await;
287 let body = form_body(&[
288 (
289 "custom_html",
290 "<img src=\"https://evil.com/x.png\" alt=\"x\">",
291 ),
292 (
293 "custom_css",
294 ".a { background: url(https://evil.com/y.png) }",
295 ),
296 ]);
297 let resp = h
298 .client
299 .post_form("/dashboard/custom-page/draft", &body)
300 .await;
301 assert_eq!(
302 resp.status, 200,
303 "draft status {}: {}",
304 resp.status, resp.text
305 );
306 // The blocked-references panel lists the stripped off-platform refs...
307 assert!(resp.text.contains("evil.com"), "panel: {}", resp.text);
308 assert!(resp.text.contains("Off-platform link"));
309 // ...and the out-of-band iframe reloads the preview.
310 assert!(resp.text.contains("cp-preview"));
311 }
312
313 #[tokio::test]
314 async fn reset_clears_custom_page() {
315 let mut h = TestHarness::new().await;
316 h.create_creator("ed4").await;
317 let body = form_body(&[("custom_html", "<h1>GoneSoon</h1>"), ("custom_css", "")]);
318 h.client.post_form("/dashboard/custom-page", &body).await;
319 // It renders before reset.
320 assert!(get_u(&mut h, "/ed4").await.text.contains("GoneSoon"));
321
322 let resp = h.client.post_form("/dashboard/custom-page/reset", "").await;
323 assert_eq!(resp.status, 303, "reset: {}", resp.status);
324
325 let live = get_u(&mut h, "/ed4").await;
326 assert!(!live.text.contains("GoneSoon"));
327 }
328
329 #[tokio::test]
330 async fn project_editor_rejects_non_owner() {
331 let mut h = TestHarness::new().await;
332 let _owner = h.create_creator_with_item("owna", "digital", 0).await;
333 // Switch to a different logged-in creator.
334 h.create_creator("intruder").await;
335 let resp = h
336 .client
337 .get("/dashboard/project/owna-proj/custom-page")
338 .await;
339 assert_eq!(resp.status, 404);
340 }
341
342 // ── Security-review checklist coverage ───────────────────────────────────────
343
344 #[tokio::test]
345 async fn custom_content_never_renders_on_the_apex_domain() {
346 // Isolation: custom HTML/CSS is served only from the u. host. The apex
347 // profile page must never include it (no sanitizer-bypass reaches a
348 // cookie-bearing origin).
349 let mut h = TestHarness::new().await;
350 h.create_creator("apexiso").await;
351 let body = form_body(&[
352 ("custom_html", "<h1>SecretOnlyOnU</h1>"),
353 ("custom_css", ".x{color:red}"),
354 ]);
355 h.client.post_form("/dashboard/custom-page", &body).await;
356
357 let apex = h.client.get("/u/apexiso").await;
358 assert_eq!(apex.status, 200, "body: {}", apex.text);
359 assert!(
360 !apex.text.contains("SecretOnlyOnU"),
361 "custom HTML leaked onto the apex profile"
362 );
363 }
364
365 #[tokio::test]
366 async fn locked_owner_project_and_item_render_default() {
367 let mut h = TestHarness::new().await;
368 let setup = h.create_creator_with_item("lockp", "digital", 0).await;
369 h.publish_project_and_item(&setup.project_id, &setup.item_id)
370 .await;
371 set_project_custom(
372 &h,
373 &setup.project_id,
374 "<p>StyledStore</p>",
375 ".hero { padding: 17px }",
376 )
377 .await;
378
379 // Sanity: renders before the lock.
380 assert!(
381 get_u(&mut h, "/lockp/lockp-proj")
382 .await
383 .text
384 .contains("StyledStore")
385 );
386
387 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
388 .bind(setup.user_id)
389 .execute(&h.db)
390 .await
391 .unwrap();
392
393 let proj = get_u(&mut h, "/lockp/lockp-proj").await;
394 assert!(
395 !proj.text.contains("StyledStore"),
396 "locked project still shows custom HTML"
397 );
398
399 let slug = item_slug(&h, &setup.item_id).await;
400 let item = get_u(&mut h, &format!("/lockp/lockp-proj/{slug}")).await;
401 assert!(
402 !item.text.contains("17px"),
403 "locked item still wears project CSS"
404 );
405 }
406
407 #[tokio::test]
408 async fn locked_user_editor_save_is_blocked() {
409 let mut h = TestHarness::new().await;
410 let uid = h.create_creator("lockuser").await;
411 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
412 .bind(uid)
413 .execute(&h.db)
414 .await
415 .unwrap();
416
417 let body = form_body(&[
418 ("custom_html", "<h1>ShouldNotSave</h1>"),
419 ("custom_css", ""),
420 ]);
421 let resp = h.client.post_form("/dashboard/custom-page", &body).await;
422 assert!(
423 resp.text.to_lowercase().contains("locked"),
424 "expected locked notice: {}",
425 resp.text
426 );
427
428 // Nothing was published.
429 let live = get_u(&mut h, "/lockuser").await;
430 assert!(!live.text.contains("ShouldNotSave"));
431 }
432