Files

515 lines
19 KiB
Go
Raw Permalink Normal View History

package settingsresolve
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// fakeStore records the query it was asked and replays fixed rows, so a test
// can assert both the answer and that only one read produced it.
type fakeStore struct {
rows []userstore.SettingValue
queries []userstore.SettingResolutionQuery
err error
}
func (f *fakeStore) ListSettingValuesForResolution(
_ context.Context, query userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error) {
f.queries = append(f.queries, query)
if f.err != nil {
return nil, f.err
}
return f.rows, nil
}
func row(key string, scope settingscontract.Scope, value string, id userstore.SettingIdentity) userstore.SettingValue {
id.Key = key
id.Scope = scope
return userstore.SettingValue{SettingIdentity: id, Value: json.RawMessage(value)}
}
func mustContract(t *testing.T) *settingscontract.Manifest {
t.Helper()
manifest, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
return manifest
}
func resolveOne(t *testing.T, store Store, rc Context, key string, constraints Constraints) Effective {
t.Helper()
got, err := New(mustContract(t)).Resolve(context.Background(), store, rc, []string{key}, constraints)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if len(got) != 1 {
t.Fatalf("Resolve returned %d results, want 1", len(got))
}
return got[0]
}
// TestResolutionOrderIsHonored is the whole point of the package: the most
// specific scope holding a value wins, and the declared order decides what
// "specific" means rather than each caller's own opinion.
func TestResolutionOrderIsHonored(t *testing.T) {
const key = "playback.subtitle_language"
profile := row(key, settingscontract.ScopeProfile, `"en"`,
userstore.SettingIdentity{ProfileID: "p1"})
device := row(key, settingscontract.ScopeProfileDevice, `"de"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "d1"})
library := row(key, settingscontract.ScopeProfileLibrary, `"fr"`,
userstore.SettingIdentity{ProfileID: "p1", LibraryID: 7})
series := row(key, settingscontract.ScopeProfileSeries, `"ja"`,
userstore.SettingIdentity{ProfileID: "p1", SeriesID: "s1"})
rc := Context{ProfileID: "p1", DeviceID: "d1", LibraryIDs: []int{7}, SeriesIDs: []string{"s1"}}
for name, tc := range map[string]struct {
rows []userstore.SettingValue
wantValue string
wantSource settingscontract.Scope
}{
"series beats everything": {
[]userstore.SettingValue{profile, device, library, series}, `"ja"`,
settingscontract.ScopeProfileSeries,
},
"library beats device and profile": {
[]userstore.SettingValue{profile, device, library}, `"fr"`,
settingscontract.ScopeProfileLibrary,
},
"device beats profile": {
[]userstore.SettingValue{profile, device}, `"de"`,
settingscontract.ScopeProfileDevice,
},
"profile alone": {
[]userstore.SettingValue{profile}, `"en"`, settingscontract.ScopeProfile,
},
"nothing stored falls to the default": {
nil, `null`, settingscontract.ScopeDefault,
},
} {
t.Run(name, func(t *testing.T) {
got := resolveOne(t, &fakeStore{rows: tc.rows}, rc, key, nil)
if string(got.Value) != tc.wantValue {
t.Errorf("value = %s, want %s", got.Value, tc.wantValue)
}
if got.Source != tc.wantSource {
t.Errorf("source = %q, want %q", got.Source, tc.wantSource)
}
})
}
}
func TestEffectiveReportsCurrentManifestRevision(t *testing.T) {
manifest := mustContract(t)
got, err := New(manifest).Resolve(context.Background(), &fakeStore{},
Context{ProfileID: "p1"}, []string{"playback.subtitle_language"}, nil)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if len(got) != 1 || got[0].DefinitionRevision != manifest.Revision {
t.Fatalf("definition_revision = %d, want current manifest revision %d",
got[0].DefinitionRevision, manifest.Revision)
}
}
// TestAbsentIdentityDropsItsScope covers the anonymous caller. jellycompat
// seeds DisplayPreferences without a device, and a device override leaking into
// that seed would hand one device's settings to every Jellyfin client.
func TestAbsentIdentityDropsItsScope(t *testing.T) {
const key = "playback.subtitle_language"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"en"`,
userstore.SettingIdentity{ProfileID: "p1"}),
row(key, settingscontract.ScopeProfileDevice, `"de"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "d1"}),
}
got := resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, nil)
if got.Source != settingscontract.ScopeProfile {
t.Fatalf("source = %q, want profile: a device row resolved for a caller with no device",
got.Source)
}
if string(got.Value) != `"en"` {
t.Errorf("value = %s, want \"en\"", got.Value)
}
}
// TestUnrelatedIdentitiesAreIgnored guards the cross-identity leak: rows for
// another profile, device, library or series must not resolve just because the
// batched read returned them.
func TestUnrelatedIdentitiesAreIgnored(t *testing.T) {
const key = "playback.subtitle_language"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"xx"`,
userstore.SettingIdentity{ProfileID: "other"}),
row(key, settingscontract.ScopeProfileDevice, `"yy"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "other-device"}),
row(key, settingscontract.ScopeProfileSeries, `"zz"`,
userstore.SettingIdentity{ProfileID: "p1", SeriesID: "other-series"}),
}
rc := Context{ProfileID: "p1", DeviceID: "d1", SeriesIDs: []string{"s1"}}
got := resolveOne(t, &fakeStore{rows: rows}, rc, key, nil)
if got.Source != settingscontract.ScopeDefault {
t.Fatalf("source = %q with value %s, want default: a foreign row resolved",
got.Source, got.Value)
}
}
// TestResolveIssuesOneRead pins the batching. The design rejects one lookup per
// scope per key, and a season view resolving many items is exactly where that
// would show up.
func TestResolveIssuesOneRead(t *testing.T) {
store := &fakeStore{}
keys := []string{
"playback.subtitle_language", "playback.audio_language",
"playback.subtitle_mode", "playback.show_forced_subtitles",
}
rc := Context{
ProfileID: "p1",
DeviceID: "d1",
LibraryIDs: []int{1, 2, 3},
SeriesIDs: []string{"s1", "s2", "s3"},
}
if _, err := New(mustContract(t)).Resolve(context.Background(), store, rc, keys, nil); err != nil {
t.Fatalf("Resolve: %v", err)
}
if len(store.queries) != 1 {
t.Fatalf("issued %d reads for %d keys, want 1", len(store.queries), len(keys))
}
if len(store.queries[0].Keys) != len(keys) {
t.Errorf("query carried %d keys, want %d", len(store.queries[0].Keys), len(keys))
}
}
func TestResolveContextsIssuesOneReadAndRanksEachContext(t *testing.T) {
const key = "playback.subtitle_language"
store := &fakeStore{rows: []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"en"`,
userstore.SettingIdentity{ProfileID: "p1"}),
row(key, settingscontract.ScopeProfileSeries, `"ja"`,
userstore.SettingIdentity{ProfileID: "p1", SeriesID: "s1"}),
row(key, settingscontract.ScopeProfileSeries, `"de"`,
userstore.SettingIdentity{ProfileID: "p1", SeriesID: "s2"}),
}}
contexts := []Context{
{ProfileID: "p1", DeviceID: "d1", LibraryIDs: []int{7}, SeriesIDs: []string{"s1"}},
{ProfileID: "p1", DeviceID: "d1", LibraryIDs: []int{7}, SeriesIDs: []string{"s2"}},
}
got, err := New(mustContract(t)).ResolveContexts(
context.Background(), store, contexts, []string{key}, nil,
)
if err != nil {
t.Fatalf("ResolveContexts: %v", err)
}
if len(store.queries) != 1 {
t.Fatalf("issued %d candidate reads, want 1", len(store.queries))
}
query := store.queries[0].Normalized()
if len(query.SeriesIDs) != 2 || query.SeriesIDs[0] != "s1" || query.SeriesIDs[1] != "s2" {
t.Fatalf("query series ids = %#v, want both contexts", query.SeriesIDs)
}
if len(got) != 2 || len(got[0]) != 1 || len(got[1]) != 1 {
t.Fatalf("resolved shape = %#v", got)
}
if string(got[0][0].Value) != `"ja"` || string(got[1][0].Value) != `"de"` {
t.Errorf("per-context values = %s, %s; want ja, de", got[0][0].Value, got[1][0].Value)
}
}
// TestUnknownAndLocalKeysAreOmitted keeps a newer client's request from
// failing wholesale. A key this server does not have, or one the contract says
// never leaves the device, simply has no server answer.
func TestUnknownAndLocalKeysAreOmitted(t *testing.T) {
got, err := New(mustContract(t)).Resolve(context.Background(), &fakeStore{},
Context{ProfileID: "p1"},
[]string{"playback.subtitle_mode", "not.a.real.key", "downloads.wifi_only"}, nil)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if len(got) != 1 {
t.Fatalf("returned %d results, want only the one remote key", len(got))
}
if got[0].Key != "playback.subtitle_mode" {
t.Errorf("resolved %q", got[0].Key)
}
}
// TestCeilingNarrowsWithoutDestroying is the preferences-versus-restrictions
// rule: a capped preference is reported capped and kept intact, so it takes
// effect the day the cap lifts.
func TestCeilingNarrowsWithoutDestroying(t *testing.T) {
const key = "playback.preferred_quality"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"2160p"`,
userstore.SettingIdentity{ProfileID: "p1"}),
}
constraints := Constraints{"max_playback_quality": json.RawMessage(`"1080p"`)}
got := resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, constraints)
if string(got.Value) != `"1080p"` {
t.Errorf("effective = %s, want \"1080p\"", got.Value)
}
if string(got.StoredValue) != `"2160p"` {
t.Errorf("stored = %s, want \"2160p\" preserved", got.StoredValue)
}
if !got.Constrained || got.ConstraintKind != settingscontract.ConstraintCeiling {
t.Errorf("constrained=%v kind=%q, want true/ceiling", got.Constrained, got.ConstraintKind)
}
if string(got.RequestedValue) != `"2160p"` {
t.Errorf("requested_value = %s, want authored 2160p", got.RequestedValue)
}
if got.ConstrainedBy == nil || got.ConstrainedBy.PolicyInput != "max_playback_quality" ||
got.ConstrainedBy.Constraint != settingscontract.ConstraintCeiling {
t.Errorf("constrained_by = %#v", got.ConstrainedBy)
}
wantPermitted := []string{`"auto"`, `"480p"`, `"720p"`, `"1080p"`}
if len(got.PermittedValues) != len(wantPermitted) {
t.Fatalf("permitted_values = %q, want %q", got.PermittedValues, wantPermitted)
}
for i := range wantPermitted {
if string(got.PermittedValues[i]) != wantPermitted[i] {
t.Errorf("permitted_values[%d] = %s, want %s", i, got.PermittedValues[i], wantPermitted[i])
}
}
// Under the cap, nothing is touched and no constraint is reported.
rows[0].Value = json.RawMessage(`"720p"`)
got = resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, constraints)
if string(got.Value) != `"720p"` || got.Constrained {
t.Errorf("value = %s constrained = %v, want \"720p\"/false", got.Value, got.Constrained)
}
if got.StoredValue != nil {
t.Errorf("stored_value = %s, want absent when nothing was narrowed", got.StoredValue)
}
if got.RequestedValue != nil {
t.Errorf("requested_value = %s, want absent when nothing was narrowed", got.RequestedValue)
}
if got.ConstrainedBy == nil || len(got.PermittedValues) == 0 {
t.Error("active ceiling metadata was omitted for an already-permitted value")
}
}
// TestCeilingCapsAnUncappedNullable covers the case CompareValues cannot rank.
// null on max_bitrate_kbps means "no cap of my own", which is unbounded above —
// precisely what a ceiling exists to bring down. Ranking it as equal would let
// the one value that most needs capping slip past.
func TestCeilingCapsAnUncappedNullable(t *testing.T) {
manifest := mustContract(t)
def, ok := manifest.Lookup("playback.max_bitrate_kbps")
if !ok {
t.Fatal("playback.max_bitrate_kbps is not registered")
}
// The manifest does not bind this key to a policy input today; the rule
// still has to hold for whichever numeric key does.
bound := *def
bound.ConstrainedBy = &settingscontract.Constraint{
PolicyInput: "max_bitrate_kbps",
Constraint: settingscontract.ConstraintCeiling,
}
capped := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`null`),
Source: settingscontract.ScopeDefault,
}, Constraints{"max_bitrate_kbps": json.RawMessage(`8000`)})
if string(capped.Value) != `8000` {
t.Errorf("uncapped bitrate resolved to %s, want the policy cap 8000", capped.Value)
}
if !capped.Constrained {
t.Error("capping an uncapped value was not reported as constrained")
}
// A floor is the mirror: unbounded already satisfies it.
bound.ConstrainedBy.Constraint = settingscontract.ConstraintFloor
floored := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`null`),
}, Constraints{"max_bitrate_kbps": json.RawMessage(`8000`)})
if floored.Constrained {
t.Errorf("floor narrowed an already-unbounded value to %s", floored.Value)
}
}
// TestAllowlistFallsBackInsideTheAllowedSet guards the one thing a constraint
// must never do: return a value the policy forbids. The definition's own
// default is not a safe fallback, because it may itself be outside the list.
func TestAllowlistFallsBackInsideTheAllowedSet(t *testing.T) {
manifest := mustContract(t)
def, ok := manifest.Lookup("catalog.metadata_language")
if !ok {
t.Fatal("catalog.metadata_language is not registered")
}
bound := *def
bound.ConstrainedBy = &settingscontract.Constraint{
PolicyInput: "allowed_metadata_languages",
Constraint: settingscontract.ConstraintAllowlist,
}
got := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`"ja"`),
Source: settingscontract.ScopeProfile,
}, Constraints{"allowed_metadata_languages": json.RawMessage(`["en","fr"]`)})
if string(got.Value) != `"en"` {
t.Errorf("value = %s, want the first allowed member", got.Value)
}
if string(got.StoredValue) != `"ja"` {
t.Errorf("stored = %s, want the authored value kept", got.StoredValue)
}
// A permitted value passes through untouched.
allowed := applyConstraint(&bound, Effective{
Key: bound.Key,
Value: json.RawMessage(`"fr"`),
}, Constraints{"allowed_metadata_languages": json.RawMessage(`["en","fr"]`)})
if allowed.Constrained {
t.Error("a permitted value was reported as constrained")
}
}
// TestNoConstraintInputLeavesValueAlone covers the viewer a policy says nothing
// about, which is most of them.
func TestNoConstraintInputLeavesValueAlone(t *testing.T) {
const key = "playback.preferred_quality"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfile, `"2160p"`,
userstore.SettingIdentity{ProfileID: "p1"}),
}
for name, constraints := range map[string]Constraints{
"no constraints at all": nil,
"unrelated input": {"something_else": json.RawMessage(`"1080p"`)},
"empty input": {"max_playback_quality": json.RawMessage(``)},
} {
t.Run(name, func(t *testing.T) {
got := resolveOne(t, &fakeStore{rows: rows}, Context{ProfileID: "p1"}, key, constraints)
if got.Constrained || string(got.Value) != `"2160p"` {
t.Errorf("value = %s constrained = %v, want the stored value untouched",
got.Value, got.Constrained)
}
})
}
}
// TestIdentityLocatesTheResolvedRow so a client can offer "reset this device's
// override" against the scope that actually holds the value.
func TestIdentityLocatesTheResolvedRow(t *testing.T) {
const key = "playback.subtitle_language"
rows := []userstore.SettingValue{
row(key, settingscontract.ScopeProfileDevice, `"de"`,
userstore.SettingIdentity{ProfileID: "p1", DeviceID: "d1"}),
}
got := resolveOne(t, &fakeStore{rows: rows},
Context{ProfileID: "p1", DeviceID: "d1"}, key, nil)
if got.Identity == nil {
t.Fatal("no identity reported for a stored value")
}
if got.Identity.Scope != settingscontract.ScopeProfileDevice || got.Identity.DeviceID != "d1" {
t.Errorf("identity = %+v, want the profile_device row", *got.Identity)
}
// A default came from no row, so there is nothing to reset.
def := resolveOne(t, &fakeStore{}, Context{ProfileID: "p1", DeviceID: "d1"}, key, nil)
if def.Identity != nil {
t.Errorf("identity = %+v for a default, want none", *def.Identity)
}
}
func TestStoreErrorsPropagate(t *testing.T) {
sentinel := errors.New("boom")
_, err := New(mustContract(t)).Resolve(context.Background(),
&fakeStore{err: sentinel}, Context{ProfileID: "p1"},
[]string{"playback.subtitle_mode"}, nil)
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want it to wrap the store error", err)
}
}
// The production store must satisfy the narrow read interface declared here.
// The package takes an interface rather than the concrete store so tests can
// fake it, which is exactly the seam that lets an incompatible signature go
// unnoticed until a caller wires the two together.
var _ Store = (userstore.UserStore)(nil)
// TestResolveProfilesRanksPerProfileInOneRead is the household shape: several
// profiles resolved from one candidate set, each getting exactly what a
// single-profile Resolve would have returned, and no profile inheriting
// another's value. GET /profiles is the caller that makes this matter — a read
// per profile would turn one list request into n round trips.
func TestResolveProfilesRanksPerProfileInOneRead(t *testing.T) {
const key = "playback.subtitle_language"
store := &fakeStore{rows: []userstore.SettingValue{
row(key, settingscontract.ScopeAccount, `"en"`, userstore.SettingIdentity{}),
row(key, settingscontract.ScopeProfile, `"fr"`,
userstore.SettingIdentity{ProfileID: "p1"}),
row(key, settingscontract.ScopeProfile, `"ja"`,
userstore.SettingIdentity{ProfileID: "p2"}),
}}
got, err := New(mustContract(t)).ResolveProfiles(context.Background(), store,
[]string{"p1", "p2", "p3"}, []string{key}, nil)
if err != nil {
t.Fatalf("ResolveProfiles: %v", err)
}
if len(store.queries) != 1 {
t.Fatalf("issued %d reads for 3 profiles, want 1", len(store.queries))
}
if len(store.queries[0].ProfileIDs) != 3 {
t.Errorf("query carried %d profile ids, want 3", len(store.queries[0].ProfileIDs))
}
// p3 has no row of its own. The account-scope row is not in this key's
// resolution order, so p3 falls to the contract default rather than
// inheriting a neighbor's language.
for profileID, want := range map[string]string{
"p1": `"fr"`,
"p2": `"ja"`,
"p3": `null`,
} {
effective, ok := got[profileID]
if !ok {
t.Errorf("no result for %s", profileID)
continue
}
if len(effective) != 1 {
t.Errorf("%s returned %d results, want 1", profileID, len(effective))
continue
}
if string(effective[0].Value) != want {
t.Errorf("%s resolved to %s, want %s", profileID, effective[0].Value, want)
}
}
}
// TestResolveProfilesWithoutProfilesReadsNothing: an account with no profiles
// is not an error and must not issue a read that would return every
// account-scope row for no one to use.
func TestResolveProfilesWithoutProfilesReadsNothing(t *testing.T) {
store := &fakeStore{}
got, err := New(mustContract(t)).ResolveProfiles(context.Background(), store,
nil, []string{"playback.subtitle_mode"}, nil)
if err != nil {
t.Fatalf("ResolveProfiles: %v", err)
}
if len(got) != 0 {
t.Errorf("returned %d results for no profiles, want none", len(got))
}
if len(store.queries) != 0 {
t.Errorf("issued %d reads for no profiles, want none", len(store.queries))
}
}