check / check (push) Waiting to run
DeleteDB stopped at the first file it could not remove, and the webhook delete handler logged one message for every failure. Once the database file itself is gone, its events are lost, and telling the operator only that a file was left behind suggests otherwise. DeleteDB now tries the database file and both sidecars, and its error wraps ErrEventDBNotRemoved when the database file is still on disk, or ErrSidecarNotRemoved when only a -wal or -shm sidecar is left. The handler logs a different message for each. Model: opus-5-5
626 lines
14 KiB
Go
626 lines
14 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"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"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"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
|
|
}
|
|
|
|
// errInjectedDelete is the failure failDeleteOnTable reports
|
|
// from a delete statement.
|
|
var errInjectedDelete = errors.New("injected delete failure")
|
|
|
|
// errInjectedSave is the failure failSaveOnTable reports from a
|
|
// save of an existing row.
|
|
var errInjectedSave = errors.New("injected save failure")
|
|
|
|
// seedEntrypoint inserts an entrypoint for a webhook.
|
|
func seedEntrypoint(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
webhookID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
ep := &database.Entrypoint{
|
|
WebhookID: webhookID,
|
|
Path: "ep-" + webhookID,
|
|
Active: true,
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
db.DB().Omit(clause.Associations).Create(ep).Error,
|
|
)
|
|
}
|
|
|
|
// countRows counts the live (not soft-deleted) rows of a model
|
|
// matching column = value.
|
|
func countRows(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
model any,
|
|
column, value string,
|
|
) int64 {
|
|
t.Helper()
|
|
|
|
var n int64
|
|
|
|
require.NoError(
|
|
t,
|
|
db.DB().Model(model).
|
|
Where(column+" = ?", value).
|
|
Count(&n).Error,
|
|
)
|
|
|
|
return n
|
|
}
|
|
|
|
// failDeleteOnTable makes every delete against the named table
|
|
// fail the way a database-level error does: the statement
|
|
// reports an error but leaves the surrounding transaction
|
|
// usable, so a caller that does not check it can go on to
|
|
// commit the statements that did succeed.
|
|
func failDeleteOnTable(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
table string,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, db.DB().Callback().Delete().
|
|
Before("gorm:delete").
|
|
Register(
|
|
"test:fail_delete_"+table,
|
|
func(tx *gorm.DB) {
|
|
if tx.Statement.Table == table {
|
|
_ = tx.AddError(errInjectedDelete)
|
|
}
|
|
},
|
|
),
|
|
)
|
|
}
|
|
|
|
// failSaveOnTable is failDeleteOnTable for saves: every update of
|
|
// an existing row in the named table fails.
|
|
func failSaveOnTable(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
table string,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, db.DB().Callback().Update().
|
|
Before("gorm:update").
|
|
Register(
|
|
"test:fail_save_"+table,
|
|
func(tx *gorm.DB) {
|
|
if tx.Statement.Table == table {
|
|
_ = tx.AddError(errInjectedSave)
|
|
}
|
|
},
|
|
),
|
|
)
|
|
}
|
|
|
|
// archivePathFor returns the archive database path the
|
|
// delivery engine would use for a database target: beside the
|
|
// webhook's event database in the data directory.
|
|
func archivePathFor(
|
|
t *testing.T,
|
|
mgr *database.WebhookDBManager,
|
|
wh *database.Webhook,
|
|
tgt *database.Target,
|
|
) string {
|
|
t.Helper()
|
|
|
|
return filepath.Join(
|
|
filepath.Dir(mgr.DBPath(wh.ID)),
|
|
delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID),
|
|
)
|
|
}
|
|
|
|
// 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 writers, exercised through the real
|
|
// deletion handler rather than by calling the engine directly.
|
|
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
ev *recordingArchives
|
|
)
|
|
|
|
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(
|
|
"/hook/"+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)
|
|
tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
|
|
// Place an archive file where the delivery engine would.
|
|
archivePath := archivePathFor(t, mgr, wh, tgt)
|
|
require.NoError(
|
|
t,
|
|
writeArchivePlaceholder(archivePath),
|
|
)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/hook/"+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",
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceDelete_FailedDeleteKeepsEverything proves
|
|
// that a failing delete statement loses nothing: the
|
|
// configuration is rolled back whole, the event database
|
|
// survives, and the operator is told the deletion failed
|
|
// instead of being redirected as though it worked.
|
|
func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
|
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)
|
|
seedEntrypoint(t, db, wh.ID)
|
|
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
|
|
require.NoError(t, mgr.CreateDB(wh.ID))
|
|
|
|
eventDBPath := mgr.DBPath(wh.ID)
|
|
require.FileExists(t, eventDBPath)
|
|
|
|
// The entrypoint delete runs first and succeeds; the target
|
|
// delete then fails, which is what the whole transaction has
|
|
// to be rolled back over.
|
|
failDeleteOnTable(t, db, "targets")
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/hook/"+wh.ID+"/delete",
|
|
cookies,
|
|
map[string]string{paramSourceID: wh.ID},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
|
|
|
assert.Equal(
|
|
t, http.StatusInternalServerError, w.Code,
|
|
"a failed deletion must be reported, not redirected",
|
|
)
|
|
assert.Empty(
|
|
t, w.Header().Get("Location"),
|
|
"a failed deletion must not redirect to /hooks",
|
|
)
|
|
|
|
assert.Equal(
|
|
t, int64(1),
|
|
countRows(t, db, &database.Webhook{}, "id", wh.ID),
|
|
"the webhook must survive a failed deletion",
|
|
)
|
|
assert.Equal(
|
|
t, int64(1),
|
|
countRows(
|
|
t, db, &database.Entrypoint{}, "webhook_id", wh.ID,
|
|
),
|
|
"the entrypoint delete must be rolled back",
|
|
)
|
|
assert.Equal(
|
|
t, int64(1),
|
|
countRows(
|
|
t, db, &database.Target{}, "webhook_id", wh.ID,
|
|
),
|
|
"the target must survive a failed deletion",
|
|
)
|
|
|
|
assert.FileExists(
|
|
t, eventDBPath,
|
|
"event history must not be destroyed when the "+
|
|
"configuration delete did not commit",
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceDelete_RemovesConfigAndEventDatabase is the
|
|
// positive control for the rollback above: an ordinary deletion
|
|
// still removes the webhook, its children and its event
|
|
// database.
|
|
func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|
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)
|
|
seedEntrypoint(t, db, wh.ID)
|
|
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
|
|
|
|
require.NoError(t, mgr.CreateDB(wh.ID))
|
|
|
|
eventDBPath := mgr.DBPath(wh.ID)
|
|
require.FileExists(t, eventDBPath)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/hook/"+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, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
|
|
)
|
|
|
|
assert.Equal(
|
|
t, int64(0),
|
|
countRows(t, db, &database.Webhook{}, "id", wh.ID),
|
|
)
|
|
assert.Equal(
|
|
t, int64(0),
|
|
countRows(
|
|
t, db, &database.Entrypoint{}, "webhook_id", wh.ID,
|
|
),
|
|
)
|
|
assert.Equal(
|
|
t, int64(0),
|
|
countRows(
|
|
t, db, &database.Target{}, "webhook_id", wh.ID,
|
|
),
|
|
)
|
|
assert.NoFileExists(
|
|
t, eventDBPath,
|
|
"a successful deletion removes the event database",
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceDelete_LeftoverSidecar proves that when the event
|
|
// database file is removed but a sidecar beside it is not, the
|
|
// operator is told the events are gone, never that the event
|
|
// database file is still there.
|
|
func TestHandleSourceDelete_LeftoverSidecar(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)
|
|
|
|
logs := new(bytes.Buffer)
|
|
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
require.NoError(t, mgr.CreateDB(wh.ID))
|
|
// Closing removes the sidecars, so the -wal below is the only
|
|
// one there.
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
// A non-empty directory in the -wal file's place, which
|
|
// os.Remove cannot remove whoever runs the test.
|
|
eventDBPath := mgr.DBPath(wh.ID)
|
|
require.NoError(t, os.MkdirAll(
|
|
filepath.Join(eventDBPath+"-wal", "keep"), 0o700,
|
|
))
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/hook/"+wh.ID+"/delete",
|
|
cookies,
|
|
map[string]string{paramSourceID: wh.ID},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.NoFileExists(t, eventDBPath)
|
|
assert.Contains(t, logs.String(), "its events are gone")
|
|
assert.Contains(t, logs.String(), eventDBPath+"-wal")
|
|
assert.NotContains(
|
|
t, logs.String(), "event database file is still on disk",
|
|
"the events are gone, so the operator must not be told "+
|
|
"the event database file survived",
|
|
)
|
|
}
|
|
|
|
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a
|
|
// database target releases that target's archive writer and no
|
|
// other: the webhook's other database target keeps its own.
|
|
func TestHandleTargetDelete_EvictsThatTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
ev *recordingArchives
|
|
)
|
|
|
|
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(
|
|
"/hook/"+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.Equal(
|
|
t, []string{doomed.ID}, ev.EvictedTargets(),
|
|
"deleting a database target should evict its writer",
|
|
)
|
|
assert.Empty(t, ev.Evicted(), "the webhook is not deleted")
|
|
}
|
|
|
|
// TestHandleTargetDelete_IgnoresAnotherWebhooksTarget proves that
|
|
// a target id from the URL that is not a target of the webhook
|
|
// deletes nothing and so evicts nothing.
|
|
func TestHandleTargetDelete_IgnoresAnotherWebhooksTarget(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
ev *recordingArchives
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &ev)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
elsewhere := seedTarget(
|
|
t, db, seedWebhook(t, db).ID, database.TargetTypeDatabase,
|
|
)
|
|
|
|
cookies := authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
)
|
|
|
|
req := postRequest(
|
|
"/hook/"+wh.ID+"/targets/"+elsewhere.ID+"/delete",
|
|
cookies,
|
|
map[string]string{
|
|
paramSourceID: wh.ID,
|
|
paramTargetID: elsewhere.ID,
|
|
},
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.HandleTargetDelete().ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Empty(
|
|
t, ev.EvictedTargets(),
|
|
"another webhook's target must not be evicted",
|
|
)
|
|
}
|