Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ coverage:
threshold: 1%
patch:
default:
target: 70%
target: 65%
threshold: 1%
comment:
layout: "condensed_header, diff, flags, tree, condensed_footer"
require_changes: true
6 changes: 3 additions & 3 deletions internal/access/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ func (s *Service) permissionsForPath(
return LibraryPermissions{}, false, err
}
if canonical == "" {
return LibraryPermissions{}, false, nil //nolint:exhaustruct // zero perms
return LibraryPermissions{}, false, nil
}

libraries, err := s.store.ListLibraries(ctx)
Expand All @@ -619,7 +619,7 @@ func (s *Service) permissionsForPath(

library, ok := MatchLibrary(libraries, canonical)
if !ok {
return LibraryPermissions{}, false, nil //nolint:exhaustruct // zero perms
return LibraryPermissions{}, false, nil
}

grants, err := s.store.GetUserGrantMap(ctx, userID)
Expand All @@ -629,7 +629,7 @@ func (s *Service) permissionsForPath(

perms, found := grants[library.ID]
if !found {
return LibraryPermissions{}, false, nil //nolint:exhaustruct // zero perms
return LibraryPermissions{}, false, nil
}

return perms, true, nil
Expand Down
2 changes: 1 addition & 1 deletion internal/access/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestService_ListReadableLibraries_FiltersByReadGrant(t *testing.T) {
},
}
service := access.NewService(store)
user := auth.PublicUser{ //nolint:exhaustruct // test fixture
user := auth.PublicUser{
ID: testUserOne,
Role: auth.RoleUser,
}
Expand Down
6 changes: 3 additions & 3 deletions internal/auth/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func (s *Service) UpdateUser( //nolint:cyclop // email/enabled/role patches
ctx,
userID,
UserPatch{Role: patch.Role},
) //nolint:exhaustruct // role-only
)
if err != nil {
return AdminUser{}, fmt.Errorf("update user: %w", err)
}
Expand Down Expand Up @@ -225,7 +225,7 @@ func (s *Service) DeleteUser(ctx context.Context, userID string) error {
err = s.guardLastAdminMutation(
ctx,
*target,
UserPatch{Enabled: new(false)}, //nolint:exhaustruct // last-admin check
UserPatch{Enabled: new(false)},
)
if err != nil {
return err
Expand Down Expand Up @@ -273,7 +273,7 @@ func (s *Service) applyEnabledPatch(ctx context.Context, userID string, enabled
err = s.store.UpdateUser(
ctx,
userID,
UserPatch{Enabled: &disabled}, //nolint:exhaustruct // partial patch
UserPatch{Enabled: &disabled},
)
if err != nil {
return fmt.Errorf("disable user: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/ginjwt/issuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func New(store auth.Store, config auth.Config) (*Issuer, error) {

refreshStore := authpostgres.NewRefreshTokenStore(store)
jwtMiddleware, err := jwtmw.New(
&jwtmw.GinJWTMiddleware{ //nolint:exhaustruct // gin-jwt defaults
&jwtmw.GinJWTMiddleware{
Realm: auth.JWTRealm,
Key: secret,
Timeout: accessTTL,
Expand Down
75 changes: 75 additions & 0 deletions internal/auth/playback_prefs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package auth

import (
"context"
"errors"
"fmt"
"strings"
)

const maxAudioLanguagePrefs = 3

var (
// ErrInvalidAudioLanguagePrefs is returned when more than three languages are supplied.
ErrInvalidAudioLanguagePrefs = errors.New("audio language list exceeds maximum of 3")
errPlaybackStoreUnavailable = errors.New("auth store unavailable")
)

// PlaybackPreferences holds per-user playback defaults (E-28).
type PlaybackPreferences struct {
AudioLanguages []string `json:"audioLanguages"`
}

// PlaybackAudioLanguages returns normalized user audio language priority (may be empty).
func (s *Service) PlaybackAudioLanguages(ctx context.Context, userID string) ([]string, error) {
if s.store == nil {
return nil, nil
}

prefs, err := s.store.GetPlaybackPreferences(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get playback preferences: %w", err)
}

return prefs.AudioLanguages, nil
}

// UpdatePlaybackPreferences stores up to three ordered audio language codes.
func (s *Service) UpdatePlaybackPreferences(
ctx context.Context,
userID string,
prefs PlaybackPreferences,
) (PlaybackPreferences, error) {
normalized, err := NormalizePlaybackPreferences(prefs)
if err != nil {
return PlaybackPreferences{}, err
}

if s.store == nil {
return PlaybackPreferences{}, errPlaybackStoreUnavailable
}

err = s.store.SavePlaybackPreferences(ctx, userID, normalized)
if err != nil {
return PlaybackPreferences{}, fmt.Errorf("save playback preferences: %w", err)
}

return normalized, nil
}

// NormalizePlaybackPreferences validates and normalizes audio language codes.
func NormalizePlaybackPreferences(prefs PlaybackPreferences) (PlaybackPreferences, error) {
langs := make([]string, 0, len(prefs.AudioLanguages))
for _, raw := range prefs.AudioLanguages {
lang := strings.ToLower(strings.TrimSpace(raw))
if lang == "" {
continue
}
langs = append(langs, lang)
if len(langs) > maxAudioLanguagePrefs {
return PlaybackPreferences{}, ErrInvalidAudioLanguagePrefs
}
}

return PlaybackPreferences{AudioLanguages: langs}, nil
}
57 changes: 57 additions & 0 deletions internal/auth/playback_prefs_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package auth_test

import (
"context"
"errors"
"sudoStream/internal/auth"
"testing"

allure "github.com/allure-framework/allure-go/commons/gotest"
)

func TestService_PlaybackPreferences(t *testing.T) {
allure.Test(t, "playback prefs round-trip via auth service", func(a *allure.Context) {
t := a.T()
ctx := context.Background()
service, _ := newAuthIntegration(ctx, t)
admin := seedAdminUser(ctx, t, service)

empty, err := service.PlaybackAudioLanguages(ctx, admin.ID)
if err != nil {
t.Fatalf("get empty: %v", err)
}
if len(empty) != 0 {
t.Fatalf("expected no langs, got %v", empty)
}

saved, err := service.UpdatePlaybackPreferences(ctx, admin.ID, auth.PlaybackPreferences{
AudioLanguages: []string{" EN ", "ru"},
})
if err != nil {
t.Fatalf("update: %v", err)
}
if len(saved.AudioLanguages) != 2 || saved.AudioLanguages[0] != "en" {
t.Fatalf("saved: %+v", saved.AudioLanguages)
}

langs, err := service.PlaybackAudioLanguages(ctx, admin.ID)
if err != nil {
t.Fatalf("reload: %v", err)
}
if len(langs) != 2 || langs[1] != "ru" {
t.Fatalf("reload: %v", langs)
}

_, err = service.UpdatePlaybackPreferences(ctx, admin.ID, auth.PlaybackPreferences{
AudioLanguages: []string{"en", "ru", "de", "fr"},
})
if !errors.Is(err, auth.ErrInvalidAudioLanguagePrefs) {
t.Fatalf("expected ErrInvalidAudioLanguagePrefs, got %v", err)
}

_, err = service.PlaybackAudioLanguages(ctx, "00000000-0000-0000-0000-000000000099")
if err == nil {
t.Fatal("expected error for missing user")
}
})
}
76 changes: 76 additions & 0 deletions internal/auth/playback_prefs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package auth

import (
"context"
"errors"
"testing"

allure "github.com/allure-framework/allure-go/commons/gotest"
)

func TestNormalizePlaybackPreferences(t *testing.T) {
t.Parallel()

allure.Test(t, "accepts up to three languages", func(a *allure.Context) {
t := a.T()
got, err := NormalizePlaybackPreferences(PlaybackPreferences{
AudioLanguages: []string{" EN ", "ru", "jpn"},
})
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(got.AudioLanguages) != 3 || got.AudioLanguages[0] != "en" {
t.Fatalf("got %+v", got.AudioLanguages)
}
})

allure.Test(t, "rejects more than three languages", func(a *allure.Context) {
t := a.T()
_, err := NormalizePlaybackPreferences(PlaybackPreferences{
AudioLanguages: []string{"en", "ru", "de", "fr"},
})
if err == nil {
t.Fatal("expected error")
}
})

allure.Test(t, "empty list normalizes to empty slice", func(a *allure.Context) {
t := a.T()
got, err := NormalizePlaybackPreferences(PlaybackPreferences{
AudioLanguages: []string{" ", ""},
})
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(got.AudioLanguages) != 0 {
t.Fatalf("got %+v", got.AudioLanguages)
}
})
}

func TestPlaybackPreferences_StoreUnavailable(t *testing.T) {
t.Parallel()

allure.Test(t, "update fails when auth store is unset", func(a *allure.Context) {
t := a.T()
service := &Service{}
_, err := service.UpdatePlaybackPreferences(context.Background(), "user-id", PlaybackPreferences{
AudioLanguages: []string{"en"},
})
if !errors.Is(err, errPlaybackStoreUnavailable) {
t.Fatalf("expected errPlaybackStoreUnavailable, got %v", err)
}
})

allure.Test(t, "playback languages empty when store is unset", func(a *allure.Context) {
t := a.T()
service := &Service{}
langs, err := service.PlaybackAudioLanguages(context.Background(), "user-id")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if langs != nil {
t.Fatalf("expected nil langs, got %v", langs)
}
})
}
21 changes: 11 additions & 10 deletions internal/auth/postgres/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ import (
)

type userModel struct {
ID string `gorm:"column:id;primaryKey;type:uuid"`
Email string `gorm:"column:email"`
PasswordHash string `gorm:"column:password_hash"`
Role string `gorm:"column:role"`
Enabled bool `gorm:"column:enabled"`
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at"`
MustChangePassword bool `gorm:"column:must_change_password"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
LastLoginAt *time.Time `gorm:"column:last_login_at"`
ID string `gorm:"column:id;primaryKey;type:uuid"`
Email string `gorm:"column:email"`
PasswordHash string `gorm:"column:password_hash"`
Role string `gorm:"column:role"`
Enabled bool `gorm:"column:enabled"`
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at"`
MustChangePassword bool `gorm:"column:must_change_password"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
LastLoginAt *time.Time `gorm:"column:last_login_at"`
AudioLanguagePrefs datatypes.JSON `gorm:"column:audio_language_prefs"`
}

func (userModel) TableName() string {
Expand Down
51 changes: 51 additions & 0 deletions internal/auth/postgres/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func (s *Store) CreateUser(ctx context.Context, user auth.User, passwordHash str
Role: user.Role,
Enabled: user.Enabled,
MustChangePassword: user.MustChangePassword,
AudioLanguagePrefs: datatypes.JSON("[]"),
}

err := s.db.WithContext(ctx).
Expand Down Expand Up @@ -897,6 +898,56 @@ func (s *Store) ConsumeBackupCode(ctx context.Context, userID string, index int)
return nil
}

// GetPlaybackPreferences loads ordered audio language codes for a user.
func (s *Store) GetPlaybackPreferences(ctx context.Context, userID string) (auth.PlaybackPreferences, error) {
var model userModel
err := s.db.WithContext(ctx).Select("audio_language_prefs").Where("id = ?", userID).First(&model).Error
if err != nil {
return auth.PlaybackPreferences{}, fmt.Errorf("get playback preferences: %w", err)
}

return playbackPrefsFromJSON(model.AudioLanguagePrefs), nil
}

// SavePlaybackPreferences stores ordered audio language codes for a user.
func (s *Store) SavePlaybackPreferences(
ctx context.Context,
userID string,
prefs auth.PlaybackPreferences,
) error {
raw, err := json.Marshal(prefs.AudioLanguages)
if err != nil {
return fmt.Errorf("encode playback preferences: %w", err)
}

result := s.db.WithContext(ctx).
Model(&userModel{}).
Where("id = ?", userID).
Updates(map[string]any{
"audio_language_prefs": datatypes.JSON(raw),
columnUpdatedAt: time.Now().UTC(),
})
if result.Error != nil {
return fmt.Errorf("save playback preferences: %w", result.Error)
}

return nil
}

func playbackPrefsFromJSON(raw datatypes.JSON) auth.PlaybackPreferences {
if len(raw) == 0 {
return auth.PlaybackPreferences{AudioLanguages: []string{}}
}

var langs []string
err := json.Unmarshal(raw, &langs)
if err != nil {
return auth.PlaybackPreferences{AudioLanguages: []string{}}
}

return auth.PlaybackPreferences{AudioLanguages: langs}
}

func userFromModel(model userModel) *auth.User {
return &auth.User{
ID: model.ID,
Expand Down
Loading
Loading