feat(jellyseer): move jellyseerr_api_key to an encrypted secret (slice 2/3)

The Jellyseerr API key was stored as plaintext in the Jellyfin service config.
It is now a SecretField on the Jellyfin service, so it is encrypted at rest and
rendered as a masked secret input (the generic config editor stops exposing
it, and the secret editor picks it up automatically).

Migration (idempotent, runs in ensure_defaults):
- _migrate_jellyseerr_api_key_to_secret: for every Jellyfin service with a
  plaintext jellyseerr_api_key still in config, encrypt it ONCE into the
  secrets blob (direct UPDATE so existing encrypted secrets are preserved, not
  re-encrypted) and remove it from config.
- _migrate_jellyseerr_into_jellyfin: standalone-jellyseerr absorption now
  stores the key as a secret, and decrypts the Jellyfin api_key before handing
  it to upsert_service (fixes a pre-existing double-encrypt on that rare path).

The stats provider already reads jellyseerr_api_key from secrets-or-config, so
it works before, during, and after the migration.

Tests: absorbed-key lands in secrets (and existing api_key isn't corrupted);
new plaintext-config -> secret migration + idempotency. 401/401 backend pass.
This commit is contained in:
Developer
2026-07-12 13:40:56 +00:00
parent b8cb29e330
commit e25240c2f3
7 changed files with 107 additions and 20 deletions
+39 -4
View File
@@ -73,9 +73,12 @@ def test_registry_contains_eight_service_types():
def test_jellyseerr_absorbed_into_jellyfin():
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
assert "jellyseerr" not in SERVICE_DEFINITIONS
jellyfin_config = get_service_definition("jellyfin").config_schema["properties"]
jellyfin = get_service_definition("jellyfin")
jellyfin_config = jellyfin.config_schema["properties"]
assert "jellyseerr_url" in jellyfin_config
assert "jellyseerr_api_key" in jellyfin_config
# jellyseerr_api_key moved from config to a secret field.
assert "jellyseerr_api_key" not in jellyfin_config
assert "jellyseerr_api_key" in {sf.key for sf in jellyfin.secret_fields}
def test_backups_service_definition():
@@ -555,10 +558,42 @@ def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
# Jellyseerr row is gone.
assert store.list_services("jellyseerr") == []
# Jellyfin config gained the absorbed fields.
# Jellyfin config gained jellyseerr_url; the api key is now a secret.
migrated = store.get_service(jellyfin["id"])
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
assert migrated["config"]["jellyseerr_api_key"] == "js-key"
assert "jellyseerr_api_key" not in migrated["config"]
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key"
# The existing Jellyfin api_key is preserved (not double-encrypted).
assert decrypt_value(migrated["secrets"]["api_key"]) == "jf-key"
def test_jellyseerr_api_key_migrates_from_config_to_secret(tmp_path):
"""A pre-existing plaintext jellyseerr_api_key in config moves to a secret."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
jellyfin = store.upsert_service(
{
"service_type": "jellyfin",
"name": "Main Jellyfin",
"config": {
"base_url": "https://jellyfin.example.com",
"jellyseerr_url": "https://jellyseerr.example.com",
"jellyseerr_api_key": "plaintext-key", # legacy plaintext in config
},
"enabled": True,
},
secret_values={"api_key": "jf-key"},
)
store.ensure_defaults() # runs the config->secret migration
migrated = store.get_service(jellyfin["id"])
assert "jellyseerr_api_key" not in migrated["config"]
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
# Idempotent: a second run keeps it in secrets, doesn't wipe it.
store.ensure_defaults()
migrated = store.get_service(jellyfin["id"])
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):