selfhostblocks/test/services/jellyfin.nix
Dmitry 60dfb9d19b Extract Jellyfin test extraScript into commonExtraScript and prepend it in LDAP test
`commonTestScript.access.override` replaces the full extraScript, and in particular drops:

```
server.wait_until_succeeds("journalctl --since -1m --unit jellyfin --grep 'Startup complete'")
```

https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731 shows the symptoms:

* https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731#step:5:7163 logs `Login from client` (defined in 8871b9435b/test/common.nix (L166)) at timestamp `2026-06-14T08:34:06.7507323Z` (visible in the raw logs)
* https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731#step:5:7167 is the first request from Firefox at timestamp 2026-06-14T08:34:18.2868336Z, so the attempt to fill in the username starts around there, with a 30-second timeout
*  https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731#step:5:7577 logs `Main: Startup complete 0:01:00.1813498` at timestamp 2026-06-14T08:34:51.1325282Z, 45 seconds after test starts

Work around by explicitly prepending the full extraScript to the override.
2026-06-14 16:21:49 +02:00

497 lines
15 KiB
Nix

{ pkgs, shb, ... }:
let
port = 9096;
adminUser = "jellyfin2";
adminPassword = "admin";
commonExtraScript =
{ node, ... }:
''
server.wait_until_succeeds("journalctl --since -1m --unit jellyfin --grep 'Startup complete'")
headers = unline_with(" ", """
-H 'Content-Type: application/json'
-H 'Authorization: MediaBrowser Client="Android TV", Device="Nvidia Shield", DeviceId="ZQ9YQHHrUzk24vV", Version="0.15.3"'
""")
import time
with subtest("api login success"):
ok = False
for i in range(1, 5):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "${adminUser}", "Pw": "${adminPassword}"}""",
extra=headers)
if response['code'] == 200:
ok = True
break
time.sleep(5)
if not ok:
raise Exception(f"Expected success, got: {response['code']}")
with subtest("api login failure"):
response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName",
data="""{"Username": "${adminUser}", "Pw": "badpassword"}""",
extra=headers)
if response['code'] != 401:
raise Exception(f"Expected failure, got: {response['code']}")
'';
commonTestScript = shb.test.mkScripts {
hasSSL = { node, ... }: !(isNull node.config.shb.jellyfin.ssl);
waitForServices =
{ ... }:
[
"jellyfin.service"
"nginx.service"
];
waitForPorts =
{ node, ... }:
[
port
];
waitForUrls =
{ proto_fqdn, ... }:
[
"${proto_fqdn}/System/Info/Public"
{
url = "${proto_fqdn}/Users/AuthenticateByName";
status = 401;
}
];
extraScript = commonExtraScript;
};
basic =
{ config, ... }:
{
imports = [
shb.test.baseModule
../../modules/services/jellyfin.nix
];
# Jellyfin checks for minimum 2Gib on startup.
virtualisation.diskSize = 4096;
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
shb.jellyfin = {
enable = true;
inherit (config.test) subdomain domain;
inherit port;
admin = {
username = adminUser;
password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result;
};
debug = true;
};
shb.hardcodedsecret.jellyfinAdminPassword = {
request = config.shb.jellyfin.admin.password.request;
settings.content = adminPassword;
};
environment.systemPackages = [
pkgs.sqlite
];
};
clientLogin =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
browser = "firefox";
startUrl = "${config.test.proto}://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]ser";
loginButtonNameRegex = "Sign In";
testLoginWith = [
{
username = adminUser;
password = "badpassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
{
username = adminUser;
password = adminPassword;
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
];
};
};
https =
{ config, ... }:
{
shb.jellyfin = {
ssl = config.shb.certs.certs.selfsigned.n;
};
test = {
hasSSL = true;
};
};
ldap =
{ config, lib, ... }:
{
shb.jellyfin = {
ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
userGroup = "user_group";
adminGroup = "admin_group";
adminPassword.result = config.shb.hardcodedsecret.jellyfinLdapUserPassword.result;
};
};
# There's something weird happending here
# where this plugin disappears after a jellyfin restart.
# I don't know why this is the case.
# I tried using a real plugin here instead of a mock or just creating a meta.json file.
# But this didn't help.
shb.jellyfin.plugins = lib.mkBefore [
(shb.mkJellyfinPlugin (rec {
pname = "jellyfin-plugin-ldapauth";
version = "19";
url = "https://github.com/jellyfin/${pname}/releases/download/v${version}/ldap-authentication_${version}.0.0.0.zip";
hash = "sha256-NunkpdYjsxYT6a4RaDXLkgRn4scRw8GaWvyHGs9IdWo=";
}))
];
shb.hardcodedsecret.jellyfinLdapUserPassword = {
request = config.shb.jellyfin.ldap.adminPassword.request;
settings.content = "ldapUserPassword";
};
};
clientLoginLdap =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
startUrl = "${config.test.proto}://${config.test.fqdn}";
usernameFieldLabelRegex = "[Uu]ser";
loginButtonNameRegex = "Sign In";
testLoginWith = [
{
username = adminUser;
password = "badpassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
{
username = adminUser;
password = adminPassword;
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)"
];
}
];
};
};
sso =
{ config, ... }:
{
shb.jellyfin = {
ldap = {
userGroup = "user_group";
adminGroup = "admin_group";
};
sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.hardcodedsecret.jellyfinSSOPassword.result;
sharedSecretForAuthelia.result = config.shb.hardcodedsecret.jellyfinSSOPasswordAuthelia.result;
};
};
shb.hardcodedsecret.jellyfinSSOPassword = {
request = config.shb.jellyfin.sso.sharedSecret.request;
settings.content = "ssoPassword";
};
shb.hardcodedsecret.jellyfinSSOPasswordAuthelia = {
request = config.shb.jellyfin.sso.sharedSecretForAuthelia.request;
settings.content = "ssoPassword";
};
};
clientLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
virtualisation.memorySize = 4096;
test = {
subdomain = "j";
};
test.login = {
startUrl = "${config.test.proto}://${config.test.fqdn}";
beforeHook = ''
page.locator('text=Sign in with Authelia').click()
'';
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[Ss]ign [Ii]n";
loginSpawnsNewPage = true;
testLoginWith = [
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
"page.get_by_text(re.compile('[Aa]ccept')).click()"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
# For a reason I can't explain, redirection needs to happen manually.
# So for failing auth, we check we're back on the login page.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
"page.get_by_text(re.compile('[Aa]ccept')).click()"
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
# For a reason I can't explain, redirection needs to happen manually.
"page.goto('${config.test.proto}://${config.test.fqdn}/web/')"
# "expect(page).to_have_title(re.compile('Jellyfin'))"
"expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)"
"expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)"
];
}
];
};
};
jellyfinTest =
name:
{ nodes, testScript }:
shb.test.runNixOSTest {
name = "jellyfin_${name}";
interactive.sshBackdoor.enable = true;
interactive.nodes.server = {
environment.systemPackages = [
pkgs.sqlite
];
};
inherit nodes;
inherit testScript;
};
in
{
basic = jellyfinTest "basic" {
nodes.server = {
imports = [
basic
];
};
nodes.client = {
imports = [
clientLogin
];
};
testScript = commonTestScript.access;
};
backup = jellyfinTest "backup" {
nodes.server =
{ config, ... }:
{
imports = [
basic
(shb.test.backup config.shb.jellyfin.backup)
];
};
nodes.client = { };
testScript = commonTestScript.backup;
};
https = jellyfinTest "https" {
nodes.server = {
imports = [
basic
shb.test.certs
https
];
};
nodes.client =
{ config, lib, ... }:
{
imports = [
clientLogin
];
};
testScript = commonTestScript.access;
};
ldap = jellyfinTest "ldap" {
nodes.server = {
imports = [
basic
shb.test.certs
https
shb.test.ldap
ldap
];
};
nodes.client = {
imports = [
clientLoginLdap
];
};
testScript = commonTestScript.access.override {
extraScript =
args@{
node,
...
}:
(commonExtraScript args)
# I have no idea why the LDAP Authentication_19.0.0.0 plugin disappears.
+ ''
r = server.execute('cat "${node.config.services.jellyfin.dataDir}/plugins/LDAP Authentication_19.0.0.0/meta.json"')
if r[0] != 0:
print("meta.json for plugin LDAP Authentication_19.0.0.0 not found")
else:
c = json.loads(r[1])
if "status" in c and c["status"] != "Disabled":
raise Exception(f'meta.json status: expected Disabled, got: {c["status"]}')
'';
};
};
sso = jellyfinTest "sso" {
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
shb.test.certs
https
shb.test.ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
};
nodes.client = {
imports = [
clientLoginSso
];
};
testScript = commonTestScript.access;
};
}