Skip to main content

rustio_core/
templates.rs

1//! Template rendering. Rust code passes typed context; this module
2//! owns everything about HTML generation.
3//!
4//! # Loader contract (Phase 6a)
5//!
6//! Per-request lookup via [`minijinja::Environment::set_loader`].
7//! On every `render` call the cache is cleared, forcing the loader
8//! closure to re-resolve from disk so a developer can edit a
9//! template under `RUSTIO_TEMPLATE_DIR` and see the change on the
10//! next request without restarting the process.
11//!
12//! Lookup order, by template name `<path>`:
13//!
14//! 1. `<RUSTIO_TEMPLATE_DIR>/<path>` — project disk override.
15//! 2. Embedded default — compiled into the binary via `include_str!`.
16//!
17//! Per-model lookup (Phase 7 hook): callers that pass a model context
18//! can use [`Templates::render_for_model`] to add a third tier:
19//!
20//! 1. `<RUSTIO_TEMPLATE_DIR>/admin/<model>/<page>.html`
21//! 2. `<RUSTIO_TEMPLATE_DIR>/<path>`
22//! 3. Embedded default
23//!
24//! No handler in Phase 6a calls `render_for_model`; the path is
25//! exercised only by tests so the wiring is ready when a project
26//! needs a per-model override.
27
28use 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    /// Build the environment.
42    ///
43    /// `project_templates_dir = None` → embedded templates only.
44    /// `project_templates_dir = Some(path)` → disk overrides win at
45    /// render time. Pass the value of `RUSTIO_TEMPLATE_DIR` (or your
46    /// own resolved path) here.
47    ///
48    /// Phase 12/c-fix — when a disk root is supplied, the constructor
49    /// scans it once for overrides of embedded templates. Each match
50    /// is logged at INFO; an override that looks structurally
51    /// incomplete (no `{% extends %}`, no `{% block %}`, no `<html>`
52    /// tag) is logged at WARN so a one-line stub of `admin/base.html`
53    /// stops being a silent failure. Non-fatal: the override is still
54    /// served — the scan exists only to make the failure mode visible.
55    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        // Phase 7a/2 — `icon(name, class="...")` returns inline SVG
85        // for one of the lucide stroke icons baked at compile time.
86        // Templates use this to render sidebar nav icons, button
87        // icons, alert-banner glyphs without an extra HTTP round
88        // trip. See `admin/icons.rs` for the catalogue.
89        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            // The output is HTML — minijinja's autoescape would mangle
93            // it. Wrap in `safe()` so it renders as markup.
94            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    /// Render a template by name.
105    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        // Clear cache so the loader runs again — restart-free dev edits.
111        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    /// Render with a per-model override hook.
120    ///
121    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
122    /// stripped of any leading `admin/`), falling back to `name`.
123    /// Phase 6a wires the API but no handler calls it yet — the
124    /// existing Phase 6a admin pages all call [`Self::render`].
125    #[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        // Try per-model first; fall through if loader returns None.
140        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/// Phase 12/c-fix — outcome of inspecting one project override file at
154/// startup. Per-file, not per-render: the cost is paid once when the
155/// `Templates` arc is built, not on every request.
156///
157/// Pure data — `Templates::new` translates each variant to a log line.
158/// Returned as a `Vec` so unit tests can assert on the structural
159/// classification without scraping log output.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub(crate) enum OverrideValidation {
162    /// File loaded and contains at least one of `{% extends %}`,
163    /// `{% block %}`, or `<html`. Heuristic for "this looks like a
164    /// real template body".
165    Loaded { name: &'static str, bytes: usize },
166    /// File loaded but contains none of the structural markers.
167    /// Most likely a stub or a placeholder; the framework default is
168    /// silently being replaced by something that will not render the
169    /// admin UI correctly.
170    Suspicious { name: &'static str, bytes: usize },
171    /// File exists on disk but `read_to_string` failed
172    /// (permissions, encoding, IO error).
173    Unreadable { name: &'static str, error: String },
174}
175
176/// Phase 12/c-fix — walk `EMBEDDED_TEMPLATES`, classify any project
177/// override of one of those names, return the per-file results.
178///
179/// Files in `disk_root` that do NOT shadow an embedded name are
180/// ignored: those are project-only templates (e.g. `home.html`) and
181/// have no framework default to compare against. Only shadowing files
182/// can cause the silent-replacement failure mode this scan exists for.
183pub(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
233// Baked into the binary. Single-binary deploy is a hard constraint.
234const 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        // Cleanup.
298        let _ = std::fs::remove_dir_all(&dir);
299    }
300
301    #[test]
302    fn embedded_fallback_when_disk_missing() {
303        let dir = tempdir();
304        // dir exists but contains no admin/login.html — embedded must win.
305        let t = Templates::new(Some(dir.clone())).unwrap();
306        let body = t.render("admin/login.html", &Empty {}).unwrap();
307        // Embedded login.html is never empty; reject if it returned the
308        // disk-override sentinel.
309        assert!(!body.is_empty());
310        assert!(!body.contains("OVERRIDDEN-BODY"));
311
312        let _ = std::fs::remove_dir_all(&dir);
313    }
314
315    // ----- Phase 12/c-fix — validate_overrides ---------------------------
316
317    /// Phase 12/c-fix — a faithful copy of an embedded admin template
318    /// (here, just "contains `{% extends %}`") classifies as Loaded.
319    /// The structural marker is enough; the content doesn't have to
320    /// match the upstream template byte-for-byte.
321    #[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    /// Phase 12/c-fix — the bug we shipped pre-fix: a one-line stub of
344    /// admin/base.html silently destroys the admin UI. Validation must
345    /// flag it as Suspicious so the operator sees a WARN in the log.
346    #[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    /// Phase 12/c-fix — a project-only template like `home.html` is NOT
365    /// in EMBEDDED_TEMPLATES, so it doesn't shadow a framework default
366    /// and must be silently ignored by the validator. Otherwise every
367    /// scaffolded project would emit warnings about its own home page.
368    #[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    /// Phase 12/c-fix — empty disk root (the common case for projects
381    /// that ship no overrides) returns an empty Vec without touching
382    /// the filesystem beyond `is_file` checks.
383    #[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    /// Phase 12/c-fix — multiple overrides classify independently.
392    /// Order is the order of EMBEDDED_TEMPLATES (deterministic); test
393    /// asserts on the multi-set rather than the sequence.
394    #[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        // A real-looking override of login.html.
400        std::fs::write(
401            admin_dir.join("login.html"),
402            "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
403        )
404        .unwrap();
405        // And a stub of base.html.
406        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        // The win of the loader refactor: edit a template on disk, the
425        // next render reflects it — no Templates rebuild, no restart.
426        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        // Edit in place.
436        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    /// Phase 7a/0.5/f-fix regression: the embedded loader must know
447    /// about `admin/user_confirm_delete.html`. The browser smoke run
448    /// of /f hit a 500 because the template existed on disk but the
449    /// EMBEDDED_TEMPLATES const didn't list it. This test renders the
450    /// template with two distinct contexts and asserts the
451    /// last-developer banner + the submit-button disabled/enabled
452    /// invariant — so any future delete-handler refactor that adds a
453    /// new template can't ship the same gap unnoticed.
454    #[test]
455    fn user_confirm_delete_renders_with_last_developer_banner() {
456        let t = Templates::new(None).unwrap();
457
458        // Last-developer case → blocking banner + disabled submit.
459        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        // Self-delete case → different errornote, also disabled.
485        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    // ------------------------------------------------------------------
508    // Phase 7a/0.5/h — admin/user_view.html
509    //
510    // Five render tests covering the load-bearing template branches:
511    // - groups list populated vs empty
512    // - is_self / is_last_developer disable the Delete button
513    // - is_demo absent → no demo row
514    //
515    // All run as sandbox tests (no DB) because the template only
516    // depends on the context dict — perfect for catching template
517    // typos + missing variables without needing postgres.
518    // ------------------------------------------------------------------
519
520    /// Phase 10/b — base context fixture for `admin/user_view.html`.
521    /// Matches the `UserViewCtx` shape in `admin/builtin.rs`. Tests
522    /// override individual fields via `ctx[...] = …`.
523    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    /// Phase 7a/2 — the `icon()` minijinja function is registered in
561    /// `Templates::new`. A template can call `{{ icon("home", class="w-4 h-4") }}`
562    /// and the inline SVG is emitted unescaped (because we wrap it in
563    /// `Value::from_safe_string`). Lock that contract.
564    #[test]
565    fn icon_function_emits_inline_svg() {
566        // Write a temp template that exercises icon() and render it.
567        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        // Unknown icon name → empty string, page still renders.
587        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    /// Phase 10/b — Overview tab renders the splitview shell + show-grid
599    /// profile + recent-activity timeline. Groups appear as `<code>` chips
600    /// in the show-grid Groups row.
601    #[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        // Splitview shell + Overview branch markers.
609        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        // Groups row content — chips appear inside the show-grid.
614        assert!(body.contains("<code>Auditors</code>"));
615        assert!(body.contains("<code>Content Editors</code>"));
616    }
617
618    /// Phase 10/b — empty groups render the muted "No groups" inline
619    /// in the Groups row of the show-grid.
620    #[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    /// Phase 10/b — Activity tab renders the timeline component and a
631    /// pager when there's more than one page. Inline-Delete contract
632    /// from the pre-10/b template is intentionally gone; the destructive
633    /// path lives behind `/admin/users/:id/delete` exclusively.
634    #[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        // Tab links must NOT carry &page=… (per Phase 10/b spec — tab clicks reset page).
656        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    /// Phase 10/b — Permissions tab renders direct + inherited grants
662    /// with a source chip on each tile.
663    #[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    /// Phase 10/b — Sessions tab renders the table; absent IP / UA
682    /// columns render as the framework's "—" cell-empty marker. The
683    /// session token is truncated to its first 7 chars.
684    #[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        // Null IP / UA fall back to the framework's empty-cell marker.
703        assert!(body.contains("rio-cell-empty"), "absent IP / UA must render as the empty marker");
704    }
705
706    /// The users list now navigates to the profile view (not edit).
707    /// Lock the invariant: every cell in every row carries a `.row-link`
708    /// anchor pointing to `/admin/users/:id/` — never to `/edit`. A
709    /// regression here would silently revert /h's UX shift.
710    #[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        // The table must declare itself row-clickable (CSS hook).
726        assert!(body.contains(r#"class="results row-clickable""#));
727
728        // Each row → 4 anchors pointing at the profile, none at /edit.
729        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        // Defensive: no /edit links should leak through from the
741        // pre-/h pattern.
742        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    /// Phase 10/c — when the project registers a `user_profile_extension`,
750    /// the closure's `Vec<UserProfileSection>` lands in `project_fields`
751    /// and the default `{% block project_user_fields %}` renders each
752    /// section as a labeled show-grid. Empty `project_fields` must
753    /// produce no extra markup — no orphan headings, no empty divs.
754    #[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    /// Phase 10/c — empty project_fields must produce no extra section
777    /// markup. Zero-config projects (no extension registered) get a
778    /// clean Overview tab with no orphan headings.
779    #[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        // Fixture has project_fields=[] — no project label should appear.
784        // Spot-check a few labels that appear only if any section rendered.
785        assert!(
786            !body.contains("Halal certification"),
787            "no extension means no project section heading"
788        );
789    }
790
791    /// Phase 10/b — demo flag drives a yellow badge in the identity
792    /// strip of the detail-head; the badge must NOT render for a real
793    /// user, and MUST include the demo_label when one is set.
794    #[test]
795    fn user_view_demo_badge_renders_only_for_demo_users() {
796        let t = Templates::new(None).unwrap();
797        // Real (non-demo) user: no DEMO badge.
798        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        // Demo case: badge renders with the label.
805        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    /// Companion to `…with_last_developer_banner`: when neither guard
820    /// fires, the submit button MUST be enabled (no `disabled`
821    /// attribute). Lock that invariant in too — a stray `{% if %}`
822    /// edit could otherwise quietly disable every confirm button.
823    #[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        // Submit must render without `disabled`. The exact button
839        // markup includes an icon child (Phase 7a/2), so we assert
840        // on the contract — `<button type="submit" class="btn-danger">`
841        // present, and `disabled` NOT present anywhere on it.
842        assert!(
843            body.contains(r#"<button type="submit" class="btn-danger">"#),
844            "submit button must render with btn-danger class"
845        );
846        // Find the button's opening tag and assert no disabled attr
847        // sneaks in via a different code path.
848        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        // And the warning banners must NOT appear.
853        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}