Compare commits

1 Commits
Author SHA1 Message Date
sneak d17a687dba Drive the archive reopen debounce test from a clock (closes #190)
check / check (push) Successful in 4m16s
The archive writer now reads the time its reopen debounce is
measured on from a clock field, time.Now outside tests. The
debounce test moves that clock instead of sleeping past a real
2 second window, so a slow host between the two quick writes can
no longer turn a correct result red.

Model: opus-5-5
2026-10-02 12:18:54 +00:00
11 changed files with 53 additions and 181 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ jobs:
# report success from cache. # report success from cache.
run: git rev-parse HEAD > .ci-fingerprint run: git rev-parse HEAD > .ci-fingerprint
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build) - name: Build Docker image (runs make check)
run: script/cibuild run: script/cibuild
+4 -5
View File
@@ -19,8 +19,8 @@ before deploying one.
### Prerequisites ### Prerequisites
- Go 1.26.1+ (the version in `go.mod`) - Go 1.26.1+ (the version in `go.mod`)
- Docker (for `make lint` and so for `make check`, for the CI gate, and - Docker (for linting, for the test stage of the CI gate, and for
for containerized deployment) containerized deployment)
golangci-lint is not a prerequisite and must not be installed on the golangci-lint is not a prerequisite and must not be installed on the
host: `script/bootstrap` does not install it, and `make lint` runs the host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -3335,9 +3335,8 @@ linked, which is what lets it run on the Alpine runtime image.
inside the image, so a build that succeeds is a repo that is formatted, inside the image, so a build that succeeds is a repo that is formatted,
linted, tested and compiled. `script/lint` also uses Docker linted, tested and compiled. `script/lint` also uses Docker
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check` (`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
run the same pinned linter version the gate does; of the steps run the same pinned linter version the gate does; only `script/test`
`make check` runs, only `script/test` and `script/fmt-check` run on the and `script/fmt-check` run on the host.
host.
#### CI gate honesty #### CI gate honesty
+3 -3
View File
@@ -191,9 +191,9 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
path, archiveTestLogger(), debounce, path, archiveTestLogger(), debounce,
) )
// The writer measures its reopen debounce on this clock, which // The writer reads the time from this clock, which only the
// only the test moves, so how long the host takes between // test moves, so how long the host takes between writes
// writes cannot change the result. // cannot change the result.
now := time.Now() now := time.Now()
w.SetNow(func() time.Time { return now }) w.SetNow(func() time.Time { return now })
+2 -1
View File
@@ -139,7 +139,8 @@ func (h *Handlers) renderLoginError(
), ),
} }
h.renderTemplateStatus(w, r, "login.html", data, status) w.WriteHeader(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,7 +3,6 @@ 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"
@@ -405,60 +404,6 @@ 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) {
+10 -28
View File
@@ -309,26 +309,12 @@ func (s *Handlers) getUserInfo(
} }
// renderTemplate renders a pre-parsed template with common // renderTemplate renders a pre-parsed template with common
// data and answers 200. // data
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 {
@@ -341,9 +327,7 @@ func (s *Handlers) renderTemplateStatus(
return return
} }
s.executeTemplate( s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
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
@@ -379,20 +363,19 @@ func (s *Handlers) pageData(
} }
} }
// executeTemplate renders the template into a buffer and writes status // executeTemplate renders the template into a buffer and writes to
// and the page to the response only once rendering has fully // the response only once rendering has fully succeeded. Executing
// succeeded. Executing straight into the ResponseWriter commits a // straight into the ResponseWriter commits a partial body and a 200
// partial body and the status before a mid-render error can be // status before a mid-render error can be reported, leaving no way
// reported, leaving no way to serve a 500. Buffering makes a page's // to serve a 500. Buffering makes a page's rendered size resident
// rendered size resident memory per concurrent viewer, so every page // memory per concurrent viewer, so every page owes it a bound: the
// owes it a bound: the event log caps each stored body at // event log caps each stored body at maxRenderedBodyBytes for exactly
// maxRenderedBodyBytes for exactly this reason. // 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
@@ -407,7 +390,6 @@ 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 {
+12 -13
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 == "" {
h.renderTemplateStatus( w.WriteHeader(http.StatusBadRequest)
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 {
h.renderTemplateStatus( w.WriteHeader(http.StatusBadRequest)
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,7 +644,8 @@ func (h *Handlers) applyWebhookEdit(
tmplKeyError: "Name is required", tmplKeyError: "Name is required",
} }
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -664,7 +665,8 @@ func (h *Handlers) applyWebhookEdit(
tmplKeyError: retentionErrorMessage(retErr), tmplKeyError: retentionErrorMessage(retErr),
} }
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -701,7 +703,8 @@ func (h *Handlers) applyWebhookEdit(
"it, then save again.", "it, then save again.",
} }
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict) w.WriteHeader(http.StatusConflict)
h.renderTemplate(w, r, "source_edit.html", data)
return return
} }
@@ -1908,13 +1911,9 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return false, err return false, err
} }
// Only the active column: saving the whole row would tgt.Active = !tgt.Active
// write back the name and settings read above over an
// edit saved since.
active := !tgt.Active
return active, h.db.DB().Model(&tgt). return tgt.Active, h.db.DB().Save(&tgt).Error
Update("active", active).Error
}, },
"failed to toggle target", "failed to toggle target",
targetActivated, targetDeactivated, targetActivated, targetDeactivated,
-66
View File
@@ -1,66 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestHandleTargetToggle_DoesNotUndoAnEdit proves that a toggle which
// loaded the target before an edit of it was saved does not write the
// old name and settings back over the edit. The edit is submitted from
// a callback on the toggle's own read of the target, so it is saved
// after that read and before the toggle writes.
func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
wh, tgt := seedHTTPTarget(t, env, "", "")
require.True(t, tgt.Active)
var (
edited bool
editCode int
)
require.NoError(t, env.db.DB().Callback().Query().
After("gorm:query").
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
// The edit reads the target too; only the toggle's read,
// the first, submits it.
if tx.Statement.Table != "targets" || edited {
return
}
edited = true
editCode = submitTargetEdit(
env, wh.ID, tgt.ID,
editForm(editReplacedURL, "", ""),
).Code
}),
)
req := postRequest(
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/toggle",
env.cookies,
map[string]string{paramSourceID: wh.ID, paramTargetID: tgt.ID},
)
w := httptest.NewRecorder()
env.handlers.HandleTargetToggle().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, http.StatusSeeOther, editCode)
stored := storedTarget(t, env, tgt.ID)
assert.False(t, stored.Active)
assert.Equal(t, "edited-name", stored.Name)
assert.Equal(t, 5, stored.MaxRetries)
assert.Equal(
t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL,
)
}
+11 -2
View File
@@ -12,6 +12,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
@@ -77,7 +78,14 @@ func newTestSessionManager(
key[i] = byte(i) key[i] = byte(i)
} }
store := session.NewStore(key) store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
}
var now func() time.Time var now func() time.Time
@@ -923,7 +931,8 @@ func metricsAuthMiddleware(
} }
key := make([]byte, testKeySize) key := make([]byte, testKeySize)
store := session.NewStore(key) store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log, key, nil) sessManager := session.NewForTest(store, cfg, log, key, nil)
+10
View File
@@ -0,0 +1,10 @@
package session
import "github.com/gorilla/sessions"
// NewStore exposes the production cookie-store constructor so tests
// exercise the store the application actually runs with, rather than a
// lookalike assembled in the test.
func NewStore(key []byte) *sessions.CookieStore {
return newStore(key)
}
-7
View File
@@ -8,13 +8,6 @@ import (
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
) )
// NewStore exposes the production cookie-store constructor so tests
// exercise the store the application actually runs with, rather than a
// lookalike assembled in the test.
func NewStore(key []byte) *sessions.CookieStore {
return newStore(key)
}
// NewForTest creates a Session with a pre-configured cookie store for use // NewForTest creates a Session with a pre-configured cookie store for use
// in tests. This bypasses the fx lifecycle and database dependency, allowing // in tests. This bypasses the fx lifecycle and database dependency, allowing
// middleware and handler tests to use real session functionality. The key // middleware and handler tests to use real session functionality. The key