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 Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.ConfigurationDatabase.Configurations;
@@ -11,12 +12,38 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
/// </summary>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
{
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
// (a column hydrated from the database always surfaces as
// DateTimeKind.Unspecified). Without a converter, downstream code that
// calls .ToLocalTime() / .ToUniversalTime() on an OccurredAtUtc value would
// silently re-interpret it as local time. These converters force the Kind
// back to Utc on read, and re-stamp Utc on write so a producer that hands
// EF a DateTime literal with Kind=Unspecified still lands a UTC-tagged
// value in the model cache (CLAUDE.md: "All timestamps are UTC throughout
// the system."). Applied to every DateTime property whose name ends in
// `Utc`; DateTimeOffset columns already carry their own offset and are NOT
// routed through these converters.
private static readonly ValueConverter<DateTime, DateTime> UtcConverter = new(
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
private static readonly ValueConverter<DateTime?, DateTime?> NullableUtcConverter = new(
v => v.HasValue
? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc))
: null,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<AuditEvent> builder)
{
builder.ToTable("AuditLog");
// Enforce DateTimeKind.Utc on every *Utc-suffixed DateTime column. See
// the UtcConverter remarks above for the rationale.
builder.Property(e => e.OccurredAtUtc).HasConversion(UtcConverter);
builder.Property(e => e.IngestedAtUtc).HasConversion(NullableUtcConverter);
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
@@ -383,7 +383,14 @@ VALUES
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(reader.GetDateTime(0));
// SQL Server's datetime2 surfaces as DateTimeKind.Unspecified
// through ADO.NET (the column type carries no offset/kind).
// Boundary values are stored in UTC, so re-tag the kind here —
// matches the explicit defence in
// AuditLogPartitionMaintenance.GetMaxBoundaryAsync and prevents
// downstream .ToLocalTime()/.ToUniversalTime() conversions
// from silently treating the value as local time.
results.Add(DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc));
}
}
finally