Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).
- Extend release_events with a kind discriminator and add a movie
availability spine (movie_availability + kind-keyed
notification_content_seed_state; first full scan seeds silently so
upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
grouping, per-channel backoff, and auto-disable; request events post
best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
SSRF guard, embed limits, HMAC signing; shared type/name validation
extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
Channels section in the notifications admin settings UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
83 lines
2.7 KiB
Go
83 lines
2.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
)
|
|
|
|
func TestWriteServerChannelErrorMapping(t *testing.T) {
|
|
cases := []struct {
|
|
err error
|
|
want int
|
|
}{
|
|
{notifications.ErrServerChannelsDisabled, http.StatusForbidden},
|
|
{notifications.ErrServerChannelNotFound, http.StatusNotFound},
|
|
{notifications.ErrServerChannelLimit, http.StatusUnprocessableEntity},
|
|
{fmt.Errorf("%w: name is required", notifications.ErrServerChannelInvalid), http.StatusBadRequest},
|
|
{fmt.Errorf("boom"), http.StatusInternalServerError},
|
|
}
|
|
for _, tc := range cases {
|
|
rec := httptest.NewRecorder()
|
|
writeServerChannelError(rec, tc.err)
|
|
if rec.Code != tc.want {
|
|
t.Errorf("writeServerChannelError(%v) = %d, want %d", tc.err, rec.Code, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The response shape must never leak the destination URL (a bearer credential
|
|
// for Discord webhooks) or the stored signing secret; signing_secret appears
|
|
// only when explicitly set by the create/rotate paths.
|
|
func TestServerChannelResponseNeverLeaksSecrets(t *testing.T) {
|
|
secret := "ciphertext-secret"
|
|
ch := notifications.ServerChannel{
|
|
ID: "ch-1",
|
|
Name: "Community",
|
|
Type: "discord",
|
|
URLCiphertext: "enc:v1:secret-url-material",
|
|
URLHost: "discord.com",
|
|
SigningSecretCiphertext: &secret,
|
|
Enabled: true,
|
|
}
|
|
body, err := json.Marshal(serverChannelToResponse(ch))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encoded := string(body)
|
|
if strings.Contains(encoded, "secret-url-material") || strings.Contains(encoded, "ciphertext-secret") {
|
|
t.Fatalf("response leaked ciphertext material: %s", encoded)
|
|
}
|
|
if strings.Contains(encoded, "signing_secret") {
|
|
t.Fatalf("signing_secret must be omitted unless explicitly set: %s", encoded)
|
|
}
|
|
if !strings.Contains(encoded, `"url_host":"discord.com"`) {
|
|
t.Fatalf("url_host missing from response: %s", encoded)
|
|
}
|
|
|
|
response := serverChannelToResponse(ch)
|
|
response.SigningSecret = "shown-once"
|
|
body, err = json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), `"signing_secret":"shown-once"`) {
|
|
t.Fatalf("explicit signing_secret missing: %s", body)
|
|
}
|
|
}
|
|
|
|
// A handler with no notifications system reports 503 instead of panicking.
|
|
func TestServerChannelHandlersUnavailableWithoutSystem(t *testing.T) {
|
|
h := NewAdminServerChannelsHandler(nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleList(rec, httptest.NewRequest(http.MethodGet, "/admin/notifications/server-channels", nil))
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("HandleList without system = %d, want 503", rec.Code)
|
|
}
|
|
}
|