Notify the requesting profile once its media request is actually present in the catalog (roadmap 06, item 2). Completion transitions stay notification-agnostic; a presence-gated pass at the end of each reconcile run fires the notice, so it means "watchable in Silo", not "download finished". - New System.DispatchOperational: delivery insert + webhook/web-push outbox enqueue in one transaction, post-commit multi-dispatch. The webhook auto-disable notice now rides the same path (replacing its hand-rolled hub publish and the now-removed InsertOperational), which also delivers auto-disable notices over web push. - At-most-once delivery: partial unique index on (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at marker on media_requests, backfilled for pre-existing completed requests so deploys never flood. - Per-webhook notify_requests toggle (default on) through repo, service, API, and settings UI; gated independently of the episode reason flags. - request.fulfilled rendering in web inbox, realtime toast, web push payload, and Discord/generic webhook payloads, deep-linking to the matched catalog item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
398 lines
12 KiB
Go
398 lines
12 KiB
Go
package notifications
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestWebhookIPAllowed(t *testing.T) {
|
|
denied := []string{
|
|
"0.1.2.3",
|
|
"10.1.2.3",
|
|
"100.64.0.1", // CGNAT
|
|
"127.0.0.1", // loopback
|
|
"169.254.1.1", // link-local
|
|
"172.16.0.1", // private
|
|
"192.0.0.1", // IETF
|
|
"192.0.2.1", // TEST-NET-1
|
|
"198.51.100.1", // TEST-NET-2
|
|
"203.0.113.1", // TEST-NET-3
|
|
"192.88.99.1", // 6to4 anycast
|
|
"192.168.1.1", // private
|
|
"198.18.0.1", // benchmarking
|
|
"198.19.255.1", // benchmarking upper half
|
|
"224.0.0.1", // multicast
|
|
"255.255.255.255",
|
|
"::1",
|
|
"fc00::1", // ULA
|
|
"fe80::1", // link-local
|
|
"2001:db8::1", // documentation
|
|
"64:ff9b::7f00:1", // NAT64-mapped loopback
|
|
"::ffff:127.0.0.1", // v4-mapped loopback (the classic bypass)
|
|
"::ffff:192.168.0.5", // v4-mapped private
|
|
}
|
|
for _, raw := range denied {
|
|
if webhookIPAllowed(net.ParseIP(raw)) {
|
|
t.Errorf("webhookIPAllowed(%q) = true, want denied", raw)
|
|
}
|
|
}
|
|
|
|
allowed := []string{"1.1.1.1", "8.8.8.8", "151.101.1.69", "2606:4700::1111"}
|
|
for _, raw := range allowed {
|
|
if !webhookIPAllowed(net.ParseIP(raw)) {
|
|
t.Errorf("webhookIPAllowed(%q) = false, want allowed", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateWebhookURL(t *testing.T) {
|
|
if _, err := ValidateWebhookURL("http://example.com/hook", false); err == nil {
|
|
t.Fatal("plain http must be rejected")
|
|
}
|
|
if _, err := ValidateWebhookURL("https://user:pass@example.com/hook", false); err == nil {
|
|
t.Fatal("embedded credentials must be rejected")
|
|
}
|
|
if _, err := ValidateWebhookURL("https://127.0.0.1/hook", false); err == nil {
|
|
t.Fatal("loopback literal must be rejected")
|
|
}
|
|
if _, err := ValidateWebhookURL("https://[::ffff:127.0.0.1]/hook", false); err == nil {
|
|
t.Fatal("v4-mapped loopback literal must be rejected")
|
|
}
|
|
// allowPrivate bypasses the guard for dev environments.
|
|
host, err := ValidateWebhookURL("https://192.168.1.50/hook", true)
|
|
if err != nil || host != "192.168.1.50" {
|
|
t.Fatalf("allowPrivate bypass failed: %q %v", host, err)
|
|
}
|
|
}
|
|
|
|
func TestDiscordWebhookURLDetection(t *testing.T) {
|
|
positives := []string{
|
|
"https://discord.com/api/webhooks/123/abc",
|
|
"https://discordapp.com/api/webhooks/123/abc",
|
|
"https://ptb.discord.com/api/webhooks/123/abc",
|
|
}
|
|
for _, raw := range positives {
|
|
if !discordWebhookURL(raw) {
|
|
t.Errorf("discordWebhookURL(%q) = false, want true", raw)
|
|
}
|
|
}
|
|
negatives := []string{
|
|
"https://hooks.slack.com/services/T/B/x",
|
|
"https://discord.com/channels/123",
|
|
"https://evil.com/api/webhooks/123/abc",
|
|
"https://discord.com.evil.com/api/webhooks/1/2",
|
|
}
|
|
for _, raw := range negatives {
|
|
if discordWebhookURL(raw) {
|
|
t.Errorf("discordWebhookURL(%q) = true, want false", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func webhookTestRow() DeliveryRow {
|
|
libraryID := 7
|
|
seriesID := "series-123"
|
|
episodeID := "episode-456"
|
|
season := 2
|
|
episode := 1
|
|
return DeliveryRow{
|
|
Delivery: Delivery{
|
|
ID: "01DELIVERY",
|
|
ProfileID: "profile-1",
|
|
LibraryID: &libraryID,
|
|
SeriesID: &seriesID,
|
|
EpisodeID: &episodeID,
|
|
Type: DeliveryTypeEpisodeAvailable,
|
|
ReasonFlags: []byte(`{"favorite":true,"continue_watching":true}`),
|
|
CreatedAt: time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC),
|
|
},
|
|
SeriesTitle: "Severance",
|
|
EpisodeTitle: "Hello, Ms. Cobel",
|
|
SeasonNumber: &season,
|
|
EpisodeNumber: &episode,
|
|
}
|
|
}
|
|
|
|
func TestBuildDiscordWebhookPayload(t *testing.T) {
|
|
payload, err := BuildDiscordWebhookPayload(webhookTestRow(), false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body struct {
|
|
Username string `json:"username"`
|
|
Embeds []struct {
|
|
Title string `json:"title"`
|
|
Color int `json:"color"`
|
|
Footer struct {
|
|
Text string `json:"text"`
|
|
} `json:"footer"`
|
|
Fields []struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
} `json:"fields"`
|
|
} `json:"embeds"`
|
|
}
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v", err)
|
|
}
|
|
if body.Username != "Silo" || len(body.Embeds) != 1 {
|
|
t.Fatalf("unexpected body shape: %+v", body)
|
|
}
|
|
embed := body.Embeds[0]
|
|
if embed.Title != "Severance — S2 E1: Hello, Ms. Cobel" {
|
|
t.Fatalf("unexpected title %q", embed.Title)
|
|
}
|
|
if embed.Color != discordColorFavorite {
|
|
t.Fatalf("favorite reason must pick the favorite color, got %d", embed.Color)
|
|
}
|
|
if embed.Fields[0].Name != "Reason" || embed.Fields[0].Value != "Favorited & Continue Watching" {
|
|
t.Fatalf("unexpected reason field: %+v", embed.Fields[0])
|
|
}
|
|
// The v1 privacy contract: no image, url, thumbnail, or avatar fields.
|
|
for _, forbidden := range []string{`"image"`, `"thumbnail"`, `"avatar_url"`, `"url"`} {
|
|
if strings.Contains(string(payload), forbidden) {
|
|
t.Fatalf("v1 Discord payload must not contain %s: %s", forbidden, payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func requestFulfilledTestRow() DeliveryRow {
|
|
contentID := "movie-123"
|
|
return DeliveryRow{
|
|
Delivery: Delivery{
|
|
ID: "01REQUEST",
|
|
ProfileID: "profile-1",
|
|
SeriesID: &contentID,
|
|
Type: DeliveryTypeRequestFulfilled,
|
|
ReasonFlags: []byte(`{"request_id":"01REQ","tmdb_id":438631,"media_type":"movie"}`),
|
|
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
|
},
|
|
SeriesTitle: "Dune",
|
|
}
|
|
}
|
|
|
|
func TestBuildDiscordWebhookPayloadRequestFulfilled(t *testing.T) {
|
|
payload, err := BuildDiscordWebhookPayload(requestFulfilledTestRow(), false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body struct {
|
|
Embeds []struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Fields []struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
} `json:"fields"`
|
|
} `json:"embeds"`
|
|
}
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v", err)
|
|
}
|
|
if len(body.Embeds) != 1 {
|
|
t.Fatalf("unexpected body shape: %+v", body)
|
|
}
|
|
embed := body.Embeds[0]
|
|
if embed.Title != "Dune" {
|
|
t.Fatalf("unexpected title %q", embed.Title)
|
|
}
|
|
if embed.Description != "Your media request is now available on Silo" {
|
|
t.Fatalf("unexpected description %q", embed.Description)
|
|
}
|
|
if len(embed.Fields) != 1 || embed.Fields[0].Name != "Type" || embed.Fields[0].Value != "Movie" {
|
|
t.Fatalf("expected a single Type=Movie field, got %+v", embed.Fields)
|
|
}
|
|
}
|
|
|
|
func TestGenericWebhookPayloadRequestFulfilled(t *testing.T) {
|
|
payload, err := BuildGenericWebhookPayload(requestFulfilledTestRow(), "hook-1", false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body struct {
|
|
Type string `json:"type"`
|
|
Series *struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"series"`
|
|
Request *struct {
|
|
ID string `json:"id"`
|
|
TMDBID int `json:"tmdb_id"`
|
|
MediaType string `json:"media_type"`
|
|
} `json:"request"`
|
|
}
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v", err)
|
|
}
|
|
if body.Type != DeliveryTypeRequestFulfilled {
|
|
t.Fatalf("unexpected type %q", body.Type)
|
|
}
|
|
if body.Request == nil || body.Request.ID != "01REQ" || body.Request.TMDBID != 438631 || body.Request.MediaType != "movie" {
|
|
t.Fatalf("unexpected request block: %+v", body.Request)
|
|
}
|
|
if body.Series == nil || body.Series.ID != "movie-123" || body.Series.Title != "Dune" {
|
|
t.Fatalf("unexpected series block: %+v", body.Series)
|
|
}
|
|
}
|
|
|
|
func TestBuildDiscordWebhookPayloadTestMarker(t *testing.T) {
|
|
payload, err := BuildDiscordWebhookPayload(webhookTestRow(), true)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if !strings.Contains(string(payload), "Silo test notification") {
|
|
t.Fatal("test sends must be clearly marked in the footer")
|
|
}
|
|
}
|
|
|
|
func TestDiscordTotalLimitTruncation(t *testing.T) {
|
|
row := webhookTestRow()
|
|
row.SeriesTitle = strings.Repeat("a", 300) // title gets clipped to 256
|
|
payload, err := BuildDiscordWebhookPayload(row, false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body struct {
|
|
Embeds []struct {
|
|
Title string `json:"title"`
|
|
} `json:"embeds"`
|
|
}
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(body.Embeds[0].Title) > discordTitleLimit {
|
|
t.Fatalf("title exceeds Discord limit: %d bytes", len(body.Embeds[0].Title))
|
|
}
|
|
|
|
embed := discordEmbed{
|
|
Title: "t",
|
|
Description: strings.Repeat("d", 7000),
|
|
Fields: []discordEmbedField{
|
|
{Name: "a", Value: strings.Repeat("x", 500)},
|
|
{Name: "b", Value: strings.Repeat("y", 500)},
|
|
},
|
|
}
|
|
enforceDiscordTotalLimit(&embed)
|
|
if total := discordEmbedTotal(&embed); total > discordTotalLimit {
|
|
t.Fatalf("embed total %d exceeds %d after enforcement", total, discordTotalLimit)
|
|
}
|
|
if len(embed.Fields) != 2 {
|
|
t.Fatal("description must be truncated before fields are dropped")
|
|
}
|
|
}
|
|
|
|
func TestGenericWebhookPayloadAndSignature(t *testing.T) {
|
|
row := webhookTestRow()
|
|
payload, err := BuildGenericWebhookPayload(row, "01HOOK", false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v", err)
|
|
}
|
|
if body["event"] != "notification.created" || body["version"] != float64(1) || body["test"] != false {
|
|
t.Fatalf("unexpected envelope: %v", body)
|
|
}
|
|
if body["delivery_id"] != "01DELIVERY" || body["webhook_id"] != "01HOOK" {
|
|
t.Fatalf("unexpected ids: %v", body)
|
|
}
|
|
series, seriesOK := body["series"].(map[string]any)
|
|
episode, episodeOK := body["episode"].(map[string]any)
|
|
if !seriesOK || !episodeOK || series["title"] != "Severance" || episode["season_number"] != float64(2) {
|
|
t.Fatalf("unexpected content: %v", body)
|
|
}
|
|
if strings.Contains(string(payload), "http") {
|
|
t.Fatal("generic payload must not contain any URLs")
|
|
}
|
|
|
|
// Signature: deterministic, Stripe-style, verifiable from literal bytes.
|
|
const secretValue = "test-secret"
|
|
timestamp := int64(1714299296)
|
|
header := SignGenericWebhook(secretValue, timestamp, payload)
|
|
wantPrefix := fmt.Sprintf("t=%d,v1=", timestamp)
|
|
if !strings.HasPrefix(header, wantPrefix) {
|
|
t.Fatalf("unexpected signature header %q", header)
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(secretValue))
|
|
mac.Write([]byte("1714299296."))
|
|
mac.Write(payload)
|
|
if header != wantPrefix+hex.EncodeToString(mac.Sum(nil)) {
|
|
t.Fatal("signature does not verify against literal body bytes")
|
|
}
|
|
if SignGenericWebhook(secretValue, timestamp, payload) != header {
|
|
t.Fatal("signature must be deterministic")
|
|
}
|
|
}
|
|
|
|
func TestWebhookRetrySchedule(t *testing.T) {
|
|
// Cumulative schedule: 0, 30s, 2m, 10m, 30m, 2h, 6h, 12h, 18h, 24h.
|
|
total := time.Duration(0)
|
|
for attempt := 1; attempt < webhookMaxAttempts; attempt++ {
|
|
delay, ok := webhookRetryDelay(attempt)
|
|
if !ok {
|
|
t.Fatalf("schedule ended early at attempt %d", attempt)
|
|
}
|
|
total += delay
|
|
if total != webhookRetrySchedule[attempt] {
|
|
t.Fatalf("cumulative delay after attempt %d = %v, want %v", attempt, total, webhookRetrySchedule[attempt])
|
|
}
|
|
}
|
|
if _, ok := webhookRetryDelay(webhookMaxAttempts); ok {
|
|
t.Fatal("attempt 10 must exhaust the schedule")
|
|
}
|
|
if total != 24*time.Hour {
|
|
t.Fatalf("schedule must span 24h, got %v", total)
|
|
}
|
|
}
|
|
|
|
func TestRetryableHTTPStatus(t *testing.T) {
|
|
retryable := []int{0, 500, 502, 503, 408, 425, 429}
|
|
for _, status := range retryable {
|
|
if !retryableHTTPStatus(status) {
|
|
t.Errorf("status %d must be retryable", status)
|
|
}
|
|
}
|
|
nonRetryable := []int{400, 401, 403, 404, 410, 422}
|
|
for _, status := range nonRetryable {
|
|
if retryableHTTPStatus(status) {
|
|
t.Errorf("status %d must not be retryable", status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWebhookMatchesReasons(t *testing.T) {
|
|
hook := Webhook{NotifyFavorites: true, NotifyWatchlist: false, NotifyContinueWatching: false, NotifyNextUp: false}
|
|
if !hook.MatchesReasons(ReasonFlags{Favorite: true, Watchlist: true}) {
|
|
t.Fatal("favorite reason must match a favorites-enabled webhook")
|
|
}
|
|
if hook.MatchesReasons(ReasonFlags{Watchlist: true}) {
|
|
t.Fatal("watchlist-only delivery must not match a favorites-only webhook")
|
|
}
|
|
if hook.MatchesReasons(ReasonFlags{}) {
|
|
t.Fatal("no reasons must never match")
|
|
}
|
|
}
|
|
|
|
func TestProfileRateLimiter(t *testing.T) {
|
|
limiter := newProfileRateLimiter()
|
|
for i := 0; i < 3; i++ {
|
|
if !limiter.Allow("p1", 3) {
|
|
t.Fatalf("delivery %d must be allowed", i+1)
|
|
}
|
|
}
|
|
if limiter.Allow("p1", 3) {
|
|
t.Fatal("4th delivery within the window must be limited")
|
|
}
|
|
if !limiter.Allow("p2", 3) {
|
|
t.Fatal("limits must be per-profile")
|
|
}
|
|
}
|