Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: create a repository (GET/POST /new). |
| 2 | |
| 3 | use forge_core::mirrors::{DEFAULT_INTERVAL_MINUTES, MirrorDirection, NewMirror}; |
| 4 | use forge_core::names::validate_name; |
| 5 | use forge_core::time::now_stamp; |
| 6 | use forge_core::{AuditAction, User, Visibility}; |
| 7 | use forge_git::repo::{init_bare, repo_path}; |
| 8 | use serde::Deserialize; |
| 9 | use topcoat::Result; |
| 10 | use topcoat::context::{Cx, app_context}; |
| 11 | use topcoat::router::content::Form; |
| 12 | use topcoat::router::error::see_other; |
| 13 | use topcoat::router::page; |
| 14 | use topcoat::view::{View, component, view}; |
| 15 | |
| 16 | use crate::audit::{self, Actor, Target}; |
| 17 | use crate::auth::{require_csrf, require_user}; |
| 18 | use crate::i18n::community::{self as community, K as CommunityK}; |
| 19 | use crate::i18n::misc::{self as g, K}; |
| 20 | use crate::i18n::{self, Key, Lang}; |
| 21 | use crate::pages::org_ctx::may_create_repo_in; |
| 22 | use crate::pages::repo_mirrors::{Checked, check_form_in, redacted_url}; |
| 23 | use crate::state::AppState; |
| 24 | use crate::views::{alert, csrf_field, shell, user_facing_message}; |
| 25 | |
| 26 | const MAX_DESCRIPTION_CHARS: usize = 350; |
| 27 | const DEFAULT_BRANCH: &str = "main"; |
| 28 | |
| 29 | #[derive(Deserialize)] |
| 30 | struct NewRepoForm { |
| 31 | #[serde(default)] |
| 32 | csrf: String, |
| 33 | #[serde(default)] |
| 34 | name: String, |
| 35 | #[serde(default)] |
| 36 | description: String, |
| 37 | #[serde(default)] |
| 38 | visibility: String, |
| 39 | #[serde(default)] |
| 40 | default_branch: String, |
| 41 | /// The checkbox sends `on` when checked and nothing when unchecked. |
| 42 | #[serde(default)] |
| 43 | default_labels: String, |
| 44 | /// The optional "Mirror an existing repository" section. A blank URL means no mirror. |
| 45 | #[serde(default)] |
| 46 | mirror_url: String, |
| 47 | #[serde(default)] |
| 48 | mirror_username: String, |
| 49 | #[serde(default)] |
| 50 | mirror_secret: String, |
| 51 | #[serde(default)] |
| 52 | mirror_interval: String, |
| 53 | /// The account that will own the repo: the viewer's own name, or an org they may create in. |
| 54 | /// Blank means the viewer. |
| 55 | #[serde(default)] |
| 56 | owner: String, |
| 57 | } |
| 58 | |
| 59 | /// The form's values, echoed back when validation fails. |
| 60 | struct Draft { |
| 61 | owner: String, |
| 62 | name: String, |
| 63 | description: String, |
| 64 | private: bool, |
| 65 | default_branch: String, |
| 66 | default_labels: bool, |
| 67 | mirror_url: String, |
| 68 | mirror_username: String, |
| 69 | mirror_interval: String, |
| 70 | } |
| 71 | |
| 72 | impl Draft { |
| 73 | fn fresh() -> Self { |
| 74 | Draft { |
| 75 | owner: String::new(), |
| 76 | name: String::new(), |
| 77 | description: String::new(), |
| 78 | private: true, |
| 79 | default_branch: DEFAULT_BRANCH.into(), |
| 80 | default_labels: true, |
| 81 | mirror_url: String::new(), |
| 82 | mirror_username: String::new(), |
| 83 | mirror_interval: DEFAULT_INTERVAL_MINUTES.to_string(), |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /// The submitted values. The mirror password is never echoed back. |
| 88 | fn from_form(form: &NewRepoForm) -> Self { |
| 89 | Draft { |
| 90 | owner: form.owner.trim().to_owned(), |
| 91 | name: form.name.clone(), |
| 92 | description: form.description.clone(), |
| 93 | private: form.visibility != "public", |
| 94 | default_branch: form.default_branch.trim().to_owned(), |
| 95 | default_labels: !form.default_labels.is_empty(), |
| 96 | mirror_url: form.mirror_url.trim().to_owned(), |
| 97 | mirror_username: form.mirror_username.trim().to_owned(), |
| 98 | mirror_interval: form.mirror_interval.trim().to_owned(), |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | #[component] |
| 104 | async fn new_repo_form( |
| 105 | lang: Lang, |
| 106 | csrf: &str, |
| 107 | draft: Draft, |
| 108 | error: Option<String>, |
| 109 | owners: Vec<String>, |
| 110 | ) -> Result<impl View> { |
| 111 | let branch = if draft.default_branch.is_empty() { |
| 112 | DEFAULT_BRANCH.to_owned() |
| 113 | } else { |
| 114 | draft.default_branch |
| 115 | }; |
| 116 | let selected_owner = if draft.owner.is_empty() { |
| 117 | owners.first().cloned().unwrap_or_default() |
| 118 | } else { |
| 119 | draft.owner.clone() |
| 120 | }; |
| 121 | let private = draft.private; |
| 122 | let default_labels = draft.default_labels; |
| 123 | // A mirror URL in the draft means the form was re-rendered after a validation error. |
| 124 | let mirror_open = !draft.mirror_url.is_empty(); |
| 125 | let draft_mirror_url = draft.mirror_url; |
| 126 | let draft_mirror_username = draft.mirror_username; |
| 127 | let draft_mirror_interval = draft.mirror_interval; |
| 128 | Ok(view! { |
| 129 | if let Some(message) = error { |
| 130 | alert(message: message.as_str()) |
| 131 | } |
| 132 | <form method="post" action="/new" class="newrepo-form"> |
| 133 | csrf_field(token: csrf) |
| 134 | <header class="newrepo-head"> |
| 135 | <h1>(i18n::t(lang, Key::NewRepository))</h1> |
| 136 | <div class="newrepo-head-actions"> |
| 137 | <fieldset class="newrepo-visibility"> |
| 138 | <legend class="sr-only">(g::t(lang, K::VisibilityLegend))</legend> |
| 139 | <div class="segmented snippet-segmented"> |
| 140 | <label class="segment" title=(g::t(lang, K::PrivateHelp))> |
| 141 | <input type="radio" name="visibility" value="private" checked=(private)> |
| 142 | <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg> |
| 143 | <span>(community::t(lang, CommunityK::VisibilityPrivate))</span> |
| 144 | </label> |
| 145 | <label class="segment" title=(g::t(lang, K::PublicHelp))> |
| 146 | <input type="radio" name="visibility" value="public" checked=(!private)> |
| 147 | <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg> |
| 148 | <span>(community::t(lang, CommunityK::VisibilityPublic))</span> |
| 149 | </label> |
| 150 | </div> |
| 151 | </fieldset> |
| 152 | <button type="submit" class="primary">(g::t(lang, K::CreateRepository))</button> |
| 153 | </div> |
| 154 | </header> |
| 155 | <div class="newrepo-card"> |
| 156 | <div class="newrepo-row newrepo-slug"> |
| 157 | <label class="newrepo-owner"> |
| 158 | <span>(g::t(lang, K::NewOwner))</span> |
| 159 | <select name="owner"> |
| 160 | for name in owners.iter() { |
| 161 | <option value=(name.as_str()) selected=(*name == selected_owner)>(name.as_str())</option> |
| 162 | } |
| 163 | </select> |
| 164 | </label> |
| 165 | <span class="newrepo-slash" aria-hidden="true">("/")</span> |
| 166 | <label class="newrepo-name"> |
| 167 | <span>(g::t(lang, K::RepoName))</span> |
| 168 | <input type="text" name="name" value=(draft.name) required=(true) maxlength="64" autocomplete="off" class="newrepo-mono"> |
| 169 | </label> |
| 170 | </div> |
| 171 | <label> |
| 172 | <span>(g::t(lang, K::DescriptionOptional))</span> |
| 173 | <textarea name="description" rows="3" maxlength="350">(draft.description)</textarea> |
| 174 | </label> |
| 175 | <div class="newrepo-row newrepo-options"> |
| 176 | <label class="newrepo-branch"> |
| 177 | <span>(g::t(lang, K::DefaultBranch))</span> |
| 178 | <input type="text" name="default_branch" value=(branch) maxlength="255" autocomplete="off" class="newrepo-mono"> |
| 179 | </label> |
| 180 | <label class="inline newrepo-labels"> |
| 181 | <input type="checkbox" name="default_labels" value="on" checked=(default_labels)> |
| 182 | <span>(g::t(lang, K::AddDefaultLabels))</span> |
| 183 | </label> |
| 184 | </div> |
| 185 | </div> |
| 186 | <details class="newrepo-mirror" open=(mirror_open)> |
| 187 | <summary> |
| 188 | <span>(g::t(lang, K::MirrorLegend))</span> |
| 189 | <svg viewBox="0 0 24 24" aria-hidden="true" class="newrepo-chev"><path d="M6 9l6 6 6-6"/></svg> |
| 190 | </summary> |
| 191 | <div class="newrepo-mirror-body"> |
| 192 | <p class="muted small">(g::t(lang, K::MirrorIntro))</p> |
| 193 | <label> |
| 194 | <span>(g::t(lang, K::SourceUrl))</span> |
| 195 | <input type="url" name="mirror_url" value=(draft_mirror_url.as_str()) autocomplete="off" spellcheck="false" maxlength="2048"> |
| 196 | </label> |
| 197 | <div class="newrepo-row"> |
| 198 | <label class="newrepo-half"> |
| 199 | <span>(g::t(lang, K::UsernameOptional))</span> |
| 200 | <input type="text" name="mirror_username" value=(draft_mirror_username.as_str()) autocomplete="off" maxlength="255"> |
| 201 | </label> |
| 202 | <label class="newrepo-half"> |
| 203 | <span>(g::t(lang, K::PasswordOrToken))</span> |
| 204 | <input type="password" name="mirror_secret" autocomplete="new-password" maxlength="1024"> |
| 205 | </label> |
| 206 | </div> |
| 207 | <label class="newrepo-interval"> |
| 208 | <span>(g::t(lang, K::SyncInterval))</span> |
| 209 | <input type="number" name="mirror_interval" value=(draft_mirror_interval.as_str()) min="0" max="525600" step="1"> |
| 210 | </label> |
| 211 | </div> |
| 212 | </details> |
| 213 | </form> |
| 214 | }) |
| 215 | } |
| 216 | |
| 217 | /// The accounts the viewer may create a repo under: themselves, then the orgs where |
| 218 | /// `may_create_repo_in` allows it. |
| 219 | async fn owner_choices(state: &AppState, viewer: &User) -> Result<Vec<String>> { |
| 220 | let mut names = vec![viewer.username.clone()]; |
| 221 | for (org, _) in state.store.orgs_for_user(&viewer.id).await? { |
| 222 | if may_create_repo_in(state, &org.id, &viewer.id).await? { |
| 223 | names.push(org.username); |
| 224 | } |
| 225 | } |
| 226 | Ok(names) |
| 227 | } |
| 228 | |
| 229 | #[page("/new")] |
| 230 | async fn new_repo_page(cx: &Cx) -> Result<impl View> { |
| 231 | let viewer = require_user(cx).await?; |
| 232 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 233 | let owners = owner_choices(app_context::<AppState>(cx), &viewer.0).await?; |
| 234 | let csrf = viewer.1.csrf_token.clone(); |
| 235 | Ok(view! { cx => |
| 236 | shell(title: i18n::t(lang, Key::NewRepository), viewer: Some(&viewer), new_repo_form(lang: lang, csrf: csrf.as_str(), draft: Draft::fresh(), error: None, owners: owners)) |
| 237 | }) |
| 238 | } |
| 239 | |
| 240 | #[page(POST "/new")] |
| 241 | async fn new_repo_submit(cx: &Cx, Form(form): Form<NewRepoForm>) -> Result<impl View> { |
| 242 | let viewer = require_csrf(cx, &form.csrf).await?; |
| 243 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 244 | let outcome = create_repo(cx, &viewer.0, &form).await?; |
| 245 | match outcome { |
| 246 | Ok(location) => Err(see_other(location).into()), |
| 247 | Err(message) => { |
| 248 | let owners = owner_choices(app_context::<AppState>(cx), &viewer.0).await?; |
| 249 | let csrf = viewer.1.csrf_token.clone(); |
| 250 | let draft = Draft::from_form(&form); |
| 251 | Ok(view! { cx => |
| 252 | shell(title: i18n::t(lang, Key::NewRepository), viewer: Some(&viewer), new_repo_form(lang: lang, csrf: csrf.as_str(), draft: draft, error: Some(message), owners: owners)) |
| 253 | }) |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | /// The account the new repo belongs to. Blank or the viewer's own name means the viewer. An org |
| 259 | /// name is accepted only when the viewer may create repos under it. |
| 260 | async fn repo_owner( |
| 261 | state: &AppState, |
| 262 | viewer: &User, |
| 263 | lang: Lang, |
| 264 | requested: &str, |
| 265 | ) -> Result<std::result::Result<User, String>> { |
| 266 | let requested = requested.trim(); |
| 267 | if requested.is_empty() || requested.eq_ignore_ascii_case(&viewer.username) { |
| 268 | return Ok(Ok(viewer.clone())); |
| 269 | } |
| 270 | let refused = g::t(lang, K::OwnerRefused).to_string(); |
| 271 | let Some(org) = state.store.user_by_username(requested).await? else { |
| 272 | return Ok(Err(refused)); |
| 273 | }; |
| 274 | if !org.is_org() || !may_create_repo_in(state, &org.id, &viewer.id).await? { |
| 275 | return Ok(Err(refused)); |
| 276 | } |
| 277 | Ok(Ok(org)) |
| 278 | } |
| 279 | |
| 280 | /// Validates the form and creates the repo. `Ok(Err(message))` is a user-facing rejection; |
| 281 | /// the outer `Err` is an internal failure. |
| 282 | async fn create_repo( |
| 283 | cx: &Cx, |
| 284 | owner: &User, |
| 285 | form: &NewRepoForm, |
| 286 | ) -> Result<std::result::Result<String, String>> { |
| 287 | let state = app_context::<AppState>(cx); |
| 288 | let lang = i18n::lang(cx, Some(owner)); |
| 289 | let name = match validate_name(&form.name) { |
| 290 | Ok(name) => name, |
| 291 | Err(e) => { |
| 292 | return Ok(Err(user_facing_message(&e) |
| 293 | .unwrap_or_else(|| g::t(lang, K::InvalidRepoName).to_owned()))); |
| 294 | } |
| 295 | }; |
| 296 | if form.description.chars().count() > MAX_DESCRIPTION_CHARS { |
| 297 | return Ok(Err(g::description_too_long(lang, MAX_DESCRIPTION_CHARS))); |
| 298 | } |
| 299 | let visibility = match form.visibility.as_str() { |
| 300 | "public" => Visibility::Public, |
| 301 | _ => Visibility::Private, |
| 302 | }; |
| 303 | let branch = match form.default_branch.trim() { |
| 304 | "" => DEFAULT_BRANCH, |
| 305 | other => other, |
| 306 | }; |
| 307 | let add_default_labels = !form.default_labels.is_empty(); |
| 308 | let target = match repo_owner(state, owner, lang, &form.owner).await? { |
| 309 | Ok(target) => target, |
| 310 | Err(message) => return Ok(Err(message)), |
| 311 | }; |
| 312 | // The mirror is checked before anything is created, so a refused URL leaves no repo behind. |
| 313 | let mirror = if form.mirror_url.trim().is_empty() { |
| 314 | None |
| 315 | } else { |
| 316 | match check_form_in( |
| 317 | lang, |
| 318 | state, |
| 319 | &form.mirror_url, |
| 320 | &form.mirror_username, |
| 321 | &form.mirror_secret, |
| 322 | &form.mirror_interval, |
| 323 | ) { |
| 324 | Ok(checked) => Some(checked), |
| 325 | Err(message) => return Ok(Err(message)), |
| 326 | } |
| 327 | }; |
| 328 | |
| 329 | let repo = match state |
| 330 | .store |
| 331 | .create_repo( |
| 332 | &target.id, |
| 333 | &name, |
| 334 | form.description.trim(), |
| 335 | visibility, |
| 336 | branch, |
| 337 | ) |
| 338 | .await |
| 339 | { |
| 340 | Ok(repo) => repo, |
| 341 | Err(e) => match user_facing_message(&e) { |
| 342 | Some(message) => return Ok(Err(message)), |
| 343 | None => return Err(e.into()), |
| 344 | }, |
| 345 | }; |
| 346 | |
| 347 | let path = repo_path(&state.config.data_dir, &repo.id)?; |
| 348 | if let Err(e) = init_bare(&state.git, &path, branch).await { |
| 349 | // TODO: delete the repository row so a failed init does not leave a dangling name. |
| 350 | return Ok(Err(g::git_init_failed(lang, e))); |
| 351 | } |
| 352 | // The repo already exists at this point, so a label failure is logged rather than shown as an |
| 353 | // error page for a repo that was in fact created. |
| 354 | if add_default_labels && let Err(e) = state.store.create_default_labels(&repo.id).await { |
| 355 | tracing::warn!(error = %e, repo_id = %repo.id, "new repo: could not add default labels"); |
| 356 | } |
| 357 | if let Some(checked) = &mirror { |
| 358 | create_pull_mirror(cx, state, owner, &repo, checked).await?; |
| 359 | } |
| 360 | let full_name = format!("{}/{}", repo.owner_name, repo.name); |
| 361 | audit::record( |
| 362 | &state.store, |
| 363 | Some(cx), |
| 364 | Actor::User(owner), |
| 365 | AuditAction::RepoCreated, |
| 366 | Target { |
| 367 | kind: "repo", |
| 368 | id: &repo.id, |
| 369 | name: &full_name, |
| 370 | }, |
| 371 | serde_json::Value::Null, |
| 372 | ) |
| 373 | .await; |
| 374 | Ok(Ok(format!("/{}/{}", repo.owner_name, repo.name))) |
| 375 | } |
| 376 | |
| 377 | /// Creates the pull mirror of a new repo and queues its first sync now. The repo is new and empty, |
| 378 | /// so a store failure here is an internal error. |
| 379 | async fn create_pull_mirror( |
| 380 | cx: &Cx, |
| 381 | state: &AppState, |
| 382 | owner: &User, |
| 383 | repo: &forge_core::Repo, |
| 384 | checked: &Checked, |
| 385 | ) -> Result<()> { |
| 386 | let secret = (!checked.secret.is_empty()).then_some(checked.secret.as_bytes()); |
| 387 | let mirror = state |
| 388 | .store |
| 389 | .create_mirror(&NewMirror { |
| 390 | repo_id: &repo.id, |
| 391 | direction: MirrorDirection::Pull, |
| 392 | url: &checked.url, |
| 393 | username: &checked.username, |
| 394 | secret, |
| 395 | interval_minutes: checked.interval, |
| 396 | }) |
| 397 | .await?; |
| 398 | state |
| 399 | .store |
| 400 | .schedule_mirror(&mirror.id, &now_stamp()) |
| 401 | .await?; |
| 402 | state.mirror_waker.notify_one(); |
| 403 | audit::record( |
| 404 | &state.store, |
| 405 | Some(cx), |
| 406 | Actor::User(owner), |
| 407 | AuditAction::MirrorCreated, |
| 408 | Target { |
| 409 | kind: "repo", |
| 410 | id: &repo.id, |
| 411 | name: &format!("{}/{}", repo.owner_name, repo.name), |
| 412 | }, |
| 413 | serde_json::json!({ |
| 414 | "mirror_id": mirror.id, |
| 415 | "direction": MirrorDirection::Pull.as_str(), |
| 416 | "url": redacted_url(&mirror.url), |
| 417 | }), |
| 418 | ) |
| 419 | .await; |
| 420 | Ok(()) |
| 421 | } |
| 422 | |
| 423 | #[cfg(test)] |
| 424 | mod tests { |
| 425 | use forge_core::Store; |
| 426 | use forge_core::mirrors::MirrorDirection; |
| 427 | use forge_core::time::now_stamp; |
| 428 | use topcoat::router::request::Request; |
| 429 | use topcoat::router::{Body, Router, to_bytes}; |
| 430 | |
| 431 | use crate::auth::SESSION_COOKIE; |
| 432 | |
| 433 | const CSRF: &str = "new-repo-test-csrf"; |
| 434 | |
| 435 | /// POSTs `/new` as alice with the given extra fields; returns the status and the label count |
| 436 | /// of the repo it created, or `None` when no repo was created. |
| 437 | async fn create_with(fields: &[(&str, &str)]) -> (u16, Option<usize>) { |
| 438 | let dir = tempfile::tempdir().expect("tempdir"); |
| 439 | let state = crate::test_support::test_state(dir.path()).await; |
| 440 | let store: Store = state.store.clone(); |
| 441 | let router: Router = crate::test_support::app_router(state); |
| 442 | let alice = store |
| 443 | .create_user("alice", None, "x", false) |
| 444 | .await |
| 445 | .expect("alice"); |
| 446 | crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; |
| 447 | let cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); |
| 448 | |
| 449 | let mut pairs = vec![("csrf", CSRF), ("name", "fresh")]; |
| 450 | pairs.extend_from_slice(fields); |
| 451 | let body = form_urlencoded::Serializer::new(String::new()) |
| 452 | .extend_pairs(pairs) |
| 453 | .finish(); |
| 454 | let req = Request::builder() |
| 455 | .method("POST") |
| 456 | .uri("/new") |
| 457 | .header("cookie", cookie) |
| 458 | .header("content-type", "application/x-www-form-urlencoded") |
| 459 | .body(Body::from(body)) |
| 460 | .expect("request"); |
| 461 | let res = router.handle(req).await; |
| 462 | let status = res.status().as_u16(); |
| 463 | let _ = to_bytes(res.into_body(), usize::MAX).await.expect("body"); |
| 464 | |
| 465 | let repo = store |
| 466 | .repo_by_owner_and_name("alice", "fresh") |
| 467 | .await |
| 468 | .expect("query"); |
| 469 | let count = match repo { |
| 470 | Some(repo) => Some(store.list_labels(&repo.id).await.expect("labels").len()), |
| 471 | None => None, |
| 472 | }; |
| 473 | (status, count) |
| 474 | } |
| 475 | |
| 476 | #[tokio::test] |
| 477 | async fn new_repo_with_default_labels_checked_gets_nine() { |
| 478 | let (status, count) = create_with(&[("default_labels", "on")]).await; |
| 479 | assert_eq!(status, 303); |
| 480 | assert_eq!(count, Some(9)); |
| 481 | } |
| 482 | |
| 483 | #[tokio::test] |
| 484 | async fn new_repo_with_default_labels_unchecked_gets_none() { |
| 485 | let (status, count) = create_with(&[]).await; |
| 486 | assert_eq!(status, 303); |
| 487 | assert_eq!(count, Some(0)); |
| 488 | } |
| 489 | |
| 490 | /// POSTs `/new` as alice with the mirror fields and returns the status, the store and the repo |
| 491 | /// created (if any). The form's token is the shared CSRF value. |
| 492 | async fn submit_with_mirror(fields: &[(&str, &str)]) -> (u16, Store, Option<forge_core::Repo>) { |
| 493 | let dir = tempfile::tempdir().expect("tempdir"); |
| 494 | let state = crate::test_support::test_state(dir.path()).await; |
| 495 | let store: Store = state.store.clone(); |
| 496 | let router: Router = crate::test_support::app_router(state); |
| 497 | let alice = store |
| 498 | .create_user("alice", None, "x", false) |
| 499 | .await |
| 500 | .expect("alice"); |
| 501 | crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; |
| 502 | let cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); |
| 503 | |
| 504 | let mut pairs = vec![("csrf", CSRF), ("name", "mirrored")]; |
| 505 | pairs.extend_from_slice(fields); |
| 506 | let body = form_urlencoded::Serializer::new(String::new()) |
| 507 | .extend_pairs(pairs) |
| 508 | .finish(); |
| 509 | let req = Request::builder() |
| 510 | .method("POST") |
| 511 | .uri("/new") |
| 512 | .header("cookie", cookie) |
| 513 | .header("content-type", "application/x-www-form-urlencoded") |
| 514 | .body(Body::from(body)) |
| 515 | .expect("request"); |
| 516 | let res = router.handle(req).await; |
| 517 | let status = res.status().as_u16(); |
| 518 | let body = to_bytes(res.into_body(), usize::MAX).await.expect("body"); |
| 519 | let text = String::from_utf8_lossy(&body).into_owned(); |
| 520 | assert!( |
| 521 | !text.contains("hunter2-secret"), |
| 522 | "the mirror password is rendered" |
| 523 | ); |
| 524 | let repo = store |
| 525 | .repo_by_owner_and_name("alice", "mirrored") |
| 526 | .await |
| 527 | .expect("query"); |
| 528 | (status, store, repo) |
| 529 | } |
| 530 | |
| 531 | #[tokio::test] |
| 532 | async fn new_repo_with_mirror_url_creates_a_pull_mirror() { |
| 533 | let (status, store, repo) = submit_with_mirror(&[ |
| 534 | ("mirror_url", "https://example.com/upstream/proj.git"), |
| 535 | ("mirror_username", "bot"), |
| 536 | ("mirror_secret", "hunter2-secret"), |
| 537 | ("mirror_interval", "60"), |
| 538 | ]) |
| 539 | .await; |
| 540 | assert_eq!(status, 303); |
| 541 | let repo = repo.expect("the repo is created"); |
| 542 | let mirror = store |
| 543 | .pull_mirror(&repo.id) |
| 544 | .await |
| 545 | .expect("query") |
| 546 | .expect("a pull mirror"); |
| 547 | assert_eq!(mirror.direction, MirrorDirection::Pull); |
| 548 | assert_eq!(mirror.url, "https://example.com/upstream/proj.git"); |
| 549 | assert_eq!(mirror.username, "bot"); |
| 550 | assert!(mirror.has_secret); |
| 551 | assert_eq!(mirror.interval_minutes, 60); |
| 552 | assert!( |
| 553 | mirror.next_sync_at <= now_stamp(), |
| 554 | "the first sync is due now" |
| 555 | ); |
| 556 | } |
| 557 | |
| 558 | #[tokio::test] |
| 559 | async fn new_repo_with_loopback_mirror_url_is_refused_and_creates_nothing() { |
| 560 | let (status, store, repo) = |
| 561 | submit_with_mirror(&[("mirror_url", "https://127.0.0.1/upstream/proj.git")]).await; |
| 562 | assert_eq!(status, 200, "the form is shown again with the error"); |
| 563 | assert!(repo.is_none(), "no repo is created for a refused mirror"); |
| 564 | assert!( |
| 565 | store |
| 566 | .repo_by_owner_and_name("alice", "mirrored") |
| 567 | .await |
| 568 | .expect("query") |
| 569 | .is_none() |
| 570 | ); |
| 571 | } |
| 572 | |
| 573 | #[tokio::test] |
| 574 | async fn new_repo_with_blank_mirror_url_creates_no_mirror() { |
| 575 | let (status, store, repo) = submit_with_mirror(&[("mirror_url", " ")]).await; |
| 576 | assert_eq!(status, 303); |
| 577 | let repo = repo.expect("the repo is created"); |
| 578 | assert!(store.pull_mirror(&repo.id).await.expect("query").is_none()); |
| 579 | } |
| 580 | |
| 581 | #[tokio::test] |
| 582 | async fn the_form_is_in_turkish_for_a_turkish_viewer() { |
| 583 | let dir = tempfile::tempdir().expect("tempdir"); |
| 584 | let state = crate::test_support::test_state(dir.path()).await; |
| 585 | let store: Store = state.store.clone(); |
| 586 | let router: Router = crate::test_support::app_router(state); |
| 587 | let alice = store |
| 588 | .create_user("alice", None, "x", false) |
| 589 | .await |
| 590 | .expect("alice"); |
| 591 | store |
| 592 | .set_ui_prefs(&alice.id, "auto", "tr") |
| 593 | .await |
| 594 | .expect("prefs"); |
| 595 | crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; |
| 596 | let req = Request::builder() |
| 597 | .method("GET") |
| 598 | .uri("/new") |
| 599 | .header("cookie", format!("__Host-{SESSION_COOKIE}=alice-token")) |
| 600 | .body(Body::empty()) |
| 601 | .expect("request"); |
| 602 | let res = router.handle(req).await; |
| 603 | let status = res.status().as_u16(); |
| 604 | let body = to_bytes(res.into_body(), usize::MAX).await.expect("body"); |
| 605 | let text = String::from_utf8_lossy(&body).into_owned(); |
| 606 | assert_eq!(status, 200, "{text}"); |
| 607 | assert!(text.contains("Yeni depo"), "{text}"); |
| 608 | assert!(text.contains("Depo oluştur"), "{text}"); |
| 609 | } |
| 610 | } |