Some checks failed
check / check (push) Superseded by a newer commit; never tested
583 lines
13 KiB
Go
583 lines
13 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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/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")
|
|
|
|
// 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)
|
|
}
|
|
},
|
|
),
|
|
)
|
|
}
|
|
|
|
// 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",
|
|
)
|
|
}
|
|
|
|
// 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(
|
|
"/source/"+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 /sources",
|
|
)
|
|
|
|
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(
|
|
"/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, "/sources", 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",
|
|
)
|
|
}
|
|
|
|
// 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",
|
|
)
|
|
}
|