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