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");
+ //}
}