All checks were successful
check / check (push) Successful in 2m47s
The per-webhook archiveWriter registry in the database delivery target was never evicted, so a deleted webhook's writer -- and any archive file handle open within its debounce window -- lingered for the process lifetime. Separately, expiry pruning ran only when an archive was (re)opened, and reopens only happen on writes, so an archive belonging to a webhook that stopped receiving events kept its expired rows forever. Eviction: a new one-method delivery.WebhookEvictor interface (kept separate from Notifier: archiving lifecycle is not notification) is implemented by the Engine and injected into the handlers. Deleting a webhook, or deleting its last database target, drops the writer from the registry and closes its handle under the writer's own mutex, so eviction can never race an in-flight write. An evicted writer refuses further writes rather than reopening a file nothing holds. The archive file is deliberately left on disk: it is long-term storage an operator may want to keep or move away, and destroying it as a side effect of deleting a webhook would be unrecoverable. Idle sweep: a new ArchiveSweeper, modelled on the event RetentionReaper (fx lifecycle hooks, cancellable context, WaitGroup, ticker loop), prunes archives whose database target declares a positive expiry. It reuses the existing RETENTION_SWEEP_INTERVAL rather than adding a config key. It never creates an archive -- a missing file is skipped, and the reopen uses SQLite mode=rw so the file cannot be conjured even if it disappears mid-sweep -- routes the prune through the per-webhook writer so its mutex orders the sweep against concurrent writes, and leaves the archive closed so the move-the-file-away workflow keeps working. A failure for one webhook is logged and the sweep continues. Archives with no expiry or the expiry "never" are untouched. The sweep loop's context is rooted at context.Background(), not at the fx OnStart hook context. The hook context carries fx's 15 second start timeout, so a loop derived from it is cancelled three quarters of an hour before the first tick under the default one-hour interval, giving a sweeper that never sweeps. OnStop still cancels the loop and waits on the WaitGroup, so shutdown is unchanged. The sweep also never leaves a registry entry behind. Reaching the writer through the ordinary create-and-cache accessor would let a sweep that raced a webhook deletion re-insert a writer for a webhook that no longer exists, which nothing would ever evict again -- the very leak this change closes. An entry the sweep has to create is marked sweep-owned and released when the prune finishes, unless a delivery claimed it meanwhile, in which case it belongs to the registry and an eviction can still reach it. A writer evicted underneath a sweep is an ordinary interleaving and is logged at debug, not error. Each of those guards is pinned by a test that fails when the guard is removed. The adopt-during-sweep window -- a delivery claiming the sweep's own registry entry while that sweep is still running -- is driven directly against the registry, because a delivery placed between two sweeps never reaches the release path at all. The requirement that an idle archive ends the sweep closed is asserted both on a writer proven to hold an open handle beforehand and, end to end, on a delivery-owned entry the sweep keeps, rather than on an entry the sweep has already released and which therefore reports "not open" either way. The "never" expiry short circuit is checked against an archive file that has never been migrated, so any open of it would be observable as a created table.
276 lines
5.8 KiB
Go
276 lines
5.8 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
type noopNotifier struct{}
|
|
|
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
|
|
|
// recordingEvictor is a delivery.WebhookEvictor that records
|
|
// the webhook ids it was asked to evict, so a test can prove
|
|
// that a deletion path reached the delivery engine.
|
|
type recordingEvictor struct {
|
|
mu sync.Mutex
|
|
evicted []string
|
|
}
|
|
|
|
func (r *recordingEvictor) EvictWebhook(webhookID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.evicted = append(r.evicted, webhookID)
|
|
}
|
|
|
|
// Evicted returns a copy of the recorded webhook ids.
|
|
func (r *recordingEvictor) Evicted() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
out := make([]string, len(r.evicted))
|
|
copy(out, r.evicted)
|
|
|
|
return out
|
|
}
|
|
|
|
func newTestApp(
|
|
t *testing.T,
|
|
targets ...any,
|
|
) *fxtest.App {
|
|
t.Helper()
|
|
|
|
return fxtest.New(
|
|
t,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
func() *config.Config {
|
|
return &config.Config{
|
|
DataDir: t.TempDir(),
|
|
}
|
|
},
|
|
database.New,
|
|
database.NewWebhookDBManager,
|
|
healthcheck.New,
|
|
session.New,
|
|
func() delivery.Notifier {
|
|
return &noopNotifier{}
|
|
},
|
|
func() *recordingEvictor {
|
|
return &recordingEvictor{}
|
|
},
|
|
func(r *recordingEvictor) delivery.WebhookEvictor {
|
|
return r
|
|
},
|
|
handlers.New,
|
|
),
|
|
fx.Populate(targets...),
|
|
)
|
|
}
|
|
|
|
func TestHandleIndex_Unauthenticated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler := h.HandleIndex()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t, "/pages/login", w.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
func TestHandleIndex_Authenticated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
var sess *session.Session
|
|
|
|
app := newTestApp(t, &h, &sess)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
s, err := sess.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
sess.SetUser(s, "test-user-id", "testuser")
|
|
|
|
err = sess.Save(req, w, s)
|
|
require.NoError(t, err)
|
|
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
for _, cookie := range w.Result().Cookies() {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
|
|
w2 := httptest.NewRecorder()
|
|
h.HandleIndex().ServeHTTP(w2, req2)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
|
assert.Equal(
|
|
t, "/sources", w2.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
w, req, "http://93.184.216.34/services/T00/B00/xxx",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, cfg, "webhookUrl")
|
|
}
|
|
|
|
func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
w, req, "http://169.254.169.254/latest/meta-data/",
|
|
)
|
|
|
|
require.Error(t, err)
|
|
assert.Empty(t, cfg)
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestRenderTemplate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
data := map[string]any{"Version": "1.0.0"}
|
|
|
|
h.RenderTemplateForTest(
|
|
w, req, "nonexistent.html", data,
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusInternalServerError, w.Code,
|
|
)
|
|
}
|
|
|
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
// Empty expiry: the keep-forever default, empty config.
|
|
w := httptest.NewRecorder()
|
|
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cfg)
|
|
|
|
// Explicit never is stored as config.
|
|
w = httptest.NewRecorder()
|
|
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
|
|
|
// A positive duration is stored as config.
|
|
w = httptest.NewRecorder()
|
|
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
|
}
|
|
|
|
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
|
w := httptest.NewRecorder()
|
|
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
|
|
|
|
require.Error(t, err, "expiry %q", bad)
|
|
assert.Empty(t, cfg)
|
|
assert.Equal(
|
|
t, http.StatusBadRequest, w.Code,
|
|
"expiry %q should be rejected with 400", bad,
|
|
)
|
|
}
|
|
}
|