Skip to content

Commit 57f409f

Browse files
committed
Coordinate user record saves
1 parent c33052c commit 57f409f

9 files changed

Lines changed: 173 additions & 1 deletion

File tree

‎src/Exceptionless.Core/Models/User.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66

77
namespace Exceptionless.Core.Models;
88

9-
public record User : IIdentity, IHaveDates, IValidatableObject
9+
public record User : IIdentity, IHaveDates, IVersioned, IValidatableObject
1010
{
1111
/// <summary>
1212
/// Unique id that identifies an user.
1313
/// </summary>
1414
[ObjectId]
1515
public string Id { get; set; } = null!;
1616

17+
// Repository concurrency metadata also survives serialized cache reads.
18+
public string Version { get; set; } = null!;
19+
1720
/// <summary>
1821
/// The organizations that the user has access to.
1922
/// </summary>

‎src/Exceptionless.Core/Repositories/UserRepository.cs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Exceptionless.Core.Repositories.Configuration;
33
using Exceptionless.Core.Validation;
44
using Foundatio.Repositories;
5+
using Foundatio.Repositories.Exceptions;
56
using Foundatio.Repositories.Models;
67
using Foundatio.Repositories.Options;
78
using User = Exceptionless.Core.Models.User;
@@ -19,6 +20,26 @@ public UserRepository(ExceptionlessElasticConfiguration configuration, MiniValid
1920
AddRequiredField(u => u.EmailAddress, u => u.OrganizationIds);
2021
}
2122

23+
public override Task SaveAsync(IEnumerable<User> documents, ICommandOptions? options = null)
24+
{
25+
ArgumentNullException.ThrowIfNull(documents);
26+
var users = documents.ToArray();
27+
if (users.Any(user => String.IsNullOrWhiteSpace(user.Version)))
28+
throw new VersionConflictDocumentException("Read the current user record before saving changes.");
29+
return base.SaveAsync(users, options);
30+
}
31+
32+
public override async Task<User?> GetByIdAsync(Id id, ICommandOptions? options = null)
33+
{
34+
var user = await base.GetByIdAsync(id, options);
35+
if (user is null || !String.IsNullOrWhiteSpace(user.Version))
36+
return user;
37+
38+
// Cached records written by previous hosts do not contain concurrency metadata.
39+
await InvalidateCacheAsync(user);
40+
return await base.GetByIdAsync(id, new CommandOptions<User>().Cache(false));
41+
}
42+
2243
public async Task<User?> RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc)
2344
{
2445
const string script = """
@@ -135,6 +156,11 @@ public Task<bool> SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor<Us
135156

136157
emailAddress = emailAddress.Trim().ToLowerInvariant();
137158
var hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), o => o.Cache(EmailCacheKey(emailAddress)));
159+
if (hit?.Document is { } user && String.IsNullOrWhiteSpace(user.Version))
160+
{
161+
await InvalidateCacheAsync(user);
162+
hit = await FindOneAsync(q => q.FieldEquals(u => u.EmailAddress, emailAddress), o => o.Cache(false));
163+
}
138164
return hit?.Document;
139165
}
140166

‎src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ public static JsonSerializerOptions ConfigureExceptionlessDefaults(this JsonSeri
3535
public static JsonSerializerOptions ConfigureExceptionlessApiDefaults(this JsonSerializerOptions options)
3636
{
3737
ConfigureExceptionlessDefaults(options, skipEmptyCollections: false);
38+
((DefaultJsonTypeInfoResolver)options.TypeInfoResolver!).Modifiers.Add(typeInfo =>
39+
{
40+
if (typeInfo.Type != typeof(Models.User))
41+
return;
42+
var version = typeInfo.Properties.FirstOrDefault(property => property.Name == "version");
43+
if (version is not null)
44+
typeInfo.Properties.Remove(version);
45+
});
3846
return options;
3947
}
4048

‎src/Exceptionless.Web/Api/Endpoints/AuthEndpoints.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ headers api_key input box.
183183
.Produces<TokenResult>()
184184
.ProducesProblem(StatusCodes.Status400BadRequest)
185185
.WithSummary("Removes an external login provider from the account")
186+
.ProducesProblem(StatusCodes.Status409Conflict)
186187
.WithMetadata(new EndpointDocumentation {
187188
RequestBodyDescription = "The provider user id.",
188189
ParameterDescriptions = new() {
@@ -191,6 +192,7 @@ headers api_key input box.
191192
ResponseDescriptions = new() {
192193
["200"] = "User Authentication Token",
193194
["400"] = "Invalid provider name.",
195+
["409"] = "The account changed while the request was being processed.",
194196
}
195197
});
196198

@@ -202,9 +204,11 @@ headers api_key input box.
202204
.Produces<TokenResult>()
203205
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
204206
.WithSummary("Change password")
207+
.ProducesProblem(StatusCodes.Status409Conflict)
205208
.WithMetadata(new EndpointDocumentation {
206209
ResponseDescriptions = new() {
207210
["200"] = "User Authentication Token",
211+
["409"] = "The account changed while the request was being processed.",
208212
["422"] = "Validation error",
209213
}
210214
});
@@ -220,13 +224,15 @@ headers api_key input box.
220224
.Produces(StatusCodes.Status200OK)
221225
.ProducesProblem(StatusCodes.Status400BadRequest)
222226
.WithSummary("Forgot password")
227+
.ProducesProblem(StatusCodes.Status409Conflict)
223228
.WithMetadata(new EndpointDocumentation {
224229
ParameterDescriptions = new() {
225230
["email"] = "The email address.",
226231
},
227232
ResponseDescriptions = new() {
228233
["200"] = "Forgot password email was sent.",
229234
["400"] = "Invalid email address.",
235+
["409"] = "The account changed while the request was being processed.",
230236
}
231237
});
232238

@@ -239,9 +245,11 @@ headers api_key input box.
239245
.Produces(StatusCodes.Status200OK)
240246
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
241247
.WithSummary("Reset password")
248+
.ProducesProblem(StatusCodes.Status409Conflict)
242249
.WithMetadata(new EndpointDocumentation {
243250
ResponseDescriptions = new() {
244251
["200"] = "Password reset email was sent.",
252+
["409"] = "The account changed while the request was being processed.",
245253
["422"] = "Invalid reset password model.",
246254
}
247255
});
@@ -258,13 +266,15 @@ headers api_key input box.
258266
.Produces(StatusCodes.Status200OK)
259267
.ProducesProblem(StatusCodes.Status400BadRequest)
260268
.WithSummary("Cancel reset password")
269+
.ProducesProblem(StatusCodes.Status409Conflict)
261270
.WithMetadata(new EndpointDocumentation {
262271
ParameterDescriptions = new() {
263272
["token"] = "The password reset token.",
264273
},
265274
ResponseDescriptions = new() {
266275
["200"] = "Password reset email was cancelled.",
267276
["400"] = "Invalid password reset token.",
277+
["409"] = "The account changed while the request was being processed.",
268278
}
269279
});
270280

‎tests/Exceptionless.Tests/Api/Data/openapi.json‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,6 +2013,16 @@
20132013
}
20142014
}
20152015
}
2016+
},
2017+
"409": {
2018+
"description": "The account changed while the request was being processed.",
2019+
"content": {
2020+
"application/problem\u002Bjson": {
2021+
"schema": {
2022+
"$ref": "#/components/schemas/ProblemDetails"
2023+
}
2024+
}
2025+
}
20162026
}
20172027
}
20182028
}
@@ -2058,6 +2068,16 @@
20582068
}
20592069
}
20602070
}
2071+
},
2072+
"409": {
2073+
"description": "The account changed while the request was being processed.",
2074+
"content": {
2075+
"application/problem\u002Bjson": {
2076+
"schema": {
2077+
"$ref": "#/components/schemas/ProblemDetails"
2078+
}
2079+
}
2080+
}
20612081
}
20622082
}
20632083
}
@@ -2093,6 +2113,16 @@
20932113
}
20942114
}
20952115
}
2116+
},
2117+
"409": {
2118+
"description": "The account changed while the request was being processed.",
2119+
"content": {
2120+
"application/problem\u002Bjson": {
2121+
"schema": {
2122+
"$ref": "#/components/schemas/ProblemDetails"
2123+
}
2124+
}
2125+
}
20962126
}
20972127
}
20982128
}
@@ -2131,6 +2161,16 @@
21312161
}
21322162
}
21332163
}
2164+
},
2165+
"409": {
2166+
"description": "The account changed while the request was being processed.",
2167+
"content": {
2168+
"application/problem\u002Bjson": {
2169+
"schema": {
2170+
"$ref": "#/components/schemas/ProblemDetails"
2171+
}
2172+
}
2173+
}
21342174
}
21352175
}
21362176
}
@@ -2166,6 +2206,16 @@
21662206
}
21672207
}
21682208
}
2209+
},
2210+
"409": {
2211+
"description": "The account changed while the request was being processed.",
2212+
"content": {
2213+
"application/problem\u002Bjson": {
2214+
"schema": {
2215+
"$ref": "#/components/schemas/ProblemDetails"
2216+
}
2217+
}
2218+
}
21692219
}
21702220
}
21712221
}

‎tests/Exceptionless.Tests/Api/Endpoints/AuthEndpointTests.cs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
using FluentRest;
2323
using Foundatio.Queues;
2424
using Foundatio.Repositories;
25+
using Foundatio.Repositories.Exceptions;
2526
using Foundatio.Repositories.Utility;
2627
using Microsoft.AspNetCore.Authentication;
2728
using Microsoft.AspNetCore.Http;
@@ -2010,6 +2011,46 @@ public async Task ChangePassword_StoredOldTokenSurvivesCleanup_CannotAuthenticat
20102011
Assert.Equal(user.AuthenticationVersion, (await _tokenRepository.GetByIdAsync(changed.Token))!.AuthenticationVersion);
20112012
}
20122013

2014+
[Theory]
2015+
[InlineData(false)]
2016+
[InlineData(true)]
2017+
public async Task ChangePassword_StaleUserSave_CannotRestoreCredentials(bool readFromCache)
2018+
{
2019+
var login = await SendRequestAsAsync<TokenResult>(r => r.Post().AppendPath("auth/login")
2020+
.Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD })
2021+
.StatusCodeShouldBeOk());
2022+
Assert.NotNull(login);
2023+
var current = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
2024+
Assert.NotNull(current);
2025+
var stale = readFromCache
2026+
? await _userRepository.GetByEmailAddressAsync(current.EmailAddress)
2027+
: await _userRepository.GetByIdAsync(current.Id, o => o.Cache(false));
2028+
Assert.NotNull(stale);
2029+
Assert.False(String.IsNullOrEmpty(stale.Version));
2030+
var changed = await SendRequestAsAsync<TokenResult>(r => r.Post().AppendPath("auth/change-password")
2031+
.BearerToken(login.Token)
2032+
.Content(new ChangePasswordModel { CurrentPassword = SampleDataService.TEST_USER_PASSWORD, Password = "ChangedPassword2$" })
2033+
.StatusCodeShouldBeOk());
2034+
Assert.NotNull(changed);
2035+
stale.FullName = "Stale profile update";
2036+
await Assert.ThrowsAsync<VersionConflictDocumentException>(() => _userRepository.SaveAsync(stale));
2037+
var stored = await _userRepository.GetByIdAsync(stale.Id, o => o.Cache(false));
2038+
Assert.NotNull(stored);
2039+
Assert.True(stored.IsCorrectPassword("ChangedPassword2$"));
2040+
Assert.False(stored.IsCorrectPassword(SampleDataService.TEST_USER_PASSWORD));
2041+
Assert.NotEqual(stale.AuthenticationVersion, stored.AuthenticationVersion);
2042+
await SendRequestAsync(r => r.BearerToken(changed.Token).AppendPath("users/me").StatusCodeShouldBeOk());
2043+
}
2044+
2045+
[Fact]
2046+
public async Task SaveUser_WithoutReadVersion_RejectsWrite()
2047+
{
2048+
var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
2049+
Assert.NotNull(user);
2050+
user.Version = null!;
2051+
await Assert.ThrowsAsync<VersionConflictDocumentException>(() => _userRepository.SaveAsync(user));
2052+
}
2053+
20132054
[Fact]
20142055
public async Task AuthenticateAsync_InactiveUserToken_ReturnsUnauthorized()
20152056
{

‎tests/Exceptionless.Tests/Api/Endpoints/UserEndpointTests.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,27 @@ public async Task GetCurrentUserAsync_CachedUser_ReturnsPersistedProfile()
444444
Assert.Equal(persistedName, refreshed.FullName);
445445
}
446446

447+
[Theory]
448+
[InlineData(false)]
449+
[InlineData(true)]
450+
public async Task ReadUser_LegacyCacheWithoutVersion_RefreshesFromStorage(bool byEmail)
451+
{
452+
var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL);
453+
Assert.NotNull(user);
454+
string persistedName = user.FullName;
455+
user.Version = null!;
456+
user.FullName = "Old cached profile";
457+
await Assert.IsType<PausingProfileUserRepository>(_userRepository).CacheUserAsync(user);
458+
var refreshed = byEmail
459+
? await _userRepository.GetByEmailAddressAsync(user.EmailAddress)
460+
: await _userRepository.GetByIdAsync(user.Id);
461+
Assert.NotNull(refreshed);
462+
Assert.Equal(persistedName, refreshed.FullName);
463+
Assert.False(String.IsNullOrWhiteSpace(refreshed.Version));
464+
refreshed.FullName = "Current update";
465+
await _userRepository.SaveAsync(refreshed);
466+
}
467+
447468
[Fact]
448469
public async Task GetCurrentUserAsync_WithAvatar_ReturnsRoutableAvatarUrl()
449470
{

‎tests/Exceptionless.Tests/Serializer/Models/AuthenticationVersionSerializerTests.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Text.Json;
12
using Exceptionless.Core.Models;
3+
using Exceptionless.Core.Serialization;
24
using Foundatio.Serializer;
35
using Xunit;
46

@@ -16,4 +18,14 @@ public void Deserialize_LegacyRecords_PreservesMissingGeneration()
1618
var user = new User { AuthenticationVersion = "current" };
1719
Assert.Equal("current", serializer.Deserialize<User>(serializer.SerializeToString(user))!.AuthenticationVersion);
1820
}
21+
22+
[Fact]
23+
public void UserVersion_CachePreservesMetadata_ApiOmitsMetadata()
24+
{
25+
var user = new User { Version = "1:42" };
26+
var serializer = GetService<ITextSerializer>();
27+
Assert.Equal(user.Version, serializer.Deserialize<User>(serializer.SerializeToString(user))!.Version);
28+
string apiJson = JsonSerializer.Serialize(user, new JsonSerializerOptions().ConfigureExceptionlessApiDefaults());
29+
Assert.DoesNotContain("\"version\"", apiJson);
30+
}
1931
}

‎tests/http/users.http‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
@password = tester
44

55
# External sign-in returns 401 when the account is unavailable. Existing provider links identify their exact provider and subject.
6+
# Concurrent account changes may return 409; retrieve the current state before retrying.
67

78
### login to test account
89
# @name login

0 commit comments

Comments
 (0)