Write a form-error page's status only after it renders (closes #128)
check / check (push) Waiting to run
check / check (push) Waiting to run
The login form and the webhook create and edit forms set their 400 or 409 with WriteHeader before calling renderTemplate, so a template failure there answered with that status instead of 500. A variant of renderTemplate now takes the status and writes it only once the page has rendered into the buffer; renderTemplate sends 200 through it. Model: opus-5-5
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user