From 36a1bacf11a2ba4d185fb5d5b448143c1978bc2d Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 7 Aug 2026 16:51:42 +0000 Subject: [PATCH] Update golangci-lint to v2.12.2 with canonical config Bump the golangci-lint Docker image pin in Dockerfile and the release-archive sha256 pins in script/bootstrap from 2.11.3 to 2.12.2, and replace .golangci.yml with the canonical config. The canonical config moves lll/funlen/cyclop/dupl settings from the top-level linters-settings key (ignored by the v2 schema) to linters.settings, so those thresholds now actually apply. Fix all findings the newly applied thresholds surfaced: - lll: wrap or shorten seven over-length lines (struct tag comments, test logger construction, a func signature, and a nosec comment) - goconst: use http.MethodPost/http.MethodPut and new shared constants for repeated test strings; add tmplKeyError and tmplKeyWebhook constants for template data keys in handlers - dupl: merge buildHTTPTargetConfig and buildSlackTargetConfig into a parameterized buildURLTargetConfig; drop the duplicate iWebhookDB test helper in favor of testWebhookDB; extract shared helpers in middleware and session tests --- .golangci.yml | 26 +++-- Dockerfile | 4 +- TODO.md | 5 + internal/database/database_test.go | 13 ++- internal/database/model_entrypoint.go | 9 +- internal/database/model_target.go | 3 +- internal/database/model_webhook.go | 10 +- internal/database/retention_test.go | 9 +- internal/database/testing.go | 12 +- internal/database/webhook_db_manager_test.go | 29 ++--- internal/delivery/engine_integration_test.go | 42 +------ internal/delivery/engine_test.go | 29 ++--- internal/delivery/target_http.go | 2 +- internal/handlers/auth.go | 4 +- internal/handlers/export_test.go | 10 +- internal/handlers/handlers.go | 5 + internal/handlers/source_management.go | 111 ++++++------------- internal/middleware/middleware_test.go | 41 +++---- internal/session/session_test.go | 66 ++++++----- internal/session/testing.go | 7 +- script/bootstrap | 10 +- 21 files changed, 214 insertions(+), 233 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 34a8e31..26b1610 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,9 @@ version: "2" +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + run: timeout: 5m modules-download-mode: readonly @@ -14,19 +18,17 @@ linters: - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go - -linters-settings: - lll: - line-length: 88 - funlen: - lines: 80 - statements: 50 - cyclop: - max-complexity: 15 - dupl: - threshold: 100 + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile index 9559c97..8d62594 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Lint stage -# golangci/golangci-lint:v2.11.3 (Debian-based), 2026-03-17 +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # Using Debian-based image because mattn/go-sqlite3 (CGO) does not # compile on Alpine musl (off64_t is a glibc type). -FROM golangci/golangci-lint:v2.11.3@sha256:e838e8ab68aaefe83e2408691510867ade9329c0e0b895a3fb35eb93d1c2a4ba AS lint +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* diff --git a/TODO.md b/TODO.md index be74524..c869649 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,11 @@ databases currently grow without bound. # Completed Steps +- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in + `Dockerfile`, release-archive sha256 pins in `script/bootstrap`), + adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so + `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix + all newly surfaced lint findings - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile shims, README Entrypoints section - 2026-03-25 pin golangci-lint Docker image for linting (#55) diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 7db7ac6..22f7312 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -11,6 +11,15 @@ import ( "sneak.berlin/go/webhooker/internal/logger" ) +const ( + // testAppname is the Globals.Appname used in tests. + testAppname = "webhooker-test" + // testVersion is the Globals.Version used in tests. + testVersion = "test" + // testContentType is the event content type used in tests. + testContentType = "application/json" +) + func setupTestDB( t *testing.T, ) (*database.Database, *fxtest.Lifecycle) { @@ -19,8 +28,8 @@ func setupTestDB( lc := fxtest.NewLifecycle(t) g := &globals.Globals{ - Appname: "webhooker-test", - Version: "test", + Appname: testAppname, + Version: testVersion, } l, err := logger.New( diff --git a/internal/database/model_entrypoint.go b/internal/database/model_entrypoint.go index efae828..607f836 100644 --- a/internal/database/model_entrypoint.go +++ b/internal/database/model_entrypoint.go @@ -4,10 +4,13 @@ package database type Entrypoint struct { BaseModel - WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` - Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint + WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` + + // Path is the URL path for this entrypoint. + Path string `gorm:"uniqueIndex;not null" json:"path"` + Description string `json:"description"` - Active bool `gorm:"default:true" json:"active"` + Active bool `gorm:"default:true" json:"active"` // Relations Webhook Webhook `json:"webhook,omitzero"` diff --git a/internal/database/model_target.go b/internal/database/model_target.go index 4134894..9c8aa83 100644 --- a/internal/database/model_target.go +++ b/internal/database/model_target.go @@ -23,7 +23,8 @@ type Target struct { // Configuration fields (JSON stored based on type) Config string `gorm:"type:text" json:"config"` // JSON configuration - // For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff) + // For HTTP targets (max_retries=0 means fire-and-forget, + // >0 enables retries with backoff) MaxRetries int `json:"maxRetries,omitempty"` MaxQueueSize int `json:"maxQueueSize,omitempty"` diff --git a/internal/database/model_webhook.go b/internal/database/model_webhook.go index 5c6bd0e..9b47516 100644 --- a/internal/database/model_webhook.go +++ b/internal/database/model_webhook.go @@ -4,10 +4,12 @@ package database type Webhook struct { BaseModel - UserID string `gorm:"type:uuid;not null" json:"userId"` - Name string `gorm:"not null" json:"name"` - Description string `json:"description"` - RetentionDays int `gorm:"default:30" json:"retentionDays"` // Days to retain events + UserID string `gorm:"type:uuid;not null" json:"userId"` + Name string `gorm:"not null" json:"name"` + Description string `json:"description"` + + // RetentionDays is the number of days to retain events. + RetentionDays int `gorm:"default:30" json:"retentionDays"` // Relations User User `json:"user,omitzero"` diff --git a/internal/database/retention_test.go b/internal/database/retention_test.go index 2df0b0e..0ff0c90 100644 --- a/internal/database/retention_test.go +++ b/internal/database/retention_test.go @@ -2,6 +2,7 @@ package database_test import ( "context" + "net/http" "testing" "time" @@ -30,8 +31,8 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv { lc := fxtest.NewLifecycle(t) g := &globals.Globals{ - Appname: "webhooker-test", - Version: "test", + Appname: testAppname, + Version: testVersion, } l, err := logger.New(lc, logger.LoggerParams{Globals: g}) @@ -117,9 +118,9 @@ func seedEventChain( event := &database.Event{ WebhookID: webhookID, EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Body: `{"seed": true}`, - ContentType: "application/json", + ContentType: testContentType, } event.CreatedAt = createdAt require.NoError(t, db.Create(event).Error) diff --git a/internal/database/testing.go b/internal/database/testing.go index 1e00cf5..6327eac 100644 --- a/internal/database/testing.go +++ b/internal/database/testing.go @@ -13,8 +13,11 @@ import ( // sql.DB connection. func NewTestDatabase(db *gorm.DB) *Database { return &Database{ - db: db, - log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), + db: db, + log: slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )), } } @@ -23,6 +26,9 @@ func NewTestDatabase(db *gorm.DB) *Database { func NewTestWebhookDBManager(dataDir string) *WebhookDBManager { return &WebhookDBManager{ dataDir: dataDir, - log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), + log: slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )), } } diff --git a/internal/database/webhook_db_manager_test.go b/internal/database/webhook_db_manager_test.go index 5a9d033..282d890 100644 --- a/internal/database/webhook_db_manager_test.go +++ b/internal/database/webhook_db_manager_test.go @@ -2,6 +2,7 @@ package database_test import ( "context" + "net/http" "os" "path/filepath" "testing" @@ -25,8 +26,8 @@ func setupTestWebhookDBManager( lc := fxtest.NewLifecycle(t) g := &globals.Globals{ - Appname: "webhooker-test", - Version: "test", + Appname: testAppname, + Version: testVersion, } l, err := logger.New( @@ -83,10 +84,10 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) { event := &database.Event{ WebhookID: webhookID, EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Headers: `{"Content-Type":["application/json"]}`, Body: `{"test": true}`, - ContentType: "application/json", + ContentType: testContentType, } require.NoError(t, db.Create(event).Error) assert.NotEmpty(t, event.ID) @@ -99,7 +100,7 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) { db.First(&readEvent, "id = ?", event.ID).Error, ) assert.Equal(t, webhookID, readEvent.WebhookID) - assert.Equal(t, "POST", readEvent.Method) + assert.Equal(t, http.MethodPost, readEvent.Method) assert.Equal(t, `{"test": true}`, readEvent.Body) } @@ -123,9 +124,9 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) { event := &database.Event{ WebhookID: webhookID, EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Body: `{"test": true}`, - ContentType: "application/json", + ContentType: testContentType, } require.NoError(t, db.Create(event).Error) @@ -196,10 +197,10 @@ func seedDeliveryWorkflow( event := &database.Event{ WebhookID: webhookID, EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Headers: `{"Content-Type":["application/json"]}`, Body: `{"payload": "test"}`, - ContentType: "application/json", + ContentType: testContentType, } require.NoError(t, db.Create(event).Error) @@ -231,7 +232,7 @@ func verifyPendingDeliveries( ) require.Len(t, pending, 1) assert.Equal(t, event.ID, pending[0].EventID) - assert.Equal(t, "POST", pending[0].Event.Method) + assert.Equal(t, http.MethodPost, pending[0].Event.Method) } func completeDelivery( @@ -303,16 +304,16 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) { event1 := &database.Event{ WebhookID: webhook1, EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Body: `{"webhook": 1}`, - ContentType: "application/json", + ContentType: testContentType, } event2 := &database.Event{ WebhookID: webhook2, EntrypointID: uuid.New().String(), - Method: "PUT", + Method: http.MethodPut, Body: `{"webhook": 2}`, - ContentType: "application/json", + ContentType: testContentType, } require.NoError(t, db1.Create(event1).Error) diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 1f6b064..6b07c43 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -126,36 +126,6 @@ func iHTTPConfig(url string) string { return string(data) } -func iWebhookDB(t *testing.T) *gorm.DB { - t.Helper() - - dbPath := filepath.Join( - t.TempDir(), "events-test.db", - ) - - dsn := fmt.Sprintf( - "file:%s?cache=shared&mode=rwc", dbPath, - ) - - sqlDB, err := sql.Open("sqlite", dsn) - require.NoError(t, err) - - t.Cleanup(func() { _ = sqlDB.Close() }) - - db, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, &gorm.Config{}, - ) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate( - &database.Event{}, - &database.Delivery{}, - &database.DeliveryResult{}, - )) - - return db -} - func iEngine( t *testing.T, workers int, ) *delivery.Engine { @@ -182,10 +152,10 @@ func iSeedEvent( event := database.Event{ WebhookID: webhookID, EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Headers: `{}`, Body: body, - ContentType: "application/json", + ContentType: testContentType, } require.NoError(t, db.Create(&event).Error) @@ -935,7 +905,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { func TestDeliverHTTP_TargetTimeout(t *testing.T) { t.Parallel() - db := iWebhookDB(t) + db := testWebhookDB(t) e := iEngine(t, 1) ts := httptest.NewServer( @@ -987,10 +957,10 @@ func iSeedEventAndDelivery( event := database.Event{ WebhookID: uuid.New().String(), EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Headers: `{"Content-Type":["application/json"]}`, Body: body, - ContentType: "application/json", + ContentType: testContentType, } require.NoError(t, db.Create(&event).Error) @@ -1067,7 +1037,7 @@ func iAssertResultFailed( func TestDeliverHTTP_InvalidConfig(t *testing.T) { t.Parallel() - db := iWebhookDB(t) + db := testWebhookDB(t) e := iEngine(t, 1) event, del := iSeedEventAndDelivery( diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index 9c58ae0..2376de7 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -27,6 +27,9 @@ import ( "sneak.berlin/go/webhooker/internal/delivery" ) +// testContentType is the event content type used in tests. +const testContentType = "application/json" + func testWebhookDB(t *testing.T) *gorm.DB { t.Helper() @@ -94,10 +97,10 @@ func seedEvent( event := database.Event{ WebhookID: uuid.New().String(), EntrypointID: uuid.New().String(), - Method: "POST", + Method: http.MethodPost, Headers: `{"Content-Type":["application/json"]}`, Body: body, - ContentType: "application/json", + ContentType: testContentType, } require.NoError(t, db.Create(&event).Error) @@ -1113,10 +1116,10 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) { } event := &database.Event{ - Method: "POST", + Method: http.MethodPost, Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`, Body: `{"test":true}`, - ContentType: "application/json", + ContentType: testContentType, } statusCode, _, _, err := e.ExportDoHTTPRequest( @@ -1138,7 +1141,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) { ) assert.Equal(t, - "application/json", + testContentType, receivedHeaders.Get("Content-Type"), ) @@ -1297,8 +1300,8 @@ func TestFormatSlackMessage_JSONBody(t *testing.T) { t.Parallel() event := &database.Event{ - Method: "POST", - ContentType: "application/json", + Method: http.MethodPost, + ContentType: testContentType, Body: `{"action":"push",` + `"repo":"test/repo",` + `"ref":"refs/heads/main"}`, @@ -1323,7 +1326,7 @@ func TestFormatSlackMessage_NonJSONBody(t *testing.T) { t.Parallel() event := &database.Event{ - Method: "POST", + Method: http.MethodPost, ContentType: "text/plain", Body: "hello world plain text", } @@ -1346,8 +1349,8 @@ func TestFormatSlackMessage_EmptyBody(t *testing.T) { t.Parallel() event := &database.Event{ - Method: "POST", - ContentType: "application/json", + Method: http.MethodPost, + ContentType: testContentType, Body: "", } event.CreatedAt = time.Date( @@ -1375,8 +1378,8 @@ func TestFormatSlackMessage_LargeJSONTruncated( require.NoError(t, err) event := &database.Event{ - Method: "POST", - ContentType: "application/json", + Method: http.MethodPost, + ContentType: testContentType, Body: string(largeJSON), } event.CreatedAt = time.Date( @@ -1705,7 +1708,7 @@ func assertLogLineComplete( "log line must contain the webhook id", ) - assert.Contains(t, out, "application/json", + assert.Contains(t, out, testContentType, "log line must contain the content type", ) } diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index 9a58a8f..3ad6fb2 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -495,5 +495,5 @@ func applyRequestHeaders( func executeHTTPRequest( client *http.Client, req *http.Request, ) (*http.Response, error) { - return client.Do(req) //#nosec G704 -- URL validated by parseHTTPConfig/parseSlackConfig and SSRF-safe transport + return client.Do(req) //#nosec G704 -- validated URL, SSRF-safe transport } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 7bcf5e9..b934280 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -19,7 +19,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc { // Render login page data := map[string]any{ - "Error": "", + tmplKeyError: "", } h.renderTemplate(w, r, "login.html", data) @@ -86,7 +86,7 @@ func (h *Handlers) renderLoginError( status int, ) { data := map[string]any{ - "Error": msg, + tmplKeyError: msg, } w.WriteHeader(status) diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index cff6c09..8d65e54 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -13,14 +13,18 @@ func (s *Handlers) RenderTemplateForTest( s.renderTemplate(w, r, pageTemplate, data) } -// BuildSlackTargetConfigForTest exposes buildSlackTargetConfig -// for use in the handlers_test package. +// BuildSlackTargetConfigForTest exposes buildURLTargetConfig +// with the Slack target parameters for use in the +// handlers_test package. func (s *Handlers) BuildSlackTargetConfigForTest( w http.ResponseWriter, r *http.Request, targetURL string, ) (string, error) { - return s.buildSlackTargetConfig(w, r, targetURL) + return s.buildURLTargetConfig( + w, r, targetURL, "webhookUrl", + "Webhook URL is required for Slack targets", + ) } // BuildDatabaseTargetConfigForTest exposes diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 3c7bd1a..193856c 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -30,6 +30,11 @@ const ( defaultRetentionDays = 30 // paginationPerPage is the number of items per page. paginationPerPage = 25 + + // tmplKeyError is the template data key for an error message. + tmplKeyError = "Error" + // tmplKeyWebhook is the template data key for a webhook. + tmplKeyWebhook = "Webhook" ) // errInvalidPassword is returned when a password does not match. diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 7d43ad5..eaaf7e9 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -107,7 +107,7 @@ func (h *Handlers) buildWebhookListItems( func (h *Handlers) HandleSourceCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { data := map[string]any{ - "Error": "", + tmplKeyError: "", } h.renderTemplate(w, r, "sources_new.html", data) @@ -146,7 +146,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc { if name == "" { data := map[string]any{ - "Error": "Name is required", + tmplKeyError: "Name is required", } w.WriteHeader(http.StatusBadRequest) @@ -316,11 +316,11 @@ func (h *Handlers) renderSourceDetail( } data := map[string]any{ - "Webhook": webhook, - "Entrypoints": entrypoints, - "Targets": targets, - "Events": events, - "BaseURL": scheme + "://" + host, + tmplKeyWebhook: webhook, + "Entrypoints": entrypoints, + "Targets": targets, + "Events": events, + "BaseURL": scheme + "://" + host, } h.renderTemplate(w, r, "source_detail.html", data) @@ -352,8 +352,8 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc { } data := map[string]any{ - "Webhook": webhook, - "Error": "", + tmplKeyWebhook: webhook, + tmplKeyError: "", } h.renderTemplate(w, r, "source_edit.html", data) @@ -416,8 +416,8 @@ func (h *Handlers) applyWebhookEdit( name := r.FormValue("name") if name == "" { data := map[string]any{ - "Webhook": *webhook, - "Error": "Name is required", + tmplKeyWebhook: *webhook, + tmplKeyError: "Name is required", } w.WriteHeader(http.StatusBadRequest) @@ -590,15 +590,15 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc { } data := map[string]any{ - "Webhook": webhook, - "Events": evts, - "Page": page, - "TotalPages": totalPages, - "TotalEvents": total, - "HasPrev": page > 1, - "HasNext": page < totalPages, - "PrevPage": page - 1, - "NextPage": page + 1, + tmplKeyWebhook: webhook, + "Events": evts, + "Page": page, + "TotalPages": totalPages, + "TotalEvents": total, + "HasPrev": page > 1, + "HasNext": page < totalPages, + "PrevPage": page - 1, + "NextPage": page + 1, } h.renderTemplate(w, r, "source_logs.html", data) @@ -904,9 +904,15 @@ func (h *Handlers) buildTargetConfig( ) (string, error) { switch targetType { case database.TargetTypeHTTP: - return h.buildHTTPTargetConfig(w, r, targetURL) + return h.buildURLTargetConfig( + w, r, targetURL, "url", + "URL is required for HTTP targets", + ) case database.TargetTypeSlack: - return h.buildSlackTargetConfig(w, r, targetURL) + return h.buildURLTargetConfig( + w, r, targetURL, "webhookUrl", + "Webhook URL is required for Slack targets", + ) case database.TargetTypeDatabase: return h.buildDatabaseTargetConfig(w, expiry) case database.TargetTypeLog: @@ -921,16 +927,18 @@ func (h *Handlers) buildTargetConfig( } } -// buildHTTPTargetConfig builds config JSON for an HTTP target. -func (h *Handlers) buildHTTPTargetConfig( +// buildURLTargetConfig builds config JSON for a target whose +// configuration is a single SSRF-validated URL stored under +// configKey. missingMsg is the error shown when no URL is given. +func (h *Handlers) buildURLTargetConfig( w http.ResponseWriter, r *http.Request, - targetURL string, + targetURL, configKey, missingMsg string, ) (string, error) { if targetURL == "" { http.Error( w, - "URL is required for HTTP targets", + missingMsg, http.StatusBadRequest, ) @@ -955,56 +963,7 @@ func (h *Handlers) buildHTTPTargetConfig( return "", err } - cfg := map[string]any{"url": targetURL} - - configBytes, err := json.Marshal(cfg) - if err != nil { - http.Error( - w, "Internal server error", - http.StatusInternalServerError, - ) - - return "", err - } - - return string(configBytes), nil -} - -// buildSlackTargetConfig builds config JSON for a Slack target. -func (h *Handlers) buildSlackTargetConfig( - w http.ResponseWriter, - r *http.Request, - targetURL string, -) (string, error) { - if targetURL == "" { - http.Error( - w, - "Webhook URL is required for Slack targets", - http.StatusBadRequest, - ) - - return "", errMissingURL - } - - err := delivery.ValidateTargetURL( - r.Context(), targetURL, - ) - if err != nil { - h.log.Warn( - "target URL blocked by SSRF protection", - "url", targetURL, - "error", err, - ) - http.Error( - w, - "Invalid target URL: "+err.Error(), - http.StatusBadRequest, - ) - - return "", err - } - - cfg := map[string]any{"webhookUrl": targetURL} + cfg := map[string]any{configKey: targetURL} configBytes, err := json.Marshal(cfg) if err != nil { diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 05c5b68..fafec86 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -484,8 +484,13 @@ func metricsAuthMiddleware( return middleware.NewForTest(log, cfg, sessManager) } -func TestMetricsAuth_ValidCredentials(t *testing.T) { - t.Parallel() +// runMetricsAuthRequest sends a GET /metrics request with the +// given basic-auth password through MetricsAuth and reports +// whether the wrapped handler ran plus the recorded response. +func runMetricsAuthRequest( + t *testing.T, password string, +) (bool, *httptest.ResponseRecorder) { + t.Helper() m := metricsAuthMiddleware(t) @@ -503,12 +508,20 @@ func TestMetricsAuth_ValidCredentials(t *testing.T) { context.Background(), http.MethodGet, "/metrics", nil, ) - req.SetBasicAuth("admin", "secret") + req.SetBasicAuth("admin", password) w := httptest.NewRecorder() handler.ServeHTTP(w, req) + return called, w +} + +func TestMetricsAuth_ValidCredentials(t *testing.T) { + t.Parallel() + + called, w := runMetricsAuthRequest(t, "secret") + assert.True( t, called, "handler should be called with valid basic auth", @@ -519,27 +532,7 @@ func TestMetricsAuth_ValidCredentials(t *testing.T) { func TestMetricsAuth_InvalidCredentials(t *testing.T) { t.Parallel() - m := metricsAuthMiddleware(t) - - var called bool - - handler := m.MetricsAuth()(http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { - called = true - - w.WriteHeader(http.StatusOK) - }, - )) - - req := httptest.NewRequestWithContext( - context.Background(), - http.MethodGet, "/metrics", nil, - ) - req.SetBasicAuth("admin", "wrong-password") - - w := httptest.NewRecorder() - - handler.ServeHTTP(w, req) + called, w := runMetricsAuthRequest(t, "wrong-password") assert.False( t, called, diff --git a/internal/session/session_test.go b/internal/session/session_test.go index eec33c5..ba1e8c0 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -173,8 +173,18 @@ func TestSetUser_SetsAllFields(t *testing.T) { ) } -func TestGetUserID(t *testing.T) { - t.Parallel() +// testSessionGetter exercises a session string getter before and +// after SetUser: it must report false with an empty value on a +// fresh session, then true with the expected value once +// SetUser(sess, "user-xyz", "bob") has run. +func testSessionGetter( + t *testing.T, + get func( + *session.Session, *sessions.Session, + ) (string, bool), + expected string, +) { + t.Helper() s := testSession(t) @@ -185,44 +195,46 @@ func TestGetUserID(t *testing.T) { require.NoError(t, err) // Before setting user - userID, ok := s.GetUserID(sess) + val, ok := get(s, sess) assert.False( - t, ok, "should return false when no user ID is set", + t, ok, "should return false before SetUser", ) - assert.Empty(t, userID) + assert.Empty(t, val) // After setting user s.SetUser(sess, "user-xyz", "bob") - userID, ok = s.GetUserID(sess) + val, ok = get(s, sess) assert.True(t, ok) - assert.Equal(t, "user-xyz", userID) + assert.Equal(t, expected, val) +} + +func TestGetUserID(t *testing.T) { + t.Parallel() + + testSessionGetter( + t, + func( + s *session.Session, sess *sessions.Session, + ) (string, bool) { + return s.GetUserID(sess) + }, + "user-xyz", + ) } func TestGetUsername(t *testing.T) { t.Parallel() - s := testSession(t) - - req := httptest.NewRequestWithContext( - context.Background(), http.MethodGet, "/", nil) - - sess, err := s.Get(req) - require.NoError(t, err) - - // Before setting user - username, ok := s.GetUsername(sess) - assert.False( - t, ok, "should return false when no username is set", + testSessionGetter( + t, + func( + s *session.Session, sess *sessions.Session, + ) (string, bool) { + return s.GetUsername(sess) + }, + "bob", ) - assert.Empty(t, username) - - // After setting user - s.SetUser(sess, "user-xyz", "bob") - - username, ok = s.GetUsername(sess) - assert.True(t, ok) - assert.Equal(t, "bob", username) } // --- IsAuthenticated Tests --- diff --git a/internal/session/testing.go b/internal/session/testing.go index d15c4af..5906ddc 100644 --- a/internal/session/testing.go +++ b/internal/session/testing.go @@ -12,7 +12,12 @@ import ( // middleware and handler tests to use real session functionality. The key // parameter is the raw 32-byte authentication key used for session encryption // and CSRF cookie signing. -func NewForTest(store *sessions.CookieStore, cfg *config.Config, log *slog.Logger, key []byte) *Session { +func NewForTest( + store *sessions.CookieStore, + cfg *config.Config, + log *slog.Logger, + key []byte, +) *Session { return &Session{ store: store, key: key, diff --git a/script/bootstrap b/script/bootstrap index 5adb93c..f1b3e30 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -10,11 +10,11 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-07-07. Never "latest"; exact versions only. -GOLANGCI_LINT_VERSION="2.11.3" -# sha256 of golangci-lint-2.11.3-linux-.tar.gz release archives -GOLANGCI_LINT_SHA256_AMD64="87bb8cddbcc825d5778b64e8a91b46c0526b247f4e2f2904dea74ec7450475d1" -GOLANGCI_LINT_SHA256_ARM64="ee3d95f301359e7d578e6d99c8ad5aeadbabc5a13009a30b2b0df11c8058afe9" +# Pinned versions, 2026-08-07. Never "latest"; exact versions only. +GOLANGCI_LINT_VERSION="2.12.2" +# sha256 of golangci-lint-2.12.2-linux-.tar.gz release archives +GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553" +GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a" PKGMGR="" SUDO=""