Repository navigation
[BUG] Major-upgrade age check reads the wall clock instead of an injected TimeProvider #289
Description
Summary
UpdateDetectionService decides whether a major upgrade is old enough to recommend by comparing publication timestamps with DateTimeOffset.UtcNow. The result of an otherwise pure evaluation therefore depends on the ambient clock, and the tests that cover the age gate build their tag timestamps from the real clock as well. AGENTS.md and docs/UNIT_TESTS.md (Time in tests) require time-dependent service logic to take an injected TimeProvider and tests never to depend on the real clock; the update detection must read time through an injected TimeProvider while staying pure and I/O-free.
Area
docs/areas/update-detection.md(added in [Docs] Describe the current behavior by area #263), Major-upgrade policy.- Security area: None.
Current behavior
src/DockerUpdateGuard/Images/UpdateDetectionService.cs:593-635 (IsMajorUpgradeRecommendable):
if (scanningOptions.MajorUpgradeMinimumAgeDays <= 0)
{
return true;
}
var establishedBeforeUtc = DateTimeOffset.UtcNow.AddDays(-scanningOptions.MajorUpgradeMinimumAgeDays);- The constructor takes only
IOptionsMonitor<DockerUpdateGuardOptions>(UpdateDetectionService.cs:41-44); the service is registered as scoped (src/DockerUpdateGuard/ServiceCollectionExtensions.cs:117). NoTimeProvideris registered anywhere in the host. - Tests construct the service without a clock (
src/Tests/DockerUpdateGuard.Tests/UpdateDetectionServiceTests.cs:1165,RuntimeContainerScanOrchestratorTests.cs:131,:288) and compute tag publication times relative to the real clock (UpdateDetectionServiceTests.cs:1175:return DateTimeOffset.UtcNow.AddDays(offsetDays);), e.g.UpdateDetectionServiceSemverDefersRecentMajorUpgradeandUpdateDetectionServiceSemverRecommendsEstablishedMajorUpgrade.
Failure scenario: a test whose next-major release is placed exactly at the threshold (-30 days with MajorUpgradeMinimumAgeDays = 30) passes or fails depending on the milliseconds between building the test data and the evaluation; boundary cases (== threshold) cannot be tested deterministically at all. In production, the same tag list evaluated twice can yield different results without any input change, which the area document has to describe as "measured against the wall clock at evaluation time".
Expected behavior
UpdateDetectionServicereceives aTimeProviderthrough its constructor and computes the threshold astimeProvider.GetUtcNow().AddDays(-MajorUpgradeMinimumAgeDays); it never readsDateTimeOffset.UtcNow/DateTime.UtcNow.- The host registers
TimeProvider.Systemonce as a singleton (TryAddSingleton(TimeProvider.System)) in the composition root, so further services can use the same registration. - Evaluation stays pure and I/O-free (guarantee in
.squad/project.md): reading the injected clock is the only time input; no other behavior of the major-upgrade policy changes (minimum release count,<major>.0.0first release, dormant current line, deferred-upgrade texts). - Boundary rule made explicit and tested: a first release published exactly
MajorUpgradeMinimumAgeDaysdays before "now" counts as old enough (the current comparisonfirstMajorReleaseAtUtc > establishedBeforeUtc→ defer only when strictly newer); the same for the current line's latest release (<=).
Acceptance criteria
-
UpdateDetectionServicehas a constructor parameterTimeProvider timeProviderstored in a_timeProviderreadonly field;DateTimeOffset.UtcNowno longer appears in the file. -
TimeProvider.Systemis registered as a singleton insrc/DockerUpdateGuard/ServiceCollectionExtensions.cs; the app resolvesIUpdateDetectionServiceat startup without errors (existing composition tests still pass). - All tests that construct
UpdateDetectionServicepass a substitutedTimeProvider(NSubstitute,GetUtcNow()returns a fixed instant) and build tag timestamps from that fixed instant;UpdateDetectionServiceTests.csno longer usesDateTimeOffset.UtcNow. - New tests:
UpdateDetectionServiceSemverMajorUpgradeExactlyAtMinimumAgeIsRecommended,UpdateDetectionServiceSemverMajorUpgradeOneSecondBelowMinimumAgeIsDeferred,UpdateDetectionServiceSemverDeferredMajorUpgradeIsRecommendedAfterClockAdvances(same tag list, two differentGetUtcNowvalues). - The area document
docs/areas/update-detection.mddescribes the new behavior (age measured against the injected clock, inclusive boundary) and its Known gaps entry for this issue is removed.
Implementation notes
- Affected:
src/DockerUpdateGuard/Images/UpdateDetectionService.cs(constructor, field region,IsMajorUpgradeRecommendable),src/DockerUpdateGuard/ServiceCollectionExtensions.cs,src/Tests/DockerUpdateGuard.Tests/UpdateDetectionServiceTests.cs,RuntimeContainerScanOrchestratorTests.cs(constructor calls). - Alternative considered: pass the evaluation time as a parameter of
IUpdateDetectionService.Evaluate. That makes the function even purer but changes the interface for every caller; not recommended for this fix. InjectingTimeProviderkeeps the interface stable and matchesdocs/UNIT_TESTS.md. TimeProvider.Systemis thread-safe; a singleton registration is fine for the scoped service.- Other services still read
DateTimeOffset.UtcNow; perdocs/UNIT_TESTS.mdthey move toTimeProviderwhen they are changed — do not widen this fix. - Do not change: the configuration keys
DockerUpdateGuard:Scanning:MajorUpgradeMinimumAgeDays/MajorUpgradeMinimumReleaseCount(no README or appsettings change),VersionTagResolutionHelper.
Related
- [Docs] Describe the current behavior by area #263 (area documents).
Generated by Claude Code
Activity