diff --git a/CHANGELOG.md b/CHANGELOG.md index a31775a..15078fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +## [1.3.0] + +### Added +- Custom request headers per monitor. Set them as `Key: Value` lines from + the monitor detail page, or via the JSON API on create. +- Outbound auth per monitor. Choose Basic (`user:pass`) or Bearer; pingtower + attaches the matching `Authorization` header on every poll. +- Dashboard routes `POST /dashboard/checks/{id}/headers` and + `POST /dashboard/checks/{id}/auth` for managing headers and auth. +- New `headers`, `auth_type`, and `auth_value` fields on the check model and + on `POST /checks`. +- New `SetCheckHeaders` and `SetCheckAuth` store methods. + +### Notes +- Auth values are stored in the data file as plain text. Restrict + permissions on `data/pingtower.json` to trusted users. +- The dashboard reflects the masked state of an existing auth value rather + than the secret itself. Submitting the auth form with the value field + blank preserves the existing secret. +- Unlocks monitoring of authenticated APIs that were previously impossible + to check (anything behind an API key, OAuth bearer, or HTTP Basic). + ## [1.2.0] ### Added diff --git a/README.md b/README.md index 15de91b..e608b5d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ - Pause and resume polling per monitor - Remove monitors you no longer need - Configure expected status code and timeout +- Send custom request headers and Basic / Bearer auth on each poll +- Webhook alerts on status transitions - Run locally with Go or in Docker ## Dashboard @@ -101,6 +103,26 @@ curl -X POST http://localhost:8080/checks \ }' ``` +### Create a monitor with custom headers and a Bearer token + +```bash +curl -X POST http://localhost:8080/checks \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Authenticated API", + "url": "https://api.example.com/me", + "headers": { + "X-API-Key": "abc123", + "Accept": "application/json" + }, + "auth_type": "bearer", + "auth_value": "my-secret-token" + }' +``` + +`auth_type` accepts `none`, `basic`, or `bearer`. For `basic`, `auth_value` is +the literal `user:pass` string (pingtower base64-encodes it on each request). + ### List monitors ```bash @@ -157,10 +179,17 @@ internal/store file-backed persistence ## Roadmap -- Add webhook or email alerts -- Support request headers and auth - Improve live dashboard updates without full page refresh - Move persistence to SQLite or Postgres +- Dashboard authentication + +## Security note + +Custom auth values (Basic credentials and Bearer tokens) are stored in the +data file in plain text. Restrict file permissions on `data/pingtower.json` +to trusted users, and consider running pingtower behind a reverse proxy or +on a private network rather than exposing the dashboard publicly — there is +no authentication on the dashboard yet. ## License diff --git a/internal/httpapi/dashboard.go b/internal/httpapi/dashboard.go index 208f19c..21a50af 100644 --- a/internal/httpapi/dashboard.go +++ b/internal/httpapi/dashboard.go @@ -6,6 +6,7 @@ import ( "fmt" "html/template" "net/http" + "sort" "strconv" "strings" "time" @@ -33,6 +34,10 @@ type dashboardCheckView struct { TimeoutSeconds int Paused bool WebhookURL string + Headers map[string]string + HeadersText string + AuthType string + HasAuth bool LastStatus string LastStatusCode int LastResponseMS int64 @@ -151,6 +156,50 @@ func (s *Server) handleDashboardCheckAction(w http.ResponseWriter, r *http.Reque } http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) return + case "headers": + headers := parseHeadersText(r.FormValue("headers")) + if _, err := s.store.SetCheckHeaders(checkID, headers); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to update headers", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) + return + case "auth": + authType := strings.TrimSpace(r.FormValue("auth_type")) + authValue := r.FormValue("auth_value") + if authType != "" && authType != "none" && authType != "basic" && authType != "bearer" { + http.Error(w, "invalid auth_type", http.StatusBadRequest) + return + } + if authType == "none" || authType == "" { + authValue = "" + } else if strings.TrimSpace(authValue) == "" { + // keep the existing secret if the user didn't supply a new one + existing, err := s.store.GetCheck(checkID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to load check", http.StatusInternalServerError) + return + } + authValue = existing.AuthValue + } + if _, err := s.store.SetCheckAuth(checkID, authType, authValue); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to update auth", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) + return case "trigger": if s.triggerer == nil { http.Error(w, "trigger not available", http.StatusNotImplemented) @@ -303,6 +352,10 @@ func newDashboardCheckView(check model.Check) dashboardCheckView { TimeoutSeconds: check.TimeoutSeconds, Paused: check.Paused, WebhookURL: check.WebhookURL, + Headers: check.Headers, + HeadersText: headersToText(check.Headers), + AuthType: check.AuthType, + HasAuth: check.AuthValue != "", LastStatus: effectiveStatus(check), LastStatusCode: check.LastStatusCode, LastResponseMS: check.LastResponseMS, @@ -375,6 +428,51 @@ func withFormDefaults(values map[string]string, s *Server) map[string]string { return values } +func parseHeadersText(text string) map[string]string { + headers := map[string]string{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key == "" { + continue + } + headers[key] = value + } + if len(headers) == 0 { + return nil + } + return headers +} + +func headersToText(headers map[string]string) string { + if len(headers) == 0 { + return "" + } + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(k) + b.WriteString(": ") + b.WriteString(headers[k]) + } + return b.String() +} + func parseIntOrZero(value string) int { if strings.TrimSpace(value) == "" { return 0 diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7080125..605406c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -32,11 +32,14 @@ func (s *Server) SetTriggerer(t Triggerer) { } type createCheckRequest struct { - Name string `json:"name"` - URL string `json:"url"` - IntervalSeconds int `json:"interval_seconds"` - TimeoutSeconds int `json:"timeout_seconds"` - ExpectedStatusCode int `json:"expected_status_code"` + Name string `json:"name"` + URL string `json:"url"` + IntervalSeconds int `json:"interval_seconds"` + TimeoutSeconds int `json:"timeout_seconds"` + ExpectedStatusCode int `json:"expected_status_code"` + Headers map[string]string `json:"headers,omitempty"` + AuthType string `json:"auth_type,omitempty"` + AuthValue string `json:"auth_value,omitempty"` } func NewServer(cfg config.Config, logger *log.Logger, dataStore store.Store) *Server { diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 1216fb7..4161066 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -394,3 +394,127 @@ func TestSetWebhookViaDashboard(t *testing.T) { t.Fatalf("WebhookURL = %q after clear, want empty", check.WebhookURL) } } + +func TestSetHeadersViaDashboard(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + created, err := dataStore.CreateCheck(model.Check{ + Name: "Headers Form Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore) + + form := url.Values{"headers": []string{"X-API-Key: abc123\nAccept: application/json"}} + req := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/headers", bytes.NewBufferString(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusSeeOther { + t.Fatalf("set headers status = %d, want %d", res.Code, http.StatusSeeOther) + } + + check, err := dataStore.GetCheck(created.ID) + if err != nil { + t.Fatalf("GetCheck() error = %v", err) + } + if check.Headers["X-API-Key"] != "abc123" || check.Headers["Accept"] != "application/json" { + t.Fatalf("Headers = %v, want both keys parsed", check.Headers) + } +} + +func TestSetAuthViaDashboard(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + created, err := dataStore.CreateCheck(model.Check{ + Name: "Auth Form Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore) + + // set bearer token + form := url.Values{ + "auth_type": []string{"bearer"}, + "auth_value": []string{"my-token"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusSeeOther { + t.Fatalf("set auth status = %d, want %d", res.Code, http.StatusSeeOther) + } + + check, err := dataStore.GetCheck(created.ID) + if err != nil { + t.Fatalf("GetCheck() error = %v", err) + } + if check.AuthType != "bearer" || check.AuthValue != "my-token" { + t.Fatalf("Auth = (%q, %q), want bearer/my-token", check.AuthType, check.AuthValue) + } + + // submitting with auth_type set but no auth_value should preserve the existing value + keepForm := url.Values{ + "auth_type": []string{"bearer"}, + "auth_value": []string{""}, + } + keepReq := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(keepForm.Encode())) + keepReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + keepRes := httptest.NewRecorder() + server.Handler().ServeHTTP(keepRes, keepReq) + + check, _ = dataStore.GetCheck(created.ID) + if check.AuthValue != "my-token" { + t.Fatalf("AuthValue after empty resubmit = %q, want preserved", check.AuthValue) + } + + clearForm := url.Values{ + "auth_type": []string{"none"}, + "auth_value": []string{""}, + } + clearReq := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(clearForm.Encode())) + clearReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + clearRes := httptest.NewRecorder() + server.Handler().ServeHTTP(clearRes, clearReq) + + check, _ = dataStore.GetCheck(created.ID) + if check.AuthType != "" || check.AuthValue != "" { + t.Fatalf("Auth after clear = (%q, %q), want empty", check.AuthType, check.AuthValue) + } + + badForm := url.Values{"auth_type": []string{"hmac"}} + badReq := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(badForm.Encode())) + badReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + badRes := httptest.NewRecorder() + server.Handler().ServeHTTP(badRes, badReq) + if badRes.Code != http.StatusBadRequest { + t.Fatalf("invalid auth_type status = %d, want %d", badRes.Code, http.StatusBadRequest) + } +} diff --git a/internal/httpapi/templates/detail.html b/internal/httpapi/templates/detail.html index ecc8d4d..b5e3bb0 100644 --- a/internal/httpapi/templates/detail.html +++ b/internal/httpapi/templates/detail.html @@ -289,6 +289,57 @@

