Skip to main content

max / makenotwork

10.7 KB · 395 lines History Blame Raw
1 //! Custom domain CRUD, caddy-ask, fallback routing, and item slug tests.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 #[tokio::test]
7 async fn add_custom_domain() {
8 let mut h = TestHarness::new().await;
9 let _uid = h.create_creator("domuser").await;
10
11 let resp = h
12 .client
13 .post_form("/api/domains", "domain=mysite.example.com")
14 .await;
15 assert!(
16 resp.status.is_success(),
17 "Add domain failed: {} {}",
18 resp.status,
19 resp.text
20 );
21
22 // Handler returns HTML with DNS verification instructions
23 assert!(
24 resp.text.contains("_mnw-verify.mysite.example.com"),
25 "Response should contain DNS verification instructions: {}",
26 resp.text
27 );
28 }
29
30 #[tokio::test]
31 async fn add_domain_rejects_duplicate() {
32 let mut h = TestHarness::new().await;
33 let _uid = h.create_creator("domdup1").await;
34
35 let resp = h
36 .client
37 .post_form("/api/domains", "domain=dup.example.com")
38 .await;
39 assert!(resp.status.is_success());
40
41 // Second user tries the same domain
42 h.client.post_form("/logout", "").await;
43 let _uid2 = h.create_creator("domdup2").await;
44
45 let resp = h
46 .client
47 .post_form("/api/domains", "domain=dup.example.com")
48 .await;
49 assert!(
50 !resp.status.is_success(),
51 "Duplicate domain should fail: {} {}",
52 resp.status,
53 resp.text
54 );
55 }
56
57 #[tokio::test]
58 async fn add_domain_one_per_user_limit() {
59 let mut h = TestHarness::new().await;
60 let _uid = h.create_creator("domlimit").await;
61
62 let resp = h
63 .client
64 .post_form("/api/domains", "domain=first.example.com")
65 .await;
66 assert!(resp.status.is_success());
67
68 // Second domain should fail
69 let resp = h
70 .client
71 .post_form("/api/domains", "domain=second.example.com")
72 .await;
73 assert!(
74 !resp.status.is_success(),
75 "Second domain should be rejected: {} {}",
76 resp.status,
77 resp.text
78 );
79 }
80
81 #[tokio::test]
82 async fn get_domain_returns_null_when_none() {
83 let mut h = TestHarness::new().await;
84 let _uid = h.create_creator("domget").await;
85
86 let resp = h.client.get("/api/domains").await;
87 assert!(resp.status.is_success());
88 let body: Value = resp.json();
89 assert!(body.is_null(), "Expected null when no domain, got: {body}");
90 }
91
92 #[tokio::test]
93 async fn get_domain_returns_domain() {
94 let mut h = TestHarness::new().await;
95 let _uid = h.create_creator("domgetok").await;
96
97 h.client
98 .post_form("/api/domains", "domain=getme.example.com")
99 .await;
100
101 let resp = h.client.get("/api/domains").await;
102 assert!(resp.status.is_success());
103 let body: Value = resp.json();
104 assert_eq!(body["domain"].as_str().unwrap(), "getme.example.com");
105 }
106
107 #[tokio::test]
108 async fn remove_domain() {
109 let mut h = TestHarness::new().await;
110 let _uid = h.create_creator("domrm").await;
111
112 let resp = h
113 .client
114 .post_form("/api/domains", "domain=remove.example.com")
115 .await;
116 assert!(resp.status.is_success());
117
118 // GET the domain to retrieve its id (GET returns JSON)
119 let resp = h.client.get("/api/domains").await;
120 let body: Value = resp.json();
121 let id = body["id"].as_str().unwrap();
122
123 let resp = h.client.delete(&format!("/api/domains/{id}")).await;
124 assert_eq!(resp.status, 204);
125
126 // Verify it's gone
127 let resp = h.client.get("/api/domains").await;
128 let body: Value = resp.json();
129 assert!(body.is_null());
130 }
131
132 #[tokio::test]
133 async fn caddy_ask_unknown_domain_returns_404() {
134 let mut h = TestHarness::new().await;
135
136 let resp = h
137 .client
138 .get("/api/domains/caddy-ask?domain=unknown.example.com")
139 .await;
140 assert_eq!(resp.status, 404);
141 }
142
143 #[tokio::test]
144 async fn caddy_ask_verified_domain_returns_200() {
145 let mut h = TestHarness::new().await;
146 let uid = h.create_creator("domcaddy").await;
147
148 // Insert a verified domain directly via SQL
149 sqlx::query(
150 "INSERT INTO custom_domains (user_id, domain, verified, verification_token, verified_at)
151 VALUES ($1, 'caddy.example.com', true, 'tok', NOW())",
152 )
153 .bind(uid)
154 .execute(&h.db)
155 .await
156 .unwrap();
157
158 // Also insert into domain cache (simulating startup warm)
159 // We can't access the cache directly, but the caddy-ask endpoint has a DB fallback
160 let resp = h
161 .client
162 .get("/api/domains/caddy-ask?domain=caddy.example.com")
163 .await;
164 assert_eq!(resp.status, 200);
165 }
166
167 #[tokio::test]
168 async fn custom_domain_fallback_user_profile() {
169 let mut h = TestHarness::new().await;
170 let uid = h.create_creator("domprofile").await;
171
172 // Insert verified domain directly
173 sqlx::query(
174 "INSERT INTO custom_domains (user_id, domain, verified, verification_token, verified_at)
175 VALUES ($1, 'profile.example.com', true, 'tok', NOW())",
176 )
177 .bind(uid)
178 .execute(&h.db)
179 .await
180 .unwrap();
181
182 // Insert into domain cache via caddy-ask (triggers DB fallback + cache populate)
183 h.client
184 .get("/api/domains/caddy-ask?domain=profile.example.com")
185 .await;
186
187 // Request the root path with the custom Host header
188 let resp = h
189 .client
190 .request_with_headers("GET", "/", None, &[("Host", "profile.example.com")])
191 .await;
192 assert!(
193 resp.status.is_success(),
194 "Profile fallback failed: {} {}",
195 resp.status,
196 resp.text
197 );
198 assert!(
199 resp.text.contains("domprofile"),
200 "Profile page should contain username"
201 );
202 }
203
204 #[tokio::test]
205 async fn custom_domain_fallback_project() {
206 let mut h = TestHarness::new().await;
207 let uid = h.create_creator("domproj").await;
208
209 // Create a project
210 let resp = h
211 .client
212 .post_form("/api/projects", "slug=test-project&title=Test+Project")
213 .await;
214 assert!(
215 resp.status.is_success(),
216 "Create project: {} {}",
217 resp.status,
218 resp.text
219 );
220 let proj: Value = resp.json();
221 let project_id = proj["id"].as_str().unwrap();
222
223 // Publish project
224 h.client
225 .put_json(
226 &format!("/api/projects/{project_id}"),
227 r#"{"is_public": true}"#,
228 )
229 .await;
230
231 // Insert verified domain + warm cache
232 sqlx::query(
233 "INSERT INTO custom_domains (user_id, domain, verified, verification_token, verified_at)
234 VALUES ($1, 'proj.example.com', true, 'tok', NOW())",
235 )
236 .bind(uid)
237 .execute(&h.db)
238 .await
239 .unwrap();
240
241 h.client
242 .get("/api/domains/caddy-ask?domain=proj.example.com")
243 .await;
244
245 let resp = h
246 .client
247 .request_with_headers(
248 "GET",
249 "/test-project",
250 None,
251 &[("Host", "proj.example.com")],
252 )
253 .await;
254 assert!(
255 resp.status.is_success(),
256 "Project fallback failed: {} {}",
257 resp.status,
258 resp.text
259 );
260 assert!(
261 resp.text.contains("Test Project"),
262 "Project page should contain title"
263 );
264 }
265
266 #[tokio::test]
267 async fn custom_domain_fallback_mnw_domain_returns_404() {
268 let mut h = TestHarness::new().await;
269
270 // A request with makenot.work Host to an unknown path should 404 (not trigger fallback)
271 let resp = h
272 .client
273 .request_with_headers(
274 "GET",
275 "/nonexistent-path-xyz",
276 None,
277 &[("Host", "makenot.work")],
278 )
279 .await;
280 assert_eq!(
281 resp.status, 404,
282 "MNW domain unmatched path should 404, got {}",
283 resp.status
284 );
285
286 // And it must carry the branded page, not a bare status. Caddy's
287 // handle_errors only fires on errors Caddy itself generates, never on a 4xx
288 // returned by a healthy upstream, so nothing downstream will supply a body
289 // the app leaves empty: an empty 404 here reaches the visitor as a blank
290 // page.
291 assert!(
292 resp.text.contains("404 ยท Not Found") && resp.text.contains("error-page-message"),
293 "404 should render the branded error page, got {} bytes: {}",
294 resp.text.len(),
295 resp.text
296 );
297 }
298
299 #[tokio::test]
300 async fn item_slug_auto_generated_on_create() {
301 let mut h = TestHarness::new().await;
302 let setup = h.create_creator_with_item("domslug", "digital", 500).await;
303
304 // Verify the item has a slug
305 let row: (String,) = sqlx::query_as("SELECT slug FROM items WHERE id = $1::uuid")
306 .bind(&setup.item_id)
307 .fetch_one(&h.db)
308 .await
309 .unwrap();
310 assert!(!row.0.is_empty(), "Item slug should be non-empty");
311 assert_eq!(
312 row.0, "test-item",
313 "Item slug should be derived from title 'Test Item'"
314 );
315 }
316
317 #[tokio::test]
318 async fn item_slug_collision_handling() {
319 let mut h = TestHarness::new().await;
320 let _uid = h.create_creator("domcoll").await;
321
322 let resp = h
323 .client
324 .post_form("/api/projects", "slug=coll-proj&title=Collision+Project")
325 .await;
326 assert!(resp.status.is_success());
327 let proj: Value = resp.json();
328 let pid = proj["id"].as_str().unwrap();
329
330 // Create two items with the same title
331 let resp = h
332 .client
333 .post_form(
334 &format!("/api/projects/{pid}/items"),
335 "title=Same+Title&item_type=digital&price_cents=0",
336 )
337 .await;
338 assert!(
339 resp.status.is_success(),
340 "First item: {} {}",
341 resp.status,
342 resp.text
343 );
344
345 let resp = h
346 .client
347 .post_form(
348 &format!("/api/projects/{pid}/items"),
349 "title=Same+Title&item_type=digital&price_cents=0",
350 )
351 .await;
352 assert!(
353 resp.status.is_success(),
354 "Second item: {} {}",
355 resp.status,
356 resp.text
357 );
358
359 // Verify both have unique slugs
360 let slugs: Vec<(String,)> =
361 sqlx::query_as("SELECT slug FROM items WHERE project_id = $1::uuid ORDER BY created_at")
362 .bind(pid)
363 .fetch_all(&h.db)
364 .await
365 .unwrap();
366
367 assert_eq!(slugs.len(), 2);
368 assert_ne!(slugs[0].0, slugs[1].0, "Slugs should be unique: {slugs:?}");
369 assert!(
370 slugs[1].0.starts_with("same-title"),
371 "Second slug should start with 'same-title'"
372 );
373 }
374
375 #[tokio::test]
376 async fn add_domain_rejects_mnw_domains() {
377 let mut h = TestHarness::new().await;
378 let _uid = h.create_creator("dommnw").await;
379
380 let resp = h
381 .client
382 .post_form("/api/domains", "domain=makenot.work")
383 .await;
384 assert!(!resp.status.is_success(), "makenot.work should be rejected");
385
386 let resp = h
387 .client
388 .post_form("/api/domains", "domain=sub.makenot.work")
389 .await;
390 assert!(
391 !resp.status.is_success(),
392 "sub.makenot.work should be rejected"
393 );
394 }
395