Deploy main eacf1e7 to prod
check / check (push) Canceled after 0s

Replaces #559, which conflicted.

Model: opus-5-5
This commit is contained in:
2026-10-25 01:55:36 +00:00
parent 1647b43aa6
commit 537374e4cc
311 changed files with 35614 additions and 14120 deletions
+170 -68
View File
@@ -1,8 +1,10 @@
package handlers_test
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -14,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
@@ -61,10 +64,11 @@ func seedTarget(
t.Helper()
tgt := &database.Target{
WebhookID: webhookID,
Name: "t-" + string(targetType),
Type: targetType,
Active: true,
WebhookID: webhookID,
Name: "t-" + string(targetType),
Type: targetType,
Active: true,
MaxAttempts: 1,
}
require.NoError(
@@ -79,12 +83,17 @@ func seedTarget(
// from a delete statement.
var errInjectedDelete = errors.New("injected delete failure")
// seedEntrypoint inserts an entrypoint for a webhook.
// errInjectedSave is the failure failSaveOnTable reports from a
// save of an existing row.
var errInjectedSave = errors.New("injected save failure")
// seedEntrypoint inserts an active entrypoint for a webhook and
// returns it.
func seedEntrypoint(
t *testing.T,
db *database.Database,
webhookID string,
) {
) *database.Entrypoint {
t.Helper()
ep := &database.Entrypoint{
@@ -97,6 +106,8 @@ func seedEntrypoint(
t,
db.DB().Omit(clause.Associations).Create(ep).Error,
)
return ep
}
// countRows counts the live (not soft-deleted) rows of a model
@@ -146,19 +157,42 @@ func failDeleteOnTable(
)
}
// 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 webhook: beside the webhook's
// event database in the data directory.
// archive writers would use for a database target: beside the
// webhook's event database in the data directory.
func archivePathFor(
t *testing.T,
mgr *database.WebhookDBManager,
webhookID string,
wh *database.Webhook,
tgt *database.Target,
) string {
t.Helper()
return filepath.Join(
filepath.Dir(mgr.DBPath(webhookID)),
"archive-"+webhookID+".db",
filepath.Dir(mgr.DBPath(wh.ID)),
archive.FileName(wh.Name, tgt.Name, tgt.ID),
)
}
@@ -194,9 +228,9 @@ func postRequest(
}
// 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.
// deleting a webhook releases the webhook's archive writers,
// exercised through the real deletion handler rather than by
// calling the archive writers directly.
func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
t.Parallel()
@@ -204,7 +238,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingEvictor
ev *recordingArchives
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -220,7 +254,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
)
req := postRequest(
"/source/"+wh.ID+"/delete",
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -254,9 +288,10 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
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.ID)
// Place an archive file where the archive writers would.
archivePath := archivePathFor(t, mgr, wh, tgt)
require.NoError(
t,
writeArchivePlaceholder(archivePath),
@@ -267,7 +302,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
)
req := postRequest(
"/source/"+wh.ID+"/delete",
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -323,7 +358,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
)
req := postRequest(
"/source/"+wh.ID+"/delete",
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -337,7 +372,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
)
assert.Empty(
t, w.Header().Get("Location"),
"a failed deletion must not redirect to /sources",
"a failed deletion must not redirect to /hooks",
)
assert.Equal(
@@ -402,7 +437,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
req := postRequest(
"/source/"+wh.ID+"/delete",
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
@@ -411,7 +446,9 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
h.HandleSourceDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/sources", w.Header().Get("Location"))
assert.Equal(
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
)
assert.Equal(
t, int64(0),
@@ -435,68 +472,132 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
)
}
// TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone
// proves that removing the last database target releases the
// archive writer.
func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
t *testing.T,
) {
// 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
ev *recordingEvictor
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &ev)
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)
tgt := seedTarget(
t, db, wh.ID, database.TargetTypeDatabase,
)
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(
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
paramTargetID: tgt.ID,
},
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleTargetDelete().ServeHTTP(w, req)
h.HandleSourceDelete().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",
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(), handlers.EventDBLeftMsgForTest,
"the events are gone, so the operator must not be told "+
"the event database file survived",
)
}
// 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,
) {
// TestHandleSourceDelete_LeftoverDatabaseFile proves that when the
// event database file itself cannot be removed, the operator is told
// it is still on disk, never that its events are gone.
func TestHandleSourceDelete_LeftoverDatabaseFile(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingEvictor
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)
// A non-empty directory in the database 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, "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.Contains(
t, logs.String(), "event database file is still on disk",
)
assert.Contains(t, logs.String(), eventDBPath)
assert.NotContains(
t, logs.String(), handlers.SidecarLeftMsgForTest,
"the database file is still on disk, so the operator must "+
"not be told its events are gone",
)
}
// 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)
@@ -515,7 +616,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
)
req := postRequest(
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
@@ -527,17 +628,17 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
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",
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_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(
// 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()
@@ -546,7 +647,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
h *handlers.Handlers
sess *session.Session
db *database.Database
ev *recordingEvictor
ev *recordingArchives
)
app := newTestApp(t, &h, &sess, &db, &ev)
@@ -555,19 +656,20 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
other := seedTarget(t, db, wh.ID, database.TargetTypeLog)
elsewhere := seedTarget(
t, db, seedWebhook(t, db).ID, database.TargetTypeDatabase,
)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
"/hook/"+wh.ID+"/targets/"+elsewhere.ID+"/delete",
cookies,
map[string]string{
paramSourceID: wh.ID,
paramTargetID: other.ID,
paramTargetID: elsewhere.ID,
},
)
w := httptest.NewRecorder()
@@ -576,7 +678,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Empty(
t, ev.Evicted(),
"a surviving database target must keep its writer",
t, ev.EvictedTargets(),
"another webhook's target must not be evicted",
)
}