Webhook alert

{{end}} + +
+
+
+

Request headers

+
Custom headers sent with each poll. One Key: Value per line.
+
+
+
+ +
+ + {{if .Check.Headers}} + + {{end}} +
+
+
+ +
+
+
+

Outbound auth

+
Adds an Authorization header to each poll. Stored in the data file — keep it readable only by trusted users.
+
+
+
+ + + + {{if .Check.HasAuth}} + + {{end}} +
+
diff --git a/internal/httpapi/validation.go b/internal/httpapi/validation.go index 113eb23..993ce45 100644 --- a/internal/httpapi/validation.go +++ b/internal/httpapi/validation.go @@ -18,6 +18,12 @@ func (s *Server) validateCreateCheck(req createCheckRequest) error { return fmt.Errorf("url must be a valid absolute URL") } + switch req.AuthType { + case "", "none", "basic", "bearer": + default: + return fmt.Errorf("auth_type must be one of: none, basic, bearer") + } + return nil } @@ -36,11 +42,21 @@ func (s *Server) buildCheck(req createCheckRequest) (model.Check, error) { req.ExpectedStatusCode = http.StatusOK } + authType := req.AuthType + authValue := req.AuthValue + if authType == "" || authType == "none" { + authType = "" + authValue = "" + } + return model.Check{ Name: strings.TrimSpace(req.Name), URL: req.URL, IntervalSeconds: req.IntervalSeconds, TimeoutSeconds: req.TimeoutSeconds, ExpectedStatusCode: req.ExpectedStatusCode, + Headers: req.Headers, + AuthType: authType, + AuthValue: authValue, }, nil } diff --git a/internal/model/model.go b/internal/model/model.go index a6681d6..867cf95 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -3,21 +3,24 @@ package model import "time" type Check struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - IntervalSeconds int `json:"interval_seconds"` - TimeoutSeconds int `json:"timeout_seconds"` - ExpectedStatusCode int `json:"expected_status_code"` - Paused bool `json:"paused"` - WebhookURL string `json:"webhook_url,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LastCheckedAt time.Time `json:"last_checked_at,omitempty"` - LastStatus string `json:"last_status,omitempty"` - LastStatusCode int `json:"last_status_code,omitempty"` - LastResponseMS int64 `json:"last_response_ms,omitempty"` - LastError string `json:"last_error,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + IntervalSeconds int `json:"interval_seconds"` + TimeoutSeconds int `json:"timeout_seconds"` + ExpectedStatusCode int `json:"expected_status_code"` + Paused bool `json:"paused"` + WebhookURL string `json:"webhook_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + AuthType string `json:"auth_type,omitempty"` + AuthValue string `json:"auth_value,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastCheckedAt time.Time `json:"last_checked_at,omitempty"` + LastStatus string `json:"last_status,omitempty"` + LastStatusCode int `json:"last_status_code,omitempty"` + LastResponseMS int64 `json:"last_response_ms,omitempty"` + LastError string `json:"last_error,omitempty"` } type Result struct { diff --git a/internal/monitor/service.go b/internal/monitor/service.go index 6f31027..14cef55 100644 --- a/internal/monitor/service.go +++ b/internal/monitor/service.go @@ -3,6 +3,7 @@ package monitor import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "log" @@ -110,6 +111,13 @@ func (s *Service) evaluate(check model.Check) model.Result { return result } req.Header.Set("User-Agent", s.userAgent) + applyAuth(req, check) + for key, value := range check.Headers { + if strings.TrimSpace(key) == "" { + continue + } + req.Header.Set(key, value) + } resp, err := s.httpClient.Do(req) if err != nil { @@ -200,3 +208,15 @@ func (s *Service) fireWebhook(check model.Check, result model.Result) { s.logger.Printf("webhook delivered check_id=%s status=%d", check.ID, resp.StatusCode) } + +func applyAuth(req *http.Request, check model.Check) { + if check.AuthValue == "" { + return + } + switch check.AuthType { + case "bearer": + req.Header.Set("Authorization", "Bearer "+check.AuthValue) + case "basic": + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(check.AuthValue))) + } +} diff --git a/internal/monitor/service_test.go b/internal/monitor/service_test.go index d40825c..8224077 100644 --- a/internal/monitor/service_test.go +++ b/internal/monitor/service_test.go @@ -103,3 +103,84 @@ func TestRunNow_NotFound(t *testing.T) { t.Fatal("RunNow() expected error for unknown ID, got nil") } } + +func TestRunNow_AppliesHeadersAndAuth(t *testing.T) { + t.Parallel() + + var gotAuth, gotAPIKey, gotAccept string + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAPIKey = r.Header.Get("X-API-Key") + gotAccept = r.Header.Get("Accept") + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + svc, dataStore := newTestService(t) + + check, err := dataStore.CreateCheck(model.Check{ + Name: "Auth headers", + URL: target.URL, + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: http.StatusOK, + AuthType: "bearer", + AuthValue: "test-token", + Headers: map[string]string{ + "X-API-Key": "abc123", + "Accept": "application/json", + }, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + if _, err := svc.RunNow(check.ID); err != nil { + t.Fatalf("RunNow() error = %v", err) + } + + if gotAuth != "Bearer test-token" { + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer test-token") + } + if gotAPIKey != "abc123" { + t.Fatalf("X-API-Key header = %q, want %q", gotAPIKey, "abc123") + } + if gotAccept != "application/json" { + t.Fatalf("Accept header = %q, want %q", gotAccept, "application/json") + } +} + +func TestRunNow_BasicAuthEncoded(t *testing.T) { + t.Parallel() + + var gotAuth string + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + svc, dataStore := newTestService(t) + + check, err := dataStore.CreateCheck(model.Check{ + Name: "Basic auth", + URL: target.URL, + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: http.StatusOK, + AuthType: "basic", + AuthValue: "alice:hunter2", + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + if _, err := svc.RunNow(check.ID); err != nil { + t.Fatalf("RunNow() error = %v", err) + } + + want := "Basic YWxpY2U6aHVudGVyMg==" + if gotAuth != want { + t.Fatalf("Authorization header = %q, want %q", gotAuth, want) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index f6418b0..8dc9bf9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -24,6 +24,8 @@ type Store interface { UpdateCheckStatus(id string, result model.Result, maxHistory int) error SetCheckPaused(id string, paused bool) (model.Check, error) SetCheckWebhook(id string, webhookURL string) (model.Check, error) + SetCheckHeaders(id string, headers map[string]string) (model.Check, error) + SetCheckAuth(id string, authType string, authValue string) (model.Check, error) DeleteCheck(id string) error } @@ -173,6 +175,58 @@ func (fs *FileStore) SetCheckWebhook(id string, webhookURL string) (model.Check, return check, nil } +func (fs *FileStore) SetCheckHeaders(id string, headers map[string]string) (model.Check, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + check, ok := fs.checks[id] + if !ok { + return model.Check{}, ErrNotFound + } + + if len(headers) == 0 { + check.Headers = nil + } else { + copied := make(map[string]string, len(headers)) + for k, v := range headers { + copied[k] = v + } + check.Headers = copied + } + check.UpdatedAt = time.Now().UTC() + fs.checks[id] = check + + if err := fs.saveLocked(); err != nil { + return model.Check{}, err + } + return check, nil +} + +func (fs *FileStore) SetCheckAuth(id string, authType string, authValue string) (model.Check, error) { + fs.mu.Lock() + defer fs.mu.Unlock() + + check, ok := fs.checks[id] + if !ok { + return model.Check{}, ErrNotFound + } + + if authType == "" || authType == "none" { + check.AuthType = "" + check.AuthValue = "" + } else { + check.AuthType = authType + check.AuthValue = authValue + } + check.UpdatedAt = time.Now().UTC() + fs.checks[id] = check + + if err := fs.saveLocked(); err != nil { + return model.Check{}, err + } + return check, nil +} + func (fs *FileStore) DeleteCheck(id string) error { fs.mu.Lock() defer fs.mu.Unlock() diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7d44094..00833f3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -83,3 +83,86 @@ func TestSetCheckWebhook(t *testing.T) { t.Fatal("SetCheckWebhook() expected error for unknown ID, got nil") } } + +func TestSetCheckHeaders(t *testing.T) { + t.Parallel() + + fs, err := NewFileStore(filepath.Join(t.TempDir(), "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + check, err := fs.CreateCheck(model.Check{ + Name: "Headers Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 10, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + updated, err := fs.SetCheckHeaders(check.ID, map[string]string{ + "X-API-Key": "abc123", + "Accept": "application/json", + }) + if err != nil { + t.Fatalf("SetCheckHeaders() error = %v", err) + } + if updated.Headers["X-API-Key"] != "abc123" || updated.Headers["Accept"] != "application/json" { + t.Fatalf("Headers = %v, want both keys set", updated.Headers) + } + + cleared, err := fs.SetCheckHeaders(check.ID, nil) + if err != nil { + t.Fatalf("SetCheckHeaders(clear) error = %v", err) + } + if cleared.Headers != nil { + t.Fatalf("Headers = %v, want nil after clear", cleared.Headers) + } + + if _, err := fs.SetCheckHeaders("nonexistent", map[string]string{"X": "Y"}); err == nil { + t.Fatal("SetCheckHeaders() expected error for unknown ID, got nil") + } +} + +func TestSetCheckAuth(t *testing.T) { + t.Parallel() + + fs, err := NewFileStore(filepath.Join(t.TempDir(), "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + check, err := fs.CreateCheck(model.Check{ + Name: "Auth Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 10, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + updated, err := fs.SetCheckAuth(check.ID, "bearer", "secret-token") + if err != nil { + t.Fatalf("SetCheckAuth() error = %v", err) + } + if updated.AuthType != "bearer" || updated.AuthValue != "secret-token" { + t.Fatalf("Auth = (%q, %q), want bearer/secret-token", updated.AuthType, updated.AuthValue) + } + + cleared, err := fs.SetCheckAuth(check.ID, "none", "still-there") + if err != nil { + t.Fatalf("SetCheckAuth(none) error = %v", err) + } + if cleared.AuthType != "" || cleared.AuthValue != "" { + t.Fatalf("Auth after none = (%q, %q), want empty", cleared.AuthType, cleared.AuthValue) + } + + if _, err := fs.SetCheckAuth("nonexistent", "bearer", "x"); err == nil { + t.Fatal("SetCheckAuth() expected error for unknown ID, got nil") + } +}