Files
silo-server/internal/api/handlers/autoscan_test.go
d68e70bb47 feat(autoscan): Sonarr/Radarr webhook intake without arr API keys (#353)
* 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>
2026-07-09 14:13:31 -04:00

1415 lines
48 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/autoscan"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
type fakeAutoscanStore struct {
getSettingsFn func() (autoscan.Settings, error)
updateSettingsFn func(autoscan.Settings) (autoscan.Settings, error)
listConnectionsFn func() ([]autoscan.Connection, error)
createConnectionFn func(autoscan.Connection) (autoscan.Connection, error)
updateConnectionFn func(autoscan.Connection) (autoscan.Connection, error)
deleteConnectionFn func(string) error
listSourcesFn func() ([]autoscan.Source, error)
getSourceFn func(string) (autoscan.Source, error)
createSourceFn func(autoscan.Source) (autoscan.Source, error)
updateSourceFn func(autoscan.Source) (autoscan.Source, error)
deleteSourceFn func(string) error
listScansFn func(autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error)
countScansFn func(autoscan.ScanListFilter) (int, error)
listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
countEventsFn func(autoscan.EventListFilter) (int, error)
listRunningFn func() ([]autoscan.Event, error)
queueSummaryFn func() (autoscan.QueueSummary, error)
latestEventAtFn func() (*time.Time, error)
createWebhookFn func(string) (autoscan.WebhookEndpoint, string, error)
rotateWebhookFn func(string) (autoscan.WebhookEndpoint, string, error)
deleteWebhookFn func(string) error
getWebhookFn func(string) (autoscan.WebhookEndpoint, error)
listWebhooksFn func() ([]autoscan.WebhookEndpoint, error)
revealTokenFn func(string) (string, error)
resolveTokenFn func(string) (autoscan.Source, autoscan.WebhookEndpoint, error)
touchedSources []string
webhookErrs map[string]string
}
func (f *fakeAutoscanStore) GetSettings(context.Context) (autoscan.Settings, error) {
if f.getSettingsFn != nil {
return f.getSettingsFn()
}
return autoscan.Settings{}, nil
}
func (f *fakeAutoscanStore) UpdateSettings(_ context.Context, s autoscan.Settings) (autoscan.Settings, error) {
if f.updateSettingsFn != nil {
return f.updateSettingsFn(s)
}
return s, nil
}
func (f *fakeAutoscanStore) ListConnections(context.Context) ([]autoscan.Connection, error) {
if f.listConnectionsFn != nil {
return f.listConnectionsFn()
}
return nil, nil
}
func (f *fakeAutoscanStore) CreateConnection(_ context.Context, c autoscan.Connection) (autoscan.Connection, error) {
if f.createConnectionFn != nil {
return f.createConnectionFn(c)
}
return c, nil
}
func (f *fakeAutoscanStore) UpdateConnection(_ context.Context, c autoscan.Connection) (autoscan.Connection, error) {
if f.updateConnectionFn != nil {
return f.updateConnectionFn(c)
}
return c, nil
}
func (f *fakeAutoscanStore) DeleteConnection(_ context.Context, id string) error {
if f.deleteConnectionFn != nil {
return f.deleteConnectionFn(id)
}
return nil
}
func (f *fakeAutoscanStore) ListSources(context.Context) ([]autoscan.Source, error) {
if f.listSourcesFn != nil {
return f.listSourcesFn()
}
return nil, nil
}
func (f *fakeAutoscanStore) GetSource(_ context.Context, id string) (autoscan.Source, error) {
if f.getSourceFn != nil {
return f.getSourceFn(id)
}
return autoscan.Source{ID: id}, nil
}
func (f *fakeAutoscanStore) CreateSource(_ context.Context, s autoscan.Source) (autoscan.Source, error) {
if f.createSourceFn != nil {
return f.createSourceFn(s)
}
return s, nil
}
func (f *fakeAutoscanStore) UpdateSource(_ context.Context, s autoscan.Source) (autoscan.Source, error) {
if f.updateSourceFn != nil {
return f.updateSourceFn(s)
}
return s, nil
}
func (f *fakeAutoscanStore) DeleteSource(_ context.Context, id string) error {
if f.deleteSourceFn != nil {
return f.deleteSourceFn(id)
}
return nil
}
func (f *fakeAutoscanStore) ListAutoscanScans(_ context.Context, filter autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) {
if f.listScansFn != nil {
return f.listScansFn(filter)
}
return nil, nil
}
func (f *fakeAutoscanStore) CountAutoscanScans(_ context.Context, filter autoscan.ScanListFilter) (int, error) {
if f.countScansFn != nil {
return f.countScansFn(filter)
}
return 0, nil
}
func (f *fakeAutoscanStore) ListEvents(_ context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) {
if f.listEventsFn != nil {
return f.listEventsFn(filter)
}
return nil, nil
}
func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.EventListFilter) (int, error) {
if f.countEventsFn != nil {
return f.countEventsFn(filter)
}
return 0, nil
}
func (f *fakeAutoscanStore) ListRunningEvents(context.Context) ([]autoscan.Event, error) {
if f.listRunningFn != nil {
return f.listRunningFn()
}
return nil, nil
}
func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) {
if f.queueSummaryFn != nil {
return f.queueSummaryFn()
}
return autoscan.QueueSummary{}, nil
}
func (f *fakeAutoscanStore) LatestEventAt(context.Context) (*time.Time, error) {
if f.latestEventAtFn != nil {
return f.latestEventAtFn()
}
return nil, nil
}
func (f *fakeAutoscanStore) CreateWebhookEndpoint(_ context.Context, sourceID string) (autoscan.WebhookEndpoint, string, error) {
if f.createWebhookFn != nil {
return f.createWebhookFn(sourceID)
}
return autoscan.WebhookEndpoint{SourceID: sourceID}, "", nil
}
func (f *fakeAutoscanStore) RotateWebhookEndpoint(_ context.Context, sourceID string) (autoscan.WebhookEndpoint, string, error) {
if f.rotateWebhookFn != nil {
return f.rotateWebhookFn(sourceID)
}
return autoscan.WebhookEndpoint{SourceID: sourceID}, "", nil
}
func (f *fakeAutoscanStore) DeleteWebhookEndpoint(_ context.Context, sourceID string) error {
if f.deleteWebhookFn != nil {
return f.deleteWebhookFn(sourceID)
}
return nil
}
func (f *fakeAutoscanStore) GetWebhookEndpoint(_ context.Context, sourceID string) (autoscan.WebhookEndpoint, error) {
if f.getWebhookFn != nil {
return f.getWebhookFn(sourceID)
}
return autoscan.WebhookEndpoint{}, autoscan.ErrNotFound
}
func (f *fakeAutoscanStore) ListWebhookEndpoints(context.Context) ([]autoscan.WebhookEndpoint, error) {
if f.listWebhooksFn != nil {
return f.listWebhooksFn()
}
return nil, nil
}
func (f *fakeAutoscanStore) RevealWebhookToken(_ context.Context, sourceID string) (string, error) {
if f.revealTokenFn != nil {
return f.revealTokenFn(sourceID)
}
return "", autoscan.ErrNotFound
}
func (f *fakeAutoscanStore) ResolveWebhookToken(_ context.Context, token string) (autoscan.Source, autoscan.WebhookEndpoint, error) {
if f.resolveTokenFn != nil {
return f.resolveTokenFn(token)
}
return autoscan.Source{}, autoscan.WebhookEndpoint{}, autoscan.ErrNotFound
}
func (f *fakeAutoscanStore) TouchWebhookReceived(_ context.Context, sourceID string) error {
f.touchedSources = append(f.touchedSources, sourceID)
return nil
}
func (f *fakeAutoscanStore) RecordWebhookError(_ context.Context, sourceID, msg string) error {
if f.webhookErrs == nil {
f.webhookErrs = map[string]string{}
}
f.webhookErrs[sourceID] = msg
return nil
}
// fakeTriggerUpdater records the last UpdateTriggers call so a test can assert
// the handler reschedules the poll task with the new interval.
type fakeTriggerUpdater struct {
called bool
key string
triggers []taskmanager.TriggerConfig
err error
}
func (f *fakeTriggerUpdater) UpdateTriggers(key string, cfgs []taskmanager.TriggerConfig) error {
f.called = true
f.key = key
f.triggers = cfgs
return f.err
}
type fakeAutoscanTriggerer struct {
called bool
err error
// available is returned by ListAvailableScanSources (the Add-source picker /
// create-validation set).
available []autoscan.AvailableScanSource
// testResult / testErr drive TestConnection + TestConnectionByID.
testResult autoscan.ConnectionTestResult
testErr error
// suggestions / suggestErr drive SuggestRewrites.
suggestions autoscan.RewriteSuggestions
suggestErr error
// done, when non-nil, receives once PollOnce runs. HandleTrigger dispatches
// PollOnce on a detached goroutine, so tests synchronize on this instead of
// reading `called` straight after the handler returns (which races the
// goroutine and is also an unsynchronized read of `called`). The channel
// send happens-before the test's receive, so reading `called` afterwards is
// race-free.
done chan struct{}
// ingested records IngestChanges calls; ingestResult/ingestErr drive the
// response.
ingested []autoscan.ChangeIngest
ingestResult autoscan.IngestResult
ingestErr error
}
func (f *fakeAutoscanTriggerer) PollOnce(context.Context) error {
f.called = true
if f.done != nil {
f.done <- struct{}{}
}
return f.err
}
func (f *fakeAutoscanTriggerer) ListAvailableScanSources(context.Context) ([]autoscan.AvailableScanSource, error) {
return f.available, nil
}
func (f *fakeAutoscanTriggerer) TestConnection(context.Context, autoscan.Connection) (autoscan.ConnectionTestResult, error) {
return f.testResult, f.testErr
}
func (f *fakeAutoscanTriggerer) TestConnectionByID(context.Context, string) (autoscan.ConnectionTestResult, error) {
return f.testResult, f.testErr
}
func (f *fakeAutoscanTriggerer) SuggestRewrites(context.Context, string) (autoscan.RewriteSuggestions, error) {
return f.suggestions, f.suggestErr
}
func (f *fakeAutoscanTriggerer) IngestChanges(_ context.Context, in autoscan.ChangeIngest) (autoscan.IngestResult, error) {
f.ingested = append(f.ingested, in)
return f.ingestResult, f.ingestErr
}
func newAutoscanRequest(method, target, body, id string) *http.Request {
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, target, strings.NewReader(body))
} else {
r = httptest.NewRequest(method, target, nil)
}
if id != "" {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", id)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
}
return r
}
func TestAutoscanHandleGetSettingsReturnsJSON(t *testing.T) {
store := &fakeAutoscanStore{
getSettingsFn: func() (autoscan.Settings, error) {
return autoscan.Settings{Enabled: true, DefaultPollIntervalSeconds: 300, DebounceSeconds: 30}, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
rec := httptest.NewRecorder()
h.HandleGetSettings(rec, httptest.NewRequest("GET", "/api/v1/admin/autoscan/settings", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body autoscanSettingsResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.Enabled || body.DefaultPollIntervalSeconds != 300 {
t.Errorf("settings = %+v", body)
}
}
func TestAutoscanHandleUpdateSettingsRejectsZeroInterval(t *testing.T) {
h := NewAutoscanHandler(&fakeAutoscanStore{}, &fakeAutoscanTriggerer{})
req := httptest.NewRequest("PUT", "/api/v1/admin/autoscan/settings",
strings.NewReader(`{"enabled":true,"default_poll_interval_seconds":0,"debounce_seconds":10}`))
rec := httptest.NewRecorder()
h.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
// Sources are now operator-created (no auto-seed on list). The list endpoint
// must simply return the stored rows.
listed := false
store := &fakeAutoscanStore{
listSourcesFn: func() ([]autoscan.Source, error) {
listed = true
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
rec := httptest.NewRecorder()
h.HandleListSources(rec, newAutoscanRequest("GET", "/api/v1/admin/autoscan/sources", "", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !listed {
t.Fatal("HandleListSources did not list stored sources")
}
var body struct {
Sources []autoscanSourceResponse `json:"sources"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Sources) != 1 || body.Sources[0].ID != "src-1" {
t.Fatalf("unexpected sources: %+v", body.Sources)
}
}
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
}}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
rec := httptest.NewRecorder()
h.HandleListAvailableScanSources(rec, newAutoscanRequest("GET", "/api/v1/admin/autoscan/scan-source-plugins", "", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var body struct {
Plugins []autoscanScanSourcePluginResponse `json:"plugins"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Plugins) != 1 || body.Plugins[0].PluginID != "sonarr" || body.Plugins[0].DisplayName != "Sonarr" {
t.Fatalf("unexpected plugins: %+v", body.Plugins)
}
}
func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
createSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
s.ID = "new-src"
return s, nil
},
}
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
t.Fatalf("unexpected created source: %+v", got)
}
var body autoscanSourceResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.ID != "new-src" {
t.Fatalf("expected created id echoed, got %+v", body)
}
}
func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
created := false
store := &fakeAutoscanStore{
createSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
created = true
return s, nil
},
}
// Installed set does NOT include ("silo.autoscan.arr", "arr").
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if created {
t.Fatal("CreateSource was called for an uninstalled capability")
}
}
func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T) {
// A connection is optional: a source can be created enabled without one
// (e.g. a filesystem/CephFS provider). The plugin surfaces any
// missing-credential error at poll time.
var got autoscan.Source
store := &fakeAutoscanStore{
createSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if !got.Enabled || got.ConnectionID != nil {
t.Fatalf("expected enabled connection-less source created, got %+v", got)
}
}
func TestAutoscanHandleTestConnectionOK(t *testing.T) {
trig := &fakeAutoscanTriggerer{testResult: autoscan.ConnectionTestResult{OK: true, Version: "4.0.1"}}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/connections/test",
`{"base_url":"http://radarr:7878","api_key_ref":"k"}`, "")
rec := httptest.NewRecorder()
h.HandleTestConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var body autoscanTestConnectionResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.OK || body.Version != "4.0.1" {
t.Fatalf("unexpected test result: %+v", body)
}
}
func TestAutoscanHandleTestConnectionFailureIs200WithError(t *testing.T) {
trig := &fakeAutoscanTriggerer{testResult: autoscan.ConnectionTestResult{OK: false, Err: "arr: HTTP 401"}}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/connections/test",
`{"connection_id":"conn-1"}`, "")
rec := httptest.NewRecorder()
h.HandleTestConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var body autoscanTestConnectionResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.OK || body.Error == "" {
t.Fatalf("expected ok=false with error, got %+v", body)
}
}
func TestAutoscanHandleTestConnectionRejectsEmptyInput(t *testing.T) {
h := NewAutoscanHandler(&fakeAutoscanStore{}, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/connections/test", `{}`, "")
rec := httptest.NewRecorder()
h.HandleTestConnection(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAutoscanHandleRewriteSuggestions(t *testing.T) {
trig := &fakeAutoscanTriggerer{suggestions: autoscan.RewriteSuggestions{
Proposed: []autoscan.ProposedRewrite{{From: "/data/tv", To: "/mnt/media/tv", MatchDepth: 1}},
Unmatched: []string{},
Ambiguous: []autoscan.AmbiguousRoot{},
Covered: []string{},
}}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
req := newAutoscanRequest("GET", "/api/v1/admin/autoscan/sources/src-1/rewrite-suggestions", "", "src-1")
rec := httptest.NewRecorder()
h.HandleRewriteSuggestions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var body autoscan.RewriteSuggestions
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Proposed) != 1 || body.Proposed[0].From != "/data/tv" {
t.Fatalf("unexpected suggestions: %+v", body)
}
}
func TestAutoscanHandleRewriteSuggestionsNoConnectionReturns400(t *testing.T) {
trig := &fakeAutoscanTriggerer{suggestErr: autoscan.ErrNoConnection}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
req := newAutoscanRequest("GET", "/api/v1/admin/autoscan/sources/src-1/rewrite-suggestions", "", "src-1")
rec := httptest.NewRecorder()
h.HandleRewriteSuggestions(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAutoscanHandleListConnectionsOmitsSecrets(t *testing.T) {
store := &fakeAutoscanStore{
listConnectionsFn: func() ([]autoscan.Connection, error) {
return []autoscan.Connection{
{
ID: "conn-1",
Name: "Radarr",
Kind: "radarr",
BaseURL: "http://radarr.internal:7878",
APIKeyRef: "secret-ref-xyz",
},
}, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
rec := httptest.NewRecorder()
h.HandleListConnections(rec, httptest.NewRequest("GET", "/api/v1/admin/autoscan/connections", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
raw := rec.Body.String()
if strings.Contains(raw, "api_key_ref") || strings.Contains(raw, "secret-ref-xyz") {
t.Errorf("connections response leaks api_key_ref: %s", raw)
}
// has_api_key should be reported true (so operators know a key is set) without
// disclosing the ref itself.
var body struct {
Connections []autoscanConnectionResponse `json:"connections"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Connections) != 1 || !body.Connections[0].HasAPIKey {
t.Errorf("connections = %+v", body.Connections)
}
}
func TestAutoscanHandleCreateConnectionRejectsBothEmpty(t *testing.T) {
created := false
store := &fakeAutoscanStore{
createConnectionFn: func(c autoscan.Connection) (autoscan.Connection, error) {
created = true
return c, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := httptest.NewRequest("POST", "/api/v1/admin/autoscan/connections",
strings.NewReader(`{"name":"x"}`))
rec := httptest.NewRecorder()
h.HandleCreateConnection(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if created {
t.Fatal("CreateConnection was called for a both-empty connection")
}
}
func TestAutoscanHandleUpdateConnectionRejectsBothEmpty(t *testing.T) {
updated := false
store := &fakeAutoscanStore{
updateConnectionFn: func(c autoscan.Connection) (autoscan.Connection, error) {
updated = true
return c, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
// Whitespace-only request_integration_id must count as absent.
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/connections/conn-1",
`{"name":"x","base_url":"","request_integration_id":" "}`, "conn-1")
rec := httptest.NewRecorder()
h.HandleUpdateConnection(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if updated {
t.Fatal("UpdateConnection was called for a both-empty connection")
}
}
func TestAutoscanHandleUpdateSourceNotFoundReturns404(t *testing.T) {
store := &fakeAutoscanStore{
updateSourceFn: func(autoscan.Source) (autoscan.Source, error) {
return autoscan.Source{}, autoscan.ErrNotFound
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/missing",
`{"enabled":true,"connection_id":"conn-1"}`, "missing")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T) {
// A connection is OPTIONAL — a source may enable without one (e.g. a
// filesystem/CephFS provider that needs no credentials). The handler no
// longer blocks it; if the plugin actually needs a connection it surfaces
// the error at poll time instead.
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":true}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !got.Enabled || got.ConnectionID != nil {
t.Fatalf("expected enabled connection-less source persisted, got %+v", got)
}
}
func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
// Full-state update: enable=true and supply the connection_id explicitly.
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":true,"connection_id":"conn-1"}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
t.Fatalf("expected connection bound, got %+v", got.ConnectionID)
}
}
func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
// Source starts with no connection.
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
// Bind a connection while leaving the source disabled.
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":false,"connection_id":"conn-42"}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got.ConnectionID == nil || *got.ConnectionID != "conn-42" {
t.Fatalf("expected connection bound to conn-42, got %+v", got.ConnectionID)
}
}
func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
// Source starts with a connection already bound.
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
// Unbind: send connection_id: null explicitly with enabled: false.
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":false,"connection_id":null}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got.ConnectionID != nil {
t.Fatalf("expected connection unbound (nil), got %+v", got.ConnectionID)
}
}
func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
// Unbinding the connection while enabled is allowed now that a connection is
// optional — the row persists enabled with connection_id: null. (A provider
// that needs credentials surfaces the error at poll time.)
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":true,"connection_id":null}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !got.Enabled || got.ConnectionID != nil {
t.Fatalf("expected enabled source with nil connection, got %+v", got)
}
}
func TestAutoscanHandleDeleteConnectionNotFoundReturns404(t *testing.T) {
store := &fakeAutoscanStore{
deleteConnectionFn: func(string) error {
return autoscan.ErrNotFound
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("DELETE", "/api/v1/admin/autoscan/connections/missing", "", "missing")
rec := httptest.NewRecorder()
h.HandleDeleteConnection(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestAutoscanHandleUpdateConnectionBlankKeyPassedThroughForKeep(t *testing.T) {
// Fix 1: a metadata-only edit omits api_key_ref ("leave blank to keep
// existing"). The handler must pass a blank api_key_ref through to the repo,
// whose UpdateConnection SQL keeps the stored key (CASE WHEN $5 = '' THEN
// api_key_ref ...). Here we assert the handler does NOT fabricate/clear a key:
// it forwards the empty string so the repo's keep-semantics fire.
var got autoscan.Connection
store := &fakeAutoscanStore{
updateConnectionFn: func(c autoscan.Connection) (autoscan.Connection, error) {
got = c
// Emulate the repo keep-semantics: a blank incoming ref leaves the
// previously-stored key in place.
if strings.TrimSpace(c.APIKeyRef) == "" {
c.APIKeyRef = "existing-stored-ref"
}
return c, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
// Update with base_url present but api_key_ref absent from the JSON.
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/connections/conn-1",
`{"name":"Radarr","kind":"radarr","base_url":"http://radarr:7878"}`, "conn-1")
rec := httptest.NewRecorder()
h.HandleUpdateConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
// Handler must forward a blank api_key_ref (so the repo keeps the existing key).
if strings.TrimSpace(got.APIKeyRef) != "" {
t.Fatalf("expected handler to forward blank api_key_ref, got %q", got.APIKeyRef)
}
// And the response must still report HasAPIKey=true (the stored key survived).
var body autoscanConnectionResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.HasAPIKey {
t.Fatalf("expected preserved key reflected as has_api_key=true, got %+v", body)
}
}
func TestAutoscanHandleDeleteSourceSucceeds(t *testing.T) {
deleted := ""
store := &fakeAutoscanStore{
deleteSourceFn: func(id string) error {
deleted = id
return nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("DELETE", "/api/v1/admin/autoscan/sources/src-1", "", "src-1")
rec := httptest.NewRecorder()
h.HandleDeleteSource(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
if deleted != "src-1" {
t.Fatalf("expected DeleteSource called with src-1, got %q", deleted)
}
}
func TestAutoscanHandleDeleteSourceNotFoundReturns404(t *testing.T) {
store := &fakeAutoscanStore{
deleteSourceFn: func(string) error {
return autoscan.ErrNotFound
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("DELETE", "/api/v1/admin/autoscan/sources/missing", "", "missing")
rec := httptest.NewRecorder()
h.HandleDeleteSource(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestAutoscanHandleUpdateSettingsReschedulesPollTask(t *testing.T) {
// Fix 5: a successful settings update must reschedule the poll task with the
// new default_poll_interval_seconds.
store := &fakeAutoscanStore{
updateSettingsFn: func(s autoscan.Settings) (autoscan.Settings, error) {
return s, nil
},
}
trig := &fakeTriggerUpdater{}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
h.SetTriggerUpdater(trig)
req := httptest.NewRequest("PUT", "/api/v1/admin/autoscan/settings",
strings.NewReader(`{"enabled":true,"default_poll_interval_seconds":300,"debounce_seconds":30}`))
rec := httptest.NewRecorder()
h.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !trig.called {
t.Fatal("expected UpdateTriggers to be called on settings update")
}
if trig.key != "autoscan_poll" {
t.Fatalf("rescheduled wrong task key: %q", trig.key)
}
if len(trig.triggers) != 1 || trig.triggers[0].Type != taskmanager.TriggerTypeInterval {
t.Fatalf("unexpected triggers: %+v", trig.triggers)
}
// 300 seconds -> 300_000 ms.
if trig.triggers[0].IntervalMs != 300*1000 {
t.Fatalf("interval = %d ms, want 300000", trig.triggers[0].IntervalMs)
}
}
func TestAutoscanHandleUpdateSettingsWithoutTriggerUpdaterSucceeds(t *testing.T) {
// Fix 5: the reschedule dep is optional; a nil updater must not break the
// settings update.
store := &fakeAutoscanStore{}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := httptest.NewRequest("PUT", "/api/v1/admin/autoscan/settings",
strings.NewReader(`{"enabled":true,"default_poll_interval_seconds":300,"debounce_seconds":30}`))
rec := httptest.NewRecorder()
h.HandleUpdateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestAutoscanHandleTriggerInvokesPollOnce(t *testing.T) {
trig := &fakeAutoscanTriggerer{done: make(chan struct{}, 1)}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
rec := httptest.NewRecorder()
h.HandleTrigger(rec, httptest.NewRequest("POST", "/api/v1/admin/autoscan/trigger", nil))
// The handler responds immediately and runs PollOnce on a detached
// goroutine; wait (bounded) for that goroutine to fire.
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202", rec.Code)
}
select {
case <-trig.done:
case <-time.After(2 * time.Second):
t.Fatal("PollOnce was not invoked within timeout")
}
if !trig.called {
t.Fatal("PollOnce was not invoked")
}
}
func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":true,"connection_id":"conn-1","path_rewrites":[{"from":"/data/tv","to":"/mnt/media/tv"}]}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
// The repo received the rewrite.
if len(got.PathRewrites) != 1 || got.PathRewrites[0].From != "/data/tv" || got.PathRewrites[0].To != "/mnt/media/tv" {
t.Fatalf("expected path_rewrites passed to repo, got %+v", got.PathRewrites)
}
// The response echoes it back.
var body autoscanSourceResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.PathRewrites) != 1 || body.PathRewrites[0].From != "/data/tv" || body.PathRewrites[0].To != "/mnt/media/tv" {
t.Fatalf("response missing path_rewrites: %+v", body.PathRewrites)
}
}
func TestAutoscanHandleUpdateSourceRoundTripsSourceConfig(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":true,"source_config":{" movie_flat_paths ":" /mnt/movies ","exclusions":".downloads\n.recyclebin"}}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got.SourceConfig["movie_flat_paths"] != "/mnt/movies" || got.SourceConfig["exclusions"] != ".downloads\n.recyclebin" {
t.Fatalf("source_config passed to repo = %#v", got.SourceConfig)
}
var body autoscanSourceResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.SourceConfig["movie_flat_paths"] != "/mnt/movies" {
t.Fatalf("response source_config = %#v", body.SourceConfig)
}
}
func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
upserted := false
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
upserted = true
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
// A rewrite with a blank "to" must be rejected with 400.
req := newAutoscanRequest("PUT", "/api/v1/admin/autoscan/sources/src-1",
`{"enabled":true,"connection_id":"conn-1","path_rewrites":[{"from":"/data/tv","to":""}]}`, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if upserted {
t.Fatal("UpsertSource was called despite an invalid path_rewrite")
}
}
func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
requested := time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC)
started := requested.Add(2 * time.Second)
completed := started.Add(5 * time.Second)
eventCompleted := completed.Add(-1 * time.Second)
eventID := int64(42)
sourceID := "src-1"
pluginID := "silo.autoscan.cephfs"
var gotFilter autoscan.ScanListFilter
var gotCountFilter autoscan.ScanListFilter
store := &fakeAutoscanStore{
listScansFn: func(filter autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) {
gotFilter = filter
return []autoscan.ScanWithEvent{{
ScanRunSummary: autoscan.ScanRunSummary{
ID: "run-1",
MediaFolderID: 9,
Mode: "subtree",
Path: "/mnt/media/Show/S01",
Trigger: "autoscan",
Status: "completed",
RequestedAt: &requested,
StartedAt: &started,
CompletedAt: &completed,
},
AutoscanEventID: &eventID,
SourceID: &sourceID,
PluginID: pluginID,
CapabilityID: "cephfs",
EventStatus: autoscan.EventStatusSuccess,
EventCompletedAt: &eventCompleted,
}}, nil
},
countScansFn: func(filter autoscan.ScanListFilter) (int, error) {
gotCountFilter = filter
return 137, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := httptest.NewRequest("GET", "/api/v1/admin/autoscan/scans?status=completed&limit=25&offset=50&q=Show", nil)
rec := httptest.NewRecorder()
h.HandleListScans(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if gotFilter.Status != "completed" || gotFilter.Limit != 25 || gotFilter.Offset != 50 || gotFilter.Search != "Show" {
t.Fatalf("filter = %+v", gotFilter)
}
// Count must filter identically to the list, minus the page window.
if gotCountFilter.Status != "completed" || gotCountFilter.Search != "Show" {
t.Fatalf("count filter = %+v", gotCountFilter)
}
var body autoscanScansResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Total != 137 || body.Limit != 25 || body.Offset != 50 {
t.Fatalf("pagination = total %d limit %d offset %d", body.Total, body.Limit, body.Offset)
}
if len(body.Scans) != 1 {
t.Fatalf("scans = %+v", body.Scans)
}
scan := body.Scans[0]
if scan.ID != "run-1" || scan.Path != "/mnt/media/Show/S01" || scan.Status != "completed" {
t.Fatalf("scan = %+v", scan)
}
if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" {
t.Fatalf("event context = %+v", scan)
}
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
t.Fatalf("source context = %+v", scan)
}
}
func TestAutoscanHandleListScansRejectsBadFilters(t *testing.T) {
listed := false
store := &fakeAutoscanStore{
listScansFn: func(autoscan.ScanListFilter) ([]autoscan.ScanWithEvent, error) {
listed = true
return nil, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
for _, target := range []string{
"/api/v1/admin/autoscan/scans?status=unknown",
"/api/v1/admin/autoscan/scans?limit=0",
"/api/v1/admin/autoscan/scans?offset=-1",
} {
rec := httptest.NewRecorder()
h.HandleListScans(rec, httptest.NewRequest("GET", target, nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d, want 400; body=%s", target, rec.Code, rec.Body.String())
}
}
if listed {
t.Fatal("ListAutoscanScans should not run for invalid filters")
}
}
func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
started := time.Date(2026, 6, 4, 14, 0, 0, 0, time.UTC)
completed := started.Add(1500 * time.Millisecond)
sourceID := "src-1"
var gotFilter autoscan.EventListFilter
var gotCountFilter autoscan.EventListFilter
store := &fakeAutoscanStore{
listEventsFn: func(filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) {
gotFilter = filter
return []autoscan.EventWithRuns{{
Event: autoscan.Event{
ID: 42,
SourceID: &sourceID,
PluginID: "silo.autoscan.cephfs",
CapabilityID: "cephfs",
StartedAt: started,
CompletedAt: completed,
DurationMS: 1500,
Status: autoscan.EventStatusSuccess,
ChangesReturned: 3,
ChangesResolved: 2,
TargetsClaimed: 2,
ScansCreated: 1,
ScansReused: 1,
ScansSuppressed: 1,
},
Runs: []autoscan.ScanRunSummary{{
ID: "run-1",
MediaFolderID: 9,
Mode: "subtree",
Path: "/mnt/media/Show/S01",
Trigger: "autoscan",
Status: "accepted",
StartedAt: nil,
CompletedAt: nil,
}},
}}, nil
},
countEventsFn: func(filter autoscan.EventListFilter) (int, error) {
gotCountFilter = filter
return 88, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
req := httptest.NewRequest("GET", "/api/v1/admin/autoscan/events?source_id=src-1&status=success&limit=25&offset=25&q=Show", nil)
rec := httptest.NewRecorder()
h.HandleListEvents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if gotFilter.SourceID != "src-1" ||
gotFilter.Status != autoscan.EventStatusSuccess ||
gotFilter.Limit != 25 ||
gotFilter.Offset != 25 ||
gotFilter.Search != "Show" {
t.Fatalf("filter = %+v", gotFilter)
}
// Count must filter identically to the list, minus the page window.
if gotCountFilter.SourceID != "src-1" || gotCountFilter.Status != autoscan.EventStatusSuccess || gotCountFilter.Search != "Show" {
t.Fatalf("count filter = %+v", gotCountFilter)
}
var body autoscanEventsResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Total != 88 || body.Limit != 25 || body.Offset != 25 {
t.Fatalf("pagination = total %d limit %d offset %d", body.Total, body.Limit, body.Offset)
}
if len(body.Events) != 1 {
t.Fatalf("events = %+v", body.Events)
}
event := body.Events[0]
if event.ID != 42 || event.SourceID == nil || *event.SourceID != sourceID || event.Status != "success" {
t.Fatalf("event identity = %+v", event)
}
if event.ChangesReturned != 3 || event.ScansCreated != 1 || event.ScansReused != 1 || event.ScansSuppressed != 1 {
t.Fatalf("event counts = %+v", event)
}
if len(event.ScanRuns) != 1 || event.ScanRuns[0].ID != "run-1" || event.ScanRuns[0].Path != "/mnt/media/Show/S01" {
t.Fatalf("scan_runs = %+v", event.ScanRuns)
}
}
func TestAutoscanHandleListEventsRejectsBadFilters(t *testing.T) {
listed := false
store := &fakeAutoscanStore{
listEventsFn: func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error) {
listed = true
return nil, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
for _, target := range []string{
"/api/v1/admin/autoscan/events?status=unknown",
"/api/v1/admin/autoscan/events?limit=0",
"/api/v1/admin/autoscan/events?offset=-1",
} {
rec := httptest.NewRecorder()
h.HandleListEvents(rec, httptest.NewRequest("GET", target, nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d, want 400; body=%s", target, rec.Code, rec.Body.String())
}
}
if listed {
t.Fatal("ListEvents should not run for invalid filters")
}
}
func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
return s, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
body := `{"connection_id":null,"enabled":false,"label":" 4K Movies "}`
req := newAutoscanRequest("PATCH", "/api/v1/admin/autoscan/sources/src-1", body, "src-1")
rec := httptest.NewRecorder()
h.HandleUpdateSource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got.Label != "4K Movies" {
t.Fatalf("stored label = %q, want %q", got.Label, "4K Movies")
}
var resp autoscanSourceResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Label != "4K Movies" {
t.Fatalf("response label = %q, want %q", resp.Label, "4K Movies")
}
}
func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC)
runningStartedAt := time.Now().Add(-90 * time.Second)
sourceID := "src-1"
store := &fakeAutoscanStore{
getSettingsFn: func() (autoscan.Settings, error) {
return autoscan.Settings{Enabled: true}, nil
},
listSourcesFn: func() ([]autoscan.Source, error) {
return []autoscan.Source{
{ID: "src-1", PluginID: "silo.autoscan.cephfs", CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
}, nil
},
queueSummaryFn: func() (autoscan.QueueSummary, error) {
return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil
},
listRunningFn: func() ([]autoscan.Event, error) {
return []autoscan.Event{{
ID: 44,
SourceID: &sourceID,
PluginID: "silo.autoscan.cephfs",
CapabilityID: "cephfs",
StartedAt: runningStartedAt,
Status: autoscan.EventStatusRunning,
}}, nil
},
latestEventAtFn: func() (*time.Time, error) {
return &latestEventAt, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
rec := httptest.NewRecorder()
h.HandleStatus(rec, httptest.NewRequest("GET", "/api/v1/admin/autoscan/status", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
raw := rec.Body.String()
if strings.Contains(raw, "base_url") || strings.Contains(raw, "api_key_ref") {
t.Errorf("status leaks secrets: %s", raw)
}
var body autoscanStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.Enabled || len(body.Sources) != 1 || body.Sources[0].ID != "src-1" {
t.Errorf("status = %+v", body)
}
if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 {
t.Errorf("queue summary = %+v", body)
}
if len(body.RunningPolls) != 1 {
t.Fatalf("running_polls = %+v", body.RunningPolls)
}
poll := body.RunningPolls[0]
if poll.ID != 44 || poll.SourceID == nil || *poll.SourceID != sourceID || poll.PluginID != "silo.autoscan.cephfs" || poll.CapabilityID != "cephfs" {
t.Fatalf("running poll identity = %+v", poll)
}
if poll.ElapsedMS <= 0 {
t.Fatalf("running poll elapsed_ms = %d, want > 0", poll.ElapsedMS)
}
if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) {
t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt)
}
}