Some checks failed
check / check (push) Has been cancelled
Per-webhook archive writers are now evicted when the webhook or its last database target is deleted, and a background sweeper prunes expired rows from idle archives that no longer receive writes. Archive files themselves are never deleted.
357 lines
7.8 KiB
Go
357 lines
7.8 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
const (
|
|
deleteTestUserID = "test-user-id"
|
|
deleteTestUsername = "testuser"
|
|
|
|
// paramSourceID and paramTargetID are the chi URL parameter
|
|
// names the deletion handlers read.
|
|
paramSourceID = "sourceID"
|
|
paramTargetID = "targetID"
|
|
)
|
|
|
|
// seedWebhook inserts a webhook owned by the test user and
|
|
// returns it.
|
|
func seedWebhook(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
) *database.Webhook {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID,
|
|
Name: "delete-me",
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
db.DB().Omit(clause.Associations).Create(wh).Error,
|
|
)
|
|
|
|
return wh
|
|
}
|
|
|
|
// seedTarget inserts a target of the given type for a webhook
|
|
// and returns it.
|
|
func seedTarget(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
webhookID string,
|
|
targetType database.TargetType,
|
|
) *database.Target {
|
|
t.Helper()
|
|
|
|
tgt := &database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "t-" + string(targetType),
|
|
Type: targetType,
|
|
Active: true,
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
db.DB().Omit(clause.Associations).Create(tgt).Error,
|
|
)
|
|
|
|
return tgt
|
|
}
|
|
|
|
// archivePathFor returns the archive database path the
|
|
// delivery engine would use for a webhook: beside the webhook's
|
|
// event database in the data directory.
|
|
func archivePathFor(
|
|
t *testing.T,
|
|
mgr *database.WebhookDBManager,
|
|
webhookID string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
return filepath.Join(
|
|
filepath.Dir(mgr.DBPath(webhookID)),
|
|
"archive-"+webhookID+".db",
|
|
)
|
|
}
|
|
|
|
// writeArchivePlaceholder creates a stand-in archive file so a
|
|
// test can assert the file survives webhook deletion.
|
|
func writeArchivePlaceholder(path string) error {
|
|
return os.WriteFile(path, []byte("archive"), 0o600)
|
|
}
|
|
|
|
// postRequest builds an authenticated POST request carrying the
|
|
// given chi URL parameters.
|
|
func postRequest(
|
|
path string,
|
|
cookies []*http.Cookie,
|
|
params map[string]string,
|
|
) *http.Request {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, path, nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rctx := chi.NewRouteContext()
|
|
for k, v := range params {
|
|
rctx.URLParams.Add(k, v)
|
|
}
|
|
|
|
return req.WithContext(
|
|
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceDelete_EvictsArchiveWriter proves that
|
|
// deleting a webhook reaches the delivery engine and releases
|
|
// the webhook's archive writer, exercised through the real
|
|
// deletion handler rather than by calling the evictor directly.
|
|
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
ev *recordingEvictor
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/source/"+wh.ID+"/delete",
|
|
cookies,
|
|
map[string]string{paramSourceID: wh.ID},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t, []string{wh.ID}, ev.Evicted(),
|
|
"deleting a webhook should evict its archive writer",
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceDelete_KeepsArchiveFile proves that deleting
|
|
// a webhook does not remove its archive database file: the
|
|
// archive is long-term storage the operator owns.
|
|
func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
mgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &mgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
// Place an archive file where the delivery engine would.
|
|
archivePath := archivePathFor(t, mgr, wh.ID)
|
|
require.NoError(
|
|
t,
|
|
writeArchivePlaceholder(archivePath),
|
|
)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/source/"+wh.ID+"/delete",
|
|
cookies,
|
|
map[string]string{paramSourceID: wh.ID},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.FileExists(
|
|
t, archivePath,
|
|
"webhook deletion must not destroy the archive file",
|
|
)
|
|
}
|
|
|
|
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
|
|
// proves that removing the last database target releases the
|
|
// archive writer.
|
|
func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
ev *recordingEvictor
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
tgt := seedTarget(
|
|
t, db, wh.ID, database.TargetTypeDatabase,
|
|
)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
|
cookies,
|
|
map[string]string{
|
|
paramSourceID: wh.ID,
|
|
paramTargetID: tgt.ID,
|
|
},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleTargetDelete().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t, []string{wh.ID}, ev.Evicted(),
|
|
"removing the last database target should evict",
|
|
)
|
|
}
|
|
|
|
// TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains
|
|
// proves that deleting one of several database targets leaves
|
|
// the still-needed archive writer alone: the surviving target
|
|
// keeps archiving to the same file, so the writer must stay.
|
|
func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
ev *recordingEvictor
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
doomed := seedTarget(
|
|
t, db, wh.ID, database.TargetTypeDatabase,
|
|
)
|
|
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
|
cookies,
|
|
map[string]string{
|
|
paramSourceID: wh.ID,
|
|
paramTargetID: doomed.ID,
|
|
},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleTargetDelete().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Empty(
|
|
t, ev.Evicted(),
|
|
"a second database target still needs the writer",
|
|
)
|
|
}
|
|
|
|
// TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted proves
|
|
// that deleting a target of an unrelated type leaves a
|
|
// still-needed archive writer alone: the webhook's database
|
|
// target is untouched, so its writer must stay.
|
|
func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
ev *recordingEvictor
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
other := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
|
|
cookies,
|
|
map[string]string{
|
|
paramSourceID: wh.ID,
|
|
paramTargetID: other.ID,
|
|
},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleTargetDelete().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Empty(
|
|
t, ev.Evicted(),
|
|
"a surviving database target must keep its writer",
|
|
)
|
|
}
|