using System.Threading; using AcDream.Launcher.Core.Profiles; namespace AcDream.Launcher.Core.Tests.Profiles; public sealed class LauncherProfileStoreTests : IDisposable { private readonly string _root; private readonly string _filePath; public LauncherProfileStoreTests() { _root = Path.Combine( Path.GetTempPath(), "acdream-launcher-profile-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); _filePath = Path.Combine(_root, "launcher-profiles.json"); } public void Dispose() { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } [Fact] public void LoadOnMissingFileYieldsEmptyDocumentWithoutTouchingDisk() { var store = new LauncherProfileStore(_filePath); bool loaded = store.Load(); Assert.False(loaded); Assert.False(File.Exists(_filePath)); Assert.Equal(1, store.Document.Version); Assert.Empty(store.Document.Servers); } [Fact] public void AddServerThenSaveThenReloadRoundTrips() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.Save(); Assert.True(File.Exists(_filePath)); var reloaded = new LauncherProfileStore(_filePath); reloaded.Load(); ServerProfile server = Assert.Single(reloaded.Document.Servers); Assert.Equal("Local ACE", server.Name); Assert.Equal("127.0.0.1", server.Host); Assert.Equal(9000, server.Port); Assert.Empty(server.Accounts); } [Fact] public void AddServerRejectsDuplicateName() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); var ex = Assert.Throws( () => store.AddServer("Local ACE", "127.0.0.1", 9001)); Assert.Contains("Local ACE", ex.Message); } [Theory] [InlineData(0)] [InlineData(65536)] [InlineData(-1)] public void AddServerRejectsOutOfRangePort(int port) { var store = new LauncherProfileStore(_filePath); store.Load(); Assert.Throws( () => store.AddServer("Local ACE", "127.0.0.1", port)); } [Fact] public void EditServerRenamesAndUpdatesHostAndPort() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.EditServer("Local ACE", newName: "Home ACE", newHost: "10.0.0.5", newPort: 9001); ServerProfile server = Assert.Single(store.Document.Servers); Assert.Equal("Home ACE", server.Name); Assert.Equal("10.0.0.5", server.Host); Assert.Equal(9001, server.Port); } [Fact] public void EditServerOnUnknownNameThrows() { var store = new LauncherProfileStore(_filePath); store.Load(); Assert.Throws( () => store.EditServer("Nope", newHost: "1.2.3.4")); } [Fact] public void RemoveServerRemovesIt() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.RemoveServer("Local ACE"); Assert.Empty(store.Document.Servers); } [Fact] public void AddEditRemoveAccountRoundTrip() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.AddAccount("Local ACE", "testaccount", "testpassword"); AccountProfile account = Assert.Single( store.Document.Servers.Single().Accounts); Assert.Equal("testaccount", account.Account); Assert.Equal("testpassword", account.Password); store.EditAccount( "Local ACE", "testaccount", newAccount: "renamed", newPassword: "newpass"); account = Assert.Single(store.Document.Servers.Single().Accounts); Assert.Equal("renamed", account.Account); Assert.Equal("newpass", account.Password); store.RemoveAccount("Local ACE", "renamed"); Assert.Empty(store.Document.Servers.Single().Accounts); } [Fact] public void AddAccountRejectsDuplicateAccountOnSameServer() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.AddAccount("Local ACE", "testaccount", "pw"); Assert.Throws( () => store.AddAccount("Local ACE", "testaccount", "pw2")); } [Fact] public void EditCharacterUpdatesLaunchModePluginsAndLoginCommandsOnly() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.AddAccount("Local ACE", "testaccount", "pw"); store.MergeRoster( "Local ACE", "testaccount", [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); store.EditCharacter( "Local ACE", "testaccount", "+Acdream", launchMode: LaunchMode.Headless, plugins: ["ExamplePlugin"], loginCommands: ["/tell someone, hi"]); CharacterProfile character = Assert.Single( store.Document.Servers.Single().Accounts.Single().Characters); Assert.Equal(LaunchMode.Headless, character.LaunchMode); Assert.Equal(["ExamplePlugin"], character.Plugins); Assert.Equal(["/tell someone, hi"], character.LoginCommands); Assert.Equal("0x5000000A", character.Id); } [Fact] public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.AddAccount("Local ACE", "testaccount", "testpassword"); store.MergeRoster( "Local ACE", "testaccount", [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); store.EditCharacter( "Local ACE", "testaccount", "+Acdream", launchMode: LaunchMode.Gui, plugins: ["ExamplePlugin"], loginCommands: ["/vt start"]); store.Save(); // Direct proof of the on-disk enum casing — a round-trip alone // could mask a PascalCase regression if the reader ever became // case-insensitive on enum values. string text = File.ReadAllText(_filePath); Assert.Contains("\"launchMode\":\"gui\"", text.Replace(" ", string.Empty)); var reloaded = new LauncherProfileStore(_filePath); reloaded.Load(); ServerProfile server = Assert.Single(reloaded.Document.Servers); AccountProfile account = Assert.Single(server.Accounts); CharacterProfile character = Assert.Single(account.Characters); Assert.Equal("+Acdream", character.Name); Assert.Equal("0x5000000A", character.Id); Assert.Equal(LaunchMode.Gui, character.LaunchMode); Assert.Equal(["ExamplePlugin"], character.Plugins); Assert.Equal(["/vt start"], character.LoginCommands); } [Fact] public void LoadRejectsUnsupportedVersion() { File.WriteAllText(_filePath, """{"version":2,"servers":[]}"""); var store = new LauncherProfileStore(_filePath); Assert.Throws(() => store.Load()); } [Fact] public void LoadRejectsUnmappedMembersStrictly() { File.WriteAllText( _filePath, """{"version":1,"servers":[],"unexpectedField":true}"""); var store = new LauncherProfileStore(_filePath); Assert.Throws(() => store.Load()); } [Fact] public void SaveWritesCamelCaseJson() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.Save(); string text = File.ReadAllText(_filePath); Assert.Contains("\"version\"", text); Assert.Contains("\"servers\"", text); Assert.Contains("\"host\"", text); Assert.DoesNotContain("\"Version\"", text); Assert.DoesNotContain("\"Servers\"", text); } [Fact] public void SaveSetsOwnerOnlyPermissionsOnLinux() { // Linux-conditional: 0600 is a Linux-only hygiene step (spec §5, // decisions log item "Windows profile-file permissions"). A no-op // pass on Windows/macOS, matching the repo's established // OperatingSystem.IsLinux() early-return pattern (e.g. // HeadlessCredentialResolverTests.LinuxRejectsGroupOrOtherCredentialPermissions). if (!OperatingSystem.IsLinux()) return; var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.AddAccount("Local ACE", "testaccount", "testpassword"); store.Save(); UnixFileMode mode = File.GetUnixFileMode(_filePath); Assert.Equal( UnixFileMode.UserRead | UnixFileMode.UserWrite, mode); } [Fact] public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite() { // Review finding F4: the temp file used to be created with the // process's default umask and only chmod'd AFTER the atomic // rename, leaving a window where the plaintext-credential temp // file could be world/group-readable. The fix chmods the temp // file immediately after creation, BEFORE any content (including // the password) is serialized into it. A large document makes // the write take long enough for a concurrent poller to have a // real chance at observing a regression. if (!OperatingSystem.IsLinux()) return; var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); for (int i = 0; i < 300; i++) { store.AddAccount("Local ACE", $"account{i}", new string('x', 4096)); } string tempPath = _filePath + ".tmp"; bool observedLooseMode = false; bool stop = false; var poller = new Thread(() => { while (!Volatile.Read(ref stop)) { if (File.Exists(tempPath)) { try { // The platform-compat analyzer can't see the // enclosing test method's `OperatingSystem.IsLinux()` // guard across this lambda boundary; suppressed // rather than restructured, since the guard is // real and this whole method is a no-op off Linux. #pragma warning disable CA1416 UnixFileMode mode = File.GetUnixFileMode(tempPath); #pragma warning restore CA1416 if ((mode & ~(UnixFileMode.UserRead | UnixFileMode.UserWrite)) != 0) { observedLooseMode = true; } } catch (IOException) { // Renamed/deleted between the Exists check and // GetUnixFileMode — not a finding, just keep // polling. } } } }); poller.Start(); store.Save(); Volatile.Write(ref stop, true); poller.Join(); Assert.False(observedLooseMode); } [Fact] public void SaveDeletesTheStaleTempFileWhenTheFinalRenameFails() { // Review finding F4: force the rename step to fail (the // destination path names an existing DIRECTORY, which // File.Move(..., overwrite: true) refuses to replace — Windows // reports this as UnauthorizedAccessException, Linux as // IOException, so the assertion below accepts either) and assert // the temp file — which still carries the just-serialized // plaintext credentials — doesn't linger on disk afterward. Directory.CreateDirectory(_filePath); var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.AddAccount("Local ACE", "testaccount", "testpassword"); Assert.ThrowsAny(() => store.Save()); Assert.False(File.Exists(_filePath + ".tmp")); } [Fact] public void LoadDeletesAStaleTempFileLeftBehindByACrashedSave() { // Review finding F4: a Save() that crashed between creating the // temp file and the atomic rename leaves a ".tmp" carrying the // same plaintext credentials as the real store. Load() cleans it // up opportunistically the next time the store is opened. File.WriteAllText(_filePath + ".tmp", """{"version":1,"servers":[]}"""); var store = new LauncherProfileStore(_filePath); store.Load(); Assert.False(File.Exists(_filePath + ".tmp")); } }