Compare commits

...

2 Commits

Author SHA1 Message Date
Praneeth Lingam
4480e8b6d9 test(channel-receivers): drop dead site param from jira test helper 2026-09-29 14:34:11 +05:30
Praneeth Lingam
31a19c3964 chore(channel-receivers): relaxed all the strict validations 2026-09-29 13:46:26 +05:30
8 changed files with 14 additions and 129 deletions

View File

@@ -1,9 +1,6 @@
package alertmanagertypes
import (
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
@@ -89,15 +86,5 @@ func (c *GoogleChatReceiverConfig) UnmarshalYAML(unmarshal func(any) error) erro
if c.WebhookURL == nil {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url is required")
}
u, err := url.Parse(c.WebhookURL.String())
if err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid google chat webhook_url: %v", err)
}
if u.Scheme != "https" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use https")
}
if strings.ToLower(u.Hostname()) != "chat.googleapis.com" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use chat.googleapis.com")
}
return nil
}

View File

@@ -13,8 +13,6 @@ func TestNewReceiverGoogleChatWebhookURL(t *testing.T) {
wantErr bool
}{
{"valid", `{"name":"gc","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t"}]}`, false},
{"http scheme rejected", `{"name":"gc","googlechat_configs":[{"webhook_url":"http://chat.googleapis.com/v1/spaces/x/messages"}]}`, true},
{"wrong host rejected", `{"name":"gc","googlechat_configs":[{"webhook_url":"https://example.com/x"}]}`, true},
{"missing webhook_url", `{"name":"gc","googlechat_configs":[{"title":"x"}]}`, true},
}
for _, c := range cases {

View File

@@ -1,10 +1,6 @@
package alertmanagertypes
import (
"fmt"
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
@@ -64,11 +60,6 @@ func newChannelIncidentIOConfigFromReceiver(name string, receiver *Receiver) (Ch
}, nil
}
// incidentIOEventsPathPrefix is the path of incident.io's HTTP alert source
// endpoint (Alert Events V2 API). The full URL is per-source:
// https://api.incident.io/v2/alert_events/http/<source_config_id>.
const incidentIOEventsPathPrefix = "/v2/alert_events/http/"
// The description is markdown; incident.io renders it natively. The templates
// mirror Google Chat / Jira / JSM for a consistent default across channels.
const (
@@ -132,32 +123,11 @@ func (c *IncidentIOReceiverConfig) UnmarshalYAML(unmarshal func(any) error) erro
c.Description = DefaultIncidentIODescriptionTemplate
}
// Values are stored and sent exactly as configured, so anything that is
// not already canonical is rejected rather than rewritten.
if c.URL != strings.TrimSpace(c.URL) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio url must not have leading or trailing whitespace")
if c.URL == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio url is required")
}
u, err := url.Parse(c.URL)
if c.URL == "" || err != nil || u.Scheme != "https" || u.Host == "" ||
!strings.Contains(u.Path, incidentIOEventsPathPrefix) ||
strings.HasSuffix(u.Path, incidentIOEventsPathPrefix) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("incidentio url must be an alert events URL (https://api.incident.io%s<source_config_id>)", incidentIOEventsPathPrefix))
}
if strings.HasSuffix(c.URL, "/") {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio url must not end with a trailing slash")
}
token := string(c.Token)
if token == "" {
if c.Token == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio token is required")
}
if token != strings.TrimSpace(token) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio token must not have leading or trailing whitespace")
}
// incident.io's setup page shows the header value as "Bearer <token>"; a
// pasted prefix would be sent doubled, so reject it instead.
if strings.EqualFold(token, "bearer") || (len(token) >= 7 && strings.EqualFold(token[:7], "bearer ")) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "incidentio token must be the source's secret token only, without the Bearer prefix")
}
return nil
}

View File

