Compare commits
2 Commits
36a1bacf11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5ecb18e5 | |||
| 734606b7af |
@@ -1,5 +1,9 @@
|
|||||||
version: "2"
|
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:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
modules-download-mode: readonly
|
modules-download-mode: readonly
|
||||||
@@ -14,19 +18,17 @@ linters:
|
|||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
settings:
|
||||||
linters-settings:
|
lll:
|
||||||
lll:
|
line-length: 88
|
||||||
line-length: 88
|
funlen:
|
||||||
funlen:
|
lines: 80
|
||||||
lines: 80
|
statements: 50
|
||||||
statements: 50
|
cyclop:
|
||||||
cyclop:
|
max-complexity: 15
|
||||||
max-complexity: 15
|
dupl:
|
||||||
dupl:
|
threshold: 100
|
||||||
threshold: 100
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Lint stage
|
# 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
|
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
||||||
# compile on Alpine musl (off64_t is a glibc type).
|
# 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/*
|
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
|
# 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,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"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(
|
func setupTestDB(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) (*database.Database, *fxtest.Lifecycle) {
|
) (*database.Database, *fxtest.Lifecycle) {
|
||||||
@@ -19,8 +28,8 @@ func setupTestDB(
|
|||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
g := &globals.Globals{
|
g := &globals.Globals{
|
||||||
Appname: "webhooker-test",
|
Appname: testAppname,
|
||||||
Version: "test",
|
Version: testVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
l, err := logger.New(
|
l, err := logger.New(
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ package database
|
|||||||
type Entrypoint struct {
|
type Entrypoint struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint
|
|
||||||
|
// Path is the URL path for this entrypoint.
|
||||||
|
Path string `gorm:"uniqueIndex;not null" json:"path"`
|
||||||
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhook Webhook `json:"webhook,omitzero"`
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ type Target struct {
|
|||||||
// Configuration fields (JSON stored based on type)
|
// Configuration fields (JSON stored based on type)
|
||||||
Config string `gorm:"type:text" json:"config"` // JSON configuration
|
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"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ package database
|
|||||||
type Webhook struct {
|
type Webhook struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
UserID string `gorm:"type:uuid;not null" json:"userId"`
|
UserID string `gorm:"type:uuid;not null" json:"userId"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
RetentionDays int `gorm:"default:30" json:"retentionDays"` // Days to retain events
|
|
||||||
|
// RetentionDays is the number of days to retain events.
|
||||||
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
User User `json:"user,omitzero"`
|
User User `json:"user,omitzero"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package database_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,8 +31,8 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv {
|
|||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
g := &globals.Globals{
|
g := &globals.Globals{
|
||||||
Appname: "webhooker-test",
|
Appname: testAppname,
|
||||||
Version: "test",
|
Version: testVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
||||||
@@ -117,9 +118,9 @@ func seedEventChain(
|
|||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Body: `{"seed": true}`,
|
Body: `{"seed": true}`,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
event.CreatedAt = createdAt
|
event.CreatedAt = createdAt
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import (
|
|||||||
// sql.DB connection.
|
// sql.DB connection.
|
||||||
func NewTestDatabase(db *gorm.DB) *Database {
|
func NewTestDatabase(db *gorm.DB) *Database {
|
||||||
return &Database{
|
return &Database{
|
||||||
db: db,
|
db: db,
|
||||||
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
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 {
|
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
|
||||||
return &WebhookDBManager{
|
return &WebhookDBManager{
|
||||||
dataDir: dataDir,
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -25,8 +26,8 @@ func setupTestWebhookDBManager(
|
|||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
g := &globals.Globals{
|
g := &globals.Globals{
|
||||||
Appname: "webhooker-test",
|
Appname: testAppname,
|
||||||
Version: "test",
|
Version: testVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
l, err := logger.New(
|
l, err := logger.New(
|
||||||
@@ -83,10 +84,10 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
|||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Headers: `{"Content-Type":["application/json"]}`,
|
Headers: `{"Content-Type":["application/json"]}`,
|
||||||
Body: `{"test": true}`,
|
Body: `{"test": true}`,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
assert.NotEmpty(t, event.ID)
|
assert.NotEmpty(t, event.ID)
|
||||||
@@ -99,7 +100,7 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
|||||||
db.First(&readEvent, "id = ?", event.ID).Error,
|
db.First(&readEvent, "id = ?", event.ID).Error,
|
||||||
)
|
)
|
||||||
assert.Equal(t, webhookID, readEvent.WebhookID)
|
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)
|
assert.Equal(t, `{"test": true}`, readEvent.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +124,9 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
|||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Body: `{"test": true}`,
|
Body: `{"test": true}`,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
@@ -196,10 +197,10 @@ func seedDeliveryWorkflow(
|
|||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Headers: `{"Content-Type":["application/json"]}`,
|
Headers: `{"Content-Type":["application/json"]}`,
|
||||||
Body: `{"payload": "test"}`,
|
Body: `{"payload": "test"}`,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
@@ -231,7 +232,7 @@ func verifyPendingDeliveries(
|
|||||||
)
|
)
|
||||||
require.Len(t, pending, 1)
|
require.Len(t, pending, 1)
|
||||||
assert.Equal(t, event.ID, pending[0].EventID)
|
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(
|
func completeDelivery(
|
||||||
@@ -303,16 +304,16 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
|
|||||||
event1 := &database.Event{
|
event1 := &database.Event{
|
||||||
WebhookID: webhook1,
|
WebhookID: webhook1,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Body: `{"webhook": 1}`,
|
Body: `{"webhook": 1}`,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
event2 := &database.Event{
|
event2 := &database.Event{
|
||||||
WebhookID: webhook2,
|
WebhookID: webhook2,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "PUT",
|
Method: http.MethodPut,
|
||||||
Body: `{"webhook": 2}`,
|
Body: `{"webhook": 2}`,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db1.Create(event1).Error)
|
require.NoError(t, db1.Create(event1).Error)
|
||||||
|
|||||||
@@ -126,36 +126,6 @@ func iHTTPConfig(url string) string {
|
|||||||
return string(data)
|
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(
|
func iEngine(
|
||||||
t *testing.T, workers int,
|
t *testing.T, workers int,
|
||||||
) *delivery.Engine {
|
) *delivery.Engine {
|
||||||
@@ -182,10 +152,10 @@ func iSeedEvent(
|
|||||||
event := database.Event{
|
event := database.Event{
|
||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Headers: `{}`,
|
Headers: `{}`,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.Create(&event).Error)
|
require.NoError(t, db.Create(&event).Error)
|
||||||
@@ -935,7 +905,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
|||||||
func TestDeliverHTTP_TargetTimeout(t *testing.T) {
|
func TestDeliverHTTP_TargetTimeout(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
db := iWebhookDB(t)
|
db := testWebhookDB(t)
|
||||||
e := iEngine(t, 1)
|
e := iEngine(t, 1)
|
||||||
|
|
||||||
ts := httptest.NewServer(
|
ts := httptest.NewServer(
|
||||||
@@ -987,10 +957,10 @@ func iSeedEventAndDelivery(
|
|||||||
event := database.Event{
|
event := database.Event{
|
||||||
WebhookID: uuid.New().String(),
|
WebhookID: uuid.New().String(),
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Headers: `{"Content-Type":["application/json"]}`,
|
Headers: `{"Content-Type":["application/json"]}`,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.Create(&event).Error)
|
require.NoError(t, db.Create(&event).Error)
|
||||||
@@ -1067,7 +1037,7 @@ func iAssertResultFailed(
|
|||||||
func TestDeliverHTTP_InvalidConfig(t *testing.T) {
|
func TestDeliverHTTP_InvalidConfig(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
db := iWebhookDB(t)
|
db := testWebhookDB(t)
|
||||||
e := iEngine(t, 1)
|
e := iEngine(t, 1)
|
||||||
|
|
||||||
event, del := iSeedEventAndDelivery(
|
event, del := iSeedEventAndDelivery(
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"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 {
|
func testWebhookDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -94,10 +97,10 @@ func seedEvent(
|
|||||||
event := database.Event{
|
event := database.Event{
|
||||||
WebhookID: uuid.New().String(),
|
WebhookID: uuid.New().String(),
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Headers: `{"Content-Type":["application/json"]}`,
|
Headers: `{"Content-Type":["application/json"]}`,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db.Create(&event).Error)
|
require.NoError(t, db.Create(&event).Error)
|
||||||
@@ -1113,10 +1116,10 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`,
|
Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`,
|
||||||
Body: `{"test":true}`,
|
Body: `{"test":true}`,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
||||||
@@ -1138,7 +1141,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
"application/json",
|
testContentType,
|
||||||
receivedHeaders.Get("Content-Type"),
|
receivedHeaders.Get("Content-Type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1297,8 +1300,8 @@ func TestFormatSlackMessage_JSONBody(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
Body: `{"action":"push",` +
|
Body: `{"action":"push",` +
|
||||||
`"repo":"test/repo",` +
|
`"repo":"test/repo",` +
|
||||||
`"ref":"refs/heads/main"}`,
|
`"ref":"refs/heads/main"}`,
|
||||||
@@ -1323,7 +1326,7 @@ func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
ContentType: "text/plain",
|
ContentType: "text/plain",
|
||||||
Body: "hello world plain text",
|
Body: "hello world plain text",
|
||||||
}
|
}
|
||||||
@@ -1346,8 +1349,8 @@ func TestFormatSlackMessage_EmptyBody(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
Body: "",
|
Body: "",
|
||||||
}
|
}
|
||||||
event.CreatedAt = time.Date(
|
event.CreatedAt = time.Date(
|
||||||
@@ -1375,8 +1378,8 @@ func TestFormatSlackMessage_LargeJSONTruncated(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
event := &database.Event{
|
event := &database.Event{
|
||||||
Method: "POST",
|
Method: http.MethodPost,
|
||||||
ContentType: "application/json",
|
ContentType: testContentType,
|
||||||
Body: string(largeJSON),
|
Body: string(largeJSON),
|
||||||
}
|
}
|
||||||
event.CreatedAt = time.Date(
|
event.CreatedAt = time.Date(
|
||||||
@@ -1705,7 +1708,7 @@ func assertLogLineComplete(
|
|||||||
"log line must contain the webhook id",
|
"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",
|
"log line must contain the content type",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,5 +495,5 @@ func applyRequestHeaders(
|
|||||||
func executeHTTPRequest(
|
func executeHTTPRequest(
|
||||||
client *http.Client, req *http.Request,
|
client *http.Client, req *http.Request,
|
||||||
) (*http.Response, error) {
|
) (*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
|
// Render login page
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Error": "",
|
tmplKeyError: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "login.html", data)
|
h.renderTemplate(w, r, "login.html", data)
|
||||||
@@ -86,7 +86,7 @@ func (h *Handlers) renderLoginError(
|
|||||||
status int,
|
status int,
|
||||||
) {
|
) {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Error": msg,
|
tmplKeyError: msg,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -13,14 +13,18 @@ func (s *Handlers) RenderTemplateForTest(
|
|||||||
s.renderTemplate(w, r, pageTemplate, data)
|
s.renderTemplate(w, r, pageTemplate, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSlackTargetConfigForTest exposes buildSlackTargetConfig
|
// BuildSlackTargetConfigForTest exposes buildURLTargetConfig
|
||||||
// for use in the handlers_test package.
|
// with the Slack target parameters for use in the
|
||||||
|
// handlers_test package.
|
||||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
targetURL string,
|
targetURL string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return s.buildSlackTargetConfig(w, r, targetURL)
|
return s.buildURLTargetConfig(
|
||||||
|
w, r, targetURL, "webhookUrl",
|
||||||
|
"Webhook URL is required for Slack targets",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildDatabaseTargetConfigForTest exposes
|
// BuildDatabaseTargetConfigForTest exposes
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ const (
|
|||||||
defaultRetentionDays = 30
|
defaultRetentionDays = 30
|
||||||
// paginationPerPage is the number of items per page.
|
// paginationPerPage is the number of items per page.
|
||||||
paginationPerPage = 25
|
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.
|
// errInvalidPassword is returned when a password does not match.
|
||||||
|
|||||||
@@ -4,63 +4,202 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleProfile returns a handler for the user profile page
|
// HandleProfile returns a handler for the user profile page
|
||||||
func (h *Handlers) HandleProfile() http.HandlerFunc {
|
func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get username from URL
|
sessionUserID, sessionUsername, ok :=
|
||||||
requestedUsername := chi.URLParam(r, "username")
|
h.profileOwnerOrDeny(w, r)
|
||||||
if requestedUsername == "" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get session. RequireAuth middleware guarantees an
|
|
||||||
// authenticated session before this handler runs, so we
|
|
||||||
// only need to guard against an unexpected retrieval error.
|
|
||||||
sess, err := h.session.Get(r)
|
|
||||||
if err != nil {
|
|
||||||
h.log.Error("failed to get session", "error", err)
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user info from session
|
|
||||||
sessionUsername, ok := h.session.GetUsername(sess)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionUserID, ok := h.session.GetUserID(sess)
|
h.renderProfile(w, r, sessionUserID, sessionUsername, "", "")
|
||||||
if !ok {
|
|
||||||
h.log.Error("authenticated session missing user ID")
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, only allow users to view their own profile
|
|
||||||
if requestedUsername != sessionUsername {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare data for template
|
|
||||||
data := map[string]any{
|
|
||||||
"User": &UserInfo{
|
|
||||||
ID: sessionUserID,
|
|
||||||
Username: sessionUsername,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the profile page
|
|
||||||
h.renderTemplate(w, r, "profile.html", data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandlePasswordChange returns a handler that lets an authenticated
|
||||||
|
// user change their own password. It is served by the CSRF- and
|
||||||
|
// auth-protected POST /password route under /user/{username}.
|
||||||
|
func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionUserID, sessionUsername, ok :=
|
||||||
|
h.profileOwnerOrDeny(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit request body to prevent memory exhaustion.
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<maxBodyShift)
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("failed to parse form", "error", err)
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||||
|
w,
|
||||||
|
sessionUsername,
|
||||||
|
r.FormValue("current_password"),
|
||||||
|
r.FormValue("new_password"),
|
||||||
|
r.FormValue("confirm_password"),
|
||||||
|
)
|
||||||
|
if !handled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderProfile(
|
||||||
|
w, r, sessionUserID, sessionUsername,
|
||||||
|
successMessage, errorMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyPasswordChange verifies the current password and, on success,
|
||||||
|
// persists a fresh hash for the user, reusing the same helpers that
|
||||||
|
// bootstrap the admin user. It returns the success and error messages
|
||||||
|
// to display on the profile page. On an internal failure it writes a
|
||||||
|
// 500 response itself and returns handled=false, signalling the caller
|
||||||
|
// to stop without re-rendering the page.
|
||||||
|
func (h *Handlers) applyPasswordChange(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
username, currentPassword, newPassword, confirmPassword string,
|
||||||
|
) (string, string, bool) {
|
||||||
|
// Load the user row so we can verify the current password and
|
||||||
|
// persist the new hash.
|
||||||
|
var user database.User
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"username = ?", username,
|
||||||
|
).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, "failed to load user for password change", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := database.VerifyPassword(
|
||||||
|
currentPassword, user.Password,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to verify password", err)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return "", "Current password is incorrect.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if newPassword == "" {
|
||||||
|
return "", "New password must not be empty.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
if newPassword != confirmPassword {
|
||||||
|
return "", "New password and confirmation do not match.", true
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := database.HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to hash new password", err)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.db.DB().Model(&user).Update(
|
||||||
|
"password", hashedPassword,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to update password", err)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info("user changed password", "username", username)
|
||||||
|
|
||||||
|
return "Password changed successfully.", "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileOwnerOrDeny resolves the session identity and enforces that a
|
||||||
|
// user may only act on their own profile (the requested username in the
|
||||||
|
// URL must equal the session username). On any failure it writes the
|
||||||
|
// appropriate HTTP response and returns ok=false; callers must stop
|
||||||
|
// when ok is false.
|
||||||
|
func (h *Handlers) profileOwnerOrDeny(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) (string, string, bool) {
|
||||||
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
|
if requestedUsername == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth middleware guarantees an authenticated session
|
||||||
|
// before this handler runs, so we only need to guard against an
|
||||||
|
// unexpected retrieval error.
|
||||||
|
sess, err := h.session.Get(r)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to get session", err)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionUsername, ok := h.session.GetUsername(sess)
|
||||||
|
if !ok {
|
||||||
|
h.log.Error("authenticated session missing username")
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionUserID, ok := h.session.GetUserID(sess)
|
||||||
|
if !ok {
|
||||||
|
h.log.Error("authenticated session missing user ID")
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow users to act on their own profile.
|
||||||
|
if requestedUsername != sessionUsername {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionUserID, sessionUsername, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderProfile renders the profile page for the given user,
|
||||||
|
// optionally including a success or error message.
|
||||||
|
func (h *Handlers) renderProfile(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
userID, username, successMessage, errorMessage string,
|
||||||
|
) {
|
||||||
|
data := map[string]any{
|
||||||
|
"User": &UserInfo{
|
||||||
|
ID: userID,
|
||||||
|
Username: username,
|
||||||
|
},
|
||||||
|
"SuccessMessage": successMessage,
|
||||||
|
"ErrorMessage": errorMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, r, "profile.html", data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
@@ -157,3 +160,134 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// passwordChangeRequest builds a POST request to the password-change
|
||||||
|
// endpoint for the given username, attaching the supplied cookies, an
|
||||||
|
// urlencoded form body, and the chi URL parameter the handler reads.
|
||||||
|
func passwordChangeRequest(
|
||||||
|
username string,
|
||||||
|
cookies []*http.Cookie,
|
||||||
|
form url.Values,
|
||||||
|
) *http.Request {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodPost,
|
||||||
|
"/user/"+username+"/password",
|
||||||
|
strings.NewReader(form.Encode()),
|
||||||
|
)
|
||||||
|
req.Header.Set(
|
||||||
|
"Content-Type", "application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("username", username)
|
||||||
|
|
||||||
|
return req.WithContext(
|
||||||
|
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePasswordChange_Success(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
var db *database.Database
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
oldHash, err := database.HashPassword("oldpassword")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user := &database.User{Username: "pwuser", Password: oldHash}
|
||||||
|
require.NoError(t, db.DB().Create(user).Error)
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(t, sess, user.ID, "pwuser")
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("current_password", "oldpassword")
|
||||||
|
form.Set("new_password", "newpassword")
|
||||||
|
form.Set("confirm_password", "newpassword")
|
||||||
|
|
||||||
|
req := passwordChangeRequest("pwuser", cookies, form)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.HandlePasswordChange().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Body.String(), "Password changed successfully.",
|
||||||
|
)
|
||||||
|
|
||||||
|
var updated database.User
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
db.DB().Where("username = ?", "pwuser").First(&updated).Error,
|
||||||
|
)
|
||||||
|
assert.NotEqual(t, oldHash, updated.Password)
|
||||||
|
|
||||||
|
valid, err := database.VerifyPassword(
|
||||||
|
"newpassword", updated.Password,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, valid, "new password should verify against new hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePasswordChange_WrongCurrentPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
var db *database.Database
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
oldHash, err := database.HashPassword("oldpassword")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user := &database.User{Username: "pwuser2", Password: oldHash}
|
||||||
|
require.NoError(t, db.DB().Create(user).Error)
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(t, sess, user.ID, "pwuser2")
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("current_password", "wrongpassword")
|
||||||
|
form.Set("new_password", "newpassword")
|
||||||
|
form.Set("confirm_password", "newpassword")
|
||||||
|
|
||||||
|
req := passwordChangeRequest("pwuser2", cookies, form)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.HandlePasswordChange().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Body.String(), "Current password is incorrect.",
|
||||||
|
)
|
||||||
|
|
||||||
|
var unchanged database.User
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
db.DB().Where(
|
||||||
|
"username = ?", "pwuser2",
|
||||||
|
).First(&unchanged).Error,
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, oldHash, unchanged.Password,
|
||||||
|
"stored hash must be unchanged after a rejected change",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (h *Handlers) buildWebhookListItems(
|
|||||||
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Error": "",
|
tmplKeyError: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "sources_new.html", data)
|
h.renderTemplate(w, r, "sources_new.html", data)
|
||||||
@@ -146,7 +146,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Error": "Name is required",
|
tmplKeyError: "Name is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
@@ -316,11 +316,11 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Webhook": webhook,
|
tmplKeyWebhook: webhook,
|
||||||
"Entrypoints": entrypoints,
|
"Entrypoints": entrypoints,
|
||||||
"Targets": targets,
|
"Targets": targets,
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": scheme + "://" + host,
|
"BaseURL": scheme + "://" + host,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
@@ -352,8 +352,8 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Webhook": webhook,
|
tmplKeyWebhook: webhook,
|
||||||
"Error": "",
|
tmplKeyError: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_edit.html", data)
|
h.renderTemplate(w, r, "source_edit.html", data)
|
||||||
@@ -416,8 +416,8 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Webhook": *webhook,
|
tmplKeyWebhook: *webhook,
|
||||||
"Error": "Name is required",
|
tmplKeyError: "Name is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
@@ -590,15 +590,15 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Webhook": webhook,
|
tmplKeyWebhook: webhook,
|
||||||
"Events": evts,
|
"Events": evts,
|
||||||
"Page": page,
|
"Page": page,
|
||||||
"TotalPages": totalPages,
|
"TotalPages": totalPages,
|
||||||
"TotalEvents": total,
|
"TotalEvents": total,
|
||||||
"HasPrev": page > 1,
|
"HasPrev": page > 1,
|
||||||
"HasNext": page < totalPages,
|
"HasNext": page < totalPages,
|
||||||
"PrevPage": page - 1,
|
"PrevPage": page - 1,
|
||||||
"NextPage": page + 1,
|
"NextPage": page + 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_logs.html", data)
|
h.renderTemplate(w, r, "source_logs.html", data)
|
||||||
@@ -904,9 +904,15 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
) (string, error) {
|
) (string, error) {
|
||||||
switch targetType {
|
switch targetType {
|
||||||
case database.TargetTypeHTTP:
|
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:
|
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:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, expiry)
|
return h.buildDatabaseTargetConfig(w, expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
@@ -921,16 +927,18 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildHTTPTargetConfig builds config JSON for an HTTP target.
|
// buildURLTargetConfig builds config JSON for a target whose
|
||||||
func (h *Handlers) buildHTTPTargetConfig(
|
// 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,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
targetURL string,
|
targetURL, configKey, missingMsg string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
if targetURL == "" {
|
if targetURL == "" {
|
||||||
http.Error(
|
http.Error(
|
||||||
w,
|
w,
|
||||||
"URL is required for HTTP targets",
|
missingMsg,
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -955,56 +963,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := map[string]any{"url": targetURL}
|
cfg := map[string]any{configKey: 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}
|
|
||||||
|
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,3 +32,7 @@ func IsClientTLS(r *http.Request) bool {
|
|||||||
|
|
||||||
// LoginRateLimitConst exposes the loginRateLimit constant.
|
// LoginRateLimitConst exposes the loginRateLimit constant.
|
||||||
const LoginRateLimitConst = loginRateLimit
|
const LoginRateLimitConst = loginRateLimit
|
||||||
|
|
||||||
|
// PasswordChangeRateLimitConst exposes the
|
||||||
|
// passwordChangeRateLimit constant.
|
||||||
|
const PasswordChangeRateLimitConst = passwordChangeRateLimit
|
||||||
|
|||||||
@@ -484,8 +484,13 @@ func metricsAuthMiddleware(
|
|||||||
return middleware.NewForTest(log, cfg, sessManager)
|
return middleware.NewForTest(log, cfg, sessManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
// runMetricsAuthRequest sends a GET /metrics request with the
|
||||||
t.Parallel()
|
// 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)
|
m := metricsAuthMiddleware(t)
|
||||||
|
|
||||||
@@ -503,12 +508,20 @@ func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
|||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet, "/metrics", nil,
|
http.MethodGet, "/metrics", nil,
|
||||||
)
|
)
|
||||||
req.SetBasicAuth("admin", "secret")
|
req.SetBasicAuth("admin", password)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return called, w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
called, w := runMetricsAuthRequest(t, "secret")
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t, called,
|
t, called,
|
||||||
"handler should be called with valid basic auth",
|
"handler should be called with valid basic auth",
|
||||||
@@ -519,27 +532,7 @@ func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
|||||||
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
|
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m := metricsAuthMiddleware(t)
|
called, w := runMetricsAuthRequest(t, "wrong-password")
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
assert.False(
|
assert.False(
|
||||||
t, called,
|
t, called,
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ const (
|
|||||||
|
|
||||||
// loginRateInterval is the time window for the rate limit.
|
// loginRateInterval is the time window for the rate limit.
|
||||||
loginRateInterval = 1 * time.Minute
|
loginRateInterval = 1 * time.Minute
|
||||||
|
|
||||||
|
// passwordChangeRateLimit is the maximum number of password
|
||||||
|
// change attempts per interval. Each attempt verifies the
|
||||||
|
// current password, so the endpoint must be rate-limited
|
||||||
|
// like any other password-based authentication endpoint.
|
||||||
|
passwordChangeRateLimit = 5
|
||||||
|
|
||||||
|
// passwordChangeRateInterval is the time window for the
|
||||||
|
// password change rate limit.
|
||||||
|
passwordChangeRateInterval = 1 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoginRateLimit returns middleware that enforces per-IP rate
|
// LoginRateLimit returns middleware that enforces per-IP rate
|
||||||
@@ -24,19 +34,53 @@ const (
|
|||||||
// honours X-Forwarded-For, X-Real-IP, and True-Client-IP headers
|
// honours X-Forwarded-For, X-Real-IP, and True-Client-IP headers
|
||||||
// for reverse-proxy setups.
|
// for reverse-proxy setups.
|
||||||
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||||
limiter := httprate.Limit(
|
return m.postRateLimit(
|
||||||
loginRateLimit,
|
loginRateLimit,
|
||||||
loginRateInterval,
|
loginRateInterval,
|
||||||
|
"login rate limit exceeded",
|
||||||
|
"Too many login attempts. Please try again later.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordChangeRateLimit returns middleware that enforces
|
||||||
|
// per-IP rate limiting on password change attempts. The change
|
||||||
|
// endpoint verifies the current password, so without a limit a
|
||||||
|
// stolen session could be used to brute-force it; the limit
|
||||||
|
// matches the login endpoint's.
|
||||||
|
func (m *Middleware) PasswordChangeRateLimit() func(http.Handler) http.Handler {
|
||||||
|
return m.postRateLimit(
|
||||||
|
passwordChangeRateLimit,
|
||||||
|
passwordChangeRateInterval,
|
||||||
|
"password change rate limit exceeded",
|
||||||
|
"Too many password change attempts. "+
|
||||||
|
"Please try again later.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postRateLimit builds middleware that enforces a per-IP rate
|
||||||
|
// limit on POST requests only; all other methods pass through
|
||||||
|
// unaffected. Requests over the limit receive a 429 with the
|
||||||
|
// given response message, and each rejection is logged with the
|
||||||
|
// given log message. IP extraction honours X-Forwarded-For,
|
||||||
|
// X-Real-IP, and True-Client-IP headers for reverse-proxy
|
||||||
|
// setups.
|
||||||
|
func (m *Middleware) postRateLimit(
|
||||||
|
limit int,
|
||||||
|
interval time.Duration,
|
||||||
|
logMessage, responseMessage string,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
|
limiter := httprate.Limit(
|
||||||
|
limit,
|
||||||
|
interval,
|
||||||
httprate.WithKeyFuncs(httprate.KeyByRealIP),
|
httprate.WithKeyFuncs(httprate.KeyByRealIP),
|
||||||
httprate.WithLimitHandler(http.HandlerFunc(
|
httprate.WithLimitHandler(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
m.log.Warn("login rate limit exceeded",
|
m.log.Warn(logMessage,
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
)
|
)
|
||||||
http.Error(
|
http.Error(
|
||||||
w,
|
w,
|
||||||
"Too many login attempts. "+
|
responseMessage,
|
||||||
"Please try again later.",
|
|
||||||
http.StatusTooManyRequests,
|
http.StatusTooManyRequests,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -50,8 +94,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) {
|
) {
|
||||||
// Only rate-limit POST requests (actual login
|
// Only rate-limit POST requests.
|
||||||
// attempts)
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,20 @@ func TestLoginRateLimit_AllowsGET(t *testing.T) {
|
|||||||
assert.Equal(t, 20, callCount)
|
assert.Equal(t, 20, callCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
// runPostLimitTest exercises a POST-only rate limit middleware:
|
||||||
t.Parallel()
|
// the first limit POSTs to path from ip must pass, and the next
|
||||||
|
// one must be rejected with 429 without reaching the handler.
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
func runPostLimitTest(
|
||||||
|
t *testing.T,
|
||||||
|
mw func(http.Handler) http.Handler,
|
||||||
|
limit int,
|
||||||
|
path, ip string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
var callCount int
|
var callCount int
|
||||||
|
|
||||||
handler := m.LoginRateLimit()(http.HandlerFunc(
|
handler := mw(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
callCount++
|
callCount++
|
||||||
|
|
||||||
@@ -61,13 +67,13 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
// First loginRateLimit POST requests should succeed
|
// The first limit POST requests should succeed
|
||||||
for i := range middleware.LoginRateLimitConst {
|
for i := range limit {
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodPost, "/pages/login", nil,
|
http.MethodPost, path, nil,
|
||||||
)
|
)
|
||||||
req.RemoteAddr = "10.0.0.1:12345"
|
req.RemoteAddr = ip
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
@@ -81,9 +87,9 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
|||||||
// Next POST should be rate-limited
|
// Next POST should be rate-limited
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodPost, "/pages/login", nil,
|
http.MethodPost, path, nil,
|
||||||
)
|
)
|
||||||
req.RemoteAddr = "10.0.0.1:12345"
|
req.RemoteAddr = ip
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
@@ -92,7 +98,35 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
|||||||
t, http.StatusTooManyRequests, w.Code,
|
t, http.StatusTooManyRequests, w.Code,
|
||||||
"POST after limit should be 429",
|
"POST after limit should be 429",
|
||||||
)
|
)
|
||||||
assert.Equal(t, middleware.LoginRateLimitConst, callCount)
|
assert.Equal(t, limit, callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
|
runPostLimitTest(
|
||||||
|
t,
|
||||||
|
m.LoginRateLimit(),
|
||||||
|
middleware.LoginRateLimitConst,
|
||||||
|
"/pages/login",
|
||||||
|
"10.0.0.1:12345",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordChangeRateLimit_LimitsPOST(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
|
runPostLimitTest(
|
||||||
|
t,
|
||||||
|
m.PasswordChangeRateLimit(),
|
||||||
|
middleware.PasswordChangeRateLimitConst,
|
||||||
|
"/user/admin/password",
|
||||||
|
"10.0.0.2:12345",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
|
func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ func (s *Server) setupUserRoutes() {
|
|||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
|
r.With(s.mw.PasswordChangeRateLimit()).Post(
|
||||||
|
"/password", s.h.HandlePasswordChange(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,8 +173,18 @@ func TestSetUser_SetsAllFields(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUserID(t *testing.T) {
|
// testSessionGetter exercises a session string getter before and
|
||||||
t.Parallel()
|
// 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)
|
s := testSession(t)
|
||||||
|
|
||||||
@@ -185,44 +195,46 @@ func TestGetUserID(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Before setting user
|
// Before setting user
|
||||||
userID, ok := s.GetUserID(sess)
|
val, ok := get(s, sess)
|
||||||
assert.False(
|
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
|
// After setting user
|
||||||
s.SetUser(sess, "user-xyz", "bob")
|
s.SetUser(sess, "user-xyz", "bob")
|
||||||
|
|
||||||
userID, ok = s.GetUserID(sess)
|
val, ok = get(s, sess)
|
||||||
assert.True(t, ok)
|
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) {
|
func TestGetUsername(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
testSessionGetter(
|
||||||
|
t,
|
||||||
req := httptest.NewRequestWithContext(
|
func(
|
||||||
context.Background(), http.MethodGet, "/", nil)
|
s *session.Session, sess *sessions.Session,
|
||||||
|
) (string, bool) {
|
||||||
sess, err := s.Get(req)
|
return s.GetUsername(sess)
|
||||||
require.NoError(t, err)
|
},
|
||||||
|
"bob",
|
||||||
// Before setting user
|
|
||||||
username, ok := s.GetUsername(sess)
|
|
||||||
assert.False(
|
|
||||||
t, ok, "should return false when no username is set",
|
|
||||||
)
|
)
|
||||||
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 ---
|
// --- IsAuthenticated Tests ---
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import (
|
|||||||
// middleware and handler tests to use real session functionality. The key
|
// middleware and handler tests to use real session functionality. The key
|
||||||
// parameter is the raw 32-byte authentication key used for session encryption
|
// parameter is the raw 32-byte authentication key used for session encryption
|
||||||
// and CSRF cookie signing.
|
// 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{
|
return &Session{
|
||||||
store: store,
|
store: store,
|
||||||
key: key,
|
key: key,
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Pinned versions, 2026-07-07. Never "latest"; exact versions only.
|
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
||||||
GOLANGCI_LINT_VERSION="2.11.3"
|
GOLANGCI_LINT_VERSION="2.12.2"
|
||||||
# sha256 of golangci-lint-2.11.3-linux-<arch>.tar.gz release archives
|
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
||||||
GOLANGCI_LINT_SHA256_AMD64="87bb8cddbcc825d5778b64e8a91b46c0526b247f4e2f2904dea74ec7450475d1"
|
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
||||||
GOLANGCI_LINT_SHA256_ARM64="ee3d95f301359e7d578e6d99c8ad5aeadbabc5a13009a30b2b0df11c8058afe9"
|
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mb-6">User Profile</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mb-6">User Profile</h1>
|
||||||
|
|
||||||
|
{{if .SuccessMessage}}
|
||||||
|
<div class="alert-success">
|
||||||
|
<span>{{.SuccessMessage}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ErrorMessage}}
|
||||||
|
<div class="alert-error">
|
||||||
|
<span>{{.ErrorMessage}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="card p-6">
|
<div class="card p-6">
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="mr-4">
|
<div class="mr-4">
|
||||||
@@ -43,6 +55,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6 mt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-3">Change Password</h3>
|
||||||
|
<form method="POST" action="/user/{{.User.Username}}/password" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="current_password" class="label">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="current_password"
|
||||||
|
name="current_password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password" class="label">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password" class="label">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary">Change Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<a href="/" class="btn-secondary">Back to Home</a>
|
<a href="/" class="btn-secondary">Back to Home</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user