Files
serverAppConfigs/tests/jellyfin_providers.bats
T
Frank e9314ea1ee fix(jellyfin): anchor the provider-key write on the file's real root
TheTVDB writes its own configuration file and its serializer omits a
null-valued setting entirely, so a fresh install has no <ApiKey> element to
fill, under a root element called <PluginConfiguration>. xml_secret's
fallback insertion was hardcoded to </Configuration>, matched nothing, and
install refused with "no place to write <ApiKey>" — measured on Dankserver
2026-11-02, where the TVDB key has been present in the secret file since
October and has never been installable.

The anchor now comes from the document's own root, so both shapes Jellyfin
actually produces work (the core plugins' <Configuration> and a plugin's
<PluginConfiguration>), and the refusal is kept for a file with no root at
all. Tests: the plugin-written shape is red before this commit and green
after; every other setting in the file is asserted preserved.
2026-11-02 04:38:40 -08:00

425 lines
17 KiB
Bash
Executable File

#!/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:
# "+ <action>" something this run will change
# "= <fact>" 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. a key is written into the EXISTING config with every other setting
# preserved, under whatever root that file's owner chose — Jellyfin's core
# configs say `<Configuration>`, TheTVDB writes `<PluginConfiguration>` and
# omits a null setting outright, so the element to fill can be absent and
# the anchor has to come from the file; clobbering the file 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.
# 10. verify refuses a library that is a SILENT FILE INDEX — internet providers
# switched off, or a type whose ImageFetchers is empty while its
# MetadataFetchers list is populated (the 2026-11-01 missing-posters fault).
load helpers
PROV="$REPO_ROOT/scripts/jellyfin-providers.sh"
TMDB_KEY="tmdbk3a9Zq7Rtestkey"
TVDB_KEY="tvdbk8b2Wt4Ptestkey"
GUID_OFFICIAL="a677c0da-fac5-4cde-941a-7134223f14c8"
# make_plugin <file> <guid> <version> — 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"
# The per-library option file Jellyfin 12 owns (it was library.xml). Conforming
# by default so the pre-existing verify tests keep testing what they tested.
mkdir -p "$JF/root/default/Movies"
cat >"$JF/root/default/Movies/options.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<LibraryOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<EnableInternetProviders>true</EnableInternetProviders>
<MetadataSavers>
<string>Nfo</string>
</MetadataSavers>
<TypeOptions>
<TypeOptions>
<Type>Movie</Type>
<MetadataFetchers>
<string>TheMovieDb</string>
</MetadataFetchers>
<ImageFetchers>
<string>TheMovieDb</string>
</ImageFetchers>
<ImageFetcherOrder>
<string>TheMovieDb</string>
</ImageFetcherOrder>
</TypeOptions>
</TypeOptions>
</LibraryOptions>
XML
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tmdb.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<MetadataCountryCode>US</MetadataCountryCode>
<PreferOriginalLanguage />
<Language>en</Language>
<EnableCinematicForTrailers>false</EnableCinematicForTrailers>
<IncludeAdult>false</IncludeAdult>
<MaxCastMembers>25</MaxCastMembers>
<AddGenresFromKeywords>true</AddGenresFromKeywords>
<TmdbApiKey />
</Configuration>
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 '<IncludeAdult>false</IncludeAdult>' "$cfg"
grep -qF "<TmdbApiKey>$TMDB_KEY</TmdbApiKey>" "$cfg"
grep -qF "<ApiKey>$TVDB_KEY</ApiKey>" "$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" <<XML
<?xml version="1.0" encoding="utf-8"?>
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ApiKey>ui-saved-key</ApiKey>
<EnableSeasonPathMatching>true</EnableSeasonPathMatching>
</Configuration>
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 '<ApiKey>ui-saved-key</ApiKey>' "$cfg"
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
}
@test "the key lands in a plugin-written config with no ApiKey element" {
# What TheTVDB actually writes on a fresh install: its own root element and
# no <ApiKey /> at all, because its serializer drops null settings. The
# installer used to anchor on </Configuration> and refuse this file.
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<SubscriberPIN />
<CacheDurationInHours>1</CacheDurationInHours>
<OriginalTitle>true</OriginalTitle>
</PluginConfiguration>
XML
run prov install
[ "$status" -eq 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'filled the ApiKey'
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
grep -qF "<ApiKey>$TVDB_KEY</ApiKey>" "$cfg"
grep -qF '<CacheDurationInHours>1</CacheDurationInHours>' "$cfg"
grep -qF '<OriginalTitle>true</OriginalTitle>' "$cfg"
}
@test "a config file with no root element is refused by name" {
# The guard still has to fire when there is genuinely nowhere to write —
# a refusal naming the file beats inventing a document shape.
printf '<?xml version="1.0" encoding="utf-8"?>\n' \
>"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
run prov install
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qF 'Jellyfin.Plugin.Tvdb.xml'
printf '%s\n' "${lines[@]}" | grep -qiF 'no place to write'
}
@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 '<Pin>123456</Pin>' "$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 '<TmdbApiKey />' "$(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 "verify refuses a library whose internet providers are switched off" {
# The whole library becomes a file index: refreshes answer 204 and store
# nothing, and the item count still looks like a healthy scan.
prov install >/dev/null
sed -i 's#<EnableInternetProviders>true</EnableInternetProviders>#<EnableInternetProviders>false</EnableInternetProviders>#' \
"$JF/root/default/Movies/options.xml"
run prov verify
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'SILENT INDEX Movies'
printf '%s\n' "${lines[@]}" | grep -qiF 'EnableInternetProviders'
}
@test "verify refuses a type with no image fetchers while metadata fetchers are named" {
prov install >/dev/null
cat >"$JF/root/default/Movies/options.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<LibraryOptions>
<EnableInternetProviders>true</EnableInternetProviders>
<TypeOptions>
<TypeOptions>
<Type>Movie</Type>
<MetadataFetchers>
<string>TheMovieDb</string>
</MetadataFetchers>
<ImageFetchers />
<ImageFetcherOrder>
<string>TheMovieDb</string>
</ImageFetcherOrder>
</TypeOptions>
</TypeOptions>
</LibraryOptions>
XML
run prov verify
[ "$status" -ne 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF 'ImageFetchers empty'
}
@test "an older options.xml that never carried the switch is not refused" {
# BoxSets on this host were written before the property existed; absence is
# the vendor default, not a switched-off library, so it must not fail.
prov install >/dev/null
mkdir -p "$JF/root/default/Collections"
cat >"$JF/root/default/Collections/options.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<LibraryOptions>
<TypeOptions>
<TypeOptions>
<Type>BoxSet</Type>
<MetadataFetchers>
<string>TheMovieDb</string>
</MetadataFetchers>
<ImageFetchers>
<string>TheMovieDb</string>
</ImageFetchers>
</TypeOptions>
</TypeOptions>
</LibraryOptions>
XML
run prov verify
[ "$status" -eq 0 ]
printf '%s\n' "${lines[@]}" | grep -qiF '= Collections'
}
@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}" ]
}