Skip to main content

max / makenotwork

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