Files
MeterVault/tests/Integration.Tests/ApiTests.cs
T
schmidt.florian 9eb3f7d53c
ci / build-test (push) Successful in 1m9s
Update: add an opt-in "Update now" button, gated on an API key
Adds the button, plus POST /api/v1/system/update for driving it from Home
Assistant or curl. Both pull the latest source, rebuild and restart the service.

The gating is the substance of this change. The updater builds whatever is on
the branch and the LXC runs MeterVault as root, so triggering it is
root-equivalent on that host, and the web UI has no login — "reachable from the
dashboard" alone would mean any device on the network could take the machine.
Three independent conditions must hold before anything runs: the operator set
MeterVault__AllowInAppUpdate, at least one API key is configured, and the caller
presented one, compared in constant time so retries cannot time out the key.

AllowAnonymousApi cannot reach it. That flag opens reads, and opening reads must
not open root, so the endpoint checks the presented key itself rather than
relying on the shared group filter that honours it. Availability is re-checked
inside LaunchAsync rather than trusting the caller to have done so.

The UI button asks for the key every time instead of remembering it: with no
login, a browser left open on the dashboard would otherwise be a standing
permission to execute code on the host. The key is cleared from component state
immediately, and a wrong key and a keyless deployment give the same message so
an unauthenticated caller cannot tell them apart.

Launched detached through systemd-run: the updater restarts the service, so a
child process would be killed part-way through, leaving the app down with a
half-published build. --collect reaps the transient unit so a later update is
not blocked by the remains of the previous one.

Off by default, and where there is no /usr/bin/update — a container, a dev box —
it reports that rather than half-running something. Tests pin every refusal,
including through the real HTTP pipeline; none of them launch anything.

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

126 lines
5.5 KiB
C#

using System.Net;
using System.Net.Http.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
[Collection("Timescale")]
public sealed class ApiTests(TimescaleFixture fx)
{
private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
[Fact]
public async Task Update_endpoint_is_shut_by_default_and_not_openable_anonymously()
{
// Through the real pipeline, not just the runner: an install that never opted in must return
// a refusal for this endpoint even when the caller presents a valid key, and even when the
// API itself has been opened anonymously. 409 rather than 401 — the endpoint is disabled,
// which is a different fact from the caller being unauthenticated.
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true);
using var client = factory.CreateClient();
using var withKey = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
withKey.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var authorised = await client.SendAsync(withKey);
Assert.Equal(HttpStatusCode.Conflict, authorised.StatusCode);
// And without a key it is certainly not reachable.
var anonymous = await client.PostAsync(new Uri("/api/v1/system/update", UriKind.Relative), content: null);
Assert.True(
anonymous.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Conflict,
$"expected the update endpoint to refuse an unauthenticated caller, got {anonymous.StatusCode}.");
}
[Fact]
public async Task Readings_push_requires_key_and_writes_when_authorized()
{
int meterId;
await using (var db = fx.CreateContext())
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var meter = new Meter { Name = $"api-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
meterId = meter.Id;
}
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
var push = new[] { new ReadingPush(meterId, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), 1500) };
// Without the key → 401.
var unauthorized = await client.PostAsJsonAsync("/api/v1/readings", push);
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
// With the key → 200 and the reading is persisted.
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/readings")
{
Content = JsonContent.Create(push),
};
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var authorized = await client.SendAsync(request);
authorized.EnsureSuccessStatusCode();
await using (var db = fx.CreateContext())
{
var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId);
Assert.Equal(1500, reading.Value, 3);
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}
private sealed record EventPush(int MeterId, DateTimeOffset Time, string Type,
double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
[Fact]
public async Task Api_is_closed_when_no_keys_are_configured()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: false);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/readings",
new[] { new ReadingPush(1, DateTimeOffset.UtcNow, 1) });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Events_for_a_missing_meter_return_404_not_500()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/events")
{
Content = JsonContent.Create(new EventPush(999999, DateTimeOffset.UtcNow, "Delivery", 100, null, null, "L", null)),
};
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Meters_endpoint_and_swagger_are_available()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
using var metersRequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/meters");
metersRequest.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var meters = await client.SendAsync(metersRequest);
meters.EnsureSuccessStatusCode();
// Swagger document is served (no API key required).
var swagger = await client.GetAsync(new Uri("/swagger/v1/swagger.json", UriKind.Relative));
swagger.EnsureSuccessStatusCode();
Assert.Contains("MeterVault API", await swagger.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
}