* docs(autoscan): add arr webhook intake spec and implementation plan Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(autoscan): add webhook intake schema migration Adds delivery_mode to autoscan_sources, the autoscan_webhook_endpoints table, and delivery_mode/provider_event_type on autoscan_events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(autoscan): add built-in arr-webhook source identity Host-discovered scan-source entry so webhook-mode sources need no plugin installation; composite lister appends it to plugin discovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(autoscan): persist delivery mode, webhook endpoints, event metadata Sources carry delivery_mode; autoscan_webhook_endpoints CRUD with SHA-256 token lookup and AAD-bound encrypted redisplay; events record delivery_mode/provider_event_type; CreateEvent gains SkipRunningCheck so webhook deliveries are never dropped by the poll exclusion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(autoscan): share the consume path and add webhook IngestChanges Extracts consumeSourceChanges from PollOnce (marker semantics preserved, existing poll tests unchanged); PollOnce skips webhook sources; IngestChanges feeds deliveries through the shared pipeline without markers and without the running-event exclusion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(autoscan): add Sonarr/Radarr webhook payload parser Host-side arrwebhook package: provider inference, import/rename/delete path extraction with vanished-path-friendly previous paths, subtree fallback, exact-path dedupe, and no-op unknown events. Fixture-backed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(autoscan): add public webhook delivery route and admin endpoint management Public POST /api/v1/autoscan/webhooks/{token} with per-IP rate limiting, 256KiB body cap, 202-for-noop semantics, and token/body kept out of logs; admin create/rotate/delete endpoint routes; source responses carry delivery mode + webhook status/URL; create/update validate delivery mode against source identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): add webhook delivery mode to Autoscan admin UI Webhook sources get a generate/copy/rotate webhook URL section, provider selector, delivery status, and a connection-free Add-source flow; activity rows badge webhook deliveries with the arr event type. Path rewrites stay editable in both modes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): redact secret path params from request and activity logs The request logger and activity-log middleware recorded raw URLs, so bearer credentials in secret path segments (autoscan webhook {token}, webhook-sync {secret}) were persisted to app logs and activity_log. Redact the secret segment via the chi route params in both sinks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(autoscan): make webhook delivery reliable --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
86 lines
2.6 KiB
Go
86 lines
2.6 KiB
Go
package autoscan
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
// fakeLister returns a fixed set of discovered scan sources.
|
|
type fakeLister struct{ sources []DiscoveredSource }
|
|
|
|
func (f fakeLister) ListScanSources(context.Context) ([]DiscoveredSource, error) {
|
|
return f.sources, nil
|
|
}
|
|
|
|
func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
|
|
lister := fakeLister{sources: []DiscoveredSource{
|
|
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
|
|
{PluginID: "radarr", CapabilityID: "arr-b", DisplayName: "Radarr"},
|
|
}}
|
|
svc := &Service{lister: lister}
|
|
|
|
available, err := svc.ListAvailableScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListAvailableScanSources: %v", err)
|
|
}
|
|
if len(available) != 2 {
|
|
t.Fatalf("expected 2 available, got %d: %+v", len(available), available)
|
|
}
|
|
if available[0] != (AvailableScanSource{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"}) {
|
|
t.Fatalf("unexpected first available: %+v", available[0])
|
|
}
|
|
}
|
|
|
|
func TestWithBuiltinSourcesAppendsToInner(t *testing.T) {
|
|
inner := fakeLister{sources: []DiscoveredSource{
|
|
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
|
|
}}
|
|
lister := WithBuiltinSources(inner, BuiltinArrWebhookSource())
|
|
|
|
discovered, err := lister.ListScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListScanSources: %v", err)
|
|
}
|
|
if len(discovered) != 2 {
|
|
t.Fatalf("expected 2 discovered, got %d: %+v", len(discovered), discovered)
|
|
}
|
|
if discovered[0].PluginID != "sonarr" {
|
|
t.Fatalf("plugin entries must pass through first, got %+v", discovered[0])
|
|
}
|
|
if discovered[1] != BuiltinArrWebhookSource() {
|
|
t.Fatalf("expected builtin appended, got %+v", discovered[1])
|
|
}
|
|
}
|
|
|
|
func TestWithBuiltinSourcesNilInner(t *testing.T) {
|
|
lister := WithBuiltinSources(nil, BuiltinArrWebhookSource())
|
|
|
|
discovered, err := lister.ListScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListScanSources: %v", err)
|
|
}
|
|
if len(discovered) != 1 || discovered[0] != BuiltinArrWebhookSource() {
|
|
t.Fatalf("expected only builtin, got %+v", discovered)
|
|
}
|
|
}
|
|
|
|
func TestIsBuiltinArrWebhookIdentity(t *testing.T) {
|
|
if !IsBuiltinArrWebhookIdentity(BuiltinArrWebhookPluginID, BuiltinArrWebhookCapabilityID) {
|
|
t.Fatal("builtin identity must match")
|
|
}
|
|
if IsBuiltinArrWebhookIdentity("sonarr", "arr") {
|
|
t.Fatal("plugin identity must not match builtin")
|
|
}
|
|
}
|
|
|
|
func TestListAvailableScanSourcesNilListerEmpty(t *testing.T) {
|
|
svc := &Service{lister: nil}
|
|
available, err := svc.ListAvailableScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListAvailableScanSources: %v", err)
|
|
}
|
|
if len(available) != 0 {
|
|
t.Fatalf("nil lister must return empty, got %+v", available)
|
|
}
|
|
}
|