Skip to main content

max / makenotwork

13.6 KB · 354 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!(resp.text.contains(&format!(".user-canvas#uc-{}", setup.user_id)));
79 assert!(resp.text.contains("prefers-reduced-motion"));
80 assert!(!resp.text.contains("@import"));
81 }
82
83 #[tokio::test]
84 async fn project_page_scopes_css_and_shows_system_slots() {
85 let mut h = TestHarness::new().await;
86 let setup = h.create_creator_with_item("bob", "digital", 1299).await;
87 h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
88
89 // Give the project itself a buy-once price (the helper price went to the item).
90 sqlx::query("UPDATE projects SET pricing_model='buy_once', price_cents=1299 WHERE id=$1::uuid")
91 .bind(&setup.project_id)
92 .execute(&h.db)
93 .await
94 .unwrap();
95
96 set_project_custom(
97 &h,
98 &setup.project_id,
99 "<h2>My storefront</h2>",
100 ".mnw-buy { display: none } header { color: red }",
101 )
102 .await;
103
104 let resp = get_u(&mut h, "/bob/bob-proj").await;
105 assert_eq!(resp.status, 200, "body: {}", resp.text);
106
107 // Creator HTML + project-scoped CSS.
108 assert!(resp.text.contains("My storefront"));
109 assert!(resp.text.contains(&format!(".user-canvas#uc-{}", setup.project_id)));
110 // System slots present and the price rendered.
111 assert!(resp.text.contains("mnw-buy"));
112 assert!(resp.text.contains("$12.99"));
113 // The file list links the published item back to the apex.
114 assert!(resp.text.contains("Test Item"));
115 assert!(resp.text.contains("/i/"));
116 // The creator's attempt to hide the buy slot was stripped.
117 assert!(!resp.text.replace(' ', "").contains("display:none"));
118 }
119
120 #[tokio::test]
121 async fn item_inherits_project_css_rescoped_to_item_canvas() {
122 let mut h = TestHarness::new().await;
123 let setup = h.create_creator_with_item("carol", "digital", 0).await;
124 h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
125 // Use a length value (survives verbatim; lightningcss normalizes color names).
126 set_project_custom(&h, &setup.project_id, "<p>store</p>", ".hero { padding: 17px }").await;
127
128 let slug = item_slug(&h, &setup.item_id).await;
129 let resp = get_u(&mut h, &format!("/carol/carol-proj/{slug}")).await;
130 assert_eq!(resp.status, 200, "body: {}", resp.text);
131
132 // Item canvas keyed on the parent project id; project CSS re-scoped to it.
133 assert!(resp.text.contains(&format!("ic-{}", setup.project_id)));
134 assert!(resp.text.contains(&format!(".item-canvas#ic-{} .hero", setup.project_id)));
135 assert!(resp.text.contains("17px"));
136 // Item slot + default layout (no creator HTML on item pages).
137 assert!(resp.text.contains("mnw-item"));
138 assert!(resp.text.contains("Test Item"));
139 assert!(resp.text.contains("Free"));
140 }
141
142 #[tokio::test]
143 async fn unstyled_project_item_is_plain_default() {
144 let mut h = TestHarness::new().await;
145 let setup = h.create_creator_with_item("dave", "digital", 500).await;
146 h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
147 // No custom CSS set on the project.
148
149 let slug = item_slug(&h, &setup.item_id).await;
150 let resp = get_u(&mut h, &format!("/dave/dave-proj/{slug}")).await;
151 assert_eq!(resp.status, 200, "body: {}", resp.text);
152
153 // Default layout renders, but with no creator CSS (only the reduced-motion
154 // guard is conditional on there being CSS, so it's absent here).
155 assert!(resp.text.contains("mnw-item"));
156 assert!(!resp.text.contains(".item-canvas#ic-"));
157 }
158
159 #[tokio::test]
160 async fn locked_user_renders_chrome_only() {
161 let mut h = TestHarness::new().await;
162 let setup = h.create_creator_with_item("erin", "digital", 500).await;
163 set_user_custom(&h, setup.user_id, "<h1>SecretLayout</h1>", ".hero{color:red}").await;
164 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
165 .bind(setup.user_id)
166 .execute(&h.db)
167 .await
168 .unwrap();
169
170 let resp = get_u(&mut h, "/erin").await;
171 assert_eq!(resp.status, 200, "body: {}", resp.text);
172 // Chrome present, but the creator content is withheld while locked.
173 assert!(resp.text.contains("makenot.work"));
174 assert!(!resp.text.contains("SecretLayout"));
175 }
176
177 #[tokio::test]
178 async fn strict_csp_and_no_session_cookie() {
179 let mut h = TestHarness::new().await;
180 let setup = h.create_creator_with_item("frank", "digital", 500).await;
181 set_user_custom(&h, setup.user_id, "<p>hi</p>", "p{color:red}").await;
182
183 let resp = get_u(&mut h, "/frank").await;
184 assert_eq!(resp.status, 200);
185
186 let csp = resp
187 .headers
188 .get("content-security-policy")
189 .and_then(|v| v.to_str().ok())
190 .unwrap_or("");
191 assert!(csp.contains("default-src 'none'"), "csp: {csp}");
192 assert!(csp.contains("frame-ancestors 'none'"), "csp: {csp}");
193 // No script source is allowed at all.
194 assert!(!csp.contains("script-src"), "csp: {csp}");
195
196 // The user-pages host must never set a session cookie.
197 assert!(
198 resp.headers.get(axum::http::header::SET_COOKIE).is_none(),
199 "u. host leaked a Set-Cookie"
200 );
201 }
202
203 #[tokio::test]
204 async fn unknown_handle_is_404() {
205 let mut h = TestHarness::new().await;
206 let resp = get_u(&mut h, "/nobody-here").await;
207 assert_eq!(resp.status, 404);
208 }
209
210 #[tokio::test]
211 async fn bare_host_redirects_to_apex() {
212 let mut h = TestHarness::new().await;
213 let resp = get_u(&mut h, "/").await;
214 // Temporary redirect to the apex.
215 assert!(resp.status.is_redirection(), "status: {}", resp.status);
216 }
217
218 // ── Editor (apex, authenticated) ─────────────────────────────────────────────
219
220 fn form_body(pairs: &[(&str, &str)]) -> String {
221 url::form_urlencoded::Serializer::new(String::new())
222 .extend_pairs(pairs)
223 .finish()
224 }
225
226 #[tokio::test]
227 async fn editor_get_renders() {
228 let mut h = TestHarness::new().await;
229 h.create_creator("ed1").await;
230 let resp = h.client.get("/dashboard/custom-page").await;
231 assert_eq!(resp.status, 200, "body: {}", resp.text);
232 assert!(resp.text.contains("name=\"custom_html\""));
233 assert!(resp.text.contains("name=\"custom_css\""));
234 assert!(resp.text.contains("id=\"cp-preview\""));
235 assert!(resp.text.contains("/preview/"));
236 }
237
238 #[tokio::test]
239 async fn save_persists_and_renders_on_u_host() {
240 let mut h = TestHarness::new().await;
241 let uid = h.create_creator("ed2").await;
242 let body = form_body(&[("custom_html", "<h1>MyHero</h1>"), ("custom_css", ".intro { color: red }")]);
243 let resp = h.client.post_form("/dashboard/custom-page", &body).await;
244 assert!(resp.status.is_success(), "save status {}: {}", resp.status, resp.text);
245
246 let live = get_u(&mut h, "/ed2").await;
247 assert_eq!(live.status, 200, "body: {}", live.text);
248 assert!(live.text.contains("MyHero"));
249 assert!(live.text.contains(&format!(".user-canvas#uc-{uid}")));
250 }
251
252 #[tokio::test]
253 async fn autosave_surfaces_blocked_references() {
254 let mut h = TestHarness::new().await;
255 h.create_creator("ed3").await;
256 let body = form_body(&[
257 ("custom_html", "<img src=\"https://evil.com/x.png\" alt=\"x\">"),
258 ("custom_css", ".a { background: url(https://evil.com/y.png) }"),
259 ]);
260 let resp = h.client.post_form("/dashboard/custom-page/draft", &body).await;
261 assert!(resp.status.is_success(), "draft status {}: {}", resp.status, resp.text);
262 // The blocked-references panel lists the stripped off-platform refs...
263 assert!(resp.text.contains("evil.com"), "panel: {}", resp.text);
264 assert!(resp.text.contains("Off-platform link"));
265 // ...and the out-of-band iframe reloads the preview.
266 assert!(resp.text.contains("cp-preview"));
267 }
268
269 #[tokio::test]
270 async fn reset_clears_custom_page() {
271 let mut h = TestHarness::new().await;
272 h.create_creator("ed4").await;
273 let body = form_body(&[("custom_html", "<h1>GoneSoon</h1>"), ("custom_css", "")]);
274 h.client.post_form("/dashboard/custom-page", &body).await;
275 // It renders before reset.
276 assert!(get_u(&mut h, "/ed4").await.text.contains("GoneSoon"));
277
278 let resp = h.client.post_form("/dashboard/custom-page/reset", "").await;
279 assert!(resp.status.is_redirection() || resp.status.is_success(), "reset: {}", resp.status);
280
281 let live = get_u(&mut h, "/ed4").await;
282 assert!(!live.text.contains("GoneSoon"));
283 }
284
285 #[tokio::test]
286 async fn project_editor_rejects_non_owner() {
287 let mut h = TestHarness::new().await;
288 let _owner = h.create_creator_with_item("owna", "digital", 0).await;
289 // Switch to a different logged-in creator.
290 h.create_creator("intruder").await;
291 let resp = h.client.get("/dashboard/project/owna-proj/custom-page").await;
292 assert_eq!(resp.status, 404);
293 }
294
295 // ── Security-review checklist coverage ───────────────────────────────────────
296
297 #[tokio::test]
298 async fn custom_content_never_renders_on_the_apex_domain() {
299 // Isolation: custom HTML/CSS is served only from the u. host. The apex
300 // profile page must never include it (no sanitizer-bypass reaches a
301 // cookie-bearing origin).
302 let mut h = TestHarness::new().await;
303 h.create_creator("apexiso").await;
304 let body = form_body(&[("custom_html", "<h1>SecretOnlyOnU</h1>"), ("custom_css", ".x{color:red}")]);
305 h.client.post_form("/dashboard/custom-page", &body).await;
306
307 let apex = h.client.get("/u/apexiso").await;
308 assert_eq!(apex.status, 200, "body: {}", apex.text);
309 assert!(!apex.text.contains("SecretOnlyOnU"), "custom HTML leaked onto the apex profile");
310 }
311
312 #[tokio::test]
313 async fn locked_owner_project_and_item_render_default() {
314 let mut h = TestHarness::new().await;
315 let setup = h.create_creator_with_item("lockp", "digital", 0).await;
316 h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
317 set_project_custom(&h, &setup.project_id, "<p>StyledStore</p>", ".hero { padding: 17px }").await;
318
319 // Sanity: renders before the lock.
320 assert!(get_u(&mut h, "/lockp/lockp-proj").await.text.contains("StyledStore"));
321
322 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
323 .bind(setup.user_id)
324 .execute(&h.db)
325 .await
326 .unwrap();
327
328 let proj = get_u(&mut h, "/lockp/lockp-proj").await;
329 assert!(!proj.text.contains("StyledStore"), "locked project still shows custom HTML");
330
331 let slug = item_slug(&h, &setup.item_id).await;
332 let item = get_u(&mut h, &format!("/lockp/lockp-proj/{slug}")).await;
333 assert!(!item.text.contains("17px"), "locked item still wears project CSS");
334 }
335
336 #[tokio::test]
337 async fn locked_user_editor_save_is_blocked() {
338 let mut h = TestHarness::new().await;
339 let uid = h.create_creator("lockuser").await;
340 sqlx::query("UPDATE users SET custom_pages_locked = true WHERE id = $1")
341 .bind(uid)
342 .execute(&h.db)
343 .await
344 .unwrap();
345
346 let body = form_body(&[("custom_html", "<h1>ShouldNotSave</h1>"), ("custom_css", "")]);
347 let resp = h.client.post_form("/dashboard/custom-page", &body).await;
348 assert!(resp.text.to_lowercase().contains("locked"), "expected locked notice: {}", resp.text);
349
350 // Nothing was published.
351 let live = get_u(&mut h, "/lockuser").await;
352 assert!(!live.text.contains("ShouldNotSave"));
353 }
354