All checks were successful
check / check (push) Successful in 3m59s
Every Slack and Mattermost message the engine sent rendered a `*Timestamp:*` of `0001-01-01T00:00:00Z` while the stored event's `created_at` was correct. Both delivery paths reconstruct the event from the Task that carries it, and no Task carries a receipt time, so `FormatSlackMessage` formatted a zero `time.Time`. `Task` is not the place to fix it: it is built in three places, two of them on the receiver side, and a field there would have left the live first-attempt and retry paths still zero while only the restart recovery path came out correct. The stored row stays the single source of truth instead. `resolveEventBody` becomes `hydrateEvent` and reads `created_at` alongside the body, so every path that reconstructs an event gets the receipt time with it. A task that inlined its body previously never read the event row. It does now, and a read failure there is no longer fatal: the row can be reaped by retention while a queued delivery still holds its body, and such a delivery goes out with the timestamp unset rather than being dropped. A task with no inlined body still fails, as before.
408 lines
9.7 KiB
Go
408 lines
9.7 KiB
Go
package delivery_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// tsEventCreatedAt is the receipt time seeded on the events these
|
|
// tests deliver. It is far enough from both the zero time and from
|
|
// now that neither can be mistaken for it.
|
|
func tsEventCreatedAt() time.Time {
|
|
return time.Date(
|
|
2026, time.March, 4, 5, 6, 7, 0, time.UTC,
|
|
)
|
|
}
|
|
|
|
// tsZeroStamp is what a Slack message renders when the event handed
|
|
// to FormatSlackMessage carries no CreatedAt.
|
|
const tsZeroStamp = "*Timestamp:* `0001-01-01T00:00:00Z`"
|
|
|
|
// tsEventBody is the body seeded on every event in this file. It is
|
|
// small enough that a Task can inline it.
|
|
const tsEventBody = `{"hello":"world"}`
|
|
|
|
// tsUndeliverableHook stands in for a Slack incoming webhook on the
|
|
// tests that never send: the config parser requires a URL, but no
|
|
// request is made.
|
|
const tsUndeliverableHook = "https://hooks.slack.com/services/T/B/x"
|
|
|
|
// tsSink is a stand-in Slack incoming webhook that records the raw
|
|
// body posted to it.
|
|
type tsSink struct {
|
|
*httptest.Server
|
|
|
|
bodies chan []byte
|
|
}
|
|
|
|
func newTSSink(t *testing.T) *tsSink {
|
|
t.Helper()
|
|
|
|
s := &tsSink{bodies: make(chan []byte, 8)}
|
|
|
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
|
|
select {
|
|
case s.bodies <- body:
|
|
default:
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
t.Cleanup(s.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
// text returns the Slack message text from the single payload the
|
|
// sink received.
|
|
func (s *tsSink) text(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
select {
|
|
case raw := <-s.bodies:
|
|
t.Logf("raw slack payload: %s", raw)
|
|
|
|
var payload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
require.NoError(t, json.Unmarshal(raw, &payload))
|
|
|
|
return payload.Text
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("slack sink received no payload")
|
|
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func tsSlackConfig(t *testing.T, url string) string {
|
|
t.Helper()
|
|
|
|
data, err := json.Marshal(
|
|
delivery.SlackTargetConfig{WebhookURL: url},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return string(data)
|
|
}
|
|
|
|
// tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt
|
|
// rather than the write time, so an assertion on the rendered
|
|
// timestamp cannot pass by accident against "roughly now".
|
|
func tsSeedEvent(
|
|
t *testing.T, db *gorm.DB, webhookID string,
|
|
) database.Event {
|
|
t.Helper()
|
|
|
|
event := database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPost,
|
|
Headers: `{}`,
|
|
Body: tsEventBody,
|
|
ContentType: "application/json",
|
|
}
|
|
event.ID = uuid.New().String()
|
|
event.CreatedAt = tsEventCreatedAt()
|
|
event.UpdatedAt = tsEventCreatedAt()
|
|
|
|
require.NoError(t, db.Create(&event).Error)
|
|
|
|
var stored database.Event
|
|
|
|
require.NoError(t,
|
|
db.First(&stored, "id = ?", event.ID).Error,
|
|
)
|
|
require.Equal(t,
|
|
tsEventCreatedAt().UTC(), stored.CreatedAt.UTC(),
|
|
"seeded created_at did not round-trip",
|
|
)
|
|
|
|
return event
|
|
}
|
|
|
|
// tsSeedTarget writes the slack target row into the main database.
|
|
// The retry path confirms the target still exists before sending.
|
|
func tsSeedTarget(
|
|
t *testing.T, mainDB *gorm.DB, webhookID, config string,
|
|
) database.Target {
|
|
t.Helper()
|
|
|
|
target := database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "slack-sink",
|
|
Type: database.TargetTypeSlack,
|
|
Config: config,
|
|
Active: true,
|
|
}
|
|
|
|
require.NoError(t, mainDB.Create(&target).Error)
|
|
|
|
return target
|
|
}
|
|
|
|
func tsTask(
|
|
d database.Delivery,
|
|
event database.Event,
|
|
webhookID string,
|
|
target database.Target,
|
|
attemptNum int,
|
|
body *string,
|
|
) delivery.Task {
|
|
return delivery.Task{
|
|
DeliveryID: d.ID,
|
|
EventID: event.ID,
|
|
WebhookID: webhookID,
|
|
EntrypointID: event.EntrypointID,
|
|
TargetID: target.ID,
|
|
TargetName: target.Name,
|
|
TargetType: database.TargetTypeSlack,
|
|
TargetConfig: target.Config,
|
|
MaxRetries: 0,
|
|
Method: event.Method,
|
|
Headers: event.Headers,
|
|
ContentType: event.ContentType,
|
|
Body: body,
|
|
AttemptNum: attemptNum,
|
|
}
|
|
}
|
|
|
|
func tsAssertRealTimestamp(t *testing.T, text string) {
|
|
t.Helper()
|
|
|
|
assert.NotContains(t, text, tsZeroStamp,
|
|
"slack message carries the zero timestamp",
|
|
)
|
|
assert.Contains(t, text,
|
|
"*Timestamp:* `"+
|
|
tsEventCreatedAt().UTC().Format(time.RFC3339)+"`",
|
|
"slack message does not carry the event's receipt time",
|
|
)
|
|
}
|
|
|
|
// tsCase is one end-to-end delivery of a seeded event to a slack
|
|
// sink, over whichever engine path `process` names.
|
|
type tsCase struct {
|
|
// status is the delivery row's status before the engine runs.
|
|
// The retry path refuses a delivery that is not retrying.
|
|
status database.DeliveryStatus
|
|
|
|
// inlineBody mirrors a Task built for a body under
|
|
// MaxInlineBodySize. When false the engine reads the body back
|
|
// from the stored row.
|
|
inlineBody bool
|
|
|
|
attemptNum int
|
|
|
|
process func(
|
|
ctx context.Context, e *delivery.Engine, task *delivery.Task,
|
|
)
|
|
}
|
|
|
|
// run delivers one event through the named path and returns the
|
|
// Slack message text the sink received.
|
|
func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) {
|
|
t.Helper()
|
|
|
|
s := newISetup(t)
|
|
sink := newTSSink(t)
|
|
|
|
cfg := tsSlackConfig(t, sink.URL)
|
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
|
|
|
d := iSeedDelivery(
|
|
t, s.WebhookDB, event.ID, target.ID, c.status,
|
|
)
|
|
|
|
var body *string
|
|
|
|
if c.inlineBody {
|
|
bodyStr := event.Body
|
|
body = &bodyStr
|
|
}
|
|
|
|
task := tsTask(
|
|
d, event, s.WebhookID, target, c.attemptNum, body,
|
|
)
|
|
|
|
c.process(context.TODO(), s.Engine, &task)
|
|
|
|
return s, d, sink.text(t)
|
|
}
|
|
|
|
// TestSlackFirstAttemptCarriesEventTimestamp covers the path an
|
|
// event takes on its first delivery: the task comes from the
|
|
// receiver and the engine reconstructs the event from it.
|
|
func TestSlackFirstAttemptCarriesEventTimestamp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, d, text := tsCase{
|
|
status: database.DeliveryStatusPending,
|
|
inlineBody: true,
|
|
attemptNum: 1,
|
|
process: func(
|
|
ctx context.Context,
|
|
e *delivery.Engine,
|
|
task *delivery.Task,
|
|
) {
|
|
e.ExportProcessNewTask(ctx, task)
|
|
},
|
|
}.run(t)
|
|
|
|
tsAssertRealTimestamp(t, text)
|
|
|
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
}
|
|
|
|
// TestSlackFirstAttemptLargeBodyCarriesEventTimestamp covers the
|
|
// first-attempt path for an event whose body exceeded
|
|
// MaxInlineBodySize, so the task carries no body and the engine
|
|
// reads it back from the stored row.
|
|
func TestSlackFirstAttemptLargeBodyCarriesEventTimestamp(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
_, _, text := tsCase{
|
|
status: database.DeliveryStatusPending,
|
|
inlineBody: false,
|
|
attemptNum: 1,
|
|
process: func(
|
|
ctx context.Context,
|
|
e *delivery.Engine,
|
|
task *delivery.Task,
|
|
) {
|
|
e.ExportProcessNewTask(ctx, task)
|
|
},
|
|
}.run(t)
|
|
|
|
tsAssertRealTimestamp(t, text)
|
|
}
|
|
|
|
// TestSlackRetryCarriesEventTimestamp covers the retry path, which
|
|
// reconstructs the event from the same task the first attempt used.
|
|
func TestSlackRetryCarriesEventTimestamp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, d, text := tsCase{
|
|
status: database.DeliveryStatusRetrying,
|
|
inlineBody: true,
|
|
attemptNum: 2,
|
|
process: func(
|
|
ctx context.Context,
|
|
e *delivery.Engine,
|
|
task *delivery.Task,
|
|
) {
|
|
e.ExportProcessRetryTask(ctx, task)
|
|
},
|
|
}.run(t)
|
|
|
|
tsAssertRealTimestamp(t, text)
|
|
|
|
iAssertStatus(t, s.WebhookDB, d.ID,
|
|
database.DeliveryStatusDelivered,
|
|
)
|
|
}
|
|
|
|
// TestFormatSlackMessageOverTaskReconstructedEvent asserts on the
|
|
// formatted message directly, over the event the delivery paths
|
|
// reconstruct from a Task. It is the unit-level guard under the
|
|
// end-to-end tests: revert the CreatedAt population in hydrateEvent
|
|
// and this fails on the zero timestamp.
|
|
func TestFormatSlackMessageOverTaskReconstructedEvent(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
s := newISetup(t)
|
|
|
|
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
|
|
|
d := iSeedDelivery(
|
|
t, s.WebhookDB, event.ID, target.ID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
bodyStr := event.Body
|
|
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
|
|
|
|
rebuilt, err := s.Engine.ExportEventForTask(
|
|
s.WebhookDB, &task,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.False(t, rebuilt.CreatedAt.IsZero(),
|
|
"reconstructed event carries the zero time",
|
|
)
|
|
assert.Equal(t,
|
|
tsEventCreatedAt().UTC(), rebuilt.CreatedAt.UTC(),
|
|
)
|
|
|
|
tsAssertRealTimestamp(
|
|
t, delivery.FormatSlackMessage(&rebuilt),
|
|
)
|
|
}
|
|
|
|
// TestEventReconstructionSurvivesAReapedRow pins the fallback: an
|
|
// event row reaped by retention while its delivery still holds the
|
|
// body inline is still delivered, with the receipt time unset,
|
|
// rather than dropped.
|
|
func TestEventReconstructionSurvivesAReapedRow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newISetup(t)
|
|
|
|
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
|
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
|
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
|
|
|
d := iSeedDelivery(
|
|
t, s.WebhookDB, event.ID, target.ID,
|
|
database.DeliveryStatusPending,
|
|
)
|
|
|
|
bodyStr := event.Body
|
|
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
|
|
|
|
require.NoError(t, s.WebhookDB.Unscoped().Delete(
|
|
&database.Event{}, "id = ?", event.ID,
|
|
).Error)
|
|
|
|
rebuilt, err := s.Engine.ExportEventForTask(
|
|
s.WebhookDB, &task,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, bodyStr, rebuilt.Body)
|
|
assert.True(t, rebuilt.CreatedAt.IsZero())
|
|
|
|
// A task with no inlined body has nothing left to deliver, so
|
|
// the same reaped row is an error there.
|
|
noBody := task
|
|
noBody.Body = nil
|
|
|
|
_, err = s.Engine.ExportEventForTask(s.WebhookDB, &noBody)
|
|
require.Error(t, err)
|
|
}
|