Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
200
pkg/services/teams/teams_config.go
Normal file
200
pkg/services/teams/teams_config.go
Normal file
|
@ -0,0 +1,200 @@
|
|||
package teams
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// Scheme is the identifier for the Teams service protocol.
|
||||
const Scheme = "teams"
|
||||
|
||||
// Config constants.
|
||||
const (
|
||||
DummyURL = "teams://dummy@dummy.com" // Default placeholder URL
|
||||
ExpectedOrgMatches = 2 // Full match plus organization domain capture group
|
||||
MinPathComponents = 3 // Minimum required path components: AltID, GroupOwner, ExtraID
|
||||
)
|
||||
|
||||
// Config represents the configuration for the Teams service.
|
||||
type Config struct {
|
||||
standard.EnumlessConfig
|
||||
Group string `optional:"" url:"user"`
|
||||
Tenant string `optional:"" url:"host"`
|
||||
AltID string `optional:"" url:"path1"`
|
||||
GroupOwner string `optional:"" url:"path2"`
|
||||
ExtraID string `optional:"" url:"path3"`
|
||||
|
||||
Title string `key:"title" optional:""`
|
||||
Color string `key:"color" optional:""`
|
||||
Host string `key:"host" optional:""` // Required, no default
|
||||
}
|
||||
|
||||
// WebhookParts returns the webhook components as an array.
|
||||
func (config *Config) WebhookParts() [5]string {
|
||||
return [5]string{config.Group, config.Tenant, config.AltID, config.GroupOwner, config.ExtraID}
|
||||
}
|
||||
|
||||
// SetFromWebhookURL updates the Config from a Teams webhook URL.
|
||||
func (config *Config) SetFromWebhookURL(webhookURL string) error {
|
||||
orgPattern := regexp.MustCompile(
|
||||
`https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`,
|
||||
)
|
||||
|
||||
orgGroups := orgPattern.FindStringSubmatch(webhookURL)
|
||||
if len(orgGroups) != ExpectedComponents {
|
||||
return ErrInvalidWebhookFormat
|
||||
}
|
||||
|
||||
config.Host = orgGroups[1] + WebhookDomain
|
||||
|
||||
parts, err := ParseAndVerifyWebhookURL(webhookURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.setFromWebhookParts(parts)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigFromWebhookURL creates a new Config from a parsed Teams webhook URL.
|
||||
func ConfigFromWebhookURL(webhookURL url.URL) (*Config, error) {
|
||||
webhookURL.RawQuery = ""
|
||||
config := &Config{Host: webhookURL.Host}
|
||||
|
||||
if err := config.SetFromWebhookURL(webhookURL.String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetURL constructs a URL from the Config fields.
|
||||
func (config *Config) GetURL() *url.URL {
|
||||
resolver := format.NewPropKeyResolver(config)
|
||||
|
||||
return config.getURL(&resolver)
|
||||
}
|
||||
|
||||
// getURL constructs a URL using the provided resolver.
|
||||
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
|
||||
if config.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &url.URL{
|
||||
User: url.User(config.Group),
|
||||
Host: config.Tenant,
|
||||
Path: "/" + config.AltID + "/" + config.GroupOwner + "/" + config.ExtraID,
|
||||
Scheme: Scheme,
|
||||
RawQuery: format.BuildQuery(resolver),
|
||||
}
|
||||
}
|
||||
|
||||
// SetURL updates the Config from a URL.
|
||||
func (config *Config) SetURL(url *url.URL) error {
|
||||
resolver := format.NewPropKeyResolver(config)
|
||||
|
||||
return config.setURL(&resolver, url)
|
||||
}
|
||||
|
||||
// setURL updates the Config from a URL using the provided resolver.
|
||||
// It parses the URL parts, sets query parameters, and ensures the host is specified.
|
||||
// Returns an error if the URL is invalid or the host is missing.
|
||||
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
|
||||
parts, err := parseURLParts(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.setFromWebhookParts(parts)
|
||||
|
||||
if err := config.setQueryParams(resolver, url.Query()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Host == "" {
|
||||
return ErrMissingHostParameter
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseURLParts extracts and validates webhook components from a URL.
|
||||
func parseURLParts(url *url.URL) ([5]string, error) {
|
||||
var parts [5]string
|
||||
if url.String() == DummyURL {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
pathParts := strings.Split(url.Path, "/")
|
||||
if pathParts[0] == "" {
|
||||
pathParts = pathParts[1:]
|
||||
}
|
||||
|
||||
if len(pathParts) < MinPathComponents {
|
||||
return parts, ErrMissingExtraIDComponent
|
||||
}
|
||||
|
||||
parts = [5]string{
|
||||
url.User.Username(),
|
||||
url.Hostname(),
|
||||
pathParts[0],
|
||||
pathParts[1],
|
||||
pathParts[2],
|
||||
}
|
||||
if err := verifyWebhookParts(parts); err != nil {
|
||||
return parts, fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// setQueryParams applies query parameters to the Config using the resolver.
|
||||
// It resets Color, Host, and Title, then updates them based on query values.
|
||||
// Returns an error if the resolver fails to set any parameter.
|
||||
func (config *Config) setQueryParams(resolver types.ConfigQueryResolver, query url.Values) error {
|
||||
config.Color = ""
|
||||
config.Host = ""
|
||||
config.Title = ""
|
||||
|
||||
for key, vals := range query {
|
||||
if len(vals) > 0 && vals[0] != "" {
|
||||
switch key {
|
||||
case "color":
|
||||
config.Color = vals[0]
|
||||
case "host":
|
||||
config.Host = vals[0]
|
||||
case "title":
|
||||
config.Title = vals[0]
|
||||
}
|
||||
|
||||
if err := resolver.Set(key, vals[0]); err != nil {
|
||||
return fmt.Errorf(
|
||||
"%w: key=%q, value=%q: %w",
|
||||
ErrSetParameterFailed,
|
||||
key,
|
||||
vals[0],
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setFromWebhookParts sets Config fields from webhook parts.
|
||||
func (config *Config) setFromWebhookParts(parts [5]string) {
|
||||
config.Group = parts[0]
|
||||
config.Tenant = parts[1]
|
||||
config.AltID = parts[2]
|
||||
config.GroupOwner = parts[3]
|
||||
config.ExtraID = parts[4]
|
||||
}
|
50
pkg/services/teams/teams_errors.go
Normal file
50
pkg/services/teams/teams_errors.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
package teams
|
||||
|
||||
import "errors"
|
||||
|
||||
// Error variables for the Teams package.
|
||||
var (
|
||||
// ErrInvalidWebhookFormat indicates the webhook URL doesn't contain the organization domain.
|
||||
ErrInvalidWebhookFormat = errors.New(
|
||||
"invalid webhook URL format - must contain organization domain",
|
||||
)
|
||||
|
||||
// ErrMissingHostParameter indicates the required host parameter is missing.
|
||||
ErrMissingHostParameter = errors.New(
|
||||
"missing required host parameter (organization.webhook.office.com)",
|
||||
)
|
||||
|
||||
// ErrMissingExtraIDComponent indicates the URL is missing the extraId component.
|
||||
ErrMissingExtraIDComponent = errors.New("invalid URL format: missing extraId component")
|
||||
|
||||
// ErrMissingHost indicates the host is not specified in the configuration.
|
||||
ErrMissingHost = errors.New("host is required but not specified in the configuration")
|
||||
|
||||
// ErrSetParameterFailed indicates failure to set a configuration parameter.
|
||||
ErrSetParameterFailed = errors.New("failed to set configuration parameter")
|
||||
|
||||
// ErrSendFailedStatus indicates an unexpected status code in the response.
|
||||
ErrSendFailedStatus = errors.New(
|
||||
"failed to send notification to teams, response status code unexpected",
|
||||
)
|
||||
|
||||
// ErrSendFailed indicates a general failure in sending the notification.
|
||||
ErrSendFailed = errors.New("an error occurred while sending notification to teams")
|
||||
|
||||
// ErrInvalidWebhookURL indicates the webhook URL format is invalid.
|
||||
ErrInvalidWebhookURL = errors.New("invalid webhook URL format")
|
||||
|
||||
// ErrInvalidHostFormat indicates the host format is invalid.
|
||||
ErrInvalidHostFormat = errors.New("invalid host format")
|
||||
|
||||
// ErrInvalidWebhookComponents indicates a mismatch in expected webhook URL components.
|
||||
ErrInvalidWebhookComponents = errors.New(
|
||||
"invalid webhook URL format: expected component count mismatch",
|
||||
)
|
||||
|
||||
// ErrInvalidPartLength indicates a webhook component has an incorrect length.
|
||||
ErrInvalidPartLength = errors.New("invalid webhook part length")
|
||||
|
||||
// ErrMissingExtraID indicates the extraID is missing.
|
||||
ErrMissingExtraID = errors.New("extraID is required")
|
||||
)
|
164
pkg/services/teams/teams_service.go
Normal file
164
pkg/services/teams/teams_service.go
Normal file
|
@ -0,0 +1,164 @@
|
|||
package teams
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// MaxSummaryLength defines the maximum length for a notification summary.
|
||||
const MaxSummaryLength = 20
|
||||
|
||||
// TruncatedSummaryLen defines the length for a truncated summary.
|
||||
const TruncatedSummaryLen = 21
|
||||
|
||||
// Service sends notifications to Microsoft Teams.
|
||||
type Service struct {
|
||||
standard.Standard
|
||||
Config *Config
|
||||
pkr format.PropKeyResolver
|
||||
}
|
||||
|
||||
// Send delivers a notification message to Microsoft Teams.
|
||||
func (service *Service) Send(message string, params *types.Params) error {
|
||||
config := service.Config
|
||||
if err := service.pkr.UpdateConfigFromParams(config, params); err != nil {
|
||||
service.Logf("Failed to update params: %v", err)
|
||||
}
|
||||
|
||||
return service.doSend(config, message)
|
||||
}
|
||||
|
||||
// Initialize configures the service with a URL and logger.
|
||||
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
|
||||
service.SetLogger(logger)
|
||||
service.Config = &Config{}
|
||||
service.pkr = format.NewPropKeyResolver(service.Config)
|
||||
|
||||
return service.Config.SetURL(configURL)
|
||||
}
|
||||
|
||||
// GetID returns the service identifier.
|
||||
func (service *Service) GetID() string {
|
||||
return Scheme
|
||||
}
|
||||
|
||||
// GetConfigURLFromCustom converts a custom URL to a service URL.
|
||||
func (service *Service) GetConfigURLFromCustom(customURL *url.URL) (*url.URL, error) {
|
||||
webhookURLStr := strings.TrimPrefix(customURL.String(), "teams+")
|
||||
tempURL, err := url.Parse(webhookURLStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing custom URL %q: %w", webhookURLStr, err)
|
||||
}
|
||||
|
||||
webhookURL := &url.URL{
|
||||
Scheme: tempURL.Scheme,
|
||||
Host: tempURL.Host,
|
||||
Path: tempURL.Path,
|
||||
}
|
||||
|
||||
config, err := ConfigFromWebhookURL(*webhookURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Color = ""
|
||||
config.Title = ""
|
||||
|
||||
query := customURL.Query()
|
||||
for key, vals := range query {
|
||||
if vals[0] != "" {
|
||||
switch key {
|
||||
case "color":
|
||||
config.Color = vals[0]
|
||||
case "host":
|
||||
config.Host = vals[0]
|
||||
case "title":
|
||||
config.Title = vals[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config.GetURL(), nil
|
||||
}
|
||||
|
||||
// doSend sends the notification to Teams using the configured webhook URL.
|
||||
func (service *Service) doSend(config *Config, message string) error {
|
||||
lines := strings.Split(message, "\n")
|
||||
sections := make([]section, 0, len(lines))
|
||||
|
||||
for _, line := range lines {
|
||||
sections = append(sections, section{Text: line})
|
||||
}
|
||||
|
||||
summary := config.Title
|
||||
if summary == "" && len(sections) > 0 {
|
||||
summary = sections[0].Text
|
||||
if len(summary) > MaxSummaryLength {
|
||||
summary = summary[:TruncatedSummaryLen]
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(payload{
|
||||
CardType: "MessageCard",
|
||||
Context: "http://schema.org/extensions",
|
||||
Markdown: true,
|
||||
Title: config.Title,
|
||||
ThemeColor: config.Color,
|
||||
Summary: summary,
|
||||
Sections: sections,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
if config.Host == "" {
|
||||
return ErrMissingHost
|
||||
}
|
||||
|
||||
postURL := BuildWebhookURL(
|
||||
config.Host,
|
||||
config.Group,
|
||||
config.Tenant,
|
||||
config.AltID,
|
||||
config.GroupOwner,
|
||||
config.ExtraID,
|
||||
)
|
||||
|
||||
// Validate URL before sending
|
||||
if err := ValidateWebhookURL(postURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := safePost(postURL, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrSendFailed, err.Error())
|
||||
}
|
||||
defer res.Body.Close() // Move defer after error check
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %s", ErrSendFailedStatus, res.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// safePost performs an HTTP POST with a pre-validated URL.
|
||||
// Validation is already done; this wrapper isolates the call.
|
||||
//
|
||||
//nolint:gosec,noctx // Ignoring G107: Potential HTTP request made with variable url
|
||||
func safePost(url string, payload []byte) (*http.Response, error) {
|
||||
res, err := http.Post(url, "application/json", bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("making HTTP POST request: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
265
pkg/services/teams/teams_test.go
Normal file
265
pkg/services/teams/teams_test.go
Normal file
|
@ -0,0 +1,265 @@
|
|||
package teams
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
extraIDValue = "V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"
|
||||
scopedWebhookURL = "https://test.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue
|
||||
scopedDomainHost = "test.webhook.office.com"
|
||||
testURLBase = "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue
|
||||
scopedURLBase = testURLBase + "?host=" + scopedDomainHost
|
||||
)
|
||||
|
||||
var logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
|
||||
|
||||
// TestTeams runs the test suite for the Teams package.
|
||||
func TestTeams(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Shoutrrr Teams Suite")
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("the teams service", func() {
|
||||
ginkgo.When("creating the webhook URL", func() {
|
||||
ginkgo.It("should match the expected output for custom URLs", func() {
|
||||
config := Config{}
|
||||
config.setFromWebhookParts([5]string{
|
||||
"11111111-4444-4444-8444-cccccccccccc",
|
||||
"22222222-4444-4444-8444-cccccccccccc",
|
||||
"33333301222222222233333333333344",
|
||||
"44444444-4444-4444-8444-cccccccccccc",
|
||||
extraIDValue,
|
||||
})
|
||||
apiURL := BuildWebhookURL(
|
||||
scopedDomainHost,
|
||||
config.Group,
|
||||
config.Tenant,
|
||||
config.AltID,
|
||||
config.GroupOwner,
|
||||
config.ExtraID,
|
||||
)
|
||||
gomega.Expect(apiURL).To(gomega.Equal(scopedWebhookURL))
|
||||
|
||||
parts, err := ParseAndVerifyWebhookURL(apiURL)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(parts).To(gomega.Equal(config.WebhookParts()))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("creating a config", func() {
|
||||
ginkgo.When("parsing the configuration URL", func() {
|
||||
ginkgo.It("should be identical after de-/serialization", func() {
|
||||
testURL := testURLBase + "?color=aabbcc&host=test.webhook.office.com&title=Test+title"
|
||||
url, err := url.Parse(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
|
||||
|
||||
config := &Config{}
|
||||
err = config.SetURL(url)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
|
||||
|
||||
outputURL := config.GetURL()
|
||||
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("converting custom URL to service URL", func() {
|
||||
ginkgo.When("an invalid custom URL is provided", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
service := Service{}
|
||||
testURL := "teams+https://google.com/search?q=what+is+love"
|
||||
customURL, err := url.Parse(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
|
||||
|
||||
_, err = service.GetConfigURLFromCustom(customURL)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred(), "converting")
|
||||
})
|
||||
})
|
||||
ginkgo.When("a valid custom URL is provided", func() {
|
||||
ginkgo.It("should set the host field from the custom URL", func() {
|
||||
service := Service{}
|
||||
testURL := `teams+` + scopedWebhookURL
|
||||
customURL, err := url.Parse(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
|
||||
|
||||
serviceURL, err := service.GetConfigURLFromCustom(customURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting")
|
||||
gomega.Expect(serviceURL.String()).To(gomega.Equal(scopedURLBase))
|
||||
})
|
||||
ginkgo.It("should preserve the query params in the generated service URL", func() {
|
||||
service := Service{}
|
||||
testURL := "teams+" + scopedWebhookURL + "?color=f008c1&title=TheTitle"
|
||||
customURL, err := url.Parse(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
|
||||
|
||||
serviceURL, err := service.GetConfigURLFromCustom(customURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting")
|
||||
expectedURL := testURLBase + "?color=f008c1&host=test.webhook.office.com&title=TheTitle"
|
||||
gomega.Expect(serviceURL.String()).To(gomega.Equal(expectedURL))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("sending the payload", func() {
|
||||
var err error
|
||||
var service Service
|
||||
ginkgo.BeforeEach(func() {
|
||||
httpmock.Activate()
|
||||
})
|
||||
ginkgo.AfterEach(func() {
|
||||
httpmock.DeactivateAndReset()
|
||||
})
|
||||
ginkgo.It("should not report an error if the server accepts the payload", func() {
|
||||
serviceURL, _ := url.Parse(scopedURLBase)
|
||||
err = service.Initialize(serviceURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
|
||||
httpmock.RegisterResponder(
|
||||
"POST",
|
||||
scopedWebhookURL,
|
||||
httpmock.NewStringResponder(200, ""),
|
||||
)
|
||||
err = service.Send("Message", nil)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
})
|
||||
ginkgo.It("should not panic if an error occurs when sending the payload", func() {
|
||||
serviceURL, _ := url.Parse(testURLBase + "?host=test.webhook.office.com")
|
||||
err = service.Initialize(serviceURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
|
||||
httpmock.RegisterResponder(
|
||||
"POST",
|
||||
scopedWebhookURL,
|
||||
httpmock.NewErrorResponder(errors.New("dummy error")),
|
||||
)
|
||||
err = service.Send("Message", nil)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.It("should return the correct service ID", func() {
|
||||
service := &Service{}
|
||||
gomega.Expect(service.GetID()).To(gomega.Equal("teams"))
|
||||
})
|
||||
|
||||
// Config tests
|
||||
ginkgo.Describe("the teams config", func() {
|
||||
ginkgo.Describe("setURL", func() {
|
||||
ginkgo.It("should set all fields correctly from URL", func() {
|
||||
config := &Config{}
|
||||
urlStr := testURLBase + "?title=Test&color=red&host=test.webhook.office.com"
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
|
||||
err = config.SetURL(parsedURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
|
||||
gomega.Expect(config.Group).To(gomega.Equal("11111111-4444-4444-8444-cccccccccccc"))
|
||||
gomega.Expect(config.Tenant).
|
||||
To(gomega.Equal("22222222-4444-4444-8444-cccccccccccc"))
|
||||
gomega.Expect(config.AltID).To(gomega.Equal("33333301222222222233333333333344"))
|
||||
gomega.Expect(config.GroupOwner).
|
||||
To(gomega.Equal("44444444-4444-4444-8444-cccccccccccc"))
|
||||
gomega.Expect(config.ExtraID).To(gomega.Equal(extraIDValue))
|
||||
gomega.Expect(config.Title).To(gomega.Equal("Test"))
|
||||
gomega.Expect(config.Color).To(gomega.Equal("red"))
|
||||
gomega.Expect(config.Host).To(gomega.Equal("test.webhook.office.com"))
|
||||
})
|
||||
|
||||
ginkgo.It("should reject URLs missing the extraID", func() {
|
||||
config := &Config{}
|
||||
urlStr := "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc?host=test.webhook.office.com"
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
|
||||
err = config.SetURL(parsedURL)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
})
|
||||
|
||||
ginkgo.It("should require the host parameter", func() {
|
||||
config := &Config{}
|
||||
urlStr := testURLBase
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
|
||||
err = config.SetURL(parsedURL)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("getURL", func() {
|
||||
ginkgo.It("should generate correct URL with all parameters", func() {
|
||||
config := &Config{
|
||||
Group: "11111111-4444-4444-8444-cccccccccccc",
|
||||
Tenant: "22222222-4444-4444-8444-cccccccccccc",
|
||||
AltID: "33333301222222222233333333333344",
|
||||
GroupOwner: "44444444-4444-4444-8444-cccccccccccc",
|
||||
ExtraID: extraIDValue,
|
||||
Title: "Test",
|
||||
Color: "red",
|
||||
Host: "test.webhook.office.com",
|
||||
}
|
||||
|
||||
urlObj := config.GetURL()
|
||||
urlStr := urlObj.String()
|
||||
expectedURL := testURLBase + "?color=red&host=test.webhook.office.com&title=Test"
|
||||
gomega.Expect(urlStr).To(gomega.Equal(expectedURL))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("verifyWebhookParts", func() {
|
||||
ginkgo.It("should validate correct webhook parts", func() {
|
||||
parts := [5]string{
|
||||
"11111111-4444-4444-8444-cccccccccccc",
|
||||
"22222222-4444-4444-8444-cccccccccccc",
|
||||
"33333301222222222233333333333344",
|
||||
"44444444-4444-4444-8444-cccccccccccc",
|
||||
extraIDValue,
|
||||
}
|
||||
err := verifyWebhookParts(parts)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
})
|
||||
|
||||
ginkgo.It("should reject invalid group ID", func() {
|
||||
parts := [5]string{
|
||||
"invalid-id",
|
||||
"22222222-4444-4444-8444-cccccccccccc",
|
||||
"33333333012222222222333333333344",
|
||||
"44444444-4444-4444-8444-cccccccccccc",
|
||||
extraIDValue,
|
||||
}
|
||||
err := verifyWebhookParts(parts)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("parseAndVerifyWebhookURL", func() {
|
||||
ginkgo.It("should correctly parse valid webhook URL", func() {
|
||||
webhookURL := scopedWebhookURL
|
||||
parts, err := ParseAndVerifyWebhookURL(webhookURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(parts).To(gomega.Equal([5]string{
|
||||
"11111111-4444-4444-8444-cccccccccccc",
|
||||
"22222222-4444-4444-8444-cccccccccccc",
|
||||
"33333301222222222233333333333344",
|
||||
"44444444-4444-4444-8444-cccccccccccc",
|
||||
extraIDValue,
|
||||
}))
|
||||
})
|
||||
|
||||
ginkgo.It("should reject invalid webhook URL", func() {
|
||||
webhookURL := "https://teams.microsoft.com/invalid/webhook/url"
|
||||
_, err := ParseAndVerifyWebhookURL(webhookURL)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
62
pkg/services/teams/teams_types.go
Normal file
62
pkg/services/teams/teams_types.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
package teams
|
||||
|
||||
// payload is the main structure for a Teams message card.
|
||||
type payload struct {
|
||||
CardType string `json:"@type"`
|
||||
Context string `json:"@context"`
|
||||
ThemeColor string `json:"themeColor,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Markdown bool `json:"markdown"`
|
||||
Sections []section `json:"sections"`
|
||||
}
|
||||
|
||||
// section represents a section of a Teams message card.
|
||||
type section struct {
|
||||
ActivityTitle string `json:"activityTitle,omitempty"`
|
||||
ActivitySubtitle string `json:"activitySubtitle,omitempty"`
|
||||
ActivityImage string `json:"activityImage,omitempty"`
|
||||
Facts []fact `json:"facts,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Images []image `json:"images,omitempty"`
|
||||
Actions []action `json:"potentialAction,omitempty"`
|
||||
HeroImage *heroCard `json:"heroImage,omitempty"`
|
||||
}
|
||||
|
||||
// fact represents a key-value pair in a Teams message card.
|
||||
type fact struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// image represents an image in a Teams message card.
|
||||
type image struct {
|
||||
Image string `json:"image"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// action represents an action button in a Teams message card.
|
||||
type action struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
Targets []target `json:"targets,omitempty"`
|
||||
Actions []subAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
// target represents a target for an action in a Teams message card.
|
||||
type target struct {
|
||||
OS string `json:"os"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// subAction represents a sub-action in a Teams message card.
|
||||
type subAction struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// heroCard represents a hero image in a Teams message card.
|
||||
type heroCard struct {
|
||||
Image string `json:"image"`
|
||||
}
|
107
pkg/services/teams/teams_validation.go
Normal file
107
pkg/services/teams/teams_validation.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
package teams
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Validation constants.
|
||||
const (
|
||||
UUID4Length = 36 // Length of a UUID4 identifier
|
||||
HashLength = 32 // Length of a hash identifier
|
||||
WebhookDomain = ".webhook.office.com"
|
||||
ExpectedComponents = 7 // Expected number of components in webhook URL (1 match + 6 captures)
|
||||
Path = "webhookb2"
|
||||
ProviderName = "IncomingWebhook"
|
||||
|
||||
AltIDIndex = 2 // Index of AltID in parts array
|
||||
GroupOwnerIndex = 3 // Index of GroupOwner in parts array
|
||||
)
|
||||
|
||||
var (
|
||||
// HostValidator ensures the host matches the Teams webhook domain pattern.
|
||||
HostValidator = regexp.MustCompile(`^[a-zA-Z0-9-]+\.webhook\.office\.com$`)
|
||||
// WebhookURLValidator ensures the full webhook URL matches the Teams pattern.
|
||||
WebhookURLValidator = regexp.MustCompile(
|
||||
`^https://[a-zA-Z0-9-]+\.webhook\.office\.com/webhookb2/[0-9a-f-]{36}@[0-9a-f-]{36}/IncomingWebhook/[0-9a-f]{32}/[0-9a-f-]{36}/[^/]+$`,
|
||||
)
|
||||
)
|
||||
|
||||
// ValidateWebhookURL ensures the webhook URL is valid before use.
|
||||
func ValidateWebhookURL(url string) error {
|
||||
if !WebhookURLValidator.MatchString(url) {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidWebhookURL, url)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseAndVerifyWebhookURL extracts and validates webhook components from a URL.
|
||||
func ParseAndVerifyWebhookURL(webhookURL string) ([5]string, error) {
|
||||
pattern := regexp.MustCompile(
|
||||
`https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`,
|
||||
)
|
||||
|
||||
groups := pattern.FindStringSubmatch(webhookURL)
|
||||
if len(groups) != ExpectedComponents {
|
||||
return [5]string{}, fmt.Errorf(
|
||||
"%w: expected %d components, got %d",
|
||||
ErrInvalidWebhookComponents,
|
||||
ExpectedComponents,
|
||||
len(groups),
|
||||
)
|
||||
}
|
||||
|
||||
parts := [5]string{groups[2], groups[3], groups[4], groups[5], groups[6]}
|
||||
if err := verifyWebhookParts(parts); err != nil {
|
||||
return [5]string{}, err
|
||||
}
|
||||
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// verifyWebhookParts ensures webhook components meet format requirements.
|
||||
func verifyWebhookParts(parts [5]string) error {
|
||||
type partSpec struct {
|
||||
name string
|
||||
length int
|
||||
index int
|
||||
optional bool
|
||||
}
|
||||
|
||||
specs := []partSpec{
|
||||
{name: "group ID", length: UUID4Length, index: 0, optional: true},
|
||||
{name: "tenant ID", length: UUID4Length, index: 1, optional: true},
|
||||
{name: "altID", length: HashLength, index: AltIDIndex, optional: true},
|
||||
{name: "groupOwner", length: UUID4Length, index: GroupOwnerIndex, optional: true},
|
||||
}
|
||||
|
||||
for _, spec := range specs {
|
||||
if len(parts[spec.index]) != spec.length && parts[spec.index] != "" {
|
||||
return fmt.Errorf(
|
||||
"%w: %s must be %d characters, got %d",
|
||||
ErrInvalidPartLength,
|
||||
spec.name,
|
||||
spec.length,
|
||||
len(parts[spec.index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if parts[4] == "" {
|
||||
return ErrMissingExtraID
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildWebhookURL constructs a Teams webhook URL from components.
|
||||
func BuildWebhookURL(host, group, tenant, altID, groupOwner, extraID string) string {
|
||||
// Host validation moved here for clarity
|
||||
if !HostValidator.MatchString(host) {
|
||||
return "" // Will trigger ErrInvalidHostFormat in caller
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s/%s/%s@%s/%s/%s/%s/%s",
|
||||
host, Path, group, tenant, ProviderName, altID, groupOwner, extraID)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue