* feat(web): add Connect Apps settings page for compat sign-in Jellyfin-protocol clients offer one username box and one password box and never prompt for a profile, so signing in requires `account#Profile` and `password#PIN`. Nothing in the product taught that syntax, and every way of getting it wrong surfaces as the same "invalid username or password", so it became a recurring support burden. Add Settings -> Connect Apps, which states the credentials for the signed-in account rather than describing them in the abstract. The page is segmented by app type: the two formats are never shown at once, each side names the apps it covers, and the compat side is visually distinct so it cannot be mistaken for the normal Silo login. The compat listener's separate address is shown too, since pointing a client at the Silo address fails identically to a bad password. Backend adds GET /api/v1/compat/connect-info, an account-scoped read of the compat listener's enabled flag, public URL, and server name. The admin status endpoint already covers this ground for operators, but it also reports install paths and version provenance, so it stays admin-only; this returns only what a client learns by connecting anyway. It is auth-only and deliberately not profile-scoped, since it describes how to sign in. ConnectInfoForConfig shares the enabled-flag precedence with WebComponentStatusForConfig via compatEnabled, so the two endpoints cannot disagree about whether compat is on. The page declines to display a username it knows cannot work: profile names permit `#` but the resolver splits at the last one, so `alice#Movie #2` parses as account "alice#Movie". Such profiles get an explanation and no copy button instead of a string that fails to authenticate. Part of #432 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(web): harden Connect Apps against misleading sign-in states Review of the first pass found six ways the page could state something untrue. Each one matters more than usual here, because the page exists specifically to stop people guessing at credentials. Report the running listener, not the stored setting. jellyfin_compat.enabled is restart-required and cmd/silo builds the compat server from the boot config alone, so the stored value describes intent. Reporting it promised credentials for a listener that does not exist yet, or claimed the API was off while the running one kept serving. ConnectInfo now returns the boot-time state plus a pending_restart flag, and the page distinguishes "not running yet" from "turned off". The admin status endpoint keeps reporting configured intent, so the two intentionally diverge until a restart. Stop presenting fetch failures as a disabled compat API. React Query clears isLoading on error, so a failed request rendered "the compatibility API is turned off" and sent users to an admin about a setting that was fine. A failed profile list was worse: it fell through to an empty list and offered the bare account name, which silently drops the profile suffix. Both now withhold credentials and say the load failed. Detect accounts that cannot use password login. Compat login is hardwired to the local provider, which rejects accounts with local_password_login_enabled false before checking any password, so SSO and plugin-provisioned accounts can never authenticate. The page told them to type a password anyway; it now says the compat API cannot accept the account. Flag loopback compat addresses. jellyfin_compat.public_url defaults to http://127.0.0.1:8096, which resolves to the client device on the phones and TVs this page names. An untouched default was offered as the exact address to copy; it is now explained instead of presented as usable. Apply the #-in-profile-name guard to the summary list too. The selected-profile field withheld an unusable username while "Every profile at a glance" reintroduced it two sections below. Read only the three settings this endpoint consumes. GetAll on the encrypted repository decrypted every stored secret on each authenticated page view, and an unrelated decryption failure would have silently dropped valid compat overrides. Invalidate the connect-info cache when an admin saves jellyfin_compat.* settings. The address applies without a restart, but the page cached it for five minutes and kept offering the old value for copying. Part of #432 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
4.9 KiB
Go
154 lines
4.9 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
)
|
|
|
|
func TestConnectInfoForConfigUsesBootstrapConfig(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.JellyfinCompat.Enabled = true
|
|
cfg.JellyfinCompat.PublicURL = "http://127.0.0.1:8096"
|
|
cfg.JellyfinCompat.ServerName = "Silo"
|
|
|
|
info := ConnectInfoForConfig(cfg, nil)
|
|
|
|
if !info.Enabled {
|
|
t.Fatal("Enabled = false, want true from bootstrap config")
|
|
}
|
|
if info.PublicURL != "http://127.0.0.1:8096" {
|
|
t.Fatalf("PublicURL = %q, want the configured URL", info.PublicURL)
|
|
}
|
|
if info.ServerName != "Silo" {
|
|
t.Fatalf("ServerName = %q, want Silo", info.ServerName)
|
|
}
|
|
}
|
|
|
|
// The address fields apply without a restart, so a stored override wins.
|
|
func TestConnectInfoForConfigAddressSettingsOverrideConfig(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.JellyfinCompat.Enabled = true
|
|
cfg.JellyfinCompat.PublicURL = "http://127.0.0.1:8096"
|
|
cfg.JellyfinCompat.ServerName = "Silo"
|
|
|
|
info := ConnectInfoForConfig(cfg, map[string]string{
|
|
"jellyfin_compat.public_url": "https://compat.example.test",
|
|
"jellyfin_compat.server_name": "Example Household",
|
|
})
|
|
|
|
if info.PublicURL != "https://compat.example.test" {
|
|
t.Fatalf("PublicURL = %q, want the stored URL", info.PublicURL)
|
|
}
|
|
if info.ServerName != "Example Household" {
|
|
t.Fatalf("ServerName = %q, want the stored name", info.ServerName)
|
|
}
|
|
if info.PendingRestart {
|
|
t.Fatal("PendingRestart = true, want false when only address settings changed")
|
|
}
|
|
}
|
|
|
|
// jellyfin_compat.enabled is restart-required, so the stored value describes
|
|
// intent, not the running listener. Reporting the override would promise
|
|
// credentials for a listener that does not exist yet.
|
|
func TestConnectInfoForConfigEnabledTracksRunningListener(t *testing.T) {
|
|
t.Run("stored disable does not hide a running listener", func(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.JellyfinCompat.Enabled = true
|
|
|
|
info := ConnectInfoForConfig(cfg, map[string]string{
|
|
"jellyfin_compat.enabled": "false",
|
|
})
|
|
|
|
if !info.Enabled {
|
|
t.Fatal("Enabled = false, want the running listener's state")
|
|
}
|
|
if !info.PendingRestart {
|
|
t.Fatal("PendingRestart = false, want the pending change surfaced")
|
|
}
|
|
})
|
|
|
|
t.Run("stored enable does not promise an absent listener", func(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.JellyfinCompat.Enabled = false
|
|
|
|
info := ConnectInfoForConfig(cfg, map[string]string{
|
|
"jellyfin_compat.enabled": "true",
|
|
})
|
|
|
|
if info.Enabled {
|
|
t.Fatal("Enabled = true, but no listener is running until restart")
|
|
}
|
|
if !info.PendingRestart {
|
|
t.Fatal("PendingRestart = false, want the pending change surfaced")
|
|
}
|
|
})
|
|
|
|
t.Run("agreement reports no pending restart", func(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.JellyfinCompat.Enabled = true
|
|
|
|
info := ConnectInfoForConfig(cfg, map[string]string{
|
|
"jellyfin_compat.enabled": "true",
|
|
})
|
|
|
|
if !info.Enabled || info.PendingRestart {
|
|
t.Fatalf("got Enabled=%v PendingRestart=%v, want true/false",
|
|
info.Enabled, info.PendingRestart)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A blank stored value must not erase a configured one — stringSetting treats
|
|
// empty as "unset", and the card would otherwise render an empty server field.
|
|
func TestConnectInfoForConfigIgnoresBlankSettings(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.JellyfinCompat.PublicURL = "http://127.0.0.1:8096"
|
|
|
|
info := ConnectInfoForConfig(cfg, map[string]string{
|
|
"jellyfin_compat.public_url": " ",
|
|
})
|
|
|
|
if info.PublicURL != "http://127.0.0.1:8096" {
|
|
t.Fatalf("PublicURL = %q, want the configured URL retained", info.PublicURL)
|
|
}
|
|
}
|
|
|
|
func TestConnectInfoForConfigNilConfig(t *testing.T) {
|
|
info := ConnectInfoForConfig(nil, map[string]string{
|
|
"jellyfin_compat.enabled": "true",
|
|
"jellyfin_compat.public_url": "https://compat.example.test",
|
|
})
|
|
|
|
// No config means no running listener to report.
|
|
if info.Enabled {
|
|
t.Fatal("Enabled = true, want false without a bootstrap config")
|
|
}
|
|
if info.PublicURL != "https://compat.example.test" {
|
|
t.Fatalf("PublicURL = %q, want the stored URL", info.PublicURL)
|
|
}
|
|
}
|
|
|
|
// The admin status endpoint reports configured intent while connect-info
|
|
// reports the running listener, so a pending enable is expected to make them
|
|
// disagree — with connect-info flagging the restart rather than going quiet.
|
|
func TestConnectInfoDivergesFromWebComponentStatusUntilRestart(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.JellyfinCompat.Enabled = false
|
|
|
|
for _, raw := range []string{"true", "1", "yes", "TRUE"} {
|
|
settings := map[string]string{"jellyfin_compat.enabled": raw}
|
|
|
|
info := ConnectInfoForConfig(cfg, settings)
|
|
if info.Enabled {
|
|
t.Fatalf("ConnectInfo enabled = true for %q, want the not-yet-running listener", raw)
|
|
}
|
|
if !info.PendingRestart {
|
|
t.Fatalf("ConnectInfo pending restart = false for %q, want true", raw)
|
|
}
|
|
if got := WebComponentStatusForConfig(cfg, settings).Enabled; !got {
|
|
t.Fatalf("WebComponentStatus enabled = false for %q, want configured intent", raw)
|
|
}
|
|
}
|
|
}
|