Files
silo-server/internal/api/handlers/admin_server_channels_test.go
QuickandClaude Fable 5 3d2368aed7 feat(notifications): admin server channels broadcasting new content and request activity
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>
2026-06-11 23:04:35 -04:00

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)
}
}