Files
WatchedTogether/Jellyfin.Plugin.WatchedTogether.Tests/RestrictionTests.cs
T
dtourolleandClaude Opus 5 27cd2a3d37 Inherit parental restrictions on shared accounts, with a chosen rating cap
A shared account previously inherited its members' library access but
none of their content restrictions, so a child could log into "alice+kid"
with their own password and get around their own rating cap.

The shared account now gets the strictest member's parental rating,
unrated-item block, blocked tags and allowed tags, recomputed at
creation, on membership change and at startup. An admin can raise the
rating cap on a slider between the strictest and the loosest member;
unrated and tag rules stay strictest-wins.

What makes raising the cap safe is the unlock rule: after a member's
password matches, both users' live policies are compared and the login
is refused if the account is looser than the member on any field. So
raising the cap above the child's rating means the child's password no
longer opens the account, while the parent's still does. The same rule
bounds the slider - past the loosest member nobody could unlock the
account - so a chosen cap is clamped back into range whenever applied.

Allowed tags need care: Jellyfin reads an empty list as "no whitelist",
so an empty intersection of members' whitelists is written as a sentinel
tag no item carries. Access schedules and channels are not inherited yet.

The shared account is never an administrator. Groups created at the
login screen always inherit and are restricted before the first session
exists. The dashboard shows each member's cap, who a chosen cap shuts
out, and the restrictions in effect, and gains a per-group edit form for
the sync options.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:30:50 +02:00

