| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use forge_core::AuditAction; |
| 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::{SeeOther, forbidden, not_found, see_other}; |
| 13 | use topcoat::router::request::uri; |
| 14 | use topcoat::router::{page, path_param, route}; |
| 15 | use topcoat::view::{View, component, view}; |
| 16 | |
| 17 | use crate::audit::{self, Actor, Target}; |
| 18 | use crate::auth::{require_csrf, require_user}; |
| 19 | use crate::i18n::repo_admin::{self as g, K}; |
| 20 | use crate::i18n::{self, Lang}; |
| 21 | use crate::pages::repo::repo_head; |
| 22 | use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; |
| 23 | use crate::pages::repo_mirrors::stamp_in_tz; |
| 24 | use crate::pages::repo_settings::settings_nav_for; |
| 25 | use crate::state::AppState; |
| 26 | use crate::views::{alert, enc, shell}; |
| 27 | |
| 28 | path_param!(owner); |
| 29 | path_param!(repo); |
| 30 | |
| 31 | |
| 32 | pub const MAX_VALUE_BYTES: usize = 48 * 1024; |
| 33 | |
| 34 | const MAX_NAME_CHARS: usize = 64; |
| 35 | |
| 36 | |
| 37 | const NOTICES: [(&str, K, bool); 3] = [ |
| 38 | ("saved", K::SecretSaved, false), |
| 39 | ("deleted", K::SecretDeleted, false), |
| 40 | ("missing", K::SecretMissing, true), |
| 41 | ]; |
| 42 | |
| 43 | #[derive(Deserialize)] |
| 44 | struct SetForm { |
| 45 | #[serde(default)] |
| 46 | csrf: String, |
| 47 | #[serde(default)] |
| 48 | name: String, |
| 49 | #[serde(default)] |
| 50 | value: String, |
| 51 | } |
| 52 | |
| 53 | #[derive(Deserialize)] |
| 54 | struct DeleteForm { |
| 55 | #[serde(default)] |
| 56 | csrf: String, |
| 57 | #[serde(default)] |
| 58 | name: String, |
| 59 | } |
| 60 | |
| 61 | |
| 62 | pub fn valid_name(name: &str) -> bool { |
| 63 | let bytes = name.as_bytes(); |
| 64 | let Some((first, rest)) = bytes.split_first() else { |
| 65 | return false; |
| 66 | }; |
| 67 | rest.len() < MAX_NAME_CHARS |
| 68 | && (first.is_ascii_uppercase() || *first == b'_') |
| 69 | && rest |
| 70 | .iter() |
| 71 | .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_') |
| 72 | } |
| 73 | |
| 74 | |
| 75 | fn check_secret(lang: Lang, name: &str, value: &str) -> std::result::Result<(), String> { |
| 76 | if !valid_name(name) { |
| 77 | return Err(g::t(lang, K::NameRule).to_owned()); |
| 78 | } |
| 79 | if value.is_empty() { |
| 80 | return Err(g::t(lang, K::EnterValue).to_owned()); |
| 81 | } |
| 82 | if value.len() > MAX_VALUE_BYTES { |
| 83 | return Err(g::t(lang, K::ValueTooLarge).to_owned()); |
| 84 | } |
| 85 | Ok(()) |
| 86 | } |
| 87 | |
| 88 | fn title_of(ctx: &RepoCtx) -> String { |
| 89 | format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) |
| 90 | } |
| 91 | |
| 92 | fn secrets_href(title: &str) -> String { |
| 93 | format!("/{title}/settings/secrets") |
| 94 | } |
| 95 | |
| 96 | fn back_to_list(title: &str, notice: &str) -> SeeOther { |
| 97 | see_other(format!("{}?notice={}", secrets_href(title), enc(notice))) |
| 98 | } |
| 99 | |
| 100 | |
| 101 | fn notice_from_query(cx: &Cx) -> Option<(K, bool)> { |
| 102 | let query = uri(cx).query()?; |
| 103 | let word = form_urlencoded::parse(query.as_bytes()) |
| 104 | .find(|(key, _)| key == "notice") |
| 105 | .map(|(_, value)| value.into_owned())?; |
| 106 | NOTICES |
| 107 | .iter() |
| 108 | .find(|(name, _, _)| *name == word) |
| 109 | .map(|(_, key, is_error)| (*key, *is_error)) |
| 110 | } |
| 111 | |
| 112 | |
| 113 | struct SecretRow { |
| 114 | name: String, |
| 115 | updated: String, |
| 116 | } |
| 117 | |
| 118 | |
| 119 | struct SecretsData { |
| 120 | page_title: String, |
| 121 | title: String, |
| 122 | archived: bool, |
| 123 | head: RepoHead, |
| 124 | rows: Vec<SecretRow>, |
| 125 | } |
| 126 | |
| 127 | impl SecretsData { |
| 128 | async fn load(state: &AppState, ctx: &RepoCtx, lang: Lang) -> Result<Self> { |
| 129 | let title = title_of(ctx); |
| 130 | let tz = ctx.viewer_tz().to_owned(); |
| 131 | let rows = state |
| 132 | .store |
| 133 | .list_ci_secret_names(&ctx.repo.id) |
| 134 | .await? |
| 135 | .into_iter() |
| 136 | .map(|(name, updated)| SecretRow { |
| 137 | name, |
| 138 | updated: stamp_in_tz(&updated, &tz), |
| 139 | }) |
| 140 | .collect(); |
| 141 | Ok(Self { |
| 142 | page_title: g::secrets_page_title(lang, &title), |
| 143 | title, |
| 144 | archived: ctx.repo.archived, |
| 145 | head: ctx.head(state, "settings").await?, |
| 146 | rows, |
| 147 | }) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | |
| 152 | #[component] |
| 153 | async fn frame_top( |
| 154 | lang: Lang, |
| 155 | head: RepoHead, |
| 156 | title: String, |
| 157 | notice: Option<(K, bool)>, |
| 158 | archived: bool, |
| 159 | ) -> Result<impl View> { |
| 160 | let notice = notice.map(|(key, is_error)| (g::t(lang, key), is_error)); |
| 161 | Ok(view! { |
| 162 | repo_head(head: head) |
| 163 | settings_nav_for(lang: lang, title: title, current: "secrets") |
| 164 | if let Some((text, is_error)) = notice { |
| 165 | if is_error { |
| 166 | alert(message: text) |
| 167 | } else { |
| 168 | <section class="card notice" role="status">(text)</section> |
| 169 | } |
| 170 | } |
| 171 | if archived { |
| 172 | <section class="card notice" role="status">(g::t(lang, K::ArchivedSecrets))</section> |
| 173 | } |
| 174 | }) |
| 175 | } |
| 176 | |
| 177 | |
| 178 | |
| 179 | #[component] |
| 180 | async fn secrets_body( |
| 181 | lang: Lang, |
| 182 | head: RepoHead, |
| 183 | title: String, |
| 184 | notice: Option<(K, bool)>, |
| 185 | archived: bool, |
| 186 | csrf: String, |
| 187 | rows: Vec<SecretRow>, |
| 188 | draft_name: String, |
| 189 | error: Option<String>, |
| 190 | ) -> Result<impl View> { |
| 191 | let set_action = secrets_href(&title); |
| 192 | let delete_action = format!("{set_action}/delete"); |
| 193 | Ok(view! { |
| 194 | frame_top(lang: lang, head: head, title: title.clone(), notice: notice, archived: archived) |
| 195 | <section class="card"> |
| 196 | <h2>(g::t(lang, K::Secrets))</h2> |
| 197 | <p class="muted small">(g::t(lang, K::SecretsHelp))</p> |
| 198 | if rows.is_empty() { |
| 199 | <p class="muted">(g::t(lang, K::NoSecrets))</p> |
| 200 | } else { |
| 201 | <div class="table-wrap"> |
| 202 | <table class="table secrets"> |
| 203 | <thead> |
| 204 | <tr> |
| 205 | <th>(g::t(lang, K::Name))</th> |
| 206 | <th>(g::t(lang, K::LastUpdated))</th> |
| 207 | <th></th> |
| 208 | </tr> |
| 209 | </thead> |
| 210 | <tbody> |
| 211 | for row in rows.iter() { |
| 212 | <tr> |
| 213 | <td><code>(row.name.as_str())</code></td> |
| 214 | <td class="small">(row.updated.as_str())</td> |
| 215 | <td> |
| 216 | if !archived { |
| 217 | <form method="post" action=(delete_action.as_str()) class="inline-form"> |
| 218 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 219 | <input type="hidden" name="name" value=(row.name.as_str())> |
| 220 | <button type="submit" class="danger">(g::t(lang, K::Delete))</button> |
| 221 | </form> |
| 222 | } |
| 223 | </td> |
| 224 | </tr> |
| 225 | } |
| 226 | </tbody> |
| 227 | </table> |
| 228 | </div> |
| 229 | } |
| 230 | </section> |
| 231 | if !archived { |
| 232 | <section class="card"> |
| 233 | <h2>(g::t(lang, K::AddOrUpdate))</h2> |
| 234 | if let Some(message) = error { |
| 235 | <p class="alert" role="alert">(message.as_str())</p> |
| 236 | } |
| 237 | <form method="post" action=(set_action.as_str()) class="stack"> |
| 238 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 239 | <label> |
| 240 | (g::t(lang, K::Name)) |
| 241 | <input type="text" name="name" value=(draft_name.as_str()) required=(true) maxlength="64" |
| 242 | pattern="[A-Z_][A-Z0-9_]{0,63}" autocomplete="off" spellcheck="false" |
| 243 | placeholder="DEPLOY_TOKEN"> |
| 244 | </label> |
| 245 | <label> |
| 246 | (g::t(lang, K::Value)) |
| 247 | <textarea name="value" required=(true) rows="6" autocomplete="off" spellcheck="false"></textarea> |
| 248 | </label> |
| 249 | <p class="muted small">(g::t(lang, K::SecretLimits))</p> |
| 250 | <div class="actions"> |
| 251 | <button type="submit" class="primary">(g::t(lang, K::SaveSecret))</button> |
| 252 | </div> |
| 253 | </form> |
| 254 | </section> |
| 255 | } |
| 256 | }) |
| 257 | } |
| 258 | |
| 259 | |
| 260 | #[page("/{owner}/{repo}/settings/secrets")] |
| 261 | async fn secrets_list_page(cx: &Cx) -> Result<impl View> { |
| 262 | let owner = path_param::<Owner>(cx); |
| 263 | let name = path_param::<Repo>(cx); |
| 264 | let ctx = load_repo(cx, owner, name).await?; |
| 265 | let (user, session) = require_user(cx).await?; |
| 266 | if !ctx.can_admin() { |
| 267 | return Err(not_found().into()); |
| 268 | } |
| 269 | let lang = i18n::lang(cx, Some(&user)); |
| 270 | let state = app_context::<AppState>(cx); |
| 271 | let data = SecretsData::load(state, &ctx, lang).await?; |
| 272 | let csrf = session.csrf_token.clone(); |
| 273 | let notice = notice_from_query(cx); |
| 274 | Ok(view! { |
| 275 | shell(title: data.page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 276 | secrets_body(lang: lang, head: data.head, title: data.title, notice: notice, |
| 277 | archived: data.archived, csrf: csrf, rows: data.rows, |
| 278 | draft_name: String::new(), error: None)) |
| 279 | }) |
| 280 | } |
| 281 | |
| 282 | |
| 283 | |
| 284 | #[page(POST "/{owner}/{repo}/settings/secrets")] |
| 285 | async fn secret_set(cx: &Cx, Form(form): Form<SetForm>) -> Result<impl View> { |
| 286 | let owner = path_param::<Owner>(cx); |
| 287 | let name = path_param::<Repo>(cx); |
| 288 | let ctx = load_repo(cx, owner, name).await?; |
| 289 | let (user, session) = require_csrf(cx, &form.csrf).await?; |
| 290 | if !ctx.can_admin() { |
| 291 | return Err(not_found().into()); |
| 292 | } |
| 293 | if ctx.repo.archived { |
| 294 | return Err(forbidden().into()); |
| 295 | } |
| 296 | let lang = i18n::lang(cx, Some(&user)); |
| 297 | let state = app_context::<AppState>(cx); |
| 298 | let secret_name = form.name.trim().to_owned(); |
| 299 | if let Err(message) = check_secret(lang, &secret_name, &form.value) { |
| 300 | let data = SecretsData::load(state, &ctx, lang).await?; |
| 301 | let csrf = session.csrf_token.clone(); |
| 302 | return Ok(view! { |
| 303 | shell(title: data.page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 304 | secrets_body(lang: lang, head: data.head, title: data.title, notice: None, |
| 305 | archived: data.archived, csrf: csrf, rows: data.rows, |
| 306 | draft_name: secret_name.clone(), error: Some(message))) |
| 307 | }); |
| 308 | } |
| 309 | state |
| 310 | .store |
| 311 | .set_ci_secret(&ctx.repo.id, &secret_name, form.value.as_bytes()) |
| 312 | .await?; |
| 313 | audit::record( |
| 314 | &state.store, |
| 315 | Some(cx), |
| 316 | Actor::User(&user), |
| 317 | AuditAction::CiSecretSet, |
| 318 | Target { |
| 319 | kind: "repo", |
| 320 | id: &ctx.repo.id, |
| 321 | name: &title_of(&ctx), |
| 322 | }, |
| 323 | serde_json::json!({ "name": secret_name }), |
| 324 | ) |
| 325 | .await; |
| 326 | tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, "ci secret set"); |
| 327 | Err(back_to_list(&title_of(&ctx), "saved").into()) |
| 328 | } |
| 329 | |
| 330 | |
| 331 | #[route(POST "/{owner}/{repo}/settings/secrets/delete")] |
| 332 | async fn secret_delete(cx: &Cx, Form(form): Form<DeleteForm>) -> Result<SeeOther> { |
| 333 | let owner = path_param::<Owner>(cx); |
| 334 | let name = path_param::<Repo>(cx); |
| 335 | let ctx = load_repo(cx, owner, name).await?; |
| 336 | let (user, _) = require_csrf(cx, &form.csrf).await?; |
| 337 | if !ctx.can_admin() { |
| 338 | return Err(not_found().into()); |
| 339 | } |
| 340 | if ctx.repo.archived { |
| 341 | return Err(forbidden().into()); |
| 342 | } |
| 343 | let state = app_context::<AppState>(cx); |
| 344 | let title = title_of(&ctx); |
| 345 | let secret_name = form.name.trim().to_owned(); |
| 346 | if !valid_name(&secret_name) |
| 347 | || !state |
| 348 | .store |
| 349 | .delete_ci_secret(&ctx.repo.id, &secret_name) |
| 350 | .await? |
| 351 | { |
| 352 | return Ok(back_to_list(&title, "missing")); |
| 353 | } |
| 354 | audit::record( |
| 355 | &state.store, |
| 356 | Some(cx), |
| 357 | Actor::User(&user), |
| 358 | AuditAction::CiSecretDeleted, |
| 359 | Target { |
| 360 | kind: "repo", |
| 361 | id: &ctx.repo.id, |
| 362 | name: &title, |
| 363 | }, |
| 364 | serde_json::json!({ "name": secret_name }), |
| 365 | ) |
| 366 | .await; |
| 367 | tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, "ci secret deleted"); |
| 368 | Ok(back_to_list(&title, "deleted")) |
| 369 | } |
| 370 | |
| 371 | #[cfg(test)] |
| 372 | mod tests { |
| 373 | use super::*; |
| 374 | use forge_core::{Store, Visibility}; |
| 375 | use forge_git::repo::{init_bare, repo_path}; |
| 376 | use topcoat::router::request::Request; |
| 377 | use topcoat::router::{Body, Router, to_bytes}; |
| 378 | |
| 379 | use crate::auth::SESSION_COOKIE; |
| 380 | use crate::test_support::{app_router, create_session, test_state}; |
| 381 | |
| 382 | const CSRF: &str = "secrets-test-csrf"; |
| 383 | const SECRET_VALUE: &str = "s3cr3t-value-DO-NOT-LEAK"; |
| 384 | |
| 385 | |
| 386 | async fn fixture(dir: &std::path::Path) -> (Store, Router, forge_core::Repo) { |
| 387 | let state = test_state(dir).await; |
| 388 | let store = state.store.clone(); |
| 389 | let alice = store |
| 390 | .create_user("alice", None, "unused", false) |
| 391 | .await |
| 392 | .expect("alice"); |
| 393 | let bob = store |
| 394 | .create_user("bob", None, "unused", false) |
| 395 | .await |
| 396 | .expect("bob"); |
| 397 | let carol = store |
| 398 | .create_user("carol", None, "unused", false) |
| 399 | .await |
| 400 | .expect("carol"); |
| 401 | let repo = store |
| 402 | .create_repo(&alice.id, "proj", "", Visibility::Private, "main") |
| 403 | .await |
| 404 | .expect("repo"); |
| 405 | let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); |
| 406 | init_bare(&state.git, &bare, "main").await.expect("init"); |
| 407 | store |
| 408 | .set_collaborator(&repo.id, &bob.id, forge_core::Role::Write) |
| 409 | .await |
| 410 | .expect("collaborator"); |
| 411 | create_session(&store, &alice.id, "alice-token", CSRF).await; |
| 412 | create_session(&store, &bob.id, "bob-token", CSRF).await; |
| 413 | create_session(&store, &carol.id, "carol-token", CSRF).await; |
| 414 | (store, app_router(state), repo) |
| 415 | } |
| 416 | |
| 417 | fn cookie_for(token: &str) -> String { |
| 418 | format!("__Host-{SESSION_COOKIE}={token}") |
| 419 | } |
| 420 | |
| 421 | |
| 422 | |
| 423 | async fn send( |
| 424 | app: &Router, |
| 425 | method: &str, |
| 426 | uri: &str, |
| 427 | token: Option<&str>, |
| 428 | fields: &[(&str, &str)], |
| 429 | csrf: Option<&str>, |
| 430 | ) -> (u16, Option<String>, String) { |
| 431 | let mut builder = Request::builder().method(method).uri(uri); |
| 432 | if let Some(token) = token { |
| 433 | builder = builder.header("cookie", cookie_for(token)); |
| 434 | } |
| 435 | let body = if method == "POST" { |
| 436 | let mut form = form_urlencoded::Serializer::new(String::new()); |
| 437 | form.append_pair("csrf", csrf.unwrap_or(CSRF)); |
| 438 | for (key, value) in fields { |
| 439 | form.append_pair(key, value); |
| 440 | } |
| 441 | builder = builder.header("content-type", "application/x-www-form-urlencoded"); |
| 442 | Body::from(form.finish()) |
| 443 | } else { |
| 444 | Body::empty() |
| 445 | }; |
| 446 | let res = app.handle(builder.body(body).expect("request")).await; |
| 447 | let status = res.status().as_u16(); |
| 448 | let location = res |
| 449 | .headers() |
| 450 | .get("location") |
| 451 | .and_then(|v| v.to_str().ok()) |
| 452 | .map(str::to_owned); |
| 453 | let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); |
| 454 | ( |
| 455 | status, |
| 456 | location, |
| 457 | String::from_utf8_lossy(&bytes).into_owned(), |
| 458 | ) |
| 459 | } |
| 460 | |
| 461 | const PAGE: &str = "/alice/proj/settings/secrets"; |
| 462 | |
| 463 | async fn names(store: &Store, repo: &forge_core::Repo) -> Vec<String> { |
| 464 | store |
| 465 | .list_ci_secret_names(&repo.id) |
| 466 | .await |
| 467 | .expect("names") |
| 468 | .into_iter() |
| 469 | .map(|(name, _)| name) |
| 470 | .collect() |
| 471 | } |
| 472 | |
| 473 | #[test] |
| 474 | fn names_follow_the_pattern() { |
| 475 | for ok in ["A", "_", "DEPLOY_TOKEN", "X1", "_9"] { |
| 476 | assert!(valid_name(ok), "{ok} should be valid"); |
| 477 | } |
| 478 | let long = format!("A{}", "1".repeat(63)); |
| 479 | assert!(valid_name(&long), "64 characters is the limit"); |
| 480 | let too_long = format!("A{}", "1".repeat(64)); |
| 481 | for bad in [ |
| 482 | "", |
| 483 | "1A", |
| 484 | "lower", |
| 485 | "A-B", |
| 486 | "A B", |
| 487 | "A.B", |
| 488 | "AÉ", |
| 489 | too_long.as_str(), |
| 490 | ] { |
| 491 | assert!(!valid_name(bad), "{bad:?} should be rejected"); |
| 492 | } |
| 493 | } |
| 494 | |
| 495 | #[tokio::test] |
| 496 | async fn non_admin_gets_404_and_anonymous_is_sent_to_login() { |
| 497 | let dir = tempfile::tempdir().expect("tempdir"); |
| 498 | let (store, app, repo) = fixture(dir.path()).await; |
| 499 | let (status, _, _) = send(&app, "GET", PAGE, Some("bob-token"), &[], None).await; |
| 500 | assert_eq!(status, 404, "a write collaborator is not an admin"); |
| 501 | let (status, _, _) = send(&app, "GET", PAGE, Some("carol-token"), &[], None).await; |
| 502 | assert_eq!(status, 404, "an outsider gets 404"); |
| 503 | let (status, _, _) = send( |
| 504 | &app, |
| 505 | "POST", |
| 506 | PAGE, |
| 507 | Some("bob-token"), |
| 508 | &[("name", "DEPLOY"), ("value", "x")], |
| 509 | None, |
| 510 | ) |
| 511 | .await; |
| 512 | assert_eq!(status, 404); |
| 513 | let (status, _, _) = send( |
| 514 | &app, |
| 515 | "POST", |
| 516 | "/alice/proj/settings/secrets/delete", |
| 517 | Some("carol-token"), |
| 518 | &[("name", "DEPLOY")], |
| 519 | None, |
| 520 | ) |
| 521 | .await; |
| 522 | assert_eq!(status, 404); |
| 523 | assert!(names(&store, &repo).await.is_empty(), "nothing was stored"); |
| 524 | let (status, _, _) = send(&app, "GET", PAGE, None, &[], None).await; |
| 525 | assert_ne!(status, 200, "anonymous users do not see the page"); |
| 526 | } |
| 527 | |
| 528 | #[tokio::test] |
| 529 | async fn post_without_a_valid_csrf_token_is_forbidden() { |
| 530 | let dir = tempfile::tempdir().expect("tempdir"); |
| 531 | let (store, app, repo) = fixture(dir.path()).await; |
| 532 | let (status, _, _) = send( |
| 533 | &app, |
| 534 | "POST", |
| 535 | PAGE, |
| 536 | Some("alice-token"), |
| 537 | &[("name", "DEPLOY"), ("value", SECRET_VALUE)], |
| 538 | Some("wrong-token"), |
| 539 | ) |
| 540 | .await; |
| 541 | assert_eq!(status, 403); |
| 542 | let (status, _, _) = send( |
| 543 | &app, |
| 544 | "POST", |
| 545 | "/alice/proj/settings/secrets/delete", |
| 546 | Some("alice-token"), |
| 547 | &[("name", "DEPLOY")], |
| 548 | Some(""), |
| 549 | ) |
| 550 | .await; |
| 551 | assert_eq!(status, 403); |
| 552 | assert!(names(&store, &repo).await.is_empty()); |
| 553 | } |
| 554 | |
| 555 | #[tokio::test] |
| 556 | async fn value_never_appears_in_any_response() { |
| 557 | let dir = tempfile::tempdir().expect("tempdir"); |
| 558 | let (store, app, repo) = fixture(dir.path()).await; |
| 559 | let (status, location, body) = send( |
| 560 | &app, |
| 561 | "POST", |
| 562 | PAGE, |
| 563 | Some("alice-token"), |
| 564 | &[("name", "DEPLOY_TOKEN"), ("value", SECRET_VALUE)], |
| 565 | None, |
| 566 | ) |
| 567 | .await; |
| 568 | assert_eq!(status, 303); |
| 569 | assert!(location.is_some_and(|l| l.contains("notice=saved"))); |
| 570 | assert!(!body.contains(SECRET_VALUE)); |
| 571 | assert_eq!(names(&store, &repo).await, vec!["DEPLOY_TOKEN".to_owned()]); |
| 572 | |
| 573 | let (_, _, page) = send(&app, "GET", PAGE, Some("alice-token"), &[], None).await; |
| 574 | assert!(page.contains("DEPLOY_TOKEN"), "the name is listed"); |
| 575 | assert!(!page.contains(SECRET_VALUE), "the value is never listed"); |
| 576 | |
| 577 | |
| 578 | let (status, _, refused) = send( |
| 579 | &app, |
| 580 | "POST", |
| 581 | PAGE, |
| 582 | Some("alice-token"), |
| 583 | &[("name", "bad-name"), ("value", SECRET_VALUE)], |
| 584 | None, |
| 585 | ) |
| 586 | .await; |
| 587 | assert_eq!(status, 200); |
| 588 | assert!(refused.contains("Names are 1 to 64 characters")); |
| 589 | assert!( |
| 590 | !refused.contains(SECRET_VALUE), |
| 591 | "no echo after a validation error" |
| 592 | ); |
| 593 | assert_eq!(names(&store, &repo).await.len(), 1); |
| 594 | } |
| 595 | |
| 596 | #[tokio::test] |
| 597 | async fn bad_name_and_bad_value_are_rejected() { |
| 598 | let dir = tempfile::tempdir().expect("tempdir"); |
| 599 | let (store, app, repo) = fixture(dir.path()).await; |
| 600 | let too_long = format!("A{}", "1".repeat(64)); |
| 601 | for name in ["", "lower", "1NUM", "A-B", too_long.as_str()] { |
| 602 | let (status, _, body) = send( |
| 603 | &app, |
| 604 | "POST", |
| 605 | PAGE, |
| 606 | Some("alice-token"), |
| 607 | &[("name", name), ("value", "v")], |
| 608 | None, |
| 609 | ) |
| 610 | .await; |
| 611 | assert_eq!(status, 200, "{name:?} re-renders the form"); |
| 612 | assert!(body.contains("Names are 1 to 64 characters"), "{name:?}"); |
| 613 | } |
| 614 | let (_, _, body) = send( |
| 615 | &app, |
| 616 | "POST", |
| 617 | PAGE, |
| 618 | Some("alice-token"), |
| 619 | &[("name", "EMPTY"), ("value", "")], |
| 620 | None, |
| 621 | ) |
| 622 | .await; |
| 623 | assert!(body.contains("Enter a value.")); |
| 624 | |
| 625 | let oversize = "x".repeat(MAX_VALUE_BYTES + 1); |
| 626 | let (_, _, body) = send( |
| 627 | &app, |
| 628 | "POST", |
| 629 | PAGE, |
| 630 | Some("alice-token"), |
| 631 | &[("name", "BIG"), ("value", oversize.as_str())], |
| 632 | None, |
| 633 | ) |
| 634 | .await; |
| 635 | assert!(body.contains("larger than 48 KiB")); |
| 636 | assert!(names(&store, &repo).await.is_empty(), "nothing stored"); |
| 637 | |
| 638 | let exact = "y".repeat(MAX_VALUE_BYTES); |
| 639 | let (status, _, _) = send( |
| 640 | &app, |
| 641 | "POST", |
| 642 | PAGE, |
| 643 | Some("alice-token"), |
| 644 | &[("name", "EXACT"), ("value", exact.as_str())], |
| 645 | None, |
| 646 | ) |
| 647 | .await; |
| 648 | assert_eq!(status, 303, "48 KiB exactly is accepted"); |
| 649 | assert_eq!(names(&store, &repo).await, vec!["EXACT".to_owned()]); |
| 650 | } |
| 651 | |
| 652 | #[tokio::test] |
| 653 | async fn set_twice_updates_and_delete_removes() { |
| 654 | let dir = tempfile::tempdir().expect("tempdir"); |
| 655 | let (store, app, repo) = fixture(dir.path()).await; |
| 656 | for value in ["first", "second"] { |
| 657 | send( |
| 658 | &app, |
| 659 | "POST", |
| 660 | PAGE, |
| 661 | Some("alice-token"), |
| 662 | &[("name", "API_KEY"), ("value", value)], |
| 663 | None, |
| 664 | ) |
| 665 | .await; |
| 666 | } |
| 667 | assert_eq!(names(&store, &repo).await, vec!["API_KEY".to_owned()]); |
| 668 | |
| 669 | let delete = "/alice/proj/settings/secrets/delete"; |
| 670 | let (status, location, _) = send( |
| 671 | &app, |
| 672 | "POST", |
| 673 | delete, |
| 674 | Some("alice-token"), |
| 675 | &[("name", "API_KEY")], |
| 676 | None, |
| 677 | ) |
| 678 | .await; |
| 679 | assert_eq!(status, 303); |
| 680 | assert!(location.is_some_and(|l| l.contains("notice=deleted"))); |
| 681 | assert!(names(&store, &repo).await.is_empty()); |
| 682 | |
| 683 | let (_, location, _) = send( |
| 684 | &app, |
| 685 | "POST", |
| 686 | delete, |
| 687 | Some("alice-token"), |
| 688 | &[("name", "API_KEY")], |
| 689 | None, |
| 690 | ) |
| 691 | .await; |
| 692 | assert!(location.is_some_and(|l| l.contains("notice=missing"))); |
| 693 | } |
| 694 | |
| 695 | #[tokio::test] |
| 696 | async fn turkish_viewer_sees_turkish_heading() { |
| 697 | let dir = tempfile::tempdir().expect("tempdir"); |
| 698 | let (store, app, _repo) = fixture(dir.path()).await; |
| 699 | let alice = store |
| 700 | .user_by_username("alice") |
| 701 | .await |
| 702 | .expect("lookup") |
| 703 | .expect("alice"); |
| 704 | store |
| 705 | .set_ui_prefs(&alice.id, "auto", "tr") |
| 706 | .await |
| 707 | .expect("set language"); |
| 708 | let (status, _, body) = send(&app, "GET", PAGE, Some("alice-token"), &[], None).await; |
| 709 | assert_eq!(status, 200); |
| 710 | assert!( |
| 711 | body.contains("Gizli anahtar ekle veya güncelle"), |
| 712 | "Turkish heading" |
| 713 | ); |
| 714 | } |
| 715 | } |