check / check (push) Successful in 3m16s
Each database target now has its own archive file, archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, instead of one archive-WEBHOOKID.db per webhook. delivery.ArchiveFileName builds the name. A change of webhook or target name renames its archive files under the archive writer's lock, before the new name is saved, and back again if the save fails. Webhook edits, target edits and target creation run one at a time, so no two of them interleave. A rename never replaces a file, and one that fails part way moves back what it moved. Deleting a target evicts only that target's writer. Archive files are never deleted, and nothing looks for files under the old name. Model: opus-5-5
564 lines
12 KiB
Go
564 lines
12 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/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",
|
|
)
|
|
}
|
|
|
|
// 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",
|
|
)
|
|
}
|