mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
442 lines
14 KiB
Go
442 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
|
"github.com/ShukeBta/MediaStationGo/internal/database"
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
)
|
|
|
|
func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *ProfileService, *PermissionService) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.TelegramBinding{}, &model.Setting{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repos := repository.New(db)
|
|
cfg := &config.Config{}
|
|
cfg.Secrets.JWTSecret = "test-secret"
|
|
log := zap.NewNop()
|
|
permissions := NewPermissionService(log, repos)
|
|
tokenSvc := NewTokenService(cfg, log, repos)
|
|
auth := NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
|
profile := NewProfileService(log, repos)
|
|
return repos, auth, profile, permissions
|
|
}
|
|
|
|
func TestRegisterRejectsMoreThanTwentyUsers(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, auth, _, _ := newAuthTestServices(t)
|
|
for i := 0; i < MaxUsers; i++ {
|
|
if err := repos.User.Create(ctx, &model.User{
|
|
Username: fmt.Sprintf("user-%02d", i),
|
|
PasswordHash: "hash",
|
|
Role: "user",
|
|
Tier: "free",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
_, _, err := auth.Register(ctx, "overflow", "password")
|
|
if !errors.Is(err, ErrUserLimitReached) {
|
|
t.Fatalf("expected ErrUserLimitReached, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegisterUsesLicensedUserLimit(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, auth, _, _ := newAuthTestServices(t)
|
|
maxUsers := 25
|
|
state := LicenseActivationState{Valid: true, LicenseType: "plus", MaxUsers: &maxUsers}
|
|
raw, _ := json.Marshal(state)
|
|
if err := repos.Setting.Set(ctx, LicenseSettingActivation, string(raw)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < OpenSourceUserLimit; i++ {
|
|
if err := repos.User.Create(ctx, &model.User{
|
|
Username: fmt.Sprintf("licensed-%02d", i),
|
|
PasswordHash: "hash",
|
|
Role: "user",
|
|
Tier: "free",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if _, _, err := auth.Register(ctx, "extra", "password"); err != nil {
|
|
t.Fatalf("licensed user limit should allow user 21: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLicensedMaxUsersCanBeUnlimited(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, _, _, _ := newAuthTestServices(t)
|
|
state := LicenseActivationState{Valid: true, LicenseType: "enterprise", UnlimitedUsers: true}
|
|
raw, _ := json.Marshal(state)
|
|
if err := repos.Setting.Set(ctx, LicenseSettingActivation, string(raw)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := LicensedMaxUsers(ctx, repos); got <= 1_000_000 {
|
|
t.Fatalf("unlimited license should return a very high limit, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) {
|
|
_, auth, _, _ := newAuthTestServices(t)
|
|
user, _, err := auth.Register(context.Background(), "viewer", "password")
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if !user.HideAdult {
|
|
t.Fatal("new users should hide adult libraries by default")
|
|
}
|
|
}
|
|
|
|
func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, auth, _, _ := newAuthTestServices(t)
|
|
user, _, err := auth.Register(ctx, "viewer", "old-password")
|
|
if err != nil {
|
|
t.Fatalf("register old user: %v", err)
|
|
}
|
|
if err := repos.DB.Create(&model.TelegramBinding{
|
|
TelegramUserID: 10001,
|
|
TelegramName: "@viewer",
|
|
ChatID: 10001,
|
|
UserID: user.ID,
|
|
}).Error; err != nil {
|
|
t.Fatalf("create telegram binding: %v", err)
|
|
}
|
|
if err := repos.User.Delete(ctx, user.ID); err != nil {
|
|
t.Fatalf("delete user: %v", err)
|
|
}
|
|
|
|
next, _, err := auth.Register(ctx, "viewer", "new-password")
|
|
if err != nil {
|
|
t.Fatalf("register same username after delete: %v", err)
|
|
}
|
|
if next.ID == user.ID {
|
|
t.Fatal("recreated user should be a new account row")
|
|
}
|
|
if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
|
|
t.Fatalf("login recreated user: %v", err)
|
|
}
|
|
var bindings int64
|
|
if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 10001).Count(&bindings).Error; err != nil {
|
|
t.Fatalf("count bindings: %v", err)
|
|
}
|
|
if bindings != 0 {
|
|
t.Fatalf("deleted user telegram bindings should be removed, got %d", bindings)
|
|
}
|
|
}
|
|
|
|
func TestRegisterReleasesLegacySoftDeletedUsername(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, auth, _, _ := newAuthTestServices(t)
|
|
if err := repos.User.Create(ctx, &model.User{
|
|
Username: "legacy",
|
|
PasswordHash: "hash",
|
|
Role: "user",
|
|
Tier: "free",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacy, err := repos.User.FindByUsername(ctx, "legacy")
|
|
if err != nil || legacy == nil {
|
|
t.Fatalf("find legacy user: %v", err)
|
|
}
|
|
if err := repos.DB.Delete(&model.User{}, "id = ?", legacy.ID).Error; err != nil {
|
|
t.Fatalf("legacy soft delete: %v", err)
|
|
}
|
|
|
|
if _, _, err := auth.Register(ctx, "legacy", "new-password"); err != nil {
|
|
t.Fatalf("register should release old soft-deleted username: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAdminResetPasswordAllowsLoginWithNewPassword(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, auth, _, _ := newAuthTestServices(t)
|
|
user, _, err := auth.Register(ctx, "viewer", "old-password")
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
|
|
if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil {
|
|
t.Fatalf("reset password: %v", err)
|
|
}
|
|
if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) {
|
|
t.Fatalf("old password should fail, got %v", err)
|
|
}
|
|
if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
|
|
t.Fatalf("new password should login: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoginAcceptsUsernameCaseVariant(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, auth, _, _ := newAuthTestServices(t)
|
|
if _, _, err := auth.Register(ctx, "viewer", "password"); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if _, err := auth.Login(ctx, "Viewer", "password"); err != nil {
|
|
t.Fatalf("case variant username should login: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoginKeepsOnlyConfiguredActiveRefreshTokens(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, auth, _, _ := newAuthTestServices(t)
|
|
user, _, err := auth.Register(ctx, "viewer", "password")
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil {
|
|
t.Fatalf("set max clients: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if _, err := auth.Login(ctx, "viewer", "password"); err != nil {
|
|
t.Fatalf("login %d: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
var active int64
|
|
if err := repos.DB.Model(&model.RefreshToken{}).
|
|
Where("user_id = ? AND revoked = ? AND expires_at > ?", user.ID, false, time.Now()).
|
|
Count(&active).Error; err != nil {
|
|
t.Fatalf("count active refresh tokens: %v", err)
|
|
}
|
|
if active != 3 {
|
|
t.Fatalf("active refresh tokens should be capped at 3, got %d", active)
|
|
}
|
|
}
|
|
|
|
func TestLoginRetriesTransientSQLiteBusy(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := &config.Config{}
|
|
cfg.App.DataDir = t.TempDir()
|
|
cfg.Database.DBPath = filepath.Join(cfg.App.DataDir, "busy-login.db")
|
|
cfg.Database.WALMode = true
|
|
cfg.Database.BusyTimeout = 20
|
|
cfg.Database.MaxOpenConns = 4
|
|
cfg.Database.MaxIdleConns = 2
|
|
cfg.Secrets.JWTSecret = "test-secret"
|
|
log := zap.NewNop()
|
|
db, err := database.Open(cfg, log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = sqlDB.Close() }()
|
|
if err := database.AutoMigrate(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repos := repository.New(db)
|
|
permissions := NewPermissionService(log, repos)
|
|
auth := NewAuthService(cfg, log, repos, NewTokenService(cfg, log, repos), permissions)
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repos.User.Create(ctx, &model.User{
|
|
Username: "viewer",
|
|
PasswordHash: string(hash),
|
|
Role: "user",
|
|
Tier: "free",
|
|
IsActive: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tx := repos.DB.Begin()
|
|
if err := tx.Exec("UPDATE users SET updated_at = updated_at WHERE username = ?", "viewer").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release := make(chan struct{})
|
|
go func() {
|
|
timer := time.NewTimer(250 * time.Millisecond)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-release:
|
|
case <-timer.C:
|
|
}
|
|
_ = tx.Rollback().Error
|
|
}()
|
|
defer close(release)
|
|
|
|
if _, err := auth.Login(ctx, "viewer", "password"); err != nil {
|
|
t.Fatalf("login should survive a transient sqlite write lock: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoginReturnsTokensWhenSQLiteWriteLockPersists(t *testing.T) {
|
|
ctx := context.Background()
|
|
cfg := &config.Config{}
|
|
cfg.App.DataDir = t.TempDir()
|
|
cfg.Database.DBPath = filepath.Join(cfg.App.DataDir, "busy-login-degraded.db")
|
|
cfg.Database.WALMode = true
|
|
cfg.Database.BusyTimeout = 20
|
|
cfg.Database.MaxOpenConns = 4
|
|
cfg.Database.MaxIdleConns = 2
|
|
cfg.Secrets.JWTSecret = "test-secret"
|
|
log := zap.NewNop()
|
|
db, err := database.Open(cfg, log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = sqlDB.Close() }()
|
|
if err := database.AutoMigrate(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repos := repository.New(db)
|
|
permissions := NewPermissionService(log, repos)
|
|
auth := NewAuthService(cfg, log, repos, NewTokenService(cfg, log, repos), permissions)
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := &model.User{
|
|
Username: "viewer",
|
|
PasswordHash: string(hash),
|
|
Role: "user",
|
|
Tier: "free",
|
|
IsActive: true,
|
|
}
|
|
if err := repos.User.Create(ctx, user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tx := repos.DB.Begin()
|
|
if err := tx.Exec("UPDATE users SET updated_at = updated_at WHERE username = ?", "viewer").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, err := auth.Login(ctx, "viewer", "password")
|
|
if err != nil {
|
|
t.Fatalf("login should return tokens while refresh token store is delayed: %v", err)
|
|
}
|
|
if resp == nil || resp.Tokens == nil || resp.Tokens.AccessToken == "" || resp.Tokens.RefreshToken == "" {
|
|
t.Fatalf("login returned incomplete token pair: %#v", resp)
|
|
}
|
|
|
|
if err := tx.Rollback().Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantHash := repository.HashToken(resp.Tokens.RefreshToken)
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
var count int64
|
|
if err := repos.DB.Model(&model.RefreshToken{}).Where("token_hash = ?", wantHash).Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count == 1 {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("delayed refresh token store did not complete")
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
|
|
perms := DefaultPermissions("user-1")
|
|
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
|
|
t.Fatal("viewer defaults must allow library viewing, playback, and external players")
|
|
}
|
|
if perms.CanManageDownloads || perms.CanManageSubscriptions || perms.CanManageFiles ||
|
|
perms.CanEditMedia || perms.CanRescrape || perms.CanCaptureFrames ||
|
|
perms.CanManageSites || perms.CanManageUsers || perms.CanManageStrm {
|
|
t.Fatal("viewer defaults must not allow downloads, scraping, media edits, or file management")
|
|
}
|
|
}
|
|
|
|
func TestAdminEffectivePermissionsAreAllGranted(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, _, _, permissions := newAuthTestServices(t)
|
|
admin := &model.User{Username: "admin", PasswordHash: "hash", Role: "admin", Tier: "plus"}
|
|
if err := repos.User.Create(ctx, admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
perms, err := permissions.Effective(ctx, admin.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !perms.CanEditMedia || !perms.CanRescrape || !perms.CanUseAI ||
|
|
!perms.CanCaptureFrames || !perms.CanManageUsers || !perms.CanAccessSettings {
|
|
t.Fatal("admin effective permissions must grant every advanced capability")
|
|
}
|
|
}
|
|
|
|
func TestDefaultAdminCannotBeDemoted(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos, _, profile, _ := newAuthTestServices(t)
|
|
admin := &model.User{Username: "admin", PasswordHash: "hash", Role: "admin", Tier: "plus"}
|
|
if err := repos.User.Create(ctx, admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := profile.AdminUpdateRole(ctx, admin.ID, "user")
|
|
if err == nil {
|
|
t.Fatal("expected default admin demotion to be rejected")
|
|
}
|
|
}
|
|
|
|
// TestIssueEmbyTokenIsLongLived verifies the Emby/Jellyfin compatibility token
|
|
// outlives the 60-minute access token (Emby clients have no refresh mechanism,
|
|
// so a short token caused them to drop login / fail playback hourly), parses
|
|
// with the JWT secret, and carries the user's identity/role/tier.
|
|
func TestIssueEmbyTokenIsLongLived(t *testing.T) {
|
|
_, auth, _, _ := newAuthTestServices(t)
|
|
u := &model.User{Base: model.Base{ID: "u-emby"}, Username: "emby", Role: "user", Tier: "plus"}
|
|
|
|
tok, err := auth.IssueEmbyToken(u)
|
|
if err != nil {
|
|
t.Fatalf("IssueEmbyToken: %v", err)
|
|
}
|
|
|
|
claims := &Claims{}
|
|
parsed, err := jwt.ParseWithClaims(tok, claims, func(*jwt.Token) (interface{}, error) {
|
|
return []byte("test-secret"), nil
|
|
})
|
|
if err != nil || !parsed.Valid {
|
|
t.Fatalf("token did not parse/validate: %v", err)
|
|
}
|
|
if claims.UserID != "u-emby" || claims.Role != "user" || claims.Tier != "plus" {
|
|
t.Fatalf("unexpected claims: %+v", claims)
|
|
}
|
|
ttl := time.Until(claims.ExpiresAt.Time)
|
|
if ttl <= AccessTokenDuration {
|
|
t.Fatalf("emby token ttl %v not longer than access token ttl %v", ttl, AccessTokenDuration)
|
|
}
|
|
if ttl < EmbyTokenDuration-time.Hour {
|
|
t.Fatalf("emby token ttl %v shorter than expected ~%v", ttl, EmbyTokenDuration)
|
|
}
|
|
}
|