@@ -45,16 +45,7 @@ func TestIncidentIOReceiverConfigValidation(t *testing.T) {
json string
}{
{"missing url", `{"name":"incio","incidentio_configs":[{"token":"k"}]}`},
{"http url", `{"name":"incio","incidentio_configs":[{"url":"http://api.incident.io/v2/alert_events/http/abc","token":"k"}]}`},
{"not an alert events url", `{"name":"incio","incidentio_configs":[{"url":"https://api.incident.io/v2/incidents","token":"k"}]}`},
{"missing source config id", `{"name":"incio","incidentio_configs":[{"url":"https://api.incident.io/v2/alert_events/http/","token":"k"}]}`},
{"trailing slash", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s/","token":"k"}]}`, testIncidentIOURL)},
{"whitespace around url", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":" %s ","token":"k"}]}`, testIncidentIOURL)},
{"missing token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s"}]}`, testIncidentIOURL)},
{"bearer prefixed token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s","token":"Bearer tok-123"}]}`, testIncidentIOURL)},
{"lowercase bearer prefixed token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s","token":"bearer tok-123"}]}`, testIncidentIOURL)},
{"bearer only token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s","token":"Bearer"}]}`, testIncidentIOURL)},
{"whitespace around token", fmt.Sprintf(`{"name":"incio","incidentio_configs":[{"url":"%s","token":" tok-123 "}]}`, testIncidentIOURL)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -2,7 +2,6 @@ package alertmanagertypes
import (
"fmt"
"net/url"
"strings"
"time"
@@ -153,7 +152,6 @@ const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
// Service accounts authenticate against the api.atlassian.com gateway (keyed by
// cloud id) instead of the site host; they are identified by their email domain.
const (
jiraCloudHostSuffix = ".atlassian.net"
jiraServiceAccountEmailDomain = "@serviceaccount.atlassian.com"
jiraGatewayBaseURL = "https://api.atlassian.com/ex/jira/"
)
@@ -204,11 +202,6 @@ func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
if c.ReopenDuration <= 0 {
c.ReopenDuration = defaultJiraReopenDuration
}
// sub-minute windows truncate to 0 in the reopen JQL and silently disable
// reopening, so reject them.
if c.ReopenDuration < model.Duration(time.Minute) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira reopen_duration must be at least 1m")
}
if c.Summary == "" {
c.Summary = DefaultJiraSummaryTemplate
}
@@ -216,19 +209,9 @@ func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
c.Description = DefaultJiraDescriptionTemplate
}
// Values are stored and sent exactly as configured, so anything that is
// not already canonical is rejected rather than rewritten.
if c.Site != strings.TrimSpace(c.Site) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira site must not have leading or trailing whitespace")
if c.Site == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira site is required")
}
u, err := url.Parse(c.Site)
if c.Site == "" || err != nil || u.Scheme != "https" || !strings.HasSuffix(strings.ToLower(u.Hostname()), jiraCloudHostSuffix) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("jira site must be a Jira Cloud URL (https://<site>%s)", jiraCloudHostSuffix))
}
if strings.HasSuffix(c.Site, "/") {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira site must not end with a trailing slash")
}
if c.Project == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira project is required")
}

View File

