forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
27.8 KiB
1//! Black-box tests for the organization pages: members, teams, settings, the creation policy and
2//! the owner choice on the new repo form. They start the real `forge` binary, seed the org rows
3//! through the store before the server starts, and then drive the pages over HTTP. Cookies are
4//! handled by hand, as in `repo_collaborators_e2e.rs`.
5
6mod common;
7use common::{BIN, describe, launch, launch_with, pick_ports, tool_on_path, write_config};
8use std::fs;
9use std::path::Path;
10use std::process::{Command, Output};
11use std::time::Duration;
12
13use forge_core::store::orgs::{DefaultRepoRole, OrgMemberRole, OrgSettings};
14use forge_core::{Role, Store, Visibility};
15use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE};
16
17/// Each test starts its own server, so the tests run one at a time.
18static SERIAL: std::sync::LazyLock<tokio::sync::Mutex<()>> =
19 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
20
21const PW: &str = "alice-password-123";
22/// The one-shot notice cookie of the org pages.
23const ORG_NOTICE: &str = "forge_org_notice";
24
25struct Resp {
26 status: u16,
27 headers: HeaderMap,
28 body: String,
29}
30
31impl Resp {
32 fn header(&self, name: HeaderName) -> Option<String> {
33 self.headers
34 .get(name)
35 .and_then(|v| v.to_str().ok())
36 .map(str::to_owned)
37 }
38
39 fn set_cookies(&self) -> Vec<String> {
40 self.headers
41 .get_all(SET_COOKIE)
42 .iter()
43 .filter_map(|v| v.to_str().ok().map(str::to_owned))
44 .collect()
45 }
46}
47
48struct Http {
49 client: reqwest::Client,
50 base: String,
51}
52
53impl Http {
54 fn new(port: u16) -> Self {
55 let client = reqwest::Client::builder()
56 .redirect(reqwest::redirect::Policy::none())
57 .timeout(Duration::from_secs(60))
58 .build()
59 .expect("http client");
60 Self {
61 client,
62 base: format!("http://127.0.0.1:{port}"),
63 }
64 }
65
66 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
67 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
68 .await
69 }
70
71 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
72 self.send(
73 self.client.post(format!("{}{path}", self.base)).form(form),
74 cookie,
75 )
76 .await
77 }
78
79 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
80 let req = match cookie {
81 Some(cookie) => req.header(COOKIE, cookie),
82 None => req,
83 };
84 let res = req.send().await.expect("http request");
85 let status = res.status().as_u16();
86 let headers = res.headers().clone();
87 let body = res.text().await.expect("read body");
88 Resp {
89 status,
90 headers,
91 body,
92 }
93 }
94}
95
96/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
97fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
98 resp.set_cookies().iter().find_map(|set| {
99 let pair = set.split(';').next()?;
100 let (name, value) = pair.split_once('=')?;
101 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
102 })
103}
104
105/// The value of the first `<input name="...">` on a page.
106fn input_value(body: &str, name: &str) -> String {
107 let doc = scraper::Html::parse_document(body);
108 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
109 doc.select(&selector)
110 .next()
111 .and_then(|el| el.value().attr("value"))
112 .unwrap_or_else(|| panic!("no input named {name} on page"))
113 .to_owned()
114}
115
116/// The value of the selected option of the `<select name="...">` on a page.
117fn selected_value(body: &str, name: &str) -> Option<String> {
118 let doc = scraper::Html::parse_document(body);
119 let selector = scraper::Selector::parse(&format!("select[name=\"{name}\"] option[selected]"))
120 .expect("selector");
121 doc.select(&selector)
122 .next()
123 .and_then(|el| el.value().attr("value"))
124 .map(str::to_owned)
125}
126
127/// The values of every option of the owner select on the new repo form.
128fn owner_options(body: &str) -> Vec<String> {
129 let doc = scraper::Html::parse_document(body);
130 let selector = scraper::Selector::parse("select[name=\"owner\"] option").expect("selector");
131 doc.select(&selector)
132 .filter_map(|el| el.value().attr("value").map(str::to_owned))
133 .collect()
134}
135
136/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
137fn admin(config: &Path, args: &[&str]) -> Output {
138 let out = Command::new(BIN)
139 .arg("--config")
140 .arg(config)
141 .arg("admin")
142 .args(args)
143 .env("FORGE_PASSWORD", PW)
144 .output()
145 .expect("run admin command");
146 assert!(
147 out.status.success(),
148 "admin {args:?} failed:\n{}",
149 describe(&out)
150 );
151 out
152}
153
154async fn login(http: &Http, username: &str) -> String {
155 let page = http.get("/login", None).await;
156 assert_eq!(page.status, 200, "GET /login");
157 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
158 let precsrf = input_value(&page.body, "precsrf");
159 let res = http
160 .post_form(
161 "/login",
162 &[
163 ("username", username),
164 ("password", PW),
165 ("next", "/"),
166 ("precsrf", &precsrf),
167 ],
168 Some(&precsrf_cookie),
169 )
170 .await;
171 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
172 cookie_named(&res, "forge_session").expect("session cookie after login")
173}
174
175/// The session's CSRF token, read from a page every signed-in user can open.
176async fn csrf_for(http: &Http, session: &str) -> String {
177 let page = http.get("/settings/tokens", Some(session)).await;
178 assert_eq!(page.status, 200, "GET /settings/tokens");
179 input_value(&page.body, "csrf")
180}
181
182/// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does.
183fn init_bare(data_dir: &Path, repo_id: &str) {
184 let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
185 fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent");
186 let out = Command::new("git")
187 .args(["init", "--bare", "--quiet"])
188 .arg(&path)
189 .output()
190 .expect("run git init");
191 assert!(out.status.success(), "git init --bare: {}", describe(&out));
192}
193
194/// The org notice cookie from a redirect, joined with the session cookie for the next GET.
195fn with_org_notice(session: &str, redirect: &Resp) -> String {
196 let notice = cookie_named(redirect, ORG_NOTICE).expect("org notice cookie");
197 format!("{session}; {notice}")
198}
199
200/// A temp dir short enough for the unix socket path limit.
201fn short_temp_dir() -> tempfile::TempDir {
202 tempfile::Builder::new()
203 .prefix("forg")
204 .tempdir_in("/tmp")
205 .expect("temp dir")
206}
207
208#[tokio::test]
209async fn org_members_teams_and_access_end_to_end() {
210 let _guard = SERIAL.lock().await;
211 if !tool_on_path("git") {
212 eprintln!("SKIP orgs_ui_e2e: `git` was not found on PATH");
213 return;
214 }
215 let root = short_temp_dir();
216 let dir = root.path().to_path_buf();
217 let ports = pick_ports();
218 let config = write_config(&dir, ports);
219
220 admin(&config, &["create-user", "root", "--admin"]);
221 for name in ["alice", "bob", "carol", "dana"] {
222 admin(&config, &["create-user", name]);
223 }
224
225 // Acme is alice's org. Bob is a member with no default access, and a team gives him read on
226 // acme's private repo. Initech is an empty org, used later for deletion.
227 let (alice_id, team_id) = {
228 let store = Store::open(&dir.join("forge.db"))
229 .await
230 .expect("open store");
231 let alice = store
232 .user_by_username("alice")
233 .await
234 .unwrap()
235 .expect("alice");
236 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
237 let acme = store.create_org("acme", &alice.id).await.expect("acme");
238 store
239 .add_org_member(&acme.id, &bob.id, OrgMemberRole::Member)
240 .await
241 .expect("bob joins acme");
242 store
243 .set_org_settings(
244 &acme.id,
245 OrgSettings {
246 default_repo_role: DefaultRepoRole::None,
247 members_can_create_repos: false,
248 },
249 )
250 .await
251 .expect("acme settings");
252 let proj = store
253 .create_repo(&acme.id, "proj", "", Visibility::Private, "main")
254 .await
255 .expect("acme proj");
256 init_bare(&dir, &proj.id);
257 let team = store
258 .create_team(&acme.id, "devs", "", Role::Read, false)
259 .await
260 .expect("devs team");
261 store
262 .add_team_repo(&team.id, &proj.id)
263 .await
264 .expect("devs covers proj");
265 store
266 .create_org("initech", &alice.id)
267 .await
268 .expect("initech");
269 (alice.id, team.id)
270 };
271
272 let (_server, ports) = launch(&dir, ports).await;
273 let http = Http::new(ports.http);
274 let alice = login(&http, "alice").await;
275 let bob = login(&http, "bob").await;
276 let carol = login(&http, "carol").await;
277 let dana = login(&http, "dana").await;
278 let members = "/org/acme/members";
279
280 // Anonymous visitors go to login. Non-members get 404 on the org pages. Members see the list
281 // but no owner controls.
282 assert_eq!(http.get(members, None).await.status, 303, "anonymous");
283 assert_eq!(
284 http.get(members, Some(&carol)).await.status,
285 404,
286 "carol is not yet a member"
287 );
288 let bob_view = http.get(members, Some(&bob)).await;
289 assert_eq!(bob_view.status, 200, "bob sees the members list");
290 assert!(
291 !bob_view.body.contains("Add a member"),
292 "members get no add form"
293 );
294
295 // A member cannot add people. The owner adds carol, and the page shows the notice.
296 let bob_csrf = csrf_for(&http, &bob).await;
297 let res = http
298 .post_form(
299 "/org/acme/members/add",
300 &[
301 ("csrf", &bob_csrf),
302 ("username", "carol"),
303 ("role", "member"),
304 ],
305 Some(&bob),
306 )
307 .await;
308 assert_eq!(res.status, 403, "member add is refused: {}", res.body);
309
310 let alice_csrf = csrf_for(&http, &alice).await;
311 let res = http
312 .post_form(
313 "/org/acme/members/add",
314 &[
315 ("csrf", &alice_csrf),
316 ("username", "carol"),
317 ("role", "member"),
318 ],
319 Some(&alice),
320 )
321 .await;
322 assert_eq!(res.status, 303, "owner adds carol: {}", res.body);
323 assert_eq!(res.header(LOCATION).as_deref(), Some(members));
324 let shown = http
325 .get(members, Some(&with_org_notice(&alice, &res)))
326 .await;
327 assert!(shown.body.contains("Member added."), "notice after add");
328 assert!(shown.body.contains("href=\"/carol\""), "carol listed");
329 assert_eq!(
330 http.get(members, Some(&carol)).await.status,
331 200,
332 "carol is a member now"
333 );
334
335 // The last owner cannot leave. The redirect carries a notice that says why.
336 let res = http
337 .post_form(
338 "/org/acme/members/remove",
339 &[("csrf", &alice_csrf), ("user_id", &alice_id)],
340 Some(&alice),
341 )
342 .await;
343 assert_eq!(res.status, 303, "leave is answered with a redirect");
344 let shown = http
345 .get(members, Some(&with_org_notice(&alice, &res)))
346 .await;
347 assert!(
348 shown
349 .body
350 .contains("An organization needs at least one owner."),
351 "last owner notice: {}",
352 shown.body
353 );
354
355 // Team coverage: bob cannot see acme's private repo until he joins the devs team. Dana is not
356 // in the org and never can see it through a team.
357 assert_eq!(
358 http.get("/acme/proj", Some(&bob)).await.status,
359 404,
360 "bob before the team"
361 );
362 assert_eq!(
363 http.get("/acme/proj", Some(&dana)).await.status,
364 404,
365 "a non-member never sees the private org repo"
366 );
367 let team_page = format!("/org/acme/teams/{team_id}");
368 assert_eq!(
369 http.get(&team_page, Some(&bob)).await.status,
370 200,
371 "members see teams"
372 );
373 let res = http
374 .post_form(
375 &format!("{team_page}/members/add"),
376 &[("csrf", &alice_csrf), ("username", "bob")],
377 Some(&alice),
378 )
379 .await;
380 assert_eq!(res.status, 303, "owner adds bob to devs");
381 assert_eq!(
382 http.get("/acme/proj", Some(&bob)).await.status,
383 200,
384 "bob reads the repo through devs"
385 );
386 assert_eq!(
387 http.get("/acme/proj", Some(&carol)).await.status,
388 404,
389 "carol is not in devs"
390 );
391
392 // Dana is not an org member, so the team refuses her.
393 let res = http
394 .post_form(
395 &format!("{team_page}/members/add"),
396 &[("csrf", &alice_csrf), ("username", "dana")],
397 Some(&alice),
398 )
399 .await;
400 assert_eq!(res.status, 303, "refused add still redirects");
401 let shown = http
402 .get(&team_page, Some(&with_org_notice(&alice, &res)))
403 .await;
404 assert!(
405 shown
406 .body
407 .contains("Only members of the organization can join its teams."),
408 "non-member refused by the team"
409 );
410
411 // The new repo form offers the owner's own name and the orgs it may create in. Bob may not
412 // create in acme, so acme is not offered to him, and a forged owner is refused.
413 let alice_new = http.get("/new", Some(&alice)).await;
414 assert!(
415 owner_options(&alice_new.body).contains(&"acme".to_string()),
416 "owner choice lists acme"
417 );
418 let bob_new = http.get("/new", Some(&bob)).await;
419 assert_eq!(owner_options(&bob_new.body), vec!["bob".to_string()]);
420 let res = http
421 .post_form(
422 "/new",
423 &[
424 ("csrf", &bob_csrf),
425 ("name", "sneaky"),
426 ("visibility", "private"),
427 ("default_branch", "main"),
428 ("owner", "acme"),
429 ],
430 Some(&bob),
431 )
432 .await;
433 assert_eq!(res.status, 200, "forged owner re-renders the form");
434 assert!(
435 res.body
436 .contains("you cannot create repositories for that owner"),
437 "forged owner refused"
438 );
439}
440
441#[tokio::test]
442async fn org_settings_delete_and_audit_end_to_end() {
443 let _guard = SERIAL.lock().await;
444 if !tool_on_path("git") {
445 eprintln!("SKIP orgs_ui_e2e: `git` was not found on PATH");
446 return;
447 }
448 let root = short_temp_dir();
449 let dir = root.path().to_path_buf();
450 let ports = pick_ports();
451 let config = write_config(&dir, ports);
452
453 admin(&config, &["create-user", "root", "--admin"]);
454 for name in ["alice", "bob", "dana"] {
455 admin(&config, &["create-user", name]);
456 }
457 {
458 let store = Store::open(&dir.join("forge.db"))
459 .await
460 .expect("open store");
461 let alice = store
462 .user_by_username("alice")
463 .await
464 .unwrap()
465 .expect("alice");
466 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
467 let acme = store.create_org("acme", &alice.id).await.expect("acme");
468 store
469 .add_org_member(&acme.id, &bob.id, OrgMemberRole::Member)
470 .await
471 .expect("bob joins acme");
472 store
473 .create_repo(&acme.id, "proj", "", Visibility::Public, "main")
474 .await
475 .expect("acme proj");
476 store
477 .create_org("initech", &alice.id)
478 .await
479 .expect("initech");
480 }
481
482 let (_server, ports) = launch(&dir, ports).await;
483 let http = Http::new(ports.http);
484 let alice = login(&http, "alice").await;
485 let bob = login(&http, "bob").await;
486 let dana = login(&http, "dana").await;
487 let root_session = login(&http, "root").await;
488 let settings = "/org/acme/settings";
489
490 // Owners only: members get 403, non-members 404, anonymous visitors go to login.
491 assert_eq!(http.get(settings, None).await.status, 303, "anonymous");
492 assert_eq!(http.get(settings, Some(&bob)).await.status, 403, "member");
493 assert_eq!(
494 http.get(settings, Some(&dana)).await.status,
495 404,
496 "outsider"
497 );
498
499 // The owner saves the description and the default role. The page shows the new values.
500 let csrf = csrf_for(&http, &alice).await;
501 let res = http
502 .post_form(
503 settings,
504 &[
505 ("csrf", &csrf),
506 ("description", "Anvils and more"),
507 ("default_repo_role", "write"),
508 ("members_can_create", "on"),
509 ],
510 Some(&alice),
511 )
512 .await;
513 assert_eq!(res.status, 303, "save settings: {}", res.body);
514 let shown = http
515 .get(settings, Some(&with_org_notice(&alice, &res)))
516 .await;
517 assert!(shown.body.contains("Settings saved."), "saved notice");
518 assert!(shown.body.contains("Anvils and more"), "description saved");
519 assert_eq!(
520 selected_value(&shown.body, "default_repo_role").as_deref(),
521 Some("write"),
522 "default role saved"
523 );
524
525 // Deleting acme is refused while it owns a repo, and the name must be typed again.
526 let res = http
527 .post_form(
528 "/org/acme/settings/delete",
529 &[("csrf", &csrf), ("confirm", "acme")],
530 Some(&alice),
531 )
532 .await;
533 assert_eq!(res.status, 303, "refused delete still redirects");
534 let shown = http
535 .get(settings, Some(&with_org_notice(&alice, &res)))
536 .await;
537 assert!(
538 shown.body.contains("Transfer or delete the organization"),
539 "repos block the delete"
540 );
541 assert_eq!(
542 http.get("/acme", None).await.status,
543 200,
544 "acme still exists"
545 );
546
547 // Initech has no repos, so alice can delete it once she types its name.
548 let initech_settings = "/org/initech/settings";
549 let res = http
550 .post_form(
551 "/org/initech/settings/delete",
552 &[("csrf", &csrf), ("confirm", "wrong")],
553 Some(&alice),
554 )
555 .await;
556 assert_eq!(res.status, 303, "wrong name still redirects");
557 assert_eq!(
558 http.get(initech_settings, Some(&alice)).await.status,
559 200,
560 "initech survives a wrong name"
561 );
562 let res = http
563 .post_form(
564 "/org/initech/settings/delete",
565 &[("csrf", &csrf), ("confirm", "initech")],
566 Some(&alice),
567 )
568 .await;
569 assert_eq!(res.status, 303, "delete initech");
570 assert_eq!(res.header(LOCATION).as_deref(), Some("/"));
571 assert_eq!(
572 http.get("/initech", None).await.status,
573 404,
574 "initech is gone"
575 );
576
577 // The admin audit log holds the org actions, by their stored names.
578 let audit = http
579 .get(
580 "/admin/audit?action=org_settings_updated",
581 Some(&root_session),
582 )
583 .await;
584 assert_eq!(audit.status, 200, "audit page");
585 assert!(
586 audit.body.contains("<code>org_settings_updated</code>"),
587 "settings change is audited"
588 );
589 let audit = http
590 .get("/admin/audit?action=org_deleted", Some(&root_session))
591 .await;
592 assert!(
593 audit.body.contains("<code>org_deleted</code>"),
594 "org deletion is audited"
595 );
596}
597
598#[tokio::test]
599async fn org_creation_can_be_limited_to_admins() {
600 let _guard = SERIAL.lock().await;
601 let root = short_temp_dir();
602 let dir = root.path().to_path_buf();
603 let ports = pick_ports();
604 let config = write_config(&dir, ports);
605 admin(&config, &["create-user", "root", "--admin"]);
606 admin(&config, &["create-user", "alice"]);
607
608 let (_server, ports) = launch_with(&dir, ports, "[orgs]\ncreation = \"admin\"\n").await;
609 let http = Http::new(ports.http);
610 let alice = login(&http, "alice").await;
611 let root_session = login(&http, "root").await;
612
613 assert_eq!(
614 http.get("/org/new", Some(&alice)).await.status,
615 403,
616 "a person may not create orgs under the admin policy"
617 );
618 let csrf = csrf_for(&http, &alice).await;
619 let res = http
620 .post_form(
621 "/org/new",
622 &[("csrf", &csrf), ("name", "globex"), ("description", "")],
623 Some(&alice),
624 )
625 .await;
626 assert_eq!(res.status, 403, "the POST is refused too");
627
628 assert_eq!(
629 http.get("/org/new", Some(&root_session)).await.status,
630 200,
631 "an admin may create orgs"
632 );
633 let root_csrf = csrf_for(&http, &root_session).await;
634 let res = http
635 .post_form(
636 "/org/new",
637 &[
638 ("csrf", &root_csrf),
639 ("name", "globex"),
640 ("description", "Globals"),
641 ],
642 Some(&root_session),
643 )
644 .await;
645 assert_eq!(res.status, 303, "admin creates globex: {}", res.body);
646 assert_eq!(res.header(LOCATION).as_deref(), Some("/globex"));
647 let profile = http.get("/globex", None).await;
648 assert_eq!(profile.status, 200, "the org has a public page");
649 assert!(
650 profile.body.contains("Globals"),
651 "description on the profile"
652 );
653 assert!(profile.body.contains("Organization"), "marked as an org");
654}
655
656/// Git on the command line, with no credential helper and no prompts.
657fn git(cwd: &Path, args: &[&str]) -> Output {
658 Command::new("git")
659 .current_dir(cwd)
660 .args(["-c", "credential.helper="])
661 .args(args)
662 .env("GIT_TERMINAL_PROMPT", "0")
663 .output()
664 .expect("run git")
665}
666
667/// Commits one file with a fixed identity.
668fn commit_file(repo: &Path, name: &str, content: &str) {
669 fs::write(repo.join(name), content).expect("write file");
670 let out = git(repo, &["add", name]);
671 assert!(out.status.success(), "git add: {}", describe(&out));
672 let out = git(
673 repo,
674 &[
675 "-c",
676 "user.name=forge",
677 "-c",
678 "user.email=forge@example.test",
679 "-c",
680 "commit.gpgsign=false",
681 "commit",
682 "-q",
683 "-m",
684 "push over https",
685 ],
686 );
687 assert!(out.status.success(), "git commit: {}", describe(&out));
688}
689
690/// A new access token for the session, read from the page that shows it once.
691async fn create_token(http: &Http, session: &str) -> String {
692 let csrf = csrf_for(http, session).await;
693 let res = http
694 .post_form(
695 "/settings/tokens",
696 &[("csrf", &csrf), ("name", "e2e"), ("expires", "30")],
697 Some(session),
698 )
699 .await;
700 assert_eq!(res.status, 200, "create token: {}", res.body);
701 let doc = scraper::Html::parse_document(&res.body);
702 let selector = scraper::Selector::parse("pre.code code").expect("selector");
703 doc.select(&selector)
704 .next()
705 .expect("token shown after creation")
706 .text()
707 .collect::<String>()
708 .trim()
709 .to_owned()
710}
711
712/// The value of the first option of the `<select name="...">` on a page.
713fn first_option(body: &str, name: &str) -> String {
714 let doc = scraper::Html::parse_document(body);
715 let selector =
716 scraper::Selector::parse(&format!("select[name=\"{name}\"] option")).expect("selector");
717 doc.select(&selector)
718 .next()
719 .and_then(|el| el.value().attr("value"))
720 .unwrap_or_else(|| panic!("no option in select {name}"))
721 .to_owned()
722}
723
724#[tokio::test]
725async fn org_member_pushes_over_https_through_a_write_team() {
726 let _guard = SERIAL.lock().await;
727 if !tool_on_path("git") {
728 eprintln!("SKIP orgs_ui_e2e: `git` was not found on PATH");
729 return;
730 }
731 let root = short_temp_dir();
732 let dir = root.path().to_path_buf();
733 let ports = pick_ports();
734 let config = write_config(&dir, ports);
735
736 admin(&config, &["create-user", "root", "--admin"]);
737 for name in ["alice", "bob", "dana"] {
738 admin(&config, &["create-user", name]);
739 }
740 {
741 let store = Store::open(&dir.join("forge.db"))
742 .await
743 .expect("open store");
744 let alice = store
745 .user_by_username("alice")
746 .await
747 .unwrap()
748 .expect("alice");
749 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
750 let acme = store.create_org("acme", &alice.id).await.expect("acme");
751 store
752 .add_org_member(&acme.id, &bob.id, OrgMemberRole::Member)
753 .await
754 .expect("bob joins acme");
755 store
756 .set_org_settings(
757 &acme.id,
758 OrgSettings {
759 default_repo_role: DefaultRepoRole::None,
760 members_can_create_repos: false,
761 },
762 )
763 .await
764 .expect("acme settings");
765 }
766
767 let (_server, ports) = launch(&dir, ports).await;
768 let http = Http::new(ports.http);
769 let alice = login(&http, "alice").await;
770 let bob = login(&http, "bob").await;
771 let dana = login(&http, "dana").await;
772
773 // Alice creates a private repo under acme through the new repo form.
774 let csrf = csrf_for(&http, &alice).await;
775 let res = http
776 .post_form(
777 "/new",
778 &[
779 ("csrf", &csrf),
780 ("name", "pushme"),
781 ("visibility", "private"),
782 ("default_branch", "main"),
783 ("owner", "acme"),
784 ],
785 Some(&alice),
786 )
787 .await;
788 assert_eq!(res.status, 303, "create acme/pushme: {}", res.body);
789 assert_eq!(res.header(LOCATION).as_deref(), Some("/acme/pushme"));
790
791 // A write team takes the repo, and bob joins the team.
792 let res = http
793 .post_form(
794 "/org/acme/teams/new",
795 &[
796 ("csrf", &csrf),
797 ("name", "writers"),
798 ("role", "write"),
799 ("description", ""),
800 ],
801 Some(&alice),
802 )
803 .await;
804 assert_eq!(res.status, 303, "create writers: {}", res.body);
805 let team_url = res.header(LOCATION).expect("team location");
806 let team = http.get(&team_url, Some(&alice)).await;
807 assert_eq!(team.status, 200, "team page");
808 let repo_id = first_option(&team.body, "repo_id");
809 let res = http
810 .post_form(
811 &format!("{team_url}/repos/add"),
812 &[("csrf", &csrf), ("repo_id", &repo_id)],
813 Some(&alice),
814 )
815 .await;
816 assert_eq!(res.status, 303, "link pushme to writers");
817 let res = http
818 .post_form(
819 &format!("{team_url}/members/add"),
820 &[("csrf", &csrf), ("username", "bob")],
821 Some(&alice),
822 )
823 .await;
824 assert_eq!(res.status, 303, "bob joins writers");
825
826 // Bob pushes over HTTPS with his token. Dana is not in the org, so her clone is refused.
827 let bob_token = create_token(&http, &bob).await;
828 let dana_token = create_token(&http, &dana).await;
829 let bob_url = format!(
830 "http://bob:{bob_token}@127.0.0.1:{}/acme/pushme.git",
831 ports.http
832 );
833 let dana_url = format!(
834 "http://dana:{dana_token}@127.0.0.1:{}/acme/pushme.git",
835 ports.http
836 );
837
838 let refused = git(&dir, &["clone", &dana_url, "dana-work"]);
839 assert!(!refused.status.success(), "dana cannot clone acme/pushme");
840
841 let work = dir.join("bob-work");
842 let work_str = work.to_str().expect("utf8 path");
843 let clone = git(&dir, &["clone", &bob_url, work_str]);
844 assert!(clone.status.success(), "bob clones: {}", describe(&clone));
845 commit_file(&work, "hello.txt", "hello over https\n");
846 let push = git(&work, &["push", "origin", "HEAD:main"]);
847 assert!(push.status.success(), "bob pushes: {}", describe(&push));
848}