Requests previously only notified the community server channels for submitted/approved/declined and the requester personally for fulfilled. This closes the gap and makes request posts addressable: - New request.approved / request.declined delivery types ride the operational dispatch path to the requesting profile: inbox, websocket toast, email, Discord DM, personal webhooks (gated by the existing notify_requests flag), and web push. Submitted stays broadcast-only (the requester performed the action themselves). Title/year/decline reason travel in reason_flags since no catalog item exists yet. - Request status notices are transactional: digest-mode recipients get an off-schedule early send (watermark-durable, last_digest_at left alone) instead of waiting for the digest hour. Per-episode recipients were already immediate via the dispatch nudge. - At-most-once per (profile, request, type) via a partial unique index (migration 20260612100000), mirroring the fulfilled dedupe. - Server-channel Discord request posts can @mention the requester via their OAuth-linked identity (notifications.server_channels. mention_requesters, default off). Resolved lazily in the sweep worker only when a Discord destination is about to receive the event; the ping uses content-level mention with pinned allowed_mentions, and the Discord identity never leaks into generic webhook payloads. Android/Apple clients render the new inbox types with their generic fallback until they add them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
641 lines
21 KiB
Go
641 lines
21 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// mapSettingReader is a SettingReader fake; missing keys read as unset.
|
|
type mapSettingReader map[string]string
|
|
|
|
func (m mapSettingReader) Get(_ context.Context, key string) (string, error) {
|
|
return m[key], nil
|
|
}
|
|
|
|
func TestWebhooksDisabledByDefault(t *testing.T) {
|
|
ctx := context.Background()
|
|
if NewSettings(nil).WebhooksEnabled(ctx) {
|
|
t.Fatal("WebhooksEnabled must default to false until an admin opts in")
|
|
}
|
|
if !NewSettings(mapSettingReader{SettingWebhooksEnabled: "true"}).WebhooksEnabled(ctx) {
|
|
t.Fatal("WebhooksEnabled = false with the setting on, want true")
|
|
}
|
|
}
|
|
|
|
func TestWebhookCreateAndTestBlockedWhenDisabled(t *testing.T) {
|
|
ctx := context.Background()
|
|
service := newWebhookService(nil, nil, NewSettings(nil), nil)
|
|
|
|
name, url := "hook", "https://discord.com/api/webhooks/1/abc"
|
|
if _, _, err := service.Create(ctx, 1, "profile", WebhookInput{Name: &name, URL: &url}); !errors.Is(err, ErrWebhooksDisabled) {
|
|
t.Fatalf("Create error = %v, want ErrWebhooksDisabled", err)
|
|
}
|
|
if _, err := service.Test(ctx, "profile", "hook-id"); !errors.Is(err, ErrWebhooksDisabled) {
|
|
t.Fatalf("Test error = %v, want ErrWebhooksDisabled", err)
|
|
}
|
|
}
|
|
|
|
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,
|
|
PosterPath: testSeriesPosterPath,
|
|
PosterURL: testSeriesPosterCDN,
|
|
MediaType: "series",
|
|
SeriesOverview: "Mark leads a team whose memories have been surgically divided.",
|
|
EpisodeOverview: "Mark is promoted after the disappearance of his colleague.",
|
|
Genres: []string{"Drama", "Sci-Fi & Fantasy"},
|
|
ContentRating: "TV-MA",
|
|
RatingIMDB: 8.7,
|
|
IMDBID: "tt11280740",
|
|
TMDBID: "95396",
|
|
TVDBID: "371980",
|
|
}
|
|
}
|
|
|
|
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 []discordEmbed `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.Author == nil || embed.Author.Name != "New episode on Silo" {
|
|
t.Fatalf("unexpected author %+v", embed.Author)
|
|
}
|
|
if embed.URL != "https://www.themoviedb.org/tv/95396" {
|
|
t.Fatalf("unexpected title URL %q", embed.URL)
|
|
}
|
|
if embed.Thumbnail == nil || embed.Thumbnail.URL != testSeriesPosterCDN {
|
|
t.Fatalf("unexpected thumbnail %+v", embed.Thumbnail)
|
|
}
|
|
// Episode overview wins over the series overview; provider links follow.
|
|
if !strings.HasPrefix(embed.Description, "Mark is promoted") ||
|
|
!strings.Contains(embed.Description, "[TMDB](https://www.themoviedb.org/tv/95396)") ||
|
|
!strings.Contains(embed.Description, "[IMDb](https://www.imdb.com/title/tt11280740/)") ||
|
|
!strings.Contains(embed.Description, "[TVDB](https://thetvdb.com/dereferrer/series/371980)") {
|
|
t.Fatalf("unexpected description %q", embed.Description)
|
|
}
|
|
if len(embed.Fields) != 3 ||
|
|
embed.Fields[0].Name != "Reason" || embed.Fields[0].Value != "Favorited & Continue Watching" ||
|
|
embed.Fields[1].Value != "★ 8.7 IMDb" ||
|
|
embed.Fields[2].Value != "Drama, Sci-Fi & Fantasy" {
|
|
t.Fatalf("unexpected fields: %+v", embed.Fields)
|
|
}
|
|
if embed.Footer == nil || embed.Footer.Text != "Silo • TV-MA" {
|
|
t.Fatalf("unexpected footer %+v", embed.Footer)
|
|
}
|
|
// The privacy contract: only public provider origins may appear — never
|
|
// this server's own URL (which the builder cannot even see).
|
|
for _, origin := range allOriginsIn(t, string(payload)) {
|
|
switch origin {
|
|
case "www.themoviedb.org", "image.tmdb.org", "www.imdb.com", "thetvdb.com":
|
|
default:
|
|
t.Fatalf("payload names non-provider origin %q: %s", origin, payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
// allOriginsIn extracts every http(s) host named anywhere in the payload.
|
|
func allOriginsIn(t *testing.T, payload string) []string {
|
|
t.Helper()
|
|
hosts := make([]string, 0, 4)
|
|
rest := payload
|
|
for {
|
|
at := strings.Index(rest, "https://")
|
|
if at < 0 {
|
|
break
|
|
}
|
|
rest = rest[at+len("https://"):]
|
|
end := strings.IndexAny(rest, "/\"\\)")
|
|
if end < 0 {
|
|
end = len(rest)
|
|
}
|
|
hosts = append(hosts, rest[:end])
|
|
}
|
|
if strings.Contains(payload, "http://") {
|
|
t.Fatalf("payload contains insecure http:// URL: %s", payload)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
func TestBuildDiscordWebhookPayloadWithoutPosterURL(t *testing.T) {
|
|
row := webhookTestRow()
|
|
// The poster decision is the sender layer's; a row without a resolved
|
|
// PosterURL must render without an image regardless of stored paths.
|
|
row.PosterURL = ""
|
|
payload, err := BuildDiscordWebhookPayload(row, false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if strings.Contains(string(payload), `"thumbnail"`) {
|
|
t.Fatalf("rows without a resolved poster URL must not render a thumbnail: %s", payload)
|
|
}
|
|
}
|
|
|
|
// fakePresigner fakes the catalog image resolver: every path presigns to a
|
|
// recognizable server-storage URL.
|
|
type fakePresigner struct{}
|
|
|
|
func (fakePresigner) PresignImageURL(_ context.Context, path, _, _ string) string {
|
|
return "https://s3.example.com/" + path + "?sig=abc"
|
|
}
|
|
|
|
func TestDiscordPosterURLModes(t *testing.T) {
|
|
const cachedKey = "tmdb/series/95396/poster/original.jpg"
|
|
system := func(mode string, images ImageURLResolver) *System {
|
|
return &System{
|
|
Settings: NewSettings(mapSettingReader{SettingDiscordPosterMode: mode}),
|
|
images: images,
|
|
}
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Off: nothing renders, even provider-CDN-resolvable artwork.
|
|
if got := system("off", fakePresigner{}).discordPosterURL(ctx, testSeriesPosterPath, ""); got != "" {
|
|
t.Fatalf("mode off must drop posters, got %q", got)
|
|
}
|
|
// Provider (default): public CDN URLs only; cached keys never presign.
|
|
if got := system("", fakePresigner{}).discordPosterURL(ctx, testSeriesPosterPath, ""); got != testSeriesPosterCDN {
|
|
t.Fatalf("provider mode CDN resolution failed, got %q", got)
|
|
}
|
|
if got := system("", fakePresigner{}).discordPosterURL(ctx, cachedKey, ""); got != "" {
|
|
t.Fatalf("provider mode must not presign cached keys, got %q", got)
|
|
}
|
|
// Server: provider CDN still wins; cached keys presign as the fallback.
|
|
if got := system("server", fakePresigner{}).discordPosterURL(ctx, cachedKey, testSeriesPosterPath); got != testSeriesPosterCDN {
|
|
t.Fatalf("server mode must still prefer provider CDN, got %q", got)
|
|
}
|
|
if got := system("server", fakePresigner{}).discordPosterURL(ctx, cachedKey, ""); got != "https://s3.example.com/"+cachedKey+"?sig=abc" {
|
|
t.Fatalf("server mode presign fallback failed, got %q", got)
|
|
}
|
|
// Server without a wired resolver degrades to no image.
|
|
if got := system("server", nil).discordPosterURL(ctx, cachedKey, ""); got != "" {
|
|
t.Fatalf("server mode without resolver must render no image, got %q", got)
|
|
}
|
|
}
|
|
|
|
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",
|
|
MediaType: "movie",
|
|
Year: 2021,
|
|
SeriesOverview: "Paul Atreides, a brilliant and gifted young man.",
|
|
RatingTMDB: 7.8,
|
|
TMDBID: "438631",
|
|
}
|
|
}
|
|
|
|
func TestBuildDiscordWebhookPayloadRequestFulfilled(t *testing.T) {
|
|
payload, err := BuildDiscordWebhookPayload(requestFulfilledTestRow(), false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body struct {
|
|
Embeds []discordEmbed `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 (2021)" {
|
|
t.Fatalf("unexpected title %q", embed.Title)
|
|
}
|
|
if embed.Author == nil || embed.Author.Name != "Your request is now available on Silo" {
|
|
t.Fatalf("unexpected author %+v", embed.Author)
|
|
}
|
|
if embed.URL != "https://www.themoviedb.org/movie/438631" {
|
|
t.Fatalf("unexpected title URL %q", embed.URL)
|
|
}
|
|
if !strings.HasPrefix(embed.Description, "Paul Atreides") {
|
|
t.Fatalf("unexpected description %q", embed.Description)
|
|
}
|
|
if len(embed.Fields) != 2 ||
|
|
embed.Fields[0].Name != "Type" || embed.Fields[0].Value != "Movie" ||
|
|
embed.Fields[1].Name != "Rating" || embed.Fields[1].Value != "★ 7.8 TMDB" {
|
|
t.Fatalf("expected Type and Rating fields, 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 requestDeclinedTestRow() DeliveryRow {
|
|
return DeliveryRow{
|
|
Delivery: Delivery{
|
|
ID: "01DECLINED",
|
|
ProfileID: "profile-1",
|
|
Type: DeliveryTypeRequestDeclined,
|
|
ReasonFlags: []byte(`{"request_id":"01REQ","tmdb_id":438631,"media_type":"movie",` +
|
|
`"title":"Dune","year":2021,"reason":"Already available in 4K"}`),
|
|
CreatedAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestBuildDiscordWebhookPayloadRequestLifecycle(t *testing.T) {
|
|
payload, err := BuildDiscordWebhookPayload(requestDeclinedTestRow(), false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body struct {
|
|
Embeds []discordEmbed `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]
|
|
// No catalog join exists for declined requests; title and link come from
|
|
// the reason flags.
|
|
if embed.Title != "Dune (2021)" {
|
|
t.Fatalf("unexpected title %q", embed.Title)
|
|
}
|
|
if embed.Author == nil || embed.Author.Name != "Your request was declined on Silo" {
|
|
t.Fatalf("unexpected author %+v", embed.Author)
|
|
}
|
|
if embed.URL != "https://www.themoviedb.org/movie/438631" {
|
|
t.Fatalf("unexpected title URL %q", embed.URL)
|
|
}
|
|
if embed.Color != serverChannelColorDeclined {
|
|
t.Fatalf("unexpected color %d", embed.Color)
|
|
}
|
|
if len(embed.Fields) != 2 ||
|
|
embed.Fields[0].Name != "Type" || embed.Fields[0].Value != "Movie" ||
|
|
embed.Fields[1].Name != "Reason" || embed.Fields[1].Value != "Already available in 4K" {
|
|
t.Fatalf("expected Type and Reason fields, got %+v", embed.Fields)
|
|
}
|
|
|
|
approved := requestDeclinedTestRow()
|
|
approved.Type = DeliveryTypeRequestApproved
|
|
payload, err = BuildDiscordWebhookPayload(approved, false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v", err)
|
|
}
|
|
embed = body.Embeds[0]
|
|
if embed.Author == nil || embed.Author.Name != "Your request was approved on Silo" {
|
|
t.Fatalf("unexpected author %+v", embed.Author)
|
|
}
|
|
if embed.Color != serverChannelColorApproved {
|
|
t.Fatalf("unexpected color %d", embed.Color)
|
|
}
|
|
// The decline reason must not leak into approved embeds.
|
|
for _, field := range embed.Fields {
|
|
if field.Name == "Reason" {
|
|
t.Fatalf("approved embed must not carry a decline reason: %+v", embed.Fields)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenericWebhookPayloadRequestDeclined(t *testing.T) {
|
|
payload, err := BuildGenericWebhookPayload(requestDeclinedTestRow(), "hook-1", false)
|
|
if err != nil {
|
|
t.Fatalf("build failed: %v", err)
|
|
}
|
|
var body struct {
|
|
Type string `json:"type"`
|
|
Request *struct {
|
|
ID string `json:"id"`
|
|
TMDBID int `json:"tmdb_id"`
|
|
MediaType string `json:"media_type"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
Reason string `json:"reason"`
|
|
} `json:"request"`
|
|
}
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
t.Fatalf("payload is not valid JSON: %v", err)
|
|
}
|
|
if body.Type != DeliveryTypeRequestDeclined {
|
|
t.Fatalf("unexpected type %q", body.Type)
|
|
}
|
|
if body.Request == nil || body.Request.ID != "01REQ" || body.Request.Title != "Dune" ||
|
|
body.Request.Year != 2021 || body.Request.Reason != "Already available in 4K" {
|
|
t.Fatalf("unexpected request block: %+v", body.Request)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|