From 0e95171e50258f0ace7d391f223a1ead6e577cbb Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:10:48 +0200 Subject: [PATCH] test: cover runtime key, release formats and telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds ~60 new tests across RuntimeKey parsing, EnvVarKeyDelivery, the legacy and m2pack formats, ReleaseFormatFactory dispatch, manifest loader tolerance of unknown top-level fields, orchestrator wiring and the ClientAppliedReporter (disabled-by-default, success, 5xx, timeout, connection refused). The telemetry tests spin up an in-process HttpListener helper — no new NuGet dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ClientAppliedReporterTests.cs | 124 +++++++++++++++++ .../EnvVarDeliveryTests.cs | 68 +++++++++ .../Metin2Launcher.Tests/GameProcessTests.cs | 43 ++++++ .../LegacyJsonBlobFormatTests.cs | 64 +++++++++ .../Metin2Launcher.Tests/M2PackFormatTests.cs | 131 ++++++++++++++++++ .../ReleaseFormatFactoryTests.cs | 86 ++++++++++++ tests/Metin2Launcher.Tests/RuntimeKeyTests.cs | 83 +++++++++++ .../Metin2Launcher.Tests/TestHttpListener.cs | 107 ++++++++++++++ .../UpdateOrchestratorFormatDispatchTests.cs | 112 +++++++++++++++ 9 files changed, 818 insertions(+) create mode 100644 tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs create mode 100644 tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs create mode 100644 tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs create mode 100644 tests/Metin2Launcher.Tests/M2PackFormatTests.cs create mode 100644 tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs create mode 100644 tests/Metin2Launcher.Tests/RuntimeKeyTests.cs create mode 100644 tests/Metin2Launcher.Tests/TestHttpListener.cs create mode 100644 tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs diff --git a/tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs b/tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs new file mode 100644 index 0000000..27961b9 --- /dev/null +++ b/tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Http; +using Metin2Launcher.Telemetry; +using Xunit; + +namespace Metin2Launcher.Tests; + +public class ClientAppliedReporterTests +{ + [Fact] + public void Disabled_when_template_is_empty() + { + using var http = new HttpClient(); + var r = new ClientAppliedReporter(http, ""); + Assert.False(r.IsEnabled); + } + + [Fact] + public void Disabled_when_template_is_null() + { + using var http = new HttpClient(); + var r = new ClientAppliedReporter(http, null); + Assert.False(r.IsEnabled); + } + + [Fact] + public void Disabled_when_template_is_whitespace() + { + using var http = new HttpClient(); + var r = new ClientAppliedReporter(http, " "); + Assert.False(r.IsEnabled); + } + + [Fact] + public async Task ReportAsync_noop_when_disabled() + { + using var http = new HttpClient(); + var r = new ClientAppliedReporter(http, ""); + var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None); + Assert.False(ok); + } + + [Fact] + public void Expand_substitutes_placeholders() + { + var s = ClientAppliedReporter.Expand("https://x/{format}/{version}", "m2pack", "2026.04.14-1"); + Assert.Equal("https://x/m2pack/2026.04.14-1", s); + } + + [Fact] + public void Expand_uri_escapes_values() + { + var s = ClientAppliedReporter.Expand("https://x/{version}", "m2pack", "v 1+beta"); + Assert.Contains("v%201%2Bbeta", s); + } + + [Fact] + public void Expand_leaves_unknown_placeholders() + { + var s = ClientAppliedReporter.Expand("https://x/{other}", "m2pack", "v1"); + Assert.Equal("https://x/{other}", s); + } + + [Fact] + public async Task ReportAsync_posts_json_on_success() + { + using var listener = new TestHttpListener(); + var url = listener.Start(); + listener.RespondWith(HttpStatusCode.OK); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var r = new ClientAppliedReporter(http, url + "?format={format}&version={version}"); + var ok = await r.ReportAsync("m2pack", "2026.04.14-1", CancellationToken.None); + + Assert.True(ok); + var req = listener.LastRequest!; + Assert.Equal("POST", req.Method); + Assert.Contains("format=m2pack", req.RawUrl); + Assert.Contains("\"Format\":\"m2pack\"", req.Body); + Assert.Contains("\"Version\":\"2026.04.14-1\"", req.Body); + } + + [Fact] + public async Task ReportAsync_returns_false_on_500() + { + using var listener = new TestHttpListener(); + var url = listener.Start(); + listener.RespondWith(HttpStatusCode.InternalServerError); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var r = new ClientAppliedReporter(http, url); + var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None); + Assert.False(ok); + } + + [Fact] + public async Task ReportAsync_swallows_connection_refused() + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + // pick a port unlikely to be listening + var r = new ClientAppliedReporter(http, "http://127.0.0.1:1/"); + var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None); + Assert.False(ok); + } + + [Fact] + public async Task ReportAsync_times_out_after_five_seconds() + { + using var listener = new TestHttpListener(); + var url = listener.Start(); + listener.RespondAfter(TimeSpan.FromSeconds(10), HttpStatusCode.OK); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + var r = new ClientAppliedReporter(http, url); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None); + sw.Stop(); + + Assert.False(ok); + // reporter caps at 5s internally — allow slack for CI schedulers + Assert.InRange(sw.Elapsed.TotalSeconds, 3.0, 9.0); + } +} diff --git a/tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs b/tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs new file mode 100644 index 0000000..0900e45 --- /dev/null +++ b/tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using Metin2Launcher.Runtime; +using Xunit; + +namespace Metin2Launcher.Tests; + +public class EnvVarDeliveryTests +{ + private static RuntimeKey SampleKey() => new() + { + KeyId = "2026.04.14-1", + MasterKeyHex = new string('a', 64), + SignPubkeyHex = new string('b', 64), + }; + + [Fact] + public void Apply_sets_expected_env_vars() + { + var psi = new ProcessStartInfo { FileName = "nothing" }; + new EnvVarKeyDelivery().Apply(psi, SampleKey()); + + Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]); + Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]); + Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]); + } + + [Fact] + public void Apply_does_not_leak_into_current_process() + { + var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar); + var psi = new ProcessStartInfo { FileName = "nothing" }; + new EnvVarKeyDelivery().Apply(psi, SampleKey()); + var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar); + Assert.Equal(before, after); // still unset (or whatever it was) + } + + [Fact] + public void Apply_overwrites_existing_values_on_psi() + { + var psi = new ProcessStartInfo { FileName = "nothing" }; + psi.Environment[EnvVarKeyDelivery.KeyIdVar] = "stale"; + new EnvVarKeyDelivery().Apply(psi, SampleKey()); + Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]); + } + + [Fact] + public void Apply_rejects_null_args() + { + var d = new EnvVarKeyDelivery(); + Assert.Throws(() => d.Apply(null!, SampleKey())); + Assert.Throws(() => d.Apply(new ProcessStartInfo(), null!)); + } + + [Fact] + public void SharedMemoryDelivery_is_stubbed() + { + var d = new SharedMemoryKeyDelivery(); + Assert.Equal("shared-memory", d.Name); + Assert.Throws(() => + d.Apply(new ProcessStartInfo(), SampleKey())); + } + + [Fact] + public void Delivery_name_is_env_var() + { + Assert.Equal("env-var", new EnvVarKeyDelivery().Name); + } +} diff --git a/tests/Metin2Launcher.Tests/GameProcessTests.cs b/tests/Metin2Launcher.Tests/GameProcessTests.cs index 9675e96..29bfe5d 100644 --- a/tests/Metin2Launcher.Tests/GameProcessTests.cs +++ b/tests/Metin2Launcher.Tests/GameProcessTests.cs @@ -1,4 +1,5 @@ using Metin2Launcher.GameLaunch; +using Metin2Launcher.Runtime; using Xunit; namespace Metin2Launcher.Tests; @@ -29,4 +30,46 @@ public class GameProcessTests Assert.Empty(psi.ArgumentList); } } + + [Fact] + public void BuildStartInfo_without_runtime_key_does_not_set_m2pack_env() + { + var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe"); + var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), runtimeKey: null); + Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.MasterKeyVar)); + Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.SignPubkeyVar)); + Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.KeyIdVar)); + } + + [Fact] + public void BuildStartInfo_with_runtime_key_forwards_env_vars() + { + var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe"); + var rk = new RuntimeKey + { + KeyId = "2026.04.14-1", + MasterKeyHex = new string('a', 64), + SignPubkeyHex = new string('b', 64), + }; + var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk); + Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]); + Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]); + Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]); + } + + [Fact] + public void BuildStartInfo_runtime_key_does_not_pollute_current_process() + { + var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe"); + var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar); + var rk = new RuntimeKey + { + KeyId = "k", + MasterKeyHex = new string('c', 64), + SignPubkeyHex = new string('d', 64), + }; + GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk); + var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar); + Assert.Equal(before, after); + } } diff --git a/tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs b/tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs new file mode 100644 index 0000000..e4b5ac7 --- /dev/null +++ b/tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs @@ -0,0 +1,64 @@ +using Metin2Launcher.Formats; +using Metin2Launcher.Manifest; +using Xunit; +using ManifestDto = Metin2Launcher.Manifest.Manifest; + +namespace Metin2Launcher.Tests; + +public class LegacyJsonBlobFormatTests +{ + private static ManifestDto SampleManifest() => new() + { + Version = "v1", + Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 }, + Files = + { + new ManifestFile { Path = "a.pck", Sha256 = "h1", Size = 10 }, + new ManifestFile { Path = "Metin2.exe", Sha256 = "h2", Size = 20, Platform = "windows" }, + new ManifestFile { Path = "linux-bin", Sha256 = "h3", Size = 30, Platform = "linux" }, + }, + }; + + [Fact] + public void Name_is_legacy_json_blob() + { + Assert.Equal("legacy-json-blob", new LegacyJsonBlobFormat().Name); + } + + [Fact] + public void FilterApplicable_windows_platform() + { + var format = new LegacyJsonBlobFormat(); + var filtered = format.FilterApplicable(SampleManifest(), "windows"); + Assert.Equal(2, filtered.Count); + Assert.Contains(filtered, f => f.Path == "a.pck"); + Assert.Contains(filtered, f => f.Path == "Metin2.exe"); + } + + [Fact] + public void FilterApplicable_linux_platform() + { + var format = new LegacyJsonBlobFormat(); + var filtered = format.FilterApplicable(SampleManifest(), "linux"); + Assert.Equal(2, filtered.Count); + Assert.Contains(filtered, f => f.Path == "a.pck"); + Assert.Contains(filtered, f => f.Path == "linux-bin"); + } + + [Fact] + public void OnApplied_is_noop() + { + var outcome = new ReleaseOutcome(); + var tmp = Path.Combine(Path.GetTempPath(), "lg-" + Guid.NewGuid()); + Directory.CreateDirectory(tmp); + try + { + new LegacyJsonBlobFormat().OnApplied( + tmp, + new LoadedManifest(Array.Empty(), Array.Empty(), SampleManifest()), + outcome); + Assert.Null(outcome.RuntimeKey); + } + finally { Directory.Delete(tmp, true); } + } +} diff --git a/tests/Metin2Launcher.Tests/M2PackFormatTests.cs b/tests/Metin2Launcher.Tests/M2PackFormatTests.cs new file mode 100644 index 0000000..1f37438 --- /dev/null +++ b/tests/Metin2Launcher.Tests/M2PackFormatTests.cs @@ -0,0 +1,131 @@ +using Metin2Launcher.Formats; +using Metin2Launcher.Manifest; +using Metin2Launcher.Runtime; +using Xunit; +using ManifestDto = Metin2Launcher.Manifest.Manifest; + +namespace Metin2Launcher.Tests; + +public class M2PackFormatTests +{ + private const string ValidHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + private static ManifestDto SampleManifest() => new() + { + Version = "2026.04.14-1", + Format = "m2pack", + Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 }, + Files = + { + new ManifestFile { Path = "pack/item.m2p", Sha256 = "h1", Size = 1000 }, + new ManifestFile { Path = "runtime-key.json", Sha256 = "h2", Size = 200 }, + }, + }; + + private static string WriteRuntimeKey(string dir, string keyId = "2026.04.14-1") + { + var path = Path.Combine(dir, M2PackFormat.RuntimeKeyFileName); + File.WriteAllText(path, $$""" +{ + "key_id": "{{keyId}}", + "master_key_hex": "{{ValidHex}}", + "sign_pubkey_hex": "{{ValidHex}}" +} +"""); + return path; + } + + [Fact] + public void Name_is_m2pack() + { + Assert.Equal("m2pack", new M2PackFormat().Name); + } + + [Fact] + public void FilterApplicable_includes_m2p_and_runtime_key() + { + var filtered = new M2PackFormat().FilterApplicable(SampleManifest(), "windows"); + Assert.Equal(2, filtered.Count); + Assert.Contains(filtered, f => f.Path.EndsWith(".m2p")); + Assert.Contains(filtered, f => f.Path == "runtime-key.json"); + } + + [Fact] + public void OnApplied_loads_runtime_key_when_present() + { + var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid()); + Directory.CreateDirectory(tmp); + try + { + WriteRuntimeKey(tmp); + var outcome = new ReleaseOutcome(); + new M2PackFormat().OnApplied( + tmp, + new LoadedManifest(Array.Empty(), Array.Empty(), SampleManifest()), + outcome); + + Assert.NotNull(outcome.RuntimeKey); + Assert.Equal("2026.04.14-1", outcome.RuntimeKey!.KeyId); + Assert.Equal(ValidHex, outcome.RuntimeKey.MasterKeyHex); + } + finally { Directory.Delete(tmp, true); } + } + + [Fact] + public void OnApplied_tolerates_missing_runtime_key() + { + var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid()); + Directory.CreateDirectory(tmp); + try + { + var outcome = new ReleaseOutcome(); + new M2PackFormat().OnApplied( + tmp, + new LoadedManifest(Array.Empty(), Array.Empty(), SampleManifest()), + outcome); + Assert.Null(outcome.RuntimeKey); // warn-logged, not thrown + } + finally { Directory.Delete(tmp, true); } + } + + [Fact] + public void OnApplied_throws_on_malformed_runtime_key() + { + var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid()); + Directory.CreateDirectory(tmp); + try + { + File.WriteAllText(Path.Combine(tmp, M2PackFormat.RuntimeKeyFileName), "{ not valid }"); + var outcome = new ReleaseOutcome(); + Assert.ThrowsAny(() => + new M2PackFormat().OnApplied( + tmp, + new LoadedManifest(Array.Empty(), Array.Empty(), SampleManifest()), + outcome)); + } + finally { Directory.Delete(tmp, true); } + } + + [Fact] + public void Launcher_never_opens_m2p_archive() + { + // Guard test: the format must not touch the .m2p file itself. We assert + // by constructing a manifest whose .m2p entry points at a file that + // does not exist on disk and would throw if opened — OnApplied must + // ignore it entirely and only touch runtime-key.json. + var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid()); + Directory.CreateDirectory(tmp); + try + { + WriteRuntimeKey(tmp); + // Intentionally no pack/item.m2p on disk. + var outcome = new ReleaseOutcome(); + new M2PackFormat().OnApplied( + tmp, + new LoadedManifest(Array.Empty(), Array.Empty(), SampleManifest()), + outcome); + Assert.NotNull(outcome.RuntimeKey); + } + finally { Directory.Delete(tmp, true); } + } +} diff --git a/tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs b/tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs new file mode 100644 index 0000000..c653628 --- /dev/null +++ b/tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs @@ -0,0 +1,86 @@ +using System.Text; +using Metin2Launcher.Formats; +using Metin2Launcher.Manifest; +using Xunit; +using ManifestDto = Metin2Launcher.Manifest.Manifest; + +namespace Metin2Launcher.Tests; + +public class ReleaseFormatFactoryTests +{ + [Fact] + public void Resolves_legacy_by_default() + { + var f = ReleaseFormatFactory.Resolve("legacy-json-blob"); + Assert.IsType(f); + } + + [Fact] + public void Resolves_m2pack() + { + var f = ReleaseFormatFactory.Resolve("m2pack"); + Assert.IsType(f); + } + + [Fact] + public void Throws_on_unknown_format() + { + Assert.Throws(() => + ReleaseFormatFactory.Resolve("evil-format")); + } + + [Fact] + public void Manifest_without_format_defaults_to_legacy() + { + var m = new ManifestDto { Version = "v1" }; + Assert.Equal("legacy-json-blob", m.EffectiveFormat); + } + + [Fact] + public void Manifest_with_blank_format_defaults_to_legacy() + { + var m = new ManifestDto { Version = "v1", Format = " " }; + Assert.Equal("legacy-json-blob", m.EffectiveFormat); + } + + [Fact] + public void Manifest_with_explicit_format_is_honoured() + { + var m = new ManifestDto { Version = "v1", Format = "m2pack" }; + Assert.Equal("m2pack", m.EffectiveFormat); + } + + [Fact] + public void Loader_tolerates_unknown_top_level_field_next_to_format() + { + const string json = """ +{ + "format": "m2pack", + "version": "2026.04.14-1", + "created_at": "2026-04-14T14:00:00Z", + "future_field": {"nested": 1}, + "launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1}, + "files": [] +} +"""; + var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json)); + Assert.Equal("m2pack", m.EffectiveFormat); + Assert.Equal("2026.04.14-1", m.Version); + } + + [Fact] + public void Loader_parses_manifest_without_format_as_legacy() + { + const string json = """ +{ + "version": "2026.04.14-1", + "created_at": "2026-04-14T14:00:00Z", + "launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1}, + "files": [] +} +"""; + var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json)); + Assert.Null(m.Format); + Assert.Equal("legacy-json-blob", m.EffectiveFormat); + } +} diff --git a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs new file mode 100644 index 0000000..bb7ef70 --- /dev/null +++ b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs @@ -0,0 +1,83 @@ +using System.Text; +using Metin2Launcher.Runtime; +using Xunit; + +namespace Metin2Launcher.Tests; + +public class RuntimeKeyTests +{ + private const string ValidHex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + private static string SampleJson( + string keyId = "2026.04.14-1", + string master = ValidHex64, + string pub = ValidHex64) + => $$""" +{ + "key_id": "{{keyId}}", + "master_key_hex": "{{master}}", + "sign_pubkey_hex": "{{pub}}" +} +"""; + + [Fact] + public void Parse_happy_path() + { + var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson())); + Assert.Equal("2026.04.14-1", rk.KeyId); + Assert.Equal(ValidHex64, rk.MasterKeyHex); + Assert.Equal(ValidHex64, rk.SignPubkeyHex); + } + + [Fact] + public void Parse_rejects_missing_key_id() + { + var json = SampleJson(keyId: ""); + Assert.Throws(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json))); + } + + [Theory] + [InlineData("")] + [InlineData("tooshort")] + [InlineData("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")] // right len, wrong chars + public void Parse_rejects_bad_master_key(string master) + { + var json = SampleJson(master: master); + Assert.Throws(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json))); + } + + [Fact] + public void Parse_rejects_bad_pubkey() + { + var json = SampleJson(pub: new string('g', 64)); + Assert.Throws(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json))); + } + + [Fact] + public void Parse_accepts_uppercase_hex() + { + var upper = ValidHex64.ToUpperInvariant(); + var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson(master: upper, pub: upper))); + Assert.Equal(upper, rk.MasterKeyHex); + } + + [Fact] + public void Load_reads_from_disk() + { + var tmp = Path.Combine(Path.GetTempPath(), "runtime-key-test-" + Guid.NewGuid() + ".json"); + File.WriteAllText(tmp, SampleJson()); + try + { + var rk = RuntimeKey.Load(tmp); + Assert.Equal("2026.04.14-1", rk.KeyId); + } + finally { File.Delete(tmp); } + } + + [Fact] + public void Parse_rejects_malformed_json() + { + Assert.Throws(() => + RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json"))); + } +} diff --git a/tests/Metin2Launcher.Tests/TestHttpListener.cs b/tests/Metin2Launcher.Tests/TestHttpListener.cs new file mode 100644 index 0000000..0a67b47 --- /dev/null +++ b/tests/Metin2Launcher.Tests/TestHttpListener.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Text; + +namespace Metin2Launcher.Tests; + +/// +/// Minimal in-process HTTP server backed by for +/// telemetry / orchestrator tests. Dependency-free — no TestServer, no +/// WireMock, no new NuGet package. +/// +public sealed class TestHttpListener : IDisposable +{ + private readonly HttpListener _listener = new(); + private CancellationTokenSource _cts = new(); + private Task? _loop; + private HttpStatusCode _status = HttpStatusCode.OK; + private TimeSpan _delay = TimeSpan.Zero; + private Func? _bodyFactory; + + public CapturedRequest? LastRequest { get; private set; } + + public string Start() + { + int port = GetFreeTcpPort(); + var prefix = $"http://127.0.0.1:{port}/"; + _listener.Prefixes.Add(prefix); + _listener.Start(); + _loop = Task.Run(LoopAsync); + return prefix; + } + + public void RespondWith(HttpStatusCode status) => _status = status; + + public void RespondAfter(TimeSpan delay, HttpStatusCode status) + { + _delay = delay; + _status = status; + } + + public void RespondBody(Func factory) => _bodyFactory = factory; + + private async Task LoopAsync() + { + while (!_cts.IsCancellationRequested) + { + HttpListenerContext ctx; + try { ctx = await _listener.GetContextAsync().ConfigureAwait(false); } + catch { return; } + + try + { + string body; + using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) + body = await r.ReadToEndAsync().ConfigureAwait(false); + + LastRequest = new CapturedRequest + { + Method = ctx.Request.HttpMethod, + RawUrl = ctx.Request.RawUrl ?? "", + Body = body, + }; + + if (_delay > TimeSpan.Zero) + { + try { await Task.Delay(_delay, _cts.Token).ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + + ctx.Response.StatusCode = (int)_status; + if (_bodyFactory != null) + { + var payload = _bodyFactory(ctx.Request); + await ctx.Response.OutputStream.WriteAsync(payload, _cts.Token).ConfigureAwait(false); + } + ctx.Response.Close(); + } + catch + { + try { ctx.Response.Abort(); } catch { } + } + } + } + + private static int GetFreeTcpPort() + { + var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + public void Dispose() + { + try { _cts.Cancel(); } catch { } + try { _listener.Stop(); _listener.Close(); } catch { } + try { _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { } + _cts.Dispose(); + } + + public sealed class CapturedRequest + { + public string Method { get; set; } = ""; + public string RawUrl { get; set; } = ""; + public string Body { get; set; } = ""; + } +} diff --git a/tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs b/tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs new file mode 100644 index 0000000..a1ea526 --- /dev/null +++ b/tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs @@ -0,0 +1,112 @@ +using System.Text; +using System.Text.Json; +using Metin2Launcher.Config; +using Metin2Launcher.Formats; +using Metin2Launcher.Manifest; +using Metin2Launcher.Orchestration; +using Xunit; +using ManifestDto = Metin2Launcher.Manifest.Manifest; + +namespace Metin2Launcher.Tests; + +/// +/// Exercises the 's format-dispatch wiring. +/// +/// The orchestrator short-circuits to a signature exception when the fetched +/// manifest isn't signed by the hardcoded . +/// We can't realistically override that constant in a test without widening +/// the surface area, so these tests focus on: +/// +/// 1. The unit-level pieces the orchestrator relies on (factory, effective +/// format, file filtering, post-apply hook) — covered in sibling test +/// classes and asserted here for regression safety. +/// 2. End-to-end "manifest fetched but signature mismatches" showing the +/// orchestrator really reaches the verify stage against a synthetic +/// HTTP endpoint served by . +/// +public class UpdateOrchestratorFormatDispatchTests +{ + [Fact] + public async Task Bad_signature_throws_before_format_dispatch() + { + using var listener = new TestHttpListener(); + var url = listener.Start(); + + var manifest = new ManifestDto + { + Format = "m2pack", + Version = "v1", + CreatedAt = "2026-04-14T00:00:00Z", + Launcher = new ManifestLauncherEntry { Path = "L.exe", Sha256 = "dead", Size = 1 }, + }; + var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest)); + var badSig = new byte[64]; // deliberately wrong signature + + listener.RespondBody(req => req.RawUrl!.EndsWith(".sig") ? badSig : body); + + var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid()); + Directory.CreateDirectory(clientRoot); + try + { + using var http = new HttpClient(); + var settings = new LauncherSettings + { + DevMode = true, + ManifestUrlOverride = url + "manifest.json", + }; + var orchestrator = new UpdateOrchestrator(clientRoot, settings, http); + + await Assert.ThrowsAsync(async () => + await orchestrator.RunAsync(new Progress(_ => { }), CancellationToken.None)); + } + finally { Directory.Delete(clientRoot, true); } + } + + [Fact] + public async Task Manifest_fetch_failure_returns_offline_fallback() + { + using var listener = new TestHttpListener(); + var url = listener.Start(); + listener.RespondWith(System.Net.HttpStatusCode.NotFound); + + var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid()); + Directory.CreateDirectory(clientRoot); + try + { + using var http = new HttpClient(); + var settings = new LauncherSettings + { + DevMode = true, + ManifestUrlOverride = url + "manifest.json", + }; + var orchestrator = new UpdateOrchestrator(clientRoot, settings, http); + var result = await orchestrator.RunAsync( + new Progress(_ => { }), + CancellationToken.None); + Assert.False(result.Success); + Assert.Equal(LauncherState.OfflineFallback, result.FinalState); + Assert.Null(result.Format); + Assert.Null(result.RuntimeKey); + } + finally { Directory.Delete(clientRoot, true); } + } + + [Fact] + public void Result_carries_format_and_key_slots() + { + // Contract test: the orchestrator Result exposes Format and RuntimeKey + // so the launcher can thread the runtime key into GameProcess.Launch. + var r = new UpdateOrchestrator.Result(true, LauncherState.UpToDate, null); + Assert.Null(r.Format); + Assert.Null(r.RuntimeKey); + } + + [Fact] + public void Factory_falls_back_through_effective_format_on_empty_string() + { + // A manifest with format="" should resolve via EffectiveFormat -> legacy. + var m = new ManifestDto { Version = "v1", Format = "" }; + var f = ReleaseFormatFactory.Resolve(m.EffectiveFormat); + Assert.IsType(f); + } +}