Files
silo-server/internal/jellycompat/web_component_test.go
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00

773 lines
25 KiB
Go

package jellycompat
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
)
func TestWebComponentStatusMissing(t *testing.T) {
root := t.TempDir()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
"jellyfin_compat.web_version": "10.11.6",
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
status := WebComponentStatusForConfig(cfg, map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
"jellyfin_compat.web_version": "10.11.6",
})
if status.APIState != "enabled" {
t.Fatalf("APIState = %q, want enabled", status.APIState)
}
if status.WebState != WebComponentMissing {
t.Fatalf("WebState = %q, want %q", status.WebState, WebComponentMissing)
}
}
func TestWebComponentStatusInstalledWithProvenance(t *testing.T) {
root := t.TempDir()
release := filepath.Join(root, "10.11.6")
if err := os.MkdirAll(release, 0o755); err != nil {
t.Fatalf("mkdir release: %v", err)
}
if err := os.WriteFile(filepath.Join(release, "index.html"), []byte("<!doctype html>"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
if err := os.WriteFile(filepath.Join(release, "LICENSE"), []byte("GPL-2.0"), 0o644); err != nil {
t.Fatalf("write license: %v", err)
}
metadata := WebComponentMetadata{
Component: "jellyfin-web",
SourceURL: DefaultWebSourceURL,
Version: "10.11.6",
Tag: "v10.11.6",
CommitSHA: "abc123",
Checksum: "sha256:test",
License: "GPL-2.0",
}
if err := writeWebMetadata(release, metadata); err != nil {
t.Fatalf("write metadata: %v", err)
}
if err := writeWebSourceFile(release, metadata); err != nil {
t.Fatalf("write source file: %v", err)
}
if err := os.Symlink("10.11.6", filepath.Join(root, "current")); err != nil {
t.Fatalf("symlink current: %v", err)
}
status := webComponentStatus(root, filepath.Join(root, "current"), "10.11.6", DefaultWebSourceURL)
if status.WebState != WebComponentInstalled {
t.Fatalf("WebState = %q, want %q", status.WebState, WebComponentInstalled)
}
if !status.LicensePresent || !status.ProvenancePresent {
t.Fatalf("license/provenance = %t/%t, want true/true", status.LicensePresent, status.ProvenancePresent)
}
if status.CommitSHA != "abc123" {
t.Fatalf("CommitSHA = %q, want abc123", status.CommitSHA)
}
}
func TestWebComponentStatusUpdateAvailable(t *testing.T) {
root := t.TempDir()
release := filepath.Join(root, "10.11.6")
writeValidWebRelease(t, release, "10.11.6")
if err := os.Symlink("10.11.6", filepath.Join(root, "current")); err != nil {
t.Fatalf("symlink current: %v", err)
}
status := webComponentStatus(root, filepath.Join(root, "current"), "10.11.7", DefaultWebSourceURL)
if status.WebState != WebComponentUpdateAvailable {
t.Fatalf("WebState = %q, want %q", status.WebState, WebComponentUpdateAvailable)
}
}
func TestWebComponentStatusUsesPersistedSettingsForDisplay(t *testing.T) {
root := t.TempDir()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "false",
"jellyfin_compat.listen": ":8096",
"jellyfin_compat.public_url": "http://127.0.0.1:8096",
"jellyfin_compat.server_name": "Silo",
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
status := WebComponentStatusForConfig(cfg, map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.listen": ":19096",
"jellyfin_compat.public_url": "https://compat.example.test",
"jellyfin_compat.server_name": "Silo Compat",
"jellyfin_compat.emulated_server_version": "10.11.6",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if !status.Enabled || status.APIState != "enabled" {
t.Fatalf("enabled/APIState = %t/%q, want true/enabled", status.Enabled, status.APIState)
}
if status.Listen != ":19096" {
t.Fatalf("Listen = %q, want :19096", status.Listen)
}
if status.PublicURL != "https://compat.example.test" {
t.Fatalf("PublicURL = %q, want persisted public URL", status.PublicURL)
}
if status.ServerName != "Silo Compat" {
t.Fatalf("ServerName = %q, want persisted server name", status.ServerName)
}
if !status.RestartRequired {
t.Fatal("RestartRequired = false, want true when persisted settings differ from running config")
}
}
func TestWebComponentStatusDoesNotRequireRestartForLiveIdentitySettings(t *testing.T) {
root := t.TempDir()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.listen": ":8096",
"jellyfin_compat.public_url": "http://127.0.0.1:8096",
"jellyfin_compat.server_name": "Silo",
"jellyfin_compat.emulated_server_version": "10.11.0",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
status := WebComponentStatusForConfig(cfg, map[string]string{
"jellyfin_compat.public_url": "https://compat.example.test",
"jellyfin_compat.server_name": "Silo Compat",
"jellyfin_compat.emulated_server_version": "10.11.6",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if status.PublicURL != "https://compat.example.test" {
t.Fatalf("PublicURL = %q, want persisted public URL", status.PublicURL)
}
if status.ServerName != "Silo Compat" {
t.Fatalf("ServerName = %q, want persisted server name", status.ServerName)
}
if status.EmulatedVersion != "10.11.6" {
t.Fatalf("EmulatedVersion = %q, want persisted emulated version", status.EmulatedVersion)
}
if status.RestartRequired {
t.Fatal("RestartRequired = true, want false for live identity settings")
}
}
func TestWebComponentStatusDoesNotDefaultPinnedVersionToEmulatedVersion(t *testing.T) {
root := t.TempDir()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.emulated_server_version": "10.12.0",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
status := WebComponentStatusForConfig(cfg, map[string]string{
"jellyfin_compat.emulated_server_version": "10.12.0",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if status.PinnedVersion != config.DefaultJellyfinWebVersion {
t.Fatalf("PinnedVersion = %q, want configured Web default", status.PinnedVersion)
}
}
func TestWebComponentStatusDisablesWebUIWhenProxyDisabled(t *testing.T) {
root := t.TempDir()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "false",
"jellyfin_compat.web_enabled": "true",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
status := WebComponentStatusForConfig(cfg, map[string]string{
"jellyfin_compat.enabled": "false",
"jellyfin_compat.web_enabled": "true",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if status.WebEnabled {
t.Fatal("WebEnabled = true, want false when Jellyfin proxy is disabled")
}
}
func TestWebComponentStatusReportsWebEnabledSetting(t *testing.T) {
root := t.TempDir()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
status := WebComponentStatusForConfig(cfg, map[string]string{
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
"jellyfin_compat.web_enabled": "false",
})
if status.WebEnabled {
t.Fatal("WebEnabled = true, want false from persisted setting")
}
if status.RestartRequired {
t.Fatal("RestartRequired = true, want false when only persisted web_enabled differs from running config")
}
}
func TestSelectCompatibleWebVersion(t *testing.T) {
tests := []struct {
name string
api string
available []string
want string
}{
{
name: "latest patch from same emulated minor",
api: "10.12.0",
available: []string{"10.12.0", "10.12.2", "10.12.1", "10.11.9"},
want: "10.12.2",
},
{
name: "newest lower minor when emulated minor is unavailable",
api: "10.12.0",
available: []string{"10.10.9", "10.11.6", "10.11.8", "10.13.0"},
want: "10.11.8",
},
{
name: "ignores prerelease tags",
api: "10.11.0",
available: []string{"10.11.7-alpha", "10.11.6", "10.10.10"},
want: "10.11.6",
},
{
name: "uses oldest available when all versions are newer",
api: "9.9.0",
available: []string{"10.10.1", "10.9.9", "10.11.0"},
want: "10.9.9",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SelectCompatibleWebVersion(tt.api, tt.available)
if err != nil {
t.Fatalf("SelectCompatibleWebVersion: %v", err)
}
if got != tt.want {
t.Fatalf("SelectCompatibleWebVersion() = %q, want %q", got, tt.want)
}
})
}
}
func TestSelectCompatibleWebVersionRejectsInvalidInputs(t *testing.T) {
if _, err := SelectCompatibleWebVersion("not-a-version", []string{"10.11.6"}); err == nil {
t.Fatal("SelectCompatibleWebVersion returned nil error for invalid API version")
}
if _, err := SelectCompatibleWebVersion("10.12.0", []string{"main", "10.11.6-alpha"}); err == nil {
t.Fatal("SelectCompatibleWebVersion returned nil error with no stable Web versions")
}
}
func TestParseRemoteWebReleaseVersions(t *testing.T) {
versions, err := parseRemoteWebReleaseVersions(strings.NewReader(`[
{"tag_name":"v10.11.6","draft":false,"prerelease":false},
{"tag_name":"10.12.0","draft":true,"prerelease":false},
{"tag_name":"10.12.1","draft":false,"prerelease":true},
{"tag_name":"not-a-version","draft":false,"prerelease":false}
]`))
if err != nil {
t.Fatalf("parseRemoteWebReleaseVersions: %v", err)
}
want := []string{"10.11.6"}
if len(versions) != len(want) {
t.Fatalf("versions = %#v, want %#v", versions, want)
}
for i := range want {
if versions[i] != want[i] {
t.Fatalf("versions[%d] = %q, want %q", i, versions[i], want[i])
}
}
}
func TestInstallWebComponentRejectsUnsafeVersion(t *testing.T) {
root := t.TempDir()
_, err := InstallWebComponent(context.Background(), WebComponentInstallOptions{
InstallRoot: root,
Version: "10.11.6;touch-bad",
RunCommand: func(context.Context, string, []string, string) error {
t.Fatal("RunCommand should not be called for an invalid version")
return nil
},
})
if err == nil {
t.Fatal("InstallWebComponent returned nil error for invalid version")
}
}
func TestInstallWebComponentRejectsUnofficialSource(t *testing.T) {
root := t.TempDir()
_, err := InstallWebComponent(context.Background(), WebComponentInstallOptions{
InstallRoot: root,
Version: "10.11.6",
SourceURL: "https://example.test/jellyfin-web.git",
RunCommand: func(context.Context, string, []string, string) error {
t.Fatal("RunCommand should not be called for an invalid source URL")
return nil
},
})
if err == nil {
t.Fatal("InstallWebComponent returned nil error for unofficial source URL")
}
}
func TestRemoveWebComponentOnlyRemovesGeneratedAssets(t *testing.T) {
root := t.TempDir()
release := filepath.Join(root, "10.11.6")
if err := os.MkdirAll(release, 0o755); err != nil {
t.Fatalf("mkdir release: %v", err)
}
if err := os.WriteFile(filepath.Join(release, "index.html"), []byte("<!doctype html>"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
if err := os.WriteFile(filepath.Join(release, "LICENSE"), []byte("GPL-2.0"), 0o644); err != nil {
t.Fatalf("write license: %v", err)
}
metadata := WebComponentMetadata{
Component: "jellyfin-web",
SourceURL: DefaultWebSourceURL,
Version: "10.11.6",
Tag: "v10.11.6",
License: "GPL-2.0",
}
if err := writeWebMetadata(release, metadata); err != nil {
t.Fatalf("write metadata: %v", err)
}
if err := writeWebSourceFile(release, metadata); err != nil {
t.Fatalf("write source file: %v", err)
}
if err := os.Symlink("10.11.6", filepath.Join(root, "current")); err != nil {
t.Fatalf("symlink current: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("keep"), 0o644); err != nil {
t.Fatalf("write unrelated file: %v", err)
}
if err := RemoveWebComponent(root); err != nil {
t.Fatalf("RemoveWebComponent: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "keep.txt")); err != nil {
t.Fatalf("unrelated file was removed: %v", err)
}
if _, err := os.Stat(release); !os.IsNotExist(err) {
t.Fatalf("release dir still exists or stat failed unexpectedly: %v", err)
}
if _, err := os.Lstat(filepath.Join(root, "current")); !os.IsNotExist(err) {
t.Fatalf("current link still exists or stat failed unexpectedly: %v", err)
}
}
func TestStartWebComponentRemovePublishesProgress(t *testing.T) {
root := t.TempDir()
release := filepath.Join(root, "10.11.6")
writeValidWebRelease(t, release, "10.11.6")
if err := os.Symlink("10.11.6", filepath.Join(root, "current")); err != nil {
t.Fatalf("symlink current: %v", err)
}
progress := make(chan WebComponentOperationStatus, 4)
status, err := StartWebComponentRemove(WebComponentRemoveOptions{
InstallRoot: root,
OnProgress: func(op WebComponentOperationStatus) {
progress <- op
},
})
if err != nil {
t.Fatalf("StartWebComponentRemove: %v", err)
}
if status.WebState != WebComponentRemoving {
t.Fatalf("WebState = %q, want removing", status.WebState)
}
if status.Operation == nil || status.Operation.Kind != WebComponentOperationRemove {
t.Fatalf("Operation = %+v, want remove operation", status.Operation)
}
seenRunning := false
timeout := time.After(2 * time.Second)
for {
select {
case op := <-progress:
if op.Kind != WebComponentOperationRemove {
t.Fatalf("operation kind = %q, want remove", op.Kind)
}
if op.State == WebComponentOperationRunning {
seenRunning = true
continue
}
if op.State != WebComponentOperationSucceeded {
t.Fatalf("terminal state = %q, want succeeded: %s", op.State, op.Error)
}
if !seenRunning {
t.Fatal("did not receive a running progress update before terminal completion")
}
if op.ProgressPercent != 100 {
t.Fatalf("terminal progress = %d, want 100", op.ProgressPercent)
}
if op.Message != "Jellyfin Web assets removed" {
t.Fatalf("terminal message = %q, want Jellyfin Web assets removed", op.Message)
}
if _, err := os.Stat(release); !os.IsNotExist(err) {
t.Fatalf("release dir still exists or stat failed unexpectedly: %v", err)
}
if _, err := os.Lstat(filepath.Join(root, "current")); !os.IsNotExist(err) {
t.Fatalf("current link still exists or stat failed unexpectedly: %v", err)
}
return
case <-timeout:
t.Fatal("timed out waiting for remove operation completion progress")
}
}
}
func TestWebComponentStatusRecoversLegacyStaleOperationLock(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, webInstallLock), []byte("installing"), 0o644); err != nil {
t.Fatalf("write legacy lock: %v", err)
}
old := time.Now().Add(-(webMalformedLockGrace + time.Second))
if err := os.Chtimes(filepath.Join(root, webInstallLock), old, old); err != nil {
t.Fatalf("age legacy lock: %v", err)
}
status := webComponentStatus(root, filepath.Join(root, "current"), "10.11.6", DefaultWebSourceURL)
if status.WebState == WebComponentInstalling {
t.Fatalf("WebState = %q, want non-installing state after stale lock recovery", status.WebState)
}
if status.Operation != nil {
t.Fatalf("Operation = %+v, want nil after stale lock recovery", status.Operation)
}
if _, err := os.Stat(filepath.Join(root, webInstallLock)); !os.IsNotExist(err) {
t.Fatalf("legacy lock still exists or stat failed unexpectedly: %v", err)
}
if !strings.Contains(status.LastError, "recovered stale Jellyfin Web") {
t.Fatalf("LastError = %q, want recovered stale lock message", status.LastError)
}
}
func TestWebComponentOperationProgressPersistsToStatus(t *testing.T) {
root := t.TempDir()
op, err := beginWebOperation(root, WebComponentOperationInstall)
if err != nil {
t.Fatalf("beginWebOperation: %v", err)
}
t.Cleanup(func() {
webOperationsMu.Lock()
delete(webOperations, root)
webOperationsMu.Unlock()
clearWebInstallState(root)
})
if op.Phase != WebComponentOperationPreparing || op.ProgressPercent != 1 {
t.Fatalf("initial progress = %q/%d, want preparing/1", op.Phase, op.ProgressPercent)
}
updated := updateWebOperationProgress(root, op.ID, WebComponentOperationBuilding, 110, "Building Jellyfin Web assets")
if updated == nil {
t.Fatal("updateWebOperationProgress returned nil")
}
if updated.Phase != WebComponentOperationBuilding || updated.ProgressPercent != 100 {
t.Fatalf("updated progress = %q/%d, want building/100", updated.Phase, updated.ProgressPercent)
}
if updated.Message != "Building Jellyfin Web assets" {
t.Fatalf("updated message = %q", updated.Message)
}
status := webComponentStatus(root, filepath.Join(root, "current"), "10.11.6", DefaultWebSourceURL)
if status.Operation == nil {
t.Fatal("status.Operation = nil, want progress operation")
}
if status.Operation.Phase != WebComponentOperationBuilding || status.Operation.ProgressPercent != 100 {
t.Fatalf("status progress = %q/%d, want building/100", status.Operation.Phase, status.Operation.ProgressPercent)
}
finished := finishWebOperation(root, op.ID, nil)
if finished == nil {
t.Fatal("finishWebOperation returned nil")
}
if finished.State != WebComponentOperationSucceeded || finished.ProgressPercent != 100 {
t.Fatalf("finished state/progress = %q/%d, want succeeded/100", finished.State, finished.ProgressPercent)
}
if finished.Message != "Jellyfin Web install complete" {
t.Fatalf("finished message = %q", finished.Message)
}
}
func TestBeginWebOperationRejectsFreshMalformedLock(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := os.WriteFile(filepath.Join(root, webInstallLock), []byte("installing"), 0o644); err != nil {
t.Fatalf("write malformed lock: %v", err)
}
defer clearWebInstallState(root)
_, err := beginWebOperation(root, WebComponentOperationInstall)
if !errors.Is(err, ErrWebComponentOperationActive) {
t.Fatalf("beginWebOperation error = %v, want ErrWebComponentOperationActive", err)
}
}
func TestBeginWebOperationRecoversDeadProcessLock(t *testing.T) {
root := t.TempDir()
host, _ := os.Hostname()
stale := WebComponentOperationStatus{
ID: "install-dead",
Kind: WebComponentOperationInstall,
State: WebComponentOperationRunning,
PID: 999999,
Process: "dead-process-token",
Host: host,
StartedAt: "2026-06-07T00:00:00Z",
}
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := writeWebOperationLock(root, stale); err != nil {
t.Fatalf("write stale lock: %v", err)
}
op, err := beginWebOperation(root, WebComponentOperationInstall)
if err != nil {
t.Fatalf("beginWebOperation: %v", err)
}
defer finishWebOperation(root, op.ID, nil)
if op.ID == stale.ID {
t.Fatalf("operation ID was not replaced: %q", op.ID)
}
if op.PID != os.Getpid() || op.Process == "" {
t.Fatalf("operation process identity = pid %d token %q, want current process", op.PID, op.Process)
}
}
func TestBeginWebOperationRecoversDeadProcessLockFromDifferentHost(t *testing.T) {
root := t.TempDir()
stale := WebComponentOperationStatus{
ID: "install-dead-host",
Kind: WebComponentOperationInstall,
State: WebComponentOperationRunning,
PID: 999999,
Process: "dead-process-token",
Host: "previous-container",
StartedAt: "2026-06-07T00:00:00Z",
}
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := writeWebOperationLock(root, stale); err != nil {
t.Fatalf("write stale lock: %v", err)
}
op, err := beginWebOperation(root, WebComponentOperationRemove)
if err != nil {
t.Fatalf("beginWebOperation: %v", err)
}
defer finishWebOperation(root, op.ID, nil)
if op.ID == stale.ID {
t.Fatalf("operation ID was not replaced: %q", op.ID)
}
}
func TestBeginWebOperationRejectsLiveProcessLock(t *testing.T) {
root := t.TempDir()
host, _ := os.Hostname()
live := WebComponentOperationStatus{
ID: "install-live",
Kind: WebComponentOperationInstall,
State: WebComponentOperationRunning,
PID: os.Getpid(),
Process: currentProcessToken(),
Host: host,
StartedAt: "2026-06-07T00:00:00Z",
}
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := writeWebOperationLock(root, live); err != nil {
t.Fatalf("write live lock: %v", err)
}
defer clearWebInstallState(root)
_, err := beginWebOperation(root, WebComponentOperationRemove)
if !errors.Is(err, ErrWebComponentOperationActive) {
t.Fatalf("beginWebOperation error = %v, want ErrWebComponentOperationActive", err)
}
}
func TestFinishWebOperationDoesNotClearDifferentLock(t *testing.T) {
root := t.TempDir()
first, err := beginWebOperation(root, WebComponentOperationInstall)
if err != nil {
t.Fatalf("begin first operation: %v", err)
}
second := WebComponentOperationStatus{
ID: "remove-new",
Kind: WebComponentOperationRemove,
State: WebComponentOperationRunning,
PID: os.Getpid(),
Process: currentProcessToken(),
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := writeWebOperationLock(root, second); err != nil {
t.Fatalf("replace lock: %v", err)
}
finishWebOperation(root, first.ID, nil)
current := readWebOperationState(root)
if current == nil || current.ID != second.ID {
t.Fatalf("current lock = %+v, want second operation lock", current)
}
clearWebInstallState(root)
}
func TestResolveCompatWebFSHonorsWebEnabledSetting(t *testing.T) {
root := t.TempDir()
release := filepath.Join(root, "10.11.6")
writeValidWebRelease(t, release, "10.11.6")
if err := os.Symlink("10.11.6", filepath.Join(root, "current")); err != nil {
t.Fatalf("symlink current: %v", err)
}
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_install_dir": root,
"jellyfin_compat.web_dir": filepath.Join(root, "current"),
"jellyfin_compat.web_version": "10.11.6",
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
webFS, _, err := resolveCompatWebFS(context.Background(), Dependencies{Config: cfg})
if err != nil {
t.Fatalf("resolve enabled WebFS: %v", err)
}
if webFS == nil {
t.Fatal("resolve enabled WebFS = nil, want installed assets")
}
webFS, _, err = resolveCompatWebFS(context.Background(), Dependencies{
Config: cfg,
SettingsRepo: webComponentTestSettings{
"jellyfin_compat.web_enabled": "false",
},
})
if err != nil {
t.Fatalf("resolve disabled WebFS: %v", err)
}
if webFS != nil {
t.Fatal("resolve disabled WebFS returned assets, want nil")
}
webFS, _, err = resolveCompatWebFS(context.Background(), Dependencies{
Config: cfg,
SettingsRepo: webComponentTestSettings{
"jellyfin_compat.enabled": "false",
"jellyfin_compat.web_enabled": "true",
},
})
if err != nil {
t.Fatalf("resolve proxy-disabled WebFS: %v", err)
}
if webFS != nil {
t.Fatal("resolve proxy-disabled WebFS returned assets, want nil")
}
if _, err := os.Stat(release); err != nil {
t.Fatalf("disabled Web UI should not remove release: %v", err)
}
}
func TestResolveCompatWebFSDoesNotFallbackToVendoredDirectory(t *testing.T) {
cfg := &config.Config{}
webFS, _, err := resolveCompatWebFS(context.Background(), Dependencies{Config: cfg})
if webFS != nil {
t.Fatalf("resolveCompatWebFS returned a web filesystem without configured assets (err=%v)", err)
}
}
type webComponentTestSettings map[string]string
func (s webComponentTestSettings) Get(_ context.Context, key string) (string, error) {
return s[key], nil
}
func writeWebOperationLock(root string, op WebComponentOperationStatus) error {
data, err := json.Marshal(op)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, webInstallLock), data, 0o644)
}
func writeValidWebRelease(t *testing.T, release, version string) {
t.Helper()
if err := os.MkdirAll(release, 0o755); err != nil {
t.Fatalf("mkdir release: %v", err)
}
if err := os.WriteFile(filepath.Join(release, "index.html"), []byte("<!doctype html>"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
if err := os.WriteFile(filepath.Join(release, "LICENSE"), []byte("GPL-2.0"), 0o644); err != nil {
t.Fatalf("write license: %v", err)
}
metadata := WebComponentMetadata{
Component: "jellyfin-web",
SourceURL: DefaultWebSourceURL,
Version: version,
Tag: "v" + version,
CommitSHA: "abc123",
Checksum: "sha256:test",
License: "GPL-2.0",
}
if err := writeWebMetadata(release, metadata); err != nil {
t.Fatalf("write metadata: %v", err)
}
if err := writeWebSourceFile(release, metadata); err != nil {
t.Fatalf("write source file: %v", err)
}
}