@@ -11,19 +11,19 @@ import (
"github.com/stretchr/testify/require"
)
func jiraReceiverJSON(site, project, issueType string, withAuth bool) string {
func jiraReceiverJSON(project, issueType string, withAuth bool) string {
auth := ""
if withAuth {
auth = `,"http_config":{"basic_auth":{"username":"me@acme.com","password":"token"}}`
}
return fmt.Sprintf(
`{"name":"jira","jira_configs":[{"site":%q,"project":%q,"issue_type":%q%s}]}`,
site, project, issueType, auth,
`{"name":"jira","jira_configs":[{"site":"https://acme.atlassian.net","project":%q,"issue_type":%q%s}]}`,
project, issueType, auth,
)
}
func TestJiraReceiverConfigDefaults(t *testing.T) {
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true))
r, err := NewReceiver(jiraReceiverJSON("KAN", "Task", true))
require.NoError(t, err)
require.Len(t, r.JiraConfigs, 1)
@@ -56,19 +56,10 @@ func TestJiraReceiverConfigSendResolved(t *testing.T) {
assert.False(t, off.JiraConfigs[0].SendResolved())
}
func TestJiraReceiverConfigReopenDurationMinimum(t *testing.T) {
withReopen := func(v string) string {
return fmt.Sprintf(
`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":%q,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`,
v,
)
}
r, err := NewReceiver(withReopen("1m"))
func TestJiraReceiverConfigReopenDuration(t *testing.T) {
r, err := NewReceiver(`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"1m","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`)
require.NoError(t, err)
assert.Equal(t, model.Duration(time.Minute), r.JiraConfigs[0].ReopenDuration)
_, err = NewReceiver(withReopen("30s"))
assert.Error(t, err)
}
func TestJiraAPIBaseURL(t *testing.T) {
@@ -93,17 +84,10 @@ func TestJiraReceiverConfigValidation(t *testing.T) {
json string
}{
{"missing site", `{"name":"j","jira_configs":[{"project":"KAN","issue_type":"Task","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
{"http site", jiraReceiverJSON("http://acme.atlassian.net", "KAN", "Task", true)},
{"non-cloud host", jiraReceiverJSON("https://jira.acme.com", "KAN", "Task", true)},
{"lookalike host suffix", jiraReceiverJSON("https://www.iamnotatlassian.net", "KAN", "Task", true)},
{"bare atlassian.net", jiraReceiverJSON("https://atlassian.net", "KAN", "Task", true)},
{"trailing slash site", jiraReceiverJSON("https://acme.atlassian.net/", "KAN", "Task", true)},
{"padded site", jiraReceiverJSON(" https://acme.atlassian.net ", "KAN", "Task", true)},
{"missing project", jiraReceiverJSON("https://acme.atlassian.net", "", "Task", true)},
{"missing issue_type", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "", true)},
{"missing basic auth", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", false)},
{"missing project", jiraReceiverJSON("", "Task", true)},
{"missing issue_type", jiraReceiverJSON("KAN", "", true)},
{"missing basic auth", jiraReceiverJSON("KAN", "Task", false)},
{"invalid reopen_duration format", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"3days","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
{"sub-minute reopen_duration", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"30s","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -4,7 +4,6 @@ import (
"reflect"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
@@ -368,26 +367,6 @@ func TestPostableChannelToReceiverRoundTripsWebhookAuthModes(t *testing.T) {
}
}
// The SigNoz notifiers validate in their UnmarshalYAML, which ToReceiver reaches
// only through the defaulting round-trip. A spec that passes Validate can still
// be rejected there, and the request has to fail as invalid input rather than as
// an internal error.
func TestPostableChannelToReceiverReportsNotifierValidationAsInvalidInput(t *testing.T) {
postable := PostableNotificationChannel{
Name: "channel",
DisplayName: "channel",
Config: ChannelConfig{Kind: ChannelKindIncidentIO, Spec: &ChannelIncidentIOConfig{
URL: "https://api.incident.io/v2/incidents", Token: "token",
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"), Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
}},
}
require.NoError(t, postable.Validate())
_, err := postable.ToReceiver()
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "got %v", err)
}
// rejectUnsupportedHTTPConfig enumerates the fields it rejects, so one added
// upstream would pass unnoticed and be dropped on read. Pinning the counts turns
// a dependency bump into a failing test rather than silent data loss.

View File

@@ -334,13 +334,6 @@ def test_create_rejects_a_duplicate_display_name(
pytest.param({"name": "slack-confirm-without-text", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "actions": [{"type": "button", "text": "Ack", "name": "ack", "confirm": {"title": "Sure?"}}]}}}, id="slack_action_confirm_without_text"),
pytest.param({"name": "extra-field", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}, "type": "email"}, id="unknown_envelope_field"),
pytest.param({"name": "webhook-both-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u", "password": "p", "bearerToken": "t"}}}, id="webhook_basic_auth_with_bearer_token"),
# The last three reach the notifier's own validation rather than the
# spec's, so they assert it still surfaces as a 400 through v2.
pytest.param({"name": "jira-server-site", "config": {"kind": "jira", "spec": {"site": "https://jira.acme.com", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body"}}}, id="jira_site_not_jira_cloud"),
pytest.param(
{"name": "jira-short-reopen", "config": {"kind": "jira", "spec": {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body", "reopenDuration": "30s"}}}, id="jira_reopen_duration_below_a_minute"
),
pytest.param({"name": "incidentio-bearer", "config": {"kind": "incidentio", "spec": {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "Bearer incidentio-token", "title": "Alert", "description": "body"}}}, id="incidentio_token_with_bearer_prefix"),
pytest.param({"name": "slack-empty-title", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "title": ""}}}, id="empty_string_on_a_defaulted_field"),
pytest.param({"name": "jsmops-empty-tags", "config": {"kind": "jsmops", "spec": {"apiKey": "jsm-api-key", "tags": ""}}}, id="empty_string_on_a_defaulted_signoz_field"),
pytest.param({"name": "jira-noncanonical-reopen", "config": {"kind": "jira", "spec": {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "reopenDuration": "72h"}}}, id="jira_reopen_duration_not_as_reported"),