Files
silo-server/internal/autoscan/descriptor_test.go
Quick 4504d7c68e fix(autoscan): read descriptors from installed metadata, tighten source config
Second Codex review pass. Two P1s meant the descriptor mechanism was dead for
every real plugin; both are verified against the pinned SDK's converter.

Backend:
- The SDK's CapabilityRecordsFromManifest nests a capability's arbitrary
  metadata struct under "metadata" rather than flattening it, so an installed
  plugin's contract lives at metadata.metadata.scan_source. The top-level lookup
  never matched, silently resolving every plugin to host defaults. Look in the
  nested location first, keeping the flat path for host-built descriptors.
- The same converter persists config_schema without admin_form, so a plugin's
  form could never reach the UI through that route. The canonical location is
  now config_form inside the typed block, which survives installation; the
  config_schema path is retained only as a fallback.
- Compatibility merging now tracks which fields the manifest actually declared
  instead of comparing against defaults. A plugin explicitly declaring a
  default-valued field was previously indistinguishable from silence and got
  overwritten, contradicting the stated manifest-wins contract.
- Dynamic-option fields are dropped from source config forms: their options come
  from a connection-aware probe only the plugin-config page performs, so a
  required one could never be satisfied and would block creation permanently.

Frontend:
- The mapping editor asked for "the path your download manager reports" and
  suggested /downloads/tv. arrwebhook.Parse consumes episodeFile.path /
  movieFile.path — the arr's imported library paths, not the download client's
  working directory — so following the example produced rewrites that never
  matched. Relabelled to the Sonarr/Radarr root folder.
- Full-state saves no longer carry an invalid config draft. Enabling, renaming,
  or editing an interval serialized the in-progress draft, bypassing the
  validity gate; unrelated mutations now send the persisted config instead.
- Legacy movie_nested_paths/tv_nested_paths migration is scoped to the CephFS
  identity. Applied globally it would rename an unrelated plugin's
  identically-named key on first save, losing its configuration.
- Rows re-parse when the descriptor arrives after /sources. Mounting with the
  default descriptor left stored switch values as strings, so "false" rendered
  as an enabled switch. Done during render rather than in an effect, and skipped
  once the row is dirty so an in-progress edit survives.
- Values hidden by an unsatisfied show_when are no longer persisted, and
  displayed defaults are. Both come from reusing buildSchemaValues, the same
  helper the plugin-config page uses.
- The manual connection form asks which service it is when a descriptor accepts
  several, instead of always recording the first kind.
- The inline connection picker remounts per source, so a half-entered server is
  not carried into a different plugin's descriptor.
- emits_native_paths sources report unknown targets rather than "can't match
  anything": they discover Silo-native paths at poll time and need no
  configured root.
2026-07-29 14:52:16 -04:00

342 lines
12 KiB
Go

