Write a form-error page's status only after it renders (closes #128)
check / check (push) Waiting to run

Six form-error paths (the login error and the webhook form's validation and name-taken branches) called WriteHeader before rendering, so if the form page's own template failed, the answer kept its 400 or 409 status with an error body instead of a 500. The new renderTemplateStatus renders into the buffer and writes the status only after the page has rendered; renderTemplate sends 200 through it, and those handlers pass their status to it. No handler calls WriteHeader before a render. Login-form tests show the 400 page still renders in full and a failing template answers 500.

Model: opus-5-5
This commit was merged in pull request #436.
This commit is contained in:
2026-10-02 15:34:01 +02:00
parent c706199389
commit dc9deda173
4 changed files with 91 additions and 22 deletions
+1 -2
View File
@@ -139,8 +139,7 @@ func (h *Handlers) renderLoginError(
), ),
} }
w.WriteHeader(status) h.renderTemplateStatus(w, r, "login.html", data, status)
h.renderTemplate(w, r, "login.html", data)
} }
// authenticateUser looks up and verifies a user's credentials. // authenticateUser looks up and verifies a user's credentials.
+55
View File
@@ -3,6 +3,7 @@ package handlers_test
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -404,6 +405,60 @@ func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
) )
} }
// TestLogin_FormErrorAnswersItsStatusWithThePage proves that the login
// form shown again with an error still answers 400 with the whole page.
func TestLogin_FormErrorAnswersItsStatusWithThePage(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(
t, w.Body.String(), "Username and password are required",
)
assert.Contains(
t, w.Body.String(), "</html>",
"the page must render to completion",
)
}
// TestLogin_FormErrorRenderFailureAnswers500 proves that a login form
// error page whose template fails answers 500 with the error page and
// none of the form page, rather than the 400 it meant to send.
func TestLogin_FormErrorRenderFailureAnswers500(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// The page prints its error message and then fails.
h.AddTemplateForTest("login.html", template.Must(
template.New("login").Funcs(template.FuncMap{
"fail": func() (string, error) { return "", errMidRender },
}).Parse(`{{.Error}}{{fail}}`),
))
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotContains(
t, w.Body.String(), "Username and password are required",
"the response must carry no part of the aborted page",
)
assert.Contains(t, w.Body.String(), "500 Internal Server Error")
}
// TestLogin_SuccessCreatesSession is the control for the tests above: // TestLogin_SuccessCreatesSession is the control for the tests above:
// the success path they assert on really does authenticate. // the success path they assert on really does authenticate.
func TestLogin_SuccessCreatesSession(t *testing.T) { func TestLogin_SuccessCreatesSession(t *testing.T) {
+28 -10
View File
@@ -309,12 +309,26 @@ func (s *Handlers) getUserInfo(
} }
// renderTemplate renders a pre-parsed template with common // renderTemplate renders a pre-parsed template with common
// data // data and answers 200.
func (s *Handlers) renderTemplate( func (s *Handlers) renderTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
pageTemplate string, pageTemplate string,
data any, data any,
) {
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
}
// renderTemplateStatus is renderTemplate answering with status, for a
// form shown again with an error. Call it instead of WriteHeader
// followed by renderTemplate: the status is written only once the page
// has rendered, so a failed render can still answer 500.
func (s *Handlers) renderTemplateStatus(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
data any,
status int,
) { ) {
tmpl, ok := s.templates[pageTemplate] tmpl, ok := s.templates[pageTemplate]
if !ok { if !ok {
@@ -327,7 +341,9 @@ func (s *Handlers) renderTemplate(
return return
} }
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r))) s.executeTemplate(
w, r, tmpl, s.pageData(r, data, noticeFor(r)), status,
)
} }
// pageData adds the fields the shared layout renders to a page's own // pageData adds the fields the shared layout renders to a page's own
@@ -363,19 +379,20 @@ func (s *Handlers) pageData(
} }
} }
// executeTemplate renders the template into a buffer and writes to // executeTemplate renders the template into a buffer and writes status
// the response only once rendering has fully succeeded. Executing // and the page to the response only once rendering has fully
// straight into the ResponseWriter commits a partial body and a 200 // succeeded. Executing straight into the ResponseWriter commits a
// status before a mid-render error can be reported, leaving no way // partial body and the status before a mid-render error can be
// to serve a 500. Buffering makes a page's rendered size resident // reported, leaving no way to serve a 500. Buffering makes a page's
// memory per concurrent viewer, so every page owes it a bound: the // rendered size resident memory per concurrent viewer, so every page
// event log caps each stored body at maxRenderedBodyBytes for exactly // owes it a bound: the event log caps each stored body at
// this reason. // maxRenderedBodyBytes for exactly this reason.
func (s *Handlers) executeTemplate( func (s *Handlers) executeTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
tmpl *template.Template, tmpl *template.Template,
data any, data any,
status int,
) { ) {
var buf bytes.Buffer var buf bytes.Buffer
@@ -390,6 +407,7 @@ func (s *Handlers) executeTemplate(
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w) _, err = buf.WriteTo(w)
if err != nil { if err != nil {
+7 -10
View File
@@ -350,12 +350,12 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
retentionStr := r.PostFormValue("retention_days") retentionStr := r.PostFormValue("retention_days")
if name == "" { if name == "" {
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(
h.renderTemplate(
w, r, "sources_new.html", w, r, "sources_new.html",
newSourceFormData( newSourceFormData(
"Name is required", name, description, "Name is required", name, description,
), ),
http.StatusBadRequest,
) )
return return
@@ -365,13 +365,13 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
retentionStr, database.DefaultRetentionDays, retentionStr, database.DefaultRetentionDays,
) )
if retErr != nil { if retErr != nil {
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(
h.renderTemplate(
w, r, "sources_new.html", w, r, "sources_new.html",
newSourceFormData( newSourceFormData(
retentionErrorMessage(retErr), retentionErrorMessage(retErr),
name, description, name, description,
), ),
http.StatusBadRequest,
) )
return return
@@ -644,8 +644,7 @@ func (h *Handlers) applyWebhookEdit(
tmplKeyError: "Name is required", tmplKeyError: "Name is required",
} }
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -665,8 +664,7 @@ func (h *Handlers) applyWebhookEdit(
tmplKeyError: retentionErrorMessage(retErr), tmplKeyError: retentionErrorMessage(retErr),
} }
w.WriteHeader(http.StatusBadRequest) h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -703,8 +701,7 @@ func (h *Handlers) applyWebhookEdit(
"it, then save again.", "it, then save again.",
} }
w.WriteHeader(http.StatusConflict) h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }