feat: add Slack target type for incoming webhook notifications (#47)
All checks were successful
check / check (push) Successful in 4s
All checks were successful
check / check (push) Successful in 4s
## Summary Adds a new `slack` target type that sends webhook events as formatted messages to any Slack-compatible incoming webhook URL (Slack, Mattermost, and other compatible services). closes #44 ## What it does When a webhook event is received, the Slack target: 1. Formats a human-readable message with event metadata (HTTP method, content type, timestamp, body size) 2. Pretty-prints the payload in a code block — JSON payloads get indented formatting, non-JSON payloads are shown as raw text 3. Truncates large payloads at 3500 characters to keep Slack messages reasonable 4. POSTs the message as a `{"text": "..."}` JSON payload to the configured webhook URL ## Changes - **`internal/database/model_target.go`** — Add `TargetTypeSlack` constant - **`internal/delivery/engine.go`** — Add `SlackTargetConfig` struct, `deliverSlack` method, `FormatSlackMessage` function (exported), `parseSlackConfig` helper. Route slack targets in `processDelivery` switch. - **`internal/handlers/source_management.go`** — Handle `slack` type in `HandleTargetCreate`, building `webhook_url` config from the URL form field - **`templates/source_detail.html`** — Add "Slack" option to target type dropdown with URL field and helper text - **`README.md`** — Document the new target type, update roadmap ## Tests - `TestParseSlackConfig_Valid` / `_Empty` / `_MissingWebhookURL` — Config parsing - `TestFormatSlackMessage_JSONBody` / `_NonJSONBody` / `_EmptyBody` / `_LargeJSONTruncated` — Message formatting - `TestDeliverSlack_Success` / `_Failure` / `_InvalidConfig` — End-to-end delivery - `TestProcessDelivery_RoutesToSlack` — Routing from processDelivery switch All existing tests continue to pass. `docker build .` (which runs `make check`) passes clean. Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #47 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #47.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -934,3 +935,284 @@ func TestMaxInlineBodySize_Constant(t *testing.T) {
|
||||
assert.Equal(t, 16*1024, MaxInlineBodySize,
|
||||
"MaxInlineBodySize should be 16KB (16384 bytes)")
|
||||
}
|
||||
|
||||
func TestParseSlackConfig_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := testEngine(t, 1)
|
||||
|
||||
cfg, err := e.parseSlackConfig(`{"webhook_url":"https://hooks.slack.com/services/T00/B00/xxx"}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://hooks.slack.com/services/T00/B00/xxx", cfg.WebhookURL)
|
||||
}
|
||||
|
||||
func TestParseSlackConfig_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := testEngine(t, 1)
|
||||
|
||||
_, err := e.parseSlackConfig("")
|
||||
assert.Error(t, err, "empty config should return error")
|
||||
}
|
||||
|
||||
func TestParseSlackConfig_MissingWebhookURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := testEngine(t, 1)
|
||||
|
||||
_, err := e.parseSlackConfig(`{"other":"field"}`)
|
||||
assert.Error(t, err, "config without webhook_url should return error")
|
||||
}
|
||||
|
||||
func TestFormatSlackMessage_JSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
ContentType: "application/json",
|
||||
Body: `{"action":"push","repo":"test/repo","ref":"refs/heads/main"}`,
|
||||
}
|
||||
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
msg := FormatSlackMessage(event)
|
||||
|
||||
assert.Contains(t, msg, "*Webhook Event Received*")
|
||||
assert.Contains(t, msg, "`POST`")
|
||||
assert.Contains(t, msg, "`application/json`")
|
||||
assert.Contains(t, msg, "```")
|
||||
assert.NotContains(t, msg, "```json")
|
||||
// Pretty-printed JSON should have indentation
|
||||
assert.Contains(t, msg, ` "action": "push"`)
|
||||
assert.Contains(t, msg, ` "repo": "test/repo"`)
|
||||
}
|
||||
|
||||
func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
ContentType: "text/plain",
|
||||
Body: "hello world plain text",
|
||||
}
|
||||
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
msg := FormatSlackMessage(event)
|
||||
|
||||
assert.Contains(t, msg, "*Webhook Event Received*")
|
||||
assert.Contains(t, msg, "```\nhello world plain text\n```")
|
||||
// Should NOT have ```json marker for non-JSON
|
||||
assert.NotContains(t, msg, "```json")
|
||||
}
|
||||
|
||||
func TestFormatSlackMessage_EmptyBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
ContentType: "application/json",
|
||||
Body: "",
|
||||
}
|
||||
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
msg := FormatSlackMessage(event)
|
||||
|
||||
assert.Contains(t, msg, "_(empty body)_")
|
||||
assert.NotContains(t, msg, "```")
|
||||
}
|
||||
|
||||
func TestFormatSlackMessage_LargeJSONTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Build a large JSON body that will exceed 3500 chars when pretty-printed
|
||||
largeObj := make(map[string]string)
|
||||
for i := 0; i < 200; i++ {
|
||||
largeObj[fmt.Sprintf("key_%03d", i)] = strings.Repeat("v", 20)
|
||||
}
|
||||
largeJSON, err := json.Marshal(largeObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &database.Event{
|
||||
Method: "POST",
|
||||
ContentType: "application/json",
|
||||
Body: string(largeJSON),
|
||||
}
|
||||
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
msg := FormatSlackMessage(event)
|
||||
|
||||
assert.Contains(t, msg, "... (truncated)")
|
||||
}
|
||||
|
||||
func TestDeliverSlack_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := testWebhookDB(t)
|
||||
|
||||
var receivedBody string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
bodyBytes, readErr := io.ReadAll(r.Body)
|
||||
if readErr != nil {
|
||||
http.Error(w, "read error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
receivedBody = string(bodyBytes)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
targetID := uuid.New().String()
|
||||
slackCfg, err := json.Marshal(SlackTargetConfig{WebhookURL: ts.URL})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"action":"test","data":"value"}`)
|
||||
dlv := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-slack",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: string(slackCfg),
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
|
||||
e.deliverSlack(db, d)
|
||||
|
||||
// The delivery should be marked as delivered
|
||||
var updated database.Delivery
|
||||
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
|
||||
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status)
|
||||
|
||||
// Check that a result was recorded
|
||||
var result database.DeliveryResult
|
||||
require.NoError(t, db.Where("delivery_id = ?", dlv.ID).First(&result).Error)
|
||||
assert.True(t, result.Success)
|
||||
assert.Equal(t, http.StatusOK, result.StatusCode)
|
||||
|
||||
// Verify the Slack payload contains the expected message
|
||||
var slackPayload map[string]string
|
||||
require.NoError(t, json.Unmarshal([]byte(receivedBody), &slackPayload))
|
||||
assert.Contains(t, slackPayload["text"], "*Webhook Event Received*")
|
||||
assert.NotContains(t, slackPayload["text"], "**Webhook Event Received**")
|
||||
assert.Contains(t, slackPayload["text"], "```")
|
||||
assert.NotContains(t, slackPayload["text"], "```json")
|
||||
}
|
||||
|
||||
func TestDeliverSlack_Failure(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := testWebhookDB(t)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprint(w, "invalid_token")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
targetID := uuid.New().String()
|
||||
slackCfg, err := json.Marshal(SlackTargetConfig{WebhookURL: ts.URL})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"test":true}`)
|
||||
dlv := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-slack-fail",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: string(slackCfg),
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
|
||||
e.deliverSlack(db, d)
|
||||
|
||||
var updated database.Delivery
|
||||
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
|
||||
assert.Equal(t, database.DeliveryStatusFailed, updated.Status)
|
||||
|
||||
var result database.DeliveryResult
|
||||
require.NoError(t, db.Where("delivery_id = ?", dlv.ID).First(&result).Error)
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, http.StatusForbidden, result.StatusCode)
|
||||
}
|
||||
|
||||
func TestDeliverSlack_InvalidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := testWebhookDB(t)
|
||||
e := testEngine(t, 1)
|
||||
|
||||
event := seedEvent(t, db, `{"test":true}`)
|
||||
dlv := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: dlv.TargetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-slack-bad",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"not_webhook_url":"missing"}`,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
|
||||
e.deliverSlack(db, d)
|
||||
|
||||
var updated database.Delivery
|
||||
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
|
||||
assert.Equal(t, database.DeliveryStatusFailed, updated.Status)
|
||||
}
|
||||
|
||||
func TestProcessDelivery_RoutesToSlack(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := testWebhookDB(t)
|
||||
|
||||
var received atomic.Bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
received.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
slackCfg, err := json.Marshal(SlackTargetConfig{WebhookURL: ts.URL})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"route":"slack"}`)
|
||||
dlv := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
|
||||
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: dlv.TargetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-slack-route",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: string(slackCfg),
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
|
||||
task := &DeliveryTask{
|
||||
DeliveryID: dlv.ID,
|
||||
TargetType: database.TargetTypeSlack,
|
||||
}
|
||||
|
||||
e.processDelivery(context.TODO(), db, d, task)
|
||||
|
||||
assert.True(t, received.Load(), "Slack target should have received the request")
|
||||
|
||||
var updated database.Delivery
|
||||
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
|
||||
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user