Add specs for the ArikTube settings store

Covers the parts that are dangerous to get wrong and cheap to test,
which is why the validators and the origin matcher are pure functions:

- override precedence: no row keeps the environment value, a row wins,
  a stored empty list is an override rather than absence;
- decoding: a row that is not JSON, not a list, or holds a malformed
  playlist ID or a CIDR proxy is reported and ignored instead of raised,
  so it can never stop the instance from booting;
- validators: CIDR ranges, a host name where an IP belongs, header auth
  enabled with no trusted proxy, and origins carrying a path, query,
  fragment or credentials;
- origin matching: the near misses, "…ariksen.dk.evil.com",
  "…ariksen.dk@evil.com", the origin hidden in a path or query, another
  scheme, another port, a subdomain;
- the gating of both waivers: header auth off, empty list, no assertion,
  an assertion for somebody else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
NeskireDK 2026-08-13 06:43:32 +02:00
parent c47d9a93f0
commit 77eee92465

View File

@ -0,0 +1,513 @@
require "../spec_helper"
require "../../src/invidious/arik_settings"
# Stand-ins for the running configuration. `merge_overrides!` and `apply_to`
# only need something carrying these properties, so override precedence is
# testable without a parsed config file and without Postgres.
private class FakeTrustedHeaderAuthConfig
property enabled : Bool = false
property header : String = "Remote-User"
property trusted_proxies : Array(String) = [] of String
property logout_url : String? = nil
property password_self_service : Bool = true
property auto_approve_token_callbacks : Array(String) = [] of String
end
private class FakeConfig
property popular_playlists : Array(String) = [] of String
property trending_playlists : Array(String) = [] of String
property trusted_header_auth : FakeTrustedHeaderAuthConfig = FakeTrustedHeaderAuthConfig.new
end
Spectator.describe Invidious::ArikSettings do
alias Settings = Invidious::ArikSettings
alias TrustedHeaderAuthSettings = Invidious::ArikSettings::TrustedHeaderAuthSettings
# ------------------------------------------------------------------
# Override precedence
# ------------------------------------------------------------------
describe "override precedence" do
it "keeps the environment config when no override is stored" do
config = FakeConfig.new
config.popular_playlists = ["IVPLenvironment"]
config.trending_playlists = ["IVPLenvtrending"]
config.trusted_header_auth.header = "X-Env-User"
Settings.merge_overrides!(config, nil, nil, nil)
expect(config.popular_playlists).to eq(["IVPLenvironment"])
expect(config.trending_playlists).to eq(["IVPLenvtrending"])
expect(config.trusted_header_auth.header).to eq("X-Env-User")
end
it "lets a stored value override the environment config" do
config = FakeConfig.new
config.popular_playlists = ["IVPLenvironment"]
Settings.merge_overrides!(config, ["IVPLstored"], nil, nil)
expect(config.popular_playlists).to eq(["IVPLstored"])
end
it "overrides only the keys that have a stored value" do
config = FakeConfig.new
config.popular_playlists = ["IVPLenvpopular"]
config.trending_playlists = ["IVPLenvtrending"]
Settings.merge_overrides!(config, ["IVPLstored"], nil, nil)
expect(config.popular_playlists).to eq(["IVPLstored"])
expect(config.trending_playlists).to eq(["IVPLenvtrending"])
end
it "treats a stored empty list as an override, not as absence" do
config = FakeConfig.new
config.popular_playlists = ["IVPLenvironment"]
Settings.merge_overrides!(config, [] of String, nil, nil)
# An empty list is what turns the stock feed back on
expect(config.popular_playlists).to be_empty
end
it "overrides every field of the trusted-header block" do
config = FakeConfig.new
config.trusted_header_auth.enabled = false
config.trusted_header_auth.header = "X-Env-User"
config.trusted_header_auth.trusted_proxies = ["10.0.0.1"]
config.trusted_header_auth.logout_url = "https://env.example.com/logout"
stored = TrustedHeaderAuthSettings.new(
enabled: true,
header: "Remote-User",
trusted_proxies: ["192.168.1.101"],
logout_url: "https://auth.example.com/logout",
password_self_service: false,
auto_approve_token_callbacks: ["https://yt.example.com"]
)
Settings.merge_overrides!(config, nil, nil, stored)
expect(config.trusted_header_auth.enabled).to be_true
expect(config.trusted_header_auth.header).to eq("Remote-User")
expect(config.trusted_header_auth.trusted_proxies).to eq(["192.168.1.101"])
expect(config.trusted_header_auth.logout_url).to eq("https://auth.example.com/logout")
expect(config.trusted_header_auth.password_self_service).to be_false
expect(config.trusted_header_auth.auto_approve_token_callbacks).to eq(["https://yt.example.com"])
end
it "stores an empty logout URL as no logout URL" do
config = FakeConfig.new
config.trusted_header_auth.logout_url = "https://env.example.com/logout"
Settings.merge_overrides!(config, nil, nil, TrustedHeaderAuthSettings.new(logout_url: ""))
expect(config.trusted_header_auth.logout_url).to be_nil
end
end
# ------------------------------------------------------------------
# Decoding stored rows
# ------------------------------------------------------------------
describe ".decode_playlists" do
it "reports no override for a missing row" do
value, error = Settings.decode_playlists(nil)
expect(value).to be_nil
expect(error).to be_nil
end
it "decodes a stored list" do
value, error = Settings.decode_playlists(%(["IVPLone","IVPLtwo"]))
expect(value).to eq(["IVPLone", "IVPLtwo"])
expect(error).to be_nil
end
it "ignores a row that is not JSON" do
value, error = Settings.decode_playlists("IVPLone, IVPLtwo")
expect(value).to be_nil
expect(error).not_to be_nil
end
it "ignores a row that is not a list" do
value, error = Settings.decode_playlists(%({"plid": "IVPLone"}))
expect(value).to be_nil
expect(error).not_to be_nil
end
it "ignores a row holding a malformed playlist ID" do
value, error = Settings.decode_playlists(%(["IVPLone","not a plid"]))
expect(value).to be_nil
expect(error.to_s).to contain("not a valid playlist ID")
end
end
describe ".decode_trusted_header_auth" do
it "reports no override for a missing row" do
value, error = Settings.decode_trusted_header_auth(nil)
expect(value).to be_nil
expect(error).to be_nil
end
it "decodes a valid block" do
value, error = Settings.decode_trusted_header_auth(
%({"enabled":true,"header":"Remote-User","trusted_proxies":["192.168.1.101"]})
)
expect(error).to be_nil
expect(value.not_nil!.enabled).to be_true
expect(value.not_nil!.trusted_proxies).to eq(["192.168.1.101"])
end
it "defaults password self service to on" do
value, _error = Settings.decode_trusted_header_auth(%({"enabled":false}))
expect(value.not_nil!.password_self_service).to be_true
end
it "ignores a block holding a CIDR range" do
value, error = Settings.decode_trusted_header_auth(
%({"enabled":true,"trusted_proxies":["192.168.1.0/24"]})
)
expect(value).to be_nil
expect(error.to_s).to contain("CIDR")
end
it "ignores a block enabled without a trusted proxy" do
value, error = Settings.decode_trusted_header_auth(%({"enabled":true,"trusted_proxies":[]}))
expect(value).to be_nil
expect(error.to_s).to contain("at least one trusted proxy")
end
it "ignores a row that is not JSON" do
value, error = Settings.decode_trusted_header_auth("enabled = true")
expect(value).to be_nil
expect(error).not_to be_nil
end
end
# ------------------------------------------------------------------
# Validators
# ------------------------------------------------------------------
describe ".trusted_proxy_error" do
it "accepts a literal IPv4 address" do
expect(Settings.trusted_proxy_error("192.168.1.101")).to be_nil
end
it "accepts a literal IPv6 address" do
expect(Settings.trusted_proxy_error("::1")).to be_nil
end
it "refuses a CIDR range, which stops the instance from starting" do
expect(Settings.trusted_proxy_error("192.168.1.0/24").to_s).to contain("CIDR")
end
it "refuses a single address written as a range" do
expect(Settings.trusted_proxy_error("192.168.1.101/32").to_s).to contain("CIDR")
end
it "refuses a host name" do
expect(Settings.trusted_proxy_error("proxy.example.com").to_s).to contain("not a valid IP address")
end
it "refuses an empty entry" do
expect(Settings.trusted_proxy_error(" ")).not_to be_nil
end
end
describe "TrustedHeaderAuthSettings#errors" do
it "accepts a disabled block" do
expect(TrustedHeaderAuthSettings.new.errors).to be_empty
end
it "accepts an enabled block with a literal proxy IP" do
settings = TrustedHeaderAuthSettings.new(enabled: true, trusted_proxies: ["192.168.1.101"])
expect(settings.errors).to be_empty
end
it "refuses being enabled with no trusted proxy" do
settings = TrustedHeaderAuthSettings.new(enabled: true)
expect(settings.errors.join).to contain("at least one trusted proxy")
end
it "refuses being enabled with only blank trusted proxies" do
settings = TrustedHeaderAuthSettings.new(enabled: true, trusted_proxies: [" "])
expect(settings.errors.join).to contain("at least one trusted proxy")
end
it "refuses being enabled without a header name" do
settings = TrustedHeaderAuthSettings.new(
enabled: true, header: " ", trusted_proxies: ["192.168.1.101"]
)
expect(settings.errors.join).to contain("header name can't be empty")
end
it "refuses a bad proxy entry even while disabled" do
settings = TrustedHeaderAuthSettings.new(enabled: false, trusted_proxies: ["192.168.1.0/24"])
expect(settings.errors.join).to contain("CIDR")
end
it "refuses a relative logout URL" do
settings = TrustedHeaderAuthSettings.new(logout_url: "/logout")
expect(settings.errors.join).to contain("logout URL")
end
it "accepts an empty logout URL" do
expect(TrustedHeaderAuthSettings.new(logout_url: "").errors).to be_empty
end
it "refuses a callback origin carrying a path" do
settings = TrustedHeaderAuthSettings.new(
auto_approve_token_callbacks: ["https://yt.example.com/callback"]
)
expect(settings.errors.join).to contain("without a path")
end
end
describe ".origin_error" do
it "accepts a bare https origin" do
expect(Settings.origin_error("https://yt.example.com")).to be_nil
end
it "accepts a trailing slash" do
expect(Settings.origin_error("https://yt.example.com/")).to be_nil
end
it "accepts an explicit port" do
expect(Settings.origin_error("http://192.168.1.59:3000")).to be_nil
end
it "refuses a path" do
expect(Settings.origin_error("https://yt.example.com/cb").to_s).to contain("without a path")
end
it "refuses a query string" do
expect(Settings.origin_error("https://yt.example.com?a=1").to_s).to contain("query string")
end
it "refuses a fragment" do
expect(Settings.origin_error("https://yt.example.com#x").to_s).to contain("fragment")
end
it "refuses credentials" do
expect(Settings.origin_error("https://user@yt.example.com").to_s).to contain("credentials")
end
it "refuses a scheme that is not http or https" do
expect(Settings.origin_error("ftp://yt.example.com").to_s).to contain("scheme")
end
it "refuses a value with no scheme at all" do
expect(Settings.origin_error("yt.example.com").to_s).to contain("scheme")
end
it "refuses an empty entry" do
expect(Settings.origin_error(" ")).not_to be_nil
end
end
# ------------------------------------------------------------------
# Origin matching
# ------------------------------------------------------------------
describe ".origin_allowed?" do
let(allowed) { ["https://yt.ariksen.dk"] }
it "matches the same origin" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk/cb?state=1", allowed)).to be_true
end
it "matches whatever the path and query are" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk/a/b/c#frag", allowed)).to be_true
end
it "matches a differently cased host" do
expect(Settings.origin_allowed?("https://YT.Ariksen.DK/cb", allowed)).to be_true
end
it "matches the scheme's default port written out" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk:443/cb", allowed)).to be_true
end
it "refuses a host that only starts with an allowed host" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk.evil.com/cb", allowed)).to be_false
end
it "refuses an allowed host hidden in the user info" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk@evil.com/cb", allowed)).to be_false
end
it "refuses an allowed host used as a password" do
expect(Settings.origin_allowed?("https://user:yt.ariksen.dk@evil.com/cb", allowed)).to be_false
end
it "refuses an allowed origin hidden in the path" do
expect(Settings.origin_allowed?("https://evil.com/https://yt.ariksen.dk", allowed)).to be_false
end
it "refuses an allowed origin hidden in the query" do
expect(Settings.origin_allowed?("https://evil.com/?next=https://yt.ariksen.dk", allowed)).to be_false
end
it "refuses a subdomain of an allowed host" do
expect(Settings.origin_allowed?("https://sub.yt.ariksen.dk/cb", allowed)).to be_false
end
it "refuses the same host on another scheme" do
expect(Settings.origin_allowed?("http://yt.ariksen.dk/cb", allowed)).to be_false
end
it "refuses the same host on another port" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk:8443/cb", allowed)).to be_false
end
it "refuses a scheme that is not http or https" do
expect(Settings.origin_allowed?("javascript://yt.ariksen.dk/cb", allowed)).to be_false
end
it "refuses everything while the list is empty" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk/cb", [] of String)).to be_false
end
it "refuses a missing callback URL" do
expect(Settings.origin_allowed?(nil, allowed)).to be_false
end
it "matches an entry written with a trailing slash" do
expect(Settings.origin_allowed?("https://yt.ariksen.dk/cb", ["https://yt.ariksen.dk/"])).to be_true
end
it "matches an entry with a non-default port" do
expect(
Settings.origin_allowed?("http://192.168.1.59:3000/cb", ["http://192.168.1.59:3000"])
).to be_true
end
end
# ------------------------------------------------------------------
# Gating
# ------------------------------------------------------------------
describe ".auto_approve_token?" do
let(allowed) { ["https://yt.ariksen.dk"] }
let(callback) { "https://yt.ariksen.dk/cb" }
it "approves an SSO session calling back to an allowed origin" do
expect(
Settings.auto_approve_token?(true, allowed, "a@example.com", "a@example.com", callback)
).to be_true
end
it "refuses while trusted-header authentication is off" do
expect(
Settings.auto_approve_token?(false, allowed, "a@example.com", "a@example.com", callback)
).to be_false
end
it "refuses while the list is empty" do
expect(
Settings.auto_approve_token?(true, [] of String, "a@example.com", "a@example.com", callback)
).to be_false
end
it "refuses a session the header does not vouch for" do
expect(
Settings.auto_approve_token?(true, allowed, nil, "a@example.com", callback)
).to be_false
end
it "refuses a header asserting somebody else" do
expect(
Settings.auto_approve_token?(true, allowed, "b@example.com", "a@example.com", callback)
).to be_false
end
it "refuses a callback outside the list" do
expect(
Settings.auto_approve_token?(true, allowed, "a@example.com", "a@example.com", "https://evil.com/cb")
).to be_false
end
it "refuses a request without a callback URL" do
expect(
Settings.auto_approve_token?(true, allowed, "a@example.com", "a@example.com", nil)
).to be_false
end
end
describe ".password_self_service?" do
it "waives the current password for an SSO session" do
expect(Settings.password_self_service?(true, "a@example.com", "a@example.com")).to be_true
end
it "refuses while the flag is off" do
expect(Settings.password_self_service?(false, "a@example.com", "a@example.com")).to be_false
end
it "refuses a session the header does not vouch for" do
expect(Settings.password_self_service?(true, nil, "a@example.com")).to be_false
end
it "refuses a header asserting somebody else" do
expect(Settings.password_self_service?(true, "b@example.com", "a@example.com")).to be_false
end
end
# ------------------------------------------------------------------
# Playlist IDs
# ------------------------------------------------------------------
describe ".clean_playlist_ids" do
it "keeps the given order" do
plids, errors = Settings.clean_playlist_ids(["IVPLtwo", "IVPLone"])
expect(plids).to eq(["IVPLtwo", "IVPLone"])
expect(errors).to be_empty
end
it "drops duplicates and keeps the first position" do
plids, _errors = Settings.clean_playlist_ids(["IVPLone", "IVPLtwo", "IVPLone"])
expect(plids).to eq(["IVPLone", "IVPLtwo"])
end
it "drops blank lines and trims the rest" do
plids, errors = Settings.clean_playlist_ids(["", " IVPLone ", "\t"])
expect(plids).to eq(["IVPLone"])
expect(errors).to be_empty
end
it "refuses an entry that is not a playlist ID" do
plids, errors = Settings.clean_playlist_ids(["IVPLone", "https://example.com/list"])
expect(plids).to eq(["IVPLone"])
expect(errors.join).to contain("not a valid playlist ID")
end
it "accepts an empty list, which is the stock feed" do
plids, errors = Settings.clean_playlist_ids([] of String)
expect(plids).to be_empty
expect(errors).to be_empty
end
end
end