Files
schmidt.florian cf7e0396f0
ci / build-test (push) Successful in 1m13s
Dashboard: report when a newer release is available
The instance had no idea what version it was: VERSION drives tagging and the
image publish, but was never stamped into the assemblies, so a running build
reported 1.0.0 forever. Directory.Build.props now stamps it into every project.

The dashboard compares that against the newest tag in the source repository and
shows a banner when behind. A plain GET of a public tag list -- nothing about
the instance is sent -- cached six hours, failing quiet.

Two things it deliberately does not do. It never blocks a render: the banner
paints from the cached answer and refreshes after first render, so a cold start
or an unreachable repository costs nothing rather than holding the dashboard
open for an HTTP timeout. And it never guesses: an unknown version on either
side shows no banner at all, because a banner that cannot clear trains people
to ignore the next real one.

Version comparison is numeric on exactly three components, not System.Version
and not string order. Tags are written vX.Y.Z, the assembly reports X.Y.Z with
a +commithash suffix, and "0.10.0" sorts below "0.9.0" as a string -- each of
those is a way the banner sticks or never appears. Prerelease suffixes compare
equal to their release so an rc tag does not nag. Gitea does not promise semver
ordering, so the highest tag wins rather than the first.

The command shown depends on the install: the LXC has `update`, a container is
replaced by pulling an image, and telling container users to run `update` sends
them after a command that does not exist.

No update *button*. The UI has no authentication and the LXC runs the app as
root, and `update` builds whatever is on master, so a click would be an
unauthenticated path to arbitrary code execution for anything on the LAN. The
README now states the no-auth position plainly rather than leaving it implied.

Tests cover the parse and ordering cases that would strand a banner, the Gitea
payload shape captured from the live API, unreachable and garbage responses,
and that the VERSION file actually reaches the assembly -- read from MeterVault's
own assembly rather than GetEntryAssembly(), which under `dotnet test` is the
test host and reported a confident wrong answer. The suite makes no outbound
request: the app factory disables the check.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:17:52 +02:00

204 lines
7.7 KiB
C#

