0.2.77
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
// Package notify is the one door every outbound notification leaves Memby through.
|
||||
//
|
||||
// Before it, each feature both decided to notify somebody and performed the delivery
|
||||
// itself: the Sonarr lifecycle scanner wrote a row into user_notifications, the library
|
||||
// ingester pushed a banner into Redis, the integrations dispatcher posted to Discord. Each
|
||||
// knew how to deliver and none knew that the others existed, so the only way to answer
|
||||
// "what did Memby send, to whom, and did it work" was to read three subsystems' log lines
|
||||
// and hope every one of them had logged.
|
||||
//
|
||||
// The flow is now
|
||||
//
|
||||
// feature/event → notify.Service → Deliverer → notification log
|
||||
//
|
||||
// and the audit trail is a property of the door rather than something each feature
|
||||
// remembers to do. A feature says *what* it wants said and to whom; which provider carries
|
||||
// it, and the record of what happened, belong here.
|
||||
//
|
||||
// Two rules hold the package up:
|
||||
//
|
||||
// - **Logging never blocks delivery.** The record is written after the provider has
|
||||
// already answered, on a context detached from the caller's, and a write that fails is
|
||||
// logged and swallowed. A notification history that could suppress a notification would
|
||||
// be worse than no history.
|
||||
// - **Nothing secret is ever recorded.** A webhook's address is its credential, and an
|
||||
// Emby token is a live upstream session; neither has any business in a table the
|
||||
// console renders. Notification carries a Target — a destination's *name* — never its
|
||||
// address, and Redact is the belt-and-braces pass before anything is stored.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Channel is how a notification reaches somebody. It is stored, so it is part of the
|
||||
// console's vocabulary: adding one means adding a Deliverer and nothing else.
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
// ChannelInApp is a stored notification in one viewer's own list — My Alerts on the
|
||||
// television. It follows the person to whichever set they sign into.
|
||||
ChannelInApp Channel = "in-app"
|
||||
// ChannelBroadcast is a service alert: the bar every signed-in television draws off
|
||||
// the /v1/status poll. It has no recipient, because the recipient is the household.
|
||||
ChannelBroadcast Channel = "broadcast"
|
||||
// ChannelWebhook is an outbound HTTP delivery to somebody else's service — Discord
|
||||
// today, and whatever the integrations package learns to speak next.
|
||||
ChannelWebhook Channel = "webhook"
|
||||
)
|
||||
|
||||
// Status is what became of one notification.
|
||||
//
|
||||
// Sent and Delivered are deliberately different answers. Most of Memby's channels are
|
||||
// stores rather than transports — writing a row into somebody's list is done the moment it
|
||||
// returns, and there is nobody to acknowledge it — so those report Sent. Delivered is
|
||||
// reserved for a provider that actually confirmed receipt, which today means a webhook
|
||||
// that answered 2xx. Collapsing the two would make the console claim an acknowledgement
|
||||
// that nothing ever gave.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusSent Status = "sent"
|
||||
StatusDelivered Status = "delivered"
|
||||
StatusFailed Status = "failed"
|
||||
StatusPending Status = "pending"
|
||||
// StatusSkipped is a notification that was deliberately not delivered, and it is the
|
||||
// most useful row on the page: a viewer's preferences declined it, a duplicate was
|
||||
// suppressed by its source key, or an operator has the window switched off. Without
|
||||
// it, "Memby never told me" and "Memby decided not to tell you" are the same silence.
|
||||
StatusSkipped Status = "skipped"
|
||||
)
|
||||
|
||||
// Notification is what a feature asks for. It describes the news, never the transport.
|
||||
type Notification struct {
|
||||
// Channel selects the provider.
|
||||
Channel Channel
|
||||
// Kind is the notification type: "show-return", "watch-time-week", "sonarr-import".
|
||||
// It is the client's vocabulary too, so it is passed through rather than translated.
|
||||
Kind string
|
||||
// Source names the service that decided to send this — "sonarr-lifecycle",
|
||||
// "watch-time-digest", "library-ingest". It answers "why did this arrive", which the
|
||||
// kind alone often cannot: two features can legitimately produce the same kind.
|
||||
Source string
|
||||
// UserID and Username identify the recipient. Both empty is a household broadcast,
|
||||
// which is a real answer rather than a missing one.
|
||||
UserID string
|
||||
Username string
|
||||
Title string
|
||||
Body string
|
||||
// ItemID links the notification to a title, where there is one.
|
||||
ItemID string
|
||||
// Target names a destination that is not a person — an integration's name, for a
|
||||
// webhook. Never its address: see the package comment.
|
||||
Target string
|
||||
// SourceKey is the caller's idempotency key where it has one. It is what lets the
|
||||
// console explain a skipped row as "already sent" rather than as an unexplained gap.
|
||||
SourceKey string
|
||||
// EventAt is when the news happened, where that differs from when it was sent — an
|
||||
// episode's broadcast time, a digest's period end.
|
||||
EventAt *time.Time
|
||||
// Metadata is free-form context for the detail view. Keep it small and keep it
|
||||
// non-secret; Redact drops anything whose key looks like a credential.
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// Outcome is what the provider reported.
|
||||
type Outcome struct {
|
||||
Status Status
|
||||
// Detail is the failure or the reason, and it is the whole value of the detail view:
|
||||
// "429 Too Many Requests", "the viewer has summaries switched off", "already sent".
|
||||
Detail string
|
||||
// Err is the delivery error where there was one, returned to the caller so a feature
|
||||
// that wants to react to a failure still can. It is never itself the audit trail.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Sent is the ordinary success for a store-shaped channel.
|
||||
func Sent() Outcome { return Outcome{Status: StatusSent} }
|
||||
|
||||
// Delivered is for a provider that confirmed receipt.
|
||||
func Delivered(detail string) Outcome { return Outcome{Status: StatusDelivered, Detail: detail} }
|
||||
|
||||
// Failed records a delivery that was attempted and did not work.
|
||||
func Failed(err error) Outcome {
|
||||
if err == nil {
|
||||
return Outcome{Status: StatusFailed, Detail: "delivery failed"}
|
||||
}
|
||||
return Outcome{Status: StatusFailed, Detail: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
// Skipped records a notification deliberately not delivered, with the reason.
|
||||
func Skipped(reason string) Outcome { return Outcome{Status: StatusSkipped, Detail: reason} }
|
||||
|
||||
// Deliverer is one channel's provider. A new channel is a type implementing this and a
|
||||
// Register call; nothing else in the package has a case per channel.
|
||||
type Deliverer interface {
|
||||
Channel() Channel
|
||||
Deliver(ctx context.Context, n Notification) Outcome
|
||||
}
|
||||
|
||||
// DelivererFunc adapts a plain function, which is what every provider in the gateway is:
|
||||
// a small closure over an existing subsystem.
|
||||
type DelivererFunc struct {
|
||||
Name Channel
|
||||
Fn func(ctx context.Context, n Notification) Outcome
|
||||
}
|
||||
|
||||
func (d DelivererFunc) Channel() Channel { return d.Name }
|
||||
|
||||
func (d DelivererFunc) Deliver(ctx context.Context, n Notification) Outcome {
|
||||
return d.Fn(ctx, n)
|
||||
}
|
||||
|
||||
// Record is one row of the audit trail — the notification as it was asked for, plus what
|
||||
// happened to it.
|
||||
type Record struct {
|
||||
OccurredAt time.Time
|
||||
Channel Channel
|
||||
Kind string
|
||||
Source string
|
||||
UserID string
|
||||
Username string
|
||||
Title string
|
||||
Body string
|
||||
ItemID string
|
||||
Target string
|
||||
SourceKey string
|
||||
Status Status
|
||||
Detail string
|
||||
DurationMS int64
|
||||
EventAt *time.Time
|
||||
Metadata json.RawMessage
|
||||
}
|
||||
|
||||
// Recorder is the audit trail's storage. An interface rather than *store.Store so the
|
||||
// package can be tested without a database, and so a Service built with no recorder — every
|
||||
// unit test of a feature that notifies — still delivers.
|
||||
type Recorder interface {
|
||||
RecordNotification(ctx context.Context, record Record) error
|
||||
}
|
||||
|
||||
// recordTimeout bounds the audit write. It is short on purpose: the notification has
|
||||
// already been delivered by the time this runs, so a slow database must cost the history
|
||||
// rather than hold up the feature that produced the news.
|
||||
const recordTimeout = 5 * time.Second
|
||||
|
||||
// Service is the door. One instance, created at start-up.
|
||||
type Service struct {
|
||||
recorder Recorder
|
||||
log *slog.Logger
|
||||
deliverers map[Channel]Deliverer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(recorder Recorder, log *slog.Logger) *Service {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
recorder: recorder,
|
||||
log: log.With("component", "notify"),
|
||||
deliverers: map[Channel]Deliverer{},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Register installs a provider. Called at start-up only; the map is not guarded because
|
||||
// nothing registers after the first request is served.
|
||||
func (s *Service) Register(deliverers ...Deliverer) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, deliverer := range deliverers {
|
||||
if deliverer != nil {
|
||||
s.deliverers[deliverer.Channel()] = deliverer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send delivers a notification and records what happened.
|
||||
//
|
||||
// The order is the design: deliver, then record. A history written first would be a claim
|
||||
// rather than a record, and one written inside the delivery path would be able to fail the
|
||||
// delivery. A Service that is nil, or has no provider for the channel, still answers — a
|
||||
// feature must never have to nil-check the notification layer.
|
||||
func (s *Service) Send(ctx context.Context, n Notification) Outcome {
|
||||
if s == nil {
|
||||
return Skipped("notifications are not configured")
|
||||
}
|
||||
n = Redact(n)
|
||||
started := s.now()
|
||||
deliverer, ok := s.deliverers[n.Channel]
|
||||
var outcome Outcome
|
||||
if !ok {
|
||||
// A missing provider is a configuration fault, not a delivery failure, and it is
|
||||
// worth a row: a console showing every "show-return" as failed on a gateway with
|
||||
// no in-app provider is what would send somebody looking in the right place.
|
||||
outcome = Failed(errNoDeliverer{channel: n.Channel})
|
||||
} else {
|
||||
outcome = deliverer.Deliver(ctx, n)
|
||||
}
|
||||
s.record(ctx, n, outcome, s.now().Sub(started))
|
||||
return outcome
|
||||
}
|
||||
|
||||
// Log records a notification that some other code path delivered.
|
||||
//
|
||||
// It exists for the one producer that cannot reasonably be inverted: the integrations
|
||||
// dispatcher is a subscriber on the admin event bus with its own queue, pacing and
|
||||
// transport registry, and routing its deliveries back out through Send would make the
|
||||
// audit trail the thing that decides what Discord receives. It posts, then says what
|
||||
// happened. Prefer Send everywhere a feature is the one deciding to notify.
|
||||
func (s *Service) Log(ctx context.Context, n Notification, outcome Outcome, took time.Duration) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.record(ctx, Redact(n), outcome, took)
|
||||
}
|
||||
|
||||
func (s *Service) record(ctx context.Context, n Notification, outcome Outcome, took time.Duration) {
|
||||
if s.recorder == nil {
|
||||
return
|
||||
}
|
||||
record := Record{
|
||||
OccurredAt: s.now().UTC(),
|
||||
Channel: n.Channel,
|
||||
Kind: n.Kind,
|
||||
Source: n.Source,
|
||||
UserID: n.UserID,
|
||||
Username: n.Username,
|
||||
Title: n.Title,
|
||||
Body: n.Body,
|
||||
ItemID: n.ItemID,
|
||||
Target: n.Target,
|
||||
SourceKey: n.SourceKey,
|
||||
Status: outcome.Status,
|
||||
Detail: outcome.Detail,
|
||||
DurationMS: took.Milliseconds(),
|
||||
EventAt: n.EventAt,
|
||||
}
|
||||
if len(n.Metadata) > 0 {
|
||||
if raw, err := json.Marshal(n.Metadata); err == nil {
|
||||
record.Metadata = raw
|
||||
}
|
||||
}
|
||||
// Detached from the caller's context, for the reason the search recorder is: a
|
||||
// television that navigated away, or a request that timed out, still sent this.
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordTimeout)
|
||||
defer cancel()
|
||||
if err := s.recorder.RecordNotification(writeCtx, record); err != nil {
|
||||
s.log.Warn("notification not recorded",
|
||||
"channel", n.Channel, "kind", n.Kind, "status", outcome.Status, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errNoDeliverer struct{ channel Channel }
|
||||
|
||||
func (e errNoDeliverer) Error() string {
|
||||
return "no delivery provider for the " + string(e.channel) + " channel"
|
||||
}
|
||||
|
||||
// secretish matches a metadata key whose value must never be stored.
|
||||
//
|
||||
// The rule is a substring match on the key rather than an inspection of the value,
|
||||
// deliberately: a token is not recognisable by looking at it, and the one thing a caller
|
||||
// reliably gets right is what they called the field.
|
||||
var secretish = []string{
|
||||
"token", "secret", "password", "apikey", "api_key", "credential",
|
||||
"webhook", "url", "authorization",
|
||||
}
|
||||
|
||||
// Redact is the last thing between a notification and the audit trail.
|
||||
//
|
||||
// Callers are already expected not to put a credential in a Notification — Target is a
|
||||
// destination's name and never its address — and this is what makes that a property of the
|
||||
// package rather than of every caller's diligence.
|
||||
func Redact(n Notification) Notification {
|
||||
if len(n.Metadata) == 0 {
|
||||
return n
|
||||
}
|
||||
cleaned := make(map[string]any, len(n.Metadata))
|
||||
for key, value := range n.Metadata {
|
||||
if isSecretKey(key) {
|
||||
continue
|
||||
}
|
||||
cleaned[key] = value
|
||||
}
|
||||
n.Metadata = cleaned
|
||||
return n
|
||||
}
|
||||
|
||||
func isSecretKey(key string) bool {
|
||||
lowered := strings.ToLower(key)
|
||||
for _, needle := range secretish {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user