1use std::path::PathBuf;
29use std::sync::{Arc, Mutex};
30
31use minijinja::{Environment, ErrorKind};
32use serde::Serialize;
33
34use crate::error::{Error, Result};
35
36pub struct Templates {
37 env: Mutex<Environment<'static>>,
38}
39
40impl Templates {
41 pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
56 let disk_root = project_templates_dir;
57 if let Some(root) = disk_root.as_deref() {
58 for v in validate_overrides(root) {
59 match v {
60 OverrideValidation::Loaded { name, bytes } => {
61 log::info!(
62 "templates: project override loaded for `{name}` ({bytes} bytes)"
63 );
64 }
65 OverrideValidation::Suspicious { name, bytes } => {
66 log::warn!(
67 "templates: project override for `{name}` looks incomplete \
68 ({bytes} bytes, no `{{% extends %}}`, no `{{% block %}}`, no \
69 `<html>` tag) — the admin UI may render incorrectly. Either \
70 copy the framework default in full or remove the override."
71 );
72 }
73 OverrideValidation::Unreadable { name, error } => {
74 log::warn!(
75 "templates: project override `{name}` exists but cannot be read: {error}"
76 );
77 }
78 OverrideValidation::OrphanAdminFile { path } => {
79 log::warn!(
80 "templates: `{path}` is in the admin namespace but does not \
81 override any embedded template (typo? framework default \
82 will be served unchanged). Project-specific admin pages \
83 belong outside `templates/admin/`."
84 );
85 }
86 }
87 }
88 }
89 let mut env = Environment::new();
90 env.set_loader(move |name| load_template(disk_root.as_deref(), name));
91
92 env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
98 let class: String = kwargs.get("class").unwrap_or_default();
99 kwargs.assert_all_used().ok();
100 minijinja::value::Value::from_safe_string(
103 crate::admin::icons::render_inline(name, &class),
104 )
105 });
106
107 Ok(Arc::new(Self {
108 env: Mutex::new(env),
109 }))
110 }
111
112 pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
114 let mut env = self
115 .env
116 .lock()
117 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
118 env.clear_templates();
120 let tmpl = env
121 .get_template(name)
122 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
123 tmpl.render(ctx)
124 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
125 }
126
127 #[allow(dead_code)]
134 pub fn render_for_model<S: Serialize>(
135 &self,
136 model: &str,
137 name: &str,
138 ctx: &S,
139 ) -> Result<String> {
140 let page = name.strip_prefix("admin/").unwrap_or(name);
141 let per_model = format!("admin/{model}/{page}");
142 let mut env = self
143 .env
144 .lock()
145 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
146 env.clear_templates();
147 if let Ok(tmpl) = env.get_template(&per_model) {
149 return tmpl
150 .render(ctx)
151 .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
152 }
153 let tmpl = env
154 .get_template(name)
155 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
156 tmpl.render(ctx)
157 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
169pub(crate) enum OverrideValidation {
170 Loaded { name: &'static str, bytes: usize },
174 Suspicious { name: &'static str, bytes: usize },
179 Unreadable { name: &'static str, error: String },
182 OrphanAdminFile { path: String },
189}
190
191pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
199 let mut results = Vec::new();
200 for (name, _embedded) in EMBEDDED_TEMPLATES {
201 let path = disk_root.join(name);
202 if !path.is_file() {
203 continue;
204 }
205 match std::fs::read_to_string(&path) {
206 Ok(body) => {
207 let bytes = body.len();
208 let has_structure = body.contains("{% extends")
209 || body.contains("{% block")
210 || body.contains("<html");
211 if has_structure {
212 results.push(OverrideValidation::Loaded { name, bytes });
213 } else {
214 results.push(OverrideValidation::Suspicious { name, bytes });
215 }
216 }
217 Err(e) => {
218 results.push(OverrideValidation::Unreadable {
219 name,
220 error: e.to_string(),
221 });
222 }
223 }
224 }
225
226 let admin_dir = disk_root.join("admin");
236 if admin_dir.is_dir() {
237 let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
238 .iter()
239 .filter_map(|(n, _)| n.strip_prefix("admin/"))
240 .collect();
241 if let Ok(entries) = std::fs::read_dir(&admin_dir) {
242 let mut files: Vec<_> = entries
246 .filter_map(|e| e.ok())
247 .filter(|e| {
248 e.path()
249 .extension()
250 .and_then(|s| s.to_str())
251 .map(|s| s.eq_ignore_ascii_case("html"))
252 .unwrap_or(false)
253 })
254 .collect();
255 files.sort_by_key(|e| e.file_name());
256 for entry in files {
257 let file_name = entry.file_name();
258 let Some(stem_html) = file_name.to_str() else {
259 continue;
260 };
261 if known.contains(stem_html) {
262 continue;
263 }
264 results.push(OverrideValidation::OrphanAdminFile {
265 path: format!("admin/{stem_html}"),
266 });
267 }
268 }
269 }
270
271 results
272}
273
274fn load_template(
275 disk_root: Option<&std::path::Path>,
276 name: &str,
277) -> std::result::Result<Option<String>, minijinja::Error> {
278 if let Some(root) = disk_root {
279 let path = root.join(name);
280 if path.exists() {
281 return std::fs::read_to_string(&path).map(Some).map_err(|e| {
282 minijinja::Error::new(
283 ErrorKind::InvalidOperation,
284 format!("read template {}: {e}", path.display()),
285 )
286 });
287 }
288 }
289 Ok(EMBEDDED_TEMPLATES
290 .iter()
291 .find_map(|(n, b)| if *n == name { Some((*b).to_string()) } else { None }))
292}
293
294const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
296 ("base.html", include_str!("../assets/templates/base.html")),
297 ("admin/base.html", include_str!("../assets/templates/admin/base.html")),
298 ("admin/login.html", include_str!("../assets/templates/admin/login.html")),
299 ("admin/index.html", include_str!("../assets/templates/admin/index.html")),
300 ("admin/list.html", include_str!("../assets/templates/admin/list.html")),
301 ("admin/form.html", include_str!("../assets/templates/admin/form.html")),
302 ("admin/confirm_delete.html", include_str!("../assets/templates/admin/confirm_delete.html")),
303 ("admin/object_history.html", include_str!("../assets/templates/admin/object_history.html")),
304 ("admin/log_entries.html", include_str!("../assets/templates/admin/log_entries.html")),
305 ("admin/password_change.html", include_str!("../assets/templates/admin/password_change.html")),
306 ("admin/users_list.html", include_str!("../assets/templates/admin/users_list.html")),
307 ("admin/user_edit.html", include_str!("../assets/templates/admin/user_edit.html")),
308 ("admin/user_new.html", include_str!("../assets/templates/admin/user_new.html")),
309 ("admin/user_view.html", include_str!("../assets/templates/admin/user_view.html")),
310 ("admin/user_confirm_delete.html", include_str!("../assets/templates/admin/user_confirm_delete.html")),
311 ("admin/groups_list.html", include_str!("../assets/templates/admin/groups_list.html")),
312 ("admin/group_edit.html", include_str!("../assets/templates/admin/group_edit.html")),
313 ("admin/group_new.html", include_str!("../assets/templates/admin/group_new.html")),
314 ("admin/group_confirm_delete.html", include_str!("../assets/templates/admin/group_confirm_delete.html")),
315 ("admin/forbidden.html", include_str!("../assets/templates/admin/forbidden.html")),
316 ("admin/error.html", include_str!("../assets/templates/admin/error.html")),
317 ("admin/coming_soon.html", include_str!("../assets/templates/admin/coming_soon.html")),
318 ("admin/includes/_field_errors.html", include_str!("../assets/templates/admin/includes/_field_errors.html")),
319 ("admin/includes/_form_field.html", include_str!("../assets/templates/admin/includes/_form_field.html")),
320 ("search.html", include_str!("../assets/templates/search.html")),
321];
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use serde::Serialize;
327 use std::io::Write;
328
329 #[derive(Serialize)]
330 struct Empty {}
331
332 #[test]
333 fn loader_registers_all_embedded_templates() {
334 let t = Templates::new(None).unwrap();
335 assert!(t.render("base.html", &Empty {}).is_ok());
336 }
337
338 #[test]
339 fn missing_template_errors_cleanly() {
340 let t = Templates::new(None).unwrap();
341 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
342 assert_eq!(err.status(), 500);
343 }
344
345 #[test]
346 fn disk_override_wins_over_embedded() {
347 let dir = tempdir();
348 let admin_dir = dir.join("admin");
349 std::fs::create_dir_all(&admin_dir).unwrap();
350 let mut f = std::fs::File::create(admin_dir.join("login.html")).unwrap();
351 f.write_all(b"OVERRIDDEN-BODY").unwrap();
352 drop(f);
353
354 let t = Templates::new(Some(dir.clone())).unwrap();
355 let body = t.render("admin/login.html", &Empty {}).unwrap();
356 assert_eq!(body, "OVERRIDDEN-BODY");
357
358 let _ = std::fs::remove_dir_all(&dir);
360 }
361
362 #[test]
363 fn embedded_fallback_when_disk_missing() {
364 let dir = tempdir();
365 let t = Templates::new(Some(dir.clone())).unwrap();
367 let body = t.render("admin/login.html", &Empty {}).unwrap();
368 assert!(!body.is_empty());
371 assert!(!body.contains("OVERRIDDEN-BODY"));
372
373 let _ = std::fs::remove_dir_all(&dir);
374 }
375
376 #[test]
383 fn validate_overrides_classifies_real_template_as_loaded() {
384 let dir = tempdir();
385 let admin_dir = dir.join("admin");
386 std::fs::create_dir_all(&admin_dir).unwrap();
387 std::fs::write(
388 admin_dir.join("login.html"),
389 "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
390 )
391 .unwrap();
392 let v = validate_overrides(&dir);
393 assert_eq!(v.len(), 1);
394 match &v[0] {
395 OverrideValidation::Loaded { name, bytes } => {
396 assert_eq!(*name, "admin/login.html");
397 assert!(*bytes > 0);
398 }
399 other => panic!("expected Loaded, got: {other:?}"),
400 }
401 let _ = std::fs::remove_dir_all(&dir);
402 }
403
404 #[test]
408 fn validate_overrides_flags_stub_admin_base_as_suspicious() {
409 let dir = tempdir();
410 let admin_dir = dir.join("admin");
411 std::fs::create_dir_all(&admin_dir).unwrap();
412 std::fs::write(admin_dir.join("base.html"), "<h1>TEST</h1>").unwrap();
413 let v = validate_overrides(&dir);
414 assert_eq!(v.len(), 1);
415 match &v[0] {
416 OverrideValidation::Suspicious { name, bytes } => {
417 assert_eq!(*name, "admin/base.html");
418 assert_eq!(*bytes, 13);
419 }
420 other => panic!("expected Suspicious, got: {other:?}"),
421 }
422 let _ = std::fs::remove_dir_all(&dir);
423 }
424
425 #[test]
430 fn validate_overrides_ignores_project_only_templates() {
431 let dir = tempdir();
432 std::fs::write(dir.join("home.html"), "<h1>welcome</h1>").unwrap();
433 let v = validate_overrides(&dir);
434 assert!(
435 v.is_empty(),
436 "home.html shadows nothing, must not appear in validation: {v:?}"
437 );
438 let _ = std::fs::remove_dir_all(&dir);
439 }
440
441 #[test]
445 fn validate_overrides_empty_dir_returns_empty_vec() {
446 let dir = tempdir();
447 let v = validate_overrides(&dir);
448 assert!(v.is_empty(), "no files ⇒ no validations: {v:?}");
449 let _ = std::fs::remove_dir_all(&dir);
450 }
451
452 #[test]
456 fn validate_overrides_handles_mixed_loaded_and_suspicious() {
457 let dir = tempdir();
458 let admin_dir = dir.join("admin");
459 std::fs::create_dir_all(&admin_dir).unwrap();
460 std::fs::write(
462 admin_dir.join("login.html"),
463 "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
464 )
465 .unwrap();
466 std::fs::write(admin_dir.join("base.html"), "<h1>TEST</h1>").unwrap();
468 let v = validate_overrides(&dir);
469 assert_eq!(v.len(), 2, "both files must be classified: {v:?}");
470 let suspicious_count = v
471 .iter()
472 .filter(|x| matches!(x, OverrideValidation::Suspicious { .. }))
473 .count();
474 let loaded_count = v
475 .iter()
476 .filter(|x| matches!(x, OverrideValidation::Loaded { .. }))
477 .count();
478 assert_eq!(suspicious_count, 1, "exactly one Suspicious: {v:?}");
479 assert_eq!(loaded_count, 1, "exactly one Loaded: {v:?}");
480 let _ = std::fs::remove_dir_all(&dir);
481 }
482
483 #[test]
488 fn validate_overrides_flags_orphan_admin_file() {
489 let dir = tempdir();
490 let admin_dir = dir.join("admin");
491 std::fs::create_dir_all(&admin_dir).unwrap();
492 std::fs::write(
494 admin_dir.join("baes.html"),
495 "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
496 )
497 .unwrap();
498 let v = validate_overrides(&dir);
499 assert_eq!(v.len(), 1, "exactly one orphan: {v:?}");
500 match &v[0] {
501 OverrideValidation::OrphanAdminFile { path } => {
502 assert_eq!(path, "admin/baes.html");
503 }
504 other => panic!("expected OrphanAdminFile, got: {other:?}"),
505 }
506 let _ = std::fs::remove_dir_all(&dir);
507 }
508
509 #[test]
514 fn validate_overrides_handles_real_and_orphan_in_same_admin_dir() {
515 let dir = tempdir();
516 let admin_dir = dir.join("admin");
517 std::fs::create_dir_all(&admin_dir).unwrap();
518 std::fs::write(
519 admin_dir.join("base.html"),
520 "{% block content %}real override{% endblock %}",
521 )
522 .unwrap();
523 std::fs::write(admin_dir.join("typo.html"), "<h1>oops</h1>").unwrap();
524 let v = validate_overrides(&dir);
525 let loaded_count = v
526 .iter()
527 .filter(|x| matches!(x, OverrideValidation::Loaded { name, .. } if *name == "admin/base.html"))
528 .count();
529 let orphan_count = v
530 .iter()
531 .filter(|x| matches!(x, OverrideValidation::OrphanAdminFile { path } if path == "admin/typo.html"))
532 .count();
533 assert_eq!(loaded_count, 1, "real override must be Loaded: {v:?}");
534 assert_eq!(orphan_count, 1, "typo must be OrphanAdminFile: {v:?}");
535 let _ = std::fs::remove_dir_all(&dir);
536 }
537
538 #[test]
541 fn validate_overrides_orphan_scan_skips_non_html() {
542 let dir = tempdir();
543 let admin_dir = dir.join("admin");
544 std::fs::create_dir_all(&admin_dir).unwrap();
545 std::fs::write(admin_dir.join(".gitkeep"), "").unwrap();
546 std::fs::write(admin_dir.join("notes.txt"), "draft").unwrap();
547 let v = validate_overrides(&dir);
548 assert!(
549 v.is_empty(),
550 "non-html files in admin/ must not produce orphans: {v:?}"
551 );
552 let _ = std::fs::remove_dir_all(&dir);
553 }
554
555 #[test]
556 fn live_edit_visible_on_next_render_without_restart() {
557 let dir = tempdir();
560 let admin_dir = dir.join("admin");
561 std::fs::create_dir_all(&admin_dir).unwrap();
562 let target = admin_dir.join("login.html");
563
564 std::fs::write(&target, b"V1").unwrap();
565 let t = Templates::new(Some(dir.clone())).unwrap();
566 assert_eq!(t.render("admin/login.html", &Empty {}).unwrap(), "V1");
567
568 std::fs::write(&target, b"V2").unwrap();
570 assert_eq!(
571 t.render("admin/login.html", &Empty {}).unwrap(),
572 "V2",
573 "loader must re-resolve from disk on every render"
574 );
575
576 let _ = std::fs::remove_dir_all(&dir);
577 }
578
579 #[test]
588 fn user_confirm_delete_renders_with_last_developer_banner() {
589 let t = Templates::new(None).unwrap();
590
591 let ctx = serde_json::json!({
593 "user_id": 122,
594 "email": "backup@example.com",
595 "role": "developer",
596 "group_count": 0,
597 "session_count": 1,
598 "direct_perm_count": 0,
599 "is_self": false,
600 "is_last_developer": true,
601 "csrf_token": "test-csrf",
602 });
603 let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
604 assert!(
605 body.contains("is the last active developer"),
606 "last-dev banner must mention the orphan condition"
607 );
608 assert!(
609 body.contains("rustio-cli user role set"),
610 "last-dev banner must point operators at the CLI escape hatch"
611 );
612 assert!(
613 body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
614 "submit must be disabled when target is the last active developer"
615 );
616
617 let ctx_self = serde_json::json!({
619 "user_id": 7,
620 "email": "me@rustio.local",
621 "role": "administrator",
622 "group_count": 2,
623 "session_count": 1,
624 "direct_perm_count": 0,
625 "is_self": true,
626 "is_last_developer": false,
627 "csrf_token": "test-csrf",
628 });
629 let body = t.render("admin/user_confirm_delete.html", &ctx_self).unwrap();
630 assert!(
631 body.contains("your own account"),
632 "self-delete banner must call out the self-action"
633 );
634 assert!(
635 body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
636 "submit must be disabled on self-delete"
637 );
638 }
639
640 fn view_ctx_base() -> serde_json::Value {
657 serde_json::json!({
658 "csrf_token": "test-csrf",
659 "user": {
660 "id": 42,
661 "email": "alice@example.com",
662 "full_name": "Alice",
663 "full_name_value": null,
664 "role": "Staff",
665 "is_admin": false,
666 "is_developer": false,
667 "is_active": true,
668 "is_demo": false,
669 "demo_label": null,
670 "locale": null,
671 "timezone": null,
672 "created_at_iso": "2026-04-25 12:00 UTC",
673 "last_seen_relative": "just now",
674 "last_login_iso": "2026-04-25 11:50 UTC",
675 "groups": [],
676 },
677 "users": [],
678 "total": 1,
679 "activity_count": 0,
680 "permission_count": 0,
681 "session_count": 0,
682 "tab": "overview",
683 "recent_events": [],
684 "activity_page": 1,
685 "activity_total_pages": 1,
686 "permissions": [],
687 "sessions": [],
688 "project_fields": [],
689 "can_edit": true,
690 })
691 }
692
693 #[test]
698 fn icon_function_emits_inline_svg() {
699 let dir = tempdir();
701 let admin_dir = dir.join("admin");
702 std::fs::create_dir_all(&admin_dir).unwrap();
703 std::fs::write(
704 admin_dir.join("icon_test.html"),
705 r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
706 )
707 .unwrap();
708
709 let t = Templates::new(Some(dir.clone())).unwrap();
710 let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
711 assert!(
712 body.contains("<svg"),
713 "icon() must emit raw <svg> markup, not escape it"
714 );
715 assert!(body.contains(r#"class="sidebar-icon""#));
716 assert!(body.contains(r#"viewBox="0 0 24 24""#));
717 assert!(body.contains(r#"stroke="currentColor""#));
718
719 std::fs::write(
721 admin_dir.join("icon_missing.html"),
722 r#"<span>{{ icon("not-real") }}</span>"#,
723 )
724 .unwrap();
725 let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
726 assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
727
728 let _ = std::fs::remove_dir_all(&dir);
729 }
730
731 #[test]
735 fn user_view_overview_renders_with_groups() {
736 let t = Templates::new(None).unwrap();
737 let mut ctx = view_ctx_base();
738 ctx["user"]["groups"] = serde_json::json!(["Auditors", "Content Editors"]);
739 let body = t.render("admin/user_view.html", &ctx).unwrap();
740
741 assert!(body.contains("class=\"splitview\""), "must render the splitview shell");
743 assert!(body.contains("class=\"show-grid\""), "Overview must render the show-grid");
744 assert!(body.contains("class=\"stat-strip\""), "Overview must render the stat-strip");
745
746 assert!(body.contains("<code>Auditors</code>"));
748 assert!(body.contains("<code>Content Editors</code>"));
749 }
750
751 #[test]
754 fn user_view_overview_without_groups_shows_empty_marker() {
755 let t = Templates::new(None).unwrap();
756 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
757 assert!(
758 body.contains("No groups"),
759 "empty-groups copy must appear in the show-grid Groups row"
760 );
761 }
762
763 #[test]
768 fn user_view_activity_tab_renders_pager() {
769 let t = Templates::new(None).unwrap();
770 let mut ctx = view_ctx_base();
771 ctx["tab"] = serde_json::json!("activity");
772 ctx["activity_count"] = serde_json::json!(120);
773 ctx["activity_page"] = serde_json::json!(2);
774 ctx["activity_total_pages"] = serde_json::json!(3);
775 ctx["recent_events"] = serde_json::json!([
776 { "id": 1, "kind": "info", "message": "Updated <strong>posts</strong> #5",
777 "timestamp_relative": "1h ago", "actor": "user:42" },
778 { "id": 2, "kind": "success", "message": "Created <strong>posts</strong> #5",
779 "timestamp_relative": "2h ago", "actor": "user:42" },
780 ]);
781 let body = t.render("admin/user_view.html", &ctx).unwrap();
782
783 assert!(body.contains("class=\"timeline\""), "Activity must render the timeline component");
784 assert!(body.contains("tl-info"), "event kind must drive the dot color class");
785 assert!(body.contains("class=\"pager\""), "Activity must render the pager when total_pages > 1");
786 assert!(body.contains("?tab=activity&page=1"), "pager must link Prev to page-1");
787 assert!(body.contains("?tab=activity&page=3"), "pager must link Next to page+1");
788 assert!(!body.contains("?tab=overview&page="), "Overview tab link must strip page param");
790 assert!(!body.contains("?tab=permissions&page="), "Permissions tab link must strip page param");
791 assert!(!body.contains("?tab=sessions&page="), "Sessions tab link must strip page param");
792 }
793
794 #[test]
797 fn user_view_permissions_tab_renders_with_sources() {
798 let t = Templates::new(None).unwrap();
799 let mut ctx = view_ctx_base();
800 ctx["tab"] = serde_json::json!("permissions");
801 ctx["permissions"] = serde_json::json!([
802 { "name": "posts.add_post", "source": "direct" },
803 { "name": "posts.change_post", "source": "via Editors" },
804 ]);
805 let body = t.render("admin/user_view.html", &ctx).unwrap();
806
807 assert!(body.contains("class=\"perm-grid\""), "Permissions must render perm-grid");
808 assert!(body.contains("posts.add_post"));
809 assert!(body.contains("posts.change_post"));
810 assert!(body.contains("direct"), "direct grant must show the 'direct' source chip");
811 assert!(body.contains("via Editors"), "inherited grant must show the source group");
812 }
813
814 #[test]
818 fn user_view_sessions_tab_truncates_token_and_handles_nulls() {
819 let t = Templates::new(None).unwrap();
820 let mut ctx = view_ctx_base();
821 ctx["tab"] = serde_json::json!("sessions");
822 ctx["sessions"] = serde_json::json!([
823 {
824 "token_short": "abc1234",
825 "created_at_iso": "2026-05-01 10:00 UTC",
826 "last_seen_relative": "5m ago",
827 "ip": null,
828 "user_agent": null,
829 },
830 ]);
831 let body = t.render("admin/user_view.html", &ctx).unwrap();
832
833 assert!(body.contains("<table class=\"table\""), "Sessions must render the table");
834 assert!(body.contains("<code>abc1234</code>"), "token must render truncated, never full-length");
835 assert!(body.contains("rio-cell-empty"), "absent IP / UA must render as the empty marker");
837 }
838
839 #[test]
844 fn users_list_renders_row_clickable_links() {
845 let t = Templates::new(None).unwrap();
846 let ctx = serde_json::json!({
847 "page_title": "Users",
848 "users": [
849 { "id": 7, "email": "alice@example.com", "role": "staff",
850 "is_active": true, "created_at": "2026-04-01" },
851 { "id": 9, "email": "bob@example.com", "role": "developer",
852 "is_active": false, "created_at": "2026-04-02" },
853 ],
854 "csrf_token": "x",
855 });
856 let body = t.render("admin/users_list.html", &ctx).unwrap();
857
858 assert!(body.contains(r#"class="results row-clickable""#));
860
861 let count_for = |needle: &str| body.matches(needle).count();
863 assert_eq!(
864 count_for(r#"href="/admin/users/7/""#),
865 4,
866 "every cell in row 7 must link to the profile view (4 anchors)"
867 );
868 assert_eq!(
869 count_for(r#"href="/admin/users/9/""#),
870 4,
871 "every cell in row 9 must link to the profile view (4 anchors)"
872 );
873 assert_eq!(
876 count_for(r#"href="/admin/users/7/edit""#),
877 0,
878 "list rows must NOT link to /edit anymore — that lives behind the view"
879 );
880 }
881
882 #[test]
888 fn user_view_overview_renders_project_fields_section() {
889 let t = Templates::new(None).unwrap();
890 let mut ctx = view_ctx_base();
891 ctx["project_fields"] = serde_json::json!([
892 {
893 "label": "Halal certification",
894 "rows": [
895 { "label": "Certified by", "value": "ICCV Halal Authority" },
896 { "label": "License #", "value": "HC-2025-0042" },
897 ],
898 },
899 ]);
900 let body = t.render("admin/user_view.html", &ctx).unwrap();
901
902 assert!(body.contains("Halal certification"), "section label must render");
903 assert!(body.contains("Certified by"));
904 assert!(body.contains("ICCV Halal Authority"));
905 assert!(body.contains("License #"));
906 assert!(body.contains("HC-2025-0042"));
907 }
908
909 #[test]
913 fn user_view_overview_omits_extension_when_project_fields_empty() {
914 let t = Templates::new(None).unwrap();
915 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
916 assert!(
919 !body.contains("Halal certification"),
920 "no extension means no project section heading"
921 );
922 }
923
924 #[test]
928 fn user_view_demo_badge_renders_only_for_demo_users() {
929 let t = Templates::new(None).unwrap();
930 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
932 assert!(
933 !body.contains(">DEMO"),
934 "DEMO badge must NOT render for a real user"
935 );
936
937 let mut demo_ctx = view_ctx_base();
939 demo_ctx["user"]["is_demo"] = serde_json::Value::Bool(true);
940 demo_ctx["user"]["demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
941 let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
942 assert!(
943 demo_body.contains(">DEMO"),
944 "DEMO badge must render for a demo user"
945 );
946 assert!(
947 demo_body.contains("staff @ rustio.local"),
948 "demo label must appear in the badge"
949 );
950 }
951
952 #[test]
957 fn user_confirm_delete_submit_enabled_for_normal_user() {
958 let t = Templates::new(None).unwrap();
959 let ctx = serde_json::json!({
960 "user_id": 99,
961 "email": "throwaway@example.com",
962 "role": "staff",
963 "group_count": 0,
964 "session_count": 0,
965 "direct_perm_count": 0,
966 "is_self": false,
967 "is_last_developer": false,
968 "csrf_token": "test-csrf",
969 });
970 let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
971 assert!(
976 body.contains(r#"<button type="submit" class="btn-danger">"#),
977 "submit button must render with btn-danger class"
978 );
979 assert!(
982 !body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
983 "submit button must NOT render with `disabled` for a deletable user"
984 );
985 assert!(!body.contains("is the last active developer"));
987 assert!(!body.contains("your own account"));
988 }
989
990 fn tempdir() -> PathBuf {
991 let pid = std::process::id();
992 let nonce: u64 = std::time::SystemTime::now()
993 .duration_since(std::time::UNIX_EPOCH)
994 .unwrap()
995 .as_nanos() as u64;
996 let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
997 std::fs::create_dir_all(&path).unwrap();
998 path
999 }
1000}