test: cover runtime key, release formats and telemetry
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) <noreply@anthropic.com>
This commit is contained in:
124
tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs
Normal file
124
tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
68
tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs
Normal file
68
tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs
Normal file
@@ -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<ArgumentNullException>(() => d.Apply(null!, SampleKey()));
|
||||
Assert.Throws<ArgumentNullException>(() => d.Apply(new ProcessStartInfo(), null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedMemoryDelivery_is_stubbed()
|
||||
{
|
||||
var d = new SharedMemoryKeyDelivery();
|
||||
Assert.Equal("shared-memory", d.Name);
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
d.Apply(new ProcessStartInfo(), SampleKey()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delivery_name_is_env_var()
|
||||
{
|
||||
Assert.Equal("env-var", new EnvVarKeyDelivery().Name);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
64
tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs
Normal file
64
tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs
Normal file
@@ -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<byte>(), Array.Empty<byte>(), SampleManifest()),
|
||||
outcome);
|
||||
Assert.Null(outcome.RuntimeKey);
|
||||
}
|
||||
finally { Directory.Delete(tmp, true); }
|
||||
}
|
||||
}
|
||||
131
tests/Metin2Launcher.Tests/M2PackFormatTests.cs
Normal file
131
tests/Metin2Launcher.Tests/M2PackFormatTests.cs
Normal file
@@ -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<byte>(), Array.Empty<byte>(), 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<byte>(), Array.Empty<byte>(), 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<Exception>(() =>
|
||||
new M2PackFormat().OnApplied(
|
||||
tmp,
|
||||
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), 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<byte>(), Array.Empty<byte>(), SampleManifest()),
|
||||
outcome);
|
||||
Assert.NotNull(outcome.RuntimeKey);
|
||||
}
|
||||
finally { Directory.Delete(tmp, true); }
|
||||
}
|
||||
}
|
||||
86
tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs
Normal file
86
tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs
Normal file
@@ -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<LegacyJsonBlobFormat>(f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolves_m2pack()
|
||||
{
|
||||
var f = ReleaseFormatFactory.Resolve("m2pack");
|
||||
Assert.IsType<M2PackFormat>(f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_on_unknown_format()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
83
tests/Metin2Launcher.Tests/RuntimeKeyTests.cs
Normal file
83
tests/Metin2Launcher.Tests/RuntimeKeyTests.cs
Normal file
@@ -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<InvalidDataException>(() => 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<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_rejects_bad_pubkey()
|
||||
{
|
||||
var json = SampleJson(pub: new string('g', 64));
|
||||
Assert.Throws<InvalidDataException>(() => 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<System.Text.Json.JsonException>(() =>
|
||||
RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json")));
|
||||
}
|
||||
}
|
||||
107
tests/Metin2Launcher.Tests/TestHttpListener.cs
Normal file
107
tests/Metin2Launcher.Tests/TestHttpListener.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace Metin2Launcher.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-process HTTP server backed by <see cref="HttpListener"/> for
|
||||
/// telemetry / orchestrator tests. Dependency-free — no TestServer, no
|
||||
/// WireMock, no new NuGet package.
|
||||
/// </summary>
|
||||
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<HttpListenerRequest, byte[]>? _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<HttpListenerRequest, byte[]> 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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the <see cref="UpdateOrchestrator"/>'s format-dispatch wiring.
|
||||
///
|
||||
/// The orchestrator short-circuits to a signature exception when the fetched
|
||||
/// manifest isn't signed by the hardcoded <see cref="LauncherConfig.PublicKeyHex"/>.
|
||||
/// 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 <see cref="TestHttpListener"/>.
|
||||
/// </summary>
|
||||
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<ManifestSignatureException>(async () =>
|
||||
await orchestrator.RunAsync(new Progress<UpdateProgress>(_ => { }), 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<UpdateProgress>(_ => { }),
|
||||
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<LegacyJsonBlobFormat>(f);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user