Audit fixes: batch recompute, negative-baseline percentages, key-ring persistence
ci / build-test (push) Successful in 1m17s

Three defects found reviewing the last few commits.

Deriving consumption on ingest made the batch reading endpoint quadratic. A
recompute rewrites a meter's entire consumption series, and POST
/api/v1/readings ran one per reading -- 500 readings for one meter meant 500
full rewrites. IngestByMeterAsync takes renormalize:false and the endpoint
normalizes each touched meter once after the batch.

Percentage change divided by a possibly negative baseline. A net-export meter
going from -100 to -150 exported half again as much and would have been
reported as "+50%", reading as more consumption. A non-positive baseline now
reports no basis rather than a confident lie.

The data-protection key ring had no persistent home outside Docker Compose. The
LXC installer now creates /var/lib/metervault/keys at 0700 -- the app would
otherwise create it under the default umask, leaving a key ring world-readable
-- and the Unraid template maps it, since without that every UI-entered secret
was lost whenever the container was recreated. README documents the variable
and the trust boundary: keys on disk protect against leaked database content,
not against an attacker who already has the host.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
This commit is contained in:
2026-07-18 19:39:56 +02:00
parent ad896db051
commit cedd60ab45
8 changed files with 107 additions and 8 deletions
@@ -174,6 +174,31 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_batch_can_defer_normalization_and_derive_the_same_series_once_at_the_end()
{
// Recomputing rewrites a meter's whole consumption series, so the batch endpoint skips it
// per reading and does it once. The result must be identical to normalizing as it goes.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
for (var hour = 0; hour < 5; hour++)
{
await service.IngestByMeterAsync(meterId, T0.AddHours(hour), 1000 + (hour * 10), renormalize: false);
}
Assert.False(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
await service.RenormalizeMeterAsync(meterId);
var consumption = await db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId).ToListAsync();
Assert.Equal(5, consumption.Count);
Assert.Equal(1040d, consumption.Sum(c => c.Amount), 3); // baseline 0 → 1000, then 4 × 10
await CleanupAsync(db, meterId);
}
private static IngestionService NewIngestion(MeterVaultDbContext db) =>
new(db, new MeterVault.Infrastructure.Normalization.NormalizationService(
db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault()));
@@ -77,6 +77,27 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_negative_previous_period_reports_no_basis_rather_than_an_inverted_percentage()
{
// Net export: -100 -> -150 is half again as much exported, but dividing by a negative
// baseline would render it "+50%", which reads as more consumption.
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var thisMonth = new DateOnly(today.Year, today.Month, 1);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), -150);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddMonths(-1).AddDays(3), -100);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Null(view!.MonthChange);
await CleanupAsync(db, meterId);
}
private MeterPeriodService NewService()
{
var options = Microsoft.Extensions.Options.Options.Create(