All checks were successful
check / check (push) Successful in 4s
## Summary Adds a read-only web dashboard at `GET /` that shows the current monitoring state and recent alerts. Unauthenticated, single-page, no navigation. ## What it shows - **Summary bar**: counts of monitored domains, hostnames, ports, certificates - **Domains**: nameservers with last-checked age - **Hostnames**: per-nameserver DNS records, status badges, relative age - **Ports**: open/closed state with associated hostnames and age - **TLS Certificates**: CN, issuer, expiry (color-coded by urgency), status, age - **Recent Alerts**: last 100 notifications in reverse chronological order with priority badges Every data point displays its age (e.g. "5m ago") so freshness is visible at a glance. Auto-refreshes every 30 seconds. ## What it does NOT show No secrets: webhook URLs, ntfy topics, Slack/Mattermost endpoints, API tokens, and configuration details are never exposed. ## Design All assets (CSS) are embedded in the binary and served from `/s/`. Zero external HTTP requests at runtime — no CDN dependencies or third-party resources. Dark, technical aesthetic with saturated teals and blues on dark slate. Single page — everything on one screen. ## Implementation - `internal/notify/history.go` — thread-safe ring buffer (`AlertHistory`) storing last 100 alerts - `internal/notify/notify.go` — records each alert in history before dispatch; refactored `SendNotification` into smaller `dispatch*` helpers to satisfy funlen - `internal/handlers/dashboard.go` — `HandleDashboard()` handler with embedded HTML template, helper functions (`relTime`, `formatRecords`, `expiryDays`, `joinStrings`) - `internal/handlers/templates/dashboard.html` — Tailwind-styled single-page dashboard - `internal/handlers/handlers.go` — added `State` and `Notify` dependencies via fx - `internal/server/routes.go` — registered `GET /` route - `static/` — embedded CSS assets served via `/s/` prefix - `README.md` — documented the dashboard and new endpoint ## Tests - `internal/notify/history_test.go` — empty, add+recent ordering, overflow beyond capacity - `internal/handlers/dashboard_test.go` — `relTime`, `expiryDays`, `formatRecords` - All existing tests pass unchanged - `docker build .` passes closes [#82](#82) <!-- session: rework-pr-83 --> Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #83 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
260 lines
7.4 KiB
Go
260 lines
7.4 KiB
Go
package config_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/viper"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/config"
|
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
)
|
|
|
|
// newTestParams creates config.Params suitable for testing
|
|
// without requiring the fx dependency injection framework.
|
|
func newTestParams(t *testing.T) config.Params {
|
|
t.Helper()
|
|
|
|
g := &globals.Globals{
|
|
Appname: "dnswatcher",
|
|
Version: "test",
|
|
}
|
|
|
|
l, err := logger.New(nil, logger.Params{Globals: g})
|
|
require.NoError(t, err, "failed to create logger")
|
|
|
|
return config.Params{
|
|
Globals: g,
|
|
Logger: l,
|
|
}
|
|
}
|
|
|
|
// These tests exercise viper global state and MUST NOT use
|
|
// t.Parallel(). Each test resets viper for isolation.
|
|
|
|
func TestNew_DefaultValues(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 8080, cfg.Port)
|
|
assert.False(t, cfg.Debug)
|
|
assert.Equal(t, "./data", cfg.DataDir)
|
|
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
|
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
|
|
assert.Equal(t, 7, cfg.TLSExpiryWarning)
|
|
assert.False(t, cfg.MaintenanceMode)
|
|
assert.Empty(t, cfg.SlackWebhook)
|
|
assert.Empty(t, cfg.MattermostWebhook)
|
|
assert.Empty(t, cfg.NtfyTopic)
|
|
assert.Empty(t, cfg.SentryDSN)
|
|
assert.Empty(t, cfg.MetricsUsername)
|
|
assert.Empty(t, cfg.MetricsPassword)
|
|
}
|
|
|
|
func TestNew_EnvironmentOverrides(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
t.Setenv("PORT", "9090")
|
|
t.Setenv("DNSWATCHER_DEBUG", "true")
|
|
t.Setenv("DNSWATCHER_DATA_DIR", "/tmp/test-data")
|
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "30m")
|
|
t.Setenv("DNSWATCHER_TLS_INTERVAL", "6h")
|
|
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "14")
|
|
t.Setenv("DNSWATCHER_SLACK_WEBHOOK", "https://hooks.slack.com/t")
|
|
t.Setenv("DNSWATCHER_MATTERMOST_WEBHOOK", "https://mm.test/hooks/t")
|
|
t.Setenv("DNSWATCHER_NTFY_TOPIC", "https://ntfy.sh/test")
|
|
t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1")
|
|
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "true")
|
|
t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin")
|
|
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 9090, cfg.Port)
|
|
assert.True(t, cfg.Debug)
|
|
assert.Equal(t, "/tmp/test-data", cfg.DataDir)
|
|
assert.Equal(t, 30*time.Minute, cfg.DNSInterval)
|
|
assert.Equal(t, 6*time.Hour, cfg.TLSInterval)
|
|
assert.Equal(t, 14, cfg.TLSExpiryWarning)
|
|
assert.Equal(t, "https://hooks.slack.com/t", cfg.SlackWebhook)
|
|
assert.Equal(t, "https://mm.test/hooks/t", cfg.MattermostWebhook)
|
|
assert.Equal(t, "https://ntfy.sh/test", cfg.NtfyTopic)
|
|
assert.Equal(t, "https://sentry.test/1", cfg.SentryDSN)
|
|
assert.True(t, cfg.MaintenanceMode)
|
|
assert.Equal(t, "admin", cfg.MetricsUsername)
|
|
assert.Equal(t, "secret", cfg.MetricsPassword)
|
|
}
|
|
|
|
func TestNew_NoTargetsError(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "")
|
|
|
|
_, err := config.New(nil, newTestParams(t))
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, config.ErrNoTargets)
|
|
}
|
|
|
|
func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", " , , ")
|
|
|
|
_, err := config.New(nil, newTestParams(t))
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, config.ErrNoTargets)
|
|
}
|
|
|
|
func TestNew_InvalidDNSInterval_FallsBackToDefault(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "banana")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, time.Hour, cfg.DNSInterval,
|
|
"invalid DNS interval should fall back to 1h default")
|
|
}
|
|
|
|
func TestNew_InvalidTLSInterval_FallsBackToDefault(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
t.Setenv("DNSWATCHER_TLS_INTERVAL", "notaduration")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 12*time.Hour, cfg.TLSInterval,
|
|
"invalid TLS interval should fall back to 12h default")
|
|
}
|
|
|
|
func TestNew_BothIntervalsInvalid(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "xyz")
|
|
t.Setenv("DNSWATCHER_TLS_INTERVAL", "abc")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
|
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
|
|
}
|
|
|
|
func TestNew_DebugEnablesDebugLogging(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
t.Setenv("DNSWATCHER_DEBUG", "true")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.True(t, cfg.Debug)
|
|
}
|
|
|
|
func TestNew_PortEnvNotPrefixed(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
t.Setenv("PORT", "3000")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 3000, cfg.Port,
|
|
"PORT env should work without DNSWATCHER_ prefix")
|
|
}
|
|
|
|
func TestNew_TargetClassification(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS",
|
|
"example.com,www.example.com,api.example.com,example.org")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
|
|
// example.com and example.org are apex domains
|
|
assert.Len(t, cfg.Domains, 2)
|
|
// www.example.com and api.example.com are hostnames
|
|
assert.Len(t, cfg.Hostnames, 2)
|
|
}
|
|
|
|
func TestNew_InvalidTargetPublicSuffix(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "co.uk")
|
|
|
|
_, err := config.New(nil, newTestParams(t))
|
|
require.Error(t, err, "public suffix should be rejected")
|
|
}
|
|
|
|
func TestNew_EmptyAppnameDefaultsToDnswatcher(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
|
|
g := &globals.Globals{Appname: "", Version: "test"}
|
|
|
|
l, err := logger.New(nil, logger.Params{Globals: g})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := config.New(
|
|
nil, config.Params{Globals: g, Logger: l},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 8080, cfg.Port,
|
|
"defaults should load when appname is empty")
|
|
}
|
|
|
|
func TestNew_TargetsWithWhitespace(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", " example.com , www.example.com ")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
|
|
"whitespace around targets should be trimmed")
|
|
}
|
|
|
|
func TestNew_TargetsWithTrailingComma(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com,")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
|
|
"trailing comma should be ignored")
|
|
}
|
|
|
|
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
|
|
viper.Reset()
|
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "5s")
|
|
|
|
cfg, err := config.New(nil, newTestParams(t))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 5*time.Second, cfg.DNSInterval)
|
|
}
|
|
|
|
func TestStatePath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
dataDir string
|
|
want string
|
|
}{
|
|
{"default", "./data", "./data/state.json"},
|
|
{"absolute", "/var/lib/dw", "/var/lib/dw/state.json"},
|
|
{"nested", "/opt/app/data", "/opt/app/data/state.json"},
|
|
{"empty", "", "/state.json"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{DataDir: tt.dataDir}
|
|
assert.Equal(t, tt.want, cfg.StatePath())
|
|
})
|
|
}
|
|
}
|