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;
///
/// 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.
///
[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";
/// The seeded server's own row (see ) is never a player account.
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(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();
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(
Assert.IsType((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(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((await PlayerUtils.CreateAccount(first)).value);
var second = Registration();
second.username = "SecondComer";
second.email = "someone@example.com";
var secondAccount = Assert.IsType((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(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(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(
Assert.IsType((await CreateApi(db, PlatformId).ConnectWithToken(boot)).Result).Value);
Assert.True(string.IsNullOrEmpty(first.error), first.error_description);
var second = Assert.IsType(
Assert.IsType((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(Assert.IsType(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((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((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());
}
///
/// 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.
///
[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(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(
Assert.IsType((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(secondResult.Result);
Assert.Equal(400, refusal.StatusCode);
Assert.Contains("just created", Assert.IsType(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(
Assert.IsType((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(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);
}
///
/// 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.
///
[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((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((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(
Assert.IsType((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(
Assert.IsType((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(
Assert.IsType((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
};
///
/// 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.
///
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();
}
/// A boot grant carrying nothing the player has chosen yet.
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;
}
///
/// 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.
///
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>()!;
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 };
}
/// A request signed in as this account, the way an authorized route sees one.
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 };
}
/// Points the process-wide factories at this test's database for the test's lifetime.
private sealed class Harness : IDisposable
{
private readonly IDbContextFactory previousPlayerFactory = PlayerUtils.DbFactory;
private readonly IDbContextFactory previousRoomFactory = RoomUtils.DbFactory;
private readonly string previousJwtKey = DBConfig.JwtKey;
private readonly string previousSteamKey = DBConfig.SteamApiKey;
public Harness(IDbContextFactory 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
{
private readonly DbContextOptions options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
public RecDBContext CreateDbContext() => new(options);
}
///
/// 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.
///
private sealed class SqliteHarness : IDisposable
{
private readonly SqliteConnection connection = new("Data Source=:memory:");
private readonly Harness harness;
public SqliteDbContextFactory Factory { get; }
///
/// Turns on the provider's retrying execution strategy, which is what the production server
/// configures (EnableRetryOnFailure(3) 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.
///
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();
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 options)
: IDbContextFactory
{
public RecDBContext CreateDbContext() => new(options);
}
///
/// Stands in for the server's SqlServerRetryingExecutionStrategy. It reports the same
/// , which is the flag the framework's own
/// OnFirstExecution check consults before it refuses a user-initiated transaction - so the
/// exception this produces is EF's, not this shim's.
///
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> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult 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 AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken) =>
Task.FromResult(authenticatedSteamId);
}
}