Compare commits
4 Commits
feat/recei
...
issue-66-s
| Author | SHA1 | Date | |
|---|---|---|---|
| b04bc2cc7b | |||
| 4f5ecb18e5 | |||
| 734606b7af | |||
| ee7c626071 |
@@ -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/*
|
||||
|
||||
|
||||
52
README.md
52
README.md
@@ -92,6 +92,27 @@ TTY detection, and security headers are always applied.
|
||||
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
|
||||
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
|
||||
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
|
||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||
|
||||
Sessions are bounded by two independent clocks, and end at whichever
|
||||
one runs out first:
|
||||
|
||||
- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding
|
||||
window. Every authenticated request pushes it forward, so a session
|
||||
in continuous use never hits it, while an abandoned one expires a day
|
||||
after its last use. Set it to `0` to disable idle expiry entirely;
|
||||
the absolute cap below still applies. A set-but-unparseable value
|
||||
aborts startup rather than silently falling back to the default.
|
||||
- **Absolute expiry** is a fixed 7 days from login. Activity does
|
||||
**not** extend it: after a week, every session ends and the user
|
||||
authenticates again.
|
||||
|
||||
Only requests that authenticate with the session count as activity, so
|
||||
an unauthenticated request carrying the cookie cannot keep a session
|
||||
alive. The idle timestamp is rewritten at most once per tenth of the
|
||||
idle window rather than on every request, which means a session may
|
||||
expire up to 10% early relative to the user's true last request, but
|
||||
never late.
|
||||
|
||||
On first startup, webhooker automatically generates a cryptographically
|
||||
secure session encryption key and stores it in the database. This key
|
||||
@@ -363,10 +384,12 @@ events should be forwarded.
|
||||
greater than 0, failed deliveries are retried with exponential backoff
|
||||
up to `max_retries` attempts, protected by a per-target circuit
|
||||
breaker.
|
||||
- **`database`** — Confirm the event is stored in the webhook's
|
||||
per-webhook database (no external delivery). Since events are always
|
||||
written to the per-webhook DB on ingestion, this target marks delivery
|
||||
as immediately successful. Useful for ensuring durable event archival.
|
||||
- **`database`** — Archive the full event as a row into a separate
|
||||
per-webhook archive database (`archive-{webhookID}.db`) for long-term
|
||||
retention, with an optional creation-validated expiry (default: keep
|
||||
forever). No external delivery and no retries; an archive write
|
||||
failure fails the delivery. See the database target section under
|
||||
"Per-Webhook Event Databases" for the full semantics.
|
||||
- **`log`** — Write the event to the application log (stdout). Useful
|
||||
for debugging.
|
||||
|
||||
@@ -512,11 +535,22 @@ This separation provides:
|
||||
page cache, and its own lock, so concurrent event ingestion across
|
||||
webhooks won't contend.
|
||||
|
||||
The **database target type** leverages this architecture: since events
|
||||
are already stored in the per-webhook database by design, the database
|
||||
target simply marks the delivery as immediately successful. The
|
||||
per-webhook DB IS the dedicated event database — that's the whole point
|
||||
of the database target type.
|
||||
The **database target type** builds on this architecture to provide
|
||||
long-term archiving, separate from the per-webhook event database (which
|
||||
may prune events under its own retention). Delivering to a database
|
||||
target writes the full event — body, headers, method, content type, and
|
||||
webhook/entrypoint/event identifiers — as a row into a dedicated archive
|
||||
database, `archive-{webhookID}.db`, stored under the data directory
|
||||
beside the event database. After each write the archive handle is closed
|
||||
and reopened, debounced to at most once per second, so an operator can
|
||||
move the archive file away for offline archiving without stopping the
|
||||
service; a moved or removed archive file is recreated automatically on
|
||||
the next write. An optional `expiry` in the target's config JSON (e.g.
|
||||
`{"expiry":"720h"}`) is validated when the target is created — the
|
||||
default (unset or the literal `never`) keeps rows forever — and rows
|
||||
older than the expiry are pruned each time the archive is (re)opened. An
|
||||
archive write failure is never silent success: the delivery records a
|
||||
failed attempt with the error and is marked failed.
|
||||
|
||||
The **Slack target type** sends webhook events as formatted messages to
|
||||
any Slack-compatible incoming webhook URL (works with Slack, Mattermost,
|
||||
|
||||
11
TODO.md
11
TODO.md
@@ -28,6 +28,15 @@ databases currently grow without bound.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
|
||||
requests, with the 7-day absolute cap kept as an independent
|
||||
backstop that activity never extends (#66)
|
||||
- 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)
|
||||
@@ -66,7 +75,7 @@ databases currently grow without bound.
|
||||
- event redelivery endpoint
|
||||
- OpenAPI specification
|
||||
- Analytics dashboard: success rates, response times, volume
|
||||
- Session expiration tuning and a remember-me option
|
||||
- A remember-me option at login
|
||||
- Password change and reset flow
|
||||
- Later, nice to have
|
||||
- email delivery target type
|
||||
|
||||
@@ -31,6 +31,10 @@ const (
|
||||
// defaultRetentionSweepInterval is how often the retention
|
||||
// reaper deletes events older than each webhook's RetentionDays.
|
||||
defaultRetentionSweepInterval = time.Hour
|
||||
|
||||
// defaultSessionIdleTimeout is how long a session may go without
|
||||
// authenticated activity before it expires.
|
||||
defaultSessionIdleTimeout = 24 * time.Hour
|
||||
)
|
||||
|
||||
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
||||
@@ -60,6 +64,10 @@ type Config struct {
|
||||
// RetentionSweepInterval is how often the retention reaper runs.
|
||||
RetentionSweepInterval time.Duration
|
||||
|
||||
// SessionIdleTimeout is the sliding inactivity window after
|
||||
// which a session expires. Non-positive disables idle expiry.
|
||||
SessionIdleTimeout time.Duration
|
||||
|
||||
params *ConfigParams
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -162,6 +170,15 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Same fail-loud treatment for the session idle timeout.
|
||||
sessionIdleTimeout, err := envDuration(
|
||||
"SESSION_IDLE_TIMEOUT",
|
||||
defaultSessionIdleTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load configuration values from environment variables
|
||||
s := &Config{
|
||||
DataDir: envString("DATA_DIR"),
|
||||
@@ -173,6 +190,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
Port: envInt("PORT", defaultPort),
|
||||
SentryDSN: envString("SENTRY_DSN"),
|
||||
RetentionSweepInterval: retentionSweepInterval,
|
||||
SessionIdleTimeout: sessionIdleTimeout,
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestRetentionSweepInterval(t *testing.T) {
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
testRetentionSweepIntervalError(t)
|
||||
expectStartupError(t)
|
||||
} else {
|
||||
testRetentionSweepIntervalSuccess(t, tt.expected)
|
||||
}
|
||||
@@ -171,7 +171,9 @@ func TestRetentionSweepInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testRetentionSweepIntervalError(t *testing.T) {
|
||||
// expectStartupError asserts that fx refuses to build the app,
|
||||
// which is what a set-but-unparseable duration must cause.
|
||||
func expectStartupError(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
var cfg *config.Config
|
||||
@@ -215,6 +217,82 @@ func testRetentionSweepIntervalSuccess(
|
||||
assert.Equal(t, expected, cfg.RetentionSweepInterval)
|
||||
}
|
||||
|
||||
func TestSessionIdleTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set bool
|
||||
value string
|
||||
expectError bool
|
||||
expected time.Duration
|
||||
}{
|
||||
{
|
||||
name: "unset uses default",
|
||||
set: false,
|
||||
expected: 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "valid value is parsed",
|
||||
set: true,
|
||||
value: "30m",
|
||||
expected: 30 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "unparseable value fails startup",
|
||||
set: true,
|
||||
value: "not-a-duration",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
|
||||
} else {
|
||||
require.NoError(t, os.Unsetenv(
|
||||
"SESSION_IDLE_TIMEOUT",
|
||||
))
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
expectStartupError(t)
|
||||
} else {
|
||||
testSessionIdleTimeoutSuccess(t, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testSessionIdleTimeoutSuccess(
|
||||
t *testing.T,
|
||||
expected time.Duration,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
require.NoError(t, app.Err())
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
defer app.RequireStop()
|
||||
|
||||
assert.Equal(t, expected, cfg.SessionIdleTimeout)
|
||||
}
|
||||
|
||||
func TestDefaultDataDir(t *testing.T) {
|
||||
for _, env := range []string{"", "dev", "prod"} {
|
||||
name := env
|
||||
|
||||
@@ -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)
|
||||
@@ -342,33 +345,29 @@ func TestDeliverDatabase_ImmediateSuccess(
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := testEngine(t, 1)
|
||||
|
||||
event := seedEvent(t, db, `{"db":"target"}`)
|
||||
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, uuid.New().String(),
|
||||
database.DeliveryStatusPending,
|
||||
// The database target archives for real now, so the engine
|
||||
// needs a webhook DB manager to locate the data directory.
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil,
|
||||
database.NewTestWebhookDBManager(t.TempDir()),
|
||||
slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
)),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: dlv.TargetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-db",
|
||||
Type: database.TargetTypeDatabase,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
event := seedEvent(t, db, `{"db":"target"}`)
|
||||
d := seedDatabaseTargetDelivery(t, db, event, "")
|
||||
|
||||
e.ExportDeliverDatabase(db, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
require.NoError(t, db.First(
|
||||
&updated, "id = ?", dlv.ID,
|
||||
&updated, "id = ?", d.ID,
|
||||
).Error)
|
||||
|
||||
assert.Equal(t,
|
||||
@@ -379,7 +378,7 @@ func TestDeliverDatabase_ImmediateSuccess(
|
||||
var result database.DeliveryResult
|
||||
|
||||
require.NoError(t, db.Where(
|
||||
"delivery_id = ?", dlv.ID,
|
||||
"delivery_id = ?", d.ID,
|
||||
).First(&result).Error)
|
||||
|
||||
assert.True(t, result.Success)
|
||||
@@ -1117,10 +1116,10 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
Method: http.MethodPost,
|
||||
Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`,
|
||||
Body: `{"test":true}`,
|
||||
ContentType: "application/json",
|
||||
ContentType: testContentType,
|
||||
}
|
||||
|
||||
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
||||
@@ -1142,7 +1141,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
"application/json",
|
||||
testContentType,
|
||||
receivedHeaders.Get("Content-Type"),
|
||||
)
|
||||
|
||||
@@ -1158,7 +1157,19 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := testEngine(t, 1)
|
||||
|
||||
// The database target archives for real now, so the engine
|
||||
// needs a webhook DB manager to locate the data directory.
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil,
|
||||
database.NewTestWebhookDBManager(t.TempDir()),
|
||||
slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
)),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1289,8 +1300,8 @@ func TestFormatSlackMessage_JSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
ContentType: "application/json",
|
||||
Method: http.MethodPost,
|
||||
ContentType: testContentType,
|
||||
Body: `{"action":"push",` +
|
||||
`"repo":"test/repo",` +
|
||||
`"ref":"refs/heads/main"}`,
|
||||
@@ -1315,7 +1326,7 @@ func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
Method: http.MethodPost,
|
||||
ContentType: "text/plain",
|
||||
Body: "hello world plain text",
|
||||
}
|
||||
@@ -1338,8 +1349,8 @@ func TestFormatSlackMessage_EmptyBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
ContentType: "application/json",
|
||||
Method: http.MethodPost,
|
||||
ContentType: testContentType,
|
||||
Body: "",
|
||||
}
|
||||
event.CreatedAt = time.Date(
|
||||
@@ -1367,8 +1378,8 @@ func TestFormatSlackMessage_LargeJSONTruncated(
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
ContentType: "application/json",
|
||||
Method: http.MethodPost,
|
||||
ContentType: testContentType,
|
||||
Body: string(largeJSON),
|
||||
}
|
||||
event.CreatedAt = time.Date(
|
||||
@@ -1697,7 +1708,7 @@ func assertLogLineComplete(
|
||||
"log line must contain the webhook id",
|
||||
)
|
||||
|
||||
assert.Contains(t, out, "application/json",
|
||||
assert.Contains(t, out, testContentType,
|
||||
"log line must contain the content type",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -273,3 +273,64 @@ func NewTestCircuitBreaker(
|
||||
cooldown: cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportArchivedEvent aliases the archive row type so black-box
|
||||
// tests can construct and read archive rows.
|
||||
type ExportArchivedEvent = archivedEvent
|
||||
|
||||
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
||||
// can exercise the per-webhook archive file mechanics.
|
||||
type ExportArchiveWriter struct {
|
||||
w *archiveWriter
|
||||
}
|
||||
|
||||
// NewExportArchiveWriter builds an archive writer for tests,
|
||||
// optionally overriding the reopen debounce (a non-positive
|
||||
// debounce keeps the production default).
|
||||
func NewExportArchiveWriter(
|
||||
path string, log *slog.Logger, debounce time.Duration,
|
||||
) *ExportArchiveWriter {
|
||||
w := newArchiveWriter(path, log)
|
||||
if debounce > 0 {
|
||||
w.debounce = debounce
|
||||
}
|
||||
|
||||
return &ExportArchiveWriter{w: w}
|
||||
}
|
||||
|
||||
// Write archives a row through the writer.
|
||||
func (e *ExportArchiveWriter) Write(
|
||||
row ExportArchivedEvent, expiry time.Duration,
|
||||
) error {
|
||||
return e.w.write(row, expiry)
|
||||
}
|
||||
|
||||
// Open opens the archive file, pruning when expiry is positive.
|
||||
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
||||
return e.w.open(expiry)
|
||||
}
|
||||
|
||||
// Reopen closes and reopens the archive file.
|
||||
func (e *ExportArchiveWriter) Reopen(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.reopen(expiry)
|
||||
}
|
||||
|
||||
// Reopens reports how many times the file has been opened.
|
||||
func (e *ExportArchiveWriter) Reopens() int {
|
||||
return e.w.reopens
|
||||
}
|
||||
|
||||
// DB returns the writer's current open handle for row
|
||||
// inspection in tests.
|
||||
func (e *ExportArchiveWriter) DB() *gorm.DB {
|
||||
return e.w.db
|
||||
}
|
||||
|
||||
// ExportParseArchiveExpiry exposes parseArchiveExpiry.
|
||||
func ExportParseArchiveExpiry(
|
||||
configJSON string,
|
||||
) (time.Duration, error) {
|
||||
return parseArchiveExpiry(configJSON)
|
||||
}
|
||||
|
||||
@@ -2,21 +2,38 @@ package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// databaseTarget is a fire-and-forget target: the event is
|
||||
// already persisted in the per-webhook database by the time
|
||||
// delivery runs, so the target records a single successful
|
||||
// attempt. (Durable archiving to a separate store is tracked
|
||||
// as its own work.)
|
||||
// databaseTarget is a no-retry target that archives the
|
||||
// full inbound event into a per-webhook archive SQLite file,
|
||||
// separate from the per-webhook event database. The event is
|
||||
// already persisted in the per-webhook event DB by the time
|
||||
// delivery runs; the database target additionally writes a
|
||||
// durable long-term copy into archive-{webhookID}.db and then
|
||||
// records a single attempt whose outcome reflects whether the
|
||||
// archive write succeeded. See archiveWriter for the
|
||||
// close/reopen, auto-recreate, and expiry semantics.
|
||||
type databaseTarget struct {
|
||||
eng *Engine
|
||||
|
||||
mu sync.Mutex
|
||||
writers map[string]*archiveWriter
|
||||
}
|
||||
|
||||
// Deliver implements Target.
|
||||
// Deliver implements Target. It archives the event, then
|
||||
// records one successful attempt and marks the delivery
|
||||
// delivered. An archiving error fails the delivery: the
|
||||
// attempt is recorded as failed with the error and the
|
||||
// delivery is marked failed, so a target that could not do
|
||||
// its one job (archiving) never reports success. The target
|
||||
// does not retry; the event remains durably stored in the
|
||||
// per-webhook event database.
|
||||
func (t *databaseTarget) Deliver(
|
||||
_ context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
@@ -24,6 +41,27 @@ func (t *databaseTarget) Deliver(
|
||||
_ *Task,
|
||||
_ Scheduler,
|
||||
) {
|
||||
err := t.archive(d)
|
||||
if err != nil {
|
||||
t.eng.log.Error(
|
||||
"failed to archive event to database target",
|
||||
"delivery_id", d.ID,
|
||||
"event_id", d.EventID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
t.eng.recordResult(
|
||||
webhookDB, d, 1, false, 0, "",
|
||||
err.Error(), 0,
|
||||
)
|
||||
|
||||
t.eng.updateDeliveryStatus(
|
||||
webhookDB, d, database.DeliveryStatusFailed,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.eng.recordResult(
|
||||
webhookDB, d, 1, true, 0, "", "", 0,
|
||||
)
|
||||
@@ -32,3 +70,68 @@ func (t *databaseTarget) Deliver(
|
||||
webhookDB, d, database.DeliveryStatusDelivered,
|
||||
)
|
||||
}
|
||||
|
||||
// archive writes the full event as a row into the webhook's
|
||||
// archive database, honouring the optional per-target expiry
|
||||
// parsed from the target config JSON.
|
||||
func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||
webhookID := d.Event.WebhookID
|
||||
if webhookID == "" {
|
||||
return errArchiveMissingWebhookID
|
||||
}
|
||||
|
||||
expiry, err := parseArchiveExpiry(d.Target.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w, err := t.writerFor(webhookID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := archivedEvent{
|
||||
EventID: d.Event.ID,
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: d.Event.EntrypointID,
|
||||
Method: d.Event.Method,
|
||||
Headers: d.Event.Headers,
|
||||
Body: d.Event.Body,
|
||||
ContentType: d.Event.ContentType,
|
||||
}
|
||||
|
||||
return w.write(row, expiry)
|
||||
}
|
||||
|
||||
// writerFor returns the archiveWriter for a webhook, creating
|
||||
// and caching it on first use. Each webhook has one writer so
|
||||
// its close/reopen debounce state is shared across concurrent
|
||||
// deliveries. The archive file lives beside the per-webhook
|
||||
// event database in the data directory.
|
||||
func (t *databaseTarget) writerFor(
|
||||
webhookID string,
|
||||
) (*archiveWriter, error) {
|
||||
if t.eng.dbManager == nil {
|
||||
return nil, errArchiveNoDataDir
|
||||
}
|
||||
|
||||
dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
|
||||
path := filepath.Join(
|
||||
dir, fmt.Sprintf("archive-%s.db", webhookID),
|
||||
)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.writers == nil {
|
||||
t.writers = make(map[string]*archiveWriter)
|
||||
}
|
||||
|
||||
w, ok := t.writers[webhookID]
|
||||
if !ok {
|
||||
w = newArchiveWriter(path, t.eng.log)
|
||||
t.writers[webhookID] = w
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
312
internal/delivery/target_database_archive.go
Normal file
312
internal/delivery/target_database_archive.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// archiveExpiryNever is the expiry sentinel (and default) that
|
||||
// disables pruning so archived rows are kept forever.
|
||||
const archiveExpiryNever = "never"
|
||||
|
||||
// archiveReopenDebounce bounds how often an archive file is
|
||||
// closed and reopened. After each write the handle is closed
|
||||
// and reopened so an operator can move the file away for
|
||||
// offline archiving, but never more than once per this window.
|
||||
const archiveReopenDebounce = time.Second
|
||||
|
||||
var (
|
||||
// errArchiveMissingWebhookID is returned when an event to
|
||||
// archive has no webhook id to key its archive file on.
|
||||
errArchiveMissingWebhookID = errors.New(
|
||||
"cannot archive event without a webhook id",
|
||||
)
|
||||
|
||||
// errArchiveNoDataDir is returned when the database target
|
||||
// has no webhook database manager and so cannot locate the
|
||||
// data directory for archive files.
|
||||
errArchiveNoDataDir = errors.New(
|
||||
"database target has no data directory",
|
||||
)
|
||||
|
||||
// errArchiveExpiryNotPositive is returned when a
|
||||
// user-supplied archive expiry parses as a duration but is
|
||||
// zero or negative; "never" is the way to disable pruning.
|
||||
errArchiveExpiryNotPositive = errors.New(
|
||||
"expiry must be a positive duration or \"never\"",
|
||||
)
|
||||
)
|
||||
|
||||
// databaseTargetConfig is the optional per-target JSON config
|
||||
// for a database (archive) target.
|
||||
type databaseTargetConfig struct {
|
||||
// Expiry is a Go duration (e.g. "720h") after which
|
||||
// archived rows are pruned, or "never" (the default) to
|
||||
// keep them forever.
|
||||
Expiry string `json:"expiry"`
|
||||
}
|
||||
|
||||
// archivedEvent is one fully captured webhook event stored in a
|
||||
// per-webhook archive database for long-term retention. It is a
|
||||
// self-contained copy — independent of the per-webhook event
|
||||
// database, which may prune events under its own retention.
|
||||
type archivedEvent struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
EventID string `gorm:"index"`
|
||||
WebhookID string
|
||||
EntrypointID string
|
||||
Method string
|
||||
Headers string
|
||||
Body string
|
||||
ContentType string
|
||||
|
||||
// ArchivedAt is when the row was archived and is the age
|
||||
// basis for expiry pruning.
|
||||
ArchivedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// parseArchiveExpiry reads the optional expiry from a database
|
||||
// target's config JSON. An empty config, an empty expiry, or
|
||||
// the literal "never" all mean keep forever, returned as a zero
|
||||
// duration. Any other value must parse as a positive Go
|
||||
// duration; a set-but-invalid value (unparseable, zero, or
|
||||
// negative) is an error rather than a silent default, matching
|
||||
// ValidateArchiveExpiry at target creation.
|
||||
func parseArchiveExpiry(
|
||||
configJSON string,
|
||||
) (time.Duration, error) {
|
||||
if configJSON == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var cfg databaseTargetConfig
|
||||
|
||||
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"parsing database target config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
dur, err := time.ParseDuration(cfg.Expiry)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"parsing archive expiry %q: %w", cfg.Expiry, err,
|
||||
)
|
||||
}
|
||||
|
||||
if dur <= 0 {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: %q", errArchiveExpiryNotPositive, cfg.Expiry,
|
||||
)
|
||||
}
|
||||
|
||||
return dur, nil
|
||||
}
|
||||
|
||||
// ValidateArchiveExpiry checks a user-supplied archive expiry
|
||||
// for a database target at configuration time. Valid values are
|
||||
// empty, "never" (both meaning keep forever), or a positive Go
|
||||
// duration such as "720h". Anything else is an error, so a bad
|
||||
// expiry is rejected when the target is created rather than
|
||||
// failing every subsequent delivery.
|
||||
func ValidateArchiveExpiry(expiry string) error {
|
||||
if expiry == "" || expiry == archiveExpiryNever {
|
||||
return nil
|
||||
}
|
||||
|
||||
dur, err := time.ParseDuration(expiry)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"expiry must be %q or a Go duration "+
|
||||
"such as \"720h\": %w",
|
||||
archiveExpiryNever, err,
|
||||
)
|
||||
}
|
||||
|
||||
if dur <= 0 {
|
||||
return fmt.Errorf(
|
||||
"%w: %q", errArchiveExpiryNotPositive, expiry,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// archiveWriter owns one per-webhook archive SQLite file. It
|
||||
// serialises writes, and after each write closes and reopens
|
||||
// the file (debounced to at most once per debounce window) so
|
||||
// an operator can move the file away for offline archiving. The
|
||||
// next write recreates a moved or removed file, because the
|
||||
// file is opened create-if-missing and its schema is migrated
|
||||
// on every open.
|
||||
type archiveWriter struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
log *slog.Logger
|
||||
debounce time.Duration
|
||||
db *gorm.DB
|
||||
lastReopen time.Time
|
||||
reopens int
|
||||
}
|
||||
|
||||
// newArchiveWriter builds an archiveWriter for a file path with
|
||||
// the default reopen debounce.
|
||||
func newArchiveWriter(
|
||||
path string, log *slog.Logger,
|
||||
) *archiveWriter {
|
||||
return &archiveWriter{
|
||||
path: path,
|
||||
log: log,
|
||||
debounce: archiveReopenDebounce,
|
||||
}
|
||||
}
|
||||
|
||||
// write appends the event as a row, then applies the debounced
|
||||
// close/reopen. It recreates the archive file if it was moved
|
||||
// or removed since the last open. A positive expiry prunes rows
|
||||
// older than it on each (re)open.
|
||||
func (w *archiveWriter) write(
|
||||
row archivedEvent, expiry time.Duration,
|
||||
) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.db == nil || !fileExists(w.path) {
|
||||
err := w.reopen(expiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
row.ArchivedAt = time.Now()
|
||||
|
||||
err := w.db.Create(&row).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"archiving event to %s: %w", w.path, err,
|
||||
)
|
||||
}
|
||||
|
||||
if time.Since(w.lastReopen) >= w.debounce {
|
||||
return w.reopen(expiry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// open opens (creating if missing) the archive file, migrates
|
||||
// its schema, records the reopen time, and prunes expired rows
|
||||
// when expiry is positive.
|
||||
func (w *archiveWriter) open(expiry time.Duration) error {
|
||||
dbURL := fmt.Sprintf("file:%s?mode=rwc", w.path)
|
||||
|
||||
sqlDB, err := sql.Open("sqlite", dbURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"opening archive database %s: %w", w.path, err,
|
||||
)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return fmt.Errorf(
|
||||
"connecting to archive database %s: %w",
|
||||
w.path, err,
|
||||
)
|
||||
}
|
||||
|
||||
err = gdb.AutoMigrate(&archivedEvent{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return fmt.Errorf(
|
||||
"migrating archive database %s: %w", w.path, err,
|
||||
)
|
||||
}
|
||||
|
||||
w.db = gdb
|
||||
w.lastReopen = time.Now()
|
||||
w.reopens++
|
||||
|
||||
if expiry > 0 {
|
||||
w.prune(expiry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reopen closes any open handle and opens the file afresh. The
|
||||
// fresh open recreates the file if it was moved away.
|
||||
func (w *archiveWriter) reopen(expiry time.Duration) error {
|
||||
w.close()
|
||||
|
||||
return w.open(expiry)
|
||||
}
|
||||
|
||||
// close closes the underlying handle, if any.
|
||||
func (w *archiveWriter) close() {
|
||||
if w.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sqlDB, err := w.db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
|
||||
w.db = nil
|
||||
}
|
||||
|
||||
// prune deletes archived rows older than expiry, measured from
|
||||
// each row's archived time. It runs on every (re)open, and
|
||||
// because the file is reopened after writes this keeps the
|
||||
// archive swept without a separate background sweeper. Failures
|
||||
// are logged, not fatal: a prune error must not stop archiving.
|
||||
func (w *archiveWriter) prune(expiry time.Duration) {
|
||||
cutoff := time.Now().Add(-expiry)
|
||||
|
||||
res := w.db.Where("archived_at < ?", cutoff).
|
||||
Delete(&archivedEvent{})
|
||||
if res.Error != nil {
|
||||
w.log.Error(
|
||||
"failed to prune expired archive rows",
|
||||
"path", w.path,
|
||||
"error", res.Error,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
w.log.Info(
|
||||
"pruned expired archive rows",
|
||||
"path", w.path,
|
||||
"rows_deleted", res.RowsAffected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// fileExists reports whether a path currently exists.
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
395
internal/delivery/target_database_test.go
Normal file
395
internal/delivery/target_database_test.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
func archiveTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
}
|
||||
|
||||
// openArchiveDBForRead opens an archive file read-only so a
|
||||
// test can inspect the rows the writer persisted.
|
||||
func openArchiveDBForRead(
|
||||
t *testing.T, path string,
|
||||
) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite",
|
||||
fmt.Sprintf("file:%s?mode=ro", path),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return gdb
|
||||
}
|
||||
|
||||
// removeArchiveFiles simulates an operator moving the archive
|
||||
// away by deleting the SQLite file and its sidecar files.
|
||||
func removeArchiveFiles(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
for _, suffix := range []string{
|
||||
"", "-wal", "-shm", "-journal",
|
||||
} {
|
||||
err := os.Remove(path + suffix)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("removing %s%s: %v", path, suffix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
||||
// a database target marks the delivery delivered and archives
|
||||
// the full event into a separate per-webhook archive file.
|
||||
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
||||
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil, dbMgr,
|
||||
archiveTestLogger(),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
|
||||
|
||||
e.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
require.NoError(t, webhookDB.First(
|
||||
&updated, "id = ?", d.ID,
|
||||
).Error)
|
||||
assert.Equal(t,
|
||||
database.DeliveryStatusDelivered, updated.Status,
|
||||
"database target should mark the delivery delivered",
|
||||
)
|
||||
|
||||
archivePath := filepath.Join(
|
||||
dataDir,
|
||||
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
||||
)
|
||||
assert.FileExists(t, archivePath)
|
||||
|
||||
rdb := openArchiveDBForRead(t, archivePath)
|
||||
|
||||
var rows []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, rdb.Find(&rows).Error)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, event.ID, rows[0].EventID)
|
||||
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
||||
assert.Equal(t, event.Method, rows[0].Method)
|
||||
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_WritesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
row := delivery.ExportArchivedEvent{
|
||||
EventID: "ev-1",
|
||||
WebhookID: "wh-1",
|
||||
EntrypointID: "ep-1",
|
||||
Method: "POST",
|
||||
Headers: `{"X":"Y"}`,
|
||||
Body: `{"hello":"world"}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
|
||||
require.NoError(t, w.Write(row, 0))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
var got []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "ev-1", got[0].EventID)
|
||||
assert.Equal(t, "wh-1", got[0].WebhookID)
|
||||
assert.Equal(t, "ep-1", got[0].EntrypointID)
|
||||
assert.Equal(t, row.Method, got[0].Method)
|
||||
assert.Equal(t, row.ContentType, got[0].ContentType)
|
||||
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
||||
assert.False(t, got[0].ArchivedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestArchiveWriter_RecreatesAfterRemoval(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
||||
))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
// The operator moves the archive away while the handle is
|
||||
// still open.
|
||||
removeArchiveFiles(t, path)
|
||||
require.NoFileExists(t, path)
|
||||
|
||||
// The next write recreates the file with a fresh schema and
|
||||
// only the new row.
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
||||
))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
var got []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "b", got[0].EventID)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A generous debounce keeps the two rapid writes inside
|
||||
// the window even on a heavily loaded test machine.
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 2*time.Second,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
||||
))
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
||||
))
|
||||
|
||||
// Two writes inside the debounce window trigger only the
|
||||
// initial open — no extra close/reopen.
|
||||
assert.Equal(t, 1, w.Reopens())
|
||||
|
||||
time.Sleep(2100 * time.Millisecond)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
||||
))
|
||||
|
||||
// A write after the window elapses closes and reopens once.
|
||||
assert.Equal(t, 2, w.Reopens())
|
||||
}
|
||||
|
||||
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Open(0))
|
||||
|
||||
old := delivery.ExportArchivedEvent{
|
||||
EventID: "old",
|
||||
ArchivedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
fresh := delivery.ExportArchivedEvent{
|
||||
EventID: "fresh",
|
||||
ArchivedAt: time.Now(),
|
||||
}
|
||||
|
||||
require.NoError(t, w.DB().Create(&old).Error)
|
||||
require.NoError(t, w.DB().Create(&fresh).Error)
|
||||
|
||||
// Reopening with a one-hour expiry prunes the old row.
|
||||
require.NoError(t, w.Reopen(time.Hour))
|
||||
|
||||
var got []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "fresh", got[0].EventID)
|
||||
}
|
||||
|
||||
func TestParseArchiveExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty config", "", 0, false},
|
||||
{"explicit never", `{"expiry":"never"}`, 0, false},
|
||||
{"empty expiry", `{"expiry":""}`, 0, false},
|
||||
{"duration", `{"expiry":"1h"}`, time.Hour, false},
|
||||
{"unparseable", `{"expiry":"nonsense"}`, 0, true},
|
||||
{"zero duration", `{"expiry":"0s"}`, 0, true},
|
||||
{"negative duration", `{"expiry":"-5h"}`, 0, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := delivery.ExportParseArchiveExpiry(tc.in)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedDatabaseTargetDelivery seeds a pending delivery for a
|
||||
// database target with the given config JSON and returns the
|
||||
// in-memory delivery the target handler is invoked with.
|
||||
func seedDatabaseTargetDelivery(
|
||||
t *testing.T,
|
||||
webhookDB *gorm.DB,
|
||||
event database.Event,
|
||||
config string,
|
||||
) *database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
dlv := seedDelivery(
|
||||
t, webhookDB, event.ID, uuid.New().String(),
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: dlv.TargetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-db",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: config,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that
|
||||
// an archive error (here: an unparseable expiry in the target
|
||||
// config) fails the delivery loudly: the attempt is recorded as
|
||||
// failed with the error and the delivery is marked failed, not
|
||||
// delivered.
|
||||
func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
|
||||
e := delivery.NewTestEngineWithDB(
|
||||
nil, database.NewTestWebhookDBManager(dataDir),
|
||||
archiveTestLogger(),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
||||
d := seedDatabaseTargetDelivery(
|
||||
t, webhookDB, event, `{"expiry":"nonsense"}`,
|
||||
)
|
||||
|
||||
e.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
require.NoError(t, webhookDB.First(
|
||||
&updated, "id = ?", d.ID,
|
||||
).Error)
|
||||
assert.Equal(t,
|
||||
database.DeliveryStatusFailed, updated.Status,
|
||||
"archive failure must mark the delivery failed",
|
||||
)
|
||||
|
||||
var results []database.DeliveryResult
|
||||
|
||||
require.NoError(t, webhookDB.Where(
|
||||
"delivery_id = ?", d.ID,
|
||||
).Find(&results).Error)
|
||||
require.Len(t, results, 1)
|
||||
assert.False(t,
|
||||
results[0].Success,
|
||||
"the attempt must be recorded as failed",
|
||||
)
|
||||
assert.Contains(t,
|
||||
results[0].Error, "nonsense",
|
||||
"the archive error must be recorded on the attempt",
|
||||
)
|
||||
|
||||
assert.NoFileExists(t,
|
||||
filepath.Join(
|
||||
dataDir,
|
||||
fmt.Sprintf("archive-%s.db", event.WebhookID),
|
||||
),
|
||||
"no archive file should exist for a failed config",
|
||||
)
|
||||
}
|
||||
|
||||
func TestValidateArchiveExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := []string{"", "never", "1h", "720h", "30m"}
|
||||
for _, in := range valid {
|
||||
require.NoError(t,
|
||||
delivery.ValidateArchiveExpiry(in),
|
||||
"expiry %q should be accepted", in,
|
||||
)
|
||||
}
|
||||
|
||||
invalid := []string{"nonsense", "7d", "-5h", "0s", "0"}
|
||||
for _, in := range invalid {
|
||||
require.Error(t,
|
||||
delivery.ValidateArchiveExpiry(in),
|
||||
"expiry %q should be rejected", in,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,26 @@ func (s *Handlers) RenderTemplateForTest(
|
||||
s.renderTemplate(w, r, pageTemplate, data)
|
||||
}
|
||||
|
||||
// BuildSlackTargetConfigForTest exposes buildSlackTargetConfig
|
||||
// for use in the handlers_test package.
|
||||
// BuildSlackTargetConfigForTest exposes buildURLTargetConfig
|
||||
// with the Slack target parameters for use in the
|
||||
// handlers_test package.
|
||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetURL string,
|
||||
) (string, error) {
|
||||
return s.buildSlackTargetConfig(w, r, targetURL)
|
||||
return s.buildURLTargetConfig(
|
||||
w, r, targetURL, "webhookUrl",
|
||||
"Webhook URL is required for Slack targets",
|
||||
)
|
||||
}
|
||||
|
||||
// BuildDatabaseTargetConfigForTest exposes
|
||||
// buildDatabaseTargetConfig for use in the handlers_test
|
||||
// package.
|
||||
func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
||||
w http.ResponseWriter,
|
||||
expiry string,
|
||||
) (string, error) {
|
||||
return s.buildDatabaseTargetConfig(w, expiry)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -186,3 +186,57 @@ func TestRenderTemplate(t *testing.T) {
|
||||
t, http.StatusInternalServerError, w.Code,
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// Empty expiry: the keep-forever default, empty config.
|
||||
w := httptest.NewRecorder()
|
||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cfg)
|
||||
|
||||
// Explicit never is stored as config.
|
||||
w = httptest.NewRecorder()
|
||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||
|
||||
// A positive duration is stored as config.
|
||||
w = httptest.NewRecorder()
|
||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
||||
}
|
||||
|
||||
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||
w := httptest.NewRecorder()
|
||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
|
||||
|
||||
require.Error(t, err, "expiry %q", bad)
|
||||
assert.Empty(t, cfg)
|
||||
assert.Equal(
|
||||
t, http.StatusBadRequest, w.Code,
|
||||
"expiry %q should be rejected with 400", bad,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,63 +4,202 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// HandleProfile returns a handler for the user profile page
|
||||
func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get username from URL
|
||||
requestedUsername := chi.URLParam(r, "username")
|
||||
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)
|
||||
sessionUserID, sessionUsername, ok :=
|
||||
h.profileOwnerOrDeny(w, r)
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing username")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
h.renderProfile(w, r, sessionUserID, sessionUsername, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"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, "/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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
@@ -106,7 +107,7 @@ func (h *Handlers) buildWebhookListItems(
|
||||
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Error": "",
|
||||
tmplKeyError: "",
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "sources_new.html", data)
|
||||
@@ -145,7 +146,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||
|
||||
if name == "" {
|
||||
data := map[string]any{
|
||||
"Error": "Name is required",
|
||||
tmplKeyError: "Name is required",
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@@ -315,11 +316,11 @@ func (h *Handlers) renderSourceDetail(
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Webhook": webhook,
|
||||
"Entrypoints": entrypoints,
|
||||
"Targets": targets,
|
||||
"Events": events,
|
||||
"BaseURL": scheme + "://" + host,
|
||||
tmplKeyWebhook: webhook,
|
||||
"Entrypoints": entrypoints,
|
||||
"Targets": targets,
|
||||
"Events": events,
|
||||
"BaseURL": scheme + "://" + host,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_detail.html", data)
|
||||
@@ -351,8 +352,8 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Webhook": webhook,
|
||||
"Error": "",
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyError: "",
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_edit.html", data)
|
||||
@@ -415,8 +416,8 @@ func (h *Handlers) applyWebhookEdit(
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
data := map[string]any{
|
||||
"Webhook": *webhook,
|
||||
"Error": "Name is required",
|
||||
tmplKeyWebhook: *webhook,
|
||||
tmplKeyError: "Name is required",
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@@ -589,15 +590,15 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Webhook": webhook,
|
||||
"Events": evts,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"TotalEvents": total,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < totalPages,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
tmplKeyWebhook: webhook,
|
||||
"Events": evts,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"TotalEvents": total,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < totalPages,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_logs.html", data)
|
||||
@@ -815,6 +816,7 @@ func (h *Handlers) processTargetCreate(
|
||||
targetType := database.TargetType(r.FormValue("type"))
|
||||
targetURL := r.FormValue("url")
|
||||
maxRetriesStr := r.FormValue("max_retries")
|
||||
expiry := r.FormValue("expiry")
|
||||
|
||||
if name == "" {
|
||||
http.Error(
|
||||
@@ -834,7 +836,7 @@ func (h *Handlers) processTargetCreate(
|
||||
}
|
||||
|
||||
configJSON, err := h.buildTargetConfig(
|
||||
w, r, targetType, targetURL,
|
||||
w, r, targetType, targetURL, expiry,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -892,18 +894,28 @@ func parseNonNegativeInt(s string) int {
|
||||
}
|
||||
|
||||
// buildTargetConfig builds the JSON config string for a target.
|
||||
// The expiry form value is read by the caller (which bounds the
|
||||
// request body) and applies to database targets only.
|
||||
func (h *Handlers) buildTargetConfig(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetType database.TargetType,
|
||||
targetURL string,
|
||||
targetURL, expiry string,
|
||||
) (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)
|
||||
case database.TargetTypeDatabase, database.TargetTypeLog:
|
||||
return h.buildURLTargetConfig(
|
||||
w, r, targetURL, "webhookUrl",
|
||||
"Webhook URL is required for Slack targets",
|
||||
)
|
||||
case database.TargetTypeDatabase:
|
||||
return h.buildDatabaseTargetConfig(w, expiry)
|
||||
case database.TargetTypeLog:
|
||||
return "", nil
|
||||
default:
|
||||
http.Error(
|
||||
@@ -915,16 +927,18 @@ func (h *Handlers) buildTargetConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// buildHTTPTargetConfig builds config JSON for an HTTP target.
|
||||
func (h *Handlers) buildHTTPTargetConfig(
|
||||
// buildURLTargetConfig builds config JSON for a target whose
|
||||
// configuration is a single SSRF-validated URL stored under
|
||||
// configKey. missingMsg is the error shown when no URL is given.
|
||||
func (h *Handlers) buildURLTargetConfig(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetURL string,
|
||||
targetURL, configKey, missingMsg string,
|
||||
) (string, error) {
|
||||
if targetURL == "" {
|
||||
http.Error(
|
||||
w,
|
||||
"URL is required for HTTP targets",
|
||||
missingMsg,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
@@ -949,7 +963,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
||||
return "", err
|
||||
}
|
||||
|
||||
cfg := map[string]any{"url": targetURL}
|
||||
cfg := map[string]any{configKey: targetURL}
|
||||
|
||||
configBytes, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
@@ -964,41 +978,33 @@ func (h *Handlers) buildHTTPTargetConfig(
|
||||
return string(configBytes), nil
|
||||
}
|
||||
|
||||
// buildSlackTargetConfig builds config JSON for a Slack target.
|
||||
func (h *Handlers) buildSlackTargetConfig(
|
||||
// buildDatabaseTargetConfig builds config JSON for a database
|
||||
// (archive) target. The optional expiry (a form value read by
|
||||
// the caller, which bounds the request body) is validated here,
|
||||
// at creation time, so an unparseable value is rejected with a
|
||||
// 400 instead of failing every subsequent delivery. An empty
|
||||
// expiry yields an empty config (the keep-forever default).
|
||||
func (h *Handlers) buildDatabaseTargetConfig(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetURL string,
|
||||
expiry string,
|
||||
) (string, error) {
|
||||
if targetURL == "" {
|
||||
http.Error(
|
||||
w,
|
||||
"Webhook URL is required for Slack targets",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return "", errMissingURL
|
||||
expiry = strings.TrimSpace(expiry)
|
||||
if expiry == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
err := delivery.ValidateTargetURL(
|
||||
r.Context(), targetURL,
|
||||
)
|
||||
err := delivery.ValidateArchiveExpiry(expiry)
|
||||
if err != nil {
|
||||
h.log.Warn(
|
||||
"target URL blocked by SSRF protection",
|
||||
"url", targetURL,
|
||||
"error", err,
|
||||
)
|
||||
http.Error(
|
||||
w,
|
||||
"Invalid target URL: "+err.Error(),
|
||||
"Invalid archive expiry: "+err.Error(),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
cfg := map[string]any{"webhookUrl": targetURL}
|
||||
cfg := map[string]any{"expiry": expiry}
|
||||
|
||||
configBytes, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
|
||||
@@ -32,3 +32,7 @@ func IsClientTLS(r *http.Request) bool {
|
||||
|
||||
// LoginRateLimitConst exposes the loginRateLimit constant.
|
||||
const LoginRateLimitConst = loginRateLimit
|
||||
|
||||
// PasswordChangeRateLimitConst exposes the
|
||||
// passwordChangeRateLimit constant.
|
||||
const PasswordChangeRateLimitConst = passwordChangeRateLimit
|
||||
|
||||
@@ -186,6 +186,10 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// IsAuthenticated also enforces both session expiry
|
||||
// deadlines, so an idle-expired or absolutely-expired
|
||||
// session lands here and is sent back to the login
|
||||
// page.
|
||||
if !s.session.IsAuthenticated(sess) {
|
||||
s.log.Debug(
|
||||
"auth middleware: unauthenticated request",
|
||||
@@ -199,6 +203,26 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// This request authenticated with the session, so it
|
||||
// counts as activity: push the idle deadline forward.
|
||||
// This is the only place sessions are refreshed, which
|
||||
// is what keeps an unauthenticated request from
|
||||
// extending someone else's session. Touch advances the
|
||||
// idle clock only -- the absolute cap is untouched --
|
||||
// and reports false when nothing changed, so most
|
||||
// requests do not re-issue the cookie. Save before the
|
||||
// handler runs, while the headers are still ours to
|
||||
// write.
|
||||
if s.session.Touch(sess) {
|
||||
err = s.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
"auth middleware: failed to refresh session",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -28,13 +29,30 @@ func testMiddleware(
|
||||
) (*middleware.Middleware, *session.Session) {
|
||||
t.Helper()
|
||||
|
||||
m, s, _ := testMiddlewareWithSessionClock(t, env, 0, nil)
|
||||
|
||||
return m, s
|
||||
}
|
||||
|
||||
// testMiddlewareWithSessionClock is testMiddleware with a
|
||||
// configurable session idle timeout and a manually advanced clock,
|
||||
// for the session-expiry tests. A nil clock uses the real one.
|
||||
func testMiddlewareWithSessionClock(
|
||||
t *testing.T,
|
||||
env string,
|
||||
idleTimeout time.Duration,
|
||||
clock *fakeClock,
|
||||
) (*middleware.Middleware, *session.Session, *fakeClock) {
|
||||
t.Helper()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: env,
|
||||
Environment: env,
|
||||
SessionIdleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
// Create a real session manager with a known key
|
||||
@@ -53,11 +71,40 @@ func testMiddleware(
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
sessManager := session.NewForTest(store, cfg, log, key)
|
||||
var now func() time.Time
|
||||
|
||||
if clock != nil {
|
||||
now = clock.Now
|
||||
}
|
||||
|
||||
sessManager := session.NewForTest(store, cfg, log, key, now)
|
||||
|
||||
m := middleware.NewForTest(log, cfg, sessManager)
|
||||
|
||||
return m, sessManager
|
||||
return m, sessManager, clock
|
||||
}
|
||||
|
||||
// fakeClock is a manually advanced clock, so session expiry can be
|
||||
// tested without sleeping.
|
||||
type fakeClock struct {
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
// newFakeClock returns a clock started at a fixed instant.
|
||||
func newFakeClock() *fakeClock {
|
||||
return &fakeClock{
|
||||
t: time.Date(
|
||||
2026, time.January, 2, 3, 4, 5, 0, time.UTC,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Logging Middleware Tests ---
|
||||
@@ -387,6 +434,181 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// --- RequireAuth Session Expiry Tests ---
|
||||
|
||||
// loginCookies authenticates a new session and returns the cookies
|
||||
// a browser would then send back.
|
||||
func loginCookies(
|
||||
t *testing.T,
|
||||
sessManager *session.Session,
|
||||
) []*http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
sess, err := sessManager.Get(req)
|
||||
require.NoError(t, err)
|
||||
sessManager.SetUser(sess, "user-123", "testuser")
|
||||
require.NoError(t, sessManager.Save(req, w, sess))
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
require.NotEmpty(t, cookies, "session cookie should be set")
|
||||
|
||||
return cookies
|
||||
}
|
||||
|
||||
// runAuthed sends a request carrying cookies through RequireAuth
|
||||
// and reports whether the protected handler ran, plus the response.
|
||||
func runAuthed(
|
||||
t *testing.T,
|
||||
m *middleware.Middleware,
|
||||
cookies []*http.Cookie,
|
||||
) (bool, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.RequireAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/dashboard", nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
return called, w
|
||||
}
|
||||
|
||||
// sessionCookies filters a response's cookies down to the session
|
||||
// cookie, so tests can tell whether the session was re-issued.
|
||||
func sessionCookies(
|
||||
w *httptest.ResponseRecorder,
|
||||
) []*http.Cookie {
|
||||
var out []*http.Cookie
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == session.SessionName {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
idle := time.Hour
|
||||
|
||||
m, sessManager, clock := testMiddlewareWithSessionClock(
|
||||
t, config.EnvironmentDev, idle, newFakeClock(),
|
||||
)
|
||||
|
||||
cookies := loginCookies(t, sessManager)
|
||||
|
||||
clock.Advance(idle)
|
||||
|
||||
called, w := runAuthed(t, m, cookies)
|
||||
|
||||
assert.False(
|
||||
t, called,
|
||||
"handler should not run for an idle-expired session",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
assert.Empty(
|
||||
t, sessionCookies(w),
|
||||
"an expired session must not be refreshed",
|
||||
)
|
||||
}
|
||||
|
||||
func TestRequireAuth_RefreshesIdleDeadlineOnActivity(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
idle := time.Hour
|
||||
|
||||
m, sessManager, clock := testMiddlewareWithSessionClock(
|
||||
t, config.EnvironmentDev, idle, newFakeClock(),
|
||||
)
|
||||
|
||||
cookies := loginCookies(t, sessManager)
|
||||
|
||||
// Activity halfway through the idle window.
|
||||
clock.Advance(idle / 2)
|
||||
|
||||
called, w := runAuthed(t, m, cookies)
|
||||
require.True(t, called, "handler should run while valid")
|
||||
|
||||
refreshed := sessionCookies(w)
|
||||
require.NotEmpty(
|
||||
t, refreshed,
|
||||
"activity should re-issue the session cookie",
|
||||
)
|
||||
|
||||
// Past the original deadline. The refreshed cookie is still
|
||||
// good; the original one is not.
|
||||
clock.Advance(idle - time.Second)
|
||||
|
||||
calledRefreshed, _ := runAuthed(t, m, refreshed)
|
||||
assert.True(
|
||||
t, calledRefreshed,
|
||||
"refreshed session should outlive the original deadline",
|
||||
)
|
||||
|
||||
calledStale, staleW := runAuthed(t, m, cookies)
|
||||
assert.False(
|
||||
t, calledStale,
|
||||
"the pre-refresh cookie carries the old idle deadline",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, staleW.Code)
|
||||
}
|
||||
|
||||
func TestRequireAuth_UnauthenticatedRequestDoesNotRefresh(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
m, sessManager, _ := testMiddlewareWithSessionClock(
|
||||
t, config.EnvironmentDev, time.Hour, newFakeClock(),
|
||||
)
|
||||
|
||||
// A session cookie that exists but was never authenticated.
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/setup", nil)
|
||||
setupW := httptest.NewRecorder()
|
||||
|
||||
sess, err := sessManager.Get(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sessManager.Save(req, setupW, sess))
|
||||
|
||||
cookies := setupW.Result().Cookies()
|
||||
require.NotEmpty(t, cookies)
|
||||
|
||||
called, w := runAuthed(t, m, cookies)
|
||||
|
||||
assert.False(t, called)
|
||||
assert.Empty(
|
||||
t, sessionCookies(w),
|
||||
"an unauthenticated request must not stamp the session",
|
||||
)
|
||||
}
|
||||
|
||||
// --- NoCache Middleware Tests ---
|
||||
|
||||
func TestNoCache_SetsHeaders(t *testing.T) {
|
||||
@@ -479,13 +701,18 @@ func metricsAuthMiddleware(
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
||||
|
||||
sessManager := session.NewForTest(store, cfg, log, key)
|
||||
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
||||
|
||||
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 +730,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 +754,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,
|
||||
|
||||
@@ -14,6 +14,16 @@ const (
|
||||
|
||||
// loginRateInterval is the time window for the rate limit.
|
||||
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
|
||||
@@ -24,19 +34,53 @@ const (
|
||||
// honours X-Forwarded-For, X-Real-IP, and True-Client-IP headers
|
||||
// for reverse-proxy setups.
|
||||
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
limiter := httprate.Limit(
|
||||
return m.postRateLimit(
|
||||
loginRateLimit,
|
||||
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.WithLimitHandler(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
m.log.Warn("login rate limit exceeded",
|
||||
m.log.Warn(logMessage,
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
http.Error(
|
||||
w,
|
||||
"Too many login attempts. "+
|
||||
"Please try again later.",
|
||||
responseMessage,
|
||||
http.StatusTooManyRequests,
|
||||
)
|
||||
},
|
||||
@@ -50,8 +94,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
// Only rate-limit POST requests (actual login
|
||||
// attempts)
|
||||
// Only rate-limit POST requests.
|
||||
if r.Method != http.MethodPost {
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
|
||||
@@ -46,14 +46,20 @@ func TestLoginRateLimit_AllowsGET(t *testing.T) {
|
||||
assert.Equal(t, 20, callCount)
|
||||
}
|
||||
|
||||
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
// runPostLimitTest exercises a POST-only rate limit middleware:
|
||||
// the first limit POSTs to path from ip must pass, and the next
|
||||
// one must be rejected with 429 without reaching the handler.
|
||||
func runPostLimitTest(
|
||||
t *testing.T,
|
||||
mw func(http.Handler) http.Handler,
|
||||
limit int,
|
||||
path, ip string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var callCount int
|
||||
|
||||
handler := m.LoginRateLimit()(http.HandlerFunc(
|
||||
handler := mw(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
callCount++
|
||||
|
||||
@@ -61,13 +67,13 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
||||
},
|
||||
))
|
||||
|
||||
// First loginRateLimit POST requests should succeed
|
||||
for i := range middleware.LoginRateLimitConst {
|
||||
// The first limit POST requests should succeed
|
||||
for i := range limit {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost, "/pages/login", nil,
|
||||
http.MethodPost, path, nil,
|
||||
)
|
||||
req.RemoteAddr = "10.0.0.1:12345"
|
||||
req.RemoteAddr = ip
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
@@ -81,9 +87,9 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
||||
// Next POST should be rate-limited
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost, "/pages/login", nil,
|
||||
http.MethodPost, path, nil,
|
||||
)
|
||||
req.RemoteAddr = "10.0.0.1:12345"
|
||||
req.RemoteAddr = ip
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
@@ -92,7 +98,35 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"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) {
|
||||
|
||||
@@ -110,6 +110,9 @@ func (s *Server) setupUserRoutes() {
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleProfile())
|
||||
r.With(s.mw.PasswordChangeRateLimit()).Post(
|
||||
"/password", s.h.HandlePasswordChange(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"go.uber.org/fx"
|
||||
@@ -32,6 +33,18 @@ const (
|
||||
// status.
|
||||
AuthenticatedKey = "authenticated"
|
||||
|
||||
// CreatedAtKey is the session key holding the Unix timestamp at
|
||||
// which the session was authenticated. It anchors the ABSOLUTE
|
||||
// expiry clock and is written exactly once, by SetUser. Nothing
|
||||
// refreshes it: an absolute deadline that moved with activity
|
||||
// would not be a cap at all.
|
||||
CreatedAtKey = "created_at"
|
||||
|
||||
// LastSeenKey is the session key holding the Unix timestamp of
|
||||
// the most recent authenticated request. It anchors the IDLE
|
||||
// expiry clock and is pushed forward by Touch.
|
||||
LastSeenKey = "last_seen"
|
||||
|
||||
// sessionKeyLength is the required length in bytes for the
|
||||
// session authentication key.
|
||||
sessionKeyLength = 32
|
||||
@@ -41,6 +54,19 @@ const (
|
||||
|
||||
// secondsPerDay is the number of seconds in a day.
|
||||
secondsPerDay = 86400
|
||||
|
||||
// sessionAbsoluteMaxAge is the hard upper bound on how long a
|
||||
// session may live, measured from CreatedAtKey. Activity never
|
||||
// extends it, so even a continuously used session ends here and
|
||||
// the user has to authenticate again.
|
||||
sessionAbsoluteMaxAge = sessionMaxAgeDays * secondsPerDay * time.Second
|
||||
|
||||
// idleRefreshDivisor rate-limits idle-deadline refreshes. Touch
|
||||
// only rewrites LastSeenKey once the stored value is older than
|
||||
// idleTimeout/idleRefreshDivisor, so an active session is
|
||||
// re-saved at most this many times per idle window instead of
|
||||
// once per request. See Touch for the tradeoff this buys.
|
||||
idleRefreshDivisor = 10
|
||||
)
|
||||
|
||||
// ErrSessionKeyLength is returned when the decoded session key
|
||||
@@ -62,6 +88,16 @@ type Session struct {
|
||||
key []byte // raw 32-byte auth key, also used for CSRF cookie signing
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
|
||||
// idleTimeout is the sliding inactivity window. A session that
|
||||
// sees no authenticated request within this window expires,
|
||||
// independently of the absolute cap. Non-positive disables idle
|
||||
// expiry and leaves sessionAbsoluteMaxAge as the only bound.
|
||||
idleTimeout time.Duration
|
||||
|
||||
// now reads the current time. Injected so expiry can be tested
|
||||
// without sleeping.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New creates a new session manager. The cookie store is
|
||||
@@ -73,8 +109,10 @@ func New(
|
||||
params Params,
|
||||
) (*Session, error) {
|
||||
s := &Session{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
idleTimeout: params.Config.SessionIdleTimeout,
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
@@ -149,29 +187,98 @@ func (s *Session) Save(
|
||||
return sess.Save(r, w)
|
||||
}
|
||||
|
||||
// SetUser sets the user information in the session.
|
||||
// SetUser sets the user information in the session. It starts both
|
||||
// expiry clocks: CreatedAtKey (absolute, never refreshed again) and
|
||||
// LastSeenKey (idle, refreshed by Touch).
|
||||
func (s *Session) SetUser(
|
||||
sess *sessions.Session,
|
||||
userID, username string,
|
||||
) {
|
||||
now := s.now().Unix()
|
||||
|
||||
sess.Values[UserIDKey] = userID
|
||||
sess.Values[UsernameKey] = username
|
||||
sess.Values[AuthenticatedKey] = true
|
||||
sess.Values[CreatedAtKey] = now
|
||||
sess.Values[LastSeenKey] = now
|
||||
}
|
||||
|
||||
// ClearUser removes user information from the session.
|
||||
// ClearUser removes user information from the session, including
|
||||
// both expiry timestamps.
|
||||
func (s *Session) ClearUser(sess *sessions.Session) {
|
||||
delete(sess.Values, UserIDKey)
|
||||
delete(sess.Values, UsernameKey)
|
||||
delete(sess.Values, AuthenticatedKey)
|
||||
delete(sess.Values, CreatedAtKey)
|
||||
delete(sess.Values, LastSeenKey)
|
||||
}
|
||||
|
||||
// IsAuthenticated checks if the session has an authenticated
|
||||
// user.
|
||||
// sessionTime reads a Unix-second timestamp stored under key.
|
||||
func sessionTime(
|
||||
sess *sessions.Session,
|
||||
key string,
|
||||
) (time.Time, bool) {
|
||||
secs, ok := sess.Values[key].(int64)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return time.Unix(secs, 0), true
|
||||
}
|
||||
|
||||
// IsAuthenticated checks if the session has an authenticated user
|
||||
// whose session has not passed either expiry deadline. Every
|
||||
// authentication decision goes through here, so neither clock can
|
||||
// be bypassed by a caller that forgets to check it.
|
||||
func (s *Session) IsAuthenticated(sess *sessions.Session) bool {
|
||||
auth, ok := sess.Values[AuthenticatedKey].(bool)
|
||||
if !ok || !auth {
|
||||
return false
|
||||
}
|
||||
|
||||
return ok && auth
|
||||
return !s.expired(sess)
|
||||
}
|
||||
|
||||
// Touch records authenticated activity by pushing the IDLE deadline
|
||||
// forward. It writes LastSeenKey only; CreatedAtKey is left alone so
|
||||
// the absolute cap keeps counting down even for a user who never
|
||||
// stops clicking.
|
||||
//
|
||||
// Callers must only invoke Touch for a request that authenticated
|
||||
// with this session. Refreshing on an unauthenticated request would
|
||||
// let anyone holding a stolen or abandoned cookie keep the session
|
||||
// alive by polling a public endpoint. Touch enforces that itself by
|
||||
// returning false for any session that is not currently
|
||||
// authenticated and unexpired.
|
||||
//
|
||||
// To avoid re-encrypting and re-emitting the session cookie on every
|
||||
// single request, the timestamp is advanced only once it is older
|
||||
// than idleTimeout/idleRefreshDivisor. The tradeoff is that
|
||||
// LastSeenKey lags real activity by up to that much, so a session
|
||||
// can expire slightly early relative to the user's true last
|
||||
// request -- never late.
|
||||
//
|
||||
// Touch reports whether it changed the session; only then does the
|
||||
// caller need to save it.
|
||||
func (s *Session) Touch(sess *sessions.Session) bool {
|
||||
if s.idleTimeout <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if !s.IsAuthenticated(sess) {
|
||||
return false
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
|
||||
lastSeen, ok := sessionTime(sess, LastSeenKey)
|
||||
if ok && now.Sub(lastSeen) < s.idleTimeout/idleRefreshDivisor {
|
||||
return false
|
||||
}
|
||||
|
||||
sess.Values[LastSeenKey] = now.Unix()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetUserID retrieves the user ID from the session.
|
||||
@@ -253,3 +360,41 @@ func (s *Session) Regenerate(
|
||||
|
||||
return newSess, nil
|
||||
}
|
||||
|
||||
// expired reports whether the session has passed either of its two
|
||||
// independent deadlines. They are deliberately kept apart:
|
||||
//
|
||||
// - the ABSOLUTE deadline is CreatedAtKey + sessionAbsoluteMaxAge.
|
||||
// It is fixed at login and no amount of activity moves it.
|
||||
// - the IDLE deadline is LastSeenKey + idleTimeout. Activity moves
|
||||
// it forward via Touch.
|
||||
//
|
||||
// Whichever comes first ends the session.
|
||||
//
|
||||
// A session that claims to be authenticated but carries no
|
||||
// timestamps predates this check; it is treated as expired so the
|
||||
// user re-authenticates rather than being granted an unbounded
|
||||
// session.
|
||||
func (s *Session) expired(sess *sessions.Session) bool {
|
||||
now := s.now()
|
||||
|
||||
createdAt, ok := sessionTime(sess, CreatedAtKey)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if !now.Before(createdAt.Add(sessionAbsoluteMaxAge)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if s.idleTimeout <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSeen, ok := sessionTime(sess, LastSeenKey)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return !now.Before(lastSeen.Add(s.idleTimeout))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -17,11 +18,47 @@ import (
|
||||
|
||||
const testKeySize = 32
|
||||
|
||||
// testSession creates a Session with a real cookie store for
|
||||
// testing.
|
||||
// testIdleTimeout is the idle window used by the expiry tests.
|
||||
const testIdleTimeout = time.Hour
|
||||
|
||||
// testAbsoluteMaxAge restates the documented absolute session cap
|
||||
// independently of the implementation constant.
|
||||
const testAbsoluteMaxAge = 7 * 24 * time.Hour
|
||||
|
||||
// fakeClock is a manually advanced clock, so expiry can be tested
|
||||
// without sleeping.
|
||||
type fakeClock struct {
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
// testSession creates a Session with a real cookie store and the
|
||||
// real clock.
|
||||
func testSession(t *testing.T) *session.Session {
|
||||
t.Helper()
|
||||
|
||||
s, _ := testSessionWithClock(t, testIdleTimeout, nil)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// testSessionWithClock creates a Session with a real cookie store,
|
||||
// the given idle timeout, and a manually advanced clock. Passing a
|
||||
// nil clock uses the real one.
|
||||
func testSessionWithClock(
|
||||
t *testing.T,
|
||||
idleTimeout time.Duration,
|
||||
clock *fakeClock,
|
||||
) (*session.Session, *fakeClock) {
|
||||
t.Helper()
|
||||
|
||||
key := make([]byte, testKeySize)
|
||||
|
||||
for i := range key {
|
||||
@@ -38,7 +75,8 @@ func testSession(t *testing.T) *session.Session {
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
Environment: config.EnvironmentDev,
|
||||
SessionIdleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
@@ -46,7 +84,46 @@ func testSession(t *testing.T) *session.Session {
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
return session.NewForTest(store, cfg, log, key)
|
||||
var now func() time.Time
|
||||
|
||||
if clock != nil {
|
||||
now = clock.Now
|
||||
}
|
||||
|
||||
return session.NewForTest(store, cfg, log, key, now), clock
|
||||
}
|
||||
|
||||
// newFakeClock returns a clock started at a fixed instant.
|
||||
func newFakeClock() *fakeClock {
|
||||
return &fakeClock{
|
||||
t: time.Date(
|
||||
2026, time.January, 2, 3, 4, 5, 0, time.UTC,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// authenticatedSession returns a fresh session that has just been
|
||||
// logged in, along with its manager and clock.
|
||||
func authenticatedSession(
|
||||
t *testing.T,
|
||||
idleTimeout time.Duration,
|
||||
) (*session.Session, *sessions.Session, *fakeClock) {
|
||||
t.Helper()
|
||||
|
||||
s, clock := testSessionWithClock(
|
||||
t, idleTimeout, newFakeClock(),
|
||||
)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-123", "alice")
|
||||
require.True(t, s.IsAuthenticated(sess))
|
||||
|
||||
return s, sess, clock
|
||||
}
|
||||
|
||||
// --- Get and Save Tests ---
|
||||
@@ -173,8 +250,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 +272,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 ---
|
||||
@@ -418,6 +507,263 @@ func TestSessionConstants(t *testing.T) {
|
||||
assert.Equal(t, "user_id", session.UserIDKey)
|
||||
assert.Equal(t, "username", session.UsernameKey)
|
||||
assert.Equal(t, "authenticated", session.AuthenticatedKey)
|
||||
assert.Equal(t, "created_at", session.CreatedAtKey)
|
||||
assert.Equal(t, "last_seen", session.LastSeenKey)
|
||||
}
|
||||
|
||||
// --- Expiry Tests ---
|
||||
|
||||
func TestSetUser_StartsBothClocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
assert.Equal(
|
||||
t, clock.Now().Unix(), sess.Values[session.CreatedAtKey],
|
||||
"SetUser should anchor the absolute clock",
|
||||
)
|
||||
assert.Equal(
|
||||
t, clock.Now().Unix(), sess.Values[session.LastSeenKey],
|
||||
"SetUser should anchor the idle clock",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsAuthenticated_WithinIdleWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
clock.Advance(testIdleTimeout - time.Second)
|
||||
|
||||
assert.True(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"session should still be valid just inside the idle window",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsAuthenticated_IdleExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
clock.Advance(testIdleTimeout)
|
||||
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"session should expire once the idle window lapses",
|
||||
)
|
||||
}
|
||||
|
||||
// TestTouch_DoesNotExtendAbsoluteCap is the regression test for the
|
||||
// refresh-the-wrong-clock bug: a session that is used continuously
|
||||
// must survive well past the idle window and still die at the
|
||||
// absolute cap.
|
||||
func TestTouch_DoesNotExtendAbsoluteCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
createdAt := sess.Values[session.CreatedAtKey]
|
||||
|
||||
// Stay active: a request every half idle window, right up to
|
||||
// the absolute cap.
|
||||
step := testIdleTimeout / 2
|
||||
steps := int(testAbsoluteMaxAge/step) - 1
|
||||
|
||||
for i := range steps {
|
||||
clock.Advance(step)
|
||||
s.Touch(sess)
|
||||
|
||||
require.True(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"active session should survive the idle window "+
|
||||
"(step %d of %d)", i+1, steps,
|
||||
)
|
||||
}
|
||||
|
||||
// One more step of activity takes the session to exactly the
|
||||
// absolute cap, measured from login. Nothing that happened in
|
||||
// the loop may have moved that deadline.
|
||||
clock.Advance(step)
|
||||
s.Touch(sess)
|
||||
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"activity must not extend the absolute cap",
|
||||
)
|
||||
assert.Equal(
|
||||
t, createdAt, sess.Values[session.CreatedAtKey],
|
||||
"Touch must never rewrite the absolute-clock anchor",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTouch_RefreshesIdleDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
// Halfway through the window, activity happens.
|
||||
clock.Advance(testIdleTimeout / 2)
|
||||
assert.True(
|
||||
t, s.Touch(sess),
|
||||
"Touch should refresh once past the lazy-refresh threshold",
|
||||
)
|
||||
|
||||
// Past the original deadline, but inside the refreshed one.
|
||||
clock.Advance(testIdleTimeout - time.Second)
|
||||
assert.True(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"refreshed session should outlive the original deadline",
|
||||
)
|
||||
|
||||
// And it still expires an idle window after that activity.
|
||||
clock.Advance(time.Second)
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"refreshed session should expire one window after activity",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTouch_LazyBelowRefreshThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
before := sess.Values[session.LastSeenKey]
|
||||
|
||||
// A request arriving almost immediately is not worth a cookie
|
||||
// rewrite.
|
||||
clock.Advance(time.Second)
|
||||
|
||||
assert.False(
|
||||
t, s.Touch(sess),
|
||||
"Touch should not rewrite the session below the threshold",
|
||||
)
|
||||
assert.Equal(
|
||||
t, before, sess.Values[session.LastSeenKey],
|
||||
"last-seen should be unchanged below the threshold",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTouch_UnauthenticatedSessionIsNotRefreshed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clock := testSessionWithClock(
|
||||
t, testIdleTimeout, newFakeClock(),
|
||||
)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
clock.Advance(testIdleTimeout / 2)
|
||||
|
||||
assert.False(
|
||||
t, s.Touch(sess),
|
||||
"an unauthenticated session must not be refreshed",
|
||||
)
|
||||
|
||||
_, hasLastSeen := sess.Values[session.LastSeenKey]
|
||||
assert.False(
|
||||
t, hasLastSeen,
|
||||
"Touch must not stamp an unauthenticated session",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTouch_IdleExpiredSessionIsNotRevived(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
clock.Advance(testIdleTimeout)
|
||||
require.False(t, s.IsAuthenticated(sess))
|
||||
|
||||
assert.False(
|
||||
t, s.Touch(sess),
|
||||
"an already expired session must not be refreshed",
|
||||
)
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"Touch must not revive an expired session",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsAuthenticated_MissingTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _ := testSessionWithClock(
|
||||
t, testIdleTimeout, newFakeClock(),
|
||||
)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A session from before idle expiry existed: authenticated,
|
||||
// but with no timestamps. Fail closed.
|
||||
sess.Values[session.AuthenticatedKey] = true
|
||||
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"a session with no timestamps should be rejected",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsAuthenticated_MissingLastSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, _ := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
delete(sess.Values, session.LastSeenKey)
|
||||
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"a session with no idle anchor should be rejected",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIdleTimeoutDisabled_AbsoluteCapStillApplies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, clock := authenticatedSession(t, 0)
|
||||
|
||||
// Idle expiry is off, so an untouched session survives an
|
||||
// arbitrary idle stretch.
|
||||
clock.Advance(testAbsoluteMaxAge - time.Second)
|
||||
assert.True(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"idle expiry should be disabled by a non-positive timeout",
|
||||
)
|
||||
|
||||
assert.False(
|
||||
t, s.Touch(sess),
|
||||
"Touch should be a no-op when idle expiry is disabled",
|
||||
)
|
||||
|
||||
// The absolute cap still ends it.
|
||||
clock.Advance(time.Second)
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"the absolute cap must still apply with idle expiry off",
|
||||
)
|
||||
}
|
||||
|
||||
func TestClearUser_RemovesTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, sess, _ := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
s.ClearUser(sess)
|
||||
|
||||
_, hasCreatedAt := sess.Values[session.CreatedAtKey]
|
||||
assert.False(t, hasCreatedAt, "CreatedAtKey should be removed")
|
||||
|
||||
_, hasLastSeen := sess.Values[session.LastSeenKey]
|
||||
assert.False(t, hasLastSeen, "LastSeenKey should be removed")
|
||||
}
|
||||
|
||||
// --- Edge Cases ---
|
||||
|
||||
@@ -2,6 +2,7 @@ package session
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
@@ -12,11 +13,28 @@ 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 {
|
||||
//
|
||||
// The idle timeout is taken from cfg.SessionIdleTimeout, exactly as in
|
||||
// production. The now parameter supplies the clock used for expiry
|
||||
// checks so tests can advance time without sleeping; pass nil for the
|
||||
// real clock.
|
||||
func NewForTest(
|
||||
store *sessions.CookieStore,
|
||||
cfg *config.Config,
|
||||
log *slog.Logger,
|
||||
key []byte,
|
||||
now func() time.Time,
|
||||
) *Session {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
return &Session{
|
||||
store: store,
|
||||
key: key,
|
||||
config: cfg,
|
||||
log: log,
|
||||
store: store,
|
||||
key: key,
|
||||
config: cfg,
|
||||
log: log,
|
||||
idleTimeout: cfg.SessionIdleTimeout,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
<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>
|
||||
|
||||
{{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="flex items-center mb-6">
|
||||
<div class="mr-4">
|
||||
@@ -43,6 +55,50 @@
|
||||
</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">
|
||||
<a href="/" class="btn-secondary">Back to Home</a>
|
||||
</div>
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'database'">
|
||||
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user