refactor(settings): resolve metadata language canonically in access and policy
Repoint the last legacy column readers onto canonical contract resolution (settings cutover task A4a): - access.Resolver and policy.ViewerResolver now resolve catalog.metadata_language through settingsresolve (profile scope -> contract default) via a shared access.PreferredMetadataLanguage helper, instead of reading user_profiles.preferred_metadata_language. Resolution is deliberately unconstrained: the policy input this preference feeds is the one a constraint would have to reference, which is circular — see the key's manifest notes. - playback start now resolves playback.audio_language canonically for the profile default instead of reading user_profiles.language, matching the catalog detail path. Series and library override handling is unchanged. - items.go needed no change: it already consumes the resolver-produced scope.PreferredMetadataLanguage. The legacy columns keep their values but are no longer read on these paths; a profile with only a column value now resolves to the contract default, and a stored canonical value wins. Tests pin both directions in access, policy (including scope parity, where the column is now a decoy), and the playback handler. Read cost is one batched store read per resolution, same as the profile-row read it replaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// PreferredMetadataLanguage resolves catalog.metadata_language canonically for
|
||||
// one profile: the stored profile-scope value, else the contract default. The
|
||||
// legacy user_profiles.preferred_metadata_language column is deliberately not
|
||||
// consulted — it migrated to the canonical store, and reading both would let
|
||||
// them disagree.
|
||||
//
|
||||
// Resolution is unconstrained on purpose. The manifest gives this key no
|
||||
// constrained_by because the policy input that could constrain it
|
||||
// (profile_preferred_metadata_language) is populated from this very
|
||||
// preference; a constraint here would be circular. See the key's notes in
|
||||
// contracts/settings/v1/manifest.json.
|
||||
//
|
||||
// A resolution failure degrades to "" — the contract default, meaning "inherit
|
||||
// the library's metadata language" — rather than failing scope resolution: the
|
||||
// language is a presentation preference, not an access boundary.
|
||||
func PreferredMetadataLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
|
||||
if store == nil || profileID == "" {
|
||||
return ""
|
||||
}
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
|
||||
settingsresolve.Context{ProfileID: profileID},
|
||||
[]string{settingskeys.CatalogMetadataLanguage}, nil)
|
||||
if err != nil || len(resolved) == 0 {
|
||||
return ""
|
||||
}
|
||||
// The contract default is null — "no preference" — which unmarshals to "",
|
||||
// the same spelling the legacy column used for unset.
|
||||
var language string
|
||||
if json.Unmarshal(resolved[0].Value, &language) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(language)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
|
||||
scope.MaxContentRating = profile.MaxContentRating
|
||||
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
|
||||
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
|
||||
scope.PreferredMetadataLanguage = PreferredMetadataLanguage(ctx, store, input.ProfileID)
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
|
||||
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -40,6 +42,10 @@ type stubStore struct {
|
||||
profile *userstore.Profile
|
||||
err error
|
||||
settings map[string]string
|
||||
// settingValues are the canonical setting rows the resolver may read
|
||||
// through ListSettingValuesForResolution. Scope matching is the
|
||||
// resolver's job, so the stub returns them unfiltered.
|
||||
settingValues []userstore.SettingValue
|
||||
}
|
||||
|
||||
func (s stubStore) CreateProfile(context.Context, userstore.Profile) error { panic("unused") }
|
||||
@@ -276,7 +282,7 @@ func (s stubStore) GetSettingValue(context.Context, userstore.SettingIdentity) (
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
|
||||
panic("unused")
|
||||
return s.settingValues, nil
|
||||
}
|
||||
func (s stubStore) UpsertSettingValue(context.Context, userstore.SettingIdentity, json.RawMessage) (*userstore.SettingValue, error) {
|
||||
panic("unused")
|
||||
@@ -461,6 +467,60 @@ func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
|
||||
// The canonical catalog.metadata_language row wins; the legacy profile
|
||||
// column carries a decoy value that must no longer be read.
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
},
|
||||
settingValues: []userstore.SettingValue{{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`"de"`),
|
||||
}},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if scope.PreferredMetadataLanguage != "de" {
|
||||
t.Fatalf("PreferredMetadataLanguage = %q, want canonical value %q", scope.PreferredMetadataLanguage, "de")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MetadataLanguageIgnoresLegacyColumn(t *testing.T) {
|
||||
// A profile with only the legacy column value falls to the contract
|
||||
// default ("" — inherit), proving the column is no longer read.
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if scope.PreferredMetadataLanguage != "" {
|
||||
t.Fatalf("PreferredMetadataLanguage = %q, want contract default \"\"", scope.PreferredMetadataLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_AppliesGroupPolicy(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{
|
||||
|
||||
@@ -30,6 +30,9 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||||
@@ -1211,6 +1214,34 @@ func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *mod
|
||||
return lang
|
||||
}
|
||||
|
||||
// resolvedProfileAudioLanguage returns the effective playback.audio_language
|
||||
// for the profile with no content context, resolved through the settings
|
||||
// contract — the canonical replacement for reading the legacy
|
||||
// user_profiles.language column, matching catalog's detail resolution. It may
|
||||
// return playback.OriginalLanguageSentinel, which the caller resolves to a
|
||||
// concrete language. Returns "" when nothing is stored: the contract default
|
||||
// is null, "no preference".
|
||||
func resolvedProfileAudioLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
|
||||
if store == nil || profileID == "" {
|
||||
return ""
|
||||
}
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
|
||||
settingsresolve.Context{ProfileID: profileID},
|
||||
[]string{settingskeys.PlaybackAudioLanguage}, nil)
|
||||
if err != nil || len(resolved) == 0 {
|
||||
return ""
|
||||
}
|
||||
var language string
|
||||
if json.Unmarshal(resolved[0].Value, &language) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(language)
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) restoreSessionProgress(
|
||||
ctx context.Context,
|
||||
session *playback.Session,
|
||||
@@ -1715,9 +1746,7 @@ func (h *PlaybackHandler) handleStartPlaybackLegacy(w http.ResponseWriter, r *ht
|
||||
if seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
|
||||
seriesPref.AudioLanguage = h.resolveOriginalLanguage(r.Context(), file)
|
||||
}
|
||||
if profile, profErr := store.GetProfile(r.Context(), profileID); profErr == nil && profile != nil {
|
||||
preferredLang = profile.Language
|
||||
}
|
||||
preferredLang = resolvedProfileAudioLanguage(r.Context(), store, profileID)
|
||||
|
||||
// Resolve library override (if no series sticky pref exists).
|
||||
var libraryAudioLang string
|
||||
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||||
"github.com/Silo-Server/silo-server/internal/userdb"
|
||||
@@ -693,6 +695,86 @@ func TestHandleStartPlayback_DoesNotPersistSeriesPlaybackPreferenceOnFailure(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartPlayback_AudioLanguageResolvesCanonically(t *testing.T) {
|
||||
// The default audio track comes from the canonical playback.audio_language
|
||||
// value resolved through the settings contract, not from the legacy
|
||||
// user_profiles.language column. The column always carries the language of
|
||||
// a different track than the canonical answer, so a regression to reading
|
||||
// it flips the selected index.
|
||||
newFile := func(t *testing.T) *models.MediaFile {
|
||||
return &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "movie.mkv"),
|
||||
Duration: 3600,
|
||||
AudioTracks: []models.AudioTrack{
|
||||
{Language: "eng", Codec: "aac", Default: true},
|
||||
{Language: "jpn", Codec: "aac"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
setLegacyLanguage := func(t *testing.T, store userstore.UserStore, language string) {
|
||||
t.Helper()
|
||||
if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{
|
||||
Language: &language,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed legacy language column: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) playbackSessionResponse {
|
||||
t.Helper()
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
|
||||
handler.StoreProvider = testUserStoreProvider{store: store}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/playback/start",
|
||||
strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct"}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp playbackSessionResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
t.Run("canonical value wins over legacy column", func(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
setLegacyLanguage(t, store, "eng")
|
||||
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
Key: settingskeys.PlaybackAudioLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
}, json.RawMessage(`"ja"`)); err != nil {
|
||||
t.Fatalf("seed canonical audio language: %v", err)
|
||||
}
|
||||
|
||||
resp := startPlayback(t, store, newFile(t))
|
||||
if resp.AudioTrackIndex != 1 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", resp.AudioTrackIndex)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy column alone no longer selects a track", func(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
setLegacyLanguage(t, store, "jpn")
|
||||
|
||||
resp := startPlayback(t, store, newFile(t))
|
||||
// No canonical value stored: the contract default is "no preference",
|
||||
// so selection falls to the file's default track, not the column's.
|
||||
if resp.AudioTrackIndex != 0 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", resp.AudioTrackIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleChangeAudioTrack_PersistsSeriesAudioPreferenceSignature(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
file := &models.MediaFile{
|
||||
|
||||
@@ -10,9 +10,31 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// parityMetadataLang is the canonically stored catalog.metadata_language for
|
||||
// the parity profile. parityProfile's legacy column deliberately carries a
|
||||
// different value, so a resolver that regresses to reading the column breaks
|
||||
// parity instead of passing by coincidence.
|
||||
const parityMetadataLang = "fr"
|
||||
|
||||
func parityMetadataLangValues(profile *userstore.Profile) []userstore.SettingValue {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
return []userstore.SettingValue{{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: profile.ID,
|
||||
},
|
||||
Value: json.RawMessage(`"` + parityMetadataLang + `"`),
|
||||
}}
|
||||
}
|
||||
|
||||
func TestResolveViewerScopeParity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
engine, err := NewEngine(ctx)
|
||||
@@ -84,8 +106,9 @@ func TestResolveViewerScopeParity(t *testing.T) {
|
||||
profile.MaxContentRating = ratingCase.value
|
||||
}
|
||||
store := parityStore{
|
||||
profile: profile,
|
||||
settings: disabledSetting(disabledCase.ids),
|
||||
profile: profile,
|
||||
settings: disabledSetting(disabledCase.ids),
|
||||
settingValues: parityMetadataLangValues(profile),
|
||||
}
|
||||
resolver := access.NewResolver(
|
||||
parityUserRepo{user: user},
|
||||
@@ -170,11 +193,13 @@ func profileRatingCases(profile *userstore.Profile) []namedString {
|
||||
|
||||
func parityProfile(restricted bool, allowed []int) *userstore.Profile {
|
||||
return &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
PINHash: "pin-hash",
|
||||
MaxContentRating: "PG-13",
|
||||
MaxPlaybackQuality: "720p",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
ID: "prof-1",
|
||||
PINHash: "pin-hash",
|
||||
MaxContentRating: "PG-13",
|
||||
MaxPlaybackQuality: "720p",
|
||||
// A decoy: the canonical value is parityMetadataLang, stored through
|
||||
// parityMetadataLangValues. This column must no longer be read.
|
||||
PreferredMetadataLanguage: "hu",
|
||||
LibraryRestrictionsEnabled: restricted,
|
||||
AllowedLibraryIDs: cloneParityInts(allowed),
|
||||
}
|
||||
@@ -205,7 +230,9 @@ func scopeInputFromParity(user *models.User, profile *userstore.Profile, disable
|
||||
input.ProfileLibraryIDs = cloneParityInts(profile.AllowedLibraryIDs)
|
||||
input.ProfileHasPIN = profile.PINHash != ""
|
||||
input.ProfileVerified = verified
|
||||
input.ProfileMetadataLang = profile.PreferredMetadataLanguage
|
||||
// Canonically resolved, mirroring ViewerResolver — the legacy profile
|
||||
// column is no longer a policy input.
|
||||
input.ProfileMetadataLang = parityMetadataLang
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -287,8 +314,13 @@ func (p parityStoreProvider) Close() error {
|
||||
|
||||
type parityStore struct {
|
||||
userstore.UserStore
|
||||
profile *userstore.Profile
|
||||
settings map[string]string
|
||||
profile *userstore.Profile
|
||||
settings map[string]string
|
||||
settingValues []userstore.SettingValue
|
||||
}
|
||||
|
||||
func (s parityStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
|
||||
return s.settingValues, nil
|
||||
}
|
||||
|
||||
func (s parityStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
|
||||
|
||||
@@ -103,7 +103,12 @@ func (r *ViewerResolver) Resolve(ctx context.Context, input access.ResolveInput)
|
||||
policyInput.ProfileLibraryLimited = profile.LibraryRestrictionsEnabled
|
||||
policyInput.ProfileLibraryIDs = slices.Clone(profile.AllowedLibraryIDs)
|
||||
policyInput.ProfileHasPIN = profile.PINHash != ""
|
||||
policyInput.ProfileMetadataLang = profile.PreferredMetadataLanguage
|
||||
// Resolved canonically (profile scope -> contract default), not read off
|
||||
// the legacy profile column it migrated from. scope.rego relays this
|
||||
// value unchanged as a preference; the manifest deliberately declares no
|
||||
// constraint on it, since constraining a setting by a policy input fed
|
||||
// from that same setting would be circular.
|
||||
policyInput.ProfileMetadataLang = access.PreferredMetadataLanguage(ctx, store, input.ProfileID)
|
||||
}
|
||||
|
||||
if r.pdp == nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -22,11 +25,13 @@ func TestViewerResolverParityWithLegacyResolver(t *testing.T) {
|
||||
user *models.User
|
||||
profile *userstore.Profile
|
||||
settings map[string]string
|
||||
settingValues []userstore.SettingValue
|
||||
input access.ResolveInput
|
||||
tokens access.ProfileTokenValidator
|
||||
wantNilAllowed bool
|
||||
wantEmptyAllowed bool
|
||||
wantDisabled []int
|
||||
wantMetadataLang string
|
||||
}{
|
||||
{
|
||||
name: "no profile unrestricted",
|
||||
@@ -167,13 +172,39 @@ func TestViewerResolverParityWithLegacyResolver(t *testing.T) {
|
||||
input: access.ResolveInput{UserID: 1, SessionID: "sess-1", ProfileID: "prof-1"},
|
||||
wantNilAllowed: true,
|
||||
},
|
||||
{
|
||||
// The canonical catalog.metadata_language row feeds the policy input
|
||||
// and comes back out on the scope; the legacy profile column carries
|
||||
// a decoy value that must no longer be read.
|
||||
name: "metadata language resolves canonically",
|
||||
user: &models.User{
|
||||
ID: 1,
|
||||
AccessPolicyRevision: 5,
|
||||
},
|
||||
profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
},
|
||||
settingValues: []userstore.SettingValue{{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`"de"`),
|
||||
}},
|
||||
input: access.ResolveInput{UserID: 1, SessionID: "sess-1", ProfileID: "prof-1"},
|
||||
wantNilAllowed: true,
|
||||
wantMetadataLang: "de",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := viewerResolverTestStore{
|
||||
profile: tt.profile,
|
||||
settings: tt.settings,
|
||||
profile: tt.profile,
|
||||
settings: tt.settings,
|
||||
settingValues: tt.settingValues,
|
||||
}
|
||||
users := viewerResolverUserRepo{user: tt.user}
|
||||
stores := viewerResolverStoreProvider{store: store}
|
||||
@@ -199,8 +230,14 @@ func TestViewerResolverParityWithLegacyResolver(t *testing.T) {
|
||||
if tt.wantDisabled != nil && !reflect.DeepEqual(policyScope.DisabledLibraryIDs, tt.wantDisabled) {
|
||||
t.Fatalf("DisabledLibraryIDs = %#v, want %#v", policyScope.DisabledLibraryIDs, tt.wantDisabled)
|
||||
}
|
||||
// Always asserted: cases with only the legacy profile column expect
|
||||
// "" — the canonical resolution's contract default — proving the
|
||||
// column is no longer read.
|
||||
if policyScope.PreferredMetadataLanguage != tt.wantMetadataLang {
|
||||
t.Fatalf("PreferredMetadataLanguage = %q, want %q", policyScope.PreferredMetadataLanguage, tt.wantMetadataLang)
|
||||
}
|
||||
|
||||
decisionInput := viewerResolverExpectedInput(tt.user, tt.profile, tt.input, policyScope.ProfileVerified, access.DisabledLibraryIDs(ctx, store))
|
||||
decisionInput := viewerResolverExpectedInput(tt.user, tt.profile, tt.input, policyScope.ProfileVerified, access.DisabledLibraryIDs(ctx, store), access.PreferredMetadataLanguage(ctx, store, tt.input.ProfileID))
|
||||
decision, _, err := pdp.ResolveViewerScope(ctx, decisionInput)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveViewerScope() error: %v", err)
|
||||
@@ -451,6 +488,10 @@ type viewerResolverTestStore struct {
|
||||
profile *userstore.Profile
|
||||
err error
|
||||
settings map[string]string
|
||||
// settingValues are the canonical setting rows the resolver may read
|
||||
// through ListSettingValuesForResolution. Scope matching is the
|
||||
// resolver's job, so the store returns them unfiltered.
|
||||
settingValues []userstore.SettingValue
|
||||
}
|
||||
|
||||
func (s viewerResolverTestStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
|
||||
@@ -467,12 +508,17 @@ func (s viewerResolverTestStore) GetSetting(_ context.Context, key string) (stri
|
||||
return s.settings[key], nil
|
||||
}
|
||||
|
||||
func (s viewerResolverTestStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
|
||||
return s.settingValues, nil
|
||||
}
|
||||
|
||||
func viewerResolverExpectedInput(
|
||||
user *models.User,
|
||||
profile *userstore.Profile,
|
||||
input access.ResolveInput,
|
||||
profileVerified bool,
|
||||
disabled []int,
|
||||
metadataLang string,
|
||||
) ScopeInput {
|
||||
out := ScopeInput{
|
||||
SchemaVersion: 1,
|
||||
@@ -495,7 +541,9 @@ func viewerResolverExpectedInput(
|
||||
out.ProfileLibraryLimited = profile.LibraryRestrictionsEnabled
|
||||
out.ProfileLibraryIDs = cloneViewerResolverInts(profile.AllowedLibraryIDs)
|
||||
out.ProfileHasPIN = profile.PINHash != ""
|
||||
out.ProfileMetadataLang = profile.PreferredMetadataLanguage
|
||||
// Canonically resolved, mirroring ViewerResolver — the legacy profile
|
||||
// column is no longer a policy input.
|
||||
out.ProfileMetadataLang = metadataLang
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user