fabrica

hanna/fabrica

55401 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Handler tests via `tower::ServiceExt::oneshot` — no network, no external
6//! services. They assert the shell renders, static assets and themes serve,
7//! CSRF is enforced on state-changing routes, and sign-in opens a session.
8
9#![allow(clippy::unwrap_used, clippy::expect_used)]
10
11use std::sync::Arc;
12
13use axum::Router;
14use axum::body::Body;
15use axum::http::{Request, StatusCode, header};
16use config::Config;
17use store::{NewRepo, NewUser, Store};
18use tower::ServiceExt as _;
19
20use crate::AppState;
21
22/// Build an app over an in-memory store with one admin-less user `ada` whose
23/// password is `secret12`.
24async fn app() -> (AppState, Router) {
25 let store = Store::connect("sqlite::memory:", 1).await.unwrap();
26 store.migrate().await.unwrap();
27
28 let hash = auth::hash_password("secret12", auth::HashParams::default()).unwrap();
29 store
30 .create_user(NewUser {
31 username: "ada".to_string(),
32 email: "ada@example.com".to_string(),
33 display_name: Some("Ada".to_string()),
34 password_hash: Some(hash),
35 is_admin: false,
36 must_change_password: false,
37 })
38 .await
39 .unwrap();
40
41 let mut config = Config::default();
42 config.instance.name = "Forge".to_string();
43 // Not HTTPS in tests, so don't demand Secure cookies.
44 config.auth.cookie_secure = false;
45
46 let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap();
47 let router = crate::build_router(state.clone());
48 (state, router)
49}
50
51async fn body_string(response: axum::response::Response) -> String {
52 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
53 .await
54 .unwrap();
55 String::from_utf8_lossy(&bytes).into_owned()
56}
57
58fn get(uri: &str) -> Request<Body> {
59 Request::builder().uri(uri).body(Body::empty()).unwrap()
60}
61
62#[tokio::test]
63async fn admin_dashboard_gating_and_settings_override() {
64 let (state, app) = app().await;
65 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
66 let cookie = login(&app).await;
67 let token = cookie
68 .split("fabrica_csrf=")
69 .nth(1)
70 .and_then(|s| s.split(';').next())
71 .unwrap()
72 .to_string();
73
74 // Non-admin: the admin area does not acknowledge itself (404).
75 let req = Request::builder()
76 .uri("/admin")
77 .header(header::COOKIE, cookie.clone())
78 .body(Body::empty())
79 .unwrap();
80 assert_eq!(
81 app.clone().oneshot(req).await.unwrap().status(),
82 StatusCode::NOT_FOUND
83 );
84
85 // Promote and retry (the session re-reads the user each request).
86 state.store.set_admin(&ada.id, true).await.unwrap();
87 let req = Request::builder()
88 .uri("/admin")
89 .header(header::COOKIE, cookie.clone())
90 .body(Body::empty())
91 .unwrap();
92 let res = app.clone().oneshot(req).await.unwrap();
93 assert_eq!(res.status(), StatusCode::OK);
94 assert!(body_string(res).await.contains("Overview"));
95
96 // Override the instance name via the settings tab.
97 let req = Request::builder()
98 .method("POST")
99 .uri("/admin/settings")
100 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
101 .header(header::COOKIE, cookie)
102 .body(Body::from(format!(
103 "name=Renamed&description=&default_visibility=private&_csrf={token}"
104 )))
105 .unwrap();
106 let res = app.oneshot(req).await.unwrap();
107 assert_eq!(res.status(), StatusCode::SEE_OTHER);
108 assert_eq!(state.instance_name(), "Renamed", "override took effect");
109}
110
111#[tokio::test]
112async fn home_renders_the_shell() {
113 let (_state, app) = app().await;
114 let res = app.oneshot(get("/")).await.unwrap();
115 assert_eq!(res.status(), StatusCode::OK);
116 let html = body_string(res).await;
117 assert!(html.contains("<!DOCTYPE html>"));
118 assert!(html.contains("Forge"), "instance name should appear");
119 assert!(html.contains("/assets/base.css"));
120 assert!(html.contains("/assets/htmx.min.js"));
121}
122
123#[tokio::test]
124async fn assets_and_themes_serve() {
125 let (_state, app) = app().await;
126
127 let css = app.clone().oneshot(get("/assets/base.css")).await.unwrap();
128 assert_eq!(css.status(), StatusCode::OK);
129 assert_eq!(
130 css.headers().get(header::CONTENT_TYPE).unwrap(),
131 "text/css; charset=utf-8"
132 );
133
134 let theme = app.clone().oneshot(get("/theme/dark.css")).await.unwrap();
135 assert_eq!(theme.status(), StatusCode::OK);
136 let body = body_string(theme).await;
137 assert!(body.contains("--fb-bg"));
138
139 let default = app.clone().oneshot(get("/theme.css")).await.unwrap();
140 assert_eq!(default.status(), StatusCode::OK);
141
142 let traversal = app.oneshot(get("/assets/../Cargo.toml")).await.unwrap();
143 assert_ne!(traversal.status(), StatusCode::OK);
144}
145
146#[tokio::test]
147async fn healthz_and_version_report() {
148 let (_state, app) = app().await;
149 let health = app.clone().oneshot(get("/healthz")).await.unwrap();
150 // git may or may not be present in the test env; either way it is valid JSON.
151 let body = body_string(health).await;
152 assert!(body.contains("\"database\":\"ok\""));
153
154 let version = app.oneshot(get("/version")).await.unwrap();
155 assert_eq!(version.status(), StatusCode::OK);
156 assert!(body_string(version).await.contains("\"name\":\"Forge\""));
157}
158
159#[tokio::test]
160async fn login_form_renders_with_a_csrf_token() {
161 let (_state, app) = app().await;
162 let res = app.oneshot(get("/login")).await.unwrap();
163 assert_eq!(res.status(), StatusCode::OK);
164 // The CSRF cookie is set, and its token is embedded in the form.
165 let set_cookie = res
166 .headers()
167 .get(header::SET_COOKIE)
168 .map(|v| v.to_str().unwrap().to_string())
169 .unwrap_or_default();
170 assert!(
171 set_cookie.contains("fabrica_csrf="),
172 "csrf cookie: {set_cookie:?}"
173 );
174 let html = body_string(res).await;
175 assert!(html.contains("name=\"_csrf\""));
176}
177
178#[tokio::test]
179async fn post_without_csrf_is_forbidden() {
180 let (_state, app) = app().await;
181 let req = Request::builder()
182 .method("POST")
183 .uri("/login")
184 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
185 .body(Body::from("username=ada&password=secret12&_csrf=nope"))
186 .unwrap();
187 let res = app.oneshot(req).await.unwrap();
188 assert_eq!(res.status(), StatusCode::FORBIDDEN);
189}
190
191#[tokio::test]
192async fn login_with_matching_csrf_opens_a_session() {
193 let (_state, app) = app().await;
194
195 // First GET /login to obtain a CSRF cookie + token.
196 let form = app.clone().oneshot(get("/login")).await.unwrap();
197 let cookie = form
198 .headers()
199 .get(header::SET_COOKIE)
200 .unwrap()
201 .to_str()
202 .unwrap()
203 .to_string();
204 let token = cookie
205 .split("fabrica_csrf=")
206 .nth(1)
207 .and_then(|s| s.split(';').next())
208 .unwrap()
209 .to_string();
210
211 // POST with the cookie echoed and the token in the body → double-submit passes.
212 let req = Request::builder()
213 .method("POST")
214 .uri("/login")
215 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
216 .header(header::COOKIE, format!("fabrica_csrf={token}"))
217 .body(Body::from(format!(
218 "username=ada&password=secret12&_csrf={token}"
219 )))
220 .unwrap();
221 let res = app.oneshot(req).await.unwrap();
222 assert_eq!(res.status(), StatusCode::SEE_OTHER, "redirect on success");
223 let set = res
224 .headers()
225 .get_all(header::SET_COOKIE)
226 .iter()
227 .map(|v| v.to_str().unwrap().to_string())
228 .collect::<Vec<_>>()
229 .join("\n");
230 assert!(
231 set.contains("fabrica_session="),
232 "session cookie set: {set:?}"
233 );
234}
235
236#[tokio::test]
237async fn bad_password_re_renders_unauthorized() {
238 let (_state, app) = app().await;
239 let form = app.clone().oneshot(get("/login")).await.unwrap();
240 let cookie = form
241 .headers()
242 .get(header::SET_COOKIE)
243 .unwrap()
244 .to_str()
245 .unwrap()
246 .to_string();
247 let token = cookie
248 .split("fabrica_csrf=")
249 .nth(1)
250 .and_then(|s| s.split(';').next())
251 .unwrap()
252 .to_string();
253
254 let req = Request::builder()
255 .method("POST")
256 .uri("/login")
257 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
258 .header(header::COOKIE, format!("fabrica_csrf={token}"))
259 .body(Body::from(format!(
260 "username=ada&password=wrong&_csrf={token}"
261 )))
262 .unwrap();
263 let res = app.oneshot(req).await.unwrap();
264 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
265}
266
267#[tokio::test]
268async fn invite_with_unknown_token_is_not_found() {
269 let (_state, app) = app().await;
270 let res = app.oneshot(get("/invite/does-not-exist")).await.unwrap();
271 assert_eq!(res.status(), StatusCode::NOT_FOUND);
272}
273
274#[tokio::test]
275async fn settings_redirects_when_signed_out() {
276 let (_state, app) = app().await;
277 let res = app.oneshot(get("/settings")).await.unwrap();
278 assert_eq!(res.status(), StatusCode::SEE_OTHER);
279 assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");
280}
281
282#[tokio::test]
283async fn unknown_theme_is_rejected() {
284 let (_state, app) = app().await;
285 let res = app.oneshot(get("/settings/theme?name=nope")).await.unwrap();
286 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
287}
288
289#[tokio::test]
290async fn private_repo_is_404_for_anonymous() {
291 let (state, app) = app().await;
292 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
293 state
294 .store
295 .create_repo(NewRepo {
296 owner_id: ada.id,
297 group_id: None,
298 name: "secret".to_string(),
299 path: "secret".to_string(),
300 description: None,
301 visibility: model::Visibility::Private,
302 default_branch: "main".to_string(),
303 })
304 .await
305 .unwrap();
306
307 // Access is denied before any git read, so existence never leaks: 404, not 403.
308 let res = app.oneshot(get("/ada/secret")).await.unwrap();
309 assert_eq!(res.status(), StatusCode::NOT_FOUND);
310}
311
312#[tokio::test]
313async fn unknown_owner_is_404() {
314 let (_state, app) = app().await;
315 let res = app.oneshot(get("/nobody")).await.unwrap();
316 assert_eq!(res.status(), StatusCode::NOT_FOUND);
317}
318
319#[tokio::test]
320async fn profile_page_renders_with_identity() {
321 let (_state, app) = app().await;
322 let res = app.oneshot(get("/ada")).await.unwrap();
323 assert_eq!(res.status(), StatusCode::OK);
324 let html = body_string(res).await;
325 // Display name in the heading and the "@username" handle line.
326 assert!(html.contains("Ada"), "display name shown");
327 assert!(html.contains("@ada"), "profile handle shown");
328 assert!(html.contains("/avatar/ada"), "avatar image referenced");
329 assert!(html.contains("Joined on"), "join date shown");
330}
331
332#[tokio::test]
333async fn avatar_falls_back_to_identicon_svg() {
334 let (_state, app) = app().await;
335 let res = app.oneshot(get("/avatar/ada")).await.unwrap();
336 assert_eq!(res.status(), StatusCode::OK);
337 assert_eq!(
338 res.headers().get(header::CONTENT_TYPE).unwrap(),
339 "image/svg+xml; charset=utf-8"
340 );
341 let body = body_string(res).await;
342 assert!(body.starts_with("<svg"), "identicon is an SVG");
343}
344
345#[tokio::test]
346async fn explore_lists_public_repos_for_anonymous() {
347 let (state, app) = app().await;
348 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
349 state
350 .store
351 .create_repo(NewRepo {
352 owner_id: ada.id,
353 group_id: None,
354 name: "open".to_string(),
355 path: "open".to_string(),
356 description: None,
357 visibility: model::Visibility::Public,
358 default_branch: "main".to_string(),
359 })
360 .await
361 .unwrap();
362
363 let res = app.oneshot(get("/explore")).await.unwrap();
364 assert_eq!(res.status(), StatusCode::OK);
365 let html = body_string(res).await;
366 assert!(html.contains("ada/open"), "public repo listed");
367 // The Explore sub-nav offers the repositories and users tabs.
368 assert!(html.contains("/explore/users"), "users tab present");
369}
370
371#[tokio::test]
372async fn explore_users_lists_the_directory() {
373 let (_state, app) = app().await;
374 let res = app.oneshot(get("/explore/users")).await.unwrap();
375 assert_eq!(res.status(), StatusCode::OK);
376 let html = body_string(res).await;
377 assert!(html.contains("Users"), "users heading");
378 assert!(html.contains("Ada"), "user display name listed");
379 assert!(html.contains("/avatar/ada"), "user avatar shown");
380}
381
382/// Sign in as `ada` and return the `Cookie` header value carrying the session.
383async fn login(app: &Router) -> String {
384 let form = app.clone().oneshot(get("/login")).await.unwrap();
385 let csrf = form
386 .headers()
387 .get(header::SET_COOKIE)
388 .unwrap()
389 .to_str()
390 .unwrap()
391 .to_string();
392 let token = csrf
393 .split("fabrica_csrf=")
394 .nth(1)
395 .and_then(|s| s.split(';').next())
396 .unwrap()
397 .to_string();
398 let req = Request::builder()
399 .method("POST")
400 .uri("/login")
401 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
402 .header(header::COOKIE, format!("fabrica_csrf={token}"))
403 .body(Body::from(format!(
404 "username=ada&password=secret12&_csrf={token}"
405 )))
406 .unwrap();
407 let res = app.clone().oneshot(req).await.unwrap();
408 let session = res
409 .headers()
410 .get(header::SET_COOKIE)
411 .unwrap()
412 .to_str()
413 .unwrap();
414 let session = session.split(';').next().unwrap();
415 format!("fabrica_csrf={token}; {session}")
416}
417
418#[tokio::test]
419async fn dashboard_shows_activity_and_repos_when_signed_in() {
420 let (_state, app) = app().await;
421 let cookie = login(&app).await;
422 let req = Request::builder()
423 .uri("/")
424 .header(header::COOKIE, cookie)
425 .body(Body::empty())
426 .unwrap();
427 let res = app.oneshot(req).await.unwrap();
428 assert_eq!(res.status(), StatusCode::OK);
429 let html = body_string(res).await;
430 assert!(
431 html.contains("contributions in the last year"),
432 "activity heatmap caption present"
433 );
434 assert!(html.contains("Recent activity"), "activity feed present");
435 assert!(html.contains("class=\"heatmap\""), "heatmap svg present");
436}
437
438#[tokio::test]
439async fn repo_settings_are_owner_only() {
440 let (state, app) = app().await;
441 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
442 let repo = state
443 .store
444 .create_repo(NewRepo {
445 owner_id: ada.id,
446 group_id: None,
447 name: "proj".to_string(),
448 path: "proj".to_string(),
449 description: None,
450 visibility: model::Visibility::Private,
451 default_branch: "main".to_string(),
452 })
453 .await
454 .unwrap();
455
456 // Anonymous visitor: a private repo's settings are a 404, not a 403.
457 let res = app
458 .clone()
459 .oneshot(get("/ada/proj/-/settings"))
460 .await
461 .unwrap();
462 assert_eq!(res.status(), StatusCode::NOT_FOUND);
463
464 // The owner sees the settings page with the delete form.
465 let cookie = login(&app).await;
466 let req = Request::builder()
467 .uri("/ada/proj/-/settings")
468 .header(header::COOKIE, cookie)
469 .body(Body::empty())
470 .unwrap();
471 let res = app.oneshot(req).await.unwrap();
472 assert_eq!(res.status(), StatusCode::OK);
473 let html = body_string(res).await;
474 assert!(html.contains("Danger zone"), "danger zone shown");
475 assert!(
476 html.contains(&format!("/repo-settings/{}/delete", repo.id)),
477 "delete action targets the repo id"
478 );
479}
480
481#[tokio::test]
482async fn new_repo_page_requires_auth_and_renders() {
483 let (_state, app) = app().await;
484 // Anonymous → redirected to sign in.
485 let res = app.clone().oneshot(get("/new")).await.unwrap();
486 assert_eq!(res.status(), StatusCode::SEE_OTHER);
487 assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");
488
489 // Signed in → the creation form.
490 let cookie = login(&app).await;
491 let req = Request::builder()
492 .uri("/new")
493 .header(header::COOKIE, cookie)
494 .body(Body::empty())
495 .unwrap();
496 let res = app.oneshot(req).await.unwrap();
497 assert_eq!(res.status(), StatusCode::OK);
498 let html = body_string(res).await;
499 assert!(html.contains("New repository"));
500 assert!(html.contains("name=\"visibility\""));
501}
502
503#[tokio::test]
504async fn settings_tabs_render() {
505 let (_state, app) = app().await;
506 let cookie = login(&app).await;
507 let get_tab = |uri: &'static str, cookie: String| {
508 let app = app.clone();
509 async move {
510 let req = Request::builder()
511 .uri(uri)
512 .header(header::COOKIE, cookie)
513 .body(Body::empty())
514 .unwrap();
515 let res = app.oneshot(req).await.unwrap();
516 assert_eq!(res.status(), StatusCode::OK, "{uri}");
517 body_string(res).await
518 }
519 };
520 // The Profile tab shows the tab nav.
521 let profile = get_tab("/settings", cookie.clone()).await;
522 assert!(profile.contains("Save profile"), "profile tab");
523 assert!(profile.contains("/settings/account"), "tab nav present");
524 // The Account tab shows username, emails, and password.
525 let account = get_tab("/settings/account", cookie.clone()).await;
526 assert!(account.contains("Change username"), "username");
527 assert!(account.contains("Emails"), "emails section");
528 assert!(account.contains("Change password"), "password");
529 // The tokens tab.
530 let tokens = get_tab("/settings/tokens", cookie).await;
531 assert!(tokens.contains("API tokens"), "tokens tab");
532}
533
534#[tokio::test]
535async fn emails_add_set_primary_and_publish_on_profile() {
536 let (state, app) = app().await;
537 let cookie = login(&app).await;
538 let token = cookie
539 .split("fabrica_csrf=")
540 .nth(1)
541 .and_then(|s| s.split(';').next())
542 .unwrap()
543 .to_string();
544 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
545
546 // Add a second address.
547 let post = |uri: &'static str, body: String, cookie: String| {
548 let app = app.clone();
549 async move {
550 let req = Request::builder()
551 .method("POST")
552 .uri(uri)
553 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
554 .header(header::COOKIE, cookie)
555 .body(Body::from(body))
556 .unwrap();
557 app.oneshot(req).await.unwrap()
558 }
559 };
560 let res = post(
561 "/settings/emails",
562 format!("email=second@example.com&_csrf={token}"),
563 cookie.clone(),
564 )
565 .await;
566 assert_eq!(res.status(), StatusCode::SEE_OTHER);
567 let emails = state.store.emails_by_user(&ada.id).await.unwrap();
568 assert_eq!(emails.len(), 2);
569 let second = emails
570 .iter()
571 .find(|e| e.email == "second@example.com")
572 .unwrap();
573 assert!(!second.is_primary);
574 assert!(!second.verified(), "added emails start unverified");
575 let second_id = second.id.clone();
576
577 // A new address must be verified before it can become primary.
578 let res = post(
579 "/settings/emails/primary",
580 format!("id={second_id}&_csrf={token}"),
581 cookie.clone(),
582 )
583 .await;
584 assert_eq!(
585 res.status(),
586 StatusCode::BAD_REQUEST,
587 "unverified cannot be primary"
588 );
589
590 // Exercise the verification flow: token issued, then redeemed.
591 state
592 .store
593 .begin_email_verification(&second_id, "verify-token")
594 .await
595 .unwrap();
596 assert!(
597 state
598 .store
599 .verify_email_token("verify-token")
600 .await
601 .unwrap()
602 .is_some()
603 );
604
605 // Now it can be made primary; users.email mirrors it.
606 let res = post(
607 "/settings/emails/primary",
608 format!("id={second_id}&_csrf={token}"),
609 cookie.clone(),
610 )
611 .await;
612 assert_eq!(res.status(), StatusCode::SEE_OTHER);
613 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
614 assert_eq!(ada.email, "second@example.com");
615
616 // Publish it and confirm it appears on the profile.
617 let res = post(
618 "/settings/emails/visibility",
619 format!("public=1&_csrf={token}"),
620 cookie,
621 )
622 .await;
623 assert_eq!(res.status(), StatusCode::SEE_OTHER);
624 let res = app.oneshot(get("/ada")).await.unwrap();
625 let html = body_string(res).await;
626 assert!(
627 html.contains("second@example.com"),
628 "email shown on profile"
629 );
630}
631
632/// An app with self-registration enabled (and no seeded user).
633async fn app_with_registration() -> Router {
634 let store = Store::connect("sqlite::memory:", 1).await.unwrap();
635 store.migrate().await.unwrap();
636 let mut config = Config::default();
637 config.instance.name = "Forge".to_string();
638 config.instance.allow_registration = true;
639 config.auth.cookie_secure = false;
640 let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap();
641 crate::build_router(state)
642}
643
644#[tokio::test]
645async fn registration_is_404_when_disabled() {
646 let (_state, app) = app().await;
647 let res = app.oneshot(get("/register")).await.unwrap();
648 assert_eq!(res.status(), StatusCode::NOT_FOUND);
649}
650
651#[tokio::test]
652async fn registration_creates_an_account_and_signs_in() {
653 let app = app_with_registration().await;
654
655 let form = app.clone().oneshot(get("/register")).await.unwrap();
656 assert_eq!(form.status(), StatusCode::OK);
657 let cookie = form
658 .headers()
659 .get(header::SET_COOKIE)
660 .unwrap()
661 .to_str()
662 .unwrap()
663 .to_string();
664 let token = cookie
665 .split("fabrica_csrf=")
666 .nth(1)
667 .and_then(|s| s.split(';').next())
668 .unwrap()
669 .to_string();
670 assert!(body_string(form).await.contains("Create account"));
671
672 let req = Request::builder()
673 .method("POST")
674 .uri("/register")
675 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
676 .header(header::COOKIE, format!("fabrica_csrf={token}"))
677 .body(Body::from(format!(
678 "username=newbie&email=new@example.com&password=secret12&confirm=secret12&_csrf={token}"
679 )))
680 .unwrap();
681 let res = app.oneshot(req).await.unwrap();
682 assert_eq!(res.status(), StatusCode::SEE_OTHER, "redirect on success");
683 let set = res
684 .headers()
685 .get_all(header::SET_COOKIE)
686 .iter()
687 .filter_map(|v| v.to_str().ok())
688 .collect::<Vec<_>>()
689 .join("\n");
690 assert!(set.contains("fabrica_session="), "signed in: {set:?}");
691}
692
693#[tokio::test]
694async fn issues_open_create_and_view() {
695 let (state, app) = app().await;
696 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
697 let repo = state
698 .store
699 .create_repo(NewRepo {
700 owner_id: ada.id,
701 group_id: None,
702 name: "proj".to_string(),
703 path: "proj".to_string(),
704 description: None,
705 visibility: model::Visibility::Private,
706 default_branch: "main".to_string(),
707 })
708 .await
709 .unwrap();
710
711 let cookie = login(&app).await;
712 let token = cookie
713 .split("fabrica_csrf=")
714 .nth(1)
715 .and_then(|s| s.split(';').next())
716 .unwrap()
717 .to_string();
718
719 // The issues list renders for the owner with a New button.
720 let req = Request::builder()
721 .uri("/ada/proj/-/issues")
722 .header(header::COOKIE, cookie.clone())
723 .body(Body::empty())
724 .unwrap();
725 let res = app.clone().oneshot(req).await.unwrap();
726 assert_eq!(res.status(), StatusCode::OK);
727 assert!(body_string(res).await.contains("New issue"));
728
729 // Create an issue.
730 let req = Request::builder()
731 .method("POST")
732 .uri(format!("/issue-new/{}", repo.id))
733 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
734 .header(header::COOKIE, cookie.clone())
735 .body(Body::from(format!(
736 "title=First+bug&body=It+is+broken&_csrf={token}"
737 )))
738 .unwrap();
739 let res = app.clone().oneshot(req).await.unwrap();
740 assert_eq!(res.status(), StatusCode::SEE_OTHER);
741 assert_eq!(
742 res.headers().get(header::LOCATION).unwrap(),
743 "/ada/proj/-/issues/1"
744 );
745
746 // View it.
747 let req = Request::builder()
748 .uri("/ada/proj/-/issues/1")
749 .header(header::COOKIE, cookie.clone())
750 .body(Body::empty())
751 .unwrap();
752 let res = app.clone().oneshot(req).await.unwrap();
753 assert_eq!(res.status(), StatusCode::OK);
754 let html = body_string(res).await;
755 assert!(html.contains("First bug"), "issue title shown");
756 assert!(html.contains("#1"), "issue number shown");
757
758 // Commenting works (regression: nested state form used to duplicate _csrf).
759 let issue = state
760 .store
761 .issue_by_number(&repo.id, 1, false)
762 .await
763 .unwrap()
764 .unwrap();
765 let req = Request::builder()
766 .method("POST")
767 .uri(format!("/issue/{}/comment", issue.id))
768 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
769 .header(header::COOKIE, cookie)
770 .body(Body::from(format!("body=a+comment&_csrf={token}")))
771 .unwrap();
772 let res = app.clone().oneshot(req).await.unwrap();
773 assert_eq!(res.status(), StatusCode::SEE_OTHER, "comment accepted");
774 assert_eq!(state.store.list_comments(&issue.id).await.unwrap().len(), 1);
775}
776
777#[tokio::test]
778async fn issue_title_and_comments_are_editable() {
779 let (state, app) = app().await;
780 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
781 let repo = state
782 .store
783 .create_repo(NewRepo {
784 owner_id: ada.id.clone(),
785 group_id: None,
786 name: "proj".to_string(),
787 path: "proj".to_string(),
788 description: None,
789 visibility: model::Visibility::Public,
790 default_branch: "main".to_string(),
791 })
792 .await
793 .unwrap();
794 let issue = state
795 .store
796 .create_issue(&repo.id, &ada.id, "typo", "body", false)
797 .await
798 .unwrap();
799 let comment = state
800 .store
801 .add_comment(&issue.id, &ada.id, "orignal")
802 .await
803 .unwrap();
804
805 let cookie = login(&app).await;
806 let token = cookie
807 .split("fabrica_csrf=")
808 .nth(1)
809 .and_then(|s| s.split(';').next())
810 .unwrap()
811 .to_string();
812 let post = |uri: String, body: String, cookie: String| {
813 let app = app.clone();
814 async move {
815 let req = Request::builder()
816 .method("POST")
817 .uri(uri)
818 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
819 .header(header::COOKIE, cookie)
820 .body(Body::from(body))
821 .unwrap();
822 app.oneshot(req).await.unwrap()
823 }
824 };
825
826 // Edit the issue title (title only; body preserved).
827 let res = post(
828 format!("/issue/{}/edit-title", issue.id),
829 format!("title=Fixed+title&_csrf={token}"),
830 cookie.clone(),
831 )
832 .await;
833 assert_eq!(res.status(), StatusCode::SEE_OTHER);
834 let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();
835 assert_eq!(updated.title, "Fixed title");
836 assert_eq!(updated.body, "body", "body untouched by title edit");
837
838 // Edit the issue description via its own card.
839 let res = post(
840 format!("/issue/{}/edit-body", issue.id),
841 format!("body=new+body&_csrf={token}"),
842 cookie.clone(),
843 )
844 .await;
845 assert_eq!(res.status(), StatusCode::SEE_OTHER);
846 let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();
847 assert_eq!(updated.title, "Fixed title", "title untouched by body edit");
848 assert_eq!(updated.body, "new body");
849
850 // Edit the comment.
851 let res = post(
852 format!("/comment/{}/edit", comment.id),
853 format!("body=corrected&_csrf={token}"),
854 cookie,
855 )
856 .await;
857 assert_eq!(res.status(), StatusCode::SEE_OTHER);
858 let edited = state
859 .store
860 .comment_by_id(&comment.id)
861 .await
862 .unwrap()
863 .unwrap();
864 assert_eq!(edited.body, "corrected");
865}
866
867#[tokio::test]
868async fn issue_can_be_locked_and_unlocked() {
869 let (state, app) = app().await;
870 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
871 let repo = state
872 .store
873 .create_repo(NewRepo {
874 owner_id: ada.id.clone(),
875 group_id: None,
876 name: "proj".to_string(),
877 path: "proj".to_string(),
878 description: None,
879 visibility: model::Visibility::Public,
880 default_branch: "main".to_string(),
881 })
882 .await
883 .unwrap();
884 let issue = state
885 .store
886 .create_issue(&repo.id, &ada.id, "hot", "", false)
887 .await
888 .unwrap();
889
890 let cookie = login(&app).await;
891 let token = cookie
892 .split("fabrica_csrf=")
893 .nth(1)
894 .and_then(|s| s.split(';').next())
895 .unwrap()
896 .to_string();
897 let lock = |locked: bool, cookie: String| {
898 let app = app.clone();
899 let token = token.clone();
900 let id = issue.id.clone();
901 async move {
902 let req = Request::builder()
903 .method("POST")
904 .uri(format!("/issue/{id}/lock"))
905 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
906 .header(header::COOKIE, cookie)
907 .body(Body::from(format!("locked={locked}&_csrf={token}")))
908 .unwrap();
909 app.oneshot(req).await.unwrap()
910 }
911 };
912
913 let res = lock(true, cookie.clone()).await;
914 assert_eq!(res.status(), StatusCode::SEE_OTHER);
915 assert!(
916 state
917 .store
918 .issue_by_id(&issue.id)
919 .await
920 .unwrap()
921 .unwrap()
922 .locked()
923 );
924
925 let res = lock(false, cookie).await;
926 assert_eq!(res.status(), StatusCode::SEE_OTHER);
927 assert!(
928 !state
929 .store
930 .issue_by_id(&issue.id)
931 .await
932 .unwrap()
933 .unwrap()
934 .locked()
935 );
936}
937
938#[tokio::test]
939async fn new_issue_can_apply_labels() {
940 let (state, app) = app().await;
941 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
942 let repo = state
943 .store
944 .create_repo(NewRepo {
945 owner_id: ada.id.clone(),
946 group_id: None,
947 name: "proj".to_string(),
948 path: "proj".to_string(),
949 description: None,
950 visibility: model::Visibility::Public,
951 default_branch: "main".to_string(),
952 })
953 .await
954 .unwrap();
955 let bug = state
956 .store
957 .create_label(&repo.id, "bug", "#ff0000", None)
958 .await
959 .unwrap();
960
961 let cookie = login(&app).await;
962 let token = cookie
963 .split("fabrica_csrf=")
964 .nth(1)
965 .and_then(|s| s.split(';').next())
966 .unwrap()
967 .to_string();
968 let req = Request::builder()
969 .method("POST")
970 .uri(format!("/issue-new/{}", repo.id))
971 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
972 .header(header::COOKIE, cookie)
973 .body(Body::from(format!(
974 "title=Tagged&body=&label={}&_csrf={token}",
975 bug.id
976 )))
977 .unwrap();
978 let res = app.oneshot(req).await.unwrap();
979 assert_eq!(res.status(), StatusCode::SEE_OTHER);
980 let issue = state
981 .store
982 .issue_by_number(&repo.id, 1, false)
983 .await
984 .unwrap()
985 .unwrap();
986 let labels = state.store.issue_labels(&issue.id).await.unwrap();
987 assert_eq!(labels.len(), 1, "label applied on create");
988 assert_eq!(labels[0].name, "bug");
989}
990
991#[tokio::test]
992async fn issue_list_paginates() {
993 let (state, app) = app().await;
994 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
995 let repo = state
996 .store
997 .create_repo(NewRepo {
998 owner_id: ada.id.clone(),
999 group_id: None,
1000 name: "proj".to_string(),
1001 path: "proj".to_string(),
1002 description: None,
1003 visibility: model::Visibility::Public,
1004 default_branch: "main".to_string(),
1005 })
1006 .await
1007 .unwrap();
1008 // Three open issues.
1009 for i in 0..3 {
1010 state
1011 .store
1012 .create_issue(&repo.id, &ada.id, &format!("bug {i}"), "", false)
1013 .await
1014 .unwrap();
1015 }
1016
1017 // First page of two shows a Next control.
1018 let res = app
1019 .clone()
1020 .oneshot(get("/ada/proj/-/issues?per_page=2"))
1021 .await
1022 .unwrap();
1023 let html = body_string(res).await;
1024 assert!(html.contains("class=\"pagination\""), "pagination shown");
1025 assert!(html.contains("page=2"), "next link present");
1026 assert_eq!(html.matches("entry-row-title").count(), 2, "two per page");
1027
1028 // Second page holds the remaining one.
1029 let res = app
1030 .oneshot(get("/ada/proj/-/issues?per_page=2&page=2"))
1031 .await
1032 .unwrap();
1033 let html = body_string(res).await;
1034 assert_eq!(html.matches("entry-row-title").count(), 1, "one on page 2");
1035}
1036
1037#[tokio::test]
1038async fn issue_dependencies_block_and_unblock() {
1039 let (state, app) = app().await;
1040 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1041 let repo = state
1042 .store
1043 .create_repo(NewRepo {
1044 owner_id: ada.id.clone(),
1045 group_id: None,
1046 name: "proj".to_string(),
1047 path: "proj".to_string(),
1048 description: None,
1049 visibility: model::Visibility::Public,
1050 default_branch: "main".to_string(),
1051 })
1052 .await
1053 .unwrap();
1054 // #1 will depend on #2.
1055 let one = state
1056 .store
1057 .create_issue(&repo.id, &ada.id, "feature", "", false)
1058 .await
1059 .unwrap();
1060 let two = state
1061 .store
1062 .create_issue(&repo.id, &ada.id, "prerequisite", "", false)
1063 .await
1064 .unwrap();
1065
1066 let cookie = login(&app).await;
1067 let token = cookie
1068 .split("fabrica_csrf=")
1069 .nth(1)
1070 .and_then(|s| s.split(';').next())
1071 .unwrap()
1072 .to_string();
1073
1074 // Add the dependency: #1 depends on #2.
1075 let req = Request::builder()
1076 .method("POST")
1077 .uri(format!("/issue/{}/deps/add", one.id))
1078 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1079 .header(header::COOKIE, cookie.clone())
1080 .body(Body::from(format!("number=2&_csrf={token}")))
1081 .unwrap();
1082 let res = app.clone().oneshot(req).await.unwrap();
1083 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1084 assert_eq!(
1085 state.store.open_dependency_count(&one.id).await.unwrap(),
1086 1,
1087 "one open blocker"
1088 );
1089
1090 // #1's view lists the blocker; #2's view lists what it blocks.
1091 let res = app
1092 .clone()
1093 .oneshot(get("/ada/proj/-/issues/1"))
1094 .await
1095 .unwrap();
1096 let html = body_string(res).await;
1097 assert!(html.contains("Blocked by"), "blocked-by section");
1098 assert!(html.contains("prerequisite"), "blocker listed");
1099
1100 // Closing #2 clears the block.
1101 state
1102 .store
1103 .set_issue_state(&two.id, model::IssueState::Closed)
1104 .await
1105 .unwrap();
1106 assert_eq!(
1107 state.store.open_dependency_count(&one.id).await.unwrap(),
1108 0,
1109 "no open blockers once closed"
1110 );
1111}
1112
1113#[tokio::test]
1114async fn issues_404_when_disabled() {
1115 let (state, app) = app().await;
1116 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1117 let repo = state
1118 .store
1119 .create_repo(NewRepo {
1120 owner_id: ada.id,
1121 group_id: None,
1122 name: "noissues".to_string(),
1123 path: "noissues".to_string(),
1124 description: None,
1125 visibility: model::Visibility::Public,
1126 default_branch: "main".to_string(),
1127 })
1128 .await
1129 .unwrap();
1130 state
1131 .store
1132 .set_feature_enabled(&repo.id, "issues", false)
1133 .await
1134 .unwrap();
1135 let res = app.oneshot(get("/ada/noissues/-/issues")).await.unwrap();
1136 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1137}
1138
1139/// Build an app whose storage lives under a temp dir, so real bare repositories
1140/// can be created on disk — needed for pull-request diffs and merges. The temp
1141/// dir is returned so it outlives the test.
1142async fn app_with_git() -> (AppState, Router, tempfile::TempDir) {
1143 let store = Store::connect("sqlite::memory:", 1).await.unwrap();
1144 store.migrate().await.unwrap();
1145 let hash = auth::hash_password("secret12", auth::HashParams::default()).unwrap();
1146 store
1147 .create_user(NewUser {
1148 username: "ada".to_string(),
1149 email: "ada@example.com".to_string(),
1150 display_name: Some("Ada".to_string()),
1151 password_hash: Some(hash),
1152 is_admin: false,
1153 must_change_password: false,
1154 })
1155 .await
1156 .unwrap();
1157
1158 let dir = tempfile::tempdir().unwrap();
1159 let mut config = Config::default();
1160 config.instance.name = "Forge".to_string();
1161 config.auth.cookie_secure = false;
1162 config.storage.repo_dir = dir.path().join("repos");
1163 config.storage.data_dir = dir.path().to_path_buf();
1164 std::fs::create_dir_all(config.storage.data_dir.join("tmp")).unwrap();
1165
1166 let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap();
1167 let router = crate::build_router(state.clone());
1168 (state, router, dir)
1169}
1170
1171/// Commit `files` onto `branch` in a bare repo via libgit2, returning the new oid.
1172fn seed_commit(
1173 raw: &git2::Repository,
1174 branch: &str,
1175 parents: &[git2::Oid],
1176 files: &[(&str, &str)],
1177 message: &str,
1178 t: i64,
1179) -> git2::Oid {
1180 let mut root = raw.treebuilder(None).unwrap();
1181 for (name, content) in files {
1182 let blob = raw.blob(content.as_bytes()).unwrap();
1183 root.insert(name, blob, 0o100_644).unwrap();
1184 }
1185 let tree = raw.find_tree(root.write().unwrap()).unwrap();
1186 let sig = git2::Signature::new("Ada", "ada@example.com", &git2::Time::new(t, 0)).unwrap();
1187 let parent_commits: Vec<_> = parents
1188 .iter()
1189 .map(|p| raw.find_commit(*p).unwrap())
1190 .collect();
1191 let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
1192 raw.commit(
1193 Some(&format!("refs/heads/{branch}")),
1194 &sig,
1195 &sig,
1196 message,
1197 &tree,
1198 &parent_refs,
1199 )
1200 .unwrap()
1201}
1202
1203#[tokio::test]
1204async fn pull_request_create_view_and_merge() {
1205 // Merge shells out to `git`; skip cleanly where it is absent.
1206 if std::process::Command::new("git")
1207 .arg("--version")
1208 .output()
1209 .is_err()
1210 {
1211 return;
1212 }
1213
1214 let (state, app, _dir) = app_with_git().await;
1215 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1216 let repo = state
1217 .store
1218 .create_repo(NewRepo {
1219 owner_id: ada.id,
1220 group_id: None,
1221 name: "proj".to_string(),
1222 path: "proj".to_string(),
1223 description: None,
1224 visibility: model::Visibility::Public,
1225 default_branch: "main".to_string(),
1226 })
1227 .await
1228 .unwrap();
1229
1230 // Create the bare repo on disk with a mergeable feature branch.
1231 let hook = state.config.storage.data_dir.join("fabrica");
1232 git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap();
1233 let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
1234 let raw = git2::Repository::open_bare(&path).unwrap();
1235 let base = seed_commit(&raw, "main", &[], &[("README.md", "hello\n")], "init", 1);
1236 seed_commit(
1237 &raw,
1238 "feature",
1239 &[base],
1240 &[("README.md", "hello\n"), ("feature.txt", "new\n")],
1241 "add feature",
1242 2,
1243 );
1244
1245 let cookie = login(&app).await;
1246 let token = cookie
1247 .split("fabrica_csrf=")
1248 .nth(1)
1249 .and_then(|s| s.split(';').next())
1250 .unwrap()
1251 .to_string();
1252
1253 // Open a pull request comparing feature → main.
1254 let req = Request::builder()
1255 .method("POST")
1256 .uri(format!("/pull-new/{}", repo.id))
1257 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1258 .header(header::COOKIE, cookie.clone())
1259 .body(Body::from(format!(
1260 "base_ref=main&head_ref=feature&title=Add+feature&body=please&_csrf={token}"
1261 )))
1262 .unwrap();
1263 let res = app.clone().oneshot(req).await.unwrap();
1264 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1265 assert_eq!(
1266 res.headers().get(header::LOCATION).unwrap(),
1267 "/ada/proj/-/pulls/1"
1268 );
1269
1270 // The PR page shows the compare summary, the diff, and a clean merge panel.
1271 let req = Request::builder()
1272 .uri("/ada/proj/-/pulls/1")
1273 .header(header::COOKIE, cookie.clone())
1274 .body(Body::empty())
1275 .unwrap();
1276 let res = app.clone().oneshot(req).await.unwrap();
1277 assert_eq!(res.status(), StatusCode::OK);
1278 let html = body_string(res).await;
1279 assert!(html.contains("wants to merge"), "compare summary shown");
1280 assert!(html.contains("feature.txt"), "diff lists the new file");
1281 assert!(html.contains("Merge pull request"), "merge button shown");
1282
1283 // Merge it (default merge-commit strategy).
1284 let issue = state
1285 .store
1286 .issue_by_number(&repo.id, 1, true)
1287 .await
1288 .unwrap()
1289 .unwrap();
1290 let req = Request::builder()
1291 .method("POST")
1292 .uri(format!("/pull/{}/merge", issue.id))
1293 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1294 .header(header::COOKIE, cookie)
1295 .body(Body::from(format!("strategy=merge&_csrf={token}")))
1296 .unwrap();
1297 let res = app.oneshot(req).await.unwrap();
1298 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1299
1300 // The PR is now merged and closed, and main carries the feature file.
1301 let pr = state.store.pull_by_issue(&issue.id).await.unwrap().unwrap();
1302 assert!(pr.merged_at.is_some(), "merge recorded");
1303 assert!(pr.merge_sha.is_some(), "merge sha stored");
1304 let closed = state.store.issue_by_id(&issue.id).await.unwrap().unwrap();
1305 assert_eq!(closed.state, model::IssueState::Closed, "PR auto-closed");
1306
1307 let main_tip = raw
1308 .find_reference("refs/heads/main")
1309 .unwrap()
1310 .peel_to_commit()
1311 .unwrap();
1312 assert!(
1313 main_tip.tree().unwrap().get_name("feature.txt").is_some(),
1314 "main now contains the merged file"
1315 );
1316 assert_eq!(main_tip.parent_count(), 2, "a merge commit");
1317}
1318
1319#[tokio::test]
1320async fn new_repo_can_use_sha256_object_format() {
1321 if std::process::Command::new("git")
1322 .arg("--version")
1323 .output()
1324 .is_err()
1325 {
1326 return;
1327 }
1328 let (state, app, _dir) = app_with_git().await;
1329 let cookie = login(&app).await;
1330 let token = cookie
1331 .split("fabrica_csrf=")
1332 .nth(1)
1333 .and_then(|s| s.split(';').next())
1334 .unwrap()
1335 .to_string();
1336
1337 let req = Request::builder()
1338 .method("POST")
1339 .uri("/new")
1340 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1341 .header(header::COOKIE, cookie)
1342 .body(Body::from(format!(
1343 "name=modern&visibility=private&default_branch=main&object_format=sha256&_csrf={token}"
1344 )))
1345 .unwrap();
1346 let res = app.oneshot(req).await.unwrap();
1347 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1348
1349 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1350 let repo = state
1351 .store
1352 .repo_by_owner_path(&ada.id, "modern")
1353 .await
1354 .unwrap()
1355 .unwrap();
1356 assert_eq!(repo.object_format, "sha256", "row records the format");
1357 // The on-disk repo is really SHA-256.
1358 let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
1359 let out = std::process::Command::new("git")
1360 .arg("--git-dir")
1361 .arg(&path)
1362 .args(["rev-parse", "--show-object-format"])
1363 .output()
1364 .unwrap();
1365 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256");
1366}
1367
1368#[tokio::test]
1369async fn repo_home_shows_a_language_bar() {
1370 let (state, app, _dir) = app_with_git().await;
1371 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1372 let repo = state
1373 .store
1374 .create_repo(NewRepo {
1375 owner_id: ada.id,
1376 group_id: None,
1377 name: "proj".to_string(),
1378 path: "proj".to_string(),
1379 description: None,
1380 visibility: model::Visibility::Public,
1381 default_branch: "main".to_string(),
1382 })
1383 .await
1384 .unwrap();
1385 let hook = state.config.storage.data_dir.join("fabrica");
1386 git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap();
1387 let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
1388 let raw = git2::Repository::open_bare(&path).unwrap();
1389 seed_commit(
1390 &raw,
1391 "main",
1392 &[],
1393 &[
1394 ("main.rs", "fn main() { println!(\"hi\"); }\n"),
1395 ("util.py", "print('hi')\n"),
1396 ],
1397 "init",
1398 1,
1399 );
1400
1401 let res = app.oneshot(get("/ada/proj")).await.unwrap();
1402 assert_eq!(res.status(), StatusCode::OK);
1403 let html = body_string(res).await;
1404 assert!(html.contains("lang-bar"), "language bar rendered");
1405 assert!(html.contains("Rust"), "Rust detected");
1406 assert!(html.contains("Python"), "Python detected");
1407}
1408
1409#[tokio::test]
1410async fn license_file_shows_a_rights_banner() {
1411 let (state, app, _dir) = app_with_git().await;
1412 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1413 let repo = state
1414 .store
1415 .create_repo(NewRepo {
1416 owner_id: ada.id,
1417 group_id: None,
1418 name: "proj".to_string(),
1419 path: "proj".to_string(),
1420 description: None,
1421 visibility: model::Visibility::Public,
1422 default_branch: "main".to_string(),
1423 })
1424 .await
1425 .unwrap();
1426 let hook = state.config.storage.data_dir.join("fabrica");
1427 git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap();
1428 let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
1429 let raw = git2::Repository::open_bare(&path).unwrap();
1430 let mit = "MIT License\n\nPermission is hereby granted, free of charge, to any person \
1431 obtaining a copy of this software.\n";
1432 seed_commit(&raw, "main", &[], &[("LICENSE", mit)], "add license", 1);
1433
1434 let res = app
1435 .oneshot(get("/ada/proj/-/blob/main/LICENSE"))
1436 .await
1437 .unwrap();
1438 assert_eq!(res.status(), StatusCode::OK);
1439 let html = body_string(res).await;
1440 assert!(html.contains("MIT License"), "license name shown");
1441 assert!(html.contains("Permissions"), "rights columns shown");
1442 assert!(html.contains("license-banner"), "banner rendered");
1443}
1444
1445#[tokio::test]
1446async fn pulls_404_when_disabled() {
1447 let (state, app) = app().await;
1448 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1449 let repo = state
1450 .store
1451 .create_repo(NewRepo {
1452 owner_id: ada.id,
1453 group_id: None,
1454 name: "nopr".to_string(),
1455 path: "nopr".to_string(),
1456 description: None,
1457 visibility: model::Visibility::Public,
1458 default_branch: "main".to_string(),
1459 })
1460 .await
1461 .unwrap();
1462 state
1463 .store
1464 .set_feature_enabled(&repo.id, "pulls", false)
1465 .await
1466 .unwrap();
1467 let res = app.oneshot(get("/ada/nopr/-/pulls")).await.unwrap();
1468 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1469}
1470
1471#[tokio::test]
1472async fn settings_requires_authentication() {
1473 let (_state, app) = app().await;
1474 let res = app.oneshot(get("/settings")).await.unwrap();
1475 // RequireUser redirects anonymous visitors to /login.
1476 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1477 assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");
1478}
1479
1480/// A valid SSH public key for exercising the key-management UI.
1481const TEST_SSH_KEY: &str = "ssh-ed25519 \
1482AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+xw1UVOompehxQE8sUYCVoyV+NBjfKU web-test@fabrica";
1483
1484#[tokio::test]
1485async fn settings_keys_add_list_and_delete() {
1486 let (state, app) = app().await;
1487 let cookie = login(&app).await;
1488 let token = cookie
1489 .split("fabrica_csrf=")
1490 .nth(1)
1491 .and_then(|s| s.split(';').next())
1492 .unwrap()
1493 .to_string();
1494
1495 // The settings page shows the keys section.
1496 let req = Request::builder()
1497 .uri("/settings/keys")
1498 .header(header::COOKIE, cookie.clone())
1499 .body(Body::empty())
1500 .unwrap();
1501 let res = app.clone().oneshot(req).await.unwrap();
1502 assert_eq!(res.status(), StatusCode::OK);
1503 assert!(body_string(res).await.contains("SSH and GPG keys"));
1504
1505 // Add an SSH key.
1506 let body = format!(
1507 "kind=ssh&name=laptop&key={}&_csrf={token}",
1508 urlencoding(TEST_SSH_KEY)
1509 );
1510 let req = Request::builder()
1511 .method("POST")
1512 .uri("/settings/keys")
1513 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1514 .header(header::COOKIE, cookie.clone())
1515 .body(Body::from(body))
1516 .unwrap();
1517 let res = app.clone().oneshot(req).await.unwrap();
1518 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1519
1520 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
1521 let keys = state.store.keys_by_user(&ada.id).await.unwrap();
1522 assert_eq!(keys.len(), 1, "key was stored");
1523 let key_id = keys[0].id.clone();
1524
1525 // It renders on the settings page.
1526 let req = Request::builder()
1527 .uri("/settings/keys")
1528 .header(header::COOKIE, cookie.clone())
1529 .body(Body::empty())
1530 .unwrap();
1531 let res = app.clone().oneshot(req).await.unwrap();
1532 let listing = body_string(res).await;
1533 assert!(listing.contains("laptop"), "key label shown");
1534 assert!(listing.contains("unverified"), "new keys start unverified");
1535 assert!(
1536 listing.contains("authenticate over SSH"),
1537 "SSH auto-verify hint shown"
1538 );
1539
1540 // Delete it.
1541 let req = Request::builder()
1542 .method("POST")
1543 .uri("/settings/keys/delete")
1544 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1545 .header(header::COOKIE, cookie)
1546 .body(Body::from(format!("id={key_id}&_csrf={token}")))
1547 .unwrap();
1548 let res = app.oneshot(req).await.unwrap();
1549 assert_eq!(res.status(), StatusCode::SEE_OTHER);
1550 assert!(state.store.keys_by_user(&ada.id).await.unwrap().is_empty());
1551}
1552
1553#[tokio::test]
1554async fn settings_key_add_rejects_garbage() {
1555 let (_state, app) = app().await;
1556 let cookie = login(&app).await;
1557 let token = cookie
1558 .split("fabrica_csrf=")
1559 .nth(1)
1560 .and_then(|s| s.split(';').next())
1561 .unwrap()
1562 .to_string();
1563 let req = Request::builder()
1564 .method("POST")
1565 .uri("/settings/keys")
1566 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1567 .header(header::COOKIE, cookie)
1568 .body(Body::from(format!(
1569 "kind=ssh&name=x&key=not-a-key&_csrf={token}"
1570 )))
1571 .unwrap();
1572 let res = app.oneshot(req).await.unwrap();
1573 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
1574}
1575
1576/// An app with hCaptcha configured, so the sign-up form must show and enforce it.
1577async fn app_with_captcha() -> Router {
1578 let store = Store::connect("sqlite::memory:", 1).await.unwrap();
1579 store.migrate().await.unwrap();
1580 let mut config = Config::default();
1581 config.instance.name = "Forge".to_string();
1582 config.instance.allow_registration = true;
1583 config.auth.cookie_secure = false;
1584 config.captcha.provider = config::CaptchaProvider::HCaptcha;
1585 config.captcha.site_key = "test-site-key".to_string();
1586 config.captcha.secret_key = Some(config::Secret::new("test-secret".to_string()));
1587 let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap();
1588 crate::build_router(state)
1589}
1590
1591#[tokio::test]
1592async fn register_page_shows_the_captcha_widget() {
1593 let app = app_with_captcha().await;
1594 let res = app.oneshot(get("/register")).await.unwrap();
1595 assert_eq!(res.status(), StatusCode::OK);
1596 let html = body_string(res).await;
1597 assert!(html.contains("js.hcaptcha.com"), "widget script present");
1598 assert!(
1599 html.contains("data-sitekey=\"test-site-key\""),
1600 "site key present"
1601 );
1602 assert!(html.contains("h-captcha"), "widget container present");
1603}
1604
1605#[tokio::test]
1606async fn register_is_rejected_when_captcha_is_missing() {
1607 let app = app_with_captcha().await;
1608 let form = app.clone().oneshot(get("/register")).await.unwrap();
1609 let cookie = form
1610 .headers()
1611 .get(header::SET_COOKIE)
1612 .unwrap()
1613 .to_str()
1614 .unwrap()
1615 .to_string();
1616 let token = cookie
1617 .split("fabrica_csrf=")
1618 .nth(1)
1619 .and_then(|s| s.split(';').next())
1620 .unwrap()
1621 .to_string();
1622 // No captcha token in the body → rejected before any network call.
1623 let req = Request::builder()
1624 .method("POST")
1625 .uri("/register")
1626 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1627 .header(header::COOKIE, format!("fabrica_csrf={token}"))
1628 .body(Body::from(format!(
1629 "username=bot&email=bot@example.com&password=secret12&confirm=secret12&_csrf={token}"
1630 )))
1631 .unwrap();
1632 let res = app.oneshot(req).await.unwrap();
1633 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
1634 assert!(
1635 body_string(res)
1636 .await
1637 .contains("Captcha verification failed")
1638 );
1639}
1640
1641/// Minimal application/x-www-form-urlencoded encoding for test bodies.
1642fn urlencoding(s: &str) -> String {
1643 use std::fmt::Write as _;
1644 let mut out = String::with_capacity(s.len());
1645 for b in s.bytes() {
1646 match b {
1647 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1648 out.push(b as char);
1649 }
1650 b' ' => out.push('+'),
1651 _ => {
1652 let _ = write!(out, "%{b:02X}");
1653 }
1654 }
1655 }
1656 out
1657}