* 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>
1415 lines
48 KiB
Go
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)
|
|
}
|
|
}
|