Skip to main content

max / makenotwork

14.3 KB · 438 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!(
272 resp.status.is_success(),
273 "save status {}: {}",
274 resp.status,
275 resp.text
276 );
277
278 let live = get_u(&mut h, "/ed2").await;
279 assert_eq!(live.status, 200, "body: {}", live.text);
280 assert!(live.text.contains("MyHero"));
281 assert!(live.text.contains(&format!(".user-canvas#uc-{uid}")));
282 }
283
284 #[tokio::test]
285 async fn autosave_surfaces_blocked_references() {
286 let mut h = TestHarness::new().await;
287 h.create_creator("ed3").await;
288 let body = form_body(&[
289 (
290 "custom_html",
291 "<img src=\"https://evil.com/x.png\" alt=\"x\">",
292 ),
293 (
294 "custom_css",
295 ".a { background: url(https://evil.com/y.png) }",
296 ),
297 ]);
298 let resp = h
299 .client
300 .post_form("/dashboard/custom-page/draft", &body)
301 .await;
302 assert!(
303 resp.status.is_success(),
304 "draft status {}: {}",
305 resp.status,
306 resp.text
307 );
308 // The blocked-references panel lists the stripped off-platform refs...
309 assert!(resp.text.contains("evil.com"), "panel: {}", resp.text);
310 assert!(resp.text.contains("Off-platform link"));
311 // ...and the out-of-band iframe reloads the preview.
312 assert!(resp.text.contains("cp-preview"));
313 }
314
315 #[tokio::test]
316 async fn reset_clears_custom_page() {
317 let mut h = TestHarness::new().await;
318 h.create_creator("ed4").await;
319 let body = form_body(&[("custom_html", "<h1>GoneSoon</h1>"), ("custom_css", "")]);
320 h.client.post_form("/dashboard/custom-page", &body).await;
321 // It renders before reset.
322 assert!(get_u(&mut h, "/ed4").await.text.contains("GoneSoon"));
323
324 let resp = h.client.post_form("/dashboard/custom-page/reset", "").await;
325 assert!(
326 resp.status.is_redirection() || resp.status.is_success(),
327 "reset: {}",
328 resp.status
329 );
330
331 let live = get_u(&mut h, "/ed4").await;
332 assert!(!live.text.contains("GoneSoon"));
333 }
334
335 #[tokio::test]
336 async fn project_editor_rejects_non_owner() {
337 let mut h = TestHarness::new().await;
338 let _owner = h.create_creator_with_item("owna", "digital", 0).await;
339 // Switch to a different logged-in creator.
340 h.create_creator("intruder").await;
341 let resp = h
342 .client
343 .get("/dashboard/project/owna-proj/custom-page")
344 .await;
345 assert_eq!(resp.status, 404);
346 }
347
348 // ── Security-review checklist coverage ───────────────────────────────────────
349
350 #[tokio::test]
351 async fn custom_content_never_renders_on_the_apex_domain() {
352 // Isolation: custom HTML/CSS is served only from the u. host. The apex
353 // profile page must never include it (no sanitizer-bypass reaches a
354 // cookie-bearing origin).
355 let mut h = TestHarness::new().await;
356 h.create_creator("apexiso").await;
357 let body = form_body(&[
358 ("custom_html", "<h1>SecretOnlyOnU</h1>"),
359 ("custom_css", ".x{color:red}"),
360 ]);
361 h.client.post_form("/dashboard/custom-page", &body).await;
362
363 let apex = h.client.get("/u/apexiso").await;
364 assert_eq!(apex.status, 200, "body: {}", apex.text);
365 assert!(
366 !apex.text.contains("SecretOnlyOnU"),
367 "custom HTML leaked onto the apex profile"
368 );
369 }
370
371 #[tokio::test]
372 async fn locked_owner_project_and_item_render_default() {
373 let mut h = TestHarness::new().await;
374 let setup = h.create_creator_with_item("lockp", "digital", 0).await;
375 h.publish_project_and_item(&setup.project_id, &setup.item_id)
376 .await;
377 set_project_custom(
378 &h,
379 &setup.project_id,
380 "<p>StyledStore</p>",
381 ".hero { padding: 17px }",
382 )
383 .await;
384
385 // Sanity: renders before the lock.
386 assert!(
387 get_u(&mut h, "/lockp/lockp-proj")
388 .await
389 .text
390 .contains("StyledStore")
391 );
392
393 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
394 .bind(setup.user_id)
395 .execute(&h.db)
396 .await
397 .unwrap();
398
399 let proj = get_u(&mut h, "/lockp/lockp-proj").await;
400 assert!(
401 !proj.text.contains("StyledStore"),
402 "locked project still shows custom HTML"
403 );
404
405 let slug = item_slug(&h, &setup.item_id).await;
406 let item = get_u(&mut h, &format!("/lockp/lockp-proj/{slug}")).await;
407 assert!(
408 !item.text.contains("17px"),
409 "locked item still wears project CSS"
410 );
411 }
412
413 #[tokio::test]
414 async fn locked_user_editor_save_is_blocked() {
415 let mut h = TestHarness::new().await;
416 let uid = h.create_creator("lockuser").await;
417 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
418 .bind(uid)
419 .execute(&h.db)
420 .await
421 .unwrap();
422
423 let body = form_body(&[
424 ("custom_html", "<h1>ShouldNotSave</h1>"),
425 ("custom_css", ""),
426 ]);
427 let resp = h.client.post_form("/dashboard/custom-page", &body).await;
428 assert!(
429 resp.text.to_lowercase().contains("locked"),
430 "expected locked notice: {}",
431 resp.text
432 );
433
434 // Nothing was published.
435 let live = get_u(&mut h, "/lockuser").await;
436 assert!(!live.text.contains("ShouldNotSave"));
437 }
438