using System.Net;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Update;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeterVault.Integration.Tests;
/// <summary>
/// Version comparison behind the update banner. Pure — no database, no network.
/// </summary>
/// <remarks>
/// The failure mode worth guarding is a banner that never clears: it trains the operator to ignore
/// it, and the next real update goes unnoticed. Every case here is one that would produce exactly
/// that if the parse or the ordering were naive.
/// </remarks>
public sealed class UpdateCheckTests
{
[Theory]
[InlineData("0.1.0", 0, 1, 0)]
[InlineData("v0.2.0", 0, 2, 0)]
[InlineData(" 1.4.7 ", 1, 4, 7)]
// The shape the running assembly actually reports — MSBuild appends the commit hash.
[InlineData("0.1.0+8fe5f4411b467943c6717cc994ca74d8883d2839", 0, 1, 0)]
// A prerelease compares equal to its release, so an rc tag never nags a released instance.
[InlineData("0.2.0-rc.1", 0, 2, 0)]
public void Parses_the_forms_tags_and_assemblies_actually_use(string text, int major, int minor, int patch)
{
Assert.True(ReleaseVersion.TryParse(text, out var version));
Assert.Equal(new ReleaseVersion(major, minor, patch), version);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("latest")]
[InlineData("1.0")] // System.Version would accept this and invent a -1
[InlineData("1.0.0.0")] // ...and this, silently changing the comparison
[InlineData("v1.x.0")]
public void Rejects_anything_it_cannot_order(string? text)
{
Assert.False(ReleaseVersion.TryParse(text, out _));
}
[Fact]
public void Orders_numerically_not_lexically()
{
Assert.True(ReleaseVersion.TryParse("0.10.0", out var ten));
Assert.True(ReleaseVersion.TryParse("0.9.0", out var nine));
// "0.10.0" < "0.9.0" as strings — the classic way an update banner goes permanently quiet.
Assert.True(ten > nine);
Assert.False(nine > ten);
}
[Fact]
public void Picks_the_highest_tag_regardless_of_listing_order()
{
// Gitea does not promise semver ordering, so taking the first entry is not safe.
string?[] tags = ["v0.9.0", "v0.10.0", "v0.2.0"];
Assert.True(ReleaseVersion.TryPickLatest(tags, out var latest));
Assert.Equal(new ReleaseVersion(0, 10, 0), latest!.Value);
}
[Fact]
public void Ignores_tags_that_are_not_versions()
{
string?[] tags = ["nightly", null, "v0.3.0", "release-candidate", ""];
Assert.True(ReleaseVersion.TryPickLatest(tags, out var latest));
Assert.Equal(new ReleaseVersion(0, 3, 0), latest!.Value);
}
[Fact]
public void Reports_nothing_when_no_tag_is_a_version()
{
Assert.False(ReleaseVersion.TryPickLatest(["nightly", "main"], out _));
}
[Fact]
public void An_update_is_only_available_when_the_published_version_is_strictly_newer()
{
var running = new ReleaseVersion(0, 1, 0);
Assert.True(new UpdateStatus(running, new ReleaseVersion(0, 2, 0), DateTimeOffset.UtcNow).UpdateAvailable);
Assert.False(new UpdateStatus(running, running, DateTimeOffset.UtcNow).UpdateAvailable);
// A dev build ahead of the newest tag must not be told to "update" backwards.
Assert.False(new UpdateStatus(new ReleaseVersion(0, 3, 0), running, DateTimeOffset.UtcNow).UpdateAvailable);
}
[Fact]
public void An_unknown_version_on_either_side_shows_no_banner()
{
// Never guess. A missing version means "cannot tell", which is silence, not a nag.
Assert.False(new UpdateStatus(null, new ReleaseVersion(9, 9, 9), null).UpdateAvailable);
Assert.False(new UpdateStatus(new ReleaseVersion(0, 1, 0), null, null).UpdateAvailable);
}
[Fact]
public void The_running_assembly_reports_the_version_from_the_VERSION_file()
{
// Guards the MSBuild plumbing: without VERSION stamped into the assembly this is null and
// the banner can never appear, however well the comparison works. Compared against the file
// itself, so reading the host process's version by mistake cannot pass this.
var repoRoot = FindRepoRoot();
Assert.True(ReleaseVersion.TryParse(File.ReadAllText(Path.Combine(repoRoot, "VERSION")), out var declared));
Assert.Equal(declared, UpdateCheckService.RunningVersion());
}
[Fact]
public async Task Reads_the_newest_tag_out_of_a_gitea_tag_listing()
{
// The payload shape is the real one, captured from
// /api/v1/repos/FinalFactory/MeterVault/tags — the seam the unit tests above cannot cover.
const string Payload = """
[
{"name":"v0.1.0","message":"Release v0.1.0","id":"4a51306"},
{"name":"v0.3.0","message":"Release v0.3.0","id":"aa11bb2"},
{"name":"v0.2.0","message":"Release v0.2.0","id":"cc33dd4"}
]
""";
var status = await NewService(HttpStatusCode.OK, Payload).GetAsync();
Assert.Equal(new ReleaseVersion(0, 3, 0), status.Latest);
Assert.NotNull(status.CheckedAt);
}
[Fact]
public async Task An_unreachable_repository_reports_no_latest_instead_of_throwing()
{
// A dashboard on an instance with no outbound access must still render.
var status = await NewService(HttpStatusCode.ServiceUnavailable, "nope").GetAsync();
Assert.Null(status.Latest);
Assert.False(status.UpdateAvailable);
}
[Fact]
public async Task Garbage_in_the_response_is_treated_as_cannot_tell()
{
var status = await NewService(HttpStatusCode.OK, "<html>login page</html>").GetAsync();
Assert.Null(status.Latest);
Assert.False(status.UpdateAvailable);
}
[Fact]
public async Task Disabling_the_check_makes_no_request_at_all()
{
var handler = new StubHandler(HttpStatusCode.OK, "[]");
var service = new UpdateCheckService(
new StubHttpClientFactory(handler),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { UpdateCheckEnabled = false }),
NullLogger<UpdateCheckService>.Instance);
await service.GetAsync();
Assert.Equal(0, handler.Calls);
}
private static UpdateCheckService NewService(HttpStatusCode status, string payload) =>
new(new StubHttpClientFactory(new StubHandler(status, payload)),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions
{
UpdateCheckEnabled = true,
UpdateCheckUrl = "https://example.invalid/tags",
}),
NullLogger<UpdateCheckService>.Instance);
private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
}
private sealed class StubHandler(HttpStatusCode status, string payload) : HttpMessageHandler
{
public int Calls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(payload) });
}
}
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VERSION")))
{
directory = directory.Parent;
}
Assert.NotNull(directory);
return directory!.FullName;
}
}