diff --git a/internal/settingscontract/validate.go b/internal/settingscontract/validate.go index a80994c2..f66ad3f2 100644 --- a/internal/settingscontract/validate.go +++ b/internal/settingscontract/validate.go @@ -780,3 +780,79 @@ func isAlpha(value string) bool { } return true } + +// CompareValues orders two values of this schema's type: negative when a sorts +// below b, zero when they are equivalent, positive when a sorts above. +// +// This is what makes a ceiling or floor constraint mean anything. Numeric types +// compare numerically; an ordered enum compares by declared member position, +// which is why manifest.schema.json only permits those constraints on a numeric +// type or an enum marked ordered — every other type has no defined direction to +// cap in. +// +// A value that is not a member, or that will not decode, sorts as equivalent so +// an unrecognized value is never silently narrowed. Validation is a separate +// concern and has already rejected it by the time a constraint is applied. +func (v *ValueSchema) CompareValues(a, b json.RawMessage) int { + switch v.Type { + case TypeInteger, TypeNumber: + left, okA := decodeFloat(a) + right, okB := decodeFloat(b) + if !okA || !okB { + return 0 + } + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } + + case TypeEnum: + if !v.Ordered { + return 0 + } + left, okA := v.enumIndex(a) + right, okB := v.enumIndex(b) + if !okA || !okB { + return 0 + } + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } + } + return 0 +} + +// enumIndex returns the declared position of raw among this schema's members. +func (v *ValueSchema) enumIndex(raw json.RawMessage) (int, bool) { + var decoded any + if err := strictUnmarshal(bytes.TrimSpace(raw), &decoded); err != nil { + return 0, false + } + for i, member := range v.Values { + if enumMatches(decoded, member.Value) { + return i, true + } + } + return 0, false +} + +func decodeFloat(raw json.RawMessage) (float64, bool) { + var number json.Number + if err := strictUnmarshal(bytes.TrimSpace(raw), &number); err != nil { + return 0, false + } + parsed, err := number.Float64() + if err != nil { + return 0, false + } + return parsed, true +} diff --git a/internal/settingsresolve/resolve.go b/internal/settingsresolve/resolve.go new file mode 100644 index 00000000..29e3e0ea --- /dev/null +++ b/internal/settingsresolve/resolve.go @@ -0,0 +1,353 @@ +// Package settingsresolve turns stored setting values into effective ones. +// +// It is the single answer to "what is this setting, for this profile, on this +// device, for this content" — the mutation endpoint, the effective-values +// endpoint, playback, catalog, and the jellycompat DisplayPreferences seed all +// resolve through here. Before this package each of those carried its own +// ladder: internal/catalog/detail.go resolved subtitles across four levels by +// hand and audio across three, internal/api/handlers/settings.go had a +// two-level device/user resolution with a lazy write-back inside a GET, and +// jellycompat read profile columns directly. Those disagreed about precedence, +// which is the drift the contract exists to remove. +// +// The package deliberately holds no storage of its own. It takes candidate rows +// and a contract, and returns decisions. +package settingsresolve + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sort" + + "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// Context is the identity a resolution happens against. +// +// Every field is optional and an absent one simply drops the scopes that need +// it: no DeviceID means no profile_device candidates, no SeriesIDs means no +// profile_series. That is what lets one code path serve an identified client, +// an anonymous jellycompat seed, and a batch spanning many series. +type Context struct { + ProfileID string + DeviceID string + // LibraryIDs and SeriesIDs are the content contexts in play. A batch + // resolving a season passes every id once rather than resolving per item. + LibraryIDs []int + SeriesIDs []string +} + +// Constraints carries the policy inputs a definition's constrained_by may +// reference, keyed by policy_input name. A missing entry means the policy does +// not constrain that setting for this viewer. +// +// Values are compared through the definition's own value schema, so a ceiling +// on an ordered enum ranks by member order and a ceiling on a number compares +// numerically. +type Constraints map[string]json.RawMessage + +// Source names where an effective value came from. It is the resolved scope, or +// ScopeDefault when nothing was stored. +type Source = settingscontract.Scope + +// Effective is one resolved setting. +type Effective struct { + Key string `json:"key"` + Value json.RawMessage `json:"value"` + // Source is the scope Value came from, or "default". + Source Source `json:"source"` + + // StoredValue is what the user actually authored, present only when a + // constraint changed the answer. A capped 4K preference must survive the + // cap so it takes effect the day the cap lifts, so the stored value is + // reported rather than overwritten. + StoredValue json.RawMessage `json:"stored_value,omitempty"` + // Constrained is set when policy narrowed Value away from StoredValue. + Constrained bool `json:"constrained,omitempty"` + // ConstraintKind names how it was narrowed, for client copy. + ConstraintKind settingscontract.ConstraintKind `json:"constraint_kind,omitempty"` + + // Identity locates the row Value came from, so a client can offer "reset + // this device's override" against the exact scope that holds it. Empty for + // a default. + Identity *userstore.SettingIdentity `json:"-"` +} + +// Store is the read surface this package needs. It is satisfied by +// userstore.UserStore and by a fake in tests. +type Store interface { + ListSettingValuesForResolution( + ctx context.Context, query userstore.SettingResolutionQuery, + ) ([]userstore.SettingValue, error) +} + +// Resolver resolves against one contract. +type Resolver struct { + contract *settingscontract.Manifest +} + +// New returns a Resolver over the given contract. +func New(contract *settingscontract.Manifest) *Resolver { + return &Resolver{contract: contract} +} + +// Resolve returns the effective value for each requested key. +// +// One batched store read regardless of how many keys, libraries, or series are +// in play; ranking happens here in Go. Unknown keys are omitted rather than +// erroring, so a newer client asking for a setting this server does not have +// gets a short answer instead of a failed request. +func (r *Resolver) Resolve( + ctx context.Context, + store Store, + rc Context, + keys []string, + constraints Constraints, +) ([]Effective, error) { + if r == nil || r.contract == nil { + return nil, fmt.Errorf("settingsresolve: no contract") + } + + known := make([]string, 0, len(keys)) + defs := make(map[string]*settingscontract.Definition, len(keys)) + for _, key := range keys { + def, ok := r.contract.Lookup(key) + if !ok || def.Persistence != settingscontract.PersistenceRemote { + // client_local settings never have server rows; asking for one is + // not an error, it simply has no server answer. + continue + } + if _, seen := defs[key]; seen { + continue + } + defs[key] = def + known = append(known, key) + } + if len(known) == 0 { + return nil, nil + } + + stored, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{ + Keys: known, + ProfileID: rc.ProfileID, + DeviceID: rc.DeviceID, + LibraryIDs: rc.LibraryIDs, + SeriesIDs: rc.SeriesIDs, + }) + if err != nil { + return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err) + } + + byKey := make(map[string][]userstore.SettingValue, len(known)) + for _, row := range stored { + byKey[row.Key] = append(byKey[row.Key], row) + } + + out := make([]Effective, 0, len(known)) + for _, key := range known { + out = append(out, r.resolveOne(defs[key], byKey[key], rc, constraints)) + } + return out, nil +} + +// resolveOne ranks one key's candidates by its declared resolution order. +func (r *Resolver) resolveOne( + def *settingscontract.Definition, + candidates []userstore.SettingValue, + rc Context, + constraints Constraints, +) Effective { + eff := Effective{ + Key: def.Key, + Value: append(json.RawMessage(nil), def.DefaultValue...), + Source: settingscontract.ScopeDefault, + } + + for _, scope := range def.ResolutionOrder { + if scope == settingscontract.ScopeDefault { + break + } + row, ok := pickForScope(scope, candidates, rc) + if !ok { + continue + } + eff.Value = append(json.RawMessage(nil), row.Value...) + eff.Source = scope + identity := row.SettingIdentity + eff.Identity = &identity + break + } + + return applyConstraint(def, eff, constraints) +} + +// pickForScope returns the candidate row for one scope. +// +// Library and series scopes can return several rows in a batch — one per +// library or series in the request — so the caller's context decides which is +// the relevant one. Ties are broken by the most specific id in the request +// order, which is why a batch must resolve per item rather than expecting one +// answer to cover a whole season. +func pickForScope( + scope settingscontract.Scope, + candidates []userstore.SettingValue, + rc Context, +) (userstore.SettingValue, bool) { + matches := make([]userstore.SettingValue, 0, 2) + for _, row := range candidates { + if row.Scope != scope { + continue + } + switch scope { + case settingscontract.ScopeAccount: + matches = append(matches, row) + case settingscontract.ScopeProfile: + if row.ProfileID == rc.ProfileID { + matches = append(matches, row) + } + case settingscontract.ScopeProfileDevice: + if row.ProfileID == rc.ProfileID && row.DeviceID == rc.DeviceID && rc.DeviceID != "" { + matches = append(matches, row) + } + case settingscontract.ScopeProfileLibrary: + if row.ProfileID == rc.ProfileID && containsInt(rc.LibraryIDs, row.LibraryID) { + matches = append(matches, row) + } + case settingscontract.ScopeProfileSeries: + if row.ProfileID == rc.ProfileID && containsString(rc.SeriesIDs, row.SeriesID) { + matches = append(matches, row) + } + } + } + if len(matches) == 0 { + return userstore.SettingValue{}, false + } + if len(matches) > 1 { + // Deterministic rather than arbitrary: a batch spanning several + // libraries or series has no single right answer, and the caller is + // expected to resolve per item. Sorting means it at least cannot differ + // between two identical requests. + sort.Slice(matches, func(i, j int) bool { + if matches[i].LibraryID != matches[j].LibraryID { + return matches[i].LibraryID < matches[j].LibraryID + } + return matches[i].SeriesID < matches[j].SeriesID + }) + } + return matches[0], true +} + +// applyConstraint narrows an effective value to what policy permits. +// +// The stored value is never destroyed: a preference capped today must take +// effect the day the cap lifts, so the cap is reported alongside the authored +// value rather than replacing it. +func applyConstraint( + def *settingscontract.Definition, + eff Effective, + constraints Constraints, +) Effective { + if def.ConstrainedBy == nil || len(constraints) == 0 { + return eff + } + limit, ok := constraints[def.ConstrainedBy.PolicyInput] + if !ok || len(limit) == 0 { + return eff + } + + narrow, changed := narrowValue(def, eff.Value, limit) + if !changed { + return eff + } + eff.StoredValue = eff.Value + eff.Value = narrow + eff.Constrained = true + eff.ConstraintKind = def.ConstrainedBy.Constraint + return eff +} + +// narrowValue applies one constraint kind, returning the permitted value and +// whether it differs from the stored one. +func narrowValue( + def *settingscontract.Definition, + value, limit json.RawMessage, +) (json.RawMessage, bool) { + switch def.ConstrainedBy.Constraint { + case settingscontract.ConstraintLocked: + // The policy value replaces the user's outright. + if bytes.Equal(bytes.TrimSpace(value), bytes.TrimSpace(limit)) { + return value, false + } + return append(json.RawMessage(nil), limit...), true + + case settingscontract.ConstraintCeiling: + // null on a nullable numeric means "no cap of my own", which is + // unbounded above — exactly what a ceiling exists to bring down. It has + // no rank, so CompareValues reports 0 and the value would otherwise + // slip past the cap it most needs to obey. + if isNull(value) && isNumeric(def) { + return append(json.RawMessage(nil), limit...), true + } + if def.ValueSchema.CompareValues(value, limit) <= 0 { + return value, false + } + return append(json.RawMessage(nil), limit...), true + + case settingscontract.ConstraintFloor: + // The mirror of the above: unbounded above already satisfies any floor. + if isNull(value) && isNumeric(def) { + return value, false + } + if def.ValueSchema.CompareValues(value, limit) >= 0 { + return value, false + } + return append(json.RawMessage(nil), limit...), true + + case settingscontract.ConstraintAllowlist: + var allowed []json.RawMessage + if err := json.Unmarshal(limit, &allowed); err != nil || len(allowed) == 0 { + return value, false + } + trimmed := bytes.TrimSpace(value) + for _, entry := range allowed { + if bytes.Equal(bytes.TrimSpace(entry), trimmed) { + return value, false + } + } + // Falling back to the first allowed member rather than the default: + // the default may itself be outside the allowlist, and an effective + // value the policy forbids is the one thing this must never return. + return append(json.RawMessage(nil), allowed[0]...), true + } + return value, false +} + +func isNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + +func isNumeric(def *settingscontract.Definition) bool { + return def.ValueSchema.Type == settingscontract.TypeInteger || + def.ValueSchema.Type == settingscontract.TypeNumber +} + +func containsInt(haystack []int, needle int) bool { + for _, v := range haystack { + if v == needle { + return true + } + } + return false +} + +func containsString(haystack []string, needle string) bool { + for _, v := range haystack { + if v == needle { + return true + } + } + return false +} diff --git a/internal/settingsresolve/resolve_test.go b/internal/settingsresolve/resolve_test.go new file mode 100644 index 00000000..0e474052 --- /dev/null +++ b/internal/settingsresolve/resolve_test.go @@ -0,0 +1,375 @@ +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) + } + }) + } +} + +// 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)) + } +} + +// 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) + } + + // 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) + } +} + +// 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)