forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
24.6 KiB
1//! Page: CI secrets (`/{owner}/{repo}/settings/secrets`). Admins of the repo list the names of its CI
2//! secrets with the time each was last set, add or update one, and delete one. Anyone else gets a 404,
3//! so the page does not reveal that a repo has secrets. A value is write-only: it is sealed by the store
4//! and is never rendered, not in the list, not on a validation error, and not in the audit log (which
5//! records the name only). Names match `^[A-Z_][A-Z0-9_]{0,63}$`; values are at most 48 KiB.
6
7use forge_core::AuditAction;
8use serde::Deserialize;
9use topcoat::Result;
10use topcoat::context::{Cx, app_context};
11use topcoat::router::content::Form;
12use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
13use topcoat::router::request::uri;
14use topcoat::router::{page, path_param, route};
15use topcoat::view::{View, component, view};
16
17use crate::audit::{self, Actor, Target};
18use crate::auth::{require_csrf, require_user};
19use crate::i18n::repo_admin::{self as g, K};
20use crate::i18n::{self, Lang};
21use crate::pages::repo::repo_head;
22use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
23use crate::pages::repo_mirrors::stamp_in_tz;
24use crate::pages::repo_settings::settings_nav_for;
25use crate::state::AppState;
26use crate::views::{alert, enc, shell};
27
28path_param!(owner);
29path_param!(repo);
30
31/// Longest secret value accepted, in bytes.
32pub const MAX_VALUE_BYTES: usize = 48 * 1024;
33/// Longest secret name accepted, in characters.
34const MAX_NAME_CHARS: usize = 64;
35
36/// Notice words (the only text a `?notice=` query can show), their text, and whether they are errors.
37const 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)]
44struct SetForm {
45 #[serde(default)]
46 csrf: String,
47 #[serde(default)]
48 name: String,
49 #[serde(default)]
50 value: String,
51}
52
53#[derive(Deserialize)]
54struct DeleteForm {
55 #[serde(default)]
56 csrf: String,
57 #[serde(default)]
58 name: String,
59}
60
61/// `^[A-Z_][A-Z0-9_]{0,63}$`, checked by hand so no regex engine is needed.
62pub 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/// Checks a name and value before anything is stored. The message never includes the value.
75fn 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
88fn title_of(ctx: &RepoCtx) -> String {
89 format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)
90}
91
92fn secrets_href(title: &str) -> String {
93 format!("/{title}/settings/secrets")
94}
95
96fn back_to_list(title: &str, notice: &str) -> SeeOther {
97 see_other(format!("{}?notice={}", secrets_href(title), enc(notice)))
98}
99
100/// The notice from `?notice=<word>`. Anything else is ignored.
101fn 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/// One row of the list: the name and when it was last set, already formatted.
113struct SecretRow {
114 name: String,
115 updated: String,
116}
117
118/// What the page shows, read once from the repo context and the store.
119struct SecretsData {
120 page_title: String,
121 title: String,
122 archived: bool,
123 head: RepoHead,
124 rows: Vec<SecretRow>,
125}
126
127impl 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/// Header, sub-navigation and the one-line notice.
152#[component]
153async 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/// The list, the add form and the delete buttons. `draft_name` and `error` come from a refused add;
178/// the value is never passed back, so the textarea is always empty.
179#[component]
180async 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/// Lists the repo's CI secret names. Admins only: anyone else gets 404.
260#[page("/{owner}/{repo}/settings/secrets")]
261async 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/// Adds or updates a secret. A refused name or value re-renders the page with the message; the value
283/// is dropped, not sent back.
284#[page(POST "/{owner}/{repo}/settings/secrets")]
285async 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/// Deletes a secret by name. A name that is not stored is refused with a notice.
331#[route(POST "/{owner}/{repo}/settings/secrets/delete")]
332async 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)]
372mod 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 /// Alice owns `proj`, bob is a Write collaborator, carol is outside the repo.
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 /// Sends a request. A POST carries the CSRF token unless `csrf` overrides it. Returns the status,
422 /// the Location header and the body.
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 // A refused name with a value: the page comes back with the error and no value.
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}