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 }
79 }
80 }
81 let mut env = Environment::new();
82 env.set_loader(move |name| load_template(disk_root.as_deref(), name));
83
84 env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
90 let class: String = kwargs.get("class").unwrap_or_default();
91 kwargs.assert_all_used().ok();
92 minijinja::value::Value::from_safe_string(
95 crate::admin::icons::render_inline(name, &class),
96 )
97 });
98
99 Ok(Arc::new(Self {
100 env: Mutex::new(env),
101 }))
102 }
103
104 pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
106 let mut env = self
107 .env
108 .lock()
109 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
110 env.clear_templates();
112 let tmpl = env
113 .get_template(name)
114 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
115 tmpl.render(ctx)
116 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
117 }
118
119 #[allow(dead_code)]
126 pub fn render_for_model<S: Serialize>(
127 &self,
128 model: &str,
129 name: &str,
130 ctx: &S,
131 ) -> Result<String> {
132 let page = name.strip_prefix("admin/").unwrap_or(name);
133 let per_model = format!("admin/{model}/{page}");
134 let mut env = self
135 .env
136 .lock()
137 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
138 env.clear_templates();
139 if let Ok(tmpl) = env.get_template(&per_model) {
141 return tmpl
142 .render(ctx)
143 .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
144 }
145 let tmpl = env
146 .get_template(name)
147 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
148 tmpl.render(ctx)
149 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
161pub(crate) enum OverrideValidation {
162 Loaded { name: &'static str, bytes: usize },
166 Suspicious { name: &'static str, bytes: usize },
171 Unreadable { name: &'static str, error: String },
174}
175
176pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
184 let mut results = Vec::new();
185 for (name, _embedded) in EMBEDDED_TEMPLATES {
186 let path = disk_root.join(name);
187 if !path.is_file() {
188 continue;
189 }
190 match std::fs::read_to_string(&path) {
191 Ok(body) => {
192 let bytes = body.len();
193 let has_structure = body.contains("{% extends")
194 || body.contains("{% block")
195 || body.contains("<html");
196 if has_structure {
197 results.push(OverrideValidation::Loaded { name, bytes });
198 } else {
199 results.push(OverrideValidation::Suspicious { name, bytes });
200 }
201 }
202 Err(e) => {
203 results.push(OverrideValidation::Unreadable {
204 name,
205 error: e.to_string(),
206 });
207 }
208 }
209 }
210 results
211}
212
213fn load_template(
214 disk_root: Option<&std::path::Path>,
215 name: &str,
216) -> std::result::Result<Option<String>, minijinja::Error> {
217 if let Some(root) = disk_root {
218 let path = root.join(name);
219 if path.exists() {
220 return std::fs::read_to_string(&path).map(Some).map_err(|e| {
221 minijinja::Error::new(
222 ErrorKind::InvalidOperation,
223 format!("read template {}: {e}", path.display()),
224 )
225 });
226 }
227 }
228 Ok(EMBEDDED_TEMPLATES
229 .iter()
230 .find_map(|(n, b)| if *n == name { Some((*b).to_string()) } else { None }))
231}
232
233const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
235 ("base.html", include_str!("../assets/templates/base.html")),
236 ("admin/base.html", include_str!("../assets/templates/admin/base.html")),
237 ("admin/login.html", include_str!("../assets/templates/admin/login.html")),
238 ("admin/index.html", include_str!("../assets/templates/admin/index.html")),
239 ("admin/list.html", include_str!("../assets/templates/admin/list.html")),
240 ("admin/form.html", include_str!("../assets/templates/admin/form.html")),
241 ("admin/confirm_delete.html", include_str!("../assets/templates/admin/confirm_delete.html")),
242 ("admin/object_history.html", include_str!("../assets/templates/admin/object_history.html")),
243 ("admin/log_entries.html", include_str!("../assets/templates/admin/log_entries.html")),
244 ("admin/password_change.html", include_str!("../assets/templates/admin/password_change.html")),
245 ("admin/users_list.html", include_str!("../assets/templates/admin/users_list.html")),
246 ("admin/user_edit.html", include_str!("../assets/templates/admin/user_edit.html")),
247 ("admin/user_new.html", include_str!("../assets/templates/admin/user_new.html")),
248 ("admin/user_view.html", include_str!("../assets/templates/admin/user_view.html")),
249 ("admin/user_confirm_delete.html", include_str!("../assets/templates/admin/user_confirm_delete.html")),
250 ("admin/groups_list.html", include_str!("../assets/templates/admin/groups_list.html")),
251 ("admin/group_edit.html", include_str!("../assets/templates/admin/group_edit.html")),
252 ("admin/group_new.html", include_str!("../assets/templates/admin/group_new.html")),
253 ("admin/group_confirm_delete.html", include_str!("../assets/templates/admin/group_confirm_delete.html")),
254 ("admin/forbidden.html", include_str!("../assets/templates/admin/forbidden.html")),
255 ("admin/error.html", include_str!("../assets/templates/admin/error.html")),
256 ("admin/coming_soon.html", include_str!("../assets/templates/admin/coming_soon.html")),
257 ("admin/includes/_field_errors.html", include_str!("../assets/templates/admin/includes/_field_errors.html")),
258 ("admin/includes/_form_field.html", include_str!("../assets/templates/admin/includes/_form_field.html")),
259 ("search.html", include_str!("../assets/templates/search.html")),
260];
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use serde::Serialize;
266 use std::io::Write;
267
268 #[derive(Serialize)]
269 struct Empty {}
270
271 #[test]
272 fn loader_registers_all_embedded_templates() {
273 let t = Templates::new(None).unwrap();
274 assert!(t.render("base.html", &Empty {}).is_ok());
275 }
276
277 #[test]
278 fn missing_template_errors_cleanly() {
279 let t = Templates::new(None).unwrap();
280 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
281 assert_eq!(err.status(), 500);
282 }
283
284 #[test]
285 fn disk_override_wins_over_embedded() {
286 let dir = tempdir();
287 let admin_dir = dir.join("admin");
288 std::fs::create_dir_all(&admin_dir).unwrap();
289 let mut f = std::fs::File::create(admin_dir.join("login.html")).unwrap();
290 f.write_all(b"OVERRIDDEN-BODY").unwrap();
291 drop(f);
292
293 let t = Templates::new(Some(dir.clone())).unwrap();
294 let body = t.render("admin/login.html", &Empty {}).unwrap();
295 assert_eq!(body, "OVERRIDDEN-BODY");
296
297 let _ = std::fs::remove_dir_all(&dir);
299 }
300
301 #[test]
302 fn embedded_fallback_when_disk_missing() {
303 let dir = tempdir();
304 let t = Templates::new(Some(dir.clone())).unwrap();
306 let body = t.render("admin/login.html", &Empty {}).unwrap();
307 assert!(!body.is_empty());
310 assert!(!body.contains("OVERRIDDEN-BODY"));
311
312 let _ = std::fs::remove_dir_all(&dir);
313 }
314
315 #[test]
322 fn validate_overrides_classifies_real_template_as_loaded() {
323 let dir = tempdir();
324 let admin_dir = dir.join("admin");
325 std::fs::create_dir_all(&admin_dir).unwrap();
326 std::fs::write(
327 admin_dir.join("login.html"),
328 "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
329 )
330 .unwrap();
331 let v = validate_overrides(&dir);
332 assert_eq!(v.len(), 1);
333 match &v[0] {
334 OverrideValidation::Loaded { name, bytes } => {
335 assert_eq!(*name, "admin/login.html");
336 assert!(*bytes > 0);
337 }
338 other => panic!("expected Loaded, got: {other:?}"),
339 }
340 let _ = std::fs::remove_dir_all(&dir);
341 }
342
343 #[test]
347 fn validate_overrides_flags_stub_admin_base_as_suspicious() {
348 let dir = tempdir();
349 let admin_dir = dir.join("admin");
350 std::fs::create_dir_all(&admin_dir).unwrap();
351 std::fs::write(admin_dir.join("base.html"), "<h1>TEST</h1>").unwrap();
352 let v = validate_overrides(&dir);
353 assert_eq!(v.len(), 1);
354 match &v[0] {
355 OverrideValidation::Suspicious { name, bytes } => {
356 assert_eq!(*name, "admin/base.html");
357 assert_eq!(*bytes, 13);
358 }
359 other => panic!("expected Suspicious, got: {other:?}"),
360 }
361 let _ = std::fs::remove_dir_all(&dir);
362 }
363
364 #[test]
369 fn validate_overrides_ignores_project_only_templates() {
370 let dir = tempdir();
371 std::fs::write(dir.join("home.html"), "<h1>welcome</h1>").unwrap();
372 let v = validate_overrides(&dir);
373 assert!(
374 v.is_empty(),
375 "home.html shadows nothing, must not appear in validation: {v:?}"
376 );
377 let _ = std::fs::remove_dir_all(&dir);
378 }
379
380 #[test]
384 fn validate_overrides_empty_dir_returns_empty_vec() {
385 let dir = tempdir();
386 let v = validate_overrides(&dir);
387 assert!(v.is_empty(), "no files ⇒ no validations: {v:?}");
388 let _ = std::fs::remove_dir_all(&dir);
389 }
390
391 #[test]
395 fn validate_overrides_handles_mixed_loaded_and_suspicious() {
396 let dir = tempdir();
397 let admin_dir = dir.join("admin");
398 std::fs::create_dir_all(&admin_dir).unwrap();
399 std::fs::write(
401 admin_dir.join("login.html"),
402 "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
403 )
404 .unwrap();
405 std::fs::write(admin_dir.join("base.html"), "<h1>TEST</h1>").unwrap();
407 let v = validate_overrides(&dir);
408 assert_eq!(v.len(), 2, "both files must be classified: {v:?}");
409 let suspicious_count = v
410 .iter()
411 .filter(|x| matches!(x, OverrideValidation::Suspicious { .. }))
412 .count();
413 let loaded_count = v
414 .iter()
415 .filter(|x| matches!(x, OverrideValidation::Loaded { .. }))
416 .count();
417 assert_eq!(suspicious_count, 1, "exactly one Suspicious: {v:?}");
418 assert_eq!(loaded_count, 1, "exactly one Loaded: {v:?}");
419 let _ = std::fs::remove_dir_all(&dir);
420 }
421
422 #[test]
423 fn live_edit_visible_on_next_render_without_restart() {
424 let dir = tempdir();
427 let admin_dir = dir.join("admin");
428 std::fs::create_dir_all(&admin_dir).unwrap();
429 let target = admin_dir.join("login.html");
430
431 std::fs::write(&target, b"V1").unwrap();
432 let t = Templates::new(Some(dir.clone())).unwrap();
433 assert_eq!(t.render("admin/login.html", &Empty {}).unwrap(), "V1");
434
435 std::fs::write(&target, b"V2").unwrap();
437 assert_eq!(
438 t.render("admin/login.html", &Empty {}).unwrap(),
439 "V2",
440 "loader must re-resolve from disk on every render"
441 );
442
443 let _ = std::fs::remove_dir_all(&dir);
444 }
445
446 #[test]
455 fn user_confirm_delete_renders_with_last_developer_banner() {
456 let t = Templates::new(None).unwrap();
457
458 let ctx = serde_json::json!({
460 "user_id": 122,
461 "email": "backup@example.com",
462 "role": "developer",
463 "group_count": 0,
464 "session_count": 1,
465 "direct_perm_count": 0,
466 "is_self": false,
467 "is_last_developer": true,
468 "csrf_token": "test-csrf",
469 });
470 let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
471 assert!(
472 body.contains("is the last active developer"),
473 "last-dev banner must mention the orphan condition"
474 );
475 assert!(
476 body.contains("rustio-cli user role set"),
477 "last-dev banner must point operators at the CLI escape hatch"
478 );
479 assert!(
480 body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
481 "submit must be disabled when target is the last active developer"
482 );
483
484 let ctx_self = serde_json::json!({
486 "user_id": 7,
487 "email": "me@rustio.local",
488 "role": "administrator",
489 "group_count": 2,
490 "session_count": 1,
491 "direct_perm_count": 0,
492 "is_self": true,
493 "is_last_developer": false,
494 "csrf_token": "test-csrf",
495 });
496 let body = t.render("admin/user_confirm_delete.html", &ctx_self).unwrap();
497 assert!(
498 body.contains("your own account"),
499 "self-delete banner must call out the self-action"
500 );
501 assert!(
502 body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
503 "submit must be disabled on self-delete"
504 );
505 }
506
507 fn view_ctx_base() -> serde_json::Value {
524 serde_json::json!({
525 "csrf_token": "test-csrf",
526 "user": {
527 "id": 42,
528 "email": "alice@example.com",
529 "full_name": "Alice",
530 "full_name_value": null,
531 "role": "Staff",
532 "is_admin": false,
533 "is_developer": false,
534 "is_active": true,
535 "is_demo": false,
536 "demo_label": null,
537 "locale": null,
538 "timezone": null,
539 "created_at_iso": "2026-04-25 12:00 UTC",
540 "last_seen_relative": "just now",
541 "last_login_iso": "2026-04-25 11:50 UTC",
542 "groups": [],
543 },
544 "users": [],
545 "total": 1,
546 "activity_count": 0,
547 "permission_count": 0,
548 "session_count": 0,
549 "tab": "overview",
550 "recent_events": [],
551 "activity_page": 1,
552 "activity_total_pages": 1,
553 "permissions": [],
554 "sessions": [],
555 "project_fields": [],
556 "can_edit": true,
557 })
558 }
559
560 #[test]
565 fn icon_function_emits_inline_svg() {
566 let dir = tempdir();
568 let admin_dir = dir.join("admin");
569 std::fs::create_dir_all(&admin_dir).unwrap();
570 std::fs::write(
571 admin_dir.join("icon_test.html"),
572 r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
573 )
574 .unwrap();
575
576 let t = Templates::new(Some(dir.clone())).unwrap();
577 let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
578 assert!(
579 body.contains("<svg"),
580 "icon() must emit raw <svg> markup, not escape it"
581 );
582 assert!(body.contains(r#"class="sidebar-icon""#));
583 assert!(body.contains(r#"viewBox="0 0 24 24""#));
584 assert!(body.contains(r#"stroke="currentColor""#));
585
586 std::fs::write(
588 admin_dir.join("icon_missing.html"),
589 r#"<span>{{ icon("not-real") }}</span>"#,
590 )
591 .unwrap();
592 let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
593 assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
594
595 let _ = std::fs::remove_dir_all(&dir);
596 }
597
598 #[test]
602 fn user_view_overview_renders_with_groups() {
603 let t = Templates::new(None).unwrap();
604 let mut ctx = view_ctx_base();
605 ctx["user"]["groups"] = serde_json::json!(["Auditors", "Content Editors"]);
606 let body = t.render("admin/user_view.html", &ctx).unwrap();
607
608 assert!(body.contains("class=\"splitview\""), "must render the splitview shell");
610 assert!(body.contains("class=\"show-grid\""), "Overview must render the show-grid");
611 assert!(body.contains("class=\"stat-strip\""), "Overview must render the stat-strip");
612
613 assert!(body.contains("<code>Auditors</code>"));
615 assert!(body.contains("<code>Content Editors</code>"));
616 }
617
618 #[test]
621 fn user_view_overview_without_groups_shows_empty_marker() {
622 let t = Templates::new(None).unwrap();
623 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
624 assert!(
625 body.contains("No groups"),
626 "empty-groups copy must appear in the show-grid Groups row"
627 );
628 }
629
630 #[test]
635 fn user_view_activity_tab_renders_pager() {
636 let t = Templates::new(None).unwrap();
637 let mut ctx = view_ctx_base();
638 ctx["tab"] = serde_json::json!("activity");
639 ctx["activity_count"] = serde_json::json!(120);
640 ctx["activity_page"] = serde_json::json!(2);
641 ctx["activity_total_pages"] = serde_json::json!(3);
642 ctx["recent_events"] = serde_json::json!([
643 { "id": 1, "kind": "info", "message": "Updated <strong>posts</strong> #5",
644 "timestamp_relative": "1h ago", "actor": "user:42" },
645 { "id": 2, "kind": "success", "message": "Created <strong>posts</strong> #5",
646 "timestamp_relative": "2h ago", "actor": "user:42" },
647 ]);
648 let body = t.render("admin/user_view.html", &ctx).unwrap();
649
650 assert!(body.contains("class=\"timeline\""), "Activity must render the timeline component");
651 assert!(body.contains("tl-info"), "event kind must drive the dot color class");
652 assert!(body.contains("class=\"pager\""), "Activity must render the pager when total_pages > 1");
653 assert!(body.contains("?tab=activity&page=1"), "pager must link Prev to page-1");
654 assert!(body.contains("?tab=activity&page=3"), "pager must link Next to page+1");
655 assert!(!body.contains("?tab=overview&page="), "Overview tab link must strip page param");
657 assert!(!body.contains("?tab=permissions&page="), "Permissions tab link must strip page param");
658 assert!(!body.contains("?tab=sessions&page="), "Sessions tab link must strip page param");
659 }
660
661 #[test]
664 fn user_view_permissions_tab_renders_with_sources() {
665 let t = Templates::new(None).unwrap();
666 let mut ctx = view_ctx_base();
667 ctx["tab"] = serde_json::json!("permissions");
668 ctx["permissions"] = serde_json::json!([
669 { "name": "posts.add_post", "source": "direct" },
670 { "name": "posts.change_post", "source": "via Editors" },
671 ]);
672 let body = t.render("admin/user_view.html", &ctx).unwrap();
673
674 assert!(body.contains("class=\"perm-grid\""), "Permissions must render perm-grid");
675 assert!(body.contains("posts.add_post"));
676 assert!(body.contains("posts.change_post"));
677 assert!(body.contains("direct"), "direct grant must show the 'direct' source chip");
678 assert!(body.contains("via Editors"), "inherited grant must show the source group");
679 }
680
681 #[test]
685 fn user_view_sessions_tab_truncates_token_and_handles_nulls() {
686 let t = Templates::new(None).unwrap();
687 let mut ctx = view_ctx_base();
688 ctx["tab"] = serde_json::json!("sessions");
689 ctx["sessions"] = serde_json::json!([
690 {
691 "token_short": "abc1234",
692 "created_at_iso": "2026-05-01 10:00 UTC",
693 "last_seen_relative": "5m ago",
694 "ip": null,
695 "user_agent": null,
696 },
697 ]);
698 let body = t.render("admin/user_view.html", &ctx).unwrap();
699
700 assert!(body.contains("<table class=\"table\""), "Sessions must render the table");
701 assert!(body.contains("<code>abc1234</code>"), "token must render truncated, never full-length");
702 assert!(body.contains("rio-cell-empty"), "absent IP / UA must render as the empty marker");
704 }
705
706 #[test]
711 fn users_list_renders_row_clickable_links() {
712 let t = Templates::new(None).unwrap();
713 let ctx = serde_json::json!({
714 "page_title": "Users",
715 "users": [
716 { "id": 7, "email": "alice@example.com", "role": "staff",
717 "is_active": true, "created_at": "2026-04-01" },
718 { "id": 9, "email": "bob@example.com", "role": "developer",
719 "is_active": false, "created_at": "2026-04-02" },
720 ],
721 "csrf_token": "x",
722 });
723 let body = t.render("admin/users_list.html", &ctx).unwrap();
724
725 assert!(body.contains(r#"class="results row-clickable""#));
727
728 let count_for = |needle: &str| body.matches(needle).count();
730 assert_eq!(
731 count_for(r#"href="/admin/users/7/""#),
732 4,
733 "every cell in row 7 must link to the profile view (4 anchors)"
734 );
735 assert_eq!(
736 count_for(r#"href="/admin/users/9/""#),
737 4,
738 "every cell in row 9 must link to the profile view (4 anchors)"
739 );
740 assert_eq!(
743 count_for(r#"href="/admin/users/7/edit""#),
744 0,
745 "list rows must NOT link to /edit anymore — that lives behind the view"
746 );
747 }
748
749 #[test]
755 fn user_view_overview_renders_project_fields_section() {
756 let t = Templates::new(None).unwrap();
757 let mut ctx = view_ctx_base();
758 ctx["project_fields"] = serde_json::json!([
759 {
760 "label": "Halal certification",
761 "rows": [
762 { "label": "Certified by", "value": "ICCV Halal Authority" },
763 { "label": "License #", "value": "HC-2025-0042" },
764 ],
765 },
766 ]);
767 let body = t.render("admin/user_view.html", &ctx).unwrap();
768
769 assert!(body.contains("Halal certification"), "section label must render");
770 assert!(body.contains("Certified by"));
771 assert!(body.contains("ICCV Halal Authority"));
772 assert!(body.contains("License #"));
773 assert!(body.contains("HC-2025-0042"));
774 }
775
776 #[test]
780 fn user_view_overview_omits_extension_when_project_fields_empty() {
781 let t = Templates::new(None).unwrap();
782 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
783 assert!(
786 !body.contains("Halal certification"),
787 "no extension means no project section heading"
788 );
789 }
790
791 #[test]
795 fn user_view_demo_badge_renders_only_for_demo_users() {
796 let t = Templates::new(None).unwrap();
797 let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
799 assert!(
800 !body.contains(">DEMO"),
801 "DEMO badge must NOT render for a real user"
802 );
803
804 let mut demo_ctx = view_ctx_base();
806 demo_ctx["user"]["is_demo"] = serde_json::Value::Bool(true);
807 demo_ctx["user"]["demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
808 let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
809 assert!(
810 demo_body.contains(">DEMO"),
811 "DEMO badge must render for a demo user"
812 );
813 assert!(
814 demo_body.contains("staff @ rustio.local"),
815 "demo label must appear in the badge"
816 );
817 }
818
819 #[test]
824 fn user_confirm_delete_submit_enabled_for_normal_user() {
825 let t = Templates::new(None).unwrap();
826 let ctx = serde_json::json!({
827 "user_id": 99,
828 "email": "throwaway@example.com",
829 "role": "staff",
830 "group_count": 0,
831 "session_count": 0,
832 "direct_perm_count": 0,
833 "is_self": false,
834 "is_last_developer": false,
835 "csrf_token": "test-csrf",
836 });
837 let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
838 assert!(
843 body.contains(r#"<button type="submit" class="btn-danger">"#),
844 "submit button must render with btn-danger class"
845 );
846 assert!(
849 !body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
850 "submit button must NOT render with `disabled` for a deletable user"
851 );
852 assert!(!body.contains("is the last active developer"));
854 assert!(!body.contains("your own account"));
855 }
856
857 fn tempdir() -> PathBuf {
858 let pid = std::process::id();
859 let nonce: u64 = std::time::SystemTime::now()
860 .duration_since(std::time::UNIX_EPOCH)
861 .unwrap()
862 .as_nanos() as u64;
863 let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
864 std::fs::create_dir_all(&path).unwrap();
865 path
866 }
867}