using System.Text.Json; using AcDream.Launcher.Core.Profiles; namespace AcDream.Launcher.Core.Tests.Profiles; public sealed class LauncherProfileHardeningTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "acdream-launcher-profile-hardening-tests", Guid.NewGuid().ToString("N")); private readonly string _filePath; public LauncherProfileHardeningTests() { 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 MultiFieldEditValidatesEverythingBeforeChangingAnything() { LauncherProfileStore store = CreatePopulatedStore(); Assert.Throws(() => store.EditServer( "Local ACE", newName: "Partially renamed", newHost: "changed.example.test", newPort: 0)); ServerProfile server = Assert.Single(store.Document.Servers); Assert.Equal("Local ACE", server.Name); Assert.Equal("127.0.0.1", server.Host); Assert.Equal(9000, server.Port); Assert.Throws(() => store.EditCharacter( "Local ACE", "testaccount", "+Acdream", newName: "+PartiallyRenamed", newId: "not-an-id")); CharacterProfile character = Assert.Single(server.Accounts.Single().Characters); Assert.Equal("+Acdream", character.Name); Assert.Equal("0x5000000A", character.Id); } [Fact] public void TransactionRestoresTheExactDocumentWhenPersistenceFails() { Directory.CreateDirectory(_filePath); var store = new LauncherProfileStore(_filePath); store.Load(); Assert.ThrowsAny(() => store.ExecuteTransaction(() => store.AddServer("Should roll back", "host", 9000))); Assert.Empty(store.Document.Servers); Assert.False(File.Exists(_filePath + ".tmp")); } [Fact] public void TransactionRestoresNestedCredentialAndSettingsOnMutationFailure() { LauncherProfileStore store = CreatePopulatedStore(); string before = JsonSerializer.Serialize(store.Document); Assert.Throws(() => store.ExecuteTransaction(() => { AccountProfile account = store.Document.Servers.Single().Accounts.Single(); account.Password = "transient-secret"; account.Characters.Single().Plugins.Add("Transient.Plugin"); throw new InvalidOperationException("simulated mutation failure"); })); Assert.Equal(before, JsonSerializer.Serialize(store.Document)); } public static TheoryData InvalidDocuments => new() { { """{"version":1,"servers":null}""" }, { """{"version":1,"servers":[null]}""" }, { """{"version":1,"servers":[{"name":" ","host":"h","port":9000,"accounts":[]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":" ","port":9000,"accounts":[]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":0,"accounts":[]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[]},{"name":"s","host":"h2","port":9001,"accounts":[]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[]},{"account":"a","password":"p2","characters":[]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":null,"characters":[]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":null}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x00000000","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"invalid","plugins":[],"loginCommands":[]}]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":null,"loginCommands":[]}]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":["P","P"],"loginCommands":[]}]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[" "]}]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c","id":"0x50000002","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" }, { """{"version":1,"servers":[{"name":"s","host":"h","port":9000,"accounts":[{"account":"a","password":"p","characters":[{"name":"c1","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]},{"name":"c2","id":"0x50000001","launchMode":"gui","plugins":[],"loginCommands":[]}]}]}]}""" }, }; [Theory] [MemberData(nameof(InvalidDocuments))] public void LoadRejectsSemanticallyInvalidDocuments(string json) { File.WriteAllText(_filePath, json); var store = new LauncherProfileStore(_filePath); Assert.Throws(() => store.Load()); } [Fact] public void FailedLoadDoesNotReplaceAnAlreadyLoadedDocument() { LauncherProfileStore store = CreatePopulatedStore(); File.WriteAllText(_filePath, """{"version":1,"servers":null}"""); Assert.Throws(() => store.Load()); Assert.Equal("Local ACE", Assert.Single(store.Document.Servers).Name); } [Fact] public void LinuxLoadNormalizesAnExistingCredentialFileTo0600BeforeReading() { if (!OperatingSystem.IsLinux()) { return; } File.WriteAllText(_filePath, """{"version":1,"servers":[]}"""); File.SetUnixFileMode( _filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); var store = new LauncherProfileStore(_filePath); Assert.True(store.Load()); Assert.Equal( UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(_filePath)); } private LauncherProfileStore CreatePopulatedStore() { var store = new LauncherProfileStore(_filePath); store.Load(); store.AddServer("Local ACE", "127.0.0.1", 9000); store.AddAccount("Local ACE", "testaccount", "password"); store.AddCharacter( "Local ACE", "testaccount", "+Acdream", "0x5000000A", LaunchMode.Headless, ["ExamplePlugin"], ["/vt start"]); store.Save(); return store; } }