#!/usr/bin/env bats # Seam: scripts/jellyfin-providers.sh — the only mechanism that puts a # metadata-provider key into Jellyfin's plugin configuration on Dankserver. # # Output vocabulary the tests depend on: # "+ " something this run will change # "= " something already true, so nothing changes # # The contracts, in the order the seam tests them: # 1. plan reports every action and never prints a key; # 2. a secret file that is not mode 600 is refused by name and nothing is # written — a key readable by other users is an error, not a warning; # 3. a downloaded plugin artifact is accepted only on its pinned sha256, its # official guid and its pinned version (a swapped or downgraded artifact # is refused before anything lands in plugins/); # 4. the TMDb key is written into the EXISTING config with every other # setting preserved — Jellyfin owns that file, and clobbering it would # silently reset country, language and adult settings; # 5. a TVDB config the operator already saved through the UI is kept; # 6. install is idempotent: a second run changes no byte; # 7. whatever is replaced is copied to the trash first (fleet law: never # overwrite without a recoverable copy); # 8. verify answers whether the endpoint is configured, key LENGTH only; # 9. no plugin configuration XML is tracked in git, the deployer places the # keys BEFORE it restarts Jellyfin, and the planner routes a change to this # installer to jellyfin. load helpers PROV="$REPO_ROOT/scripts/jellyfin-providers.sh" TMDB_KEY="tmdbk3a9Zq7Rtestkey" TVDB_KEY="tvdbk8b2Wt4Ptestkey" GUID_OFFICIAL="a677c0da-fac5-4cde-941a-7134223f14c8" # make_plugin — a zip shaped like the official TheTVDB # artifact (meta.json + its two DLLs), so extraction is exercised for real. make_plugin() { python3 - "$1" "$2" "$3" <<'PYEOF' import json, sys, zipfile path, guid, version = sys.argv[1], sys.argv[2], sys.argv[3] meta = { "category": "MoviesAndShows", "guid": guid, "name": "TheTVDB", "owner": "jellyfin", "targetAbi": "12.0.0.0", "version": version, } with zipfile.ZipFile(path, "w") as z: z.writestr("meta.json", json.dumps(meta)) z.writestr("Jellyfin.Plugin.Tvdb.dll", "dll-bytes") z.writestr("Tvdb.Sdk.dll", "dll-bytes") PYEOF sha256sum "$1" | cut -d' ' -f1 } setup() { JF="$BATS_TEST_TMPDIR/jf" TRASH="$BATS_TEST_TMPDIR/trash" SEC="$BATS_TEST_TMPDIR/secrets" mkdir -p "$JF/plugins/configurations" "$TRASH" "$SEC" cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" < US en false false 25 true XML KEYS="$SEC/provider-keys" printf 'TMDB_API_KEY=%s\nTVDB_API_KEY=%s\n' "$TMDB_KEY" "$TVDB_KEY" >"$KEYS" chmod 600 "$KEYS" ZIP="$BATS_TEST_TMPDIR/thetvdb.zip" PIN_SHA="$(make_plugin "$ZIP" "$GUID_OFFICIAL" 24.0.0.0)" PROV_ENV=( JF_ROOT="$JF" PROV_KEYS="$KEYS" TRASH_DIR="$TRASH" TVDB_PLUGIN_URL="file://$ZIP" TVDB_PLUGIN_SHA256="$PIN_SHA" TVDB_PLUGIN_GUID="$GUID_OFFICIAL" TVDB_PLUGIN_VERSION=24.0.0.0 ) } prov() { env "${PROV_ENV[@]}" bash "$PROV" "$@"; } tree_fingerprint() { find "$JF" -type f | sort | xargs -r sha256sum | sha256sum; } @test "plan names the plugin and both config files, and prints no key" { run prov plan [ "$status" -eq 0 ] printf '%s\n' "${lines[@]}" | grep -q '^+ install thetvdb_24\.0\.0\.0' printf '%s\n' "${lines[@]}" | grep -q '^+ set TmdbApiKey' printf '%s\n' "${lines[@]}" | grep -q '^+ create Jellyfin\.Plugin\.Tvdb\.xml' # The whole point of running this over a shoulder: a key never appears. ! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY" ! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY" } @test "plan writes nothing" { before="$(tree_fingerprint)" run prov plan [ "$status" -eq 0 ] [ "$(tree_fingerprint)" = "$before" ] } @test "an already-configured endpoint reports no pending action" { prov install >/dev/null run prov plan [ "$status" -eq 0 ] ! printf '%s\n' "${lines[@]}" | grep -q '^+ ' printf '%s\n' "${lines[@]}" | grep -q '= thetvdb_24\.0\.0\.0 already installed' printf '%s\n' "${lines[@]}" | grep -q '= TmdbApiKey already set' printf '%s\n' "${lines[@]}" | grep -q '= Jellyfin\.Plugin\.Tvdb\.xml already has an ApiKey' } @test "a keys file that is not mode 600 is refused and nothing is written" { chmod 644 "$KEYS" before="$(tree_fingerprint)" run prov install [ "$status" -ne 0 ] printf '%s\n' "${lines[@]}" | grep -qF '600' [ "$(tree_fingerprint)" = "$before" ] } @test "a missing TVDB key is refused by name before anything is fetched" { printf 'TMDB_API_KEY=%s\n' "$TMDB_KEY" >"$KEYS" chmod 600 "$KEYS" run prov install [ "$status" -ne 0 ] printf '%s\n' "${lines[@]}" | grep -qF 'TVDB_API_KEY' [ ! -d "$JF/plugins/thetvdb_24.0.0.0" ] } @test "a root with no plugin directory is refused instead of fabricated" { # Creating plugins/ inside a wrong path would install a plugin Jellyfin # never loads, and would hide the operator's typo. run env "${PROV_ENV[@]}" JF_ROOT="$BATS_TEST_TMPDIR/nope" bash "$PROV" install [ "$status" -ne 0 ] [ ! -d "$BATS_TEST_TMPDIR/nope/plugins" ] } @test "an artifact whose sha256 is not the pinned one is refused" { run env "${PROV_ENV[@]}" TVDB_PLUGIN_SHA256="$(printf 'f%.0s' {1..64})" \ bash "$PROV" install [ "$status" -ne 0 ] printf '%s\n' "${lines[@]}" | grep -qiF 'sha256' [ ! -d "$JF/plugins/thetvdb_24.0.0.0" ] } @test "an artifact that is not the official TVDB plugin is refused" { # A matching sha proves only that the bytes are the bytes we fetched. EVIL="$BATS_TEST_TMPDIR/evil.zip" EVIL_SHA="$(make_plugin "$EVIL" "deadbeef-0000-0000-0000-000000000000" 24.0.0.0)" run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$EVIL" TVDB_PLUGIN_SHA256="$EVIL_SHA" \ bash "$PROV" install [ "$status" -ne 0 ] printf '%s\n' "${lines[@]}" | grep -qiF 'guid' [ ! -d "$JF/plugins/thetvdb_24.0.0.0" ] } @test "an artifact that is not the pinned version is refused" { OLD="$BATS_TEST_TMPDIR/old.zip" OLD_SHA="$(make_plugin "$OLD" "$GUID_OFFICIAL" 22.0.0.0)" run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$OLD" TVDB_PLUGIN_SHA256="$OLD_SHA" \ bash "$PROV" install [ "$status" -ne 0 ] printf '%s\n' "${lines[@]}" | grep -qiF 'version' [ ! -d "$JF/plugins/thetvdb_24.0.0.0" ] } @test "a different plugin version already in plugins/ is refused, not doubled" { # Two thetvdb_* directories means Jellyfin loads one plugin twice; the # operator removes the old one (into the trash) deliberately, not by an # installer quietly adding a second. prov install >/dev/null OLD="$BATS_TEST_TMPDIR/old.zip" OLD_SHA="$(make_plugin "$OLD" "$GUID_OFFICIAL" 22.0.0.0)" run env "${PROV_ENV[@]}" TVDB_PLUGIN_URL="file://$OLD" TVDB_PLUGIN_SHA256="$OLD_SHA" \ TVDB_PLUGIN_VERSION=22.0.0.0 bash "$PROV" install [ "$status" -ne 0 ] printf '%s\n' "${lines[@]}" | grep -qF 'thetvdb_24.0.0.0' [ ! -d "$JF/plugins/thetvdb_22.0.0.0" ] } @test "install places the plugin, keys TMDb, and preserves every other setting" { run prov install [ "$status" -eq 0 ] ! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY" ! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY" [ -f "$JF/plugins/thetvdb_24.0.0.0/meta.json" ] [ -f "$JF/plugins/thetvdb_24.0.0.0/Jellyfin.Plugin.Tvdb.dll" ] local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" # Jellyfin owns this file: the settings it wrote must survive the edit. for setting in MetadataCountryCode Language IncludeAdult MaxCastMembers AddGenresFromKeywords; do grep -qF "<$setting>" "$cfg" || { echo "lost $setting" return 1 } done grep -qF 'false' "$cfg" grep -qF "$TMDB_KEY" "$cfg" grep -qF "$TVDB_KEY" "$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" } @test "a TVDB config the operator saved through the UI is kept" { cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" < ui-saved-key true XML run prov install [ "$status" -eq 0 ] printf '%s\n' "${lines[@]}" | grep -qiF 'kept' local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" grep -qF 'ui-saved-key' "$cfg" grep -qF 'true' "$cfg" } @test "a pin in the TVDB secret lands in the plugin config" { # A subscriber-shaped key needs the account's PIN beside it; a licensed key # does not, so the element appears only when the secret carries one. printf 'TMDB_API_KEY=%s\nTVDB_API_KEY=%s\nTVDB_PIN=123456\n' "$TMDB_KEY" "$TVDB_KEY" >"$KEYS" chmod 600 "$KEYS" prov install >/dev/null grep -qF '123456' "$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" } @test "install is idempotent: a second run changes no byte" { prov install >/dev/null before="$(tree_fingerprint)" run prov install [ "$status" -eq 0 ] [ "$(tree_fingerprint)" = "$before" ] printf '%s\n' "${lines[@]}" | grep -qiF 'no change' # One plugin directory only: two versions in plugins/ means two loads. [ "$(find "$JF/plugins" -maxdepth 1 -type d -name 'thetvdb_*' | wc -l)" -eq 1 ] } @test "what install replaces is copied to the trash first" { prov install >/dev/null # The TMDb config existed before this tool touched it, so the pre-edit copy # must stay recoverable. [ -n "$(find "$TRASH" -name 'Jellyfin.Plugin.Tmdb.xml*' -print -quit)" ] grep -qF '' "$(find "$TRASH" -name 'Jellyfin.Plugin.Tmdb.xml*' | head -1)" } @test "verify refuses an unconfigured endpoint and names what is missing" { run prov verify [ "$status" -ne 0 ] printf '%s\n' "${lines[@]}" | grep -qiF 'thetvdb' printf '%s\n' "${lines[@]}" | grep -qiF 'TmdbApiKey' } @test "verify passes once the plugin and both keys are in place" { prov install >/dev/null run prov verify [ "$status" -eq 0 ] ! printf '%s\n' "${lines[@]}" | grep -qF "$TMDB_KEY" ! printf '%s\n' "${lines[@]}" | grep -qF "$TVDB_KEY" # Length, not bytes: the operator sees the key loaded without reading it. printf '%s\n' "${lines[@]}" | grep -qE 'TmdbApiKey .*[0-9]+ chars' } @test "no plugin configuration XML is tracked in this repo" { # These files hold provider keys and are owned by the running container; # tracking them would put credentials in git and guarantee drift. run git -C "$REPO_ROOT" ls-files [ "$status" -eq 0 ] ! printf '%s\n' "${lines[@]}" | grep -qF 'plugins/configurations' ! printf '%s\n' "${lines[@]}" | grep -qiF 'Plugin.Tvdb.xml' } @test "the planner routes this installer to jellyfin" { run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver \ --paths scripts/jellyfin-providers.sh [ "$status" -eq 0 ] [ "$output" = "jellyfin" ] } @test "the deployer places the keys before it restarts jellyfin" { # The plugin and both keys are read at start-up, so a restart that runs # ahead of the placement boots the old, keyless state. body="$(sed -n '/^deploy_jellyfin()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")" [ -n "$body" ] up="$(printf '%s\n' "$body" | grep -n 'ensure_provider_keys' | head -1 | cut -d: -f1)" restart="$(printf '%s\n' "$body" | grep -n 'systemctl --user restart jellyfin.service' | head -1 | cut -d: -f1)" [ "${up:-999}" -lt "${restart:-0}" ] }