249 lines
8.1 KiB
Go
249 lines
8.1 KiB
Go
package notify
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func quiet() *slog.Logger {
|
||
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
|
|
}
|
||
|
|
|
||
|
|
type capture struct {
|
||
|
|
records []Record
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *capture) RecordNotification(_ context.Context, record Record) error {
|
||
|
|
c.records = append(c.records, record)
|
||
|
|
return c.err
|
||
|
|
}
|
||
|
|
|
||
|
|
func service(t *testing.T, recorder Recorder, fn func(context.Context, Notification) Outcome) *Service {
|
||
|
|
t.Helper()
|
||
|
|
s := New(recorder, quiet())
|
||
|
|
if fn != nil {
|
||
|
|
s.Register(DelivererFunc{Name: ChannelInApp, Fn: fn})
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSendDeliversThenRecords(t *testing.T) {
|
||
|
|
recorder := &capture{}
|
||
|
|
delivered := false
|
||
|
|
s := service(t, recorder, func(context.Context, Notification) Outcome {
|
||
|
|
// The record must not exist yet: the whole ordering claim is that a notification is
|
||
|
|
// delivered first and described afterwards.
|
||
|
|
if len(recorder.records) != 0 {
|
||
|
|
t.Fatal("the audit trail was written before the notification was delivered")
|
||
|
|
}
|
||
|
|
delivered = true
|
||
|
|
return Sent()
|
||
|
|
})
|
||
|
|
|
||
|
|
outcome := s.Send(context.Background(), Notification{
|
||
|
|
Channel: ChannelInApp, Kind: "show-return", Source: "test",
|
||
|
|
UserID: "u1", Username: "Ada", Title: "New episode coming",
|
||
|
|
})
|
||
|
|
|
||
|
|
if !delivered {
|
||
|
|
t.Fatal("the notification was never delivered")
|
||
|
|
}
|
||
|
|
if outcome.Status != StatusSent {
|
||
|
|
t.Fatalf("status = %q, want sent", outcome.Status)
|
||
|
|
}
|
||
|
|
if len(recorder.records) != 1 {
|
||
|
|
t.Fatalf("recorded %d rows, want 1", len(recorder.records))
|
||
|
|
}
|
||
|
|
record := recorder.records[0]
|
||
|
|
if record.UserID != "u1" || record.Kind != "show-return" || record.Status != StatusSent {
|
||
|
|
t.Fatalf("record = %+v", record)
|
||
|
|
}
|
||
|
|
if record.OccurredAt.IsZero() {
|
||
|
|
t.Fatal("the record carries no timestamp")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A history that could suppress a notification would be worse than no history, so a
|
||
|
|
// recorder that will not write must not change what the caller is told.
|
||
|
|
func TestRecorderFailureDoesNotAffectDelivery(t *testing.T) {
|
||
|
|
recorder := &capture{err: errors.New("postgres is down")}
|
||
|
|
s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() })
|
||
|
|
|
||
|
|
outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"})
|
||
|
|
|
||
|
|
if outcome.Status != StatusSent {
|
||
|
|
t.Fatalf("status = %q, want sent despite the failed write", outcome.Status)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The producers here are deliberately re-run — the digest job fires hourly and re-sends the
|
||
|
|
// same weekly key all evening — so a cancelled caller must not be able to lose the record
|
||
|
|
// of the one pass that actually delivered.
|
||
|
|
func TestRecordSurvivesACancelledCaller(t *testing.T) {
|
||
|
|
recorder := &capture{}
|
||
|
|
s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() })
|
||
|
|
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
cancel()
|
||
|
|
s.Send(ctx, Notification{Channel: ChannelInApp, UserID: "u1"})
|
||
|
|
|
||
|
|
if len(recorder.records) != 1 {
|
||
|
|
t.Fatalf("recorded %d rows, want 1 from a cancelled caller", len(recorder.records))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSkippedIsRecordedWithItsReason(t *testing.T) {
|
||
|
|
recorder := &capture{}
|
||
|
|
s := service(t, recorder, func(context.Context, Notification) Outcome {
|
||
|
|
return Skipped("this viewer has summaries switched off")
|
||
|
|
})
|
||
|
|
|
||
|
|
outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"})
|
||
|
|
|
||
|
|
if outcome.Status != StatusSkipped {
|
||
|
|
t.Fatalf("status = %q, want skipped", outcome.Status)
|
||
|
|
}
|
||
|
|
if got := recorder.records[0].Detail; got != "this viewer has summaries switched off" {
|
||
|
|
t.Fatalf("detail = %q; a skip with no reason is the row this page exists to avoid", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A channel with no provider is a configuration fault, and it is worth a row: silence would
|
||
|
|
// look exactly like a household in which nothing happened.
|
||
|
|
func TestMissingProviderIsRecordedAsAFailure(t *testing.T) {
|
||
|
|
recorder := &capture{}
|
||
|
|
s := service(t, recorder, nil)
|
||
|
|
|
||
|
|
outcome := s.Send(context.Background(), Notification{Channel: ChannelBroadcast, Kind: "x"})
|
||
|
|
|
||
|
|
if outcome.Status != StatusFailed {
|
||
|
|
t.Fatalf("status = %q, want failed", outcome.Status)
|
||
|
|
}
|
||
|
|
if len(recorder.records) != 1 || recorder.records[0].Detail == "" {
|
||
|
|
t.Fatalf("records = %+v, want one row naming the missing provider", recorder.records)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A nil service is what every unit test of a producing feature holds. It must answer rather
|
||
|
|
// than panic, or every call site grows a nil check — which is how a notification comes to be
|
||
|
|
// silently dropped.
|
||
|
|
func TestNilServiceStillAnswers(t *testing.T) {
|
||
|
|
var s *Service
|
||
|
|
if outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp}); outcome.Status != StatusSkipped {
|
||
|
|
t.Fatalf("status = %q, want skipped from a nil service", outcome.Status)
|
||
|
|
}
|
||
|
|
s.Log(context.Background(), Notification{}, Sent(), 0)
|
||
|
|
s.Register(DelivererFunc{Name: ChannelInApp})
|
||
|
|
}
|
||
|
|
|
||
|
|
// A service with no recorder still delivers. This is the shape a gateway built without a
|
||
|
|
// database has, and the shape most feature tests want.
|
||
|
|
func TestNoRecorderStillDelivers(t *testing.T) {
|
||
|
|
delivered := false
|
||
|
|
s := New(nil, quiet())
|
||
|
|
s.Register(DelivererFunc{Name: ChannelInApp, Fn: func(context.Context, Notification) Outcome {
|
||
|
|
delivered = true
|
||
|
|
return Sent()
|
||
|
|
}})
|
||
|
|
|
||
|
|
if s.Send(context.Background(), Notification{Channel: ChannelInApp}).Status != StatusSent {
|
||
|
|
t.Fatal("delivery reported something other than sent")
|
||
|
|
}
|
||
|
|
if !delivered {
|
||
|
|
t.Fatal("the notification was not delivered without a recorder")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLogRecordsWithoutDelivering(t *testing.T) {
|
||
|
|
recorder := &capture{}
|
||
|
|
called := false
|
||
|
|
s := service(t, recorder, func(context.Context, Notification) Outcome {
|
||
|
|
called = true
|
||
|
|
return Sent()
|
||
|
|
})
|
||
|
|
|
||
|
|
s.Log(context.Background(), Notification{
|
||
|
|
Channel: ChannelWebhook, Kind: "login.failed", Target: "Family Discord",
|
||
|
|
}, Delivered("HTTP 204"), 120*time.Millisecond)
|
||
|
|
|
||
|
|
if called {
|
||
|
|
t.Fatal("Log delivered the notification; it must only record one somebody else sent")
|
||
|
|
}
|
||
|
|
record := recorder.records[0]
|
||
|
|
if record.Status != StatusDelivered || record.Detail != "HTTP 204" {
|
||
|
|
t.Fatalf("record = %+v", record)
|
||
|
|
}
|
||
|
|
if record.DurationMS != 120 {
|
||
|
|
t.Fatalf("durationMs = %d, want 120", record.DurationMS)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The audit trail is rendered in the console, so anything that looks like a credential must
|
||
|
|
// never reach it — regardless of how careful the caller was.
|
||
|
|
func TestRedactDropsCredentialShapedMetadata(t *testing.T) {
|
||
|
|
cleaned := Redact(Notification{Metadata: map[string]any{
|
||
|
|
"integrationId": "disc-1",
|
||
|
|
"webhookUrl": "https://discord.com/api/webhooks/123/s3cr3t",
|
||
|
|
"apiKey": "abcd",
|
||
|
|
"embyToken": "live-session",
|
||
|
|
"Authorization": "Bearer x",
|
||
|
|
"statusCode": 204,
|
||
|
|
}})
|
||
|
|
|
||
|
|
for _, banned := range []string{"webhookUrl", "apiKey", "embyToken", "Authorization"} {
|
||
|
|
if _, present := cleaned.Metadata[banned]; present {
|
||
|
|
t.Errorf("%q survived redaction", banned)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if cleaned.Metadata["integrationId"] != "disc-1" || cleaned.Metadata["statusCode"] != 204 {
|
||
|
|
t.Fatalf("redaction dropped ordinary context: %+v", cleaned.Metadata)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSendRedactsBeforeRecording(t *testing.T) {
|
||
|
|
recorder := &capture{}
|
||
|
|
s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() })
|
||
|
|
|
||
|
|
s.Send(context.Background(), Notification{
|
||
|
|
Channel: ChannelInApp,
|
||
|
|
UserID: "u1",
|
||
|
|
Metadata: map[string]any{"series": "The Bear", "webhookUrl": "https://example.test/hook"},
|
||
|
|
})
|
||
|
|
|
||
|
|
var stored map[string]any
|
||
|
|
if err := json.Unmarshal(recorder.records[0].Metadata, &stored); err != nil {
|
||
|
|
t.Fatalf("metadata did not round-trip: %v", err)
|
||
|
|
}
|
||
|
|
if _, present := stored["webhookUrl"]; present {
|
||
|
|
t.Fatal("a credential-shaped key reached the audit trail through Send")
|
||
|
|
}
|
||
|
|
if stored["series"] != "The Bear" {
|
||
|
|
t.Fatalf("stored metadata = %+v", stored)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The provider still receives the notification it was handed; redaction is about what is
|
||
|
|
// stored, not about what is delivered.
|
||
|
|
func TestRedactionDoesNotChangeWhatIsDelivered(t *testing.T) {
|
||
|
|
var seen Notification
|
||
|
|
s := service(t, &capture{}, func(_ context.Context, n Notification) Outcome {
|
||
|
|
seen = n
|
||
|
|
return Sent()
|
||
|
|
})
|
||
|
|
|
||
|
|
s.Send(context.Background(), Notification{
|
||
|
|
Channel: ChannelInApp, UserID: "u1", Title: "Your week in Memby",
|
||
|
|
Body: "You watched 4 hours.",
|
||
|
|
})
|
||
|
|
|
||
|
|
if seen.Title != "Your week in Memby" || seen.Body != "You watched 4 hours." {
|
||
|
|
t.Fatalf("the provider received %+v", seen)
|
||
|
|
}
|
||
|
|
}
|