Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddbe8cd8df | ||
|
|
dc9deda173 | ||
|
|
c706199389 | ||
|
|
d52cac1ec6 | ||
|
|
0f9b68a0e8 |
@@ -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 check)
|
- name: Build Docker image (runs make fmt-check, golangci-lint, make test, make build)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
@@ -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 linting, for the test stage of the CI gate, and for
|
- Docker (for `make lint` and so for `make check`, for the CI gate, and
|
||||||
containerized deployment)
|
for 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,8 +3335,9 @@ 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; only `script/test`
|
run the same pinned linter version the gate does; of the steps
|
||||||
and `script/fmt-check` run on the host.
|
`make check` runs, only `script/test` and `script/fmt-check` run on the
|
||||||
|
host.
|
||||||
|
|
||||||
#### CI gate honesty
|
#### CI gate honesty
|
||||||
|
|
||||||
|
|||||||
@@ -191,9 +191,9 @@ func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
|||||||
path, archiveTestLogger(), debounce,
|
path, archiveTestLogger(), debounce,
|
||||||
)
|
)
|
||||||
|
|
||||||
// The writer reads the time from this clock, which only the
|
// The writer measures its reopen debounce on this clock, which
|
||||||
// test moves, so how long the host takes between writes
|
// only the test moves, so how long the host takes between
|
||||||
// cannot change the result.
|
// writes cannot change the result.
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
w.SetNow(func() time.Time { return now })
|
w.SetNow(func() time.Time { return now })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -1911,9 +1908,13 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tgt.Active = !tgt.Active
|
// Only the active column: saving the whole row would
|
||||||
|
// write back the name and settings read above over an
|
||||||
|
// edit saved since.
|
||||||
|
active := !tgt.Active
|
||||||
|
|
||||||
return tgt.Active, h.db.DB().Save(&tgt).Error
|
return active, h.db.DB().Model(&tgt).
|
||||||
|
Update("active", active).Error
|
||||||
},
|
},
|
||||||
"failed to toggle target",
|
"failed to toggle target",
|
||||||
targetActivated, targetDeactivated,
|
targetActivated, targetDeactivated,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ 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"
|
||||||
@@ -78,14 +77,7 @@ func newTestSessionManager(
|
|||||||
key[i] = byte(i)
|
key[i] = byte(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := sessions.NewCookieStore(key)
|
store := session.NewStore(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
|
||||||
|
|
||||||
@@ -931,8 +923,7 @@ func metricsAuthMiddleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := make([]byte, testKeySize)
|
key := make([]byte, testKeySize)
|
||||||
store := sessions.NewCookieStore(key)
|
store := session.NewStore(key)
|
||||||
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
|
||||||
|
|
||||||
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,13 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user