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    pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
48        let disk_root = project_templates_dir;
49        let mut env = Environment::new();
50        env.set_loader(move |name| load_template(disk_root.as_deref(), name));
51
52        // Phase 7a/2 — `icon(name, class="...")` returns inline SVG
53        // for one of the lucide stroke icons baked at compile time.
54        // Templates use this to render sidebar nav icons, button
55        // icons, alert-banner glyphs without an extra HTTP round
56        // trip. See `admin/icons.rs` for the catalogue.
57        env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
58            let class: String = kwargs.get("class").unwrap_or_default();
59            kwargs.assert_all_used().ok();
60            // The output is HTML — minijinja's autoescape would mangle
61            // it. Wrap in `safe()` so it renders as markup.
62            minijinja::value::Value::from_safe_string(
63                crate::admin::icons::render_inline(name, &class),
64            )
65        });
66
67        Ok(Arc::new(Self {
68            env: Mutex::new(env),
69        }))
70    }
71
72    /// Render a template by name.
73    pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
74        let mut env = self
75            .env
76            .lock()
77            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
78        // Clear cache so the loader runs again — restart-free dev edits.
79        env.clear_templates();
80        let tmpl = env
81            .get_template(name)
82            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
83        tmpl.render(ctx)
84            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
85    }
86
87    /// Render with a per-model override hook.
88    ///
89    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
90    /// stripped of any leading `admin/`), falling back to `name`.
91    /// Phase 6a wires the API but no handler calls it yet — the
92    /// existing Phase 6a admin pages all call [`Self::render`].
93    #[allow(dead_code)]
94    pub fn render_for_model<S: Serialize>(
95        &self,
96        model: &str,
97        name: &str,
98        ctx: &S,
99    ) -> Result<String> {
100        let page = name.strip_prefix("admin/").unwrap_or(name);
101        let per_model = format!("admin/{model}/{page}");
102        let mut env = self
103            .env
104            .lock()
105            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
106        env.clear_templates();
107        // Try per-model first; fall through if loader returns None.
108        if let Ok(tmpl) = env.get_template(&per_model) {
109            return tmpl
110                .render(ctx)
111                .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
112        }
113        let tmpl = env
114            .get_template(name)
115            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
116        tmpl.render(ctx)
117            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
118    }
119}
120
121fn load_template(
122    disk_root: Option<&std::path::Path>,
123    name: &str,
124) -> std::result::Result<Option<String>, minijinja::Error> {
125    if let Some(root) = disk_root {
126        let path = root.join(name);
127        if path.exists() {
128            return std::fs::read_to_string(&path).map(Some).map_err(|e| {
129                minijinja::Error::new(
130                    ErrorKind::InvalidOperation,
131                    format!("read template {}: {e}", path.display()),
132                )
133            });
134        }
135    }
136    Ok(EMBEDDED_TEMPLATES
137        .iter()
138        .find_map(|(n, b)| if *n == name { Some((*b).to_string()) } else { None }))
139}
140
141// Baked into the binary. Single-binary deploy is a hard constraint.
142const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
143    ("base.html", include_str!("../assets/templates/base.html")),
144    ("admin/base.html", include_str!("../assets/templates/admin/base.html")),
145    ("admin/login.html", include_str!("../assets/templates/admin/login.html")),
146    ("admin/index.html", include_str!("../assets/templates/admin/index.html")),
147    ("admin/list.html", include_str!("../assets/templates/admin/list.html")),
148    ("admin/form.html", include_str!("../assets/templates/admin/form.html")),
149    ("admin/confirm_delete.html", include_str!("../assets/templates/admin/confirm_delete.html")),
150    ("admin/object_history.html", include_str!("../assets/templates/admin/object_history.html")),
151    ("admin/log_entries.html", include_str!("../assets/templates/admin/log_entries.html")),
152    ("admin/password_change.html", include_str!("../assets/templates/admin/password_change.html")),
153    ("admin/users_list.html", include_str!("../assets/templates/admin/users_list.html")),
154    ("admin/user_edit.html", include_str!("../assets/templates/admin/user_edit.html")),
155    ("admin/user_new.html", include_str!("../assets/templates/admin/user_new.html")),
156    ("admin/user_view.html", include_str!("../assets/templates/admin/user_view.html")),
157    ("admin/user_confirm_delete.html", include_str!("../assets/templates/admin/user_confirm_delete.html")),
158    ("admin/groups_list.html", include_str!("../assets/templates/admin/groups_list.html")),
159    ("admin/group_edit.html", include_str!("../assets/templates/admin/group_edit.html")),
160    ("admin/group_new.html", include_str!("../assets/templates/admin/group_new.html")),
161    ("admin/group_confirm_delete.html", include_str!("../assets/templates/admin/group_confirm_delete.html")),
162    ("admin/forbidden.html", include_str!("../assets/templates/admin/forbidden.html")),
163    ("admin/error.html", include_str!("../assets/templates/admin/error.html")),
164    ("admin/coming_soon.html", include_str!("../assets/templates/admin/coming_soon.html")),
165    ("admin/includes/_field_errors.html", include_str!("../assets/templates/admin/includes/_field_errors.html")),
166    ("admin/includes/_form_field.html", include_str!("../assets/templates/admin/includes/_form_field.html")),
167    ("search.html", include_str!("../assets/templates/search.html")),
168];
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use serde::Serialize;
174    use std::io::Write;
175
176    #[derive(Serialize)]
177    struct Empty {}
178
179    #[test]
180    fn loader_registers_all_embedded_templates() {
181        let t = Templates::new(None).unwrap();
182        assert!(t.render("base.html", &Empty {}).is_ok());
183    }
184
185    #[test]
186    fn missing_template_errors_cleanly() {
187        let t = Templates::new(None).unwrap();
188        let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
189        assert_eq!(err.status(), 500);
190    }
191
192    #[test]
193    fn disk_override_wins_over_embedded() {
194        let dir = tempdir();
195        let admin_dir = dir.join("admin");
196        std::fs::create_dir_all(&admin_dir).unwrap();
197        let mut f = std::fs::File::create(admin_dir.join("login.html")).unwrap();
198        f.write_all(b"OVERRIDDEN-BODY").unwrap();
199        drop(f);
200
201        let t = Templates::new(Some(dir.clone())).unwrap();
202        let body = t.render("admin/login.html", &Empty {}).unwrap();
203        assert_eq!(body, "OVERRIDDEN-BODY");
204
205        // Cleanup.
206        let _ = std::fs::remove_dir_all(&dir);
207    }
208
209    #[test]
210    fn embedded_fallback_when_disk_missing() {
211        let dir = tempdir();
212        // dir exists but contains no admin/login.html — embedded must win.
213        let t = Templates::new(Some(dir.clone())).unwrap();
214        let body = t.render("admin/login.html", &Empty {}).unwrap();
215        // Embedded login.html is never empty; reject if it returned the
216        // disk-override sentinel.
217        assert!(!body.is_empty());
218        assert!(!body.contains("OVERRIDDEN-BODY"));
219
220        let _ = std::fs::remove_dir_all(&dir);
221    }
222
223    #[test]
224    fn live_edit_visible_on_next_render_without_restart() {
225        // The win of the loader refactor: edit a template on disk, the
226        // next render reflects it — no Templates rebuild, no restart.
227        let dir = tempdir();
228        let admin_dir = dir.join("admin");
229        std::fs::create_dir_all(&admin_dir).unwrap();
230        let target = admin_dir.join("login.html");
231
232        std::fs::write(&target, b"V1").unwrap();
233        let t = Templates::new(Some(dir.clone())).unwrap();
234        assert_eq!(t.render("admin/login.html", &Empty {}).unwrap(), "V1");
235
236        // Edit in place.
237        std::fs::write(&target, b"V2").unwrap();
238        assert_eq!(
239            t.render("admin/login.html", &Empty {}).unwrap(),
240            "V2",
241            "loader must re-resolve from disk on every render"
242        );
243
244        let _ = std::fs::remove_dir_all(&dir);
245    }
246
247    /// Phase 7a/0.5/f-fix regression: the embedded loader must know
248    /// about `admin/user_confirm_delete.html`. The browser smoke run
249    /// of /f hit a 500 because the template existed on disk but the
250    /// EMBEDDED_TEMPLATES const didn't list it. This test renders the
251    /// template with two distinct contexts and asserts the
252    /// last-developer banner + the submit-button disabled/enabled
253    /// invariant — so any future delete-handler refactor that adds a
254    /// new template can't ship the same gap unnoticed.
255    #[test]
256    fn user_confirm_delete_renders_with_last_developer_banner() {
257        let t = Templates::new(None).unwrap();
258
259        // Last-developer case → blocking banner + disabled submit.
260        let ctx = serde_json::json!({
261            "user_id": 122,
262            "email": "backup@example.com",
263            "role": "developer",
264            "group_count": 0,
265            "session_count": 1,
266            "direct_perm_count": 0,
267            "is_self": false,
268            "is_last_developer": true,
269            "csrf_token": "test-csrf",
270        });
271        let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
272        assert!(
273            body.contains("is the last active developer"),
274            "last-dev banner must mention the orphan condition"
275        );
276        assert!(
277            body.contains("rustio-cli user role set"),
278            "last-dev banner must point operators at the CLI escape hatch"
279        );
280        assert!(
281            body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
282            "submit must be disabled when target is the last active developer"
283        );
284
285        // Self-delete case → different errornote, also disabled.
286        let ctx_self = serde_json::json!({
287            "user_id": 7,
288            "email": "me@rustio.local",
289            "role": "administrator",
290            "group_count": 2,
291            "session_count": 1,
292            "direct_perm_count": 0,
293            "is_self": true,
294            "is_last_developer": false,
295            "csrf_token": "test-csrf",
296        });
297        let body = t.render("admin/user_confirm_delete.html", &ctx_self).unwrap();
298        assert!(
299            body.contains("your own account"),
300            "self-delete banner must call out the self-action"
301        );
302        assert!(
303            body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
304            "submit must be disabled on self-delete"
305        );
306    }
307
308    // ------------------------------------------------------------------
309    // Phase 7a/0.5/h — admin/user_view.html
310    //
311    // Five render tests covering the load-bearing template branches:
312    // - groups list populated vs empty
313    // - is_self / is_last_developer disable the Delete button
314    // - is_demo absent → no demo row
315    //
316    // All run as sandbox tests (no DB) because the template only
317    // depends on the context dict — perfect for catching template
318    // typos + missing variables without needing postgres.
319    // ------------------------------------------------------------------
320
321    /// Phase 10/b — base context fixture for `admin/user_view.html`.
322    /// Matches the `UserViewCtx` shape in `admin/builtin.rs`. Tests
323    /// override individual fields via `ctx[...] = …`.
324    fn view_ctx_base() -> serde_json::Value {
325        serde_json::json!({
326            "csrf_token": "test-csrf",
327            "user": {
328                "id": 42,
329                "email": "alice@example.com",
330                "full_name": "Alice",
331                "full_name_value": null,
332                "role": "Staff",
333                "is_admin": false,
334                "is_developer": false,
335                "is_active": true,
336                "is_demo": false,
337                "demo_label": null,
338                "locale": null,
339                "timezone": null,
340                "created_at_iso": "2026-04-25 12:00 UTC",
341                "last_seen_relative": "just now",
342                "last_login_iso": "2026-04-25 11:50 UTC",
343                "groups": [],
344            },
345            "users": [],
346            "total": 1,
347            "activity_count": 0,
348            "permission_count": 0,
349            "session_count": 0,
350            "tab": "overview",
351            "recent_events": [],
352            "activity_page": 1,
353            "activity_total_pages": 1,
354            "permissions": [],
355            "sessions": [],
356            "project_fields": [],
357            "can_edit": true,
358        })
359    }
360
361    /// Phase 7a/2 — the `icon()` minijinja function is registered in
362    /// `Templates::new`. A template can call `{{ icon("home", class="w-4 h-4") }}`
363    /// and the inline SVG is emitted unescaped (because we wrap it in
364    /// `Value::from_safe_string`). Lock that contract.
365    #[test]
366    fn icon_function_emits_inline_svg() {
367        // Write a temp template that exercises icon() and render it.
368        let dir = tempdir();
369        let admin_dir = dir.join("admin");
370        std::fs::create_dir_all(&admin_dir).unwrap();
371        std::fs::write(
372            admin_dir.join("icon_test.html"),
373            r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
374        )
375        .unwrap();
376
377        let t = Templates::new(Some(dir.clone())).unwrap();
378        let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
379        assert!(
380            body.contains("<svg"),
381            "icon() must emit raw <svg> markup, not escape it"
382        );
383        assert!(body.contains(r#"class="sidebar-icon""#));
384        assert!(body.contains(r#"viewBox="0 0 24 24""#));
385        assert!(body.contains(r#"stroke="currentColor""#));
386
387        // Unknown icon name → empty string, page still renders.
388        std::fs::write(
389            admin_dir.join("icon_missing.html"),
390            r#"<span>{{ icon("not-real") }}</span>"#,
391        )
392        .unwrap();
393        let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
394        assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
395
396        let _ = std::fs::remove_dir_all(&dir);
397    }
398
399    /// Phase 10/b — Overview tab renders the splitview shell + show-grid
400    /// profile + recent-activity timeline. Groups appear as `<code>` chips
401    /// in the show-grid Groups row.
402    #[test]
403    fn user_view_overview_renders_with_groups() {
404        let t = Templates::new(None).unwrap();
405        let mut ctx = view_ctx_base();
406        ctx["user"]["groups"] = serde_json::json!(["Auditors", "Content Editors"]);
407        let body = t.render("admin/user_view.html", &ctx).unwrap();
408
409        // Splitview shell + Overview branch markers.
410        assert!(body.contains("class=\"splitview\""), "must render the splitview shell");
411        assert!(body.contains("class=\"show-grid\""), "Overview must render the show-grid");
412        assert!(body.contains("class=\"stat-strip\""), "Overview must render the stat-strip");
413
414        // Groups row content — chips appear inside the show-grid.
415        assert!(body.contains("<code>Auditors</code>"));
416        assert!(body.contains("<code>Content Editors</code>"));
417    }
418
419    /// Phase 10/b — empty groups render the muted "No groups" inline
420    /// in the Groups row of the show-grid.
421    #[test]
422    fn user_view_overview_without_groups_shows_empty_marker() {
423        let t = Templates::new(None).unwrap();
424        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
425        assert!(
426            body.contains("No groups"),
427            "empty-groups copy must appear in the show-grid Groups row"
428        );
429    }
430
431    /// Phase 10/b — Activity tab renders the timeline component and a
432    /// pager when there's more than one page. Inline-Delete contract
433    /// from the pre-10/b template is intentionally gone; the destructive
434    /// path lives behind `/admin/users/:id/delete` exclusively.
435    #[test]
436    fn user_view_activity_tab_renders_pager() {
437        let t = Templates::new(None).unwrap();
438        let mut ctx = view_ctx_base();
439        ctx["tab"] = serde_json::json!("activity");
440        ctx["activity_count"] = serde_json::json!(120);
441        ctx["activity_page"] = serde_json::json!(2);
442        ctx["activity_total_pages"] = serde_json::json!(3);
443        ctx["recent_events"] = serde_json::json!([
444            { "id": 1, "kind": "info",    "message": "Updated <strong>posts</strong> #5",
445              "timestamp_relative": "1h ago", "actor": "user:42" },
446            { "id": 2, "kind": "success", "message": "Created <strong>posts</strong> #5",
447              "timestamp_relative": "2h ago", "actor": "user:42" },
448        ]);
449        let body = t.render("admin/user_view.html", &ctx).unwrap();
450
451        assert!(body.contains("class=\"timeline\""), "Activity must render the timeline component");
452        assert!(body.contains("tl-info"), "event kind must drive the dot color class");
453        assert!(body.contains("class=\"pager\""), "Activity must render the pager when total_pages > 1");
454        assert!(body.contains("?tab=activity&page=1"), "pager must link Prev to page-1");
455        assert!(body.contains("?tab=activity&page=3"), "pager must link Next to page+1");
456        // Tab links must NOT carry &page=… (per Phase 10/b spec — tab clicks reset page).
457        assert!(!body.contains("?tab=overview&page="), "Overview tab link must strip page param");
458        assert!(!body.contains("?tab=permissions&page="), "Permissions tab link must strip page param");
459        assert!(!body.contains("?tab=sessions&page="), "Sessions tab link must strip page param");
460    }
461
462    /// Phase 10/b — Permissions tab renders direct + inherited grants
463    /// with a source chip on each tile.
464    #[test]
465    fn user_view_permissions_tab_renders_with_sources() {
466        let t = Templates::new(None).unwrap();
467        let mut ctx = view_ctx_base();
468        ctx["tab"] = serde_json::json!("permissions");
469        ctx["permissions"] = serde_json::json!([
470            { "name": "posts.add_post",    "source": "direct" },
471            { "name": "posts.change_post", "source": "via Editors" },
472        ]);
473        let body = t.render("admin/user_view.html", &ctx).unwrap();
474
475        assert!(body.contains("class=\"perm-grid\""), "Permissions must render perm-grid");
476        assert!(body.contains("posts.add_post"));
477        assert!(body.contains("posts.change_post"));
478        assert!(body.contains("direct"), "direct grant must show the 'direct' source chip");
479        assert!(body.contains("via Editors"), "inherited grant must show the source group");
480    }
481
482    /// Phase 10/b — Sessions tab renders the table; absent IP / UA
483    /// columns render as the framework's "—" cell-empty marker. The
484    /// session token is truncated to its first 7 chars.
485    #[test]
486    fn user_view_sessions_tab_truncates_token_and_handles_nulls() {
487        let t = Templates::new(None).unwrap();
488        let mut ctx = view_ctx_base();
489        ctx["tab"] = serde_json::json!("sessions");
490        ctx["sessions"] = serde_json::json!([
491            {
492                "token_short": "abc1234",
493                "created_at_iso": "2026-05-01 10:00 UTC",
494                "last_seen_relative": "5m ago",
495                "ip": null,
496                "user_agent": null,
497            },
498        ]);
499        let body = t.render("admin/user_view.html", &ctx).unwrap();
500
501        assert!(body.contains("<table class=\"table\""), "Sessions must render the table");
502        assert!(body.contains("<code>abc1234</code>"), "token must render truncated, never full-length");
503        // Null IP / UA fall back to the framework's empty-cell marker.
504        assert!(body.contains("rio-cell-empty"), "absent IP / UA must render as the empty marker");
505    }
506
507    /// The users list now navigates to the profile view (not edit).
508    /// Lock the invariant: every cell in every row carries a `.row-link`
509    /// anchor pointing to `/admin/users/:id/` — never to `/edit`. A
510    /// regression here would silently revert /h's UX shift.
511    #[test]
512    fn users_list_renders_row_clickable_links() {
513        let t = Templates::new(None).unwrap();
514        let ctx = serde_json::json!({
515            "page_title": "Users",
516            "users": [
517                { "id": 7, "email": "alice@example.com", "role": "staff",
518                  "is_active": true, "created_at": "2026-04-01" },
519                { "id": 9, "email": "bob@example.com", "role": "developer",
520                  "is_active": false, "created_at": "2026-04-02" },
521            ],
522            "csrf_token": "x",
523        });
524        let body = t.render("admin/users_list.html", &ctx).unwrap();
525
526        // The table must declare itself row-clickable (CSS hook).
527        assert!(body.contains(r#"class="results row-clickable""#));
528
529        // Each row → 4 anchors pointing at the profile, none at /edit.
530        let count_for = |needle: &str| body.matches(needle).count();
531        assert_eq!(
532            count_for(r#"href="/admin/users/7/""#),
533            4,
534            "every cell in row 7 must link to the profile view (4 anchors)"
535        );
536        assert_eq!(
537            count_for(r#"href="/admin/users/9/""#),
538            4,
539            "every cell in row 9 must link to the profile view (4 anchors)"
540        );
541        // Defensive: no /edit links should leak through from the
542        // pre-/h pattern.
543        assert_eq!(
544            count_for(r#"href="/admin/users/7/edit""#),
545            0,
546            "list rows must NOT link to /edit anymore — that lives behind the view"
547        );
548    }
549
550    /// Phase 10/c — when the project registers a `user_profile_extension`,
551    /// the closure's `Vec<UserProfileSection>` lands in `project_fields`
552    /// and the default `{% block project_user_fields %}` renders each
553    /// section as a labeled show-grid. Empty `project_fields` must
554    /// produce no extra markup — no orphan headings, no empty divs.
555    #[test]
556    fn user_view_overview_renders_project_fields_section() {
557        let t = Templates::new(None).unwrap();
558        let mut ctx = view_ctx_base();
559        ctx["project_fields"] = serde_json::json!([
560            {
561                "label": "Halal certification",
562                "rows": [
563                    { "label": "Certified by", "value": "ICCV Halal Authority" },
564                    { "label": "License #",    "value": "HC-2025-0042" },
565                ],
566            },
567        ]);
568        let body = t.render("admin/user_view.html", &ctx).unwrap();
569
570        assert!(body.contains("Halal certification"), "section label must render");
571        assert!(body.contains("Certified by"));
572        assert!(body.contains("ICCV Halal Authority"));
573        assert!(body.contains("License #"));
574        assert!(body.contains("HC-2025-0042"));
575    }
576
577    /// Phase 10/c — empty project_fields must produce no extra section
578    /// markup. Zero-config projects (no extension registered) get a
579    /// clean Overview tab with no orphan headings.
580    #[test]
581    fn user_view_overview_omits_extension_when_project_fields_empty() {
582        let t = Templates::new(None).unwrap();
583        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
584        // Fixture has project_fields=[] — no project label should appear.
585        // Spot-check a few labels that appear only if any section rendered.
586        assert!(
587            !body.contains("Halal certification"),
588            "no extension means no project section heading"
589        );
590    }
591
592    /// Phase 10/b — demo flag drives a yellow badge in the identity
593    /// strip of the detail-head; the badge must NOT render for a real
594    /// user, and MUST include the demo_label when one is set.
595    #[test]
596    fn user_view_demo_badge_renders_only_for_demo_users() {
597        let t = Templates::new(None).unwrap();
598        // Real (non-demo) user: no DEMO badge.
599        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
600        assert!(
601            !body.contains(">DEMO"),
602            "DEMO badge must NOT render for a real user"
603        );
604
605        // Demo case: badge renders with the label.
606        let mut demo_ctx = view_ctx_base();
607        demo_ctx["user"]["is_demo"] = serde_json::Value::Bool(true);
608        demo_ctx["user"]["demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
609        let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
610        assert!(
611            demo_body.contains(">DEMO"),
612            "DEMO badge must render for a demo user"
613        );
614        assert!(
615            demo_body.contains("staff @ rustio.local"),
616            "demo label must appear in the badge"
617        );
618    }
619
620    /// Companion to `…with_last_developer_banner`: when neither guard
621    /// fires, the submit button MUST be enabled (no `disabled`
622    /// attribute). Lock that invariant in too — a stray `{% if %}`
623    /// edit could otherwise quietly disable every confirm button.
624    #[test]
625    fn user_confirm_delete_submit_enabled_for_normal_user() {
626        let t = Templates::new(None).unwrap();
627        let ctx = serde_json::json!({
628            "user_id": 99,
629            "email": "throwaway@example.com",
630            "role": "staff",
631            "group_count": 0,
632            "session_count": 0,
633            "direct_perm_count": 0,
634            "is_self": false,
635            "is_last_developer": false,
636            "csrf_token": "test-csrf",
637        });
638        let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
639        // Submit must render without `disabled`. The exact button
640        // markup includes an icon child (Phase 7a/2), so we assert
641        // on the contract — `<button type="submit" class="btn-danger">`
642        // present, and `disabled` NOT present anywhere on it.
643        assert!(
644            body.contains(r#"<button type="submit" class="btn-danger">"#),
645            "submit button must render with btn-danger class"
646        );
647        // Find the button's opening tag and assert no disabled attr
648        // sneaks in via a different code path.
649        assert!(
650            !body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
651            "submit button must NOT render with `disabled` for a deletable user"
652        );
653        // And the warning banners must NOT appear.
654        assert!(!body.contains("is the last active developer"));
655        assert!(!body.contains("your own account"));
656    }
657
658    fn tempdir() -> PathBuf {
659        let pid = std::process::id();
660        let nonce: u64 = std::time::SystemTime::now()
661            .duration_since(std::time::UNIX_EPOCH)
662            .unwrap()
663            .as_nanos() as u64;
664        let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
665        std::fs::create_dir_all(&path).unwrap();
666        path
667    }
668}