`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.
497 lines
15 KiB
Nix
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;
|
|
};
|
|
}
|