1.8.0 hardening review followups — fix H4 clock skew + backfill H7 test gaps
Summary
Post-merge self-audit of the 1.8.0 Upgrade Hardening initiative (8 PRs #354-#361) surfaced one real cosmetic bug and three test-coverage gaps that would not have caught a future regression. This PR addresses both in one go for 1.8.1.
🔴 Bug fix — H4 clock-skew negative elapsed
File: src/Squid.Core/Services/Machines/Upgrade/MachineUpgradeService.cs
BuildContentionMessageAsync was clamping remaining with Math.Max(0, ...) but the symmetric guard was missing for elapsed:
- $"... ~{elapsed.TotalSeconds:F0}s ago ... ~{Math.Max(0, remaining.TotalSeconds):F0}s remaining)"
+ $"... ~{Math.Max(0, elapsed.TotalSeconds):F0}s ago ... ~{Math.Max(0, remaining.TotalSeconds):F0}s remaining)"
Failure scenario: Two server pods disagree on UTC by seconds-to-minutes (NTP slew / fresh-pod startup before time-sync converges). Pod B reading metadata pod A wrote sees DispatchedAt in B's future → elapsed turns negative → operator sees the cosmetically wrong "~-3599s ago" in the contention message.
Cosmetic only — dispatch itself worked correctly. But operator UX regression. New test UpgradeAsync_Contention_DispatchedAtInFuture_ClockSkewGuarded_NoNegativeSeconds simulates 5-min skew and pins the guard.
⚠️ H7 test gaps backfilled
Gap 1 — CapabilityKeys.Role.* constants not pinned
Shell / Bin / Privilege namespaces each had a Theory-based pinning test in CapabilityKeysConstantsTests. The Role namespace shipped in 1.8.0 without one. A rename Role.IIS → Role.Iis OR a downgrade "role:iis" → "role:IIS" would have been a silent contract break with the agent's lowercase emission.
→ Added Theory pinning the 4-tuple (IIS / Docker / Nginx / Systemd).
Gap 2 — TentacleHealthCheckStrategy cache test missing installedRoles in metadata
The wire chain is: agent emits metadata["installedRoles"] → server reads into MachineRuntimeCapabilities.InstalledRoles → MachineCapabilitySet projects role:* slots → CapabilityValidator catches missing IIS at plan-time. If BuildCapabilitiesFromResponse's read of the installedRoles key broke, the whole chain silently optimistic-allows.
→ Added CheckHealth_InstalledRolesInMetadata_PopulatedIntoCache test verifying the read-side wire contract.
Gap 3 — MachineRuntimeCapabilitiesPersistence JSON shape didn't pin installedRoles
The DTO had the field but the serialisation/deserialisation tests didn't assert it appears in the canonical JSON. PascalCase regression OR [JsonPropertyName] drift would have caused cross-version hydration to silently skip roles.
→ Added installedRoles assertion to the existing serialisation + deserialisation tests. New Deserialise_PreH7Blob_WithoutInstalledRoles_BackwardCompatible test verifies 1.8.0 blobs (no installedRoles field) still deserialise with null on H7-aware servers — the in-place upgrade path from 1.8.0 to 1.8.1.
Test plan
-
+1 H4 clock-skew guard regression test -
+4 H7 Role constant pinning (Theory rows) -
+1 H7 cache-population read-side test with installedRoles -
+1 H7 backward-compat deserialisation test -
Modified 2 existing serialisation tests to pin installedRoles -
5603/5603 unit tests green (+7 net new vs 5596 baseline post-H8) -
Operator-side: no operator-visible behaviour change beyond cosmetic contention-message fix (1.8.0 dispatches succeeded already)
Acceptance criteria
After 1.8.1 ships, this 8-PR initiative chain has BOTH:
- Behavioural correctness pinned by H1-H8 + this fix (the operator's 1.7.x failure chain is structurally impossible to reproduce)
-
Contract stability pinned by the new test additions (a future refactor can't silently break the agent
↔️ server wire format without a test-time-visible decision)
What's NOT in this PR
- Other minor test gaps mentioned in audit (e.g.
LoadAllAsyncmixed corrupted/valid rows,BuildMetadataDictionarykeys symmetry) — deferred to a separate audit-coverage PR if needed. The three highest-value gaps (wire-shape stability for the new H7installedRolesfield) are the ones that would silently regress the operator's IIS deploy plan-time validation if not pinned.