mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-06-05 14:25:17 -04:00
OpenID Connect support (#3975)
Co-authored-by: DieselTech <30128380+DieselTech@users.noreply.github.com> Co-authored-by: majora2007 <josephmajora@gmail.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Extensions;
|
||||
|
||||
public class EnumExtensionTests
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData("Early Childhood", AgeRating.EarlyChildhood, true)]
|
||||
[InlineData("M", AgeRating.Mature, true)]
|
||||
[InlineData("ThisIsNotAnAgeRating", default(AgeRating), false)]
|
||||
public void TryParse<TEnum>(string? value, TEnum expected, bool success) where TEnum : struct, Enum
|
||||
{
|
||||
Assert.Equal(EnumExtensions.TryParse(value, out TEnum got), success);
|
||||
Assert.Equal(expected, got);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class AccountServiceTests: AbstractDbTest
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin", true)]
|
||||
[InlineData("^^$SomeBadChars", false)]
|
||||
[InlineData("Lisa2003", true)]
|
||||
[InlineData("Kraft Lawrance", false)]
|
||||
public async Task ValidateUsername_Regex(string username, bool valid)
|
||||
{
|
||||
await ResetDb();
|
||||
var (_, accountService, _, _) = await Setup();
|
||||
|
||||
Assert.Equal(valid, !(await accountService.ValidateUsername(username)).Any());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Throws_WhenDefaultAdminUser()
|
||||
{
|
||||
await ResetDb();
|
||||
var (_, accountService, _, _) = await Setup();
|
||||
|
||||
var defaultAdmin = await UnitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
|
||||
await Assert.ThrowsAsync<KavitaException>(() =>
|
||||
accountService.ChangeIdentityProvider(defaultAdmin.Id, defaultAdmin, IdentityProvider.Kavita));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsIsFalse()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita);
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Throws_WhenUserIsOidcManaged_AndNoChange()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, settingsService) = await Setup();
|
||||
|
||||
user.IdentityProvider = IdentityProvider.OpenIdConnect;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
settings.OidcConfig.SyncUserSettings = true;
|
||||
await settingsService.UpdateSettings(settings);
|
||||
|
||||
await Assert.ThrowsAsync<KavitaException>(() =>
|
||||
accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsTrue_AndChangeIsAllowed()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, settingsService) = await Setup();
|
||||
|
||||
user.IdentityProvider = IdentityProvider.OpenIdConnect;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
settings.OidcConfig.SyncUserSettings = true;
|
||||
await settingsService.UpdateSettings(settings);
|
||||
|
||||
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita);
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_ReturnsTrue_WhenChangedToOidc()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, settingsService) = await Setup();
|
||||
|
||||
user.IdentityProvider = IdentityProvider.Kavita;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
settings.OidcConfig.SyncUserSettings = true;
|
||||
await settingsService.UpdateSettings(settings);
|
||||
|
||||
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect);
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(IdentityProvider.OpenIdConnect, updated.IdentityProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_GrantsAccessToAllLibraries_WhenAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int>(), hasAdminRole: true);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
|
||||
Assert.Equal(2, userLibs.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_GrantsAccessToSelectedLibraries_WhenNotAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Single(userLibs);
|
||||
Assert.Equal(mangaLib.Id, userLibs.First().Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_RemovesAccessFromUnselectedLibraries_WhenNotAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Grant access to both libraries
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Equal(2, userLibs.Count);
|
||||
|
||||
// Now restrict access to only light novels
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { lightNovelsLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Single(userLibs);
|
||||
Assert.Equal(lightNovelsLib.Id, userLibs.First().Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_GrantsNoLibraries_WhenNoneSelected_AndNotAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Initially grant access to both libraries
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Equal(2, userLibs.Count);
|
||||
|
||||
// Now revoke all access by passing empty list
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int>(), hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Empty(userLibs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<(AppUser, IAccountService, UserManager<AppUser>, SettingsService)> Setup()
|
||||
{
|
||||
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
|
||||
.WithRole(PolicyConstants.AdminRole)
|
||||
.Build();
|
||||
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
|
||||
|
||||
var roleStore = new RoleStore<
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserRole<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
|
||||
var roleManager = new RoleManager<AppRole>(
|
||||
roleStore,
|
||||
[new RoleValidator<AppRole>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
Substitute.For<ILogger<RoleManager<AppRole>>>());
|
||||
|
||||
foreach (var role in PolicyConstants.ValidRoles)
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(role))
|
||||
{
|
||||
await roleManager.CreateAsync(new AppRole
|
||||
{
|
||||
Name = role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var userStore = new UserStore<
|
||||
AppUser,
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserClaim<int>,
|
||||
AppUserRole,
|
||||
IdentityUserLogin<int>,
|
||||
IdentityUserToken<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
var userManager = new UserManager<AppUser>(userStore,
|
||||
new OptionsWrapper<IdentityOptions>(new IdentityOptions()),
|
||||
new PasswordHasher<AppUser>(),
|
||||
[new UserValidator<AppUser>()],
|
||||
[new PasswordValidator<AppUser>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
Substitute.For<ILogger<UserManager<AppUser>>>());
|
||||
|
||||
// Create users with the UserManager such that the SecurityStamp is set
|
||||
await userManager.CreateAsync(user);
|
||||
await userManager.CreateAsync(defaultAdmin);
|
||||
|
||||
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(), UnitOfWork, Mapper, Substitute.For<ILocalizationService>());
|
||||
var settingsService = new SettingsService(UnitOfWork, Substitute.For<IDirectoryService>(), Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SettingsService>> (), Substitute.For<IOidcService>());
|
||||
|
||||
user = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id, AppUserIncludes.SideNavStreams);
|
||||
return (user, accountService, userManager, settingsService);
|
||||
}
|
||||
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
Context.AppUser.RemoveRange(Context.AppUser);
|
||||
Context.Library.RemoveRange(Context.Library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class OidcServiceTests: AbstractDbTest
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public async Task UserSync_Username()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, _, _, userManager) = await Setup();
|
||||
|
||||
var user = new AppUserBuilder("holo", "holo@localhost").Build();
|
||||
var res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Name, "amelia"),
|
||||
new (ClaimTypes.GivenName, "Lawrence"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
// name is updated as the current username is not found, amelia is skipped as it is alredy in use
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal("Lawrence", user.UserName);
|
||||
|
||||
claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Name, "amelia"),
|
||||
new (ClaimTypes.GivenName, "Lawrence"),
|
||||
new (ClaimTypes.Surname, "Norah"),
|
||||
};
|
||||
identity = new ClaimsIdentity(claims);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
// Ensure a name longer down the list isn't picked if the current username is found
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal("Lawrence", user.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserSync_CustomClaim()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
const string claim = "groups";
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (claim, PolicyConstants.LoginRole),
|
||||
new (claim, PolicyConstants.DownloadRole),
|
||||
new (ClaimTypes.Role, PolicyConstants.PromoteRole),
|
||||
new (claim, OidcService.AgeRestrictionPrefix + "M"),
|
||||
new (claim, OidcService.LibraryAccessPrefix + "Manga"),
|
||||
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
RolesClaim = claim,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
// Check correct roles assigned
|
||||
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
|
||||
Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles);
|
||||
|
||||
// Check correct libraries
|
||||
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(mangaLib.Name, libraries);
|
||||
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
|
||||
|
||||
// Check correct age restrictions
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
Assert.False(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserSync_CustomPrefix()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
const string prefix = "kavita-";
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, prefix + PolicyConstants.LoginRole),
|
||||
new (ClaimTypes.Role, prefix + PolicyConstants.DownloadRole),
|
||||
new (ClaimTypes.Role, PolicyConstants.PromoteRole),
|
||||
new (ClaimTypes.Role, prefix + OidcService.AgeRestrictionPrefix + "M"),
|
||||
new (ClaimTypes.Role, prefix + OidcService.LibraryAccessPrefix + "Manga"),
|
||||
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
RolesPrefix = prefix,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
// Check correct roles assigned
|
||||
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
|
||||
Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles);
|
||||
|
||||
// Check correct libraries
|
||||
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(mangaLib.Name, libraries);
|
||||
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
|
||||
|
||||
// Check correct age restrictions
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
Assert.False(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncRoles()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, PolicyConstants.LoginRole),
|
||||
new (ClaimTypes.Role, PolicyConstants.DownloadRole),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
|
||||
|
||||
// Only give one role
|
||||
claims = [new Claim(ClaimTypes.Role, PolicyConstants.LoginRole)];
|
||||
identity = new ClaimsIdentity(claims);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.DoesNotContain(PolicyConstants.DownloadRole, userRoles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncLibraries()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Manga"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(mangaLib.Name, libraries);
|
||||
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
|
||||
|
||||
// Only give access to the other library
|
||||
claims = [new Claim(ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels")];
|
||||
identity = new ClaimsIdentity(claims);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
// Check access has swicthed
|
||||
libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(lightNovelsLib.Name, libraries);
|
||||
Assert.DoesNotContain(mangaLib.Name, libraries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestrictions_NoRestrictions()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Not Applicable"),
|
||||
new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestrictions_IncludeUnknowns()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
|
||||
new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestriction_AdminNone()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, PolicyConstants.AdminRole),
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestriction_MultipleAgeRestrictionClaims()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"),
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestriction_NoAgeRestrictionClaims()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var identity = new ClaimsIdentity([]);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
|
||||
// Also default to no restrictions when only include unknowns is present
|
||||
identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns)]);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncUserSettings_DontChangeDefaultAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, _, _, userManager) = await Setup();
|
||||
|
||||
// Make user default user
|
||||
var user = await UnitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, PolicyConstants.ChangePasswordRole),
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(userFromDb);
|
||||
Assert.NotEqual(AgeRating.Teen, userFromDb.AgeRestriction);
|
||||
|
||||
var newUser = new AppUserBuilder("NotAnAdmin", "NotAnAdmin@localhost").Build();
|
||||
var res = await userManager.CreateAsync(newUser);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, newUser);
|
||||
userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(newUser.Id);
|
||||
Assert.NotNull(userFromDb);
|
||||
Assert.True(await userManager.IsInRoleAsync(newUser, PolicyConstants.ChangePasswordRole));
|
||||
Assert.Equal(AgeRating.Teen, userFromDb.AgeRestriction);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBestAvailableName_NoDuplicates()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, _, _, userManager) = await Setup();
|
||||
|
||||
|
||||
const string preferredName = "PreferredName";
|
||||
const string name = "Name";
|
||||
const string givenName = "GivenName";
|
||||
const string surname = "Surname";
|
||||
const string email = "Email";
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.PreferredUsername, preferredName),
|
||||
new(ClaimTypes.Name, name),
|
||||
new(ClaimTypes.GivenName, givenName),
|
||||
new(ClaimTypes.Surname, surname),
|
||||
new(ClaimTypes.Email, email),
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(preferredName, bestName);
|
||||
|
||||
// Create user with this name to make the method fallback to the next claim
|
||||
var user = new AppUserBuilder(bestName, bestName).Build();
|
||||
var res = await userManager.CreateAsync(user);
|
||||
// This has actual information as to why it would fail, so we check it to make sure if the test fail here we know why
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// Fallback to name
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(name, bestName);
|
||||
|
||||
user = new AppUserBuilder(bestName, bestName).Build();
|
||||
res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// Fallback to given name
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(givenName, bestName);
|
||||
|
||||
user = new AppUserBuilder(bestName, bestName).Build();
|
||||
res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// Fallback to surname
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(surname, bestName);
|
||||
|
||||
user = new AppUserBuilder(bestName, bestName).Build();
|
||||
res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// When none are found, returns null
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.Null(bestName);
|
||||
}
|
||||
|
||||
private async Task<(OidcService, AppUser, IAccountService, UserManager<AppUser>)> Setup()
|
||||
{
|
||||
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
|
||||
.WithRole(PolicyConstants.AdminRole)
|
||||
.Build();
|
||||
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
|
||||
|
||||
var roleStore = new RoleStore<
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserRole<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
|
||||
var roleManager = new RoleManager<AppRole>(
|
||||
roleStore,
|
||||
[new RoleValidator<AppRole>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
Substitute.For<ILogger<RoleManager<AppRole>>>());
|
||||
|
||||
foreach (var role in PolicyConstants.ValidRoles)
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(role))
|
||||
{
|
||||
await roleManager.CreateAsync(new AppRole
|
||||
{
|
||||
Name = role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var userStore = new UserStore<
|
||||
AppUser,
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserClaim<int>,
|
||||
AppUserRole,
|
||||
IdentityUserLogin<int>,
|
||||
IdentityUserToken<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
var userManager = new UserManager<AppUser>(userStore,
|
||||
new OptionsWrapper<IdentityOptions>(new IdentityOptions()),
|
||||
new PasswordHasher<AppUser>(),
|
||||
[new UserValidator<AppUser>()],
|
||||
[new PasswordValidator<AppUser>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
Substitute.For<ILogger<UserManager<AppUser>>>());
|
||||
|
||||
// Create users with the UserManager such that the SecurityStamp is set
|
||||
await userManager.CreateAsync(user);
|
||||
await userManager.CreateAsync(defaultAdmin);
|
||||
|
||||
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(), UnitOfWork, Mapper, Substitute.For<ILocalizationService>());
|
||||
var oidcService = new OidcService(Substitute.For<ILogger<OidcService>>(), userManager, UnitOfWork, accountService, Substitute.For<IEmailService>());
|
||||
return (oidcService, user, accountService, userManager);
|
||||
}
|
||||
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
Context.AppUser.RemoveRange(Context.AppUser);
|
||||
Context.Library.RemoveRange(Context.Library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class SettingsServiceTests
|
||||
_mockUnitOfWork = Substitute.For<IUnitOfWork>();
|
||||
_settingsService = new SettingsService(_mockUnitOfWork, ds,
|
||||
Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(),
|
||||
Substitute.For<ILogger<SettingsService>>());
|
||||
Substitute.For<ILogger<SettingsService>>(), Substitute.For<IOidcService>());
|
||||
}
|
||||
|
||||
#region ImportMetadataSettings
|
||||
|
||||
Reference in New Issue
Block a user