1f575c9da2
ci / build-test (push) Successful in 1m14s
Owner's call: MeterVault__AllowInAppUpdate is now the whole gate. One click on the banner, no key, no prompt, and the REST endpoint no longer asks for one either. What that means, recorded so it is not rediscovered later: with the flag on, anything that can reach MeterVault can trigger a rebuild and restart. On the realistic threat model that is a repeatable denial of service — minutes of downtime and a pegged CPU per request — rather than code injection, because the build comes from the owner's own repository. It becomes remote code execution if that repository is ever compromised. The flag still defaults off, and that default is now the only thing between an upgrade and an open trigger, so UpdateRunnerTests pins it along with the fact that configuring API keys does not imply consent to rebuild the host. Kept one guard, which is not authentication: the REST endpoint requires an X-MeterVault-Update header. Without it any website could POST to the endpoint through the browser of someone on the network — a plain HTML form is enough, and no key means nothing else would stop it. A form cannot set a custom header and a cross-origin fetch that tries is stopped by a preflight nothing here answers, so this costs a deliberate caller one flag and costs the button nothing, since it runs over the Blazor circuit rather than HTTP. The confirmation dialog stays, now purely as a guard against a stray click costing several minutes of downtime. Every triggered update is logged as a warning: with no key there is no caller to attribute it to, and the restart discards anything held in memory. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
122 lines
5.1 KiB
C#
122 lines
5.1 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_unless_explicitly_enabled()
|
|
{
|
|
// Through the real pipeline, not just the runner. 409 rather than 401: the endpoint is
|
|
// disabled, which is a different fact from the caller being unauthenticated — and the
|
|
// default install must refuse regardless of what the caller presents.
|
|
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true);
|
|
using var client = factory.CreateClient();
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
|
|
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
|
request.Headers.Add(MeterVault.App.Api.ApiEndpoints.UpdateRequestHeader, "1");
|
|
|
|
var response = await client.SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, response.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);
|
|
}
|
|
}
|