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
This commit is contained in:
@@ -15,6 +15,16 @@
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- VERSION on master is the single source of release truth: editing it tags and publishes
|
||||
(.gitea/workflows/version-tag.yml). Stamp it into the assemblies too, so a running instance
|
||||
can say which version it is and compare itself against the newest tag. Without this the app
|
||||
reports 1.0.0 forever and an update banner would be meaningless. -->
|
||||
<PropertyGroup>
|
||||
<MeterVaultVersionFile>$(MSBuildThisFileDirectory)VERSION</MeterVaultVersionFile>
|
||||
<MeterVaultVersion Condition="Exists('$(MeterVaultVersionFile)')">$([System.IO.File]::ReadAllText('$(MeterVaultVersionFile)').Trim().TrimStart('v'))</MeterVaultVersion>
|
||||
<Version Condition="'$(MeterVaultVersion)' != ''">$(MeterVaultVersion)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
|
||||
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -73,10 +73,24 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|
||||
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
||||
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
|
||||
| `MeterVault__DataProtectionKeyPath` | Where the key ring for UI-entered connector secrets lives (default `/var/lib/metervault/keys`) |
|
||||
| `MeterVault__UpdateCheckEnabled` | `false` to stop the dashboard checking for a newer release |
|
||||
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables it) |
|
||||
|
||||
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
|
||||
returns 401. Set at least one API key (or open it explicitly for a trusted network).
|
||||
|
||||
> **The web UI has no authentication.** There is no login: anything that can reach the port can read
|
||||
> and change everything, including connectors and their stored secrets. Put it behind a reverse proxy
|
||||
> with auth (Authelia, Traefik forward-auth, …) — `MeterVault__ReverseProxyTrust` then honours the
|
||||
> user header — or keep it on a trusted network. This is why the dashboard reports that an update is
|
||||
> available but does not offer to apply it: with the app running as root in the LXC, a one-click
|
||||
> update would be an unauthenticated path to arbitrary code execution.
|
||||
|
||||
The dashboard compares the running build against the newest tag in the source repository and shows a
|
||||
banner when it is behind. That is a plain GET of a public tag list — nothing about the instance is
|
||||
sent — cached for six hours, and it never blocks or fails a page render. Turn it off with
|
||||
`MeterVault__UpdateCheckEnabled=false`.
|
||||
|
||||
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
|
||||
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
|
||||
into the admin UI and encrypted at rest under the data-protection key ring. Either way a `pg_dump`
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
|
||||
|
||||
<UpdateBanner />
|
||||
|
||||
@if (_summary is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
@using MeterVault.Infrastructure.Update
|
||||
@inject UpdateCheckService Updates
|
||||
|
||||
@* Renders nothing at all unless a newer release genuinely exists — no "you are up to date" noise. *@
|
||||
@if (_status is { UpdateAvailable: true, Running: { } running, Latest: { } latest })
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.SystemUpdateAlt">
|
||||
<div class="d-flex flex-wrap align-center" style="gap:.75rem">
|
||||
<span>MeterVault <b>@latest</b> is available — this instance runs <b>@running</b>.</span>
|
||||
<MudTooltip Text="@_command">
|
||||
<code style="opacity:.85">@_command</code>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@code {
|
||||
private UpdateStatus? _status;
|
||||
private string _command = "";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Cached answer only — awaiting the check here would hold the dashboard's first paint open
|
||||
// for the length of an HTTP timeout on a cold start, or whenever the repo is unreachable.
|
||||
_status = Updates.Current;
|
||||
_command = UpdateCommandHint();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var refreshed = await Updates.GetAsync();
|
||||
if (refreshed != _status)
|
||||
{
|
||||
_status = refreshed;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How this particular install updates. The LXC has an <c>update</c> command; a container is
|
||||
/// replaced by pulling a new image, and telling those users to run <c>update</c> would send them
|
||||
/// looking for a command that does not exist.
|
||||
/// </summary>
|
||||
private static string UpdateCommandHint() =>
|
||||
File.Exists("/usr/bin/update")
|
||||
? "run: update"
|
||||
: "pull the new image and recreate the container";
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public static class DependencyInjection
|
||||
// Singleton: wraps one IDataProtector, and the ingestion workers (themselves singletons)
|
||||
// resolve connector secrets on every reconnect.
|
||||
services.AddSingleton<Security.SecretProtector>();
|
||||
// Singleton: it caches the last answer so the dashboard never waits on a remote call.
|
||||
services.AddSingleton<Update.UpdateCheckService>();
|
||||
services.AddScoped<Costing.CostService>();
|
||||
services.AddScoped<Dashboard.DashboardService>();
|
||||
services.AddScoped<Dashboard.SolarService>();
|
||||
|
||||
@@ -32,6 +32,20 @@ public sealed class MeterVaultOptions
|
||||
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
|
||||
public int RawRetentionDays { get; set; } = 1095;
|
||||
|
||||
/// <summary>
|
||||
/// Compare the running build against the newest published release and show a banner when behind.
|
||||
/// Set false for an air-gapped instance, or one that should make no outbound requests at all.
|
||||
/// </summary>
|
||||
public bool UpdateCheckEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tag listing consulted by the update check. Points at the project's own Gitea, not a vendor
|
||||
/// endpoint — nothing about the instance is sent, it is a plain GET of a public tag list.
|
||||
/// Repoint it at a fork, or blank it to disable the check as surely as the flag above.
|
||||
/// </summary>
|
||||
public string UpdateCheckUrl { get; set; } =
|
||||
"https://git.finalfactory.de/api/v1/repos/FinalFactory/MeterVault/tags?limit=50";
|
||||
|
||||
/// <summary>
|
||||
/// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env
|
||||
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty means the API is open (dev only).
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
namespace MeterVault.Infrastructure.Update;
|
||||
|
||||
/// <summary>
|
||||
/// A released version, parsed from either the running assembly or a git tag name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not <see cref="System.Version"/>. Tags are written <c>vX.Y.Z</c> while the assembly
|
||||
/// carries <c>X.Y.Z</c> — often with a <c>+commithash</c> suffix from the build — and comparing
|
||||
/// those as strings, or letting System.Version see a 4th component it invents as -1, produces an
|
||||
/// "update available" banner that never clears. Parsing to exactly three numbers makes the
|
||||
/// comparison total and boring.
|
||||
/// </remarks>
|
||||
public readonly record struct ReleaseVersion(int Major, int Minor, int Patch) : IComparable<ReleaseVersion>
|
||||
{
|
||||
public static bool TryParse(string? text, out ReleaseVersion version)
|
||||
{
|
||||
version = default;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = text.Trim();
|
||||
|
||||
// Build metadata ("0.2.0+3f1a9c") and prerelease suffixes ("0.2.0-rc.1") are not part of the
|
||||
// ordering here: a prerelease tag compares equal to its release, so it never nags.
|
||||
var cut = value.IndexOfAny(['+', '-']);
|
||||
if (cut >= 0)
|
||||
{
|
||||
value = value[..cut];
|
||||
}
|
||||
|
||||
value = value.TrimStart('v', 'V');
|
||||
|
||||
var parts = value.Split('.');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major)
|
||||
|| !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
|
||||
|| !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new ReleaseVersion(major, minor, patch);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Picks the highest parseable version from a list of tag names, ignoring the rest.</summary>
|
||||
public static bool TryPickLatest(IEnumerable<string?> tagNames, [NotNullWhen(true)] out ReleaseVersion? latest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagNames);
|
||||
|
||||
ReleaseVersion? best = null;
|
||||
foreach (var name in tagNames)
|
||||
{
|
||||
if (TryParse(name, out var parsed) && (best is null || parsed > best.Value))
|
||||
{
|
||||
best = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
latest = best;
|
||||
return best is not null;
|
||||
}
|
||||
|
||||
public int CompareTo(ReleaseVersion other)
|
||||
{
|
||||
var major = Major.CompareTo(other.Major);
|
||||
if (major != 0)
|
||||
{
|
||||
return major;
|
||||
}
|
||||
|
||||
var minor = Minor.CompareTo(other.Minor);
|
||||
return minor != 0 ? minor : Patch.CompareTo(other.Patch);
|
||||
}
|
||||
|
||||
public static bool operator <(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) < 0;
|
||||
|
||||
public static bool operator >(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) > 0;
|
||||
|
||||
public static bool operator <=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) <= 0;
|
||||
|
||||
public static bool operator >=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) >= 0;
|
||||
|
||||
public override string ToString() =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}");
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeterVault.Infrastructure.Update;
|
||||
|
||||
/// <summary>What the instance is running, and what the newest published release is.</summary>
|
||||
/// <param name="Running">Version of the running build, or null if the assembly carries none.</param>
|
||||
/// <param name="Latest">Newest release tag seen, or null if the check has not succeeded.</param>
|
||||
/// <param name="CheckedAt">When the last successful check completed.</param>
|
||||
public sealed record UpdateStatus(ReleaseVersion? Running, ReleaseVersion? Latest, DateTimeOffset? CheckedAt)
|
||||
{
|
||||
/// <summary>True only when both versions are known and the published one is genuinely newer.</summary>
|
||||
public bool UpdateAvailable => Running is { } running && Latest is { } latest && latest > running;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares the running build against the newest tag in the source repository, so an instance can
|
||||
/// say it is behind (SDD §12 releases are driven by the VERSION file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Singleton with a cached result: the dashboard renders on every navigation and must never wait on,
|
||||
/// or fail because of, a remote call. A failed check keeps serving the last good answer and is
|
||||
/// retried on a short backoff rather than per page view — an instance with no outbound access should
|
||||
/// cost one failed request every few minutes, not one per render.
|
||||
/// </remarks>
|
||||
public sealed class UpdateCheckService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<MeterVaultOptions> options,
|
||||
ILogger<UpdateCheckService> logger)
|
||||
{
|
||||
private static readonly TimeSpan SuccessTtl = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan FailureTtl = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
private readonly MeterVaultOptions _options = options.Value;
|
||||
private readonly ILogger<UpdateCheckService> _logger = logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private UpdateStatus _status = new(RunningVersion(), null, null);
|
||||
private DateTimeOffset _nextCheck = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>The version of the running build, or null when the assembly carries no usable one.</summary>
|
||||
/// <remarks>
|
||||
/// Reads MeterVault's own assembly rather than <see cref="Assembly.GetEntryAssembly"/>, which is
|
||||
/// whatever process happens to be hosting — the test runner under <c>dotnet test</c>, whose
|
||||
/// version parses fine and would report a confident, wrong answer. Every project shares the
|
||||
/// VERSION stamp from Directory.Build.props, so this is the release version wherever it runs.
|
||||
/// </remarks>
|
||||
public static ReleaseVersion? RunningVersion()
|
||||
{
|
||||
var informational = typeof(UpdateCheckService).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
|
||||
return ReleaseVersion.TryParse(informational, out var version) ? version : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last known status, without touching the network. Lets a page paint immediately and fill
|
||||
/// the banner in afterwards, instead of holding first render open for the length of an HTTP
|
||||
/// timeout on a cold start.
|
||||
/// </summary>
|
||||
public UpdateStatus Current => _status;
|
||||
|
||||
/// <summary>
|
||||
/// The current status, refreshing at most once per TTL. Never throws: a check that cannot reach
|
||||
/// the repository leaves the banner absent rather than breaking the page that asked.
|
||||
/// </summary>
|
||||
public async Task<UpdateStatus> GetAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.UpdateCheckEnabled || DateTimeOffset.UtcNow < _nextCheck)
|
||||
{
|
||||
return _status;
|
||||
}
|
||||
|
||||
// One caller refreshes; the rest take the cached answer rather than queueing behind it, so a
|
||||
// slow endpoint cannot stack up render-blocking waits.
|
||||
if (!await _gate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _status;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (DateTimeOffset.UtcNow < _nextCheck)
|
||||
{
|
||||
return _status;
|
||||
}
|
||||
|
||||
var latest = await FetchLatestTagAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null)
|
||||
{
|
||||
_status = new UpdateStatus(RunningVersion(), latest, DateTimeOffset.UtcNow);
|
||||
_nextCheck = DateTimeOffset.UtcNow + SuccessTtl;
|
||||
}
|
||||
else
|
||||
{
|
||||
_nextCheck = DateTimeOffset.UtcNow + FailureTtl;
|
||||
}
|
||||
|
||||
return _status;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ReleaseVersion?> FetchLatestTagAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.UpdateCheckUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
using var response = await client
|
||||
.GetAsync(new Uri(_options.UpdateCheckUrl), cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Update check returned {Status}", (int)response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Gitea's /tags returns an array of objects with a "name". Order is not guaranteed to be
|
||||
// semver, so take the highest rather than the first.
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var names = document.RootElement.EnumerateArray()
|
||||
.Select(e => e.TryGetProperty("name", out var name) ? name.GetString() : null);
|
||||
|
||||
return ReleaseVersion.TryPickLatest(names, out var latest) ? latest : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException)
|
||||
{
|
||||
// Offline, DNS gone, repository moved, unexpected payload: all mean "cannot tell", which
|
||||
// is a missing banner, not an error the operator needs to see on every page.
|
||||
_logger.LogDebug(ex, "Update check failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ public sealed class MeterVaultAppFactory(string connectionString, bool configure
|
||||
builder.UseSetting("ConnectionStrings:Default", connectionString);
|
||||
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
|
||||
builder.UseSetting("MeterVault:EnableLiveIngestion", "false");
|
||||
// No outbound calls from tests: the update check would otherwise hit the real Gitea on every
|
||||
// page render, making the suite slow and dependent on that host being up.
|
||||
builder.UseSetting("MeterVault:UpdateCheckEnabled", "false");
|
||||
if (configureApiKey)
|
||||
{
|
||||
builder.UseSetting("MeterVault:ApiKeys:0", ApiKey);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user