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); } }