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 TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation() { FileStreamOptions options = LauncherProfileStore.CreateCredentialTempFileOptions(); Assert.Equal(FileMode.CreateNew, options.Mode); Assert.Equal(FileAccess.Write, options.Access); Assert.Equal(FileShare.None, options.Share); if (OperatingSystem.IsLinux()) { Assert.Equal( LauncherProfileStore.OwnerOnlyFileMode, options.UnixCreateMode); } else { Assert.Null(options.UnixCreateMode); } } [Fact] public void TempCredentialFileIsOwnerOnlyFromItsFirstObservableLinuxState() { // Deterministic proof of the exact production create path: inspect // the file while the CreateNew handle is still open, before any // serialization or post-create chmod can occur. This replaces the // old timing-only poller, which could miss the vulnerable window. if (!OperatingSystem.IsLinux()) return; string tempPath = _filePath + ".tmp"; using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath); Assert.Equal( LauncherProfileStore.OwnerOnlyFileMode, File.GetUnixFileMode(tempPath)); } [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")); } }