fix(utc/locale): close Theme 2 — 8 UTC / time / locale findings

UTC invariant + culture-safety fixes across UI form binding, audit entity
hydrate, and locale-dependent parses. Highlights:
- CentralUI-026/027: AuditFilterBar / SiteCallsReport / NotificationReport /
  EventLogs now apply SpecifyKind(Local) + ToUniversalTime() at form submit
  so browser-local datetime-local inputs aren't silently treated as UTC.
- Commons-019: AuditEvent.OccurredAtUtc / IngestedAtUtc init-setters
  re-tag any incoming DateTime as Kind=Utc, documenting the invariant.
- CD-018: AuditLogEntityTypeConfiguration adds UTC ValueConverters on the
  *Utc DateTime columns so EF hydrate yields Kind=Utc (SQL Server's
  datetime2 has no Kind metadata, so reads were returning Unspecified).
- CD-020: GetPartitionBoundariesOlderThanAsync now SpecifyKind(Utc) on the
  raw-ADO read, matching the existing defence in AuditLogPartitionMaintenance.
- SEL-021: EventLogQueryService.DateTimeOffset.Parse now uses
  InvariantCulture + AssumeUniversal | AdjustToUniversal.
- SR-023: Convert.ToDouble in ScriptActor + AlarmActor (4 sites) now
  passes InvariantCulture so non-US locales don't mis-parse string values.
- HM-020: CentralHealthAggregator.MarkHeartbeat anchors LastHeartbeatAt to
  max(receivedAt, now) on offline→online so a stale receivedAt can't
  leave a recovered site one tick from re-going-offline.

3 new tests added (AuditLog UTC converter, AuditFilterBar/EventLogs/
NotificationReport-touching CentralUI tests already cover Apply paths,
heartbeat offline→online). Build clean; ConfigurationDatabase 236,
Commons 330, HealthMonitoring 71, SiteRuntime 301, SiteEventLogging 50,
CentralUI 50 — all green. README regenerated: 104 open (was 112).
This commit is contained in:
Joseph Doherty
2026-05-28 06:36:44 -04:00
parent 487859bff0
commit 344379a40a
20 changed files with 382 additions and 55 deletions
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Configurations;
@@ -132,6 +133,77 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.False(property.IsUnicode() ?? true);
}
[Fact]
public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
{
// Insert an AuditEvent with an Unspecified-Kind DateTime, then re-read
// it in a fresh context. The UtcConverter on the OccurredAtUtc /
// IngestedAtUtc columns must re-tag the round-tripped value as
// DateTimeKind.Utc. Without the converter the SQLite (and on production
// SQL Server, datetime2) provider would yield Kind=Unspecified — see
// ConfigurationDatabase-018/020 and Commons-019.
var unspecifiedOccurred = new DateTime(2026, 5, 28, 10, 30, 0, DateTimeKind.Unspecified);
var unspecifiedIngested = new DateTime(2026, 5, 28, 10, 31, 0, DateTimeKind.Unspecified);
var eventId = Guid.NewGuid();
var siteId = "test-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var evt = new AuditEvent
{
EventId = eventId,
// The AuditEvent record's init-setter (Commons-019 resolution)
// re-tags Unspecified values as Utc on assignment, so the value EF
// ultimately writes already has Kind=Utc. The converter's job is
// to keep the Kind tag on the READ path, which the assertions
// below exercise.
OccurredAtUtc = unspecifiedOccurred,
IngestedAtUtc = unspecifiedIngested,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
};
_context.Set<AuditEvent>().Add(evt);
await _context.SaveChangesAsync();
// Detach the tracked entity and re-read in a fresh query so we exercise
// the actual hydrate path, not the change-tracker cache.
_context.ChangeTracker.Clear();
var loaded = await _context.Set<AuditEvent>()
.AsNoTracking()
.Where(e => e.SourceSiteId == siteId)
.SingleAsync();
Assert.Equal(DateTimeKind.Utc, loaded.OccurredAtUtc.Kind);
Assert.NotNull(loaded.IngestedAtUtc);
Assert.Equal(DateTimeKind.Utc, loaded.IngestedAtUtc!.Value.Kind);
// The timestamp ticks must round-trip unchanged — the converter only
// touches the Kind flag, not the wall-clock value.
Assert.Equal(unspecifiedOccurred.Ticks, loaded.OccurredAtUtc.Ticks);
Assert.Equal(unspecifiedIngested.Ticks, loaded.IngestedAtUtc.Value.Ticks);
}
[Fact]
public void Configure_OccurredAtUtcAndIngestedAtUtc_HaveUtcValueConverters()
{
// Model-metadata cross-check on the converter wiring — guards against a
// future config refactor accidentally removing the HasConversion calls.
// The converter type itself is internal to the configuration, so we
// just assert SOME converter is present on each *Utc DateTime column.
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
Assert.NotNull(entity);
var occurredAt = entity!.FindProperty(nameof(AuditEvent.OccurredAtUtc));
Assert.NotNull(occurredAt);
Assert.NotNull(occurredAt!.GetValueConverter());
var ingestedAt = entity.FindProperty(nameof(AuditEvent.IngestedAtUtc));
Assert.NotNull(ingestedAt);
Assert.NotNull(ingestedAt!.GetValueConverter());
}
[Fact]
public void Configure_FilteredIndexes_HaveExpectedFilters()
{