Go · 9192 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package render
4
5 import (
6 "bytes"
7 "html/template"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "testing/fstest"
12 )
13
14 // Each test builds a tiny in-memory template tree to exercise one
15 // invariant of New(). Keep these focused; broader render flows are
16 // covered by handler-level tests.
17
18 func TestNew_RegistersRootPages(t *testing.T) {
19 t.Parallel()
20 fsys := fstest.MapFS{
21 "_layout.html": &fstest.MapFile{Data: []byte(`{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`)},
22 "home.html": &fstest.MapFile{Data: []byte(`{{ define "body" }}home page{{ end }}`)},
23 }
24 r, err := New(fsys, Options{})
25 if err != nil {
26 t.Fatalf("New: %v", err)
27 }
28 var buf bytes.Buffer
29 if err := r.Render(&buf, "home", nil); err != nil {
30 t.Fatalf("render: %v", err)
31 }
32 if !strings.Contains(buf.String(), "home page") {
33 t.Errorf("rendered output missing body: %q", buf.String())
34 }
35 }
36
37 func TestNew_RegistersSubdirPages(t *testing.T) {
38 t.Parallel()
39 fsys := fstest.MapFS{
40 "_layout.html": &fstest.MapFile{Data: []byte(`{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`)},
41 "errors/404.html": &fstest.MapFile{Data: []byte(`{{ define "body" }}not found{{ end }}`)},
42 }
43 r, err := New(fsys, Options{})
44 if err != nil {
45 t.Fatalf("New: %v", err)
46 }
47 var buf bytes.Buffer
48 if err := r.Render(&buf, "errors/404", nil); err != nil {
49 t.Fatalf("render: %v", err)
50 }
51 if !strings.Contains(buf.String(), "not found") {
52 t.Errorf("rendered output missing body: %q", buf.String())
53 }
54 }
55
56 func TestRenderFragmentExecutesPageWithoutLayout(t *testing.T) {
57 t.Parallel()
58 fsys := fstest.MapFS{
59 "_layout.html": &fstest.MapFile{Data: []byte(
60 `{{ define "layout" }}<html>{{ template "page" . }}</html>{{ end }}`,
61 )},
62 "fragment.html": &fstest.MapFile{Data: []byte(
63 `{{ define "page" }}fragment only{{ end }}`,
64 )},
65 }
66 r, err := New(fsys, Options{})
67 if err != nil {
68 t.Fatalf("New: %v", err)
69 }
70 var buf bytes.Buffer
71 if err := r.RenderFragment(&buf, "fragment", nil); err != nil {
72 t.Fatalf("render fragment: %v", err)
73 }
74 if got := buf.String(); got != "fragment only" {
75 t.Fatalf("RenderFragment body = %q, want fragment only", got)
76 }
77 }
78
79 func TestFlagHelperSupportsOptionalLayoutToggles(t *testing.T) {
80 t.Parallel()
81 fsys := fstest.MapFS{
82 "_layout.html": &fstest.MapFile{Data: []byte(
83 `{{ define "layout" }}<html>{{ if flag . "UseHTMX" }}HTMX{{ end }}{{ template "page" . }}</html>{{ end }}`,
84 )},
85 "page.html": &fstest.MapFile{Data: []byte(`{{ define "page" }}body{{ end }}`)},
86 }
87 r, err := New(fsys, Options{})
88 if err != nil {
89 t.Fatalf("New: %v", err)
90 }
91 var buf bytes.Buffer
92 type typedPageData struct {
93 Title string
94 }
95 if err := r.Render(&buf, "page", typedPageData{Title: "typed"}); err != nil {
96 t.Fatalf("render typed data without flag: %v", err)
97 }
98 if strings.Contains(buf.String(), "HTMX") {
99 t.Fatalf("absent typed flag rendered HTMX: %q", buf.String())
100 }
101 buf.Reset()
102 if err := r.Render(&buf, "page", map[string]any{"UseHTMX": true}); err != nil {
103 t.Fatalf("render map data with flag: %v", err)
104 }
105 if !strings.Contains(buf.String(), "HTMX") {
106 t.Fatalf("map flag did not render HTMX: %q", buf.String())
107 }
108 }
109
110 func TestStringFieldHelperSupportsOptionalLayoutMetadata(t *testing.T) {
111 t.Parallel()
112 fsys := fstest.MapFS{
113 "_layout.html": &fstest.MapFile{Data: []byte(
114 `{{ define "layout" }}<html><head>{{ with stringField . "MetaDescription" }}<meta name="description" content="{{ . }}">{{ end }}</head>{{ template "page" . }}</html>{{ end }}`,
115 )},
116 "page.html": &fstest.MapFile{Data: []byte(`{{ define "page" }}body{{ end }}`)},
117 }
118 r, err := New(fsys, Options{})
119 if err != nil {
120 t.Fatalf("New: %v", err)
121 }
122 var buf bytes.Buffer
123 type typedPageData struct {
124 Title string
125 }
126 if err := r.Render(&buf, "page", typedPageData{Title: "typed"}); err != nil {
127 t.Fatalf("render typed data without metadata: %v", err)
128 }
129 if strings.Contains(buf.String(), `name="description"`) {
130 t.Fatalf("absent typed metadata rendered meta tag: %q", buf.String())
131 }
132 buf.Reset()
133 if err := r.Render(&buf, "page", map[string]any{"MetaDescription": "self-hosted git forge"}); err != nil {
134 t.Fatalf("render map data with metadata: %v", err)
135 }
136 if !strings.Contains(buf.String(), `content="self-hosted git forge"`) {
137 t.Fatalf("map metadata did not render: %q", buf.String())
138 }
139 }
140
141 func TestJSFieldHelperOnlyRendersTrustedJS(t *testing.T) {
142 t.Parallel()
143 fsys := fstest.MapFS{
144 "_layout.html": &fstest.MapFile{Data: []byte(
145 `{{ define "layout" }}<html><head>{{ with jsField . "StructuredData" }}<script type="application/ld+json">{{ . }}</script>{{ end }}</head>{{ template "page" . }}</html>{{ end }}`,
146 )},
147 "page.html": &fstest.MapFile{Data: []byte(`{{ define "page" }}body{{ end }}`)},
148 }
149 r, err := New(fsys, Options{})
150 if err != nil {
151 t.Fatalf("New: %v", err)
152 }
153 var buf bytes.Buffer
154 if err := r.Render(&buf, "page", map[string]any{"StructuredData": `{"unsafe":true}`}); err != nil {
155 t.Fatalf("render map data with plain string metadata: %v", err)
156 }
157 if strings.Contains(buf.String(), "application/ld+json") {
158 t.Fatalf("plain string JSON-LD should not render as trusted JS: %q", buf.String())
159 }
160 buf.Reset()
161 if err := r.Render(&buf, "page", map[string]any{"StructuredData": template.JS(`{"@type":"Organization"}`)}); err != nil {
162 t.Fatalf("render map data with trusted metadata: %v", err)
163 }
164 if !strings.Contains(buf.String(), `{"@type":"Organization"}`) {
165 t.Fatalf("trusted JSON-LD did not render: %q", buf.String())
166 }
167 }
168
169 // Regression test for the inbound deferral from S30 dogfood: a partial
170 // at `profile/_tabs.html` that defines `{{ define "tabs" }}` was
171 // silently registered as an unparsed page. A page that called
172 // `{{ template "tabs" . }}` then rendered blank.
173 func TestNew_LoadsSubdirPartials(t *testing.T) {
174 t.Parallel()
175 fsys := fstest.MapFS{
176 "_layout.html": &fstest.MapFile{Data: []byte(
177 `{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`,
178 )},
179 "profile/_tabs.html": &fstest.MapFile{Data: []byte(
180 `{{ define "tabs" }}TAB CONTENT{{ end }}`,
181 )},
182 "profile.html": &fstest.MapFile{Data: []byte(
183 `{{ define "body" }}{{ template "tabs" . }}{{ end }}`,
184 )},
185 }
186 r, err := New(fsys, Options{})
187 if err != nil {
188 t.Fatalf("New: %v", err)
189 }
190 var buf bytes.Buffer
191 if err := r.Render(&buf, "profile", nil); err != nil {
192 t.Fatalf("render: %v", err)
193 }
194 if !strings.Contains(buf.String(), "TAB CONTENT") {
195 t.Errorf("subdir partial not loaded — body was %q", buf.String())
196 }
197 }
198
199 // A page that references a template name nothing defines should fail
200 // LOUDLY at New() time, not silently render blank at exec time.
201 func TestNew_FailsOnUndefinedTemplateRef(t *testing.T) {
202 t.Parallel()
203 fsys := fstest.MapFS{
204 "_layout.html": &fstest.MapFile{Data: []byte(
205 `{{ define "layout" }}<html>{{ template "body" . }}</html>{{ end }}`,
206 )},
207 "broken.html": &fstest.MapFile{Data: []byte(
208 `{{ define "body" }}{{ template "does-not-exist" . }}{{ end }}`,
209 )},
210 }
211 _, err := New(fsys, Options{})
212 if err == nil {
213 t.Fatal("New: expected error for undefined template ref, got nil")
214 }
215 if !strings.Contains(err.Error(), "does-not-exist") {
216 t.Errorf("error should name the missing template; got %v", err)
217 }
218 if !strings.Contains(err.Error(), "broken") {
219 t.Errorf("error should name the offending page; got %v", err)
220 }
221 }
222
223 // Sanity: refs into partials still resolve (not flagged as undefined).
224 func TestNew_AcceptsRefsResolvedByPartials(t *testing.T) {
225 t.Parallel()
226 fsys := fstest.MapFS{
227 "_layout.html": &fstest.MapFile{Data: []byte(
228 `{{ define "layout" }}{{ template "header" . }}{{ template "body" . }}{{ end }}`,
229 )},
230 "_header.html": &fstest.MapFile{Data: []byte(
231 `{{ define "header" }}HDR{{ end }}`,
232 )},
233 "page.html": &fstest.MapFile{Data: []byte(
234 `{{ define "body" }}body{{ end }}`,
235 )},
236 }
237 r, err := New(fsys, Options{})
238 if err != nil {
239 t.Fatalf("New: %v", err)
240 }
241 var buf bytes.Buffer
242 if err := r.Render(&buf, "page", nil); err != nil {
243 t.Fatalf("render: %v", err)
244 }
245 got := buf.String()
246 if !strings.Contains(got, "HDR") || !strings.Contains(got, "body") {
247 t.Errorf("missing partial or page output: %q", got)
248 }
249 }
250
251 // RenderPage preserves any Viewer / CSRFToken the handler set itself,
252 // rather than overwriting them. The auto-inject is for handlers that
253 // hand RenderPage an empty map; explicit handlers stay in control.
254 func TestRenderPage_PreservesExplicitViewer(t *testing.T) {
255 t.Parallel()
256 fsys := fstest.MapFS{
257 "_layout.html": &fstest.MapFile{Data: []byte(
258 `{{ define "layout" }}{{ template "body" . }}{{ end }}`,
259 )},
260 "page.html": &fstest.MapFile{Data: []byte(
261 `{{ define "body" }}viewer={{ .Viewer }}{{ end }}`,
262 )},
263 }
264 r, err := New(fsys, Options{})
265 if err != nil {
266 t.Fatalf("New: %v", err)
267 }
268 req := httptest.NewRequest("GET", "/", nil)
269 rw := httptest.NewRecorder()
270 if err := r.RenderPage(rw, req, "page", map[string]any{
271 "Viewer": "explicit",
272 }); err != nil {
273 t.Fatalf("render: %v", err)
274 }
275 if !strings.Contains(rw.Body.String(), "viewer=explicit") {
276 t.Errorf("explicit Viewer was overwritten; body=%q", rw.Body.String())
277 }
278 }
279