diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..251d656 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,25 @@ +name: Go Build and Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/Magefile.go b/Magefile.go index 059bdd2..7da702d 100644 --- a/Magefile.go +++ b/Magefile.go @@ -24,7 +24,7 @@ func buildBinary() error { env := map[string]string{ "CGO_ENABLED": "0", "GO111MODULE": "on", - "GOOS": getOrDefault("GOOS", "linux"), + "GOOS": getOrDefault("GOOS", "linux"), } if err := sh.RunWith(env, "go", "build", "-o", filepath.Join(distDir, "grafana-ntfy"), "./pkg"); err != nil { @@ -45,7 +45,11 @@ func RunLocal() error { env := map[string]string{ "DEBUG": "1", } - return sh.RunWith(env, filepath.Join(distDir, "grafana-ntfy"), "-ntfy-url", "https://ntfy.sh/mytopic") + return sh.RunWith(env, filepath.Join(distDir, "grafana-ntfy"), "-ntfy-url", "https://ntfy.sh/academo") +} + +func Test() error { + return sh.RunV("go", "test", "./...") } func Deploy() error { diff --git a/pkg/main.go b/pkg/main.go index ea0cbec..5f4c49e 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -24,6 +24,10 @@ var ( debug = flag.Bool("debug", false, "print extra debug information") ) +type HttpClient interface { + Do(*http.Request) (*http.Response, error) +} + var urlRe = regexp.MustCompile(`(https?://.*?)/([-a-zA-Z0-9()@:%_\+.~#?&=]+)$`) var topic string var serverUrl string @@ -130,7 +134,7 @@ func handleRequest(response http.ResponseWriter, request *http.Request) { } notificationPayload := prepareNotification(payload) - err = sendNotification(notificationPayload, request.Header.Get("Authorization")) + err = sendNotification(notificationPayload, request.Header.Get("Authorization"), http.DefaultClient) if err != nil { slog.Error("Error sending notification", "err", err) http.Error(response, "Error sending notification", http.StatusInternalServerError) @@ -149,6 +153,15 @@ func handleRequest(response http.ResponseWriter, request *http.Request) { } func prepareNotification(alertPayload AlertsPayload) NtfyNotification { + // edge case with a non-alert + if len(alertPayload.Alerts) == 0 { + return NtfyNotification{ + Message: alertPayload.Message, + Title: alertPayload.Title, + Topic: topic, + } + } + firstAlert := alertPayload.Alerts[0] actions := []NtfyAction{ { @@ -176,7 +189,7 @@ func prepareNotification(alertPayload AlertsPayload) NtfyNotification { return payload } -func sendNotification(payload NtfyNotification, authHeader string) error { +func sendNotification(payload NtfyNotification, authHeader string, client HttpClient) error { // Marshal the payload message, err := json.Marshal(payload) if err != nil { @@ -205,7 +218,7 @@ func sendNotification(payload NtfyNotification, authHeader string) error { // Send the request defer req.Body.Close() - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return err } diff --git a/pkg/main_test.go b/pkg/main_test.go new file mode 100644 index 0000000..b15804b --- /dev/null +++ b/pkg/main_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "reflect" + "testing" +) + +func TestValidateFlags(t *testing.T) { + tests := map[string]struct { + url string + wantErr bool + }{ + "valid url": {"https://ntfy.sh/topic", false}, + "empty url": {"", true}, + "invalid url": {"not-a-url", true}, + "no topic": {"https://ntfy.sh/", true}, + "invalid chars": {"https://ntfy.sh/topic$%^", true}, + "custom server": {"https://custom.ntfy/mytopic", false}, + "http url": {"http://ntfy.sh/topic", false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + *ntfyUrl = tc.url + err := validateFlags() + if (err != nil) != tc.wantErr { + t.Errorf("validateFlags() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestPrepareNotification(t *testing.T) { + tests := map[string]struct { + input AlertsPayload + want NtfyNotification + }{ + "basic notification": { + input: AlertsPayload{ + Message: "test message", + Title: "test title", + ExternalURL: "http://grafana/alert", + Alerts: []Alert{{SilenceURL: "http://grafana/silence"}}, + }, + want: NtfyNotification{ + Message: "test message", + Title: "test title", + Topic: "test-topic", + Actions: []NtfyAction{ + {Action: "view", Label: "Open in Grafana", Url: "http://grafana/alert", Clear: true}, + {Action: "view", Label: "Silence", Url: "http://grafana/silence", Clear: false}, + }, + }, + }, + "empty alerts": { + input: AlertsPayload{ + Message: "msg", + Alerts: []Alert{}, + }, + want: NtfyNotification{ + Message: "msg", + Topic: "test-topic", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + topic = "test-topic" + got := prepareNotification(tc.input) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("prepareNotification() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestSendNotification(t *testing.T) { + originalUsername := *username + originalPassword := *password + + tests := map[string]struct { + client *mockHttpClient + authHeader string + username string + password string + wantErr bool + checkReq func(*testing.T, *http.Request) + }{ + "success": { + client: &mockHttpClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(nil)), + }, + }, + checkReq: func(t *testing.T, req *http.Request) { + if auth := req.Header.Get("Authorization"); auth != "" { + t.Errorf("unexpected Authorization header: %v", auth) + } + }, + }, + "with auth header": { + client: &mockHttpClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(nil)), + }, + }, + authHeader: "Bearer token", + checkReq: func(t *testing.T, req *http.Request) { + if auth := req.Header.Get("Authorization"); auth != "Bearer token" { + t.Errorf("expected Authorization header %q, got %q", "Bearer token", auth) + } + }, + }, + "with basic auth": { + client: &mockHttpClient{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(nil)), + }, + }, + username: "user", + password: "pass", + checkReq: func(t *testing.T, req *http.Request) { + username, password, ok := req.BasicAuth() + if !ok { + t.Error("expected basic auth, got none") + } + if username != "user" || password != "pass" { + t.Errorf("expected basic auth user/pass, got %q/%q", username, password) + } + }, + }, + "non-200 response": { + client: &mockHttpClient{ + response: &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewBuffer(nil)), + }, + }, + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + *username = tc.username + *password = tc.password + + var capturedReq *http.Request + mockClient := &mockHttpClient{ + response: tc.client.response, + err: tc.client.err, + onDo: func(req *http.Request) { + capturedReq = req + }, + } + + payload := NtfyNotification{Message: "test"} + err := sendNotification(payload, tc.authHeader, mockClient) + + if (err != nil) != tc.wantErr { + t.Errorf("sendNotification() error = %v, wantErr %v", err, tc.wantErr) + } + + if tc.checkReq != nil && capturedReq != nil { + tc.checkReq(t, capturedReq) + } + }) + } + + // Restore original values + *username = originalUsername + *password = originalPassword +} + +// Update mockHttpClient to capture request +type mockHttpClient struct { + response *http.Response + err error + onDo func(*http.Request) +} + +func (m *mockHttpClient) Do(req *http.Request) (*http.Response, error) { + if m.onDo != nil { + m.onDo(req) + } + return m.response, m.err +}