diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs index 0ab50a03..de64d43c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; -// TODO: Add global scope +// TODO: Add global scope and implement other scopes properly and remove obsolete attribute. /// /// Defines the scope of uniqueness for an identifier. /// @@ -10,11 +10,13 @@ public enum UniquenessScope /// No uniqueness is enforced. /// Note that users still can't create duplicate identifiers. /// + [Obsolete("None uniqueness is not currently supported. Use Tenant until the v0.5.0 uniqueness expansion.")] None = 0, /// /// Uniqueness is enforced within a single user. /// + [Obsolete("WithinUser uniqueness is not currently supported. Use Tenant until the v0.5.0 uniqueness expansion.")] WithinUser = 10, /// diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs index 6d20394d..c69e0d87 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -44,8 +44,7 @@ public async Task ExistsAsync(IdentifierExistence .Where(x => x.Tenant == _tenant && x.Type == query.Type && - x.NormalizedValue == query.NormalizedValue && - x.DeletedAt == null); + x.NormalizedValue == query.NormalizedValue); if (query.ExcludeIdentifierId.HasValue) q = q.Where(x => x.Id != query.ExcludeIdentifierId.Value); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index beb61f6b..b9896d29 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -27,8 +27,7 @@ public Task ExistsAsync(IdentifierExistenceQuery var candidates = TenantValues() .Where(x => x.Type == query.Type && - x.NormalizedValue == query.NormalizedValue && - !x.IsDeleted); + x.NormalizedValue == query.NormalizedValue); if (query.ExcludeIdentifierId.HasValue) candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs index 62ae9d02..38d11f3a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs @@ -598,37 +598,62 @@ public async Task ExistsAsync_ExcludeIdentifierId_ExcludesCurrentIdentifier() await store.AddAsync(identifier); var result = await store.ExistsAsync( - new IdentifierExistenceQuery( - identifier.Type, - identifier.NormalizedValue, - IdentifierExistenceScope.TenantAny, - ExcludeIdentifierId: identifier.Id)); + new IdentifierExistenceQuery( + identifier.Type, + identifier.NormalizedValue, + IdentifierExistenceScope.TenantAny, + ExcludeIdentifierId: identifier.Id)); result.Exists.Should().BeFalse(); } - [Fact] - public async Task ExistsAsync_DoesNotMatchDeletedIdentifier() + + [Theory] + [InlineData(UserIdentifierType.Username, "alice", "ALICE")] + [InlineData(UserIdentifierType.Email, "alice@example.com", "ALICE@EXAMPLE.COM")] + public async Task ExistsAsync_SoftDeletedIdentifier_RemainsReserved(UserIdentifierType type, string value, string normalizedValue) { await using var db = await CreateDatabaseAsync(); var store = db.CreateStore(TenantA); - var identifier = CreateIdentifier(TenantA); + var identifier = CreateIdentifier( + TenantA, + type: type, + value: value, + normalizedValue: normalizedValue, + isPrimary: true); + await store.AddAsync(identifier); await store.DeleteAsync( identifier.Id, - 0, + expectedVersion: 0, DeleteMode.Soft, Now.AddHours(1)); - var result = await store.ExistsAsync( - new IdentifierExistenceQuery( - identifier.Type, - identifier.NormalizedValue, - IdentifierExistenceScope.TenantAny)); - - result.Exists.Should().BeFalse(); + // A deleted identifier still reserves its normalized value. + var exists = await store.ExistsAsync( + new IdentifierExistenceQuery( + type, + normalizedValue, + IdentifierExistenceScope.TenantAny)); + + exists.Exists.Should().BeTrue(); + exists.OwnerIdentifierId.Should().Be(identifier.Id); + + // Ordinary lookup must not resolve deleted identifiers. + var lookup = await store.GetAsync(type, normalizedValue); + lookup.Should().BeNull(); + + // Excluding the matching identifier must still work. + var excluded = await store.ExistsAsync( + new IdentifierExistenceQuery( + type, + normalizedValue, + IdentifierExistenceScope.TenantAny, + ExcludeIdentifierId: identifier.Id)); + + excluded.Exists.Should().BeFalse(); } [Fact] @@ -919,4 +944,104 @@ await store.AddAsync(CreateIdentifier( "B@EXAMPLE.COM", "C@EXAMPLE.COM"); } + + // TODO: Think these about with UniquenessScope + //[Fact] + //public async Task AddAsync_DuplicateNormalizedValue_IsRejected() + //{ + // await using var db = await CreateDatabaseAsync(); + // var store = db.CreateStore(TenantA); + + // var first = CreateIdentifier( + // TenantA, + // userKey: UserKey.New(), + // value: "first@example.com", + // normalizedValue: "SAME@EXAMPLE.COM"); + + // var second = CreateIdentifier( + // TenantA, + // userKey: UserKey.New(), + // value: "second@example.com", + // normalizedValue: "SAME@EXAMPLE.COM"); + + // await store.AddAsync(first); + + // var act = () => store.AddAsync(second); + + // await act.Should().ThrowAsync(); + + // (await store.GetByIdAsync(first.Id)) + // .Should().NotBeNull(); + + // (await store.GetByIdAsync(second.Id)) + // .Should().BeNull(); + //} + + //[Fact] + //public async Task AddAsync_SoftDeletedNormalizedValue_RemainsReserved() + //{ + // await using var db = await CreateDatabaseAsync(); + // var store = db.CreateStore(TenantA); + + // var first = CreateIdentifier( + // TenantA, + // normalizedValue: "RESERVED@EXAMPLE.COM"); + + // await store.AddAsync(first); + + // await store.DeleteAsync( + // first.Id, + // expectedVersion: 0, + // DeleteMode.Soft, + // Now.AddHours(1)); + + // var second = CreateIdentifier( + // TenantA, + // userKey: UserKey.New(), + // value: "reserved@example.com", + // normalizedValue: "RESERVED@EXAMPLE.COM"); + + // var act = () => store.AddAsync(second); + + // await act.Should().ThrowAsync(); + + // (await store.GetByIdAsync(second.Id)) + // .Should().BeNull(); + //} + + //[Fact] + //public async Task SaveAsync_DuplicateNormalizedValue_IsRejected() + //{ + // await using var db = await CreateDatabaseAsync(); + // var store = db.CreateStore(TenantA); + + // var first = CreateIdentifier( + // TenantA, + // value: "first@example.com", + // normalizedValue: "FIRST@EXAMPLE.COM"); + + // var second = CreateIdentifier( + // TenantA, + // value: "second@example.com", + // normalizedValue: "SECOND@EXAMPLE.COM"); + + // await store.AddAsync(first); + // await store.AddAsync(second); + + // second.ChangeValue( + // "first@example.com", + // "FIRST@EXAMPLE.COM", + // Now.AddMinutes(1)); + + // var act = () => store.SaveAsync( + // second, + // expectedVersion: 0); + + // await act.Should().ThrowAsync(); + + // var persisted = await store.GetByIdAsync(second.Id); + + // persisted.Should().NotBeNull(); + // persisted!.NormalizedValue.Should().Be("SECOND@EXAMPLE.COM"); + //} }