- ssr double: page template composing header/footer partials under the
HTML escaper, typed context built by a builder fn per ADR-004; golden
pins for composition, escaping of hostile interpolation, the trusted
{{& }} slot, and render-by-partial-name rejection
- code double: struct-writer template under the no-op escaper, typed
context built from a schema document with kind mapping and
source-escaped literals in the builder; golden pins for document-order
arrays and the active preserve_order feature
- fixture assets are include_str! files under tests/fixtures/; fixture
modules are per-test-binary so wave-2 proofs can grow them
- task file updated to completed with Notes and Summary
Verification: cargo test green (19 lib + 8 fixture tests), clippy
-D warnings clean, cargo fmt --check clean
210 lines
6.3 KiB
Rust
210 lines
6.3 KiB
Rust
//! The ssr-shaped engine fixture double (posture-B shape): a page
|
|
//! template composing fragment partials over a typed context built by
|
|
//! a Rust builder, rendered through the public API only, pinned by
|
|
//! golden renders.
|
|
|
|
use alkgen::{EscapePolicy, TemplateSet};
|
|
use serde::Serialize;
|
|
|
|
mod fixtures {
|
|
pub const PAGE: &str = include_str!("fixtures/ssr/page.hbs");
|
|
pub const HEADER: &str = include_str!("fixtures/ssr/header.hbs");
|
|
pub const FOOTER: &str = include_str!("fixtures/ssr/footer.hbs");
|
|
}
|
|
|
|
/// A section's prose kind. The mapping from this kind to the rendered
|
|
/// element (a note aside versus a plain paragraph) lives in the
|
|
/// builder: the template only reads the `is_callout` flag.
|
|
enum SectionKind {
|
|
Prose(String),
|
|
Note(String),
|
|
}
|
|
|
|
struct NavLinkInput {
|
|
href: String,
|
|
label: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PageContext {
|
|
nav: Vec<NavLink>,
|
|
sections: Vec<Section>,
|
|
show_banner: bool,
|
|
banner_text: String,
|
|
trusted_sidebar: String,
|
|
year: u16,
|
|
site_name: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct NavLink {
|
|
href: String,
|
|
label: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct Section {
|
|
is_callout: bool,
|
|
text: String,
|
|
}
|
|
|
|
/// The ADR-004 context builder: kind mapping and structure decisions
|
|
/// (an `Option` banner becomes a flag plus text, section kinds become
|
|
/// flag-carrying sections) happen here in Rust; the page template
|
|
/// stays dumb conditionals over context flags.
|
|
fn build_page(
|
|
nav: Vec<NavLinkInput>,
|
|
sections: Vec<SectionKind>,
|
|
banner: Option<&str>,
|
|
trusted_sidebar: &str,
|
|
year: u16,
|
|
site_name: &str,
|
|
) -> PageContext {
|
|
let (show_banner, banner_text) = match banner {
|
|
Some(text) => (true, text.to_owned()),
|
|
None => (false, String::new()),
|
|
};
|
|
PageContext {
|
|
nav: nav
|
|
.into_iter()
|
|
.map(|link| NavLink {
|
|
href: link.href,
|
|
label: link.label,
|
|
})
|
|
.collect(),
|
|
sections: sections
|
|
.into_iter()
|
|
.map(|section| match section {
|
|
SectionKind::Prose(text) => Section {
|
|
is_callout: false,
|
|
text,
|
|
},
|
|
SectionKind::Note(text) => Section {
|
|
is_callout: true,
|
|
text,
|
|
},
|
|
})
|
|
.collect(),
|
|
show_banner,
|
|
banner_text,
|
|
trusted_sidebar: trusted_sidebar.to_owned(),
|
|
year,
|
|
site_name: site_name.to_owned(),
|
|
}
|
|
}
|
|
|
|
/// The ssr-shaped fixture set: one `page` template kind plus the two
|
|
/// composition partials it includes, under the HTML escaper.
|
|
fn ssr_fixture_set() -> TemplateSet {
|
|
let mut set = TemplateSet::new(EscapePolicy::Html);
|
|
set.register_template("page", fixtures::PAGE)
|
|
.expect("fixture page template should be well-formed");
|
|
set.register_partial("header", fixtures::HEADER)
|
|
.expect("fixture header partial should be well-formed");
|
|
set.register_partial("footer", fixtures::FOOTER)
|
|
.expect("fixture footer partial should be well-formed");
|
|
set
|
|
}
|
|
|
|
const GOLDEN_PAGE: &str = concat!(
|
|
"<!doctype html>\n",
|
|
"<html lang=\"en\">\n",
|
|
"<header><nav><a href=\"/\">Home</a>",
|
|
"<a href=\"/docs?x=1&y=2\">Docs & Guide</a>",
|
|
"<a href=\"/search?q=<script>\"><script>alert(1)</script></a></nav></header>\n",
|
|
"<body>\n",
|
|
"<main><p>Plain intro paragraph.</p>",
|
|
"<aside class=\"note\">Careful: <tag> & "quotes".</aside></main>\n",
|
|
"<p class=\"banner\"><em>the</em> site</p>\n",
|
|
"<aside id=\"trusted\"><b>sidebar</b></aside>\n",
|
|
"<footer><span>© 2026 Alk Forge</span></footer>\n",
|
|
"</body>\n",
|
|
"</html>\n",
|
|
);
|
|
|
|
#[test]
|
|
fn page_golden_render_pins_composition_and_escaping() {
|
|
let set = ssr_fixture_set();
|
|
let context = build_page(
|
|
vec![
|
|
NavLinkInput {
|
|
href: "/".into(),
|
|
label: "Home".into(),
|
|
},
|
|
NavLinkInput {
|
|
href: "/docs?x=1&y=2".into(),
|
|
label: "Docs & Guide".into(),
|
|
},
|
|
NavLinkInput {
|
|
href: "/search?q=<script>".into(),
|
|
label: "<script>alert(1)</script>".into(),
|
|
},
|
|
],
|
|
vec![
|
|
SectionKind::Prose("Plain intro paragraph.".into()),
|
|
SectionKind::Note("Careful: <tag> & \"quotes\".".into()),
|
|
],
|
|
Some("<em>the</em> site"),
|
|
r#"<aside id="trusted"><b>sidebar</b></aside>"#,
|
|
2026,
|
|
"Alk Forge",
|
|
);
|
|
|
|
let rendered = set
|
|
.render("page", &context)
|
|
.expect("the golden page should render");
|
|
assert_eq!(rendered, GOLDEN_PAGE);
|
|
|
|
assert!(
|
|
rendered.contains("<a href=\"/docs?x=1&y=2\">Docs & Guide</a>"),
|
|
"interpolated user data must be entity-escaped (the substrate escaper also escapes '=' in attributes)"
|
|
);
|
|
assert!(
|
|
!rendered.contains("<script>alert(1)</script>"),
|
|
"interpolated user data must be entity-escaped"
|
|
);
|
|
assert!(
|
|
rendered.contains(&format!(
|
|
"<aside class=\"note\">Careful: {}</aside>",
|
|
"<tag> & "quotes"."
|
|
)),
|
|
"hostile-looking section text must never appear raw"
|
|
);
|
|
assert!(
|
|
rendered.contains(r#"<aside id="trusted"><b>sidebar</b></aside>"#),
|
|
"the deliberately-trusted raw slot ({{& }}) must pass through verbatim"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_banner_flag_is_the_builder_callout_not_a_template_lookup() {
|
|
let set = ssr_fixture_set();
|
|
let context = build_page(
|
|
Vec::new(),
|
|
vec![SectionKind::Prose("Only prose.".into())],
|
|
None,
|
|
"",
|
|
2026,
|
|
"Alk Forge",
|
|
);
|
|
|
|
let rendered = set
|
|
.render("page", &context)
|
|
.expect("the flag-only page should render");
|
|
assert!(
|
|
!rendered.contains("banner"),
|
|
"the builder flag decides the banner; the template only conditions on it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_by_partial_name_is_rejected_by_the_public_api() {
|
|
let set = ssr_fixture_set();
|
|
let context = build_page(Vec::new(), Vec::new(), None, "", 2026, "Alk Forge");
|
|
|
|
let err = set
|
|
.render("header", &context)
|
|
.expect_err("partials are composition units, never direct render targets");
|
|
assert!(matches!(err, alkgen::Error::Render(_)));
|
|
}
|