Update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m51s
All checks were successful
check / check (push) Successful in 2m51s
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
|
||||
|
||||
5
TODO.md
5
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -1117,10 +1120,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(
|
||||
@@ -1142,7 +1145,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
"application/json",
|
||||
testContentType,
|
||||
receivedHeaders.Get("Content-Type"),
|
||||
)
|
||||
|
||||
@@ -1289,8 +1292,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"}`,
|
||||
@@ -1315,7 +1318,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",
|
||||
}
|
||||
@@ -1338,8 +1341,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(
|
||||
@@ -1367,8 +1370,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(
|
||||
@@ -1697,7 +1700,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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,12 +13,16 @@ 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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -106,7 +106,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)
|
||||
@@ -145,7 +145,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)
|
||||
@@ -315,11 +315,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)
|
||||
@@ -351,8 +351,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)
|
||||
@@ -415,8 +415,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)
|
||||
@@ -589,15 +589,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)
|
||||
@@ -900,9 +900,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, database.TargetTypeLog:
|
||||
return "", nil
|
||||
default:
|
||||
@@ -915,16 +921,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,
|
||||
)
|
||||
|
||||
@@ -949,56 +957,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-<arch>.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-<arch>.tar.gz release archives
|
||||
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
||||
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
|
||||
Reference in New Issue
Block a user