acdream/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs
Erik ff01423f3f
Some checks failed
CI / linux-portable (push) Failing after 3m22s
CI / windows-gate (push) Successful in 5m27s
CI / release (push) Has been skipped
ci: put the launcher's update pointer in a release, and delete the dist branch
The dist branch existed to carry ~120 MB payloads that could not go on main.
Once payloads became release attachments it held one 500-byte manifest.json,
so it was a whole branch for a reason that no longer applied.

The pointer is now a release asset too: each publish recreates a one-asset
 release naming the versioned build. Forgejo has no
/releases/latest/download/ route (404), so a pointer is still required — but
keeping it in a release means nothing about distribution lives in git: no
payload branch, no bot commits on main, and no push that could retrigger the
pipeline (which is why writing the manifest to main was not the answer either).

Recreating the tag deletes the old release AND its tag; the tag outlives its
release and would otherwise block recreation.

Versioned releases are retained, so older builds stay downloadable.
tools/publish-dist.ps1 is removed — publishing is CI's job now.
2026-08-19 14:40:41 +02:00

396 lines
16 KiB
C#

using System.Net;
using System.Text;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherVersionTests
{
[Theory]
[InlineData("1.0.0-alpha", "1.0.0-alpha.1")]
[InlineData("1.0.0-alpha.1", "1.0.0-alpha.beta")]
[InlineData("1.0.0-beta.11", "1.0.0-rc.1")]
[InlineData("1.0.0-rc.1", "1.0.0")]
[InlineData("1.9.999999999999999999999", "1.10.0")]
[InlineData("999999999999999999999.0.0", "1000000000000000000000.0.0")]
public void StrictSemVerOrdersWithoutNumericOverflow(string lower, string higher)
{
LauncherVersion left = LauncherVersion.Parse(lower);
LauncherVersion right = LauncherVersion.Parse(higher);
Assert.True(left < right);
Assert.True(right > left);
Assert.Equal(0, LauncherVersion.Parse(higher + "+build.7").CompareTo(right));
}
[Theory]
[InlineData("")]
[InlineData(" 1.0.0")]
[InlineData("1.0")]
[InlineData("01.0.0")]
[InlineData("1.0.0-01")]
[InlineData("1.0.0-")]
[InlineData("1.0.0+")]
[InlineData("v1.0.0")]
public void StrictSemVerRejectsAmbiguousVersions(string value) =>
Assert.False(LauncherVersion.TryParse(value, out _));
[Fact]
public void StrictSemVerIsBoundedAgainstManifestPathAmplification() =>
Assert.False(LauncherVersion.TryParse(
"1.0.0+" + new string('a', 129),
out _));
}
public sealed class ReleaseManifestClientTests
{
[Theory]
[InlineData("https://updates.example.test/manifest.json")]
[InlineData("http://127.0.0.1:43119/manifest.json")]
[InlineData("http://localhost:43119/manifest.json")]
public void LocalUpdateFeedOverrideAcceptsOnlySecureOrLoopbackFeeds(string value)
{
using ReleaseManifestClient source =
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value));
}
[Theory]
[InlineData("http://updates.example.test/manifest.json")]
[InlineData("file:///tmp/manifest.json")]
[InlineData("https://user:secret@updates.example.test/manifest.json")]
[InlineData("https://updates.example.test/manifest.json?token=secret")]
[InlineData("https://updates.example.test/manifest.json#fragment")]
public void LocalUpdateFeedOverrideRejectsRemoteHttpAndCredentialLikeUris(string value) =>
Assert.Throws<LauncherUpdateException>(() =>
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)));
[Fact]
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip("win-x64");
byte[] launcher = UpdateTestData.LauncherZip("win-x64");
server.Add("client.zip", client);
server.Add("launcher.zip", launcher);
server.Add(
"manifest.json",
UpdateTestData.Manifest(
"2.1.0",
"1.5.0",
"win-x64",
server.UriFor("client.zip"),
client,
server.UriFor("launcher.zip"),
launcher),
contentType: "application/json");
using var http = new HttpClient();
using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
ReleaseManifest manifest = await source.FetchAsync();
Assert.Equal("2.1.0", manifest.Version.Value);
Assert.Equal(client.LongLength, manifest.RequireClient("win-x64").Size);
// Alpha distribution feed: the manifest asset of the fixed `latest`
// release. Nothing about distribution lives in git — the CI release job
// republishes this pointer after each versioned release, so it must
// agree with this URL.
Assert.Equal(
"https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json",
ReleaseManifestClient.ProductionManifestUri.AbsoluteUri);
Assert.Equal(Uri.UriSchemeHttps, ReleaseManifestClient.ProductionManifestUri.Scheme);
}
[Theory]
[MemberData(nameof(InvalidManifests))]
public void RejectsWrongVersionRidHashSizeMinimumAndUnknownOrDuplicateFields(
string json)
{
Assert.Throws<LauncherUpdateException>(() =>
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
}
public static TheoryData<string> InvalidManifests => new()
{
"{}",
ValidJson().Replace("\"schemaVersion\":1", "\"schemaVersion\":2"),
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"02.0.0\""),
ValidJson().Replace("\"minimumLauncherVersion\":\"1.0.0\"", "\"minimumLauncherVersion\":\"3.0.0\""),
ValidJson().Replace("win-x64", "WIN_X64"),
ValidJson().Replace(new string('a', 64), "1234"),
ValidJson().Replace("\"size\":12", "\"size\":0"),
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
ValidJson().Replace("https://example.test/client", "http://example.test/client"),
};
[Theory]
[InlineData("clients", "client")]
[InlineData("launchers", "launcher")]
public void ProductionManifestRejectsLoopbackHttpArtifacts(
string section,
string artifact)
{
string json = ValidJson().Replace(
$"https://example.test/{artifact}",
$"http://127.0.0.1/{artifact}",
StringComparison.Ordinal);
LauncherUpdateException error = Assert.Throws<LauncherUpdateException>(() =>
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
Assert.Contains(section, error.Message, StringComparison.Ordinal);
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest()
{
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.Found,
new Uri("http://127.0.0.1/manifest.json")));
using var source = ReleaseManifestClient.CreateForTransportTest(
ReleaseManifestClient.ProductionManifestUri,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests);
}
[Fact]
public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop()
{
var start = new Uri("https://example.test/start");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.TemporaryRedirect,
new Uri("http://example.test/plaintext-hop")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
await Assert.ThrowsAsync<LauncherUpdateException>(() => source.FetchAsync());
Assert.Equal([start], handler.Requests);
}
[Fact]
public async Task RedirectLoopIsRejectedWithoutRepeatingARequest()
{
var first = new Uri("https://example.test/first");
var second = new Uri("https://example.test/second");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.PermanentRedirect,
request.RequestUri == first ? second : first));
using var source = ReleaseManifestClient.CreateForTransportTest(
first,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal([first, second], handler.Requests);
}
[Fact]
public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop()
{
var start = new Uri("https://example.test/hop-0");
var handler = new SequenceHandler((_, index) => Redirect(
HttpStatusCode.Found,
new Uri($"https://example.test/hop-{index + 1}")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal);
Assert.Equal(6, handler.Requests.Count);
Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]);
}
private static string ValidJson() =>
$$$$"""
{"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}}
""";
private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
{
var response = new HttpResponseMessage(status);
response.Headers.Location = location;
return response;
}
private sealed class SequenceHandler(
Func<HttpRequestMessage, int, HttpResponseMessage> respond)
: HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Uri uri = request.RequestUri
?? throw new InvalidOperationException("Test request has no URI.");
int index = Requests.Count;
Requests.Add(uri);
return Task.FromResult(respond(request, index));
}
}
}
public sealed class VerifiedArtifactDownloaderTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-download-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task StreamsToStagingWithProgressAndExactDigest()
{
using var server = new LocalHttpFixture();
byte[] bytes = Enumerable.Range(0, 200_000).Select(value => (byte)value).ToArray();
server.Add("artifact", bytes, chunkSize: 4096);
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
var progress = new List<ArtifactDownloadProgress>();
string destination = Path.Combine(_root, "artifact.zip");
VerifiedArtifactDownload result = await downloader.DownloadAsync(
new ReleaseArtifact(server.UriFor("artifact"), UpdateTestData.Sha256(bytes), bytes.LongLength),
destination,
new ImmediateProgress(progress.Add));
Assert.Equal(bytes.LongLength, result.Size);
Assert.Equal(UpdateTestData.Sha256(bytes), result.Sha256);
Assert.Equal(bytes, await File.ReadAllBytesAsync(destination));
Assert.Equal(0, progress[0].BytesReceived);
Assert.Equal(bytes.LongLength, progress[^1].BytesReceived);
}
[Theory]
[InlineData("short")]
[InlineData("header")]
[InlineData("hash")]
public async Task WrongSizeHeaderPartialBodyAndHashDeleteStaging(string failure)
{
using var server = new LocalHttpFixture();
byte[] bytes = Encoding.UTF8.GetBytes("verified bytes");
long expected = failure == "short" ? bytes.Length + 5 : bytes.Length;
long declared = failure == "header" ? bytes.Length + 1 : expected;
server.Add("artifact", bytes, declaredLength: declared);
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, failure + ".zip");
string hash = failure == "hash" ? new string('0', 64) : UpdateTestData.Sha256(bytes);
await Assert.ThrowsAsync<LauncherUpdateException>(() => downloader.DownloadAsync(
new ReleaseArtifact(server.UriFor("artifact"), hash, expected),
destination));
Assert.False(File.Exists(destination));
}
[Fact]
public async Task CancellationDeletesPartialStaging()
{
using var server = new LocalHttpFixture();
byte[] bytes = new byte[2 * 1024 * 1024];
Random.Shared.NextBytes(bytes);
server.Add("slow", bytes, chunkSize: 1024, chunkDelay: TimeSpan.FromMilliseconds(3));
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "cancel.zip");
using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(40));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => downloader.DownloadAsync(
new ReleaseArtifact(server.UriFor("slow"), UpdateTestData.Sha256(bytes), bytes.LongLength),
destination,
cancellationToken: cancel.Token));
Assert.False(File.Exists(destination));
}
[Fact]
public async Task RefusesAndPreservesPreExistingCallerFile()
{
using var server = new LocalHttpFixture();
byte[] bytes = Encoding.UTF8.GetBytes("network");
server.Add("artifact", bytes);
using var http = new HttpClient();
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "already-owned.zip");
Directory.CreateDirectory(_root);
await File.WriteAllTextAsync(destination, "preserve");
await Assert.ThrowsAsync<LauncherUpdateException>(() => downloader.DownloadAsync(
new ReleaseArtifact(
server.UriFor("artifact"),
UpdateTestData.Sha256(bytes),
bytes.LongLength),
destination));
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
}
[Fact]
public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop()
{
var handler = new RedirectHandler();
using var http = new HttpClient(handler);
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "redirect.zip");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
downloader.DownloadAsync(
new ReleaseArtifact(
new Uri("https://example.test/artifact"),
new string('a', 64),
12),
destination));
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests);
Assert.False(File.Exists(destination));
}
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
: IProgress<ArtifactDownloadProgress>
{
public void Report(ArtifactDownloadProgress value) => callback(value);
}
private sealed class RedirectHandler : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!);
var response = new HttpResponseMessage(HttpStatusCode.Found);
response.Headers.Location = new Uri("http://example.test/plaintext");
return Task.FromResult(response);
}
}
}