mirror of
https://github.com/streetwriters/notesnook-sync-server.git
synced 2026-11-02 14:23:57 +01:00
* api: purge reset data in the background Resetting a large account deleted every sync collection in one Mongo transaction, which outlived Mongo's time limit and the request timeout. A reset now deletes the small collections (inbox keys and items, monographs, devices) inline and then, in one settings update, clears the keys, increments the user's Generation and marks a purge as pending. Sync items are stamped with the generation they were pushed in, and fetches and the encryption verifier only see the current one, so the old data disappears as soon as the request returns. UserPurgeJob deletes the older generations one collection at a time and clears the pending mark only if no newer reset happened meanwhile. Each push stamps its items inside its transaction by incrementing the user's WriteRevision, so a reset committed during the push conflicts with it and the retry gets the new generation. Items stored before this change have no generation and count as generation 0. * api: reject pushes that cross a reset A push retried after a conflicting reset was stamped with the new generation, although its items are still encrypted with the keys the reset cleared. The push now keeps the generation it first saw across retries and is rejected if a reset changed it. Items are stamped through the captured replacements, so a lazily enumerated input isn't left at generation 0. Client errors thrown by a hub method now reach the client as hub errors, since SignalR hides other exceptions' messages outside development. * api: reset under a lockout and clear token caches over RPC Sessions are revoked while the user is locked out, retrying and then aborting the reset if that fails. Hub connections, devices, inbox keys and monographs from before a reset are fenced by the user's generation. Identity now calls each server to clear cached tokens instead of publishing a message, and the API and subscription server keep their introspection cache in Redis so every replica sees the clear. Servers without the call still get the message until they're upgraded. Logins during a reset say so, and failed purges are counted for alerting. * api: close the gaps the reset review found - the old devices are purged one at a time in a transaction, so a device registering again keeps the ids recorded for it - the reset purge waits 15 minutes for requests still writing to the old generation, then runs once - republishing a monograph replaces a copy the purge hasn't removed yet - inbox keys from before a reset no longer show or count toward the limit - the reset holds a 10 minute lock through the attachments' removal, refuses to overlap another reset, and keeps a lockout set while it ran - the purge failure counter is exported from the first run * api: only tag sync items with a generation The user's monographs, inbox keys, inbox items and devices are few enough to delete during the reset, before the generation changes, so only the sync items need a generation and a background purge. UserPurgeJob now deletes them itself, and the purge failure counter has no labels. * api: reset in one transaction and keep devices that fetch across it unsynced The generation bump, the user check and the deletes of monographs, inbox keys, inbox items and devices run in one transaction, so a failed reset, or one for a user without settings, leaves nothing deleted. A fetch already under way when the reset commits no longer marks its re-registered device as synced, so it still does a full sync after logging in again. * api: log out the other devices before resetting They're told as soon as their sessions end, so their open sync connections close before the reset rather than after it. * api: log out the other devices after a password change even if their sessions weren't ended * identity: keep ended sessions' cache keys until a clear succeeds Ending sessions deletes them before their cached tokens are cleared, so a retry after a failed clear found nothing to clear and succeeded while the revoked tokens still worked from the cache.
187 lines
8.7 KiB
C#
187 lines
8.7 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Notesnook.API.Controllers;
|
|
using Notesnook.API.Interfaces;
|
|
using Notesnook.API.Services;
|
|
using Streetwriters.Common;
|
|
using Streetwriters.Common.Accessors;
|
|
using Streetwriters.Common.Enums;
|
|
using Streetwriters.Common.Extensions;
|
|
using Streetwriters.Common.Interfaces;
|
|
using Streetwriters.Common.Models;
|
|
using WampSharp.V2.Core.Contracts;
|
|
|
|
namespace Notesnook.API.Tests;
|
|
|
|
public class ApiErrorResponseTests(TestDatabase db) : IClassFixture<TestDatabase>
|
|
{
|
|
private const string UserId = "user";
|
|
|
|
private sealed class Api : IAsyncDisposable
|
|
{
|
|
private readonly IHost host;
|
|
public HttpClient Client { get; }
|
|
public ConcurrentQueue<(LogLevel Level, Exception? Exception)> Logs { get; } = new();
|
|
|
|
public Api(TestDatabase db, IUserAccountService identity, IUserSubscriptionService? subscriptions = null, IS3Service? s3 = null)
|
|
{
|
|
var scope = db.CreateScope();
|
|
var accessor = new WampServiceAccessor(Servers.NotesnookAPI) { UserAccountService = identity, UserSubscriptionService = subscriptions };
|
|
s3 ??= Stub<IS3Service>.Create();
|
|
|
|
host = new HostBuilder().ConfigureWebHost(web => web
|
|
.UseTestServer()
|
|
.ConfigureLogging(logging => logging.AddProvider(new CapturingLoggerProvider(Logs)))
|
|
.ConfigureServices(services =>
|
|
{
|
|
services.AddControllers().AddApplicationPart(typeof(UsersController).Assembly);
|
|
services.AddApiExceptionHandler();
|
|
services.AddDefaultCors();
|
|
services.AddAuthentication("Test").AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null);
|
|
services.AddAuthorization(options => options.AddPolicy("Sync", policy => policy.RequireAuthenticatedUser()));
|
|
services.AddHttpContextAccessor();
|
|
services.AddHttpClient();
|
|
services.AddSingleton(accessor);
|
|
services.AddSingleton(s3);
|
|
services.AddSingleton(Stub<IUserKeysService>.Create());
|
|
services.AddSingleton<ISyncItemsRepositoryAccessor>(scope.Repositories);
|
|
services.AddSingleton<IUserService>((provider) => new UserService(
|
|
provider.GetRequiredService<Microsoft.AspNetCore.Http.IHttpContextAccessor>(),
|
|
scope.Repositories, scope.Unit, s3, scope.DbContext, accessor, NullLogger<UserService>.Instance));
|
|
})
|
|
.Configure(app =>
|
|
{
|
|
app.UseExceptionHandler(_ => { });
|
|
app.UseCors("notesnook");
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseEndpoints(endpoints => endpoints.MapControllers());
|
|
})).Start();
|
|
Client = host.GetTestClient();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await host.StopAsync();
|
|
host.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
|
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
{
|
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
{
|
|
var identity = new ClaimsIdentity([new Claim("sub", UserId)], Scheme.Name);
|
|
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingLoggerProvider(ConcurrentQueue<(LogLevel, Exception?)> logs) : ILoggerProvider
|
|
{
|
|
public ILogger CreateLogger(string categoryName) => new Logger(logs);
|
|
public void Dispose() { }
|
|
|
|
private sealed class Logger(ConcurrentQueue<(LogLevel, Exception?)> logs) : ILogger
|
|
{
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
=> logs.Enqueue((logLevel, exception));
|
|
}
|
|
}
|
|
|
|
private static UserModel User => new() { UserId = UserId, Email = "user@example.com", MFA = new MFAConfig { PrimaryMethod = "app" } };
|
|
|
|
private static Subscription FreeSubscription => new() { UserId = UserId, AppId = ApplicationType.NOTESNOOK, Plan = SubscriptionPlan.FREE };
|
|
|
|
private static async Task<string?> ErrorAsync(HttpResponseMessage response)
|
|
=> (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetString();
|
|
|
|
[Fact]
|
|
public async Task Deleting_with_a_wrong_password_is_a_400()
|
|
{
|
|
var deleted = false;
|
|
var identity = Stub<IUserAccountService>.Create(
|
|
("VerifyPasswordAsync", (_) => Task.FromResult(false)),
|
|
("UnregisterUserAsync", (_) => { deleted = true; return Task.CompletedTask; }));
|
|
await using var api = new Api(db, identity);
|
|
|
|
var response = await api.Client.PostAsync("/users/delete", new FormUrlEncodedContent(new Dictionary<string, string> { ["password"] = "wrong" }));
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
Assert.Equal("Wrong password.", await ErrorAsync(response));
|
|
Assert.False(deleted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Uploading_past_the_storage_limit_is_a_400()
|
|
{
|
|
var subscriptions = Stub<IUserSubscriptionService>.Create(("GetUserSubscriptionAsync", (_) => Task.FromResult<Subscription?>(FreeSubscription)));
|
|
var s3 = Stub<IS3Service>.Create(("TryReserveStorageAsync", (_) => Task.FromResult(false)));
|
|
await using var api = new Api(db, Stub<IUserAccountService>.Create(), subscriptions, s3);
|
|
|
|
var response = await api.Client.PutAsync("/s3?name=file", new ByteArrayContent(new byte[16]));
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
Assert.Equal("Storage limit exceeded.", await ErrorAsync(response));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Identity_being_down_is_a_503()
|
|
{
|
|
var identity = Stub<IUserAccountService>.Create(("GetUserAsync", (_) => throw new WampConnectionBrokenException("connection lost")));
|
|
await using var api = new Api(db, identity);
|
|
|
|
var response = await api.Client.GetAsync("/users");
|
|
|
|
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
|
Assert.NotNull(response.Headers.RetryAfter);
|
|
Assert.DoesNotContain("connection lost", await ErrorAsync(response));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Browsers_can_read_error_responses_and_their_Retry_After()
|
|
{
|
|
var identity = Stub<IUserAccountService>.Create(("GetUserAsync", (_) => throw new WampConnectionBrokenException("connection lost")));
|
|
await using var api = new Api(db, identity);
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Get, "/users");
|
|
request.Headers.Add("Origin", "https://app.notesnook.com");
|
|
var response = await api.Client.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
|
Assert.True(response.Headers.Contains("Access-Control-Allow-Origin"));
|
|
Assert.Contains("Retry-After", response.Headers.GetValues("Access-Control-Expose-Headers").Single());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Internal_errors_are_a_500_logged_once_without_their_message()
|
|
{
|
|
// no user settings in the database, so GetUserAsync throws a plain Exception
|
|
var identity = Stub<IUserAccountService>.Create(("GetUserAsync", (_) => Task.FromResult<UserModel?>(User)));
|
|
var subscriptions = Stub<IUserSubscriptionService>.Create(("GetUserSubscriptionAsync", (_) => Task.FromResult<Subscription?>(FreeSubscription)));
|
|
await using var api = new Api(db, identity, subscriptions);
|
|
|
|
var response = await api.Client.GetAsync("/users");
|
|
|
|
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
|
Assert.DoesNotContain("settings", await ErrorAsync(response));
|
|
Assert.Single(api.Logs, (log) => log.Exception?.Message == "User settings not found.");
|
|
}
|
|
}
|