package autoscan
import "testing"
// A capability that declares nothing must behave exactly as sources did before
// descriptors existed: host-polled, connection allowed but not demanded. This
// is the guarantee that keeps already-installed plugins working.
func TestDescriptorFromMetadataDefaultsPreserveLegacyBehavior(t *testing.T) {
for name, metadata := range map[string]map[string]any{
"nil metadata": nil,
"empty metadata": {},
"unrelated keys": {"display_name": "Something"},
} {
t.Run(name, func(t *testing.T) {
got := DescriptorFromMetadata(metadata)
if got.Connection != ConnectionOptional {
t.Errorf("connection = %q, want %q", got.Connection, ConnectionOptional)
}
if !got.SupportsDeliveryMode(DeliveryModePoll) {
t.Errorf("delivery modes = %v, want poll", got.DeliveryModes)
}
if got.SupportsDeliveryMode(DeliveryModeWebhook) {
t.Errorf("delivery modes = %v, must not default to webhook", got.DeliveryModes)
}
if got.EmitsNativePaths {
t.Error("emits_native_paths must default false so rewrites stay offered")
}
})
}
}
func TestDescriptorFromMetadataReadsDeclaredContract(t *testing.T) {
got := DescriptorFromMetadata(map[string]any{
"scan_source": map[string]any{
"delivery_modes": []any{"poll"},
"connection": "none",
"connection_kinds": []any{"sonarr", "radarr"},
"emits_native_paths": true,
"summary": " Pushes to Silo. ",
"icon_url": "/assets/icon.svg",
},
})
if got.Connection != ConnectionNone {
t.Errorf("connection = %q, want none", got.Connection)
}
if !got.SupportsDeliveryMode(DeliveryModePoll) {
t.Errorf("delivery modes = %v, want poll", got.DeliveryModes)
}
if !got.EmitsNativePaths {
t.Error("emits_native_paths not read")
}
if got.Summary != "Pushes to Silo." {
t.Errorf("summary = %q, want trimmed", got.Summary)
}
if len(got.ConnectionKinds) != 2 {
t.Errorf("connection kinds = %v", got.ConnectionKinds)
}
}
// Webhook delivery is accepted only for the host's built-in ARR identity (see
// resolveDeliveryMode). Honouring a plugin's claim would offer a setup option
// whose every submission is rejected with HTTP 400.
func TestDescriptorFromMetadataIgnoresPluginDeclaredWebhook(t *testing.T) {
got := DescriptorFromMetadata(map[string]any{
"scan_source": map[string]any{"delivery_modes": []any{"webhook"}},
})
if got.SupportsDeliveryMode(DeliveryModeWebhook) {
t.Errorf("delivery modes = %v, plugin-declared webhook must be dropped", got.DeliveryModes)
}
if !got.SupportsDeliveryMode(DeliveryModePoll) {
t.Errorf("delivery modes = %v, want fallback to poll", got.DeliveryModes)
}
}
// The built-in identity supplies its descriptor directly rather than through
// the metadata parser, so its webhook mode survives.
func TestBuiltinWebhookDescriptorKeepsWebhookMode(t *testing.T) {
if !BuiltinArrWebhookSource().Descriptor.SupportsDeliveryMode(DeliveryModeWebhook) {
t.Fatal("built-in ARR identity must keep webhook delivery")
}
}
// Secret-marked fields are dropped: source_config is plaintext JSONB, so a
// masked input would misrepresent how the value is stored.
func TestConfigFormDropsSecretFields(t *testing.T) {
got := DescriptorFromMetadata(map[string]any{
"config_schema": []any{
map[string]any{"key": "scan_source", "admin_form": map[string]any{
"fields": []any{
map[string]any{"key": "root", "label": "Root", "control": "TEXT"},
map[string]any{"key": "token", "label": "Token", "control": "PASSWORD"},
map[string]any{"key": "other", "label": "Other", "control": "TEXT", "secret": true},
},
}},
},
})
if got.ConfigForm == nil {
t.Fatal("expected the non-secret field to survive")
}
for _, field := range got.ConfigForm.Fields {
if field.Key != "root" {
t.Errorf("secret-bearing field %q must not be collected", field.Key)
}
}
}
// The SDK's CapabilityRecordsFromManifest stores a capability's arbitrary
// metadata struct nested under "metadata" rather than flattening it, so a real
// installation's contract lives at metadata.metadata.scan_source. Reading only
// the top level silently resolved every installed plugin to host defaults.
func TestDescriptorFromNestedInstalledMetadata(t *testing.T) {
got := DescriptorFromMetadata(map[string]any{
"display_name": "Example",
"metadata": map[string]any{
"scan_source": map[string]any{
"connection": "required",
"connection_kinds": []any{"sonarr"},
"summary": "From an installed manifest.",
},
},
})
if got.Connection != ConnectionRequired {
t.Errorf("connection = %q, nested metadata was not read", got.Connection)
}
if got.Summary != "From an installed manifest." {
t.Errorf("summary = %q, nested metadata was not read", got.Summary)
}
}
// The SDK drops admin_form when persisting config_schema, so a plugin's form
// must travel inside the typed block to survive installation.
func TestConfigFormFromNestedScanSourceBlock(t *testing.T) {
got := DescriptorFromMetadata(map[string]any{
"metadata": map[string]any{
"scan_source": map[string]any{
"config_form": map[string]any{
"fields": []any{
map[string]any{"key": "root", "label": "Root", "control": "TEXT"},
},
},
},
},
})
if got.ConfigForm == nil || len(got.ConfigForm.Fields) != 1 {
t.Fatalf("config form not read from the nested block: %+v", got.ConfigForm)
}
if got.ConfigForm.Fields[0].Key != "root" {
t.Errorf("field = %q", got.ConfigForm.Fields[0].Key)
}
}
// Dynamic-option fields have no option source on a scan-source form, so a
// required one could never be satisfied. They are dropped at the contract.
func TestConfigFormDropsDynamicOptionFields(t *testing.T) {
got := DescriptorFromMetadata(map[string]any{
"metadata": map[string]any{
"scan_source": map[string]any{
"config_form": map[string]any{
"fields": []any{
map[string]any{"key": "root", "label": "Root", "control": "TEXT"},
map[string]any{
"key": "profile", "label": "Profile", "control": "SELECT",
"dynamic_options": true,
},
},
},
},
},
})
if got.ConfigForm == nil {
t.Fatal("expected the static field to survive")
}
for _, field := range got.ConfigForm.Fields {
if field.Key == "profile" {
t.Error("dynamic-option field must not be offered on a source form")
}
}
}
// A plugin explicitly declaring a value that equals a host default must still
// win over the compatibility descriptor — otherwise "manifest wins" is false
// for exactly the values a plugin is most likely to state.
func TestCompatibilityRespectsExplicitDefaultValuedDeclaration(t *testing.T) {
declared := DescriptorFromMetadata(map[string]any{
"metadata": map[string]any{
"scan_source": map[string]any{"connection": "optional"},
},
})
got := ApplyCompatibilityDescriptor(cephFSPluginID, cephFSCapabilityID, declared)
if got.Connection != ConnectionOptional {
t.Errorf("connection = %q, explicit declaration was overwritten by compat", got.Connection)
}
}
// A malformed or unrecognized descriptor must degrade to defaults rather than
// make an otherwise working plugin undiscoverable.
func TestDescriptorFromMetadataToleratesBadValues(t *testing.T) {
tests := map[string]map[string]any{
"wrong block type": {"scan_source": "not-an-object"},
"unknown modes": {"scan_source": map[string]any{
"delivery_modes": []any{"telepathy"},
}},
"wrong mode element type": {"scan_source": map[string]any{
"delivery_modes": []any{7},
}},
"unknown connection value": {"scan_source": map[string]any{
"connection": "sometimes",
}},
}
for name, metadata := range tests {
t.Run(name, func(t *testing.T) {
got := DescriptorFromMetadata(metadata)
if !got.SupportsDeliveryMode(DeliveryModePoll) {
t.Errorf("delivery modes = %v, want fallback to poll", got.DeliveryModes)
}
if got.Connection != ConnectionOptional {
t.Errorf("connection = %q, want fallback to optional", got.Connection)
}
})
}
}
func TestDescriptorReadsConfigFormFromCapabilityConfigSchema(t *testing.T) {
got := DescriptorFromMetadata(map[string]any{
"config_schema": []any{
map[string]any{"key": "unrelated", "admin_form": map[string]any{
"fields": []any{map[string]any{"key": "ignored", "label": "Ignored"}},
}},
map[string]any{"key": "scan_source", "admin_form": map[string]any{
"fields": []any{
map[string]any{"key": "root", "label": "Root path", "control": "TEXT"},
},
}},
},
})
if got.ConfigForm == nil {
t.Fatal("expected config form from the scan_source config_schema entry")
}
if len(got.ConfigForm.Fields) != 1 || got.ConfigForm.Fields[0].Key != "root" {
t.Fatalf("wrong form picked: %+v", got.ConfigForm.Fields)
}
}
func TestDefaultDeliveryModePrefersPoll(t *testing.T) {
webhookOnly := ScanSourceDescriptor{DeliveryModes: []string{DeliveryModeWebhook}}
if got := webhookOnly.DefaultDeliveryMode(); got != DeliveryModeWebhook {
t.Errorf("webhook-only default = %q", got)
}
both := ScanSourceDescriptor{DeliveryModes: []string{DeliveryModeWebhook, DeliveryModePoll}}
if got := both.DefaultDeliveryMode(); got != DeliveryModePoll {
t.Errorf("dual-mode default = %q, want poll", got)
}
// An empty descriptor must still name a usable mode.
if got := (ScanSourceDescriptor{}).DefaultDeliveryMode(); got != DeliveryModePoll {
t.Errorf("empty default = %q, want poll", got)
}
}
// The builtin arr webhook identity is the host's own descriptor, and the UI
// relies on it to skip both the delivery and connection steps.
func TestBuiltinArrWebhookDescriptor(t *testing.T) {
d := BuiltinArrWebhookSource().Descriptor
if d.Connection != ConnectionNone {
t.Errorf("connection = %q, want none (the arr pushes to Silo)", d.Connection)
}
if d.SupportsDeliveryMode(DeliveryModePoll) {
t.Errorf("delivery modes = %v, want webhook only", d.DeliveryModes)
}
if d.ConfigForm == nil || len(d.ConfigForm.Fields) == 0 {
t.Fatal("expected a provider config form")
}
if d.ConfigForm.Fields[0].Key != WebhookProviderConfigKey {
t.Errorf("form field = %q, want %q", d.ConfigForm.Fields[0].Key, WebhookProviderConfigKey)
}
}
// The compatibility layer is a stopgap for first-party plugins that have not
// published a descriptor yet. A plugin that declares its own contract must win.
func TestApplyCompatibilityDescriptorFillsOnlyUnsetFields(t *testing.T) {
declared := DescriptorFromMetadata(nil) // plugin said nothing
got := ApplyCompatibilityDescriptor(cephFSPluginID, cephFSCapabilityID, declared)
if got.Connection != ConnectionNone {
t.Errorf("connection = %q, want none from compat", got.Connection)
}
if got.ConfigForm == nil {
t.Fatal("expected compat config form for cephfs")
}
if got.Summary == "" {
t.Error("expected compat summary")
}
}
func TestApplyCompatibilityDescriptorDoesNotOverrideManifest(t *testing.T) {
declared := DescriptorFromMetadata(map[string]any{
"scan_source": map[string]any{
"connection": "required",
"summary": "Plugin's own words",
},
})
got := ApplyCompatibilityDescriptor(cephFSPluginID, cephFSCapabilityID, declared)
if got.Connection != ConnectionRequired {
t.Errorf("connection = %q, manifest must win over compat", got.Connection)
}
if got.Summary != "Plugin's own words" {
t.Errorf("summary = %q, manifest must win over compat", got.Summary)
}
}
// Capability ids are author-chosen and not unique across plugins, so both parts
// must match before CephFS's form is applied.
func TestApplyCompatibilityDescriptorRequiresBothIdentifiers(t *testing.T) {
declared := DescriptorFromMetadata(nil)
got := ApplyCompatibilityDescriptor("com.example.other", cephFSCapabilityID, declared)
if got.ConfigForm != nil {
t.Error("a matching capability id alone must not apply the CephFS form")
}
}
func TestApplyCompatibilityDescriptorLeavesUnknownPluginsAlone(t *testing.T) {
declared := DescriptorFromMetadata(nil)
got := ApplyCompatibilityDescriptor("com.example.future", "watcher", declared)
if got.ConfigForm != nil {
t.Error("unknown plugin must not inherit another plugin's form")
}
if got.Connection != ConnectionOptional {
t.Errorf("connection = %q, want untouched default", got.Connection)
}
}