package handlers_test import ( "context" "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/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 } // 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", ) } // 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_KeepsWriterWhileDatabaseTargetRemains // proves that deleting an unrelated target type leaves a // still-needed archive writer alone. func TestHandleTargetDelete_KeepsWriterWhileDatabaseTargetRemains( 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", ) }