mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
1052 lines
47 KiB
C#
1052 lines
47 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.EntityFrameworkCore.Storage;
|
|
using RadicadeServer.API.Accounts;
|
|
using RadicadeServer.Auth;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.Extensions;
|
|
using RadicadeServer.RecNet.Accounts;
|
|
using RadicadeServer.RecNet.Accounts.Local;
|
|
using RadicadeServer.RecNet.Auth;
|
|
using RadicadeServer.RecNet.Auth.Cached;
|
|
using RadicadeServer.RecNet.Auth.Connection;
|
|
using RadicadeServer.RecNet.Enums.Players;
|
|
using RadicadeServer.RecNet.Responses;
|
|
using RadicadeServer.RecNet.Rooms;
|
|
using RadicadeServer.Utils;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// The registration contract: what a new account is made of, what gets refused, and what a refusal
|
|
/// is allowed to leave behind. The onboarding-flow tests next door cover how the client drives the
|
|
/// flow; these cover the account the server hands it.
|
|
/// </summary>
|
|
[Collection(DbFactoryCollection.Name)]
|
|
public class AccountRegistrationTests
|
|
{
|
|
private const string PlatformId = "76561198000000042";
|
|
private const string DeviceId = "registration-device";
|
|
private const string ChosenName = "BrassLantern7412";
|
|
private const string ChosenPassword = "correct-horse-battery";
|
|
|
|
/// <summary>The seeded server's own row (see <see cref="SeedSystemAccountAsync"/>) is never a player account.</summary>
|
|
private const long SystemAccountId = 1;
|
|
|
|
public AccountRegistrationTests() => AllowEveryRegistration();
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// What a successful registration produces
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task ANewAccountGetsEveryRecordItNeedsToBePlayable()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedSystemAccountAsync(db);
|
|
var created = await PlayerUtils.CreateAccount(Registration(), clientIp: "203.0.113.7");
|
|
var profile = Assert.IsType<Profile>(created.value);
|
|
Assert.True(created.success, created.error);
|
|
|
|
// Profile: the identity every other route reads.
|
|
var savedProfile = await db.Profiles.SingleAsync(x => x.accountId == profile.accountId);
|
|
Assert.Equal(profile.accountId, savedProfile.accountId);
|
|
Assert.Equal(ChosenName, savedProfile.username);
|
|
Assert.Equal(ChosenName, savedProfile.displayName);
|
|
Assert.Equal("DefaultProfileImage", savedProfile.profileImage);
|
|
Assert.Equal(PlayerUtils.PlatformMask(Platforms.Steam), savedProfile.platforms);
|
|
Assert.False(savedProfile.isJunior);
|
|
Assert.NotEqual(default, savedProfile.createdAt);
|
|
|
|
// Player: credentials plus the identity later boots are matched on. The device id lands
|
|
// trimmed, because every lookup compares it against a trimmed value.
|
|
var player = await db.Players.SingleAsync();
|
|
Assert.Equal(savedProfile.accountId, player.AccountId);
|
|
Assert.Equal(DeviceId, player.DeviceId);
|
|
Assert.Equal("203.0.113.7", player.LastIpAddress);
|
|
Assert.Equal(DeviceClass.Screen, player.DeviceClass);
|
|
Assert.Equal(new DateTime(1998, 4, 30), player.Birthday);
|
|
Assert.False(player.IsJunior);
|
|
Assert.Equal(JuniorState.BasedOnBirthday, player.JuniorState);
|
|
Assert.False(string.IsNullOrWhiteSpace(player.Hashword));
|
|
var hasher = new PasswordHasher<PlayerData>();
|
|
Assert.NotEqual(PasswordVerificationResult.Failed,
|
|
hasher.VerifyHashedPassword(player, player.Hashword, ChosenPassword));
|
|
Assert.Equal(DBConfig.GameVersion, player.AppVersion);
|
|
Assert.Equal(99, player.NameChanges);
|
|
|
|
// Everything else the game reads on the first frame.
|
|
var avatar = await db.Avatars.SingleAsync();
|
|
Assert.Equal(savedProfile.accountId, avatar.AccountId);
|
|
Assert.False(string.IsNullOrWhiteSpace(avatar.Avatar));
|
|
Assert.Equal("[]", avatar.Outfits);
|
|
|
|
var unlocks = await db.Unlocks.SingleAsync();
|
|
Assert.Equal(savedProfile.accountId, unlocks.AccountId);
|
|
Assert.False(string.IsNullOrWhiteSpace(unlocks.AvatarItems));
|
|
|
|
var progression = await db.Progression.SingleAsync();
|
|
Assert.Equal(savedProfile.accountId, progression.PlayerId);
|
|
Assert.Equal(0, progression.XP);
|
|
Assert.Equal(1, progression.Level);
|
|
|
|
var presence = await db.Presences.SingleAsync();
|
|
Assert.Equal(savedProfile.accountId, presence.playerId);
|
|
Assert.Equal(DeviceClass.Screen, presence.deviceClass);
|
|
|
|
Assert.Equal(savedProfile.accountId, (await db.Currency.SingleAsync()).AccountId);
|
|
Assert.Equal(savedProfile.accountId, (await db.Reputations.SingleAsync()).AccountId);
|
|
Assert.NotNull(await db.PlayerSettings.SingleAsync());
|
|
|
|
// The link that makes the next boot resolve to this account instead of to the sign-up flow.
|
|
var login = await db.CachedLogins.SingleAsync();
|
|
Assert.Equal(savedProfile.accountId, login.accountId);
|
|
Assert.Equal(Platforms.Steam, login.platform);
|
|
Assert.Equal(PlatformId, login.platformId);
|
|
|
|
// And the account is immediately usable: this is the call the title screen makes.
|
|
var auth = CreateApi(db, PlatformId);
|
|
var loginRequest = Token(Registration(), "cached_login");
|
|
loginRequest.account_id = savedProfile.accountId;
|
|
var response = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await auth.ConnectWithToken(loginRequest)).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARegistrationWithoutANameStillGetsAUsableGeneratedOne()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
var registration = Registration();
|
|
registration.username = null;
|
|
|
|
var created = await PlayerUtils.CreateAccount(registration);
|
|
var profile = Assert.IsType<Profile>(created.value);
|
|
|
|
Assert.True(created.success, created.error);
|
|
Assert.Null(UsernameRules.Rejection(profile.username));
|
|
Assert.False(string.IsNullOrEmpty(profile.username));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnEmailIsKeptOnceAndNeverAttachedToASecondAccount()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
|
|
var first = Registration();
|
|
first.username = "FirstComer";
|
|
first.email = "someone@example.com";
|
|
var firstAccount = Assert.IsType<Profile>((await PlayerUtils.CreateAccount(first)).value);
|
|
|
|
var second = Registration();
|
|
second.username = "SecondComer";
|
|
second.email = "someone@example.com";
|
|
var secondAccount = Assert.IsType<Profile>((await PlayerUtils.CreateAccount(second)).value);
|
|
|
|
Assert.Equal("someone@example.com", (await db.Players.SingleAsync(x => x.AccountId == firstAccount.accountId)).Email);
|
|
// Still registered, but the address is not attached twice.
|
|
Assert.Equal("", (await db.Players.SingleAsync(x => x.AccountId == secondAccount.accountId)).Email);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AYoungBirthdayMakesAJuniorAccount()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var registration = Registration();
|
|
registration.username = "YoungerPlayer";
|
|
registration.birthday = DateTime.UtcNow.AddYears(-9);
|
|
|
|
var created = await PlayerUtils.CreateAccount(registration);
|
|
var profile = Assert.IsType<Profile>(created.value);
|
|
|
|
Assert.True(created.success, created.error);
|
|
Assert.True(profile.isJunior);
|
|
Assert.True((await db.Players.SingleAsync()).IsJunior);
|
|
Assert.True(PlayerUtils.BuildSelfAccount(profile, await db.Players.SingleAsync()).isJunior);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARegistrationWithoutABirthdayStoresNoneRatherThanAPlaceholderDate()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var registration = Registration();
|
|
registration.birthday = null;
|
|
registration.birthdayUTC = null;
|
|
|
|
var created = await PlayerUtils.CreateAccount(registration);
|
|
var profile = Assert.IsType<Profile>(created.value);
|
|
|
|
Assert.True(created.success, created.error);
|
|
var player = await db.Players.SingleAsync();
|
|
Assert.Null(player.Birthday);
|
|
Assert.False(player.IsJunior);
|
|
Assert.False(PlayerUtils.BuildSelfAccount(profile, player).hasBirthday);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// What registration refuses
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("ab", "at least")] // too short
|
|
[InlineData("AdventurousAlligator99999", "24")] // longer than the client itself can generate
|
|
[InlineData("Has Space", "letters and numbers")]
|
|
[InlineData("Not-Alphanumeric", "letters and numbers")]
|
|
[InlineData("Emoji🙂", "letters and numbers")]
|
|
[InlineData("Ünicode", "letters and numbers")] // non-ASCII letters are not letters here
|
|
[InlineData("123456", "at least one letter")]
|
|
[InlineData("system", "reserved")]
|
|
[InlineData("Official", "reserved")]
|
|
public async Task AnUnusableNameIsRefusedAndThePlayerIsNotSilentlyRenamed(string name, string expectedReason)
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var registration = Registration();
|
|
registration.username = name;
|
|
|
|
var created = await PlayerUtils.CreateAccount(registration);
|
|
|
|
Assert.False(created.success);
|
|
Assert.Contains(expectedReason, created.error, StringComparison.OrdinalIgnoreCase);
|
|
// A refusal costs no rows: not even a profile with nothing behind it.
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ADuplicateNameIsRefusedWhateverItsCase()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 4,
|
|
username = "PandaParty",
|
|
displayName = "PandaParty",
|
|
profileImage = "default",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var registration = Registration();
|
|
registration.username = "pandaparty";
|
|
|
|
var created = await PlayerUtils.CreateAccount(registration);
|
|
|
|
Assert.False(created.success);
|
|
Assert.Contains("already taken", created.error, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("", DeviceId, ChosenPassword, null)] // no platform id
|
|
[InlineData(PlatformId, "", ChosenPassword, null)] // no device id
|
|
[InlineData("76561198000000042; DROP TABLE Players--", DeviceId, ChosenPassword, null)] // crafted platform id
|
|
[InlineData(PlatformId, "device with spaces", ChosenPassword, null)] // crafted device id
|
|
[InlineData(PlatformId, DeviceId, "short", null)] // password under the floor
|
|
[InlineData(PlatformId, DeviceId, ChosenPassword, "2999-01-01")] // birthday in the future
|
|
public async Task AMalformedRegistrationIsRefusedBeforeTheDatabaseIsTouched(
|
|
string platformId, string deviceId, string? password, string? birthday)
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var registration = Registration();
|
|
registration.username = "WouldBePlayer";
|
|
registration.platformId = platformId;
|
|
registration.deviceId = deviceId;
|
|
registration.password = password;
|
|
if (birthday != null)
|
|
registration.birthday = DateTime.Parse(birthday, null, System.Globalization.DateTimeStyles.AdjustToUniversal);
|
|
|
|
var created = await PlayerUtils.CreateAccount(registration);
|
|
|
|
Assert.False(created.success);
|
|
Assert.False(string.IsNullOrWhiteSpace(created.error));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnOversizedDeviceIdIsRefusedRatherThanStored()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var registration = Registration();
|
|
registration.username = "TooLongDevice";
|
|
registration.deviceId = new string('a', 257);
|
|
|
|
var created = await PlayerUtils.CreateAccount(registration);
|
|
|
|
Assert.False(created.success);
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// One identity, one account
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task ARepeatBootGrantResumesTheAccountInsteadOfAddingASecond()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedSystemAccountAsync(db);
|
|
var boot = Token(Registration());
|
|
|
|
var first = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await CreateApi(db, PlatformId).ConnectWithToken(boot)).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(first.error), first.error_description);
|
|
|
|
var second = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await CreateApi(db, PlatformId).ConnectWithToken(Token(Registration()))).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(second.error), second.error_description);
|
|
|
|
Assert.Single(await db.Profiles.Where(x => x.accountId != SystemAccountId).ToListAsync());
|
|
Assert.Single(await db.Players.ToListAsync());
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SimultaneousBootGrantsForOneIdentityCreateExactlyOneAccount()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using (var seed = factory.CreateDbContext())
|
|
await SeedSystemAccountAsync(seed);
|
|
|
|
// Each call gets its own context and its own controller, exactly as concurrent requests do.
|
|
var apis = Enumerable.Range(0, 6).Select(_ => factory.CreateDbContext()).ToList();
|
|
try
|
|
{
|
|
var responses = await Task.WhenAll(apis.Select(
|
|
db => CreateApi(db, PlatformId).ConnectWithToken(Token(Registration()))));
|
|
|
|
foreach (var result in responses)
|
|
{
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
}
|
|
|
|
await using var verify = factory.CreateDbContext();
|
|
Assert.Single(await verify.Profiles.Where(x => x.accountId != SystemAccountId).ToListAsync());
|
|
Assert.Single(await verify.Players.ToListAsync());
|
|
Assert.Single(await verify.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
foreach (var db in apis) await db.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TwoDifferentIdentitiesOnOneDeviceEachGetTheirOwnAccount()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
|
|
var first = Registration();
|
|
first.username = "FirstIdentity";
|
|
first.platformId = "76561198000000001";
|
|
var firstAccount = Assert.IsType<Profile>((await PlayerUtils.CreateAccount(first)).value);
|
|
|
|
// A different account on the same machine: allowed, and it does not steal the first one's rows.
|
|
var second = Registration();
|
|
second.username = "SecondIdentity";
|
|
second.platformId = "76561198000000002";
|
|
var secondAccount = Assert.IsType<Profile>((await PlayerUtils.CreateAccount(second)).value);
|
|
|
|
Assert.NotEqual(firstAccount.accountId, secondAccount.accountId);
|
|
Assert.Equal(2, await db.Profiles.CountAsync());
|
|
Assert.Equal(2, await db.Players.CountAsync());
|
|
Assert.Equal(2, await db.CachedLogins.CountAsync());
|
|
Assert.Single(await db.Avatars.Where(x => x.AccountId == secondAccount.accountId).ToListAsync());
|
|
}
|
|
|
|
/// <summary>
|
|
/// A name taken by one identity is not available to another, whichever of them asks second, and
|
|
/// the refusal leaves nothing behind. Note this is the *sequential* form of the rule: on the
|
|
/// in-memory provider these two calls do not actually interleave (every await completes
|
|
/// synchronously, so the first finishes before the second reads), which is why the gate has its
|
|
/// own test below instead of being inferred from this one.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ASecondIdentityCannotTakeTheNameTheFirstOneAlreadyHas()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
// Two brand-new identities, one name between them.
|
|
var first = Registration();
|
|
first.platformId = "76561198000000011";
|
|
var second = Registration();
|
|
second.platformId = "76561198000000012";
|
|
|
|
var results = await Task.WhenAll(
|
|
PlayerUtils.CreateAccount(first),
|
|
PlayerUtils.CreateAccount(second));
|
|
|
|
var winner = Assert.Single(results, x => x.success);
|
|
var loser = Assert.Single(results, x => !x.success);
|
|
Assert.Equal(ChosenName, Assert.IsType<Profile>(winner.value).username);
|
|
Assert.Contains("already taken", loser.error, StringComparison.OrdinalIgnoreCase);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
Assert.Single(await db.Profiles.Where(x => x.username == ChosenName).ToListAsync());
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANewRegistrationLeavesAnExistingAccountAlone()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 4,
|
|
username = "Established",
|
|
displayName = "Established",
|
|
profileImage = "default",
|
|
createdAt = DateTime.UtcNow.AddYears(-1)
|
|
});
|
|
db.Players.Add(new PlayerData
|
|
{
|
|
AccountId = 4,
|
|
Hashword = "existing-hash",
|
|
Birthday = new DateTime(1990, 2, 2),
|
|
DeviceId = "someone-elses-device"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var created = await PlayerUtils.CreateAccount(Registration());
|
|
Assert.True(created.success, created.error);
|
|
|
|
var untouched = await db.Players.SingleAsync(x => x.AccountId == 4);
|
|
Assert.Equal("existing-hash", untouched.Hashword);
|
|
Assert.Equal(new DateTime(1990, 2, 2), untouched.Birthday);
|
|
Assert.Equal("someone-elses-device", untouched.DeviceId);
|
|
Assert.Equal("Established", (await db.Profiles.SingleAsync(x => x.accountId == 4)).username);
|
|
Assert.Equal(2, await db.Profiles.CountAsync());
|
|
Assert.Equal(2, await db.Players.CountAsync());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Abuse control
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task CreatingAccountsFasterThanADeviceShouldIsRefused()
|
|
{
|
|
RegistrationGuard.Reset();
|
|
RegistrationGuard.Configure(maxCreationsPerIp: 1000, maxCreationsPerDevice: 1000,
|
|
minimumDeviceInterval: TimeSpan.FromMinutes(5));
|
|
try
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedSystemAccountAsync(db);
|
|
var first = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await CreateApi(db, PlatformId).ConnectWithToken(Token(Registration()))).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(first.error), first.error_description);
|
|
|
|
// The first account has finished onboarding, so it is not up for adoption by the next
|
|
// identity that boots on this machine. That leaves provisioning as the only way the
|
|
// second boot could get an account - which is exactly what the device gap has to stop.
|
|
var settingsRow = await db.PlayerSettings.FirstAsync(x => x.AccountId != SystemAccountId);
|
|
var values = settingsRow.DeserializeSettings();
|
|
PlayerUtils.SetSettingFlag(values, "Recroom.AccountCreation.HasFinished", true);
|
|
settingsRow.Settings = values.ToJson();
|
|
await db.SaveChangesAsync();
|
|
|
|
// Same device, brand-new identity: refused, and nothing is written for it.
|
|
var second = Registration();
|
|
second.username = "ThrottledSecond";
|
|
second.platformId = "76561198000000099";
|
|
var secondResult = await CreateApi(db, second.platformId).ConnectWithToken(Token(second));
|
|
var refusal = Assert.IsType<ObjectResult>(secondResult.Result);
|
|
Assert.Equal(400, refusal.StatusCode);
|
|
Assert.Contains("just created", Assert.IsType<AuthResponse>(refusal.Value).error_description,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
Assert.Single(await db.Profiles.Where(x => x.accountId != SystemAccountId).ToListAsync());
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
|
|
// Resuming is never throttled: the identity that already has an account can still get in.
|
|
var resume = Token(Registration(), "cached_login");
|
|
resume.account_id = (await db.Profiles.SingleAsync(x => x.accountId != SystemAccountId)).accountId;
|
|
var resumed = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await CreateApi(db, PlatformId).ConnectWithToken(resume)).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(resumed.error), resumed.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(resumed.access_token));
|
|
}
|
|
finally
|
|
{
|
|
AllowEveryRegistration();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// The deployed configuration
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task CreatingAnAccountSucceedsUnderARetryingExecutionStrategy()
|
|
{
|
|
// The production server runs SQL Server with EnableRetryOnFailure(3). A retrying execution
|
|
// strategy does not permit a hand-opened transaction - "does not support user-initiated
|
|
// transactions" - so account creation failed for every player at the boot grant, with
|
|
// "Nothing was saved", while these tests stayed green because they never enabled retrying.
|
|
// Same provider shape, real primary keys: this is the test that would have caught it.
|
|
using var harness = new SqliteHarness(failOnSaveCall: 0, retrying: true);
|
|
|
|
var created = await PlayerUtils.CreateAccount(Registration());
|
|
|
|
Assert.True(created.success, created.error);
|
|
|
|
await using var verify = harness.Factory.CreateDbContext();
|
|
Assert.Single(await verify.Profiles.ToListAsync());
|
|
Assert.Single(await verify.Players.ToListAsync());
|
|
Assert.Single(await verify.CachedLogins.ToListAsync());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Failure handling
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task AWriteFailurePartWayThroughLeavesNoAccountBehind()
|
|
{
|
|
// A real relational provider, because this test is about the transaction: the profile row and
|
|
// every row that hangs off it have to commit together or not at all. The interceptor makes the
|
|
// second save fail, which is after the profile row has already been written.
|
|
using var harness = new SqliteHarness(failOnSaveCall: 2);
|
|
|
|
var created = await PlayerUtils.CreateAccount(Registration());
|
|
|
|
Assert.False(created.success);
|
|
Assert.False(string.IsNullOrWhiteSpace(created.error));
|
|
|
|
await using var verify = harness.Factory.CreateDbContext();
|
|
Assert.Empty(await verify.Profiles.ToListAsync());
|
|
Assert.Empty(await verify.Players.ToListAsync());
|
|
Assert.Empty(await verify.CachedLogins.ToListAsync());
|
|
Assert.Empty(await verify.PlayerSettings.ToListAsync());
|
|
Assert.Empty(await verify.Avatars.ToListAsync());
|
|
Assert.Empty(await verify.Unlocks.ToListAsync());
|
|
Assert.Empty(await verify.Progression.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheSameRegistrationSucceedsOnARelationalDatabase()
|
|
{
|
|
using var harness = new SqliteHarness(failOnSaveCall: 0);
|
|
|
|
var created = await PlayerUtils.CreateAccount(Registration());
|
|
var profile = Assert.IsType<Profile>(created.value);
|
|
Assert.True(created.success, created.error);
|
|
|
|
await using var verify = harness.Factory.CreateDbContext();
|
|
Assert.Single(await verify.Profiles.ToListAsync());
|
|
Assert.Single(await verify.Players.ToListAsync());
|
|
Assert.Single(await verify.CachedLogins.ToListAsync());
|
|
Assert.Equal(profile.accountId, (await verify.Players.SingleAsync()).AccountId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Username rules on their own
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("BrilliantZebra5750")]
|
|
[InlineData("abc")]
|
|
[InlineData("AdventurousAlligator9999")]
|
|
[InlineData("@ANameWithAt")]
|
|
[InlineData(" PaddedName ")]
|
|
public void UsableNamesAreUsable(string name)
|
|
{
|
|
var parsed = UsernameRules.Parse(name, out var rejection);
|
|
Assert.Null(rejection);
|
|
Assert.NotNull(parsed);
|
|
Assert.InRange(parsed!.Length, UsernameRules.MinLength, UsernameRules.MaxLength);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ab")]
|
|
[InlineData("AdventurousAlligator99999")]
|
|
[InlineData("two words")]
|
|
[InlineData("under_score")]
|
|
[InlineData("emoji😀")]
|
|
[InlineData("1234")]
|
|
[InlineData("admin")]
|
|
public void UnusableNamesAreReportedNotTrimmed(string name)
|
|
{
|
|
var parsed = UsernameRules.Parse(name, out var rejection);
|
|
Assert.Null(parsed);
|
|
Assert.False(string.IsNullOrWhiteSpace(rejection));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnAbsentNameIsNotAnInvalidName()
|
|
{
|
|
Assert.Null(UsernameRules.Parse(null, out var missing));
|
|
Assert.Null(missing);
|
|
Assert.Null(UsernameRules.Parse(" ", out missing));
|
|
Assert.Null(missing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The name gate has to do one thing: two callers holding the same name never overlap. Unlike
|
|
/// the account-level test above this cannot pass by accident, because the holders are forced to
|
|
/// be inside the gate at the same time - if they ever are, the flag says so. The three names
|
|
/// differ only in case, which is also what the duplicate-name check is case-insensitive about.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TwoHoldersOfTheSameNameAreNeverInsideTheGateAtOnce()
|
|
{
|
|
var inside = 0;
|
|
var overlapped = 0;
|
|
|
|
async Task Hold(string name)
|
|
{
|
|
using var held = await RegistrationGuard.LockNameAsync(name);
|
|
if (Interlocked.Increment(ref inside) > 1) Interlocked.Exchange(ref overlapped, 1);
|
|
await Task.Delay(25);
|
|
Interlocked.Decrement(ref inside);
|
|
}
|
|
|
|
await Task.WhenAll(Hold("ContestedName"), Hold("contestedname"), Hold("CONTESTEDNAME"));
|
|
|
|
Assert.Equal(0, overlapped);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// The username page
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task ChoosingAUsernameAlsoNamesTheAccountSoItIsNotLeftAsTheGeneratedOne()
|
|
{
|
|
// This is the route the 2023 client's account-creation username page calls. The account is
|
|
// created with a generated name in both its fields, so a route that sets only the username
|
|
// leaves the player finished with onboarding and still appearing under the name the server
|
|
// invented for them.
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var created = Assert.IsType<Profile>((await PlayerUtils.CreateAccount(Registration())).value);
|
|
|
|
var api = new AccountsAPI(db) { ControllerContext = ContextFor(created.accountId) };
|
|
var result = await api.SetUsername("WildRabbit");
|
|
|
|
Assert.True(result.success, result.error);
|
|
var profile = await db.Profiles.SingleAsync(x => x.accountId == created.accountId);
|
|
Assert.Equal("WildRabbit", profile.username);
|
|
Assert.Equal("WildRabbit", profile.displayName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChoosingAUsernameLeavesACustomDisplayNameAlone()
|
|
{
|
|
// A display name the player deliberately set to something other than their username is not
|
|
// overwritten by a later username change. Setting one is a settings-screen action, so the
|
|
// account here is a finished one: a mid-flow account always has the two names in step,
|
|
// because the creation flow has no display-name page of its own to diverge them with.
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var created = Assert.IsType<Profile>((await PlayerUtils.CreateAccount(Registration())).value);
|
|
|
|
// Both rows are written through this context: the profile CreateAccount hands back belongs to
|
|
// its own, so mutating it here would not reach the database the route reads.
|
|
var settings = await db.PlayerSettings.SingleAsync(x => x.AccountId == created.accountId);
|
|
var values = settings.DeserializeSettings();
|
|
PlayerUtils.SetSettingFlag(values, "Recroom.AccountCreation.HasFinished", true);
|
|
settings.Settings = values.ToJson();
|
|
(await db.Profiles.SingleAsync(x => x.accountId == created.accountId)).displayName = "Captain Lantern";
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new AccountsAPI(db) { ControllerContext = ContextFor(created.accountId) };
|
|
var result = await api.SetUsername("WildRabbit");
|
|
|
|
Assert.True(result.success, result.error);
|
|
var profile = await db.Profiles.SingleAsync(x => x.accountId == created.accountId);
|
|
Assert.Equal("WildRabbit", profile.username);
|
|
Assert.Equal("Captain Lantern", profile.displayName);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// The onboarding hand-off
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task AnAccountCreatedAtBootStartsTheAccountCreationFlow()
|
|
{
|
|
// The 2023 client creates its account on the boot grant and only then walks its creation
|
|
// pages: birthday, username, password, code of conduct, avatar. That happens only if the row
|
|
// says the flow has started. With HasStarted=false the client reads IsInAccountCreationState
|
|
// as false, takes the cached-account route and launches, so the welcome screen and every one
|
|
// of those steps is skipped - the player never gets to choose a name at all.
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedSystemAccountAsync(db);
|
|
|
|
var boot = Token(Registration());
|
|
boot.username = null;
|
|
boot.password = null;
|
|
boot.birthday = null;
|
|
boot.email = null;
|
|
boot.avatar = null;
|
|
boot.avatarJson = null;
|
|
|
|
var response = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await CreateApi(db, PlatformId).ConnectWithToken(boot)).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
|
|
var settings = (await db.PlayerSettings.SingleAsync()).DeserializeSettings();
|
|
Assert.True(PlayerUtils.HasSettingFlag(settings, "Recroom.AccountCreation.HasStarted"));
|
|
Assert.False(PlayerUtils.HasSettingFlag(settings, "Recroom.AccountCreation.HasChosenUsername"));
|
|
Assert.False(PlayerUtils.HasSettingFlag(settings, "Recroom.AccountCreation.HasCreatedPassword"));
|
|
Assert.False(PlayerUtils.HasSettingFlag(settings, "Recroom.AccountCreation.HasFinished"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumingAnAccountThatNeverStartedTheFlowPutsItBackIntoIt()
|
|
{
|
|
// Accounts written before the started marker was set correctly still carry HasStarted=false,
|
|
// and that state is permanent unless a resume repairs it: the client would skip onboarding
|
|
// for those accounts on every launch from here on.
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedStrandedAccountAsync(db, accountId: 7, finished: false);
|
|
|
|
var boot = BlankBoot();
|
|
var response = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await CreateApi(db, PlatformId).ConnectWithToken(boot)).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
|
|
var settings = (await db.PlayerSettings.SingleAsync(x => x.AccountId == 7)).DeserializeSettings();
|
|
Assert.True(PlayerUtils.HasSettingFlag(settings, "Recroom.AccountCreation.HasStarted"));
|
|
Assert.False(PlayerUtils.HasSettingFlag(settings, "Recroom.AccountCreation.HasFinished"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResumingAFinishedAccountLeavesItsSettingsUntouched()
|
|
{
|
|
// The repair is for accounts stuck mid-flow. A player who finished onboarding keeps exactly
|
|
// the settings the client wrote for them.
|
|
var factory = new TestDbContextFactory();
|
|
using var harness = new Harness(factory);
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedStrandedAccountAsync(db, accountId: 8, finished: true);
|
|
var before = (await db.PlayerSettings.SingleAsync(x => x.AccountId == 8)).Settings;
|
|
|
|
var response = Assert.IsType<AuthResponse>(
|
|
Assert.IsType<OkObjectResult>((await CreateApi(db, PlatformId).ConnectWithToken(BlankBoot())).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
|
|
var after = (await db.PlayerSettings.SingleAsync(x => x.AccountId == 8)).Settings;
|
|
Assert.Equal(before, after);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// Harness
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
private static AccountCreationData Registration() => new()
|
|
{
|
|
platform = Platforms.Steam,
|
|
platformId = PlatformId,
|
|
deviceId = " " + DeviceId + " ",
|
|
deviceClass = DeviceClass.Screen,
|
|
username = ChosenName,
|
|
password = ChosenPassword,
|
|
birthday = new DateTime(1998, 4, 30)
|
|
};
|
|
|
|
private static TokenConnectData Token(AccountCreationData source, string grant = "create_account") => new()
|
|
{
|
|
grant_type = grant,
|
|
account_id = 0,
|
|
ver = "20230831",
|
|
device_class = source.deviceClass,
|
|
device_id = source.deviceId,
|
|
platform = source.platform,
|
|
platform_id = source.platformId,
|
|
client_id = "recroom",
|
|
platform_auth = "{\"Ticket\":\"140test-ticket\",\"AppId\":\"471710\"}",
|
|
username = source.username,
|
|
password = source.password,
|
|
birthday = source.birthday,
|
|
birthdayUTC = source.birthdayUTC,
|
|
email = source.email,
|
|
avatar = source.avatar,
|
|
avatarJson = source.avatarJson
|
|
};
|
|
|
|
/// <summary>
|
|
/// A seeded server's first row is the system account, so real ids start at 2 and account 1 is
|
|
/// never issued a client token. The in-memory provider numbers from 1, so the fixture has to put
|
|
/// that row there or the first account this test makes would be the one the login refuses.
|
|
/// </summary>
|
|
private static async Task SeedSystemAccountAsync(RecDBContext db)
|
|
{
|
|
if (await db.Profiles.AnyAsync(x => x.accountId == 1)) return;
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 1,
|
|
profileImage = "default",
|
|
username = "SystemAccount",
|
|
displayName = "SystemAccount",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>A boot grant carrying nothing the player has chosen yet.</summary>
|
|
private static TokenConnectData BlankBoot()
|
|
{
|
|
var boot = Token(Registration());
|
|
boot.username = null;
|
|
boot.password = null;
|
|
boot.birthday = null;
|
|
boot.email = null;
|
|
boot.avatar = null;
|
|
boot.avatarJson = null;
|
|
return boot;
|
|
}
|
|
|
|
/// <summary>
|
|
/// An account whose identity is linked but which is stranded: it has a row and a link, and its
|
|
/// settings say the flow never started.
|
|
/// </summary>
|
|
private static async Task SeedStrandedAccountAsync(RecDBContext db, long accountId, bool finished)
|
|
{
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = accountId,
|
|
username = "Stranded",
|
|
displayName = "Stranded",
|
|
profileImage = "default",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
db.Players.Add(new PlayerData { AccountId = accountId, DeviceId = DeviceId, Hashword = "" });
|
|
db.CachedLogins.Add(new CachedLogin
|
|
{
|
|
accountId = accountId,
|
|
platform = Platforms.Steam,
|
|
platformId = PlatformId,
|
|
lastLoginTime = DateTime.Now
|
|
});
|
|
// Sign-in itself needs a presence row, so the account has to look like a real one here or
|
|
// the test would fail on MissingPresence rather than on the marker it is about.
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = accountId,
|
|
deviceClass = DeviceClass.Screen,
|
|
statusVisibility = StatusVisibility.Public,
|
|
vRMovementMode = VRMovementModes.TELEPORT
|
|
});
|
|
|
|
var values = DBConfig.AccountCreationInProgressSettings2023(false, false).FromJson<List<Setting>>()!;
|
|
PlayerUtils.SetSettingFlag(values, "Recroom.AccountCreation.HasStarted", false);
|
|
PlayerUtils.SetSettingFlag(values, "Recroom.AccountCreation.HasFinished", finished);
|
|
db.PlayerSettings.Add(new PlayerSettings { AccountId = accountId, Settings = values.ToJson() });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static void AllowEveryRegistration() =>
|
|
RegistrationGuard.Configure(maxCreationsPerIp: int.MaxValue, maxCreationsPerDevice: int.MaxValue,
|
|
minimumDeviceInterval: TimeSpan.Zero);
|
|
|
|
private static TestAuthAPI CreateApi(RecDBContext db, string? authenticatedSteamId) =>
|
|
new(db, authenticatedSteamId) { ControllerContext = CreateControllerContext() };
|
|
|
|
private static ControllerContext CreateControllerContext()
|
|
{
|
|
var httpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity())
|
|
};
|
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
return new ControllerContext { HttpContext = httpContext };
|
|
}
|
|
|
|
/// <summary>A request signed in as this account, the way an authorized route sees one.</summary>
|
|
private static ControllerContext ContextFor(long accountId)
|
|
{
|
|
var httpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity(
|
|
[new Claim(ClaimTypes.NameIdentifier, accountId.ToString())]))
|
|
};
|
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
return new ControllerContext { HttpContext = httpContext };
|
|
}
|
|
|
|
/// <summary>Points the process-wide factories at this test's database for the test's lifetime.</summary>
|
|
private sealed class Harness : IDisposable
|
|
{
|
|
private readonly IDbContextFactory<RecDBContext> previousPlayerFactory = PlayerUtils.DbFactory;
|
|
private readonly IDbContextFactory<RecDBContext> previousRoomFactory = RoomUtils.DbFactory;
|
|
private readonly string previousJwtKey = DBConfig.JwtKey;
|
|
private readonly string previousSteamKey = DBConfig.SteamApiKey;
|
|
|
|
public Harness(IDbContextFactory<RecDBContext> factory)
|
|
{
|
|
DBConfig.JwtKey = "registration-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
|
|
{
|
|
private readonly DbContextOptions<RecDBContext> options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
public RecDBContext CreateDbContext() => new(options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A real database, in memory, over one open connection so it outlives any single context. The
|
|
/// interceptor can be told to fail a given save, which is how a mid-write database error is
|
|
/// simulated without a live SQL Server.
|
|
/// </summary>
|
|
private sealed class SqliteHarness : IDisposable
|
|
{
|
|
private readonly SqliteConnection connection = new("Data Source=:memory:");
|
|
private readonly Harness harness;
|
|
|
|
public SqliteDbContextFactory Factory { get; }
|
|
|
|
/// <param name="retrying">
|
|
/// Turns on the provider's retrying execution strategy, which is what the production server
|
|
/// configures (<c>EnableRetryOnFailure(3)</c> in Program). It changes what the provider allows
|
|
/// you to do with a transaction, so a test that never turns it on cannot see violations of it.
|
|
/// </param>
|
|
public SqliteHarness(int failOnSaveCall, bool retrying = false)
|
|
{
|
|
connection.Open();
|
|
var builder = TestDbOptions.Create().UseSqlite(connection);
|
|
// SQL Server's strategy is the one production configures; SQLite has no equivalent to
|
|
// turn on, so the test substitutes a strategy that reports retrying the same way. That
|
|
// flag is the single thing EF checks before refusing a user-initiated transaction.
|
|
if (retrying) builder.ReplaceService<IExecutionStrategyFactory, RetryingStrategyFactory>();
|
|
var options = builder
|
|
.AddInterceptors(new FailOnSaveNumber(failOnSaveCall))
|
|
.Options;
|
|
Factory = new SqliteDbContextFactory(options);
|
|
using (var seed = Factory.CreateDbContext())
|
|
seed.Database.EnsureCreated();
|
|
harness = new Harness(Factory);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
harness.Dispose();
|
|
connection.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class SqliteDbContextFactory(DbContextOptions<RecDBContext> options)
|
|
: IDbContextFactory<RecDBContext>
|
|
{
|
|
public RecDBContext CreateDbContext() => new(options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stands in for the server's <c>SqlServerRetryingExecutionStrategy</c>. It reports the same
|
|
/// <see cref="IExecutionStrategy.RetriesOnFailure"/>, which is the flag the framework's own
|
|
/// <c>OnFirstExecution</c> check consults before it refuses a user-initiated transaction - so the
|
|
/// exception this produces is EF's, not this shim's.
|
|
/// </summary>
|
|
private sealed class RetryingStrategyFactory(ExecutionStrategyDependencies dependencies) : IExecutionStrategyFactory
|
|
{
|
|
public IExecutionStrategy Create() => new RetryingStrategy(dependencies);
|
|
}
|
|
|
|
private sealed class RetryingStrategy(ExecutionStrategyDependencies dependencies)
|
|
: ExecutionStrategy(dependencies, maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(30))
|
|
{
|
|
public override bool RetriesOnFailure => true;
|
|
|
|
protected override bool ShouldRetryOn(Exception exception) => true;
|
|
}
|
|
|
|
private sealed class FailOnSaveNumber(int failingCall) : SaveChangesInterceptor
|
|
{
|
|
private int calls;
|
|
|
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
|
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
|
{
|
|
if (failingCall > 0 && Interlocked.Increment(ref calls) == failingCall)
|
|
throw new InvalidOperationException("simulated database failure");
|
|
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
|
}
|
|
}
|
|
|
|
private sealed class TestAuthAPI(RecDBContext db, string? authenticatedSteamId) : AuthAPI(db)
|
|
{
|
|
protected override Task<string?> AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken) =>
|
|
Task.FromResult(authenticatedSteamId);
|
|
}
|
|
}
|