541 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Auth;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.WatchedTogether.Tests;
/// <summary>
/// Covers the rule that a shared account is at least as restricted as every member who can unlock
/// it: how members' restrictions combine, and who an account with a chosen rating cap lets in.
/// </summary>
[Collection(nameof(PluginTestContext))]
public class RestrictionTests
{
private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB";
private const string KidHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD";
private const string TeenHash = "$PBKDF2-SHA512$iterations=210000$C3C3C3C3$EEEEEEEEFFFFFFFF";
private static User MakeUser(string name, string? password = null)
=> new(name, "Prov", "ResetProv") { Password = password! };
/// <summary>
/// A user manager that resolves the given users by id and name and round-trips policies.
/// </summary>
private static Mock<IUserManager> MakeUserManager(List<User> users)
{
var userManager = new Mock<IUserManager>();
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
.Returns((Guid id) => users.Find(u => u.Id == id)!);
userManager.Setup(m => m.GetUserByName(It.IsAny<string>()))
.Returns((string n) => users.Find(
u => string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))!);
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
.ReturnsAsync((string name) =>
{
var created = MakeUser(name);
users.Add(created);
return created;
});
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
userManager.SetupChangePassword(users, (user, password) =>
{
user.Password = password;
return Task.CompletedTask;
});
userManager.SetupPolicyRoundTrip(users);
return userManager;
}
private static RestrictionService MakeService(List<User> users)
=> new(MakeUserManager(users).Object, NullLogger<RestrictionService>.Instance);
private sealed record Harness(
SharedAccountAuthenticationProvider Provider,
ProvisioningService Provisioning,
List<User> Users);
/// <summary>
/// Wires the real provisioning, group, dynamic-group, restriction and authentication services
/// over a stub user manager, the same way <see cref="SharedAccountEndToEndTests"/> does.
/// </summary>
private static Harness MakeHarness(List<User> users, params (string Hash, string Password)[] validPairs)
{
var crypto = new StubCryptoProvider(validPairs);
var userManager = MakeUserManager(users);
var groupService = new GroupService(userManager.Object, NullLogger<GroupService>.Instance);
var restrictions = new RestrictionService(userManager.Object, NullLogger<RestrictionService>.Instance);
var provisioning = new ProvisioningService(
userManager.Object,
Mock.Of<ILibraryAccessService>(),
restrictions,
NullLogger<ProvisioningService>.Instance);
var dynamicGroups = new DynamicGroupService(
userManager.Object,
provisioning,
restrictions,
crypto,
NullLogger<DynamicGroupService>.Instance);
var provider = new SharedAccountAuthenticationProvider(
crypto,
new Lazy<IGroupService>(() => groupService),
new Lazy<IDynamicGroupService>(() => dynamicGroups),
new Lazy<IRestrictionService>(() => restrictions),
NullLogger<SharedAccountAuthenticationProvider>.Instance);
return new Harness(provider, provisioning, users);
}
// ---- combining members ----------------------------------------------------------------
[Theory]
[InlineData(null, 13, 13)]
[InlineData(13, null, 13)]
[InlineData(7, 17, 7)]
[InlineData(null, null, null)]
public void Rating_StrictestScoreWins(int? a, int? b, int? expected)
{
var alice = MakeUser("alice").Restrict(maxRating: a);
var bob = MakeUser("bob").Restrict(maxRating: b);
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
Assert.Equal(expected, result.MaxParentalRatingScore);
}
[Theory]
[InlineData(null, 2, 2)]
[InlineData(3, 2, 2)]
[InlineData(2, 3, 2)]
[InlineData(null, null, null)]
public void Rating_AtEqualScore_StrictestSubScoreWins(int? a, int? b, int? expected)
{
// At the same score a null sub-cap allows every sub-score, so any value beats it.
var alice = MakeUser("alice").Restrict(maxRating: 13, maxSubRating: a);
var bob = MakeUser("bob").Restrict(maxRating: 13, maxSubRating: b);
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
Assert.Equal(13, result.MaxParentalRatingScore);
Assert.Equal(expected, result.MaxParentalRatingSubScore);
}
[Fact]
public void Rating_LowerScore_WinsRegardlessOfSubScore()
{
var alice = MakeUser("alice").Restrict(maxRating: 13, maxSubRating: 0);
var bob = MakeUser("bob").Restrict(maxRating: 7, maxSubRating: null);
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
Assert.Equal(7, result.MaxParentalRatingScore);
Assert.Null(result.MaxParentalRatingSubScore);
}
[Fact]
public void UnratedAndBlockedTags_AreUnioned()
{
var alice = MakeUser("alice").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"]);
var bob = MakeUser("bob").Restrict(blockUnrated: [UnratedItem.Series], blockedTags: ["Gore", "horror"]);
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
Assert.Equal(new HashSet<UnratedItem> { UnratedItem.Movie, UnratedItem.Series }, result.BlockUnratedItems);
Assert.Equal(2, result.BlockedTags.Count);
Assert.Contains("horror", result.BlockedTags);
Assert.Contains("gore", result.BlockedTags);
}
[Fact]
public void AllowedTags_NoWhitelists_StaysNoWhitelist()
{
var alice = MakeUser("alice");
var bob = MakeUser("bob");
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
Assert.False(result.HasAllowedTags);
Assert.Empty(result.AllowedTagsForPolicy());
}
[Fact]
public void AllowedTags_MemberWithoutWhitelist_AcceptsTheOthers()
{
// An empty allowed-tag list in Jellyfin is "no whitelist", not "allows nothing": it must
// not wipe out the other member's whitelist.
var alice = MakeUser("alice");
var kid = MakeUser("kid").Restrict(allowedTags: ["kids", "family"]);
var result = MakeService([alice, kid]).ComputeStrictest([alice.Id, kid.Id]);
Assert.True(result.HasAllowedTags);
Assert.Equal(new[] { "family", "kids" }, result.AllowedTagsForPolicy().OrderBy(t => t, StringComparer.Ordinal));
}
[Fact]
public void AllowedTags_TwoWhitelists_Intersect()
{
var alice = MakeUser("alice").Restrict(allowedTags: ["kids", "family"]);
var bob = MakeUser("bob").Restrict(allowedTags: ["Family", "documentary"]);
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
Assert.Equal(new[] { "family" }, result.AllowedTagsForPolicy());
}
[Fact]
public void AllowedTags_DisjointWhitelists_AllowNothing_AndSurviveARoundTrip()
{
var alice = MakeUser("alice").Restrict(allowedTags: ["kids"]);
var bob = MakeUser("bob").Restrict(allowedTags: ["documentary"]);
var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]);
// In force, but empty. Jellyfin would read an empty list as unrestricted, so what gets
// written is a tag no item carries...
Assert.True(result.HasAllowedTags);
Assert.Empty(result.AllowedTags!);
Assert.Equal(new[] { ContentRestrictions.NothingAllowedTag }, result.AllowedTagsForPolicy());
// ...and reading a user carrying only that tag comes back as "allows nothing", not as a
// one-tag whitelist, so the unlock rule treats it as stricter than anything.
var shared = MakeUser("shared").Restrict(allowedTags: result.AllowedTagsForPolicy());
var read = ContentRestrictions.FromUser(shared);
Assert.True(read.HasAllowedTags);
Assert.Empty(read.AllowedTags!);
Assert.True(read.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(alice)));
}
[Fact]
public void UnresolvableMember_YieldsFullyRestricted()
{
var alice = MakeUser("alice");
var result = MakeService([alice]).ComputeStrictest([alice.Id, Guid.NewGuid()]);
Assert.Equal(ContentRestrictions.FullyRestricted, result);
Assert.True(result.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(alice)));
}
[Fact]
public async Task ApplyAsync_WritesTheStrictestPolicy_AndNeverAdministrator()
{
var alice = MakeUser("alice").Restrict(blockedTags: ["horror"]);
var kid = MakeUser("kid").Restrict(maxRating: 7, blockUnrated: [UnratedItem.Movie]);
var shared = MakeUser("shared");
shared.SetPermission(PermissionKind.IsAdministrator, true);
await MakeService([alice, kid, shared]).ApplyAsync(new SharedGroup
{
SharedUserId = shared.Id,
MemberUserIds = [alice.Id, kid.Id]
});
Assert.Equal(7, shared.MaxParentalRatingScore);
Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems));
Assert.Equal(["horror"], shared.GetPreference(PreferenceKind.BlockedTags));
Assert.False(shared.HasPermission(PermissionKind.IsAdministrator));
}
// ---- the unlock rule ------------------------------------------------------------------
[Theory]
[InlineData(null, null, true)]
[InlineData(7, null, true)]
[InlineData(7, 7, true)]
[InlineData(7, 13, true)]
[InlineData(13, 7, false)]
[InlineData(null, 13, false)]
public void IsAtLeastAsStrict_ComparesRatingCaps(int? shared, int? member, bool expected)
{
var sharedUser = MakeUser("shared").Restrict(maxRating: shared);
var memberUser = MakeUser("member").Restrict(maxRating: member);
Assert.Equal(expected, MakeService([]).IsAtLeastAsStrict(sharedUser, memberUser));
}
[Fact]
public void IsAtLeastAsStrict_RequiresEverySetToBeCovered()
{
var service = MakeService([]);
var member = MakeUser("member").Restrict(
blockUnrated: [UnratedItem.Movie],
blockedTags: ["horror"],
allowedTags: ["kids", "family"]);
Assert.True(service.IsAtLeastAsStrict(
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie, UnratedItem.Series], blockedTags: ["horror", "gore"], allowedTags: ["kids"]),
member));
Assert.False(service.IsAtLeastAsStrict(
MakeUser("s").Restrict(blockedTags: ["horror"], allowedTags: ["kids"]),
member));
Assert.False(service.IsAtLeastAsStrict(
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], allowedTags: ["kids"]),
member));
Assert.False(service.IsAtLeastAsStrict(
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"], allowedTags: ["kids", "family", "sport"]),
member));
// No whitelist on the shared side is looser than any whitelist on the member's.
Assert.False(service.IsAtLeastAsStrict(
MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"]),
member));
}
// ---- the rating range ---------------------------------------------------------------
[Fact]
public void RatingRange_SpansStrictestToLoosest()
{
var alice = MakeUser("alice");
var teen = MakeUser("teen").Restrict(maxRating: 13);
var kid = MakeUser("kid").Restrict(maxRating: 7);
var service = MakeService([alice, teen, kid]);
Assert.Equal(new RatingRange(7, 13), service.GetRatingRange([teen.Id, kid.Id]));
Assert.Equal(new RatingRange(7, null), service.GetRatingRange([alice.Id, teen.Id, kid.Id]));
Assert.Equal(new RatingRange(null, null), service.GetRatingRange([alice.Id]));
Assert.False(service.GetRatingRange([alice.Id]).HasChoice);
Assert.Equal(new RatingRange(0, 13), service.GetRatingRange([teen.Id, Guid.NewGuid()]));
}
// ---- choosing a cap -----------------------------------------------------------------
[Fact]
public async Task ChosenCap_ShutsOutStricterMembers_AndLetsTheRestIn()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
var h = MakeHarness([alice, teen, kid], (AliceHash, "alice-pw"), (TeenHash, "teen-pw"), (KidHash, "kid-pw"));
var group = await h.Provisioning.CreateGroupAsync([alice.Id, teen.Id, kid.Id], null);
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
// Inherited: the account carries the child's cap and everyone gets in.
Assert.Equal(7, shared.MaxParentalRatingScore);
await h.Provider.Authenticate(shared.Username, "kid-pw", shared);
// Raised to the teen's level: the parent and the teen still unlock it, the child does not.
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, 13);
Assert.Equal(13, shared.MaxParentalRatingScore);
await h.Provider.Authenticate(shared.Username, "alice-pw", shared);
await h.Provider.Authenticate(shared.Username, "teen-pw", shared);
await Assert.ThrowsAsync<AuthenticationException>(
() => h.Provider.Authenticate(shared.Username, "kid-pw", shared));
// No cap at all: only the parent.
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, null);
Assert.Null(shared.MaxParentalRatingScore);
await h.Provider.Authenticate(shared.Username, "alice-pw", shared);
await Assert.ThrowsAsync<AuthenticationException>(
() => h.Provider.Authenticate(shared.Username, "teen-pw", shared));
}
[Fact]
public async Task ChosenCap_LeavesEverythingElseStrictest()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash).Restrict(blockedTags: ["horror"]);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, maxSubRating: 1, blockUnrated: [UnratedItem.Movie]);
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null);
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, null);
Assert.Null(shared.MaxParentalRatingScore);
Assert.Null(shared.MaxParentalRatingSubScore);
Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems));
Assert.Equal(["horror"], shared.GetPreference(PreferenceKind.BlockedTags));
}
[Fact]
public async Task ChosenCap_AboveTheLoosestMember_IsPulledBack()
{
using var ctx = PluginTestContext.Create();
var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
var h = MakeHarness([teen, kid], (TeenHash, "teen-pw"), (KidHash, "kid-pw"));
var group = await h.Provisioning.CreateGroupAsync([teen.Id, kid.Id], null);
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
// "No cap" past a group where everyone is capped would leave nobody able to unlock it.
var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [teen.Id, kid.Id], true, false, false, false, null);
Assert.False(updated.InheritParentalRating);
Assert.Equal(13, updated.ParentalRatingCap);
Assert.Equal(13, Assert.Single(ctx.Configuration.Groups).ParentalRatingCap);
Assert.Equal(13, shared.MaxParentalRatingScore);
await h.Provider.Authenticate(shared.Username, "teen-pw", shared);
}
[Theory]
[InlineData(7)]
[InlineData(3)]
public async Task ChosenCap_AtOrBelowTheStrictestMember_IsJustInheriting(int cap)
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, maxSubRating: 2);
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null);
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, cap);
Assert.True(updated.InheritParentalRating);
Assert.Null(updated.ParentalRatingCap);
// Inheriting keeps the strictest member's sub-score too.
Assert.Equal(7, shared.MaxParentalRatingScore);
Assert.Equal(2, shared.MaxParentalRatingSubScore);
}
[Fact]
public async Task ChosenCap_WhenNoMemberIsCapped_IsJustInheriting()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", KidHash);
var h = MakeHarness([alice, bob]);
var group = await h.Provisioning.CreateGroupAsync([alice.Id, bob.Id], null);
var updated = await h.Provisioning.UpdateGroupAsync(group.SharedUserId, [alice.Id, bob.Id], true, false, false, false, null);
Assert.True(updated.InheritParentalRating);
}
[Fact]
public async Task ChosenCap_IsReclampedWhenMembershipChanges()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
var h = MakeHarness([alice, teen, kid], (AliceHash, "alice-pw"), (TeenHash, "teen-pw"), (KidHash, "kid-pw"));
var group = await h.Provisioning.CreateGroupAsync([alice.Id, teen.Id, kid.Id], null);
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
// "No cap" is valid while the uncapped parent is a member...
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, null);
Assert.Null(shared.MaxParentalRatingScore);
// ...and is pulled back to the teen's level once the parent leaves, so the teen can still
// unlock the account.
var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [teen.Id, kid.Id], true, false, false, false, null);
Assert.Equal(13, updated.ParentalRatingCap);
Assert.Equal(13, shared.MaxParentalRatingScore);
await h.Provider.Authenticate(shared.Username, "teen-pw", shared);
}
[Fact]
public async Task InheritedGroup_MemberCapLoweredAfterLastReapply_IsRefusedUntilRecomputed()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 13);
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null);
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
Assert.Equal(13, shared.MaxParentalRatingScore);
// The drift case: the child's cap is lowered in the user editor, nothing recomputes the
// shared account, and the unlock rule still holds because it reads both users live.
kid.Restrict(maxRating: 7);
await Assert.ThrowsAsync<AuthenticationException>(
() => h.Provider.Authenticate(shared.Username, "kid-pw", shared));
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, true, null);
Assert.Equal(7, shared.MaxParentalRatingScore);
await h.Provider.Authenticate(shared.Username, "kid-pw", shared);
}
[Fact]
public async Task DynamicCreation_IsInherited_AndRestrictedBeforeTheFirstLoginCompletes()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, blockUnrated: [UnratedItem.Movie]);
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
// The child types both names with their own password on a fresh server. The session this
// creates must already be capped.
var created = await h.Provider.Authenticate("alice+kid", "kid-pw", null);
var shared = h.Users.Find(u => u.Username == created.Username)!;
Assert.Equal(7, shared.MaxParentalRatingScore);
Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems));
Assert.True(Assert.Single(ctx.Configuration.Groups).InheritParentalRating);
}
[Fact]
public async Task DynamicLogin_ToAnExistingGroupWithAChosenCap_AppliesTheUnlockRule()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7);
var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw"));
var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], "family");
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, null);
// "kid+alice" matches no account by name, so it reaches the dynamic path and resolves to
// the existing group; the same rule must apply there.
var asAlice = await h.Provider.Authenticate("kid+alice", "alice-pw", null);
Assert.Equal("family", asAlice.Username);
await Assert.ThrowsAsync<AuthenticationException>(
() => h.Provider.Authenticate("kid+alice", "kid-pw", null));
}
[Fact]
public async Task Provisioning_SharedAccountIsNeverAnAdministrator()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", KidHash);
alice.SetPermission(PermissionKind.IsAdministrator, true);
bob.SetPermission(PermissionKind.IsAdministrator, true);
var h = MakeHarness([alice, bob]);
var group = await h.Provisioning.CreateGroupAsync([alice.Id, bob.Id], null);
var shared = h.Users.Find(u => u.Id == group.SharedUserId)!;
Assert.False(shared.HasPermission(PermissionKind.IsAdministrator));
// Not even if granted afterwards.
shared.SetPermission(PermissionKind.IsAdministrator, true);
await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, bob.Id], true, false, false, true, null);
Assert.False(shared.HasPermission(PermissionKind.IsAdministrator));
}
}