check / check (push) Waiting to run
Each database target on the webhook page links to /hook/ID/targets/TARGETID/download, which streams the target's archive as archive-WEBHOOK-TARGET-YYYYMMDDTHHMMSSZ.json.gz: the webhook, the target, exported_at, and archived_events, one object per row keyed by column, a body that is not valid UTF-8 in base64 with body_encoding. The export reads one row at a time on its own connection inside a read-only transaction: one snapshot, and no write lock. The handler holds the rename lock only while it reads the names and opens the file. A missing archive exports empty and is not created. Model: opus-5-5
186 lines
4.8 KiB
Go
186 lines
4.8 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// downloadPath is the archive download route of a target.
|
|
func downloadPath(webhookID, targetID string) string {
|
|
return "/hook/" + webhookID + "/targets/" + targetID + "/download"
|
|
}
|
|
|
|
// renameTarget submits the edit form renaming a target to Renamed.
|
|
func renameTarget(
|
|
env *sourceTestEnv, webhookID, targetID string,
|
|
) *httptest.ResponseRecorder {
|
|
form := url.Values{}
|
|
form.Set("name", "Renamed")
|
|
|
|
return submitTargetEdit(env, webhookID, targetID, form)
|
|
}
|
|
|
|
// TestHandleTargetDownload proves a database target's archive
|
|
// downloads as a gzipped JSON attachment named for the webhook, the
|
|
// target and the time, here with no archive file yet, so with no
|
|
// rows; and that a target of another type has no download.
|
|
func TestHandleTargetDownload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
logTarget := seedTarget(t, env.db, wh.ID, database.TargetTypeLog)
|
|
|
|
w := serveTarget(
|
|
env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
|
)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
assert.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
|
|
assert.Regexp(t,
|
|
`^attachment; filename="archive-seeded-t-database-`+
|
|
`\d{8}T\d{6}Z\.json\.gz"$`,
|
|
w.Header().Get("Content-Disposition"),
|
|
)
|
|
|
|
zr, err := gzip.NewReader(w.Body)
|
|
require.NoError(t, err)
|
|
|
|
var got map[string]json.RawMessage
|
|
|
|
require.NoError(t, json.NewDecoder(zr).Decode(&got))
|
|
assert.JSONEq(t,
|
|
`{"id":"`+archive.ID+`","name":"t-database"}`,
|
|
string(got["target"]),
|
|
)
|
|
assert.JSONEq(t, `[]`, string(got["archived_events"]))
|
|
|
|
w = serveTarget(
|
|
env, http.MethodGet, downloadPath(wh.ID, logTarget.ID), nil,
|
|
)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// TestHandleTargetDownload_WaitsForRename proves a download reads the
|
|
// target's names and opens its archive under the lock a rename holds:
|
|
// started while an edit is renaming the archive, it waits, and is
|
|
// named for the target's new name.
|
|
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
|
|
renaming, release := env.archives.BlockNextRename()
|
|
edited := make(chan *httptest.ResponseRecorder, 1)
|
|
|
|
go func() {
|
|
edited <- renameTarget(env, wh.ID, archive.ID)
|
|
}()
|
|
|
|
<-renaming
|
|
|
|
downloaded := make(chan *httptest.ResponseRecorder, 1)
|
|
|
|
go func() {
|
|
downloaded <- serveTarget(
|
|
env, http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
|
)
|
|
}()
|
|
|
|
select {
|
|
case <-downloaded:
|
|
release()
|
|
t.Fatal("the download did not wait for the rename")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
|
|
release()
|
|
require.Equal(t, http.StatusSeeOther, (<-edited).Code)
|
|
|
|
w := <-downloaded
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t,
|
|
w.Header().Get("Content-Disposition"), "archive-seeded-renamed-",
|
|
)
|
|
}
|
|
|
|
// stalledWriter is a response writer whose first write waits until
|
|
// resume is closed, closing writing when it starts to wait.
|
|
type stalledWriter struct {
|
|
*httptest.ResponseRecorder
|
|
|
|
once sync.Once
|
|
writing chan struct{}
|
|
resume chan struct{}
|
|
}
|
|
|
|
func (s *stalledWriter) Write(b []byte) (int, error) {
|
|
s.once.Do(func() {
|
|
close(s.writing)
|
|
<-s.resume
|
|
})
|
|
|
|
return s.ResponseRecorder.Write(b)
|
|
}
|
|
|
|
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
|
|
// lets go of the rename lock once its archive is open: while the
|
|
// download is stalled writing, an edit can still rename the target.
|
|
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := setupSourceTest(t)
|
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
|
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
|
)
|
|
for _, c := range env.cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
sw := &stalledWriter{
|
|
ResponseRecorder: httptest.NewRecorder(),
|
|
writing: make(chan struct{}),
|
|
resume: make(chan struct{}),
|
|
}
|
|
downloaded := make(chan struct{})
|
|
|
|
go func() {
|
|
targetRouter(env).ServeHTTP(sw, req)
|
|
close(downloaded)
|
|
}()
|
|
|
|
<-sw.writing
|
|
|
|
edited := make(chan *httptest.ResponseRecorder, 1)
|
|
|
|
go func() {
|
|
edited <- renameTarget(env, wh.ID, archive.ID)
|
|
}()
|
|
|
|
select {
|
|
case w := <-edited:
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
case <-time.After(10 * time.Second):
|
|
t.Error("the rename waited for the download")
|
|
}
|
|
|
|
close(sw.resume)
|
|
<-downloaded
|
|
assert.Equal(t, http.StatusOK, sw.Code)
|
|
}
|