using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer; using RadicadeServer.API.Config; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts.Local; using RadicadeServer.RecNet.Auth; using Xunit; namespace RadicadeServer.Tests; public class AccountOnboardingSettingsTests { // Every key the 2023 title screen reads to decide which page of its own new-user flow to open. // They all have to be written, not just the ones this server checks itself: a key the client looks // for and cannot find makes it resume at a page it believes it already showed, and a true one skips // that page outright. This is the whole reason a brand-new account opens on "Welcome to Rec Room!". private static readonly string[] AccountCreationKeys = [ "Recroom.AccountCreation.HasStarted", "Recroom.AccountCreation.HasChosenUsername", "Recroom.AccountCreation.HasCreatedPassword", "Recroom.AccountCreation.HasFinished", "Recroom.AccountCreation.FinishedAvatarCustomization", "Recroom.AccountCreation.AcceptedCodeOfConduct", "Recroom.AccountCreation.HasImportedFriends" ]; [Fact] public void NewAccountSettings_LeaveOnboardingIncompleteAndRequireOrientation() { var settings = JsonSerializer.Deserialize>(DBConfig.NewAccountSettings2023)!; string Value(string key) => settings.Single(setting => setting.Key == key).Value; foreach (var key in AccountCreationKeys) Assert.Equal("False", Value(key)); Assert.Equal("0", Value("Recroom.OOBE")); Assert.Equal("1", Value("Radicade.NeedsOrientation")); Assert.DoesNotContain(settings, setting => setting.Key == "HAS_COMPLETED_ORIENTATION"); } [Fact] public void AccountCreationInProgressSettings_ResumeAtThePageAfterWhatThePlayerSubmitted() { var settings = JsonSerializer.Deserialize>( DBConfig.AccountCreationInProgressSettings2023(usernameChosen: true, passwordCreated: true))!; string Value(string key) => settings.Single(setting => setting.Key == key).Value; // An account the flow itself created: it started, the name and password are in, and the pages // the player has not reached yet (avatar, Code of Conduct, finished) are still open. Assert.Equal("True", Value("Recroom.AccountCreation.HasStarted")); Assert.Equal("True", Value("Recroom.AccountCreation.HasChosenUsername")); Assert.Equal("True", Value("Recroom.AccountCreation.HasCreatedPassword")); Assert.Equal("False", Value("Recroom.AccountCreation.HasFinished")); Assert.Equal("False", Value("Recroom.AccountCreation.FinishedAvatarCustomization")); Assert.Equal("False", Value("Recroom.AccountCreation.AcceptedCodeOfConduct")); Assert.Equal("1", Value("Radicade.NeedsOrientation")); // A create that carried neither: the client still has to ask for both. var bare = JsonSerializer.Deserialize>( DBConfig.AccountCreationInProgressSettings2023(usernameChosen: false, passwordCreated: false))!; Assert.Equal("False", bare.Single(setting => setting.Key == "Recroom.AccountCreation.HasChosenUsername").Value); Assert.Equal("False", bare.Single(setting => setting.Key == "Recroom.AccountCreation.HasCreatedPassword").Value); } [Fact] public void Regular2023DefaultSettings_DoNotForceNewAccountOnboarding() { var settings = JsonSerializer.Deserialize>(DBConfig.DefaultSettings2023)!; Assert.DoesNotContain(settings, setting => setting.Key.StartsWith("Recroom.AccountCreation.", StringComparison.Ordinal)); Assert.DoesNotContain(settings, setting => setting.Key == "Radicade.NeedsOrientation"); Assert.Contains(settings, setting => setting.Key == "HAS_COMPLETED_ORIENTATION" && setting.Value == "True"); } [Fact] public async Task CannotMarkOnboardingFinishedUntilPasswordBirthdayAndCocAreSaved() { await using var db = CreateDb(); db.Profiles.Add(new Profile { accountId = 2, username = "NewUser", displayName = "NewUser", profileImage = "default" }); db.Players.Add(new PlayerData { AccountId = 2, Hashword = "hashed", Birthday = new DateTime(2000, 1, 1) }); db.PlayerSettings.Add(new PlayerSettings { AccountId = 2, Settings = "[{\"Key\":\"Recroom.AccountCreation.HasChosenUsername\",\"Value\":\"True\"}]" }); await db.SaveChangesAsync(); var api = CreateApi(db); await api.SetSetting(new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = "True" }); var saved = (await db.PlayerSettings.SingleAsync()).DeserializeSettings(); Assert.DoesNotContain(saved, setting => setting.Key == "Recroom.AccountCreation.HasFinished"); Assert.DoesNotContain(saved, setting => setting.Key == "Radicade.NeedsOrientation"); } [Fact] public async Task CompletingOnboardingSetsOrientationMarkerOnlyAfterRequiredFieldsArePresent() { await using var db = CreateDb(); db.Profiles.Add(new Profile { accountId = 2, username = "NewUser", displayName = "NewUser", profileImage = "default" }); db.Players.Add(new PlayerData { AccountId = 2, Hashword = "hashed", Birthday = new DateTime(2000, 1, 1), CodeOfConductAccepted = true }); db.PlayerSettings.Add(new PlayerSettings { AccountId = 2, Settings = "[{\"Key\":\"Recroom.AccountCreation.HasChosenUsername\",\"Value\":\"True\"}]" }); await db.SaveChangesAsync(); var api = CreateApi(db); await api.SetSetting(new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = "True" }); var saved = (await db.PlayerSettings.SingleAsync()).DeserializeSettings(); Assert.Contains(saved, setting => setting.Key == "Recroom.AccountCreation.HasFinished" && setting.Value == "True"); Assert.Contains(saved, setting => setting.Key == "Radicade.NeedsOrientation" && setting.Value == "1"); } /// /// The flow records the acceptance as a setting on this build's own Code of Conduct page; the /// account's account/me/acceptcoc route is never called. Before this, an account whose /// player had walked that page still answered hasAcceptedCodeOfConduct = false - so the /// client offered the page again on the next boot, and the finish marker was refused over a flag /// the client's own pages never write. /// [Fact] public async Task TheConductPageRecordsTheAcceptanceOnTheAccountAndUnblocksTheFinishMarker() { await using var db = CreateDb(); db.Profiles.Add(new Profile { accountId = 2, username = "NewUser", displayName = "NewUser", profileImage = "default" }); db.Players.Add(new PlayerData { AccountId = 2, Hashword = "hashed", Birthday = new DateTime(2000, 1, 1) }); db.PlayerSettings.Add(new PlayerSettings { AccountId = 2, Settings = "[{\"Key\":\"Recroom.AccountCreation.HasChosenUsername\",\"Value\":\"True\"}]" }); await db.SaveChangesAsync(); var api = CreateApi(db); await api.SetSetting(new Setting { Key = "Recroom.AccountCreation.AcceptedCodeOfConduct", Value = "True" }); Assert.True((await db.Players.AsNoTracking().SingleAsync()).CodeOfConductAccepted); // And that is now enough for the flow to be able to finish. await api.SetSetting(new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = "True" }); var saved = (await db.PlayerSettings.SingleAsync()).DeserializeSettings(); Assert.Contains(saved, setting => setting.Key == "Recroom.AccountCreation.HasFinished" && setting.Value == "True"); Assert.Contains(saved, setting => setting.Key == "Radicade.NeedsOrientation" && setting.Value == "1"); } /// /// A birthday that never arrived must not hold the finish marker hostage. Every account the flow /// has created on this server still reads 0001-01-01 - its age page has never handed this server /// one - so requiring it meant no player could ever leave the flow, and the title screen kept /// opening on "Welcome back! - Continue creating account" for an account whose every other page /// was done. /// [Fact] public async Task TheFinishMarkerIsRecordedForAnAccountWhoseAgePageNeverArrived() { await using var db = CreateDb(); db.Profiles.Add(new Profile { accountId = 2, username = "NewUser", displayName = "NewUser", profileImage = "default" }); db.Players.Add(new PlayerData { AccountId = 2, Hashword = "hashed", Birthday = null }); db.PlayerSettings.Add(new PlayerSettings { AccountId = 2, Settings = "[{\"Key\":\"Recroom.AccountCreation.HasChosenUsername\",\"Value\":\"True\"}]" }); await db.SaveChangesAsync(); var api = CreateApi(db); await api.SetSetting(new Setting { Key = "Recroom.AccountCreation.AcceptedCodeOfConduct", Value = "True" }); await api.SetSetting(new Setting { Key = "Recroom.AccountCreation.HasFinished", Value = "True" }); var saved = (await db.PlayerSettings.SingleAsync()).DeserializeSettings(); Assert.Contains(saved, setting => setting.Key == "Recroom.AccountCreation.HasFinished" && setting.Value == "True"); // Still owed, so the account keeps asking for it until the age page hands one over. Assert.Null((await db.Players.AsNoTracking().SingleAsync()).Birthday); } [Theory] [InlineData("")] [InlineData("hashed-password")] public async Task SettingsDoNotSkipAnUnfinishedPasswordPage(string passwordHash) { await using var db = CreateDb(); db.Players.Add(new PlayerData { AccountId = 2, Hashword = passwordHash }); db.PlayerSettings.Add(new PlayerSettings { AccountId = 2, Settings = "[{\"Key\":\"Recroom.AccountCreation.HasCreatedPassword\",\"Value\":\"True\"}," + "{\"Key\":\"Recroom.AccountCreation.HasFinished\",\"Value\":\"True\"}," + "{\"Key\":\"Recroom.OOBE\",\"Value\":\"100\"}]" }); await db.SaveChangesAsync(); var settings = CreateApi(db).GetSettings(); var expected = passwordHash.Length == 0 ? "False" : "True"; Assert.Equal(expected, settings.Single(x => x.Key == "Recroom.AccountCreation.HasCreatedPassword").Value); Assert.Equal(expected, settings.Single(x => x.Key == "Recroom.AccountCreation.HasFinished").Value); Assert.Equal(passwordHash.Length == 0 ? "0" : "100", settings.Single(x => x.Key == "Recroom.OOBE").Value); } private static RecDBContext CreateDb() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new RecDBContext(options); } private static PlayerSettingsAPI CreateApi(RecDBContext db) { var context = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, "2")], "test")) }; return new PlayerSettingsAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; } }