Compare commits
1 Commits
1ec8856bce
...
be9e13eea9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be9e13eea9 |
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -12,16 +11,34 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// eventBodyQuery reads one event's stored body as bytes. The cast
|
// bodyChunkBytes is how much of a stored body is resident at
|
||||||
// to blob is what makes the driver hand back the stored bytes
|
// once while it is being written to the client. The event log
|
||||||
// rather than a string conversion, so Content-Length taken from
|
// page caps what it renders at maxRenderedBodyBytes, so this
|
||||||
// the result matches what goes on the wire. The soft-delete
|
// route is the only way to reach a whole body and by
|
||||||
|
// construction serves the largest ones in the system. Reading
|
||||||
|
// it in fixed chunks keeps the peak a property of this constant
|
||||||
|
// rather than of the payload.
|
||||||
|
const bodyChunkBytes = 64 * 1024
|
||||||
|
|
||||||
|
// eventBodySizeColumn measures a stored body the same way
|
||||||
|
// eventLogColumns cuts one: the cast to blob makes length count
|
||||||
|
// bytes rather than characters, so Content-Length matches what
|
||||||
|
// substr will actually hand back.
|
||||||
|
const eventBodySizeColumn = "length(cast(body as blob))"
|
||||||
|
|
||||||
|
// eventBodyChunkQuery reads one byte range of a stored body.
|
||||||
|
// substr over a blob is 1-indexed over bytes. The soft-delete
|
||||||
// predicate is spelled out because Raw bypasses GORM's default
|
// predicate is spelled out because Raw bypasses GORM's default
|
||||||
// scope, and it is what stops a reaped event still being
|
// scope, and it is what stops a reaped event still being
|
||||||
// downloadable.
|
// downloadable.
|
||||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
const eventBodyChunkQuery = "SELECT substr(cast(body as blob), ?, ?) " +
|
||||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
|
// errShortBodyRead reports that a chunk query returned nothing
|
||||||
|
// while bytes were still owed, which means the row went away
|
||||||
|
// mid-download.
|
||||||
|
var errShortBodyRead = errors.New("stored body ended early")
|
||||||
|
|
||||||
// HandleEventBodyDownload serves one event's stored body in
|
// HandleEventBodyDownload serves one event's stored body in
|
||||||
// full, which the event log page cannot: it caps each rendered
|
// full, which the event log page cannot: it caps each rendered
|
||||||
// body at maxRenderedBodyBytes.
|
// body at maxRenderedBodyBytes.
|
||||||
@@ -68,28 +85,11 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// things enforce that and they are not equally strong. The
|
// things enforce that and they are not equally strong. The
|
||||||
// operative one is that events live in a per-webhook SQLite
|
// operative one is that events live in a per-webhook SQLite
|
||||||
// file, so a sibling webhook's event is not in the database
|
// file, so a sibling webhook's event is not in the database
|
||||||
// being queried at all. The webhook_id predicate on the query
|
// being queried at all. The webhook_id predicate on every query
|
||||||
// below is the second guard, and it is currently redundant
|
// below is the second guard, and it is currently redundant
|
||||||
// against that isolation; it is there so the scoping survives
|
// against that isolation; it is there so the scoping survives
|
||||||
// any future change that puts more than one webhook's events in
|
// any future change that puts more than one webhook's events in
|
||||||
// one file.
|
// one file.
|
||||||
//
|
|
||||||
// The body is read in one query and held whole in memory while
|
|
||||||
// it is written. That is the bound: one body per concurrent
|
|
||||||
// download, and a body is capped at 1 MB when it is ingested,
|
|
||||||
// so a download cannot cost more than that. There is no
|
|
||||||
// cheaper bound available — database/sql exposes no incremental
|
|
||||||
// handle on a SQLite BLOB, and reading byte ranges with substr
|
|
||||||
// does not avoid the cost either, because SQLite materialises
|
|
||||||
// the whole column value to evaluate each substr call. Range
|
|
||||||
// reads only pay for that materialisation once per range.
|
|
||||||
//
|
|
||||||
// One consequence is worth keeping in view: the read finishes
|
|
||||||
// before the client is written to, so no read lock is held for
|
|
||||||
// the length of a slow download. These per-webhook databases
|
|
||||||
// run in SQLite's default journal mode rather than WAL, so a
|
|
||||||
// lock held that long would block the receiver from recording
|
|
||||||
// new events.
|
|
||||||
func (h *Handlers) serveEventBody(
|
func (h *Handlers) serveEventBody(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
@@ -109,29 +109,25 @@ func (h *Handlers) serveEventBody(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
size, found, err := eventBodySize(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to read event body", err)
|
h.serverError(w, "failed to size event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A miss is a 404 whether the event belongs to another
|
// A miss is a 404 whether the event belongs to another
|
||||||
// webhook or does not exist at all, so the response does
|
// webhook or does not exist at all, so the response does
|
||||||
// not report which. Reading the body before any header is
|
// not report which.
|
||||||
// written is also what keeps an event reaped mid-request
|
|
||||||
// from producing a torn response: either the read finds the
|
|
||||||
// row and the whole body is served, or it does not and the
|
|
||||||
// response is a clean 404.
|
|
||||||
if !found {
|
if !found {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setEventBodyHeaders(w, eventID, int64(len(body)))
|
setEventBodyHeaders(w, eventID, size)
|
||||||
|
|
||||||
_, err = w.Write(body)
|
err = writeEventBody(w, webhookDB, webhook.ID, eventID, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The status and Content-Length are already committed,
|
// The status and Content-Length are already committed,
|
||||||
// so the client sees a short download. There is no way
|
// so the client sees a short download. There is no way
|
||||||
@@ -145,27 +141,32 @@ func (h *Handlers) serveEventBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventBody returns an event's stored body and whether the event
|
// eventBodySize returns the stored size in bytes of an event's
|
||||||
// exists within the webhook.
|
// body and whether the event exists within the webhook. The
|
||||||
func eventBody(
|
// size is read separately from the body so Content-Length can
|
||||||
|
// be set before any bytes are written.
|
||||||
|
func eventBodySize(
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
webhookID, eventID string,
|
webhookID, eventID string,
|
||||||
) ([]byte, bool, error) {
|
) (int64, bool, error) {
|
||||||
var body []byte
|
var size int64
|
||||||
|
|
||||||
err := webhookDB.Raw(
|
result := webhookDB.Model(&database.Event{}).
|
||||||
eventBodyQuery, eventID, webhookID,
|
Select(eventBodySizeColumn).
|
||||||
).Row().Scan(&body)
|
Where(
|
||||||
|
"id = ? AND webhook_id = ?", eventID, webhookID,
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
).
|
||||||
return nil, false, nil
|
Limit(1).
|
||||||
|
Scan(&size)
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, false, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if result.RowsAffected == 0 {
|
||||||
return nil, false, err
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return body, true, nil
|
return size, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setEventBodyHeaders applies the response headers that make
|
// setEventBodyHeaders applies the response headers that make
|
||||||
@@ -190,3 +191,61 @@ func setEventBodyHeaders(
|
|||||||
)
|
)
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeEventBody copies size bytes of the event's stored body to
|
||||||
|
// w in bodyChunkBytes-sized reads.
|
||||||
|
//
|
||||||
|
// This is where the route earns its memory bound. database/sql
|
||||||
|
// exposes no incremental handle on a SQLite BLOB, so scanning
|
||||||
|
// the column would materialise the whole body regardless of the
|
||||||
|
// wrapper around it; reading byte ranges instead keeps the
|
||||||
|
// resident cost at one chunk. Nothing goes through
|
||||||
|
// renderTemplate, which buffers a whole response before writing
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// There is deliberately no wrapping read transaction. These
|
||||||
|
// per-webhook databases run in SQLite's default journal mode,
|
||||||
|
// not WAL, so a read lock held for the length of a slow client's
|
||||||
|
// download would block the receiver from recording new events.
|
||||||
|
// The cost of that choice is that a body deleted mid-download
|
||||||
|
// ends the response short, which is reported as an error rather
|
||||||
|
// than passed off as a complete file.
|
||||||
|
func writeEventBody(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID, eventID string,
|
||||||
|
size int64,
|
||||||
|
) error {
|
||||||
|
// Flushing each chunk keeps the claim above true at the
|
||||||
|
// socket as well as in this loop. A ResponseWriter that
|
||||||
|
// cannot flush is not an error: net/http's own output
|
||||||
|
// buffer is a fixed size either way.
|
||||||
|
flusher := http.NewResponseController(w)
|
||||||
|
|
||||||
|
for written := int64(0); written < size; {
|
||||||
|
var chunk []byte
|
||||||
|
|
||||||
|
err := webhookDB.Raw(
|
||||||
|
eventBodyChunkQuery,
|
||||||
|
written+1, bodyChunkBytes, eventID, webhookID,
|
||||||
|
).Row().Scan(&chunk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunk) == 0 {
|
||||||
|
return errShortBodyRead
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := w.Write(chunk)
|
||||||
|
written += int64(n)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,9 +112,9 @@ func TestHandleEventBodyDownload_ServesOversizeBodyInFull(
|
|||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
// Far above the render cap, with multibyte runes and a
|
// Larger than one read chunk as well as the render cap, so
|
||||||
// distinctive tail, so a body that the log page can only
|
// the chunked read has to reassemble the body in order and
|
||||||
// show a slice of comes back whole and in order.
|
// the tail past the last whole chunk is exercised.
|
||||||
const sentinel = "TAIL-SENTINEL-1f4a9c"
|
const sentinel = "TAIL-SENTINEL-1f4a9c"
|
||||||
|
|
||||||
stored := strings.Repeat("A", 200*1024) +
|
stored := strings.Repeat("A", 200*1024) +
|
||||||
@@ -134,65 +134,6 @@ func TestHandleEventBodyDownload_ServesOversizeBodyInFull(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleEventBodyDownload_BodiesRoundTripByteIdentical
|
|
||||||
// covers the sizes and byte values a stored body can actually
|
|
||||||
// take: empty, one byte, either side of the render cap, and
|
|
||||||
// bytes that are not text at all. Content-Length has to equal
|
|
||||||
// the bytes written in every case, since it is derived from the
|
|
||||||
// same read that produces them.
|
|
||||||
func TestHandleEventBodyDownload_BodiesRoundTripByteIdentical(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A NUL, invalid UTF-8 and a multibyte rune, so nothing on
|
|
||||||
// the path can be treating the body as text.
|
|
||||||
binary := "\x00\x01\xff\xfe" + snowman + "\x00tail"
|
|
||||||
|
|
||||||
cases := map[string]string{
|
|
||||||
"empty": "",
|
|
||||||
"single byte": "x",
|
|
||||||
"one below cap": strings.Repeat("b", bodyCap-1),
|
|
||||||
"exactly cap": strings.Repeat("c", bodyCap),
|
|
||||||
"one above cap": strings.Repeat("d", bodyCap+1),
|
|
||||||
"binary": binary,
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, stored := range cases {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
evt := seedEventWithBody(t, dbMgr, wh.ID, stored)
|
|
||||||
|
|
||||||
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Equal(t, stored, w.Body.String())
|
|
||||||
assert.Equal(
|
|
||||||
t, strconv.Itoa(len(stored)),
|
|
||||||
w.Header().Get("Content-Length"),
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, len(stored), w.Body.Len(),
|
|
||||||
"Content-Length must equal bytes written",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleEventBodyDownload_HeadersAreNotRenderable pins the
|
// TestHandleEventBodyDownload_HeadersAreNotRenderable pins the
|
||||||
// response headers that stop attacker-supplied bytes executing
|
// response headers that stop attacker-supplied bytes executing
|
||||||
// in the operator's own origin. They are a security control, not
|
// in the operator's own origin. They are a security control, not
|
||||||
@@ -400,68 +341,6 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleEventBodyDownload_ReapedEvent404s pins what happens
|
|
||||||
// when the retention reaper takes an event out from under this
|
|
||||||
// route. The body is read in one query before any header is
|
|
||||||
// written, so a reaped event cannot produce a partial download:
|
|
||||||
// it is a clean 404 with no Content-Length and no
|
|
||||||
// Content-Disposition. Both removals the codebase performs are
|
|
||||||
// covered — the reaper hard-deletes, and a soft-deleted row is
|
|
||||||
// excluded by the query's own deleted_at predicate rather than
|
|
||||||
// by GORM's default scope, which Raw bypasses.
|
|
||||||
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for name, hard := range map[string]bool{
|
|
||||||
"soft deleted": false,
|
|
||||||
"hard deleted": true,
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
const payload = "REAPED-PAYLOAD-4d2a"
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
evt := seedEventWithBody(t, dbMgr, wh.ID, payload)
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
del := webhookDB
|
|
||||||
if hard {
|
|
||||||
del = del.Unscoped()
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
del.Delete(&database.Event{}, "id = ?", evt.ID).
|
|
||||||
Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.NotContains(t, w.Body.String(), payload)
|
|
||||||
assert.Empty(t, w.Header().Get("Content-Length"))
|
|
||||||
assert.Empty(
|
|
||||||
t, w.Header().Get("Content-Disposition"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_TruncationMarkerLinksToDownload proves
|
// TestHandleSourceLogs_TruncationMarkerLinksToDownload proves
|
||||||
// the page tells the reader where the rest of the body is, and
|
// the page tells the reader where the rest of the body is, and
|
||||||
// only when there is a rest to fetch.
|
// only when there is a rest to fetch.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
@@ -50,7 +48,6 @@ type testEnv struct {
|
|||||||
router http.Handler
|
router http.Handler
|
||||||
sess *session.Session
|
sess *session.Session
|
||||||
db *database.Database
|
db *database.Database
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestEnv wires the dependency graph with fx and builds the
|
// newTestEnv wires the dependency graph with fx and builds the
|
||||||
@@ -60,13 +57,12 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
mw *middleware.Middleware
|
mw *middleware.Middleware
|
||||||
hnd *handlers.Handlers
|
hnd *handlers.Handlers
|
||||||
sess *session.Session
|
sess *session.Session
|
||||||
db *database.Database
|
db *database.Database
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
@@ -89,7 +85,7 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
middleware.New,
|
middleware.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
),
|
),
|
||||||
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db, &dbMgr),
|
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db),
|
||||||
)
|
)
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -98,7 +94,6 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
||||||
sess: sess,
|
sess: sess,
|
||||||
db: db,
|
db: db,
|
||||||
dbMgr: dbMgr,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,49 +232,6 @@ func (e *testEnv) seedUser(
|
|||||||
return user.ID, hash
|
return user.ID, hash
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedWebhook creates a webhook owned by the given user.
|
|
||||||
func (e *testEnv) seedWebhook(
|
|
||||||
t *testing.T,
|
|
||||||
userID string,
|
|
||||||
) *database.Webhook {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := &database.Webhook{UserID: userID, Name: "routed"}
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
e.db.DB().Omit(clause.Associations).Create(wh).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return wh
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedEvent records one event with the given body in a webhook's
|
|
||||||
// own database.
|
|
||||||
func (e *testEnv) seedEvent(
|
|
||||||
t *testing.T,
|
|
||||||
webhookID, body string,
|
|
||||||
) *database.Event {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
event := &database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: body,
|
|
||||||
ContentType: "application/octet-stream",
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
webhookDB.Omit(clause.Associations).Create(event).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedHash reads the current password hash for a username.
|
// storedHash reads the current password hash for a username.
|
||||||
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -429,95 +381,3 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|||||||
"an under-limit password change should still apply",
|
"an under-limit password change should still apply",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- /source/{sourceID} group ---
|
|
||||||
|
|
||||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
|
||||||
// feature the way a user does: render the event log page through
|
|
||||||
// the production router, take the download URL out of the markup
|
|
||||||
// the template emitted, and fetch that URL through the router
|
|
||||||
// again. Nothing here is hand-written, so a typo in either the
|
|
||||||
// route pattern or the template href fails this test — the
|
|
||||||
// handler-level tests cannot catch that, because they forge
|
|
||||||
// their own route context and assert a URL string they wrote
|
|
||||||
// themselves.
|
|
||||||
func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "loguser", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "loguser")
|
|
||||||
|
|
||||||
// Comfortably over the event log page's render cap, so the
|
|
||||||
// page truncates the body and renders the download link at
|
|
||||||
// all. The exact cap is the handlers package's business and
|
|
||||||
// is pinned by its own tests; this only needs to exceed it.
|
|
||||||
stored := strings.Repeat("Z", 64*1024)
|
|
||||||
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
env.seedEvent(t, wh.ID, stored)
|
|
||||||
|
|
||||||
page := env.get("/source/"+wh.ID+"/logs", cookies)
|
|
||||||
require.Equal(t, http.StatusOK, page.Code)
|
|
||||||
|
|
||||||
link := regexp.MustCompile(
|
|
||||||
`href="(/source/[^"]+/body)"`,
|
|
||||||
).FindStringSubmatch(page.Body.String())
|
|
||||||
require.Len(
|
|
||||||
t, link, 2,
|
|
||||||
"truncated body should render a download link",
|
|
||||||
)
|
|
||||||
|
|
||||||
w := env.get(html.UnescapeString(link[1]), cookies)
|
|
||||||
|
|
||||||
require.Equal(
|
|
||||||
t, http.StatusOK, w.Code,
|
|
||||||
"the link the page emits must be a live route",
|
|
||||||
)
|
|
||||||
assert.Equal(t, stored, w.Body.String())
|
|
||||||
assert.Equal(
|
|
||||||
t, strconv.Itoa(len(stored)),
|
|
||||||
w.Header().Get("Content-Length"),
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, "application/octet-stream",
|
|
||||||
w.Header().Get("Content-Type"),
|
|
||||||
)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Header().Get("Content-Disposition"), "attachment",
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, "nosniff", w.Header().Get("X-Content-Type-Options"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSourceLogsBody_OtherUser404s pins that the download route
|
|
||||||
// as registered is behind the auth the group provides and the
|
|
||||||
// ownership check the handler applies: another logged-in user
|
|
||||||
// asking the real router for the same URL gets a 404, and an
|
|
||||||
// unauthenticated request never reaches the handler at all.
|
|
||||||
func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
ownerID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
wh := env.seedWebhook(t, ownerID)
|
|
||||||
|
|
||||||
const payload = "OWNERS-PAYLOAD-77c1"
|
|
||||||
|
|
||||||
evt := env.seedEvent(t, wh.ID, payload)
|
|
||||||
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
|
|
||||||
|
|
||||||
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
|
||||||
intruder := env.authCookies(t, intruderID, "intruder")
|
|
||||||
|
|
||||||
w := env.get(path, intruder)
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.NotContains(t, w.Body.String(), payload)
|
|
||||||
|
|
||||||
anon := env.get(path, nil)
|
|
||||||
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
|
||||||
assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user