From 69a0e99b7e05fc7ad893459669bbc9c9285aa1e6 Mon Sep 17 00:00:00 2001 From: memetics19 Date: Tue, 14 Jul 2026 16:59:46 +0530 Subject: [PATCH 1/4] docs: design safe Uptime Kuma migration foundation --- ...uptime-kuma-migration-foundation-design.md | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-uptime-kuma-migration-foundation-design.md diff --git a/docs/superpowers/specs/2026-07-14-uptime-kuma-migration-foundation-design.md b/docs/superpowers/specs/2026-07-14-uptime-kuma-migration-foundation-design.md new file mode 100644 index 0000000..04bab22 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-uptime-kuma-migration-foundation-design.md @@ -0,0 +1,375 @@ +# Uptime Kuma Migration Foundation Design + +**Status:** Approved on 2026-07-14 + +## Context + +Pulse currently has a small `pulse-cli import uptime-kuma` command that reads an +Uptime Kuma JSON backup and creates one group followed by monitors through +individual REST calls. That path is not safe for real migrations: Uptime Kuma +v2 removed JSON backup/restore, real v1 exports use protocol-specific fields +that the parser ignores, push monitors are incorrectly converted to HTTP, +imports can leave partial state, reruns create duplicates, and the CLI is not +tested or shipped by the normal release workflow. + +This design establishes a reliable migration foundation for Uptime Kuma v1 +JSON exports. It adds a native push/heartbeat monitor because converting push +to polling is not semantically valid. It deliberately does not add gRPC. + +## Goals + +- Explicitly support Uptime Kuma v1 JSON configuration exports. +- Reject Uptime Kuma v2 and unknown input formats with actionable guidance. +- Produce a dry-run report before any write. +- Detect invalid, unsupported, behavior-changing, and conflicting resources. +- Apply a validated import atomically. +- Make reruns and network retries idempotent. +- Preserve stable source identities for imported groups and monitors. +- Add native, Uptime Kuma-compatible push heartbeat ingestion. +- Protect push credentials at rest and during reporting. +- Test the importer against sanitized real-export fixtures and the real Pulse + router/database stack. +- Run CLI tests in CI and publish an installable `pulse-cli` binary. + +## Non-goals + +- Uptime Kuma v2 data-directory or database migration. +- gRPC as a monitor type or management transport. +- Importing historical heartbeat/check data. +- Importing notification provider secrets or status-page definitions. +- Adding every Uptime Kuma monitor type. +- A complete general-purpose audit log. +- Full Pulse export/apply, OpenAPI, recurring maintenance, or general bulk CRUD. + Those remain later workstreams that can reuse the normalized import contract. + +## Chosen approach + +The CLI owns source-specific parsing and conversion. The Pulse API owns +authoritative validation, conflict detection, and transactional persistence. +The CLI sends normalized Pulse resources rather than raw Uptime Kuma backups, +so the server never needs to understand every upstream backup version and the +same API can later support CSV, YAML, or other migration adapters. + +Two alternatives were rejected: + +- Client-side create-and-rollback cannot guarantee cleanup after a crash or + network loss. +- Direct database migration bypasses validation and scoped authorization and is + brittle across Pulse versions. + +## Architecture + +The migration flow is: + +1. Read and size-limit the local backup file. +2. Detect and validate the Uptime Kuma v1 export version. +3. Parse the export with a versioned v1 schema. +4. Convert every source resource into a normalized import plan. +5. Classify compatibility locally and redact sensitive values from output. +6. Send the normalized plan to Pulse for authoritative validation. +7. Display the server report and plan hash. +8. Stop for `--dry-run`, or submit the same plan and hash for apply. +9. Revalidate against current database state and apply all resource mutations in + one SQLite transaction. +10. Return an import result manifest and persist an import-run record. + +The primary internal units are: + +- **Uptime Kuma v1 parser:** decodes the complete fields required for supported + conversion and rejects incompatible versions. +- **Converter:** maps v1 resources into source-neutral group and monitor inputs, + recording field-level compatibility findings. +- **Reporter:** renders deterministic human and JSON output without secrets. +- **Import planner:** performs Pulse validation and conflict detection and + produces a stable plan hash. +- **Import applier:** verifies the plan hash and applies the plan atomically. +- **Check recorder:** centralizes check-result persistence and incident/alert + evaluation so scheduled checks and push heartbeats use identical behavior. +- **Push heartbeat service:** authenticates heartbeat tokens, records incoming + results, rotates credentials, and detects missed heartbeats. + +## Source support and compatibility rules + +### Supported source format + +The parser accepts JSON with a v1 version and `monitorList`. The supported +version family is `1.x`; tests use fixtures from maintained late-v1 exports. +Missing versions, malformed versions, `2.x`, and unknown major versions are +invalid. For v2, the error explains that JSON backup/restore was removed and +that a v2 migration adapter is not yet available. + +The input file may contain sensitive notification data. The CLI reads it +locally but sends only normalized group and monitor data needed by Pulse. It +never prints or logs raw backup JSON. + +### Resource statuses + +Every group and monitor receives exactly one highest-severity status: + +- `compatible`: Pulse can preserve the relevant behavior. +- `behavior_change`: the resource can be imported, but identified behavior or + organization will differ. +- `unsupported`: Pulse cannot safely run the resource. +- `invalid`: source data is malformed or lacks required values. +- `conflict`: an existing Pulse resource with the stable source identity or a + conflicting target identity requires policy resolution. + +Findings contain a stable code, resource identity, field, and redacted message. +Apply is blocked by `invalid` or `unsupported`. Conflicts are handled by the +selected conflict policy. `behavior_change` is blocked unless the request sets +`accept_behavior_changes` and the CLI uses +`--accept-behavior-changes`. The older `allow_lossy` name is not used. + +### Monitor conversion + +- Uptime Kuma `http` maps to Pulse `http` when it is a GET request with behavior + Pulse can express. +- Uptime Kuma `keyword` maps to Pulse `http` plus `keyword_check` when matching + is non-inverted and the remaining HTTP behavior is supported. +- TCP targets are built from `hostname` and `port` with IPv6-safe host/port + joining. The source `url` field is not used as the TCP target. +- Ping and DNS targets come from `hostname`, not `url`. +- Push maps to native Pulse `push` and retains the source push token through the + secure token-import path. +- Top-level Uptime Kuma groups map to Pulse groups. Nested group paths are + flattened to `Parent / Child` and reported as a behavior change because Pulse + groups are currently flat. +- Active state, interval, timeout, name, supported expected status, keyword, and + group membership are preserved when representable. +- The default Uptime Kuma accepted-status rule `200-299` maps to Pulse's default + 2xx behavior. A single explicit status such as `200` maps to + `expected_status`. Multiple values, mixed ranges, or any other range are + unsupported because Pulse cannot express them exactly. +- Custom methods, request bodies, headers, authentication, custom TLS behavior, + inverted keywords, JSON-query checks, and incompatible accepted-status rules + make the monitor unsupported rather than creating a monitor that would check + the wrong thing. +- Retry settings, notification associations, tags, and other non-execution + metadata that Pulse cannot yet represent are reported as behavior changes. +- Other monitor types are unsupported and remain visible in the report. + +No monitor is silently skipped or converted to a different protocol. + +## Import API + +The normalized import API is protected by a dedicated `imports:write` API-key +scope or an authenticated admin session. + +The endpoint is `POST /api/imports`. Planning and applying use the same request +shape so validation cannot drift: + +```json +{ + "source": "uptime-kuma", + "source_version": "1.23.16", + "dry_run": true, + "conflict_policy": "fail", + "accept_behavior_changes": false, + "idempotency_key": "client-generated-random-value", + "expected_plan_hash": "", + "resources": { + "groups": [], + "monitors": [] + } +} +``` + +The CLI always performs a planning request first. The planning response includes +the complete redacted report and `plan_hash`. Apply sends the same normalized +resources with `dry_run: false` and `expected_plan_hash` set. The server +recomputes validation and conflicts inside the apply transaction and rejects a +stale hash with HTTP 409. + +The normalized request body remains subject to Pulse's existing 1 MiB API body +limit in the first release. The CLI checks the encoded plan size before sending +and reports a clear size-limit error. + +### Conflict policies + +- `fail` is the default and blocks apply when any conflict exists. +- `skip` leaves the existing resource unchanged and records it as skipped. +- `update` updates mutable fields on the resource with the same stable source + identity. It does not take over an unrelated internal resource based only on + a matching display name. + +Groups and monitors receive `source` and `external_id`. Partial unique indexes +on `(source, external_id)` where `external_id <> ''` enforce identity without +affecting existing internal resources. Uptime Kuma identities use source IDs, +for example `group:12` and `monitor:42`. + +`idempotency_key` protects a submitted apply from duplicate network delivery. +Repeated delivery returns the stored result. A later intentional rerun with a +new key is still idempotent at the resource level through source identity and +the selected conflict policy. + +### Transaction and import-run records + +An `import_runs` row is created before resource mutation with source, version, +input hash, idempotency key, policy, state, and timestamps. Group and monitor +mutations occur in one transaction. On success the transaction commits and the +run is marked completed with counts. On failure the transaction rolls back and +the run is marked failed with a redacted error summary. A failed run therefore +leaves traceability without leaving partial imported resources. + +The response manifest includes run ID, plan hash, created/updated/skipped counts, +resource findings, and completion state. It never contains raw authentication +values or push tokens. + +## Native push monitor behavior + +### Storage and lifecycle + +`push` becomes a valid monitor type. Push monitors are allowed to have an empty +`url`; other monitor types use type-specific target validation. + +Credentials live in a separate `push_monitor_tokens` table with one active +credential per monitor, a unique SHA-256 token hash, a non-sensitive prefix for +identification, and creation/rotation timestamps. Plaintext tokens are never +persisted. Imported Uptime Kuma push tokens are hashed during the transaction. + +New or rotated tokens are returned once. Rotation replaces the stored hash and +immediately invalidates the previous token. Token management requires the +normal monitor write scope or an admin session. Creating a push monitor through +`POST /api/monitors` returns the new token and complete push URL once. Imported +tokens are accepted and hashed but are not echoed back. The authenticated +`POST /api/monitors/{id}/push-token/rotate` endpoint returns a replacement token +and URL once. + +### Compatible heartbeat endpoint + +Pulse accepts the established Uptime Kuma heartbeat form: + +```text +GET /api/push/{token}?status=up&msg=OK&ping=123 +POST /api/push/{token}?status=up&msg=OK&ping=123 +``` + +The endpoint is public because the high-entropy token is the credential. It: + +- hashes the supplied token and looks up an active push monitor; +- accepts only `up` or `down` status; +- defaults the message to `OK` and limits its UTF-8 encoding to 1,024 bytes; +- accepts an optional finite ping value from 0 through 100,000,000,000 + milliseconds, matching the upstream compatibility bound; +- records receipt time on the server; +- writes a standard check result and invokes the shared incident/alert flow; and +- returns the same not-found response for missing, invalid, inactive, and + non-push monitors. + +Logs and errors never include the token or full request URL. + +Imported tokens must match `[A-Za-z0-9_-]{10,128}` so legacy ten-character v1 +tokens remain valid. Pulse-generated tokens contain 32 URL-safe random +characters with at least 192 bits of entropy. + +### Missed heartbeat detection + +Push monitors do not run a network checker. The scheduler runs a watchdog whose +first deadline is monitor creation plus `interval_seconds` and whose subsequent +deadline is the last heartbeat receipt plus `interval_seconds` plus a five-second +network-jitter allowance. Once a deadline is missed, it records a down result +through the shared check recorder. A later valid heartbeat records recovery. + +Scheduler reconciliation starts, updates, and stops push watchdogs when monitor +configuration or active state changes, just as it manages polling monitors. + +## CLI experience + +The primary commands are: + +```text +PULSE_TOKEN=... pulse-cli import uptime-kuma \ + --file backup.json \ + --server https://status.example.com \ + --dry-run + +PULSE_TOKEN=... pulse-cli import uptime-kuma \ + --file backup.json \ + --server https://status.example.com \ + --conflict update \ + --accept-behavior-changes +``` + +`PULSE_TOKEN` is preferred so secrets do not appear in process listings. The +legacy `--token` option may remain temporarily for compatibility but is hidden +from examples and emits no token value. + +Human output presents totals followed by actionable findings grouped by status. +`--output json` emits a stable machine-readable report. Exit codes distinguish +success (`0`), malformed input (`2`), blocked compatibility (`3`), conflict or +stale plan (`4`), and authentication/API/transport failure (`5`). Dry-run never +creates a group, monitor, token, or import-run record. + +The admin monitor form includes `Push` in the type selector, replaces the URL +field with an "Expected heartbeat every" interval control, and displays the +new endpoint once after creation. Existing push monitors show only the stored +token prefix and a rotation action because Pulse cannot recover hashed tokens. +The rotation confirmation warns that the previous endpoint stops working +immediately. + +## Error handling + +- Parser errors identify the JSON location or missing required field without + echoing source values that may contain secrets. +- API validation errors use stable finding codes rather than relying only on + prose. +- API clients include a size-limited, redacted server error message in failures + instead of reporting only the HTTP status. +- Context and cancellation flow from Cobra through HTTP requests and server + transactions. +- An interrupted apply rolls back resource mutations. A retried request with the + same idempotency key returns the recorded result or reports that the earlier + attempt did not complete. +- A stale plan or changed conflict state returns HTTP 409 and instructs the CLI + to rerun planning. + +## Testing strategy + +Testing follows TDD and includes: + +- Sanitized fixtures produced from real Uptime Kuma late-v1 exports. +- Parser golden tests for version detection, HTTP, keyword, TCP, ping, DNS, + groups, nested groups, push, unsupported types, and secret redaction. +- Conversion tests for IPv4, IPv6, missing host/port, behavior changes, and + unsupported execution semantics. +- Import planner tests for all statuses, stable plan hashes, and all conflict + policies. +- Real SQLite/router integration tests for planning and apply; permissive mock + handlers are not sufficient. +- A forced failure after earlier resource inserts proving that the resource + transaction rolls back completely. +- Repeated-delivery and intentional-rerun tests proving request and resource + idempotency. +- Push endpoint tests for GET and POST, up/down, invalid input, inactive + monitors, unknown tokens, redacted logging, token rotation, missed deadlines, + recovery, and incident creation. +- CLI tests for human/JSON reports, exit codes, environment-token handling, + cancellation, size limits, and server error propagation. +- CI jobs that vet, format, and test all Go modules (`api`, `cli`, and `agent`). +- Release checks that build and publish both `pulse` and `pulse-cli` and verify + documented installation commands. + +## Documentation and release behavior + +Documentation will say "Uptime Kuma v1 JSON import" everywhere and include a +prominent v2 limitation. It will explain dry-run, conflict modes, behavior-change +acceptance, push URL migration, token safety, rerun behavior, and unsupported +fields. + +The release workflow publishes `pulse-cli__` alongside the server +binary and includes both in checksums. The installer either installs both +binaries or the documentation provides an equally tested dedicated CLI install +path. CI and release workflows must test the CLI before claiming importer +support. + +## Later workstreams + +The normalized planner/applier and source identity model are intended to support +later, separately designed work for: + +- Uptime Kuma v2 migration from supported data backups; +- complete Pulse configuration export and idempotent apply; +- versioned OpenAPI documentation and generated clients; +- general bulk operations; +- a full audit-event subsystem; and +- recurring maintenance schedules. From e42cd71198807ac680f099ea11e836cedb5a103a Mon Sep 17 00:00:00 2001 From: memetics19 Date: Tue, 14 Jul 2026 17:09:56 +0530 Subject: [PATCH 2/4] docs: plan Uptime Kuma migration foundation --- ...-07-14-uptime-kuma-migration-foundation.md | 1211 +++++++++++++++++ 1 file changed, 1211 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-uptime-kuma-migration-foundation.md diff --git a/docs/superpowers/plans/2026-07-14-uptime-kuma-migration-foundation.md b/docs/superpowers/plans/2026-07-14-uptime-kuma-migration-foundation.md new file mode 100644 index 0000000..d97466f --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-uptime-kuma-migration-foundation.md @@ -0,0 +1,1211 @@ +# Uptime Kuma Migration Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a safe, atomic, idempotent Uptime Kuma v1 migration workflow with native push monitors, compatibility reporting, CLI distribution, and real integration coverage. + +**Architecture:** `pulse-cli` parses source-specific v1 JSON into a normalized resource plan. The Pulse API validates and hashes that plan, then applies it in one SQLite transaction using stable `(source, external_id)` identities. Push heartbeats use hashed bearer tokens and a shared result recorder so HTTP polling, incoming heartbeats, watchdog failures, incidents, and alerts follow one persistence path. + +**Tech Stack:** Go 1.25, Cobra, chi, `database/sql`, sqlc, SQLite/modernc, testify, Next.js 15/React 18/TypeScript, GitHub Actions. + +**Design:** `docs/superpowers/specs/2026-07-14-uptime-kuma-migration-foundation-design.md` + +--- + +## File structure + +New files have one responsibility each: + +- `api/internal/db/migrations/9_import_foundation.{up,down}.sql`: source identities, import runs, push-token storage, and the `push` monitor constraint. +- `api/internal/db/queries/imports.sql`: import-run and source-identity queries. +- `api/internal/db/queries/push_tokens.sql`: push-token lifecycle queries. +- `api/internal/monitorvalidation/validation.go`: validation shared by CRUD and imports. +- `api/internal/worker/checkresult/recorder.go`: check persistence plus incident/alert dispatch. +- `api/internal/push/token.go`: token generation, validation, hashing, and prefixing. +- `api/internal/handlers/push.go`: public heartbeat and authenticated rotation endpoints. +- `api/internal/importer/types.go`: normalized API request/response and finding types. +- `api/internal/importer/planner.go`: validation, conflict classification, canonicalization, and plan hashing. +- `api/internal/importer/applier.go`: idempotency and transactional resource mutation. +- `api/internal/handlers/imports.go`: HTTP decoding and status mapping for import planning/apply. +- `cli/internal/uptimekuma/model.go`: complete v1 source fields needed for compatibility decisions. +- `cli/internal/uptimekuma/converter.go`: v1-to-normalized conversion and field findings. +- `cli/internal/uptimekuma/testdata/v1.23.16-backup.json`: sanitized real-export fixture. +- `cli/internal/pulseclient/imports.go`: normalized import DTOs and API calls. +- `cli/importcmd/report.go`: deterministic human/JSON rendering and exit-code mapping. + +Generated files under `api/internal/generated/` are changed only by `make sqlc`. + +### Task 1: Add import identity, run, and push-token schema + +**Files:** +- Create: `api/internal/db/migrations/9_import_foundation.up.sql` +- Create: `api/internal/db/migrations/9_import_foundation.down.sql` +- Create: `api/internal/db/queries/imports.sql` +- Create: `api/internal/db/queries/push_tokens.sql` +- Modify: `api/internal/db/queries/groups.sql` +- Modify: `api/internal/db/queries/monitors.sql` +- Modify: `api/store/store.go` +- Test: `api/internal/db/db_test.go` +- Generate: `api/internal/generated/*.sql.go`, `api/internal/generated/models.go` + +- [ ] **Step 1: Write the migration test first** + +Add this test to `api/internal/db/db_test.go`: + +```go +func TestImportFoundationSchema(t *testing.T) { + db := testutil.NewTestDB(t) + ctx := context.Background() + + _, err := db.ExecContext(ctx, `INSERT INTO monitors + (name, url, type, interval_seconds, timeout_seconds, source, external_id) + VALUES ('Push', '', 'push', 60, 10, 'uptime-kuma', 'monitor:7')`) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, `INSERT INTO monitors + (name, url, type, interval_seconds, timeout_seconds, source, external_id) + VALUES ('Duplicate', '', 'push', 60, 10, 'uptime-kuma', 'monitor:7')`) + require.Error(t, err) + + _, err = db.ExecContext(ctx, `INSERT INTO import_runs + (source, source_version, input_hash, idempotency_key, conflict_policy, status) + VALUES ('uptime-kuma', '1.23.16', 'hash', 'request-1', 'fail', 'running')`) + require.NoError(t, err) + + var foreignKeys int + require.NoError(t, db.QueryRowContext(ctx, `PRAGMA foreign_keys`).Scan(&foreignKeys)) + require.Equal(t, 1, foreignKeys) +} +``` + +Add imports for `context`, `github.com/memetics19/pulse/api/testutil`, and testify `require` if absent. + +- [ ] **Step 2: Run the focused test and confirm the schema is missing** + +Run: `cd api && go test ./internal/db -run TestImportFoundationSchema -count=1` + +Expected: FAIL because `push` violates the current monitor type check or `import_runs` does not exist. + +- [ ] **Step 3: Create migration 9** + +Create `api/internal/db/migrations/9_import_foundation.up.sql` with the complete migration below: + +```sql +PRAGMA foreign_keys = OFF; + +CREATE TABLE monitors_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL CHECK(type IN ('http','https','tcp','ping','dns','ssl','infra','push')), + interval_seconds INTEGER NOT NULL DEFAULT 60, + timeout_seconds INTEGER NOT NULL DEFAULT 10, + expected_status INTEGER, + keyword_check TEXT NOT NULL DEFAULT '', + degraded_threshold_ms INTEGER NOT NULL DEFAULT 500, + down_threshold_ms INTEGER NOT NULL DEFAULT 2000, + is_active INTEGER NOT NULL DEFAULT 1, + group_id INTEGER REFERENCES monitor_groups(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'internal', + external_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO monitors_new +SELECT id, name, url, type, interval_seconds, timeout_seconds, expected_status, + keyword_check, degraded_threshold_ms, down_threshold_ms, is_active, + group_id, source, external_id, created_at +FROM monitors; + +DROP TABLE monitors; +ALTER TABLE monitors_new RENAME TO monitors; + +ALTER TABLE monitor_groups ADD COLUMN source TEXT NOT NULL DEFAULT 'internal'; +ALTER TABLE monitor_groups ADD COLUMN external_id TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX idx_monitors_source_external +ON monitors(source, external_id) WHERE external_id <> ''; + +CREATE UNIQUE INDEX idx_groups_source_external +ON monitor_groups(source, external_id) WHERE external_id <> ''; + +CREATE TABLE push_monitor_tokens ( + monitor_id INTEGER PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + rotated_at DATETIME +); + +CREATE TABLE import_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + source_version TEXT NOT NULL, + input_hash TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + conflict_policy TEXT NOT NULL CHECK(conflict_policy IN ('fail','skip','update')), + status TEXT NOT NULL CHECK(status IN ('running','completed','failed')), + plan_hash TEXT NOT NULL DEFAULT '', + summary_json TEXT NOT NULL DEFAULT '{}', + error_summary TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME +); + +PRAGMA foreign_keys = ON; +``` + +Create the down migration to remove imported-only state safely before restoring the original monitor constraint: + +```sql +DELETE FROM monitors WHERE type = 'push'; +DROP TABLE IF EXISTS push_monitor_tokens; +DROP TABLE IF EXISTS import_runs; +DROP INDEX IF EXISTS idx_monitors_source_external; +DROP INDEX IF EXISTS idx_groups_source_external; + +PRAGMA foreign_keys = OFF; +CREATE TABLE monitors_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL CHECK(type IN ('http','tcp','ping','dns','ssl','infra')), + interval_seconds INTEGER NOT NULL DEFAULT 60, + timeout_seconds INTEGER NOT NULL DEFAULT 10, + expected_status INTEGER, + keyword_check TEXT NOT NULL DEFAULT '', + degraded_threshold_ms INTEGER NOT NULL DEFAULT 500, + down_threshold_ms INTEGER NOT NULL DEFAULT 2000, + is_active INTEGER NOT NULL DEFAULT 1, + group_id INTEGER REFERENCES monitor_groups(id) ON DELETE SET NULL, + source TEXT NOT NULL DEFAULT 'internal', + external_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO monitors_old SELECT * FROM monitors; +DROP TABLE monitors; +ALTER TABLE monitors_old RENAME TO monitors; +PRAGMA foreign_keys = ON; +``` + +SQLite cannot remove the two new `monitor_groups` columns in-place; the down migration intentionally leaves them as backward-compatible columns. + +- [ ] **Step 4: Add source-identity and lifecycle queries** + +Append these named queries to the indicated query files: + +```sql +-- api/internal/db/queries/groups.sql +-- name: GetGroupBySourceExternalID :one +SELECT * FROM monitor_groups WHERE source = ? AND external_id = ?; + +-- name: CreateImportedGroup :one +INSERT INTO monitor_groups (name, display_order, description, source, external_id) +VALUES (?, ?, ?, ?, ?) RETURNING *; + +-- name: UpdateImportedGroup :one +UPDATE monitor_groups SET name = ?, display_order = ?, description = ? +WHERE id = ? RETURNING *; + +-- api/internal/db/queries/monitors.sql +-- name: GetMonitorBySourceExternalID :one +SELECT * FROM monitors WHERE source = ? AND external_id = ?; + +-- api/internal/db/queries/push_tokens.sql +-- name: GetPushTokenByHash :one +SELECT * FROM push_monitor_tokens WHERE token_hash = ?; + +-- name: GetPushTokenByMonitor :one +SELECT * FROM push_monitor_tokens WHERE monitor_id = ?; + +-- name: UpsertPushToken :one +INSERT INTO push_monitor_tokens (monitor_id, token_hash, prefix) +VALUES (?, ?, ?) +ON CONFLICT(monitor_id) DO UPDATE SET + token_hash = excluded.token_hash, + prefix = excluded.prefix, + rotated_at = CURRENT_TIMESTAMP +RETURNING *; + +-- api/internal/db/queries/imports.sql +-- name: CreateImportRun :one +INSERT INTO import_runs + (source, source_version, input_hash, idempotency_key, conflict_policy, status, plan_hash) +VALUES (?, ?, ?, ?, ?, 'running', ?) RETURNING *; + +-- name: GetImportRunByIdempotencyKey :one +SELECT * FROM import_runs WHERE idempotency_key = ?; + +-- name: CompleteImportRun :one +UPDATE import_runs SET status = 'completed', summary_json = ?, completed_at = CURRENT_TIMESTAMP +WHERE id = ? RETURNING *; + +-- name: FailImportRun :one +UPDATE import_runs SET status = 'failed', error_summary = ?, completed_at = CURRENT_TIMESTAMP +WHERE id = ? RETURNING *; +``` + +- [ ] **Step 5: Generate sqlc code and expose aliases used by the worker** + +Run: `make sqlc` + +Expected: sqlc updates generated models/queries without errors. + +Add aliases for `PushMonitorToken`, `ImportRun`, and new parameter types to `api/store/store.go`, following the existing explicit alias sections. + +- [ ] **Step 6: Run migration and database tests** + +Run: `cd api && go test ./internal/db ./internal/generated -count=1` + +Expected: PASS. + +- [ ] **Step 7: Commit the schema slice** + +```bash +git add api/internal/db api/internal/generated api/store/store.go +git commit -m "feat(db): add import identity and push token schema" +``` + +### Task 2: Extract shared validation and check-result recording + +**Files:** +- Create: `api/internal/monitorvalidation/validation.go` +- Create: `api/internal/monitorvalidation/validation_test.go` +- Create: `api/internal/worker/checkresult/recorder.go` +- Create: `api/internal/worker/checkresult/recorder_test.go` +- Modify: `api/internal/handlers/monitors.go` +- Modify: `api/internal/worker/scheduler/scheduler.go` +- Modify: `api/internal/worker/incident/detector.go` +- Modify: `api/internal/worker/incident/detector_test.go` +- Modify: `api/internal/db/queries/check_results.sql` +- Generate: `api/internal/generated/check_results.sql.go` + +- [ ] **Step 1: Write type-specific validation tests** + +Create table-driven cases asserting that push permits an empty target, HTTP still requires a valid URL, and TCP requires `host:port`: + +```go +func TestValidate(t *testing.T) { + tests := []struct { + name string + in monitorvalidation.Input + want string + }{ + {"push without URL", monitorvalidation.Input{Type: "push", IntervalSeconds: 60}, ""}, + {"http without URL", monitorvalidation.Input{Type: "http", IntervalSeconds: 60}, "url is required"}, + {"tcp URL syntax", monitorvalidation.Input{Type: "tcp", URL: "tcp://db:5432", IntervalSeconds: 60}, "tcp target must be host:port"}, + {"tcp host port", monitorvalidation.Input{Type: "tcp", URL: "db:5432", IntervalSeconds: 60}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, monitorvalidation.Validate(tt.in, true)) + }) + } +} +``` + +- [ ] **Step 2: Verify the validation package does not exist** + +Run: `cd api && go test ./internal/monitorvalidation -count=1` + +Expected: FAIL because the package is missing. + +- [ ] **Step 3: Implement the shared validator and replace handler-local validation** + +Create this public input and validator: + +```go +type Input struct { + URL string + Type string + IntervalSeconds int64 +} + +func Validate(in Input, allowPrivate bool) string { + if in.IntervalSeconds < 1 { + return "interval_seconds must be at least 1" + } + valid := map[string]bool{"http": true, "https": true, "tcp": true, "ping": true, "dns": true, "ssl": true, "infra": true, "push": true} + if !valid[in.Type] { + return "invalid type" + } + if in.Type == "push" { + return "" + } + if in.URL == "" { + return "url is required" + } + if in.Type == "tcp" { + if strings.Contains(in.URL, "://") { + return "tcp target must be host:port" + } + if _, _, err := net.SplitHostPort(in.URL); err != nil { + return "tcp target must be host:port" + } + } + if in.Type == "http" || in.Type == "https" { + if err := netguard.ValidateURL(in.URL, allowPrivate); err != nil { + return err.Error() + } + } + return "" +} +``` + +Use it from monitor create/update handlers and delete the handler-local type map and validator. + +- [ ] **Step 4: Make incident detection database-backed** + +Add this query and regenerate sqlc: + +```sql +-- name: LatestTwoCheckResults :many +SELECT * FROM check_results +WHERE monitor_id = ? +ORDER BY checked_at DESC, id DESC +LIMIT 2; +``` + +Replace the detector's mutex/map counter with a `LatestTwoCheckResults` check. It creates an incident only when both latest results are `down`, preserving the existing active-incident and maintenance checks. This makes HTTP handlers and the worker consistent across processes and restarts. + +- [ ] **Step 5: Write recorder tests before moving scheduler persistence** + +Test that `Recorder.Record` persists an up result, applies configured latency thresholds, and creates an incident after two down results: + +```go +func TestRecorderCreatesIncidentAfterTwoDownResults(t *testing.T) { + db := testutil.NewTestDB(t) + q := store.New(db) + mon, err := q.CreateMonitor(t.Context(), store.CreateMonitorParams{ + Name: "API", Url: "https://example.com", Type: "http", + IntervalSeconds: 60, TimeoutSeconds: 10, IsActive: true, + }) + require.NoError(t, err) + r := checkresult.New(q, incident.NewDetector(q), nil) + for range 2 { + require.NoError(t, r.Record(t.Context(), mon, checkresult.Input{ + Status: "down", CheckedAt: time.Now(), ErrorMessage: "timeout", + })) + } + incidents, err := q.ListActiveIncidents(t.Context()) + require.NoError(t, err) + require.Len(t, incidents, 1) +} +``` + +- [ ] **Step 6: Implement `checkresult.Recorder` and delegate scheduler writes** + +Define: + +```go +type Input struct { + Status string + ResponseTimeMs *int64 + StatusCode *int64 + ErrorMessage string + CheckedAt time.Time +} + +type Recorder struct { + q *store.Queries + detector *incident.Detector + alerter *alerter.Dispatcher +} + +func New(q *store.Queries, d *incident.Detector, a *alerter.Dispatcher) *Recorder { + return &Recorder{q: q, detector: d, alerter: a} +} +``` + +`Record` applies degraded/down latency thresholds when response time exists, +inserts `check_results`, calls the detector, and dispatches the current alert +shape only when a new incident is created. Change scheduler construction to +receive a recorder and replace lines 111-152 of its current `check` method with +one `recorder.Record` call. + +- [ ] **Step 7: Run the affected tests** + +Run: `cd api && go test ./internal/monitorvalidation ./internal/worker/checkresult ./internal/worker/incident ./internal/worker/scheduler -count=1` + +Expected: PASS. + +- [ ] **Step 8: Commit the shared behavior slice** + +```bash +git add api/internal/monitorvalidation api/internal/worker/checkresult api/internal/worker/incident api/internal/worker/scheduler api/internal/handlers/monitors.go api/internal/db/queries/check_results.sql api/internal/generated +git commit -m "refactor(worker): share monitor validation and result recording" +``` + +### Task 3: Add secure push creation, heartbeat, and rotation APIs + +**Files:** +- Create: `api/internal/push/token.go` +- Create: `api/internal/push/token_test.go` +- Create: `api/internal/handlers/push.go` +- Create: `api/internal/handlers/push_test.go` +- Modify: `api/internal/handlers/monitors.go` +- Modify: `api/internal/handlers/monitors_test.go` +- Modify: `api/internal/server/server.go` +- Modify: `api/internal/middleware/apikey.go` +- Modify: `api/internal/middleware/apikey_test.go` +- Create: `api/internal/middleware/requestlog.go` +- Create: `api/internal/middleware/requestlog_test.go` + +- [ ] **Step 1: Write token tests** + +```go +func TestTokenLifecycle(t *testing.T) { + token, err := push.GenerateToken() + require.NoError(t, err) + require.Regexp(t, `^[A-Za-z0-9_-]{32}$`, token) + require.True(t, push.ValidToken(token)) + require.Len(t, push.HashToken(token), 64) + require.Equal(t, token[:8], push.Prefix(token)) + require.True(t, push.ValidToken("abcdefghij")) + require.False(t, push.ValidToken("short")) +} +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `cd api && go test ./internal/push -count=1` + +Expected: FAIL because token helpers are missing. + +- [ ] **Step 3: Implement token helpers** + +Read exactly 24 bytes with `crypto/rand.Read` and encode them with +`base64.RawURLEncoding` to obtain 32 characters without trimming. Validate with +the anchored expression `[A-Za-z0-9_-]{10,128}`, hash with SHA-256 hex, and +return at most eight characters from `Prefix`. + +- [ ] **Step 4: Write heartbeat handler integration tests** + +Construct a real database, push monitor, hashed token row, DB-backed detector, +and recorder. Mount the handler on a chi router so `chi.URLParam` is exercised, +then assert: + +```go +router := chi.NewRouter() +router.Get("/api/push/{token}", h.Heartbeat) +req := httptest.NewRequest(http.MethodGet, "/api/push/abcdefghij?status=up&msg=OK&ping=12", nil) +rr := httptest.NewRecorder() +router.ServeHTTP(rr, req) +require.Equal(t, http.StatusOK, rr.Code) +latest, err := q.LatestCheckResult(t.Context(), monitor.ID) +require.NoError(t, err) +require.Equal(t, "up", latest.Status) +require.Equal(t, int64(12), *latest.ResponseTimeMs) +``` + +Add cases for POST, invalid status, ping above `100000000000`, message over 1024 +bytes, unknown token, inactive monitor, non-push monitor, and rotation invalidating +the old hash. + +Add a request-logging test that captures the logger output for +`/api/push/abcdefghij?msg=secret` and asserts neither `abcdefghij` nor `secret` +appears. + +- [ ] **Step 5: Implement push handler methods** + +Create `NewPush(q, recorder)` with: + +```go +func (h *Push) Heartbeat(w http.ResponseWriter, r *http.Request) +func (h *Push) Rotate(w http.ResponseWriter, r *http.Request) +``` + +Heartbeat validates before lookup, hashes the path token, loads token and monitor, +requires `monitor.Type == "push" && monitor.IsActive`, parses status/message/ping, +and records through `checkresult.Recorder`. Every credential lookup failure returns +the same `404 {"error":"push monitor not found"}`. Rotation verifies monitor type, +generates and upserts a token, and returns `{token,push_url}` once. + +- [ ] **Step 6: Generate a token on normal push-monitor creation** + +Extend `handlers.Monitors` with `db func() *sql.DB`, update `NewMonitors` callers +to pass `a.DB` in the server and a test DB closure in handler tests, and create a +push monitor plus its token inside one `sql.Tx`. Change the create response to an +additive envelope: + +```go +type monitorCreateResponse struct { + generated.Monitor + PushToken string `json:"push_token,omitempty"` + PushURL string `json:"push_url,omitempty"` +} +``` + +For `type == "push"`, begin a transaction, create the monitor through +`generated.New(tx)`, generate/upsert the credential, commit, and return the +one-time values. Roll back on every error. For every other type, keep the JSON +monitor fields unchanged and omit push fields. + +Build `push_url` from the request's scheme/host, honoring a single +`X-Forwarded-Proto` value of `http` or `https`, and append +`?status=up&msg=OK&ping=`. The token helper receives no request object and never +logs the resulting URL. + +- [ ] **Step 7: Wire routes and scopes** + +Register GET and POST `/api/push/{token}` in the public section. Register +`POST /api/monitors/{id}/push-token/rotate` inside authenticated monitor routes. +Map `/api/imports` to `imports:write` in middleware and add an exact-scope test. +Inside `server.New`, construct a DB-backed detector, dispatcher, and recorder +from the existing live `generated.Queries` handle and config values, then give +that recorder to the push handler. The detector is database-backed from Task 2, +so this HTTP-side instance remains consistent with the worker-side instance. + +Replace `chimiddleware.Logger` with `middleware.RequestLogger`. The replacement +wraps status/latency like the current logger but converts every `/api/push/{token}` +path to `/api/push/[REDACTED]` and omits its query string before logging. Other +routes retain method, path, status, response bytes, and duration. + +- [ ] **Step 8: Run handler and middleware tests** + +Run: `cd api && go test ./internal/push ./internal/handlers ./internal/middleware ./internal/server -count=1` + +Expected: PASS. + +- [ ] **Step 9: Commit push HTTP support** + +```bash +git add api/internal/push api/internal/handlers api/internal/server api/internal/middleware +git commit -m "feat(push): add secure heartbeat and rotation APIs" +``` + +### Task 4: Add push missed-heartbeat watchdogs + +**Files:** +- Modify: `api/internal/worker/scheduler/scheduler.go` +- Modify: `api/internal/worker/scheduler/reconcile_test.go` +- Create: `api/internal/worker/scheduler/push_test.go` +- Modify: `api/internal/worker/worker.go` + +- [ ] **Step 1: Write deterministic watchdog tests with an injected clock** + +Add an unexported scheduler clock interface: + +```go +type clock interface { + Now() time.Time + NewTimer(time.Duration) timer +} + +type timer interface { + C() <-chan time.Time + Stop() bool +} +``` + +Use a fake clock in `push_test.go` to verify no immediate down result, a down +result at `created_at + interval + 5s`, and a received up result extending the +next deadline. + +- [ ] **Step 2: Run the test and confirm normal scheduler behavior fails** + +Run: `cd api && go test ./internal/worker/scheduler -run Push -count=1` + +Expected: FAIL because push is treated like a polling checker or no watchdog exists. + +- [ ] **Step 3: Implement `runPushMonitor`** + +The loop loads `LatestCheckResult`; `sql.ErrNoRows` uses `mon.CreatedAt` as the +base. It waits until `base + interval + 5*time.Second`, reloads before declaring +failure, and records: + +```go +checkresult.Input{ + Status: "down", + CheckedAt: s.clock.Now(), + ErrorMessage: "no heartbeat received before deadline", +} +``` + +`RunMonitor` delegates push monitors to this loop and never requests a checker. +Reconciliation already restarts the loop when the type or interval fingerprint +changes. + +- [ ] **Step 4: Pass the shared recorder from worker construction** + +Create one recorder in `worker.Run`, pass it into `scheduler.New`, and preserve +all existing checker registrations except no checker is registered for `push`. + +- [ ] **Step 5: Run worker and scheduler suites** + +Run: `cd api && go test ./internal/worker/... -count=1` + +Expected: PASS. + +- [ ] **Step 6: Commit watchdog support** + +```bash +git add api/internal/worker +git commit -m "feat(push): detect missed heartbeat deadlines" +``` + +### Task 5: Add push monitor administration UI + +**Files:** +- Modify: `ui/src/lib/types.ts` +- Modify: `ui/src/lib/api.ts` +- Modify: `ui/src/app/admin/monitors/page.tsx` +- Modify: `ui/src/app/admin/api-keys/page.tsx` + +- [ ] **Step 1: Add typed API responses** + +Add `push` to `Monitor['type']` and define: + +```ts +export type MonitorCreateResponse = Monitor & { + push_token?: string + push_url?: string +} + +export type PushTokenResponse = { + token: string + push_url: string +} +``` + +Change `adminCreateMonitor` to return `MonitorCreateResponse` and add: + +```ts +export async function adminRotatePushToken(id: number): Promise { + const res = await fetch(`${BASE}/api/monitors/${id}/push-token/rotate`, { + method: 'POST', credentials: 'include', headers: ADMIN_HEADERS, + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} +``` + +Add `imports:write` to the API-key page's `ALL_SCOPES` list so the documented +CLI key can be created without manually crafting a request. + +- [ ] **Step 2: Implement the push form and one-time reveal** + +In the monitor page: + +- add `push` to `TYPES`; +- hide URL, timeout, latency, and keyword controls for push; +- relabel interval as `Expected heartbeat every (s)`; +- capture `push_url` from create/rotate responses in one-time reveal state; +- show `Rotate token` only while editing a push monitor; and +- require a confirmation click before rotation. + +Use the existing API-key one-time reveal banner pattern, including copy and +dismiss actions, so no new design system is introduced. + +- [ ] **Step 3: Build the static UI** + +Run: `cd ui && NEXT_PUBLIC_API_URL="" npm run build` + +Expected: Next.js static export succeeds with no TypeScript errors. + +- [ ] **Step 4: Commit UI support** + +```bash +git add ui/src/lib/types.ts ui/src/lib/api.ts ui/src/app/admin/monitors/page.tsx +git commit -m "feat(ui): manage push heartbeat monitors" +``` + +### Task 6: Replace the fabricated parser with a versioned v1 converter + +**Files:** +- Create: `cli/internal/uptimekuma/model.go` +- Create: `cli/internal/uptimekuma/converter.go` +- Create: `cli/internal/uptimekuma/converter_test.go` +- Create: `cli/internal/uptimekuma/testdata/v1.23.16-backup.json` +- Modify: `cli/internal/uptimekuma/parser.go` +- Modify: `cli/internal/uptimekuma/parser_test.go` +- Create: `cli/internal/pulseclient/imports.go` + +- [ ] **Step 1: Add a sanitized real v1 fixture** + +Start from an actual Uptime Kuma 1.23.16 export and retain these representative +entries with fake hosts/secrets: top-level group, nested group, HTTP, keyword, +TCP with separate hostname/port, ping, DNS, push with ten-character token, +JSON-query, and an authenticated HTTP monitor. Remove notification provider +objects completely while retaining monitor notification IDs for behavior-change +classification. + +- [ ] **Step 2: Write parser and conversion golden assertions** + +```go +func TestConvertV1Fixture(t *testing.T) { + data, err := os.ReadFile("testdata/v1.23.16-backup.json") + require.NoError(t, err) + backup, err := uptimekuma.ParseV1(data) + require.NoError(t, err) + require.Equal(t, "1.23.16", backup.Version) + + plan := uptimekuma.Convert(backup) + require.Equal(t, "db.example.test:5432", findMonitor(plan, "TCP").URL) + require.Equal(t, "push", findMonitor(plan, "Heartbeat").Type) + require.Empty(t, findMonitor(plan, "Heartbeat").URL) + require.Equal(t, "Core / Internal", findGroup(plan, "group:2").Name) + require.Contains(t, findingCodes(plan), "nested_group_flattened") + require.Contains(t, findingCodes(plan), "http_auth_unsupported") +} +``` + +Add rejection tests for missing version, malformed version, and `2.0.0` with an +error mentioning the removed JSON backup/restore path. + +- [ ] **Step 3: Define source and normalized DTOs** + +The v1 model includes ID, name, description, parent, URL, method, hostname, port, +type, interval, timeout, active, max retries, retry interval, keyword/inversion, +accepted statuses, headers/body/auth presence, TLS flags, DNS settings, tags, +notification IDs, and push token. Sensitive strings are parsed only to determine +presence and are never placed in finding messages. + +Define client-side normalized types in `cli/internal/pulseclient/imports.go` with +the exact JSON names from the design: `ImportRequest`, `Resources`, `GroupInput`, +`MonitorInput`, `Finding`, `ResourceReport`, `ImportResponse`, and `Counts`. +`GroupInput` and `MonitorInput` each carry a `findings` array so the source +adapter's field-level compatibility decisions survive JSON transport. The +server adds its own validation/conflict findings and never removes adapter +findings. + +- [ ] **Step 4: Implement parser and converter** + +`ParseV1` uses `json.Decoder`, validates major version `1`, and returns typed +source data. `Convert` sorts by source ID, builds group paths with cycle +detection, uses `net.JoinHostPort`, maps default `200-299` to nil expected status, +maps one explicit integer to a pointer, and emits stable finding codes. It never +maps unsupported monitors into runnable Pulse monitors. + +- [ ] **Step 5: Remove fabricated expectations** + +Delete `push -> http`, `tcp://...`, and standalone Uptime Kuma SSL examples from +the old tests. Keep malformed JSON and empty-list coverage using versioned input. + +- [ ] **Step 6: Run CLI conversion tests** + +Run: `cd cli && go test ./internal/uptimekuma ./internal/pulseclient -count=1` + +Expected: PASS. + +- [ ] **Step 7: Commit source conversion** + +```bash +git add cli/internal/uptimekuma cli/internal/pulseclient/imports.go +git commit -m "feat(cli): parse and classify Uptime Kuma v1 exports" +``` + +### Task 7: Implement authoritative import planning and plan hashes + +**Files:** +- Create: `api/internal/importer/types.go` +- Create: `api/internal/importer/planner.go` +- Create: `api/internal/importer/planner_test.go` +- Create: `api/internal/importer/hash.go` +- Create: `api/internal/importer/hash_test.go` + +- [ ] **Step 1: Write planner status tests** + +Create tests with real SQLite rows covering compatible create, behavior-change +blocking, unsupported blocking, invalid target, same-source conflict, unrelated +same-name resource, and `fail`/`skip`/`update` policies. Assert a same-name +internal monitor is not taken over. + +```go +plan, err := importer.NewPlanner(q, true).Plan(t.Context(), req) +require.NoError(t, err) +require.Equal(t, importer.StateBlocked, plan.State) +require.Equal(t, "behavior_change", plan.Resources.Monitors[0].Status) +require.NotEmpty(t, plan.PlanHash) +``` + +- [ ] **Step 2: Define the server contract** + +Mirror the CLI JSON DTOs exactly. Use constants: + +```go +const ( + StatusCompatible = "compatible" + StatusBehaviorChange = "behavior_change" + StatusUnsupported = "unsupported" + StatusInvalid = "invalid" + StatusConflict = "conflict" + StateReady = "ready" + StateBlocked = "blocked" +) +``` + +`Finding` contains `Code`, `ResourceKind`, `ExternalID`, `Field`, `Severity`, and +`Message`. Reject finding messages over 512 UTF-8 bytes and replace every exact +push-token value present elsewhere in the request with `[REDACTED]` before +hashing, returning, logging, or persisting the finding. The first-party converter +uses fixed messages that contain no arbitrary source values. + +- [ ] **Step 3: Implement validation and conflict classification** + +Validate source/version/policy/idempotency key, duplicate external IDs within the +request, group references, monitor values through `monitorvalidation.Validate`, +and push token format. Query existing groups/monitors only by `(source, +external_id)`. Apply the requested conflict policy to produce create/update/skip +actions while preserving all findings supplied by the CLI. + +- [ ] **Step 4: Implement canonical plan hashing** + +Copy and sort groups and monitors by external ID, sort findings by +`resource_kind/external_id/code/field`, serialize only normalized desired state, +actions, and fingerprints of matched database rows, then compute SHA-256 hex. +Exclude `dry_run`, `expected_plan_hash`, and `idempotency_key`. Two semantically +identical requests in different slice order must produce the same hash. + +- [ ] **Step 5: Run planner tests** + +Run: `cd api && go test ./internal/importer -run 'Plan|Hash' -count=1` + +Expected: PASS. + +- [ ] **Step 6: Commit planner support** + +```bash +git add api/internal/importer +git commit -m "feat(import): add compatibility planning and stable hashes" +``` + +### Task 8: Apply plans transactionally with request and resource idempotency + +**Files:** +- Create: `api/internal/importer/applier.go` +- Create: `api/internal/importer/applier_test.go` +- Create: `api/internal/handlers/imports.go` +- Create: `api/internal/handlers/imports_test.go` +- Modify: `api/internal/server/server.go` + +- [ ] **Step 1: Write rollback and idempotency tests first** + +In the rollback test, install this SQLite trigger before applying a two-monitor +plan: + +```sql +CREATE TRIGGER fail_second_import +BEFORE INSERT ON monitors +WHEN NEW.external_id = 'monitor:2' +BEGIN + SELECT RAISE(ABORT, 'forced import failure'); +END; +``` + +Assert apply fails, no imported groups/monitors/tokens exist, and the import run +is `failed`. Add a second test applying the same request twice with the same +idempotency key and assert the stored completed response is returned without new +rows. + +- [ ] **Step 2: Implement `Applier.Apply`** + +Algorithm: + +1. Look up `idempotency_key`; return completed summary, 409 for running, or the + stored failed result. +2. Compute `input_hash` as SHA-256 of the canonical normalized resources and + source/version, then plan against the current database and compare + `expected_plan_hash`. +3. Reject blocked plans before creating a run. +4. Create a running import-run row. +5. Begin `sql.Tx`, build `generated.New(tx)`, and re-plan inside the transaction. +6. Create/update/skip groups while recording external-ID-to-database-ID mapping. +7. Create/update/skip monitors with resolved group IDs. +8. Upsert hashed imported push tokens for created/updated push monitors. +9. Commit the resource transaction. +10. Store the redacted completed summary; on any pre-commit error roll back and + mark the run failed. + +Never persist plaintext push tokens in the summary. + +- [ ] **Step 3: Implement the HTTP handler** + +`handlers.Imports.Post` decodes with `DisallowUnknownFields`, enforces the 1 MiB +body cap already applied by middleware, and calls planner for dry-run or applier +for apply. Status mapping is: + +- 200 for ready dry-run or replayed completion; +- 201 for a newly completed apply; +- 400 for malformed/invalid requests; +- 409 for blocked compatibility, conflict, stale hash, or running idempotency key; +- 500 for redacted internal failures. + +- [ ] **Step 4: Register the authenticated import route** + +Construct the handler with `a.DB` access and `cfg.AllowPrivateMonitors`, then add +`r.Post("/api/imports", imports.Post)` inside `RequireSessionOrAPIKey` routes. + +- [ ] **Step 5: Run transactional and handler tests** + +Run: `cd api && go test ./internal/importer ./internal/handlers ./internal/server -count=1` + +Expected: PASS, including the forced-trigger rollback test. + +- [ ] **Step 6: Commit atomic apply support** + +```bash +git add api/internal/importer api/internal/handlers/imports.go api/internal/handlers/imports_test.go api/internal/server/server.go +git commit -m "feat(import): apply migration plans atomically" +``` + +### Task 9: Turn `pulse-cli import` into a plan/apply workflow + +**Files:** +- Modify: `cli/internal/pulseclient/client.go` +- Modify: `cli/internal/pulseclient/client_test.go` +- Modify: `cli/internal/pulseclient/imports.go` +- Modify: `cli/importcmd/import.go` +- Modify: `cli/importcmd/import_test.go` +- Create: `cli/importcmd/report.go` +- Create: `cli/importcmd/report_test.go` +- Modify: `cli/cmd/pulse-cli/main.go` + +- [ ] **Step 1: Write HTTP client tests for plan and apply** + +Use `httptest.Server` to assert both calls send the normalized payload, bearer +token, context, idempotency key, and apply plan hash. Return a 409 body and assert +the client includes a maximum 64 KiB redacted message rather than only the status. + +- [ ] **Step 2: Implement context-aware client calls** + +Add: + +```go +func (c *Client) Import(ctx context.Context, req ImportRequest) (ImportResponse, error) +``` + +Normalize the base URL with `strings.TrimRight`, use +`http.NewRequestWithContext`, decode JSON on success, and read at most 64 KiB on +failure. Do not include request bodies or authorization values in errors. + +- [ ] **Step 3: Write command behavior tests** + +Cover explicit-token override, environment-token fallback, missing token, +dry-run making one request, +apply making plan then apply requests, conflict modes, behavior-change acceptance, +JSON output, invalid output mode, v2 rejection without network calls, and plan +size over 1 MiB. + +- [ ] **Step 4: Implement flags and orchestration** + +Use an options struct: + +```go +type Options struct { + File string + Server string + Token string + DryRun bool + ConflictPolicy string + AcceptBehaviorChanges bool + Output string +} +``` + +Resolve a non-empty hidden `--token` first for backward compatibility, otherwise +use `PULSE_TOKEN`; examples and help recommend the environment variable. Validate +`conflict` in `fail|skip|update` and output in `human|json`. Parse, convert, encode +to measure size, generate one cryptographically random idempotency key, plan, +render, stop for dry-run/blockers, then apply using the returned hash. +Pass `cmd.Context()` into both client calls so Ctrl-C and parent cancellation +terminate planning or apply requests. + +- [ ] **Step 5: Implement stable reporting and exit errors** + +Define `ExitError{Code int; Err error}` and map malformed input `2`, blocked +compatibility `3`, conflict/stale plan `4`, and API/auth/transport `5`. Human +output groups findings by status and JSON output serializes only the response. +Update `main` to `errors.As` the returned error and exit with its code; success is +zero. + +- [ ] **Step 6: Run all CLI tests** + +Run: `cd cli && go test ./... -count=1` + +Expected: PASS. + +- [ ] **Step 7: Commit the CLI workflow** + +```bash +git add cli +git commit -m "feat(cli): add dry-run and idempotent import apply" +``` + +### Task 10: Prove real end-to-end import behavior + +**Files:** +- Create: `api/internal/server/import_integration_test.go` +- Modify: `cli/importcmd/import_test.go` +- Modify: `api/internal/handlers/push_test.go` + +- [ ] **Step 1: Add a real-router import integration test** + +Build `app.App` with `testutil.NewTestDB`, create an API key holding +`imports:write`, call `server.New`, submit a normalized dry-run, then apply with +the returned hash. Assert groups, TCP target, push monitor, hashed token, import +run, and repeated `skip`/`update` behavior through real routes. + +- [ ] **Step 2: Add a CLI-to-real-router test** + +Serve the real Pulse router with `httptest.NewServer`, execute the Cobra command +against the sanitized v1 fixture, and assert the command succeeds without the +old permissive `/api/monitors` mock. Verify the database contains the expected +source/external identities and no unsupported monitors. + +- [ ] **Step 3: Add migration-continuity push verification** + +Read the fake imported push token from the fixture, call the Pulse-compatible +`/api/push/{token}` endpoint, and assert an up check result exists. This proves an +existing v1 heartbeat client can switch only its base hostname. + +- [ ] **Step 4: Run complete Go verification** + +Run: + +```bash +cd api && go vet ./... && go test ./... -count=1 +cd ../cli && go vet ./... && go test ./... -count=1 +cd ../agent && go vet ./... && go test ./... -count=1 +``` + +Expected: all commands PASS. + +- [ ] **Step 5: Commit end-to-end coverage** + +```bash +git add api/internal/server/import_integration_test.go api/internal/handlers/push_test.go cli/importcmd/import_test.go +git commit -m "test(import): verify migration through the real API" +``` + +### Task 11: Ship the CLI and correct the documentation + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `Makefile` +- Modify: `deploy/install.sh` +- Modify: `docs/getting-started.md` +- Modify: `README.md` + +- [ ] **Step 1: Expand local and CI verification to every Go module** + +Make `make test` execute `go test ./... -count=1` separately in `api`, `cli`, and +`agent`. Make lint/vet and gofmt checks cover the same module directories. Mirror +those commands in CI so importer tests gate pull requests and releases. + +- [ ] **Step 2: Publish both binaries** + +Extend the release target loop to build: + +```bash +(cd api && GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" -o "../dist-bin/pulse_${os}_${arch}" ./cmd/pulse) +(cd cli && GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" -o "../dist-bin/pulse-cli_${os}_${arch}" ./cmd/pulse-cli) +``` + +Include both patterns in SHA256SUMS and GitHub release assets. + +- [ ] **Step 3: Install both binaries** + +Update `deploy/install.sh` to download and verify `pulse` and `pulse-cli`, install +both under `/usr/local/bin`, and keep the systemd service pointed only at +`/usr/local/bin/pulse`. If either current-release asset is missing, fail with the +exact missing URL rather than silently installing a partial toolset. + +- [ ] **Step 4: Correct migration documentation** + +Document: + +- the explicit Uptime Kuma v1-only support boundary; +- the v2 removal of JSON backup/restore; +- `PULSE_TOKEN`, `--dry-run`, `--conflict`, `--accept-behavior-changes`, and + `--output json`; +- compatibility statuses and exit codes; +- atomicity, rerun behavior, and the 1 MiB normalized-plan limit; +- push endpoint continuity and token rotation; and +- unsupported fields/types without claiming groups or monitors are silently + imported. + +- [ ] **Step 5: Run final release-shaped verification** + +Run: + +```bash +make test +make ui +for module in api cli agent; do (cd "$module" && go vet ./...); done +git diff --check +``` + +Expected: all tests/builds pass and `git diff --check` prints nothing. + +- [ ] **Step 6: Build local release binaries as a smoke test** + +Run: + +```bash +(cd api && CGO_ENABLED=0 go build -trimpath -o ../bin/pulse ./cmd/pulse) +(cd cli && CGO_ENABLED=0 go build -trimpath -o ../bin/pulse-cli ./cmd/pulse-cli) +./bin/pulse-cli import uptime-kuma --help +``` + +Expected: both binaries build and help shows dry-run, conflict, +accept-behavior-changes, and output flags. + +- [ ] **Step 7: Commit release and documentation changes** + +```bash +git add .github/workflows Makefile deploy/install.sh docs/getting-started.md README.md +git commit -m "build: test and distribute the migration CLI" +``` + +### Task 12: Final review against the approved design + +**Files:** +- Review: `docs/superpowers/specs/2026-07-14-uptime-kuma-migration-foundation-design.md` +- Review: all files changed by Tasks 1-11 + +- [ ] **Step 1: Run the complete verification set once more** + +Run: + +```bash +make test +make ui +cd api && go test ./... -race -count=1 +cd ../cli && go test ./... -race -count=1 +cd ../agent && go test ./... -race -count=1 +``` + +Expected: PASS. If the SQLite driver makes a race-enabled test unsupported, +record the exact failing package and fix the test or implementation; do not omit +the non-race suite. + +- [ ] **Step 2: Verify security properties with database queries** + +After the integration test fixture runs, assert: + +```sql +SELECT COUNT(*) FROM push_monitor_tokens WHERE length(token_hash) = 64; +SELECT COUNT(*) FROM push_monitor_tokens WHERE token_hash = 'abcdefghij'; +SELECT COUNT(*) FROM import_runs WHERE summary_json LIKE '%abcdefghij%'; +``` + +Expected: hashed-token count equals imported push monitor count; plaintext and +summary-secret counts are zero. + +- [ ] **Step 3: Verify design coverage manually** + +Check off every goal and non-goal in the design. Confirm v2 is rejected, gRPC is +absent, unsupported resources block apply, behavior changes require explicit +acceptance, dry-run writes nothing, apply rolls back atomically, and the CLI is +present in release assets. + +- [ ] **Step 4: Review commit boundaries and working tree** + +Run: `git status --short && git log --oneline --decorate -12` + +Expected: no uncommitted implementation files and one focused commit for each +task slice. From 07afa813e05b116cc5459d7b5624769af379adf4 Mon Sep 17 00:00:00 2001 From: memetics19 Date: Tue, 14 Jul 2026 17:14:18 +0530 Subject: [PATCH 3/4] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bf28e6a..67dd539 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ data/ # superpowers brainstorm .superpowers/ +.worktrees/ # Node ui/node_modules/ From 01eff8e0944d5ad1610ffb5d204c0f4771ec1507 Mon Sep 17 00:00:00 2001 From: memetics19 Date: Wed, 15 Jul 2026 15:16:59 +0530 Subject: [PATCH 4/4] fix(security): SSRF guard on all checker types --- .env.example | 7 + .github/workflows/ci.yml | 62 ++++++- .gitignore | 6 + Makefile | 15 +- README.md | 71 ++++++++ agent/Dockerfile | 33 ++-- agent/cmd/agent/main.go | 75 +++++++-- agent/cmd/agent/main_test.go | 100 ++++++++++++ agent/go.sum | 3 + agent/internal/collector/collector.go | 4 + agent/internal/pusher/pusher_test.go | 7 + api/cmd/pulse/main.go | 71 +++++--- api/cmd/pulse/main_test.go | 16 ++ api/cmd/pulse/resetpw_test.go | 47 ++++++ api/cmd/pulse/serve_test.go | 108 ++++++++++++ api/internal/app/app.go | 19 +++ api/internal/app/app_more_test.go | 45 +++++ api/internal/auth/more_test.go | 21 +++ api/internal/config/config.go | 32 ++++ api/internal/config/config_test.go | 75 +++++++++ api/internal/db/db.go | 16 +- api/internal/db/db_test.go | 24 +++ api/internal/db/legacy_test.go | 37 +++++ api/internal/db/queries/check_results.sql | 5 +- api/internal/generated/check_results.sql.go | 9 +- api/internal/handlers/auth.go | 14 +- api/internal/handlers/auth_more_test.go | 122 ++++++++++++++ api/internal/handlers/crud_all_test.go | 123 ++++++++++++++ api/internal/handlers/crud_more_test.go | 154 ++++++++++++++++++ api/internal/handlers/dberror_test.go | 125 ++++++++++++++ api/internal/handlers/fault_test.go | 64 ++++++++ api/internal/handlers/final_branches_test.go | 36 ++++ api/internal/handlers/health.go | 26 ++- api/internal/handlers/ingest_more_test.go | 23 +++ api/internal/handlers/loginlimit.go | 38 ++++- api/internal/handlers/loginlimit_test.go | 40 +++++ api/internal/handlers/misc_more_test.go | 73 +++++++++ api/internal/handlers/monitors.go | 129 +++++++++++++-- api/internal/handlers/more_branches_test.go | 56 +++++++ api/internal/handlers/overview_more_test.go | 65 ++++++++ api/internal/handlers/pages_delete_test.go | 22 +++ api/internal/handlers/render_paths_test.go | 61 +++++++ api/internal/handlers/setup_error_test.go | 38 +++++ api/internal/handlers/setuphelper_test.go | 20 +++ api/internal/handlers/status.go | 21 +-- .../handlers/validation_batch_test.go | 33 ++++ api/internal/handlers/validation_more_test.go | 78 +++++++++ api/internal/middleware/scope_test.go | 28 ++++ api/internal/netguard/netguard.go | 34 ++++ api/internal/netguard/netguard_test.go | 55 +++++++ api/internal/server/server.go | 6 +- api/internal/server/server_test.go | 49 ++++++ api/internal/web/paths_test.go | 43 +++++ api/internal/web/public.go | 57 +++++-- api/internal/web/render_test.go | 138 ++++++++++++++++ api/internal/web/status_json.go | 120 ++++++++++++++ api/internal/web/status_json_test.go | 83 ++++++++++ api/internal/web/web_more_test.go | 96 +++++++++++ .../worker/alerter/alerter_more_test.go | 86 ++++++++++ .../worker/alerter/notify_more_test.go | 30 ++++ api/internal/worker/checker/dns.go | 18 +- api/internal/worker/checker/dns_test.go | 4 +- api/internal/worker/checker/edge_test.go | 30 ++++ api/internal/worker/checker/http.go | 57 +++++-- .../worker/checker/http_bench_test.go | 64 ++++++++ api/internal/worker/checker/more_edge_test.go | 31 ++++ api/internal/worker/checker/ping.go | 13 +- api/internal/worker/checker/ping_test.go | 2 +- api/internal/worker/checker/ssl.go | 16 +- api/internal/worker/checker/ssl_test.go | 4 +- api/internal/worker/checker/ssrf_test.go | 28 ++++ api/internal/worker/checker/tcp.go | 14 +- api/internal/worker/checker/tcp_test.go | 4 +- api/internal/worker/incident/suppress_test.go | 53 ++++++ .../maintenance/maintenance_err_test.go | 19 +++ api/internal/worker/pruner/pruner_err_test.go | 18 ++ api/internal/worker/rollup/rollup_err_test.go | 18 ++ api/internal/worker/scheduler/check_test.go | 139 ++++++++++++++++ api/internal/worker/scheduler/scheduler.go | 20 ++- api/internal/worker/worker.go | 17 +- api/internal/worker/worker_test.go | 2 +- api/store/store_test.go | 14 ++ api/testutil/fault.go | 61 +++++++ api/testutil/testutil_test.go | 14 ++ cli/cmd/pulse-cli/main.go | 12 +- cli/cmd/pulse-cli/main_test.go | 15 ++ cli/internal/pulseclient/client_test.go | 23 +++ docs/architecture.md | 52 ++++++ docs/index.md | 2 +- mkdocs.yml | 6 +- scripts/coverage.sh | 62 +++++++ ui/.eslintrc.json | 10 +- ui/src/app/admin/agents/page.tsx | 3 +- ui/src/app/admin/api-keys/page.tsx | 52 +++--- ui/src/app/admin/layout.tsx | 5 +- ui/src/app/admin/notifications/page.tsx | 7 + ui/src/components/ConfirmDialog.tsx | 85 ++++++++++ 97 files changed, 3871 insertions(+), 217 deletions(-) create mode 100644 agent/cmd/agent/main_test.go create mode 100644 api/cmd/pulse/resetpw_test.go create mode 100644 api/cmd/pulse/serve_test.go create mode 100644 api/internal/app/app_more_test.go create mode 100644 api/internal/auth/more_test.go create mode 100644 api/internal/config/config_test.go create mode 100644 api/internal/db/legacy_test.go create mode 100644 api/internal/handlers/auth_more_test.go create mode 100644 api/internal/handlers/crud_all_test.go create mode 100644 api/internal/handlers/crud_more_test.go create mode 100644 api/internal/handlers/dberror_test.go create mode 100644 api/internal/handlers/fault_test.go create mode 100644 api/internal/handlers/final_branches_test.go create mode 100644 api/internal/handlers/ingest_more_test.go create mode 100644 api/internal/handlers/loginlimit_test.go create mode 100644 api/internal/handlers/misc_more_test.go create mode 100644 api/internal/handlers/more_branches_test.go create mode 100644 api/internal/handlers/overview_more_test.go create mode 100644 api/internal/handlers/pages_delete_test.go create mode 100644 api/internal/handlers/render_paths_test.go create mode 100644 api/internal/handlers/setup_error_test.go create mode 100644 api/internal/handlers/setuphelper_test.go create mode 100644 api/internal/handlers/validation_batch_test.go create mode 100644 api/internal/handlers/validation_more_test.go create mode 100644 api/internal/middleware/scope_test.go create mode 100644 api/internal/server/server_test.go create mode 100644 api/internal/web/paths_test.go create mode 100644 api/internal/web/render_test.go create mode 100644 api/internal/web/status_json.go create mode 100644 api/internal/web/status_json_test.go create mode 100644 api/internal/web/web_more_test.go create mode 100644 api/internal/worker/alerter/alerter_more_test.go create mode 100644 api/internal/worker/alerter/notify_more_test.go create mode 100644 api/internal/worker/checker/edge_test.go create mode 100644 api/internal/worker/checker/http_bench_test.go create mode 100644 api/internal/worker/checker/more_edge_test.go create mode 100644 api/internal/worker/checker/ssrf_test.go create mode 100644 api/internal/worker/incident/suppress_test.go create mode 100644 api/internal/worker/maintenance/maintenance_err_test.go create mode 100644 api/internal/worker/pruner/pruner_err_test.go create mode 100644 api/internal/worker/rollup/rollup_err_test.go create mode 100644 api/internal/worker/scheduler/check_test.go create mode 100644 api/store/store_test.go create mode 100644 api/testutil/fault.go create mode 100644 api/testutil/testutil_test.go create mode 100644 cli/cmd/pulse-cli/main_test.go create mode 100755 scripts/coverage.sh create mode 100644 ui/src/components/ConfirmDialog.tsx 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 67dd539..ba79fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,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, "