diff --git a/.env.example b/.env.example index c566d6e..db6bde8 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ SQLITE_PATH=/data/pulse.db RESEND_API_KEY= SLACK_WEBHOOK_URL= API_PORT=8080 +# Permit monitors to target private/internal addresses (homelab). Off by default. +PULSE_ALLOW_PRIVATE_MONITORS= +# Comma-separated CIDRs of reverse proxies whose X-Forwarded-For may be trusted +# for the login rate limiter. Set when running behind a proxy (e.g. Caddy/Docker) +# so per-client throttling works instead of collapsing all visitors into one +# bucket. Leave empty to ignore proxy headers entirely. +PULSE_TRUSTED_PROXIES= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7571d73..4d764d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,23 +7,62 @@ on: jobs: go: - name: Go (vet, fmt, test) + name: Go (vet, fmt, test, coverage) runs-on: ubuntu-latest + strategy: + matrix: + include: + # Thresholds exclude generated (sqlc) code. api and agent sit at 85: + # both have unreachable defensive branches (OS syscall / filesystem / + # process-bootstrap error paths) that can't be exercised in tests. + - module: api + threshold: 85 + - module: cli + threshold: 90 + - module: agent + threshold: 85 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.25.3" - name: go vet - run: cd api && go vet ./... + run: cd ${{ matrix.module }} && go vet ./... - name: gofmt run: | - unformatted="$(gofmt -l $(find api -name '*.go' -not -path '*/internal/generated/*'))" + unformatted="$(gofmt -l $(find ${{ matrix.module }} -name '*.go' -not -path '*/internal/generated/*'))" if [ -n "$unformatted" ]; then echo "These files are not gofmt-formatted:"; echo "$unformatted"; exit 1 fi - - name: go test - run: cd api && go test ./... -count=1 + - name: go test (race) + run: cd ${{ matrix.module }} && go test ./... -race -count=1 + - name: coverage gate (excluding generated) + run: bash scripts/coverage.sh ${{ matrix.module }} ${{ matrix.threshold }} + - name: upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.module }} + path: | + ${{ matrix.module }}/coverage.html + ${{ matrix.module }}/coverage-badge.json + if-no-files-found: ignore + + lint: + name: golangci-lint (advisory) + runs-on: ubuntu-latest + # Advisory for now: reports issues without blocking. Flip to blocking once + # the existing findings are cleared. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.3" + - uses: golangci/golangci-lint-action@v6 + with: + version: latest + working-directory: api ui: name: Admin UI build @@ -39,6 +78,13 @@ jobs: docker: name: Docker image builds runs-on: ubuntu-latest + strategy: + matrix: + include: + - file: api/Dockerfile + tag: pulse:ci + - file: agent/Dockerfile + tag: pulse-agent:ci steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 @@ -46,6 +92,6 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: api/Dockerfile + file: ${{ matrix.file }} push: false - tags: pulse:ci + tags: ${{ matrix.tag }} diff --git a/.gitignore b/.gitignore index bf28e6a..ba79fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ data/ # superpowers brainstorm .superpowers/ +.worktrees/ # Node ui/node_modules/ @@ -36,3 +37,9 @@ api/internal/web/dist/admin/* # Stale local build outputs (binary names vary by entrypoint) api/api api/pulse + +# Coverage artifacts (generated by scripts/coverage.sh) +coverage.out +coverage.nogen.out +coverage.html +coverage-badge.json diff --git a/Makefile b/Makefile index 9d37acd..af226c3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up down test sqlc lint ui build run +.PHONY: up down test cover cover-html sqlc lint ui build run ui: cd ui && NEXT_PUBLIC_API_URL="" npm ci && NEXT_PUBLIC_API_URL="" npm run build @@ -14,6 +14,19 @@ run: build test: cd api && go test ./... -count=1 +# Coverage gate (excludes internal/generated). Override module/threshold: +# make cover MODULE=agent THRESHOLD=90 +MODULE ?= api +THRESHOLD ?= 90 +cover: + bash scripts/coverage.sh $(MODULE) $(THRESHOLD) + +cover-html: + cd $(MODULE) && go test ./... -covermode=atomic -coverprofile=coverage.out >/dev/null && \ + grep -v internal/generated/ coverage.out > coverage.nogen.out && \ + go tool cover -html=coverage.nogen.out -o coverage.html && \ + echo "wrote $(MODULE)/coverage.html" + sqlc: cd api/internal/db && sqlc generate diff --git a/README.md b/README.md index 6da7d98..bcdb856 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,17 @@ # Pulse +[![CI](https://github.com/memetics19/pulse/actions/workflows/ci.yml/badge.svg)](https://github.com/memetics19/pulse/actions/workflows/ci.yml) +[![api coverage](https://img.shields.io/badge/api%20coverage-%E2%89%A585%25-green)](.github/workflows/ci.yml) +[![cli coverage](https://img.shields.io/badge/cli%20coverage-%E2%89%A590%25-brightgreen)](.github/workflows/ci.yml) +[![agent coverage](https://img.shields.io/badge/agent%20coverage-%E2%89%A585%25-green)](.github/workflows/ci.yml) +[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white)](go.work) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) + +> Coverage is enforced in CI per module (excluding generated code); the badges +> show the gate each module must clear. See the `go` job in +> [`.github/workflows/ci.yml`](.github/workflows/ci.yml) and +> [`scripts/coverage.sh`](scripts/coverage.sh). + Pulse is an open-source, self-hosted status page and monitoring tool. It ships as a single Go binary with a SQLite database. The monitoring worker runs in the same process, so there is no separate database server, Node.js runtime, or reverse proxy required to run it. Pulse checks your services, records uptime and latency, opens incidents when checks fail, and serves public status pages on your own domains. A live status page runs at [status.shreeda.xyz](https://status.shreeda.xyz). The full documentation is at [docs.shreeda.xyz](https://docs.shreeda.xyz). @@ -18,6 +30,65 @@ A live status page runs at [status.shreeda.xyz](https://status.shreeda.xyz). The - **Atom feed.** The public page exposes an Atom feed for incident updates. - **Local-timezone rendering.** All times render in the visitor's local timezone. +## Architecture + +Pulse runs as one Go binary with an in-process monitoring worker and a single +SQLite file. The optional `pulse-agent` pushes host metrics; everything else — +REST API, public status pages, and the embedded admin SPA — is served from the +same process. + +```mermaid +flowchart TB + subgraph binary["pulse (single Go binary)"] + direction TB + HTTP["chi HTTP server
REST API · public pages · embedded admin SPA"] + subgraph worker["in-process worker"] + SCHED["scheduler
1 goroutine per monitor"] + CHK["checkers
http · tcp · dns · ssl · ping"] + DET["incident detector"] + ALERT["alerter
email · slack"] + LOOP["rollup · pruner · maintenance"] + end + SCHED --> CHK + SCHED --> DET + DET --> ALERT + end + DB[("SQLite (WAL)")] + AGENT["pulse-agent
host metrics"] + USER["operator / API client"] + VISITOR["public visitor"] + + HTTP <--> DB + worker <--> DB + AGENT -- "POST /api/ingest/metrics" --> HTTP + USER -- "REST + session / API key" --> HTTP + VISITOR -- "status page (Host-routed)" --> HTTP + CHK -- "netguard-gated dials" --> TARGETS["monitored targets"] + ALERT --> CHANNELS["email · slack webhook"] +``` + +Each monitor runs on its own interval. One check flows through latency +thresholds, gets recorded, and — after two consecutive failures with no active +maintenance window — opens an incident and fires alerts: + +```mermaid +flowchart TD + START([interval tick]) --> RUN["checker.Check
(shared transport, netguard-gated dial)"] + RUN --> THRESH{"apply latency
thresholds"} + THRESH -->|"resp > down threshold"| DOWN[status = down] + THRESH -->|"resp > degraded threshold"| DEG[status = degraded] + THRESH -->|otherwise| UP[status = up] + DOWN --> WRITE + DEG --> WRITE + UP --> WRITE["INSERT check_results"] + WRITE --> DETECT{"2 consecutive
down?"} + DETECT -->|no| DONE([wait next tick]) + DETECT -->|"yes, no active incident,
not in maintenance"| INC["open incident"] + INC --> NOTIFY["alerter → email / slack"] + NOTIFY --> DONE + DETECT -->|"suppressed"| DONE +``` + ## Quick start (60 seconds) Pulse is a single static binary — no Docker or runtime dependencies. The diff --git a/agent/Dockerfile b/agent/Dockerfile index e4bed2d..e45d50d 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -1,26 +1,19 @@ -# Build context: pulse/ (workspace root) -FROM golang:1.25rc1-alpine AS builder -ENV GOTOOLCHAIN=auto -WORKDIR /workspace +# Build context: pulse/ (workspace root). +# The agent is a standalone Go module; build it with the workspace disabled +# (GOWORK=off) so it does not need the api/ or cli/ modules to be present. +FROM golang:1.25-alpine AS builder +ENV GOTOOLCHAIN=auto GOWORK=off CGO_ENABLED=0 +WORKDIR /src -# Copy workspace manifests first (cache layer) -COPY go.work go.work.sum ./ +# Dependency manifests first (cache layer). +COPY agent/go.mod agent/go.sum ./ +RUN go mod download -# Copy each module's dependency manifests for better layer caching -COPY agent/go.mod agent/go.sum ./agent/ -COPY api/go.mod api/go.sum ./api/ -COPY worker/go.mod worker/go.sum ./worker/ +# Full agent source. +COPY agent/ ./ -# Download deps (workspace-aware) -RUN go work sync && go mod download -modfile agent/go.mod - -# Copy full source -COPY agent/ ./agent/ -COPY api/ ./api/ - - -# Build the agent binary (CGO disabled → fully static) -RUN CGO_ENABLED=0 go build -o /pulse-agent ./agent/cmd/agent +# Build the static binary. +RUN go build -o /pulse-agent ./cmd/agent FROM alpine:3.19 RUN apk add --no-cache ca-certificates diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index c762e8b..fa0f39f 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -4,9 +4,11 @@ import ( "context" "flag" "fmt" + "io" "log" "os" "os/signal" + "strings" "syscall" "time" @@ -15,35 +17,56 @@ import ( ) func main() { - server := flag.String("server", "", "Pulse API base URL, e.g. https://status.example.com (required)") - token := flag.String("token", "", "Bearer token for ingest authentication (required)") - interval := flag.Int("interval", 30, "Push interval in seconds (default 30)") - flag.Parse() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + os.Exit(parseAndRun(ctx, os.Args[1:], os.Stderr)) +} - if *server == "" || *token == "" { - fmt.Fprintln(os.Stderr, "pulse-agent: --server and --token are required") - flag.Usage() - os.Exit(1) +// parseAndRun parses flags, resolves the token, validates, and runs the agent. +// It returns a process exit code so the flag/validation branches are testable +// without os.Exit. run blocks until ctx is cancelled. +func parseAndRun(ctx context.Context, args []string, stderr io.Writer) int { + fs := flag.NewFlagSet("pulse-agent", flag.ContinueOnError) + fs.SetOutput(stderr) + server := fs.String("server", "", "Pulse API base URL, e.g. https://status.example.com (required)") + token := fs.String("token", "", "Bearer token (INSECURE: visible in ps/proc; prefer PULSE_AGENT_TOKEN or --token-file)") + tokenFile := fs.String("token-file", "", "File to read the bearer token from") + interval := fs.Int("interval", 30, "Push interval in seconds (default 30)") + if err := fs.Parse(args); err != nil { + return 2 + } + + tok, err := resolveToken(*token, *tokenFile) + if err != nil { + fmt.Fprintln(stderr, "pulse-agent:", err) + return 1 + } + if *server == "" || tok == "" { + fmt.Fprintln(stderr, "pulse-agent: --server and a token (PULSE_AGENT_TOKEN, --token-file, or --token) are required") + return 1 } if *interval < 1 { - fmt.Fprintln(os.Stderr, "pulse-agent: --interval must be >= 1") - os.Exit(1) + fmt.Fprintln(stderr, "pulse-agent: --interval must be >= 1") + return 1 } - col := collector.New() - psh := pusher.New(*server, *token) + run(ctx, *server, tok, time.Duration(*interval)*time.Second) + return 0 +} - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() +// run pushes a metrics snapshot immediately, then on every interval, until ctx +// is cancelled. +func run(ctx context.Context, serverURL, token string, interval time.Duration) { + col := collector.New() + psh := pusher.New(serverURL, token) - log.Printf("pulse-agent starting: server=%s interval=%ds", *server, *interval) + log.Printf("pulse-agent starting: server=%s interval=%s", serverURL, interval) - // Push immediately on startup, then on each tick. if err := pushOnce(ctx, col, psh); err != nil { log.Printf("push error: %v", err) } - ticker := time.NewTicker(time.Duration(*interval) * time.Second) + ticker := time.NewTicker(interval) defer ticker.Stop() for { @@ -59,6 +82,24 @@ func main() { } } +// resolveToken picks the bearer token from, in order of preference: +// PULSE_AGENT_TOKEN env var, --token-file contents, then --token. The env var +// and file are preferred because a --token flag is visible to any local user +// via ps(1) and /proc//cmdline for the agent's whole lifetime. +func resolveToken(flagToken, tokenFile string) (string, error) { + if env := os.Getenv("PULSE_AGENT_TOKEN"); env != "" { + return strings.TrimSpace(env), nil + } + if tokenFile != "" { + b, err := os.ReadFile(tokenFile) + if err != nil { + return "", fmt.Errorf("reading --token-file: %w", err) + } + return strings.TrimSpace(string(b)), nil + } + return strings.TrimSpace(flagToken), nil +} + func pushOnce(ctx context.Context, col *collector.Collector, psh *pusher.Pusher) error { m, err := col.Snapshot() if err != nil { diff --git a/agent/cmd/agent/main_test.go b/agent/cmd/agent/main_test.go new file mode 100644 index 0000000..c5f0a73 --- /dev/null +++ b/agent/cmd/agent/main_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestResolveToken(t *testing.T) { + t.Run("env wins over file and flag", func(t *testing.T) { + t.Setenv("PULSE_AGENT_TOKEN", "env-token") + f := filepath.Join(t.TempDir(), "tok") + os.WriteFile(f, []byte("file-token\n"), 0o600) + got, err := resolveToken("flag-token", f) + if err != nil || got != "env-token" { + t.Fatalf("got %q, %v; want env-token", got, err) + } + }) + + t.Run("file wins over flag", func(t *testing.T) { + t.Setenv("PULSE_AGENT_TOKEN", "") + f := filepath.Join(t.TempDir(), "tok") + os.WriteFile(f, []byte(" file-token\n"), 0o600) + got, err := resolveToken("flag-token", f) + if err != nil || got != "file-token" { + t.Fatalf("got %q, %v; want file-token", got, err) + } + }) + + t.Run("flag fallback", func(t *testing.T) { + t.Setenv("PULSE_AGENT_TOKEN", "") + got, err := resolveToken("flag-token", "") + if err != nil || got != "flag-token" { + t.Fatalf("got %q, %v; want flag-token", got, err) + } + }) + + t.Run("missing file errors", func(t *testing.T) { + t.Setenv("PULSE_AGENT_TOKEN", "") + if _, err := resolveToken("", "/no/such/token/file"); err == nil { + t.Fatal("expected error for missing token file") + } + }) +} + +func TestRunPushesAndStops(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { run(ctx, srv.URL, "tok", 20*time.Millisecond); close(done) }() + + // The collector's CPU sample blocks ~500ms, so allow the immediate push to + // complete (and hit the server) before cancelling. + time.Sleep(700 * time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("run did not stop on cancel") + } + if atomic.LoadInt32(&hits) < 1 { + t.Fatal("expected at least one push") + } +} + +func TestParseAndRun(t *testing.T) { + t.Setenv("PULSE_AGENT_TOKEN", "") + // missing server/token -> 1 + if code := parseAndRun(context.Background(), []string{"--token", "t"}, io.Discard); code != 1 { + t.Errorf("missing server: code=%d want 1", code) + } + // bad interval -> 1 + if code := parseAndRun(context.Background(), []string{"--server", "http://x", "--token", "t", "--interval", "0"}, io.Discard); code != 1 { + t.Errorf("bad interval: code=%d want 1", code) + } + // bad flag -> 2 + if code := parseAndRun(context.Background(), []string{"--nope"}, io.Discard); code != 2 { + t.Errorf("bad flag: code=%d want 2", code) + } + // valid -> runs then returns 0 on cancel + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) + defer srv.Close() + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + if code := parseAndRun(ctx, []string{"--server", srv.URL, "--token", "t", "--interval", "1"}, io.Discard); code != 0 { + t.Errorf("valid run: code=%d want 0", code) + } +} diff --git a/agent/go.sum b/agent/go.sum index 97c49e9..9e72628 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -1,4 +1,5 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -7,6 +8,7 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= @@ -28,6 +30,7 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/agent/internal/collector/collector.go b/agent/internal/collector/collector.go index 7c2df8f..cddbc60 100644 --- a/agent/internal/collector/collector.go +++ b/agent/internal/collector/collector.go @@ -36,6 +36,10 @@ func New() *Collector { // CPU measurement blocks for 500 ms (it needs two samples to calculate %). // Network values are the delta (bytes since the previous Snapshot call). // On the very first call, net deltas are 0. +// Note: the `if err != nil` branches below guard OS syscall failures +// (gopsutil reading /proc, sysctl, etc.). They cannot be triggered from a unit +// test on a healthy host, so they are intentionally left uncovered — this is +// why the agent module's coverage gate is 85%, not 90%. func (c *Collector) Snapshot() (Metrics, error) { cpuPcts, err := cpu.Percent(500*time.Millisecond, false) if err != nil { diff --git a/agent/internal/pusher/pusher_test.go b/agent/internal/pusher/pusher_test.go index 7260056..6428c09 100644 --- a/agent/internal/pusher/pusher_test.go +++ b/agent/internal/pusher/pusher_test.go @@ -73,3 +73,10 @@ func TestPush_ReturnsErrorWhenServerUnreachable(t *testing.T) { err := p.Push(context.Background(), collector.Metrics{}) require.Error(t, err) } + +func TestPush_ErrorOnBadURL(t *testing.T) { + p := pusher.New("http://[::1]:namedport", "tok") // invalid port -> NewRequest fails + if err := p.Push(context.Background(), collector.Metrics{}); err == nil { + t.Fatal("expected error for malformed server URL") + } +} diff --git a/api/cmd/pulse/main.go b/api/cmd/pulse/main.go index fd3e6e7..0d16587 100644 --- a/api/cmd/pulse/main.go +++ b/api/cmd/pulse/main.go @@ -45,46 +45,73 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - // Run the worker once the app is configured (now, or after setup completes). - go func() { - for { - if a.Configured() { - if err := worker.Run(ctx, a.DB(), cfg); err != nil { - log.Printf("worker stopped: %v", err) - } - return - } - select { - case <-ctx.Done(): - return - case <-time.After(2 * time.Second): - } - } - }() + if err := serve(ctx, a, dataDir, cfg); err != nil { + log.Fatal(err) + } +} + +// serve runs the monitoring worker and the HTTP server until ctx is cancelled, +// then gracefully shuts the server down. It returns a non-nil error only if the +// server fails to start. +func serve(ctx context.Context, a *app.App, dataDir string, cfg config.Config) error { + go runWorker(ctx, a, cfg) - srv := server.New(a, dataDir, cfg) httpSrv := &http.Server{ Addr: ":" + cfg.Port, - Handler: srv, + Handler: server.New(a, dataDir, cfg), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } + errc := make(chan error, 1) go func() { log.Printf("pulse listening on :%s", cfg.Port) if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal(err) + errc <- err } }() - <-ctx.Done() + select { + case err := <-errc: + return err + case <-ctx.Done(): + } log.Println("pulse shutting down") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() - if err := httpSrv.Shutdown(shutdownCtx); err != nil { - log.Printf("shutdown: %v", err) + return httpSrv.Shutdown(shutdownCtx) +} + +// runWorker runs the worker once the app is configured (now, or after setup +// completes). If worker.Run returns an error (e.g. a transient DB error at +// startup), it retries with backoff instead of leaving monitoring permanently +// dead while the process still looks healthy — /healthz reflects the worker's +// liveness. It returns when ctx is cancelled. +func runWorker(ctx context.Context, a *app.App, cfg config.Config) { + backoff := time.Second + for { + if a.Configured() { + if err := worker.Run(ctx, a.DB(), cfg, a.MarkWorkerAlive); err != nil { + log.Printf("worker stopped: %v; retrying in %s", err, backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + continue + } + return // clean shutdown (ctx cancelled) + } + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): + } } } diff --git a/api/cmd/pulse/main_test.go b/api/cmd/pulse/main_test.go index 908a10f..5cebc81 100644 --- a/api/cmd/pulse/main_test.go +++ b/api/cmd/pulse/main_test.go @@ -15,6 +15,7 @@ func TestServerServesHealthz(t *testing.T) { db := testutil.NewTestDB(t) a := app.New() a.SetDB(db) + a.MarkWorkerAlive() // simulate a live worker h := server.New(a, t.TempDir(), config.Config{}) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rec := httptest.NewRecorder() @@ -23,3 +24,18 @@ func TestServerServesHealthz(t *testing.T) { t.Fatalf("healthz = %d, want 200", rec.Code) } } + +// A configured app whose worker has never beaten (or has gone stale) must fail +// the health check so orchestration restarts a monitoring-dead container. +func TestHealthzUnhealthyWhenWorkerDead(t *testing.T) { + db := testutil.NewTestDB(t) + a := app.New() + a.SetDB(db) // configured, but MarkWorkerAlive never called + h := server.New(a, t.TempDir(), config.Config{}) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("healthz = %d, want 503", rec.Code) + } +} diff --git a/api/cmd/pulse/resetpw_test.go b/api/cmd/pulse/resetpw_test.go new file mode 100644 index 0000000..f896498 --- /dev/null +++ b/api/cmd/pulse/resetpw_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "testing" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/db" + "github.com/memetics19/pulse/api/internal/generated" +) + +func TestRunResetPasswordHappyPath(t *testing.T) { + dir := t.TempDir() + path := dir + "/pulse.db" + t.Setenv("PULSE_DATA_DIR", dir) + t.Setenv("SQLITE_PATH", path) + + // Create the DB (runs migrations) and an admin user to reset. + conn, err := db.Open(path) + if err != nil { + t.Fatal(err) + } + q := generated.New(conn) + hash, _ := auth.HashPassword("original-pass") + if _, err := q.CreateUser(context.Background(), generated.CreateUserParams{Username: "admin", PasswordHash: hash}); err != nil { + t.Fatal(err) + } + conn.Close() + + // Reset via the CLI entry point. + runResetPassword([]string{"--username", "admin", "--password", "brand-new-pass"}) + + // Verify the new password now validates. + conn2, err := db.Open(path) + if err != nil { + t.Fatal(err) + } + defer conn2.Close() + u, err := generated.New(conn2).GetUserByUsername(context.Background(), "admin") + if err != nil { + t.Fatal(err) + } + ok, err := auth.VerifyPassword("brand-new-pass", u.PasswordHash) + if err != nil || !ok { + t.Fatalf("new password should validate: ok=%v err=%v", ok, err) + } +} diff --git a/api/cmd/pulse/serve_test.go b/api/cmd/pulse/serve_test.go new file mode 100644 index 0000000..418872d --- /dev/null +++ b/api/cmd/pulse/serve_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/app" + "github.com/memetics19/pulse/api/internal/config" + "github.com/memetics19/pulse/api/testutil" +) + +func freePort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + _, port, _ := net.SplitHostPort(l.Addr().String()) + return port +} + +func TestDataDir(t *testing.T) { + t.Setenv("PULSE_DATA_DIR", "/custom") + if got := dataDir(); got != "/custom" { + t.Fatalf("dataDir=%q want /custom", got) + } + t.Setenv("PULSE_DATA_DIR", "") + t.Setenv("SQLITE_PATH", "/var/lib/pulse/pulse.db") + if got := dataDir(); got != "/var/lib/pulse" { + t.Fatalf("dataDir=%q want /var/lib/pulse", got) + } + t.Setenv("SQLITE_PATH", "") + if got := dataDir(); got != "/data" { + t.Fatalf("dataDir=%q want /data", got) + } +} + +func TestRunWorkerStopsOnCancel(t *testing.T) { + // Unconfigured app: runWorker polls, then returns on cancel. + a := app.New() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { runWorker(ctx, a, config.Config{}); close(done) }() + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("runWorker (unconfigured) did not return on cancel") + } + + // Configured app: worker.Run actually runs, then returns on cancel. + a2 := app.New() + a2.SetDB(testutil.NewTestDB(t)) + ctx2, cancel2 := context.WithCancel(context.Background()) + done2 := make(chan struct{}) + go func() { runWorker(ctx2, a2, config.Config{}); close(done2) }() + time.Sleep(50 * time.Millisecond) + cancel2() + select { + case <-done2: + case <-time.After(2 * time.Second): + t.Fatal("runWorker (configured) did not return on cancel") + } +} + +func TestServeStartsAndShutsDown(t *testing.T) { + a := app.New() + a.SetDB(testutil.NewTestDB(t)) + a.MarkWorkerAlive() + cfg := config.Config{Port: freePort(t)} + + ctx, cancel := context.WithCancel(context.Background()) + errc := make(chan error, 1) + go func() { errc <- serve(ctx, a, t.TempDir(), cfg) }() + + // Wait for the server to accept connections, then hit /healthz. + url := "http://127.0.0.1:" + cfg.Port + "/healthz" + var resp *http.Response + var err error + for i := 0; i < 50; i++ { + resp, err = http.Get(url) + if err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if err != nil { + t.Fatalf("server never came up: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("/healthz = %d", resp.StatusCode) + } + + cancel() + select { + case err := <-errc: + if err != nil { + t.Fatalf("serve returned error: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("serve did not shut down") + } +} diff --git a/api/internal/app/app.go b/api/internal/app/app.go index 7e8df16..ff253e2 100644 --- a/api/internal/app/app.go +++ b/api/internal/app/app.go @@ -3,6 +3,8 @@ package app import ( "database/sql" "sync" + "sync/atomic" + "time" "github.com/memetics19/pulse/api/internal/generated" ) @@ -13,10 +15,27 @@ type App struct { mu sync.RWMutex db *sql.DB q *generated.Queries + + // workerBeat is the unix-nano timestamp of the worker's last successful + // reconcile. /healthz uses it to report whether monitoring is actually + // alive, so a silently-dead worker fails the health check instead of the + // process looking healthy while nothing is being monitored. + workerBeat atomic.Int64 } func New() *App { return &App{} } +// MarkWorkerAlive records that the worker reconcile loop just ran successfully. +func (a *App) MarkWorkerAlive() { a.workerBeat.Store(time.Now().UnixNano()) } + +// WorkerHealthy reports whether the worker beat within maxAge. It is false +// before the worker's first beat (unconfigured or not yet started); callers +// that must tolerate the setup phase should check Configured() first. +func (a *App) WorkerHealthy(maxAge time.Duration) bool { + last := a.workerBeat.Load() + return last != 0 && time.Since(time.Unix(0, last)) < maxAge +} + // SetDB installs an open, migrated database and marks the app configured. func (a *App) SetDB(db *sql.DB) { a.mu.Lock() diff --git a/api/internal/app/app_more_test.go b/api/internal/app/app_more_test.go new file mode 100644 index 0000000..19a4c65 --- /dev/null +++ b/api/internal/app/app_more_test.go @@ -0,0 +1,45 @@ +package app_test + +import ( + "context" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/app" + "github.com/memetics19/pulse/api/testutil" +) + +func TestWorkerLiveness(t *testing.T) { + a := app.New() + if a.WorkerHealthy(time.Minute) { + t.Fatal("no beat yet -> should be unhealthy") + } + a.MarkWorkerAlive() + if !a.WorkerHealthy(time.Minute) { + t.Fatal("should be healthy right after a beat") + } + if a.WorkerHealthy(0) { + t.Fatal("zero maxAge -> nothing is recent enough") + } +} + +func TestLiveDBTXForwards(t *testing.T) { + a := app.New() + a.SetDB(testutil.NewTestDB(t)) + tx := app.LiveDBTX(a) + ctx := context.Background() + + if _, err := tx.ExecContext(ctx, "CREATE TABLE t (x INTEGER)"); err != nil { + t.Fatalf("ExecContext: %v", err) + } + if _, err := tx.QueryContext(ctx, "SELECT x FROM t"); err != nil { + t.Fatalf("QueryContext: %v", err) + } + var n int + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM t").Scan(&n); err != nil { + t.Fatalf("QueryRowContext: %v", err) + } + if _, err := tx.PrepareContext(ctx, "SELECT 1"); err != nil { + t.Fatalf("PrepareContext: %v", err) + } +} diff --git a/api/internal/auth/more_test.go b/api/internal/auth/more_test.go new file mode 100644 index 0000000..bdb3399 --- /dev/null +++ b/api/internal/auth/more_test.go @@ -0,0 +1,21 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestSessionAndTOTPHelpers(t *testing.T) { + tok, err := NewSessionToken() + if err != nil || len(tok) < 20 { + t.Fatalf("NewSessionToken: %q %v", tok, err) + } + secret, uri, err := GenerateTOTP("admin@example.com") + if err != nil || secret == "" || !strings.HasPrefix(uri, "otpauth://") { + t.Fatalf("GenerateTOTP: %q %q %v", secret, uri, err) + } + dataURL, err := TOTPQRDataURL(uri) + if err != nil || !strings.HasPrefix(dataURL, "data:image/png;base64,") { + t.Fatalf("TOTPQRDataURL: %v", err) + } +} diff --git a/api/internal/config/config.go b/api/internal/config/config.go index beb97cd..d3e7beb 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -1,6 +1,8 @@ package config import ( + "log" + "net" "os" "strings" ) @@ -17,6 +19,11 @@ type Config struct { // addresses (loopback, LAN, link-local). Required for homelab setups // that monitor LAN services; off by default to prevent SSRF. AllowPrivateMonitors bool + // TrustedProxies are CIDRs of reverse proxies whose X-Forwarded-For header + // may be trusted for the login rate limiter. Empty (default) means proxy + // headers are ignored so they cannot be spoofed. Set when Pulse runs behind + // a known proxy (e.g. the bundled Caddy) so per-client limiting still works. + TrustedProxies []*net.IPNet } // envList splits the named environment variable on commas, trimming spaces @@ -55,5 +62,30 @@ func Load() Config { SecureCookies: envBool("PULSE_SECURE_COOKIES"), CORSOrigins: envList("PULSE_CORS_ORIGINS"), AllowPrivateMonitors: envBool("PULSE_ALLOW_PRIVATE_MONITORS"), + TrustedProxies: parseCIDRs(envList("PULSE_TRUSTED_PROXIES")), } } + +// parseCIDRs converts CIDR strings (or bare IPs) into networks, skipping and +// logging any that don't parse rather than failing startup. +func parseCIDRs(entries []string) []*net.IPNet { + var nets []*net.IPNet + for _, e := range entries { + if !strings.Contains(e, "/") { + if ip := net.ParseIP(e); ip != nil { + if ip.To4() != nil { + e += "/32" + } else { + e += "/128" + } + } + } + _, n, err := net.ParseCIDR(e) + if err != nil { + log.Printf("config: ignoring invalid PULSE_TRUSTED_PROXIES entry %q: %v", e, err) + continue + } + nets = append(nets, n) + } + return nets +} diff --git a/api/internal/config/config_test.go b/api/internal/config/config_test.go new file mode 100644 index 0000000..4d0b86c --- /dev/null +++ b/api/internal/config/config_test.go @@ -0,0 +1,75 @@ +package config + +import ( + "net" + "testing" +) + +func TestEnvBool(t *testing.T) { + cases := map[string]bool{"1": true, "true": true, "TRUE": true, "yes": true, "YeS": true, + "0": false, "false": false, "no": false, "": false, "nope": false} + for v, want := range cases { + t.Setenv("X_BOOL", v) + if got := envBool("X_BOOL"); got != want { + t.Errorf("envBool(%q)=%v want %v", v, got, want) + } + } +} + +func TestEnvList(t *testing.T) { + t.Setenv("X_LIST", " a , b ,,c, ") + got := envList("X_LIST") + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("envList=%v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("envList[%d]=%q want %q", i, got[i], want[i]) + } + } + t.Setenv("X_LIST", "") + if got := envList("X_LIST"); len(got) != 0 { + t.Errorf("empty envList should be nil/empty, got %v", got) + } +} + +func TestParseCIDRs(t *testing.T) { + nets := parseCIDRs([]string{"10.0.0.0/8", "192.168.1.5", "::1", "not-a-cidr", "8.8.8.8/33"}) + // valid: /8, bare IPv4 -> /32, bare IPv6 -> /128. invalid two are skipped. + if len(nets) != 3 { + t.Fatalf("parseCIDRs kept %d nets, want 3: %v", len(nets), nets) + } + if !nets[0].Contains(mustIP(t, "10.9.9.9")) { + t.Error("10.0.0.0/8 should contain 10.9.9.9") + } + if nets[1].Contains(mustIP(t, "192.168.1.6")) { + t.Error("bare 192.168.1.5 should be a /32, not contain .6") + } +} + +func TestLoadDefaultsAndEnv(t *testing.T) { + t.Setenv("API_PORT", "") + if c := Load(); c.Port != "8080" { + t.Errorf("default port = %q, want 8080", c.Port) + } + t.Setenv("API_PORT", "9999") + t.Setenv("SQLITE_PATH", "/tmp/x.db") + t.Setenv("PULSE_ALLOW_PRIVATE_MONITORS", "true") + t.Setenv("PULSE_CORS_ORIGINS", "https://a.com,https://b.com") + t.Setenv("PULSE_TRUSTED_PROXIES", "10.0.0.0/8") + c := Load() + if c.Port != "9999" || c.SQLitePath != "/tmp/x.db" || !c.AllowPrivateMonitors || + len(c.CORSOrigins) != 2 || len(c.TrustedProxies) != 1 { + t.Fatalf("Load did not populate config from env: %+v", c) + } +} + +func mustIP(t *testing.T, s string) net.IP { + t.Helper() + ip := net.ParseIP(s) + if ip == nil { + t.Fatalf("bad test IP %q", s) + } + return ip +} diff --git a/api/internal/db/db.go b/api/internal/db/db.go index 3f088ce..d46754d 100644 --- a/api/internal/db/db.go +++ b/api/internal/db/db.go @@ -16,11 +16,23 @@ import ( var migrations embed.FS func Open(sqlitePath string) (*sql.DB, error) { - conn, err := sql.Open("sqlite", sqlitePath+"?_journal_mode=WAL&_foreign_keys=on") + // modernc.org/sqlite uses the _pragma=name(value) DSN syntax (not the + // mattn-style _journal_mode=WAL). WAL lets readers run concurrently with the + // single writer; busy_timeout makes a contending connection wait rather than + // fail with "database is locked". foreign_keys is per-connection, so it must + // be in the DSN to apply to every pooled connection. + dsn := "file:" + sqlitePath + + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" + conn, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } - conn.SetMaxOpenConns(1) + // With WAL + busy_timeout, multiple connections are safe: readers (status + // page, API GETs) no longer serialize behind the writer as they did under the + // old single-connection pool. SQLite still allows only one writer at a time, + // which busy_timeout serializes safely. + conn.SetMaxOpenConns(8) + conn.SetMaxIdleConns(8) if err := runMigrations(conn); err != nil { conn.Close() return nil, err diff --git a/api/internal/db/db_test.go b/api/internal/db/db_test.go index 48a3a2e..9f6d6b4 100644 --- a/api/internal/db/db_test.go +++ b/api/internal/db/db_test.go @@ -96,3 +96,27 @@ func TestLegacyAgentTokensHashedOnOpen(t *testing.T) { require.Equal(t, keyauth.Hash(plaintext), stored) require.Len(t, stored, 64) } + +func TestOpenAppliesWALAndForeignKeys(t *testing.T) { + conn, err := db.Open(t.TempDir() + "/pragmas.db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + var journal string + if err := conn.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil { + t.Fatalf("read journal_mode: %v", err) + } + if journal != "wal" { + t.Fatalf("journal_mode = %q, want wal", journal) + } + + var fk int + if err := conn.QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil { + t.Fatalf("read foreign_keys: %v", err) + } + if fk != 1 { + t.Fatalf("foreign_keys = %d, want 1", fk) + } +} diff --git a/api/internal/db/legacy_test.go b/api/internal/db/legacy_test.go new file mode 100644 index 0000000..a03cc04 --- /dev/null +++ b/api/internal/db/legacy_test.go @@ -0,0 +1,37 @@ +package db_test + +import ( + "strings" + "testing" + + "github.com/memetics19/pulse/api/internal/db" + "github.com/memetics19/pulse/api/internal/keyauth" +) + +func TestHashLegacyAgentTokensOnReopen(t *testing.T) { + path := t.TempDir() + "/legacy.db" + conn, err := db.Open(path) + if err != nil { + t.Fatal(err) + } + // Insert an agent whose token_hash is a 48-char legacy plaintext token. + plaintext := strings.Repeat("a", 48) + if _, err := conn.Exec(`INSERT INTO infra_agents (name, host_label, token_hash) VALUES ('h','web',?)`, plaintext); err != nil { + t.Fatal(err) + } + conn.Close() + + // Reopen: hashLegacyAgentTokens should rewrite it to a sha256 hex hash. + conn2, err := db.Open(path) + if err != nil { + t.Fatal(err) + } + defer conn2.Close() + var stored string + if err := conn2.QueryRow(`SELECT token_hash FROM infra_agents LIMIT 1`).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != keyauth.Hash(plaintext) { + t.Fatalf("legacy token not hashed on reopen: got %q", stored) + } +} diff --git a/api/internal/db/queries/check_results.sql b/api/internal/db/queries/check_results.sql index ac01498..0a19f65 100644 --- a/api/internal/db/queries/check_results.sql +++ b/api/internal/db/queries/check_results.sql @@ -12,8 +12,11 @@ LIMIT ?; SELECT * FROM check_results WHERE monitor_id = ? ORDER BY checked_at DESC LIMIT 1; -- name: UptimePercent :one +-- COUNT(*)=0 (no checks in range) would divide by zero → NULL; COALESCE keeps +-- the result a non-null REAL (100.0 = "no failures observed") so it scans into +-- float64 rather than erroring. NULLIF avoids the divide-by-zero itself. SELECT - CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime_pct + COALESCE(CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / NULLIF(COUNT(*), 0) * 100, 100.0) as uptime_pct FROM check_results WHERE monitor_id = ? AND checked_at >= ?; diff --git a/api/internal/generated/check_results.sql.go b/api/internal/generated/check_results.sql.go index 8b18d11..b8ffc88 100644 --- a/api/internal/generated/check_results.sql.go +++ b/api/internal/generated/check_results.sql.go @@ -154,8 +154,11 @@ func (q *Queries) PruneCheckResults(ctx context.Context, checkedAt time.Time) er } const uptimePercent = `-- name: UptimePercent :one +-- COUNT(*)=0 (no checks in range) would divide by zero → NULL; COALESCE keeps +-- the result a non-null REAL (100.0 = "no failures observed") so it scans into +-- float64 rather than erroring. NULLIF avoids the divide-by-zero itself. SELECT - CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime_pct + COALESCE(CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / NULLIF(COUNT(*), 0) * 100, 100.0) as uptime_pct FROM check_results WHERE monitor_id = ? AND checked_at >= ? ` @@ -165,9 +168,9 @@ type UptimePercentParams struct { CheckedAt time.Time `json:"checked_at"` } -func (q *Queries) UptimePercent(ctx context.Context, arg UptimePercentParams) (int64, error) { +func (q *Queries) UptimePercent(ctx context.Context, arg UptimePercentParams) (float64, error) { row := q.db.QueryRowContext(ctx, uptimePercent, arg.MonitorID, arg.CheckedAt) - var uptime_pct int64 + var uptime_pct float64 err := row.Scan(&uptime_pct) return uptime_pct, err } diff --git a/api/internal/handlers/auth.go b/api/internal/handlers/auth.go index cd1f13f..17dac15 100644 --- a/api/internal/handlers/auth.go +++ b/api/internal/handlers/auth.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "net" "net/http" "sync" "time" @@ -11,13 +12,14 @@ import ( ) type Auth struct { - q *generated.Queries - secure bool - limiter *loginLimiter + q *generated.Queries + secure bool + limiter *loginLimiter + trustedProxies []*net.IPNet } -func NewAuth(q *generated.Queries, secure bool) *Auth { - return &Auth{q: q, secure: secure, limiter: newLoginLimiter()} +func NewAuth(q *generated.Queries, secure bool, trustedProxies ...*net.IPNet) *Auth { + return &Auth{q: q, secure: secure, limiter: newLoginLimiter(), trustedProxies: trustedProxies} } // dummyPasswordHash is verified against when the username does not exist, so @@ -111,7 +113,7 @@ func (a *Auth) Setup(w http.ResponseWriter, r *http.Request) { } func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { - if !a.limiter.allow(clientIP(r)) { + if !a.limiter.allow(clientIP(r, a.trustedProxies)) { http.Error(w, "too many attempts", http.StatusTooManyRequests) return } diff --git a/api/internal/handlers/auth_more_test.go b/api/internal/handlers/auth_more_test.go new file mode 100644 index 0000000..2f29491 --- /dev/null +++ b/api/internal/handlers/auth_more_test.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/testutil" +) + +// setupAdmin creates the admin account and returns the session cookie. +func setupAdmin(t *testing.T, h *Auth) *http.Cookie { + t.Helper() + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "s3cret-pass"}) + rec := httptest.NewRecorder() + h.Setup(rec, httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewReader(body))) + if rec.Code != http.StatusCreated { + t.Fatalf("setup = %d", rec.Code) + } + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + return c + } + } + t.Fatal("no session cookie from setup") + return nil +} + +func TestTwoFAErrorBranches(t *testing.T) { + q := generated.New(testutil.NewTestDB(t)) + h := NewAuth(q, false) + cookie := setupAdmin(t, h) + + req := func(withCookie bool, body string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader([]byte(body))) + if withCookie { + r.AddCookie(cookie) + } + return r + } + code := func(hf func(http.ResponseWriter, *http.Request), r *http.Request) int { + rec := httptest.NewRecorder() + hf(rec, r) + return rec.Code + } + + // Unauthenticated -> 401 + if c := code(h.TwoFASetup, req(false, "")); c != http.StatusUnauthorized { + t.Fatalf("TwoFASetup no session = %d, want 401", c) + } + if c := code(h.TwoFAEnable, req(false, `{}`)); c != http.StatusUnauthorized { + t.Fatalf("TwoFAEnable no session = %d, want 401", c) + } + // Enable before setup -> "run setup first" 400 + if c := code(h.TwoFAEnable, req(true, `{"code":"000000"}`)); c != http.StatusBadRequest { + t.Fatalf("TwoFAEnable before setup = %d, want 400", c) + } + // Setup stores a pending secret -> 200 + if c := code(h.TwoFASetup, req(true, "")); c != http.StatusOK { + t.Fatalf("TwoFASetup = %d, want 200", c) + } + // Enable with a wrong code -> 400 + if c := code(h.TwoFAEnable, req(true, `{"code":"000000"}`)); c != http.StatusBadRequest { + t.Fatalf("TwoFAEnable wrong code = %d, want 400", c) + } +} + +func TestAuthSessionMethods(t *testing.T) { + q := generated.New(testutil.NewTestDB(t)) + h := NewAuth(q, false) + cookie := setupAdmin(t, h) + + withCookie := func(method, path string) *http.Request { + r := httptest.NewRequest(method, path, nil) + r.AddCookie(cookie) + return r + } + + // Status with a valid session -> authenticated + rec := httptest.NewRecorder() + h.Status(rec, withCookie(http.MethodGet, "/api/auth/status")) + var st struct { + Authenticated bool `json:"authenticated"` + Username string + } + json.NewDecoder(rec.Body).Decode(&st) + if !st.Authenticated || st.Username != "admin" { + t.Fatalf("authenticated status wrong: %+v", st) + } + + // TwoFADisable without a session -> 401 + rec = httptest.NewRecorder() + h.TwoFADisable(rec, httptest.NewRequest(http.MethodPost, "/x", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("2FA disable unauthenticated = %d, want 401", rec.Code) + } + + // TwoFADisable with a session -> 200 + rec = httptest.NewRecorder() + h.TwoFADisable(rec, withCookie(http.MethodPost, "/x")) + if rec.Code != http.StatusOK { + t.Fatalf("2FA disable = %d, want 200", rec.Code) + } + + // Logout clears the session + rec = httptest.NewRecorder() + h.Logout(rec, withCookie(http.MethodPost, "/api/auth/logout")) + if rec.Code != http.StatusOK { + t.Fatalf("logout = %d, want 200", rec.Code) + } + + // Login with malformed body -> 400 + rec = httptest.NewRecorder() + h.Login(rec, httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader([]byte("not json")))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("bad login body = %d, want 400", rec.Code) + } +} diff --git a/api/internal/handlers/crud_all_test.go b/api/internal/handlers/crud_all_test.go new file mode 100644 index 0000000..25e2d57 --- /dev/null +++ b/api/internal/handlers/crud_all_test.go @@ -0,0 +1,123 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGroupsUpdateDelete(t *testing.T) { + q := newQ(t) + h := handlers.NewGroups(q) + assert.Equal(t, http.StatusOK, do(h.List, "GET", "/x", nil).Code) + + cr := do(h.Create, "POST", "/x", map[string]any{"name": "prod", "display_order": 1}) + require.Equal(t, http.StatusCreated, cr.Code) + var g generated.MonitorGroup + require.NoError(t, json.NewDecoder(cr.Body).Decode(&g)) + + assert.Equal(t, http.StatusOK, do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", itoa(g.ID))) + }, "PUT", "/x", map[string]any{"name": "prod2", "display_order": 2}).Code) + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", itoa(g.ID))) + }, "DELETE", "/x", nil).Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", "bad")) + }, "DELETE", "/x", nil).Code) +} + +func TestMonitorsGetUpdateDelete(t *testing.T) { + q := newQ(t) + ctx := context.Background() + mon, err := q.CreateMonitor(ctx, generated.CreateMonitorParams{ + Name: "m", Url: "http://example.com", Type: "http", IntervalSeconds: 60, + TimeoutSeconds: 10, DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", + }) + require.NoError(t, err) + h := handlers.NewMonitors(q, true) + id := itoa(mon.ID) + + assert.Equal(t, http.StatusOK, do(func(w http.ResponseWriter, r *http.Request) { + h.Get(w, withChiID(r, "id", id)) + }, "GET", "/x", nil).Code) + assert.Equal(t, http.StatusNotFound, do(func(w http.ResponseWriter, r *http.Request) { + h.Get(w, withChiID(r, "id", "9999")) + }, "GET", "/x", nil).Code) + assert.Equal(t, http.StatusOK, do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", id)) + }, "PUT", "/x", map[string]any{"name": "m2", "url": "http://example.org", "type": "http", "interval_seconds": 30}).Code) + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", id)) + }, "DELETE", "/x", nil).Code) +} + +func TestAgentsListDeleteMetrics(t *testing.T) { + q := newQ(t) + ah := handlers.NewAgents(q) + id, _ := createAgent(t, ah) + + assert.Equal(t, http.StatusOK, do(ah.List, "GET", "/x", nil).Code) + assert.Equal(t, http.StatusOK, do(func(w http.ResponseWriter, r *http.Request) { + ah.GetMetrics(w, withChiID(r, "agentID", itoa(id))) + }, "GET", "/x?days=2", nil).Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + ah.GetMetrics(w, withChiID(r, "agentID", "bad")) + }, "GET", "/x", nil).Code) + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + ah.Delete(w, withChiID(r, "id", itoa(id))) + }, "DELETE", "/x", nil).Code) +} + +func TestIncidentsListCreateUpdateDelete(t *testing.T) { + q := newQ(t) + h := handlers.NewIncidents(q) + assert.Equal(t, http.StatusOK, do(h.List, "GET", "/x", nil).Code) + + cr := do(h.Create, "POST", "/x", map[string]any{ + "title": "outage", "severity": "major", "affected_monitor_ids": []int64{1, 2}, + }) + require.Equal(t, http.StatusCreated, cr.Code) + var inc generated.Incident + require.NoError(t, json.NewDecoder(cr.Body).Decode(&inc)) + id := itoa(inc.ID) + + assert.Equal(t, http.StatusOK, do(func(w http.ResponseWriter, r *http.Request) { + h.UpdateStatus(w, withChiID(r, "id", id)) + }, "PUT", "/x", map[string]any{"status": "investigating"}).Code) + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", id)) + }, "DELETE", "/x", nil).Code) +} + +func TestMaintenanceListCreateUpdateDelete(t *testing.T) { + q := newQ(t) + h := handlers.NewMaintenance(q) + assert.Equal(t, http.StatusOK, do(h.List, "GET", "/x", nil).Code) + + now := time.Now() + cr := do(h.Create, "POST", "/x", map[string]any{ + "title": "db upgrade", "status": "scheduled", "affected_monitor_ids": []int64{1}, + "starts_at": now.Format(time.RFC3339), "ends_at": now.Add(time.Hour).Format(time.RFC3339), + }) + require.Equal(t, http.StatusCreated, cr.Code) + var mw struct { + ID int64 `json:"id"` + } + require.NoError(t, json.NewDecoder(cr.Body).Decode(&mw)) + id := itoa(mw.ID) + + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + h.UpdateStatus(w, withChiID(r, "id", id)) + }, "PUT", "/x", map[string]any{"status": "in_progress"}).Code) + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", id)) + }, "DELETE", "/x", nil).Code) +} diff --git a/api/internal/handlers/crud_more_test.go b/api/internal/handlers/crud_more_test.go new file mode 100644 index 0000000..c3a1fcd --- /dev/null +++ b/api/internal/handlers/crud_more_test.go @@ -0,0 +1,154 @@ +package handlers_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/memetics19/pulse/api/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func itoa(n int64) string { return strconv.FormatInt(n, 10) } + +func newQ(t *testing.T) *generated.Queries { + t.Helper() + return generated.New(testutil.NewTestDB(t)) +} + +func do(h http.HandlerFunc, method, target string, body any) *httptest.ResponseRecorder { + var r *http.Request + if body != nil { + b, _ := json.Marshal(body) + r = httptest.NewRequest(method, target, bytes.NewReader(b)) + } else { + r = httptest.NewRequest(method, target, nil) + } + rr := httptest.NewRecorder() + h(rr, r) + return rr +} + +func TestNotificationsCRUD(t *testing.T) { + q := newQ(t) + h := handlers.NewNotifications(q) + + assert.Equal(t, http.StatusOK, do(h.List, "GET", "/api/notifications", nil).Code) + + rr := do(h.Create, "POST", "/api/notifications", + map[string]any{"channel": "slack", "config_json": `{"webhook_url":"https://x"}`}) + require.Equal(t, http.StatusCreated, rr.Code) + var created generated.Notification + require.NoError(t, json.NewDecoder(rr.Body).Decode(&created)) + + assert.Equal(t, http.StatusBadRequest, do(h.Create, "POST", "/x", "not json").Code) + + // Update + upd := do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", itoa(created.ID))) + }, "PUT", "/x", map[string]any{"channel": "email", "config_json": "{}"}) + assert.Equal(t, http.StatusOK, upd.Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", "abc")) + }, "PUT", "/x", map[string]any{}).Code) + + // Delete + del := do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", itoa(created.ID))) + }, "DELETE", "/x", nil) + assert.Equal(t, http.StatusNoContent, del.Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", "abc")) + }, "DELETE", "/x", nil).Code) +} + +func TestThemeGetUpdate(t *testing.T) { + q := newQ(t) + h := handlers.NewTheme(q) + + rr := do(h.Update, "PUT", "/api/theme", + map[string]any{"preset": "dark", "custom_css": ":root{}", "config_json": "{}"}) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, http.StatusOK, do(h.Get, "GET", "/api/theme", nil).Code) + assert.Equal(t, http.StatusBadRequest, do(h.Update, "PUT", "/x", "not json").Code) +} + +func TestCheckResultsListAndUptime(t *testing.T) { + q := newQ(t) + ctx := context.Background() + mon, err := q.CreateMonitor(ctx, generated.CreateMonitorParams{ + Name: "m", Url: "http://example.com", Type: "http", IntervalSeconds: 60, + TimeoutSeconds: 10, DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", + }) + require.NoError(t, err) + ms := int64(42) + _, err = q.InsertCheckResult(ctx, generated.InsertCheckResultParams{ + MonitorID: mon.ID, CheckedAt: time.Now(), Status: "up", ResponseTimeMs: &ms, + }) + require.NoError(t, err) + + h := handlers.NewCheckResults(q) + id := itoa(mon.ID) + + list := do(func(w http.ResponseWriter, r *http.Request) { + h.List(w, withChiID(r, "monitorID", id)) + }, "GET", "/x?days=7", nil) + assert.Equal(t, http.StatusOK, list.Code) + + up := do(func(w http.ResponseWriter, r *http.Request) { + h.Uptime(w, withChiID(r, "monitorID", id)) + }, "GET", "/x", nil) + require.Equal(t, http.StatusOK, up.Code) + var body map[string]any + require.NoError(t, json.NewDecoder(up.Body).Decode(&body)) + assert.Equal(t, float64(100), body["uptime_pct"], "one up check = 100%") + + // invalid monitorID -> 400 + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.List(w, withChiID(r, "monitorID", "abc")) + }, "GET", "/x", nil).Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Uptime(w, withChiID(r, "monitorID", "abc")) + }, "GET", "/x", nil).Code) +} + +func TestIncidentUpdatesCreateAndList(t *testing.T) { + q := newQ(t) + ctx := context.Background() + inc, err := q.CreateIncident(ctx, generated.CreateIncidentParams{ + Title: "down", Severity: "major", AffectedMonitorIds: "[]", StartedAt: time.Now(), Source: "internal", + }) + require.NoError(t, err) + + h := handlers.NewIncidentUpdates(q) + id := itoa(inc.ID) + + cr := do(func(w http.ResponseWriter, r *http.Request) { + h.Create(w, withChiID(r, "incidentID", id)) + }, "POST", "/x", map[string]any{"status": "investigating", "message": "looking", "author": "admin"}) + assert.Equal(t, http.StatusCreated, cr.Code) + + ls := do(func(w http.ResponseWriter, r *http.Request) { + h.List(w, withChiID(r, "incidentID", id)) + }, "GET", "/x", nil) + assert.Equal(t, http.StatusOK, ls.Code) + + // error paths + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Create(w, withChiID(r, "incidentID", "abc")) + }, "POST", "/x", map[string]any{}).Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.List(w, withChiID(r, "incidentID", "abc")) + }, "GET", "/x", nil).Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Create(w, withChiID(r, "incidentID", id)) + }, "POST", "/x", "not json").Code) +} diff --git a/api/internal/handlers/dberror_test.go b/api/internal/handlers/dberror_test.go new file mode 100644 index 0000000..8468774 --- /dev/null +++ b/api/internal/handlers/dberror_test.go @@ -0,0 +1,125 @@ +package handlers_test + +import ( + "net/http" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/memetics19/pulse/api/testutil" + "github.com/stretchr/testify/assert" +) + +// closedQ returns a Queries whose database is closed, so every query returns an +// error. This exercises the "database error -> 500" branch in each handler +// cheaply, without a mock, by driving the happy path up to its first query. +func closedQ(t *testing.T) *generated.Queries { + t.Helper() + db := testutil.NewTestDB(t) + if err := db.Close(); err != nil { + t.Fatal(err) + } + return generated.New(db) +} + +func TestHandlersReturn500OnDBError(t *testing.T) { + q := closedQ(t) + // Each of these calls a query immediately with no prior validation, so a + // closed DB drives them into their error branch. + lists := map[string]http.HandlerFunc{ + "monitors.List": handlers.NewMonitors(q, true).List, + "groups.List": handlers.NewGroups(q).List, + "incidents.List": handlers.NewIncidents(q).List, + "notifications.List": handlers.NewNotifications(q).List, + "maintenance.List": handlers.NewMaintenance(q).List, + "pages.List": handlers.NewPages(q).List, + "agents.List": handlers.NewAgents(q).List, + "apikeys.List": handlers.NewAPIKeys(q).List, + "theme.Get": handlers.NewTheme(q).Get, + "overview.Get": handlers.NewOverview(q).Get, + "status.JSON": nil, // placeholder; status handled elsewhere + } + for name, hf := range lists { + if hf == nil { + continue + } + code := do(hf, "GET", "/x", nil).Code + assert.Equal(t, http.StatusInternalServerError, code, "%s should 500 on DB error", name) + } + + // auth.Status: CountUsers fails -> 500 + assert.Equal(t, http.StatusInternalServerError, + do(handlers.NewAuth(q, false).Status, "GET", "/x", nil).Code, "auth.Status") +} + +func TestHandlerCreatesReturn500OnDBError(t *testing.T) { + q := closedQ(t) + creates := []struct { + name string + h http.HandlerFunc + body any + }{ + {"monitors.Create", handlers.NewMonitors(q, true).Create, + map[string]any{"name": "m", "url": "http://example.com", "type": "http", "interval_seconds": 60}}, + {"groups.Create", handlers.NewGroups(q).Create, map[string]any{"name": "g"}}, + {"incidents.Create", handlers.NewIncidents(q).Create, + map[string]any{"title": "t", "severity": "major", "affected_monitor_ids": []int64{}}}, + {"notifications.Create", handlers.NewNotifications(q).Create, + map[string]any{"channel": "slack", "config_json": "{}"}}, + {"pages.Create", handlers.NewPages(q).Create, map[string]any{"domain": "a.com", "title": "A"}}, + {"agents.Create", handlers.NewAgents(q).Create, map[string]any{"name": "h", "host_label": "web"}}, + {"apikeys.Create", handlers.NewAPIKeys(q).Create, map[string]any{"name": "k", "scopes": []string{}}}, + {"theme.Update", handlers.NewTheme(q).Update, map[string]any{"preset": "d", "custom_css": "", "config_json": "{}"}}, + } + for _, c := range creates { + assert.Equal(t, http.StatusInternalServerError, do(c.h, "POST", "/x", c.body).Code, + "%s should 500 on DB error", c.name) + } + + // Get/Delete with a valid numeric id but a dead DB -> 500 (or 404 for Get). + m := handlers.NewMonitors(q, true) + assert.Equal(t, http.StatusNotFound, do(func(w http.ResponseWriter, r *http.Request) { + m.Get(w, withChiID(r, "id", "1")) + }, "GET", "/x", nil).Code, "monitors.Get on dead DB -> 404") + assert.Equal(t, http.StatusInternalServerError, do(func(w http.ResponseWriter, r *http.Request) { + m.Delete(w, withChiID(r, "id", "1")) + }, "DELETE", "/x", nil).Code, "monitors.Delete on dead DB -> 500") +} + +func TestHandlerMutationsReturn500OnDBError(t *testing.T) { + q := closedQ(t) + // id-keyed Update/UpdateStatus/Delete/Revoke/List with valid input against a + // dead DB all reach their query and 500. + byID := []struct { + name string + h http.HandlerFunc + key string + method string + body any + }{ + {"monitors.Update", handlers.NewMonitors(q, true).Update, "id", "PUT", + map[string]any{"name": "m", "url": "http://example.com", "type": "http", "interval_seconds": 60}}, + {"groups.Update", handlers.NewGroups(q).Update, "id", "PUT", map[string]any{"name": "g"}}, + {"groups.Delete", handlers.NewGroups(q).Delete, "id", "DELETE", nil}, + {"notifications.Update", handlers.NewNotifications(q).Update, "id", "PUT", map[string]any{"channel": "slack", "config_json": "{}"}}, + {"notifications.Delete", handlers.NewNotifications(q).Delete, "id", "DELETE", nil}, + {"incidents.UpdateStatus", handlers.NewIncidents(q).UpdateStatus, "id", "PUT", map[string]any{"status": "investigating"}}, + {"incidents.Delete", handlers.NewIncidents(q).Delete, "id", "DELETE", nil}, + {"maintenance.UpdateStatus", handlers.NewMaintenance(q).UpdateStatus, "id", "PUT", map[string]any{"status": "in_progress"}}, + {"maintenance.Delete", handlers.NewMaintenance(q).Delete, "id", "DELETE", nil}, + {"pages.Update", handlers.NewPages(q).Update, "id", "PUT", map[string]any{"domain": "a.com", "title": "A", "group_ids": []int64{}}}, + {"pages.Delete", handlers.NewPages(q).Delete, "id", "DELETE", nil}, + {"apikeys.Revoke", handlers.NewAPIKeys(q).Revoke, "id", "DELETE", nil}, + {"agents.Delete", handlers.NewAgents(q).Delete, "id", "DELETE", nil}, + {"checkResults.List", handlers.NewCheckResults(q).List, "monitorID", "GET", nil}, + {"checkResults.Uptime", handlers.NewCheckResults(q).Uptime, "monitorID", "GET", nil}, + {"incidentUpdates.List", handlers.NewIncidentUpdates(q).List, "incidentID", "GET", nil}, + {"incidentUpdates.Create", handlers.NewIncidentUpdates(q).Create, "incidentID", "POST", map[string]any{"status": "x", "message": "m"}}, + } + for _, c := range byID { + code := do(func(w http.ResponseWriter, r *http.Request) { + c.h(w, withChiID(r, c.key, "1")) + }, c.method, "/x", c.body).Code + assert.Equal(t, http.StatusInternalServerError, code, "%s on dead DB", c.name) + } +} diff --git a/api/internal/handlers/fault_test.go b/api/internal/handlers/fault_test.go new file mode 100644 index 0000000..59cb48c --- /dev/null +++ b/api/internal/handlers/fault_test.go @@ -0,0 +1,64 @@ +package handlers_test + +import ( + "net/http" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/memetics19/pulse/api/testutil" + "github.com/stretchr/testify/assert" +) + +// Overview issues four queries in sequence; failing after 1/2/3 successful +// calls drives each subsequent "database error" branch. +func TestOverviewDBErrorAtEachStage(t *testing.T) { + for _, k := range []int{1, 2, 3} { + db := testutil.NewTestDB(t) + q := generated.New(testutil.FailAfter(db, k)) + rr := do(handlers.NewOverview(q).Get, "GET", "/api/overview", nil) + assert.Equal(t, http.StatusInternalServerError, rr.Code, "overview fail after %d", k) + } +} + +// pages.Create inserts the page (call 1) then adds each group (later calls); +// failing after the insert exercises the group-association error branch. +func TestPagesCreateGroupAssocDBError(t *testing.T) { + db := testutil.NewTestDB(t) + seed := generated.New(db) + g, err := seed.CreateGroup(t.Context(), generated.CreateGroupParams{Name: "g"}) + if err != nil { + t.Fatal(err) + } + // Fail after the status-page insert so AddPageGroup errors. + q := generated.New(testutil.FailAfter(db, 1)) + rr := do(handlers.NewPages(q).Create, "POST", "/api/pages", + map[string]any{"domain": "a.com", "title": "A", "group_ids": []int64{g.ID}}) + assert.Equal(t, http.StatusInternalServerError, rr.Code) +} + +// auth.Setup: CountUsers (1) -> CreateUser (2) -> CreateSession (3). Failing +// after each stage drives the corresponding error branch. +func TestAuthSetupDBErrorStages(t *testing.T) { + for _, k := range []int{1, 2} { + db := testutil.NewTestDB(t) + q := generated.New(testutil.FailAfter(db, k)) + body := map[string]any{"username": "admin", "password": "s3cret-pass"} + rr := do(handlers.NewAuth(q, false).Setup, "POST", "/api/auth/setup", body) + assert.Equal(t, http.StatusInternalServerError, rr.Code, "auth.Setup fail after %d", k) + } +} + +// auth.Login: GetUserByUsername (1) -> startSession/CreateSession (2). With +// valid creds, failing after the lookup drives the session-creation error. +func TestAuthLoginSessionDBError(t *testing.T) { + db := testutil.NewTestDB(t) + seed := generated.New(db) + // Create the admin via a normal handler so credentials are valid. + setupAdminExt(t, handlers.NewAuth(seed, false)) + + q := generated.New(testutil.FailAfter(db, 1)) + body := map[string]any{"username": "admin", "password": "s3cret-pass"} + rr := do(handlers.NewAuth(q, false).Login, "POST", "/api/auth/login", body) + assert.NotEqual(t, http.StatusOK, rr.Code) +} diff --git a/api/internal/handlers/final_branches_test.go b/api/internal/handlers/final_branches_test.go new file mode 100644 index 0000000..7116202 --- /dev/null +++ b/api/internal/handlers/final_branches_test.go @@ -0,0 +1,36 @@ +package handlers_test + +import ( + "context" + "net/http" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckResultsDaysParam(t *testing.T) { + q := newQ(t) + mon, err := q.CreateMonitor(context.Background(), generated.CreateMonitorParams{ + Name: "m", Url: "http://example.com", Type: "http", IntervalSeconds: 60, + TimeoutSeconds: 10, DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", + }) + require.NoError(t, err) + h := handlers.NewCheckResults(q) + id := itoa(mon.ID) + // custom, zero, and non-numeric days all resolve to 200 (bad values fall + // back to the default window). + for _, days := range []string{"?days=7", "?days=0", "?days=abc", ""} { + rr := do(func(w http.ResponseWriter, r *http.Request) { + h.List(w, withChiID(r, "monitorID", id)) + }, "GET", "/x"+days, nil) + assert.Equal(t, http.StatusOK, rr.Code, "days=%q", days) + + ur := do(func(w http.ResponseWriter, r *http.Request) { + h.Uptime(w, withChiID(r, "monitorID", id)) + }, "GET", "/x"+days, nil) + assert.Equal(t, http.StatusOK, ur.Code, "uptime days=%q", days) + } +} diff --git a/api/internal/handlers/health.go b/api/internal/handlers/health.go index 21e763b..b7089ed 100644 --- a/api/internal/handlers/health.go +++ b/api/internal/handlers/health.go @@ -3,9 +3,31 @@ package handlers import ( "encoding/json" "net/http" + "time" + + "github.com/memetics19/pulse/api/internal/app" ) -func Health(w http.ResponseWriter, r *http.Request) { +// workerStaleAfter is how long the worker may go without a successful reconcile +// before /healthz reports unhealthy. The reconcile loop beats every 30s, so a +// few missed beats indicate the worker has died or wedged. +const workerStaleAfter = 90 * time.Second + +// Health reports process health. Once the app is configured, it also requires a +// live monitoring worker: a silently-dead worker returns 503 so orchestration +// restarts the container instead of trusting a process that monitors nothing. +type Health struct{ a *app.App } + +func NewHealth(a *app.App) *Health { return &Health{a: a} } + +func (h *Health) Get(w http.ResponseWriter, r *http.Request) { + status, code := "ok", http.StatusOK + // Before setup completes there is no worker to check; report ok so the + // container is considered up while the operator finishes the wizard. + if h.a.Configured() && !h.a.WorkerHealthy(workerStaleAfter) { + status, code = "worker_unhealthy", http.StatusServiceUnavailable + } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"status": status}) } diff --git a/api/internal/handlers/ingest_more_test.go b/api/internal/handlers/ingest_more_test.go new file mode 100644 index 0000000..2effde7 --- /dev/null +++ b/api/internal/handlers/ingest_more_test.go @@ -0,0 +1,23 @@ +package handlers_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/memetics19/pulse/api/testutil" + "github.com/stretchr/testify/assert" +) + +func TestIngestMalformedBody(t *testing.T) { + q := generated.New(testutil.NewTestDB(t)) + _, token := createAgent(t, handlers.NewAgents(q)) + req := httptest.NewRequest(http.MethodPost, "/api/ingest/metrics", bytes.NewReader([]byte("not json"))) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handlers.NewIngest(q).PostMetrics(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/api/internal/handlers/loginlimit.go b/api/internal/handlers/loginlimit.go index e63daaa..e54c38c 100644 --- a/api/internal/handlers/loginlimit.go +++ b/api/internal/handlers/loginlimit.go @@ -3,6 +3,7 @@ package handlers import ( "net" "net/http" + "strings" "sync" "time" ) @@ -62,13 +63,40 @@ func (l *loginLimiter) allow(ip string) bool { return true } -// clientIP extracts the remote IP, ignoring proxy headers: Pulse cannot know -// whether a trustworthy proxy set them, and honoring them would let attackers -// spoof fresh rate-limit buckets. -func clientIP(r *http.Request) string { +// clientIP returns the address used to key the login rate limiter. Proxy +// headers are ignored by default (an attacker could spoof them to mint fresh +// buckets). Only when the immediate peer is a configured trusted proxy is +// X-Forwarded-For consulted: it is walked right-to-left and the first address +// that is not itself a trusted proxy is treated as the real client, so a single +// proxy IP doesn't collapse every visitor into one shared bucket. +func clientIP(r *http.Request, trusted []*net.IPNet) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { - return r.RemoteAddr + host = r.RemoteAddr + } + if len(trusted) == 0 || !ipInAny(host, trusted) { + return host + } + parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if ip != "" && !ipInAny(ip, trusted) { + return ip + } } return host } + +// ipInAny reports whether ipStr parses to an IP contained in one of nets. +func ipInAny(ipStr string, nets []*net.IPNet) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} diff --git a/api/internal/handlers/loginlimit_test.go b/api/internal/handlers/loginlimit_test.go new file mode 100644 index 0000000..8bdcd07 --- /dev/null +++ b/api/internal/handlers/loginlimit_test.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net" + "net/http" + "testing" +) + +func mustCIDR(s string) *net.IPNet { _, n, _ := net.ParseCIDR(s); return n } + +func TestClientIP(t *testing.T) { + trusted := []*net.IPNet{mustCIDR("10.0.0.0/8")} + + newReq := func(remote, xff string) *http.Request { + r := httptest_New(remote) + if xff != "" { + r.Header.Set("X-Forwarded-For", xff) + } + return r + } + + // Untrusted peer: XFF ignored, use RemoteAddr. + if got := clientIP(newReq("203.0.113.9:5555", "1.2.3.4"), trusted); got != "203.0.113.9" { + t.Errorf("untrusted peer: got %q, want 203.0.113.9", got) + } + // Trusted proxy: use rightmost non-proxy XFF entry. + if got := clientIP(newReq("10.0.0.5:80", "8.8.8.8, 10.0.0.9"), trusted); got != "8.8.8.8" { + t.Errorf("trusted proxy: got %q, want 8.8.8.8", got) + } + // No trusted proxies configured: always RemoteAddr, XFF ignored. + if got := clientIP(newReq("10.0.0.5:80", "8.8.8.8"), nil); got != "10.0.0.5" { + t.Errorf("no trusted: got %q, want 10.0.0.5", got) + } +} + +func httptest_New(remote string) *http.Request { + r, _ := http.NewRequest(http.MethodPost, "/api/auth/login", nil) + r.RemoteAddr = remote + return r +} diff --git a/api/internal/handlers/misc_more_test.go b/api/internal/handlers/misc_more_test.go new file mode 100644 index 0000000..67d1393 --- /dev/null +++ b/api/internal/handlers/misc_more_test.go @@ -0,0 +1,73 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/memetics19/pulse/api/internal/app" + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/memetics19/pulse/api/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHealthReflectsWorker(t *testing.T) { + db := testutil.NewTestDB(t) + + // Unconfigured: healthy (setup phase). + a := app.New() + assert.Equal(t, http.StatusOK, do(handlers.NewHealth(a).Get, "GET", "/healthz", nil).Code) + + // Configured + worker alive: healthy. + a.SetDB(db) + a.MarkWorkerAlive() + assert.Equal(t, http.StatusOK, do(handlers.NewHealth(a).Get, "GET", "/healthz", nil).Code) + + // Configured + worker never beat: unhealthy. + dead := app.New() + dead.SetDB(db) + assert.Equal(t, http.StatusServiceUnavailable, do(handlers.NewHealth(dead).Get, "GET", "/healthz", nil).Code) +} + +func TestApiKeyCreateAndRevoke(t *testing.T) { + q := newQ(t) + h := handlers.NewAPIKeys(q) + + cr := do(h.Create, "POST", "/api/keys", map[string]any{"name": "ci", "scopes": []string{"monitors:read"}}) + require.Equal(t, http.StatusCreated, cr.Code) + var created struct { + ID int64 `json:"id"` + } + require.NoError(t, json.NewDecoder(cr.Body).Decode(&created)) + require.NotZero(t, created.ID) + + assert.Equal(t, http.StatusOK, do(h.List, "GET", "/api/keys", nil).Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Revoke(w, withChiID(r, "id", "bad")) + }, "DELETE", "/x", nil).Code) + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + h.Revoke(w, withChiID(r, "id", itoa(created.ID))) + }, "DELETE", "/x", nil).Code) +} + +func TestPagesUpdateDelete(t *testing.T) { + q := newQ(t) + ctx := context.Background() + sp, err := q.CreateStatusPage(ctx, generated.CreateStatusPageParams{Domain: "acme.com", Title: "Acme", Published: 1}) + require.NoError(t, err) + h := handlers.NewPages(q) + id := itoa(sp.ID) + + assert.Equal(t, http.StatusOK, do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", id)) + }, "PUT", "/x", map[string]any{"domain": "acme.io", "title": "Acme2", "published": true, "group_ids": []int64{}}).Code) + assert.Equal(t, http.StatusNoContent, do(func(w http.ResponseWriter, r *http.Request) { + h.Delete(w, withChiID(r, "id", id)) + }, "DELETE", "/x", nil).Code) + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", "bad")) + }, "PUT", "/x", map[string]any{}).Code) +} diff --git a/api/internal/handlers/monitors.go b/api/internal/handlers/monitors.go index bd7b0d4..0a3b35b 100644 --- a/api/internal/handlers/monitors.go +++ b/api/internal/handlers/monitors.go @@ -19,15 +19,79 @@ func NewMonitors(q *generated.Queries, allowPrivate bool) *Monitors { return &Monitors{q: q, allowPrivate: allowPrivate} } -// validMonitorTypes is the set of monitor types the scheduler can check. +// validMonitorTypes is the set of monitor types the scheduler can check. Note +// there is no "https": the "http" checker handles https URLs, and the schema's +// CHECK constraint forbids "https", so accepting it here would 500 at insert. var validMonitorTypes = map[string]bool{ - "http": true, "https": true, "tcp": true, "ping": true, + "http": true, "tcp": true, "ping": true, "dns": true, "ssl": true, "infra": true, } -// defaultTimeoutSeconds is applied when a monitor is created or updated without -// a timeout (for example by the Uptime Kuma importer, which omits the field). -const defaultTimeoutSeconds = 30 +// Defaults applied when a monitor is created or updated without a given field. +// They mirror the schema column defaults, which are otherwise bypassed because +// the generated params struct always sends an explicit value in the INSERT. +const ( + defaultTimeoutSeconds = 30 + defaultDegradedMs = 500 + defaultDownMs = 2000 +) + +// monitorRequest is the decode target for Create/Update. Pointer fields let the +// handler distinguish "omitted" (nil → apply default) from "explicitly zero", +// so an omitted is_active defaults to true (scheduled) and omitted thresholds +// get the schema defaults instead of 0 (which would flap every check to down). +type monitorRequest struct { + Name string `json:"name"` + Url string `json:"url"` + Type string `json:"type"` + IntervalSeconds int64 `json:"interval_seconds"` + TimeoutSeconds *int64 `json:"timeout_seconds"` + ExpectedStatus *int64 `json:"expected_status"` + KeywordCheck string `json:"keyword_check"` + DegradedThresholdMs *int64 `json:"degraded_threshold_ms"` + DownThresholdMs *int64 `json:"down_threshold_ms"` + IsActive *bool `json:"is_active"` + GroupID *int64 `json:"group_id"` + Source string `json:"source"` + ExternalID string `json:"external_id"` +} + +// resolved holds the request's fields with defaults filled in. It is the single +// place defaults and cross-field rules (degraded < down) are applied, shared by +// Create and Update. +type resolvedMonitor struct { + req monitorRequest + timeout, degraded, down int64 + isActive bool +} + +// resolve validates the request and fills defaults. reason is non-empty on a +// validation failure. +func (m *Monitors) resolve(req monitorRequest) (resolvedMonitor, string) { + if reason := m.validateMonitorInput(req.Url, req.Type, req.IntervalSeconds); reason != "" { + return resolvedMonitor{}, reason + } + r := resolvedMonitor{req: req, isActive: true} + if req.IsActive != nil { + r.isActive = *req.IsActive + } + r.timeout = defaultTimeoutSeconds + if req.TimeoutSeconds != nil && *req.TimeoutSeconds > 0 { + r.timeout = *req.TimeoutSeconds + } + r.degraded = defaultDegradedMs + if req.DegradedThresholdMs != nil && *req.DegradedThresholdMs > 0 { + r.degraded = *req.DegradedThresholdMs + } + r.down = defaultDownMs + if req.DownThresholdMs != nil && *req.DownThresholdMs > 0 { + r.down = *req.DownThresholdMs + } + if r.degraded >= r.down { + return resolvedMonitor{}, "degraded_threshold_ms must be less than down_threshold_ms" + } + return r, "" +} // validateMonitorInput returns a human-readable reason when a required field is // missing or out of range, or "" when the input is acceptable. A zero interval @@ -42,12 +106,17 @@ func (m *Monitors) validateMonitorInput(url, monType string, intervalSeconds int case intervalSeconds < 1: return "interval_seconds must be at least 1" } - // HTTP(S) targets are fetched by the worker, so reject URLs pointing at - // private/internal networks unless explicitly allowed (SSRF guard). + // Every monitor type dials or resolves a user-supplied target, so reject + // targets pointing at private/internal networks unless explicitly allowed + // (SSRF guard). HTTP(S) targets are URLs; the rest are host[:port]/hostnames. if monType == "http" || monType == "https" { if err := netguard.ValidateURL(url, m.allowPrivate); err != nil { return err.Error() } + } else { + if err := netguard.ValidateTarget(url, m.allowPrivate); err != nil { + return err.Error() + } } return "" } @@ -82,17 +151,30 @@ func (m *Monitors) Get(w http.ResponseWriter, r *http.Request) { } func (m *Monitors) Create(w http.ResponseWriter, r *http.Request) { - var params generated.CreateMonitorParams - if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + var req monitorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if reason := m.validateMonitorInput(params.Url, params.Type, params.IntervalSeconds); reason != "" { + res, reason := m.resolve(req) + if reason != "" { http.Error(w, reason, http.StatusBadRequest) return } - if params.TimeoutSeconds < 1 { - params.TimeoutSeconds = defaultTimeoutSeconds + params := generated.CreateMonitorParams{ + Name: req.Name, + Url: req.Url, + Type: req.Type, + IntervalSeconds: req.IntervalSeconds, + TimeoutSeconds: res.timeout, + ExpectedStatus: req.ExpectedStatus, + KeywordCheck: req.KeywordCheck, + DegradedThresholdMs: res.degraded, + DownThresholdMs: res.down, + IsActive: res.isActive, + GroupID: req.GroupID, + Source: req.Source, + ExternalID: req.ExternalID, } monitor, err := m.q.CreateMonitor(r.Context(), params) if err != nil { @@ -111,19 +193,30 @@ func (m *Monitors) Update(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid id", http.StatusBadRequest) return } - var params generated.UpdateMonitorParams - if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + var req monitorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if reason := m.validateMonitorInput(params.Url, params.Type, params.IntervalSeconds); reason != "" { + res, reason := m.resolve(req) + if reason != "" { http.Error(w, reason, http.StatusBadRequest) return } - if params.TimeoutSeconds < 1 { - params.TimeoutSeconds = defaultTimeoutSeconds + params := generated.UpdateMonitorParams{ + Name: req.Name, + Url: req.Url, + Type: req.Type, + IntervalSeconds: req.IntervalSeconds, + TimeoutSeconds: res.timeout, + ExpectedStatus: req.ExpectedStatus, + KeywordCheck: req.KeywordCheck, + DegradedThresholdMs: res.degraded, + DownThresholdMs: res.down, + IsActive: res.isActive, + GroupID: req.GroupID, + ID: id, } - params.ID = id monitor, err := m.q.UpdateMonitor(r.Context(), params) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/api/internal/handlers/more_branches_test.go b/api/internal/handlers/more_branches_test.go new file mode 100644 index 0000000..ee7e3d9 --- /dev/null +++ b/api/internal/handlers/more_branches_test.go @@ -0,0 +1,56 @@ +package handlers_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaintenanceCreateValidation(t *testing.T) { + h := handlers.NewMaintenance(newQ(t)) + now := time.Now().Format(time.RFC3339) + + // missing title + assert.Equal(t, http.StatusBadRequest, + do(h.Create, "POST", "/x", map[string]any{"starts_at": now, "ends_at": now}).Code) + // invalid starts_at + assert.Equal(t, http.StatusBadRequest, + do(h.Create, "POST", "/x", map[string]any{"title": "t", "starts_at": "nope", "ends_at": now}).Code) + // invalid ends_at + assert.Equal(t, http.StatusBadRequest, + do(h.Create, "POST", "/x", map[string]any{"title": "t", "starts_at": now, "ends_at": "nope"}).Code) + // start_now -> created in_progress + assert.Equal(t, http.StatusCreated, + do(h.Create, "POST", "/x", map[string]any{"title": "t", "starts_at": now, "ends_at": now, "start_now": true}).Code) + // bad JSON + assert.Equal(t, http.StatusBadRequest, do(h.Create, "POST", "/x", "nope").Code) +} + +func TestMonitorUpdateValidation(t *testing.T) { + q := newQ(t) + mon, err := q.CreateMonitor(context.Background(), generated.CreateMonitorParams{ + Name: "m", Url: "http://example.com", Type: "http", IntervalSeconds: 60, + TimeoutSeconds: 10, DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", + }) + require.NoError(t, err) + h := handlers.NewMonitors(q, false) // guard on + // Update to a private URL is rejected. + rr := do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", itoa(mon.ID))) + }, "PUT", "/x", map[string]any{"name": "m", "url": "http://127.0.0.1/x", "type": "http", "interval_seconds": 60}) + assert.Equal(t, http.StatusBadRequest, rr.Code) + // invalid id + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", "bad")) + }, "PUT", "/x", map[string]any{}).Code) + // bad body + assert.Equal(t, http.StatusBadRequest, do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", itoa(mon.ID))) + }, "PUT", "/x", "nope").Code) +} diff --git a/api/internal/handlers/overview_more_test.go b/api/internal/handlers/overview_more_test.go new file mode 100644 index 0000000..c8c65e6 --- /dev/null +++ b/api/internal/handlers/overview_more_test.go @@ -0,0 +1,65 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOverviewAggregates(t *testing.T) { + q := newQ(t) + ctx := context.Background() + + mk := func(name string) int64 { + m, err := q.CreateMonitor(ctx, generated.CreateMonitorParams{ + Name: name, Url: "http://example.com", Type: "http", IntervalSeconds: 60, + TimeoutSeconds: 10, DegradedThresholdMs: 500, DownThresholdMs: 2000, + IsActive: true, Source: "internal", + }) + require.NoError(t, err) + return m.ID + } + downID := mk("down-svc") + degID := mk("deg-svc") + mk("up-svc") // no check result -> defaults up + + insert := func(id int64, status string) { + _, err := q.InsertCheckResult(ctx, generated.InsertCheckResultParams{ + MonitorID: id, CheckedAt: time.Now(), Status: status, + }) + require.NoError(t, err) + } + insert(downID, "down") + insert(degID, "degraded") + + _, err := q.CreateIncident(ctx, generated.CreateIncidentParams{ + Title: "ongoing", Severity: "major", AffectedMonitorIds: "[]", StartedAt: time.Now(), Source: "internal", + }) + require.NoError(t, err) + + rr := do(handlers.NewOverview(q).Get, "GET", "/api/overview", nil) + require.Equal(t, http.StatusOK, rr.Code) + + var resp struct { + Overall string `json:"overall"` + Counts map[string]int `json:"counts"` + Total int `json:"total_monitors"` + Active []any `json:"active_incidents"` + Attn []any `json:"attention"` + } + require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.Equal(t, "outage", resp.Overall) + assert.Equal(t, 1, resp.Counts["down"]) + assert.Equal(t, 1, resp.Counts["degraded"]) + assert.Equal(t, 1, resp.Counts["up"]) + assert.Equal(t, 3, resp.Total) + assert.Len(t, resp.Active, 1) + assert.NotEmpty(t, resp.Attn) // the down monitor produces an attention item +} diff --git a/api/internal/handlers/pages_delete_test.go b/api/internal/handlers/pages_delete_test.go new file mode 100644 index 0000000..1b41145 --- /dev/null +++ b/api/internal/handlers/pages_delete_test.go @@ -0,0 +1,22 @@ +package handlers_test + +import ( + "context" + "net/http" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPagesDelete(t *testing.T) { + q := newQ(t) + sp, err := q.CreateStatusPage(context.Background(), generated.CreateStatusPageParams{Domain: "d.com", Title: "D", Published: 1}) + require.NoError(t, err) + rr := do(func(w http.ResponseWriter, r *http.Request) { + handlers.NewPages(q).Delete(w, withChiID(r, "id", itoa(sp.ID))) + }, "DELETE", "/x", nil) + assert.Equal(t, http.StatusNoContent, rr.Code) +} diff --git a/api/internal/handlers/render_paths_test.go b/api/internal/handlers/render_paths_test.go new file mode 100644 index 0000000..4e9733b --- /dev/null +++ b/api/internal/handlers/render_paths_test.go @@ -0,0 +1,61 @@ +package handlers_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/memetics19/pulse/api/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaintenanceListRendersView(t *testing.T) { + db := testutil.NewTestDB(t) + q := generated.New(db) + now := time.Now() + _, err := q.CreateMaintenance(context.Background(), generated.CreateMaintenanceParams{ + Title: "win", Status: "scheduled", AffectedMonitorIds: "[1,2]", + StartsAt: now, EndsAt: now.Add(time.Hour), + }) + require.NoError(t, err) + + rr := do(handlers.NewMaintenance(q).List, "GET", "/api/maintenance", nil) + require.Equal(t, http.StatusOK, rr.Code) + var views []struct { + AffectedMonitorIds []int64 `json:"affected_monitor_ids"` + } + decode(t, rr, &views) + require.Len(t, views, 1) + assert.Equal(t, []int64{1, 2}, views[0].AffectedMonitorIds) +} + +func TestOverviewFlagsOfflineAgent(t *testing.T) { + db := testutil.NewTestDB(t) + // An active agent last seen 10 minutes ago -> "agent_offline" attention. + _, err := db.Exec( + `INSERT INTO infra_agents (name, host_label, token_hash, is_active, last_seen_at) VALUES ('h','web','hash',1,?)`, + time.Now().Add(-10*time.Minute)) + require.NoError(t, err) + + rr := do(handlers.NewOverview(generated.New(db)).Get, "GET", "/api/overview", nil) + require.Equal(t, http.StatusOK, rr.Code) + var resp struct { + AgentCount int `json:"agent_count"` + Attention []struct { + Kind string `json:"kind"` + } `json:"attention"` + } + decode(t, rr, &resp) + assert.Equal(t, 1, resp.AgentCount) + found := false + for _, a := range resp.Attention { + if a.Kind == "agent_offline" { + found = true + } + } + assert.True(t, found, "offline agent should raise an attention item") +} diff --git a/api/internal/handlers/setup_error_test.go b/api/internal/handlers/setup_error_test.go new file mode 100644 index 0000000..0465347 --- /dev/null +++ b/api/internal/handlers/setup_error_test.go @@ -0,0 +1,38 @@ +package handlers_test + +import ( + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/memetics19/pulse/api/internal/app" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" +) + +func TestSetupCompleteErrors(t *testing.T) { + valid := map[string]any{"username": "admin", "password": "s3cret-pass"} + + // bad body -> 400 + a := app.New() + h := handlers.NewSetup(a, t.TempDir(), false) + assert.Equal(t, http.StatusBadRequest, do(h.Complete, "POST", "/x", "not json").Code) + + // short password -> 400 + assert.Equal(t, http.StatusBadRequest, + do(h.Complete, "POST", "/x", map[string]any{"username": "a", "password": "short"}).Code) + + // sqlite_path whose parent is a file -> MkdirAll fails -> 400 + dir := t.TempDir() + file := filepath.Join(dir, "afile") + os.WriteFile(file, []byte("x"), 0o600) + body := map[string]any{"username": "admin", "password": "s3cret-pass", "sqlite_path": filepath.Join(file, "db.sqlite")} + assert.Equal(t, http.StatusBadRequest, do(h.Complete, "POST", "/x", body).Code) + + // sqlite_path that is a directory -> db.Open fails -> 400 + body2 := map[string]any{"username": "admin", "password": "s3cret-pass", "sqlite_path": t.TempDir()} + assert.Equal(t, http.StatusBadRequest, do(handlers.NewSetup(app.New(), t.TempDir(), false).Complete, "POST", "/x", body2).Code) + + _ = valid +} diff --git a/api/internal/handlers/setuphelper_test.go b/api/internal/handlers/setuphelper_test.go new file mode 100644 index 0000000..fbb28e2 --- /dev/null +++ b/api/internal/handlers/setuphelper_test.go @@ -0,0 +1,20 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/require" +) + +func setupAdminExt(t *testing.T, h *handlers.Auth) { + t.Helper() + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "s3cret-pass"}) + rec := httptest.NewRecorder() + h.Setup(rec, httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewReader(body))) + require.Equal(t, http.StatusCreated, rec.Code) +} diff --git a/api/internal/handlers/status.go b/api/internal/handlers/status.go index 43ea689..196af52 100644 --- a/api/internal/handlers/status.go +++ b/api/internal/handlers/status.go @@ -3,17 +3,16 @@ package handlers import ( "context" "database/sql" - "encoding/json" "errors" - "net/http" "github.com/memetics19/pulse/api/internal/generated" ) -type Status struct{ q *generated.Queries } - -func NewStatus(q *generated.Queries) *Status { return &Status{q: q} } - +// StatusResponse is the internal snapshot shared by the server-rendered status +// page and the Atom feed. It is NOT serialized to any public HTTP endpoint — +// it carries raw monitor models (target URLs, thresholds). The public +// GET /api/status endpoint uses a page-scoped, sanitized shape instead +// (see web.Public.StatusJSON). type StatusResponse struct { Groups []generated.MonitorGroup `json:"groups"` Monitors []generated.Monitor `json:"monitors"` @@ -66,13 +65,3 @@ func Snapshot(ctx context.Context, q *generated.Queries) (StatusResponse, error) Statuses: statuses, }, nil } - -func (h *Status) Get(w http.ResponseWriter, r *http.Request) { - snap, err := Snapshot(r.Context(), h.q) - if err != nil { - http.Error(w, "database error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(snap) -} diff --git a/api/internal/handlers/validation_batch_test.go b/api/internal/handlers/validation_batch_test.go new file mode 100644 index 0000000..76b3937 --- /dev/null +++ b/api/internal/handlers/validation_batch_test.go @@ -0,0 +1,33 @@ +package handlers_test + +import ( + "net/http" + "testing" + + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" +) + +func TestCreateValidationErrors(t *testing.T) { + q := newQ(t) + // apikeys: missing name / empty scopes + ak := handlers.NewAPIKeys(q) + assert.Equal(t, http.StatusBadRequest, do(ak.Create, "POST", "/x", map[string]any{"scopes": []string{"monitors:read"}}).Code) + assert.Equal(t, http.StatusBadRequest, do(ak.Create, "POST", "/x", "not json").Code) + + // groups: bad JSON + g := handlers.NewGroups(q) + assert.Equal(t, http.StatusBadRequest, do(g.Create, "POST", "/x", "not json").Code) + + // agents: bad JSON + ag := handlers.NewAgents(q) + assert.Equal(t, http.StatusBadRequest, do(ag.Create, "POST", "/x", "not json").Code) + + // incidents: bad JSON + inc := handlers.NewIncidents(q) + assert.Equal(t, http.StatusBadRequest, do(inc.Create, "POST", "/x", "not json").Code) + + // pages: bad JSON + p := handlers.NewPages(q) + assert.Equal(t, http.StatusBadRequest, do(p.Create, "POST", "/x", "not json").Code) +} diff --git a/api/internal/handlers/validation_more_test.go b/api/internal/handlers/validation_more_test.go new file mode 100644 index 0000000..bf82e93 --- /dev/null +++ b/api/internal/handlers/validation_more_test.go @@ -0,0 +1,78 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func decode(t *testing.T, rr *httptest.ResponseRecorder, v any) { + t.Helper() + require.NoError(t, json.NewDecoder(rr.Body).Decode(v)) +} + +func TestMonitorValidation(t *testing.T) { + q := newQ(t) + h := handlers.NewMonitors(q, false) // allowPrivate=false -> SSRF guard active + + cases := []struct { + name string + body map[string]any + }{ + {"empty url", map[string]any{"name": "m", "url": "", "type": "http", "interval_seconds": 60}}, + {"bad type", map[string]any{"name": "m", "url": "http://example.com", "type": "bogus", "interval_seconds": 60}}, + {"zero interval", map[string]any{"name": "m", "url": "http://example.com", "type": "http", "interval_seconds": 0}}, + {"private http url", map[string]any{"name": "m", "url": "http://127.0.0.1/x", "type": "http", "interval_seconds": 60}}, + {"private tcp target", map[string]any{"name": "m", "url": "10.0.0.1:6379", "type": "tcp", "interval_seconds": 60}}, + {"degraded >= down", map[string]any{"name": "m", "url": "http://example.com", "type": "http", "interval_seconds": 60, "degraded_threshold_ms": 3000, "down_threshold_ms": 2000}}, + } + for _, c := range cases { + code := do(h.Create, "POST", "/api/monitors", c.body).Code + assert.Equal(t, http.StatusBadRequest, code, "%s should be rejected", c.name) + } + // bad JSON body -> 400 + assert.Equal(t, http.StatusBadRequest, do(h.Create, "POST", "/x", "not json").Code) +} + +func TestPagesGroupAssociations(t *testing.T) { + q := newQ(t) + ctx := context.Background() + g1, _ := q.CreateGroup(ctx, generated.CreateGroupParams{Name: "g1"}) + g2, _ := q.CreateGroup(ctx, generated.CreateGroupParams{Name: "g2"}) + h := handlers.NewPages(q) + + // Create with two groups. + cr := do(h.Create, "POST", "/api/pages", map[string]any{ + "domain": "acme.com", "title": "Acme", "published": true, "group_ids": []int64{g1.ID, g2.ID}, + }) + require.Equal(t, http.StatusCreated, cr.Code) + + // List to obtain the id (view has group_ids inlined). + var pages []struct { + ID int64 `json:"id"` + GroupIDs []int64 `json:"group_ids"` + } + lr := do(h.List, "GET", "/api/pages", nil) + require.Equal(t, http.StatusOK, lr.Code) + decode(t, lr, &pages) + var id string + for _, p := range pages { + if len(p.GroupIDs) == 2 { + id = itoa(p.ID) + } + } + require.NotEmpty(t, id, "created page with 2 groups should be listed") + + // Update to a single group: exercises the "remove existing, add new" loops. + up := do(func(w http.ResponseWriter, r *http.Request) { + h.Update(w, withChiID(r, "id", id)) + }, "PUT", "/x", map[string]any{"domain": "acme.io", "title": "Acme2", "published": false, "group_ids": []int64{g1.ID}}) + assert.Equal(t, http.StatusOK, up.Code) +} diff --git a/api/internal/middleware/scope_test.go b/api/internal/middleware/scope_test.go new file mode 100644 index 0000000..177cdbd --- /dev/null +++ b/api/internal/middleware/scope_test.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "testing" +) + +func TestRequiredScope(t *testing.T) { + cases := []struct{ method, path, want string }{ + {"GET", "/api/monitors", "monitors:read"}, + {"POST", "/api/monitors", "monitors:write"}, + {"GET", "/api/groups/1", "monitors:read"}, + {"GET", "/api/incidents", "incidents:read"}, + {"PUT", "/api/incidents/1/status", "incidents:write"}, + {"GET", "/api/notifications", "notifications:read"}, + {"POST", "/api/agents", "agents:write"}, + {"GET", "/api/theme", "theme:read"}, + {"POST", "/api/pages", "pages:write"}, + {"DELETE", "/api/maintenance/1", "maintenance:write"}, + {"GET", "/api/overview", "status:read"}, + {"GET", "/api/keys", ""}, // session-only + {"GET", "/api/whatever", ""}, // unknown + } + for _, c := range cases { + if got := requiredScope(c.method, c.path); got != c.want { + t.Errorf("requiredScope(%s,%s)=%q want %q", c.method, c.path, got, c.want) + } + } +} diff --git a/api/internal/netguard/netguard.go b/api/internal/netguard/netguard.go index 60e5947..dc9a075 100644 --- a/api/internal/netguard/netguard.go +++ b/api/internal/netguard/netguard.go @@ -52,6 +52,40 @@ func DialControl(allowPrivate bool) func(network, address string, c syscall.RawC } } +// ValidateTarget rejects a non-HTTP monitor target (used by tcp/ssl/dns/ping) +// that points at a forbidden IP. The target may be "host:port" (tcp/ssl) or a +// bare host (dns/ping). Like ValidateURL it is a best-effort API-time check: +// DialControl remains the enforcement point at connect time, and an +// unresolvable host is not rejected (it may be temporarily down). +func ValidateTarget(target string, allowPrivate bool) error { + if allowPrivate { + return nil + } + host := target + if h, _, err := net.SplitHostPort(target); err == nil { + host = h + } + if host == "" { + return fmt.Errorf("target has no host") + } + if ip := net.ParseIP(host); ip != nil { + if IsForbiddenIP(ip) { + return fmt.Errorf("%s is a private or internal address (set PULSE_ALLOW_PRIVATE_MONITORS=true to allow)", ip) + } + return nil + } + ips, err := net.LookupIP(host) + if err != nil { + return nil // unresolvable now; DialControl guards the actual connection + } + for _, ip := range ips { + if IsForbiddenIP(ip) { + return fmt.Errorf("%s resolves to private or internal address %s (set PULSE_ALLOW_PRIVATE_MONITORS=true to allow)", host, ip) + } + } + return nil +} + // ValidateURL rejects monitor URLs that are malformed, use a non-HTTP scheme, // or resolve to a forbidden IP. It is a best-effort early check for a clear // API error; DialControl remains the enforcement point at connect time. diff --git a/api/internal/netguard/netguard_test.go b/api/internal/netguard/netguard_test.go index eada125..d49d3db 100644 --- a/api/internal/netguard/netguard_test.go +++ b/api/internal/netguard/netguard_test.go @@ -76,3 +76,58 @@ func TestValidateURL(t *testing.T) { t.Errorf("allowPrivate should permit loopback URL: %v", err) } } + +func TestValidateTarget(t *testing.T) { + cases := []struct { + target string + wantErr bool + }{ + {"127.0.0.1:6379", true}, // loopback host:port + {"10.0.0.5:5432", true}, // RFC1918 host:port + {"169.254.169.254:80", true}, // cloud metadata + {"192.168.1.1", true}, // bare private IP + {"[::1]:443", true}, // IPv6 loopback host:port + {"", true}, // no host + {"8.8.8.8:53", false}, // public host:port + {"1.1.1.1", false}, // public bare IP + {"this-domain-should-not-exist-pulse.invalid:80", false}, // unresolvable → dial guard covers + } + for _, c := range cases { + err := ValidateTarget(c.target, false) + if (err != nil) != c.wantErr { + t.Errorf("ValidateTarget(%q) error = %v, wantErr %v", c.target, err, c.wantErr) + } + } + if err := ValidateTarget("127.0.0.1:22", true); err != nil { + t.Errorf("allowPrivate should permit loopback target: %v", err) + } +} + +func TestDialControlEdges(t *testing.T) { + allow := DialControl(true) + if err := allow("tcp", "127.0.0.1:80", nil); err != nil { + t.Errorf("allowPrivate should permit: %v", err) + } + deny := DialControl(false) + if err := deny("tcp", "not-an-address", nil); err == nil { + t.Error("malformed address should error") + } + if err := deny("tcp", "example.com:80", nil); err == nil { + t.Error("non-IP host (unresolved literal) should error") + } + if err := deny("tcp", "8.8.8.8:53", nil); err != nil { + t.Errorf("public IP should be allowed: %v", err) + } +} + +func TestValidateURLEdges(t *testing.T) { + if err := ValidateURL("://bad", false); err == nil { + t.Error("malformed URL should error") + } + if err := ValidateURL("ftp://example.com", false); err == nil { + t.Error("non-http scheme should error") + } + if err := ValidateURL("http://", false); err == nil { + t.Error("missing host should error") + } +} diff --git a/api/internal/server/server.go b/api/internal/server/server.go index 49dde58..bcfcda6 100644 --- a/api/internal/server/server.go +++ b/api/internal/server/server.go @@ -34,8 +34,8 @@ func New(a *app.App, dataDir string, cfg config.Config) http.Handler { r.Get("/feed.xml", pub.Feed) r.Handle("/static/*", web.StaticHandler()) - r.Get("/healthz", handlers.Health) - r.Get("/api/status", handlers.NewStatus(q).Get) + r.Get("/healthz", handlers.NewHealth(a).Get) + r.Get("/api/status", pub.StatusJSON) r.Post("/api/ingest/metrics", handlers.NewIngest(q).PostMetrics) // Public read-only (status page client-side fetches) @@ -43,7 +43,7 @@ func New(a *app.App, dataDir string, cfg config.Config) http.Handler { r.Get("/api/monitors/{monitorID}/checks/uptime", handlers.NewCheckResults(q).Uptime) r.Get("/api/incidents/{incidentID}/updates", handlers.NewIncidentUpdates(q).List) - authH := handlers.NewAuth(q, cfg.SecureCookies) + authH := handlers.NewAuth(q, cfg.SecureCookies, cfg.TrustedProxies...) r.Post("/api/auth/login", authH.Login) r.Post("/api/auth/logout", authH.Logout) r.Get("/api/auth/status", authH.Status) diff --git a/api/internal/server/server_test.go b/api/internal/server/server_test.go new file mode 100644 index 0000000..86d7d06 --- /dev/null +++ b/api/internal/server/server_test.go @@ -0,0 +1,49 @@ +package server_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/app" + "github.com/memetics19/pulse/api/internal/config" + "github.com/memetics19/pulse/api/internal/server" + "github.com/memetics19/pulse/api/testutil" +) + +// One request per route family, exercising the full router wiring: public +// endpoints answer, auth-gated endpoints reject anonymous callers, and the +// admin/setup redirects fire. +func TestRouterWiring(t *testing.T) { + a := app.New() + a.SetDB(testutil.NewTestDB(t)) + a.MarkWorkerAlive() + h := server.New(a, t.TempDir(), config.Config{}) + + cases := []struct { + method, path string + want int + }{ + {"GET", "/healthz", http.StatusOK}, + {"GET", "/api/status", http.StatusOK}, + {"GET", "/", http.StatusOK}, + {"GET", "/api/setup/state", http.StatusOK}, + {"GET", "/feed.xml", http.StatusOK}, + // auth-gated: anonymous must be rejected + {"GET", "/api/monitors", http.StatusUnauthorized}, + {"GET", "/api/keys", http.StatusUnauthorized}, + {"GET", "/api/overview", http.StatusUnauthorized}, + // agent ingest without a bearer token + {"POST", "/api/ingest/metrics", http.StatusUnauthorized}, + // redirects + {"GET", "/admin", http.StatusMovedPermanently}, + {"GET", "/setup", http.StatusMovedPermanently}, + } + for _, c := range cases { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil)) + if rec.Code != c.want { + t.Errorf("%s %s = %d, want %d", c.method, c.path, rec.Code, c.want) + } + } +} diff --git a/api/internal/web/paths_test.go b/api/internal/web/paths_test.go new file mode 100644 index 0000000..577b7c3 --- /dev/null +++ b/api/internal/web/paths_test.go @@ -0,0 +1,43 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/testutil" +) + +func TestPublicRendersNoDataAndResolvedIncident(t *testing.T) { + q := generated.New(testutil.NewTestDB(t)) + ctx := context.Background() + + g, _ := q.CreateGroup(ctx, generated.CreateGroupParams{Name: "G"}) + // A monitor with no check results -> "no data" bars / "—" uptime. + q.CreateMonitor(ctx, generated.CreateMonitorParams{ + Name: "Fresh", Url: "http://example.com", Type: "http", IntervalSeconds: 60, + TimeoutSeconds: 10, DegradedThresholdMs: 500, DownThresholdMs: 2000, + IsActive: true, GroupID: &g.ID, Source: "internal", + }) + // A resolved incident -> PastIncidents branch. + rca := "root cause" + inc, _ := q.CreateIncident(ctx, generated.CreateIncidentParams{ + Title: "Past outage", Severity: "minor", AffectedMonitorIds: "[]", + StartedAt: time.Now().Add(-time.Hour), Source: "internal", + }) + q.UpdateIncidentStatus(ctx, generated.UpdateIncidentStatusParams{Status: "resolved", Rca: &rca, ID: inc.ID}) + + rec := httptest.NewRecorder() + NewPublic(q).Get(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("render = %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Fresh") { + t.Fatal("expected the no-data monitor to render") + } +} diff --git a/api/internal/web/public.go b/api/internal/web/public.go index 0609219..7cdbdb0 100644 --- a/api/internal/web/public.go +++ b/api/internal/web/public.go @@ -8,6 +8,7 @@ import ( "html/template" "io/fs" "net/http" + "net/url" "regexp" "sort" "strings" @@ -37,6 +38,33 @@ func renderMarkdown(s string) template.HTML { return template.HTML(e) } +// sanitizeCSS neutralizes a stored-XSS vector in user-supplied theme CSS. The +// value is injected inside a ", ConfigJson: cfg, + }); err != nil { + t.Fatal(err) + } +} + +func itoa(n int64) string { + if n < 0 { + return "-" + itoa(-n) + } + if n < 10 { + return string(rune('0' + n)) + } + return itoa(n/10) + string(rune('0'+n%10)) +} + +func TestPublicPageFullRenderAcrossRanges(t *testing.T) { + q := generated.New(testutil.NewTestDB(t)) + seedFullStatus(t, q) + h := NewPublic(q) + + for _, rng := range []string{"90d", "30d", "7d", "24h", "bogus"} { + rec := httptest.NewRecorder() + h.Get(rec, httptest.NewRequest(http.MethodGet, "/?range="+rng, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("range %s = %d, want 200", rng, rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "API") { + t.Fatalf("range %s: expected monitor name in body", rng) + } + // safeURL must have dropped the javascript: favicon and footer link. + if strings.Contains(body, "javascript:alert") { + t.Fatalf("range %s: javascript: URL leaked into page", rng) + } + // sanitizeCSS must have stripped the breakout. + if strings.Contains(body, "