Skip to content

[BUG] Major-upgrade age check reads the wall clock instead of an injected TimeProvider #289

Open

Description

@LarsLaskowski

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

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). No TimeProvider is 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. UpdateDetectionServiceSemverDefersRecentMajorUpgrade and UpdateDetectionServiceSemverRecommendsEstablishedMajorUpgrade.

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

  • UpdateDetectionService receives a TimeProvider through its constructor and computes the threshold as timeProvider.GetUtcNow().AddDays(-MajorUpgradeMinimumAgeDays); it never reads DateTimeOffset.UtcNow / DateTime.UtcNow.
  • The host registers TimeProvider.System once 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.0 first release, dormant current line, deferred-upgrade texts).
  • Boundary rule made explicit and tested: a first release published exactly MajorUpgradeMinimumAgeDays days before "now" counts as old enough (the current comparison firstMajorReleaseAtUtc > establishedBeforeUtc → defer only when strictly newer); the same for the current line's latest release (<=).

Acceptance criteria

  • UpdateDetectionService has a constructor parameter TimeProvider timeProvider stored in a _timeProvider readonly field; DateTimeOffset.UtcNow no longer appears in the file.
  • TimeProvider.System is registered as a singleton in src/DockerUpdateGuard/ServiceCollectionExtensions.cs; the app resolves IUpdateDetectionService at startup without errors (existing composition tests still pass).
  • All tests that construct UpdateDetectionService pass a substituted TimeProvider (NSubstitute, GetUtcNow() returns a fixed instant) and build tag timestamps from that fixed instant; UpdateDetectionServiceTests.cs no longer uses DateTimeOffset.UtcNow.
  • New tests: UpdateDetectionServiceSemverMajorUpgradeExactlyAtMinimumAgeIsRecommended, UpdateDetectionServiceSemverMajorUpgradeOneSecondBelowMinimumAgeIsDeferred, UpdateDetectionServiceSemverDeferredMajorUpgradeIsRecommendedAfterClockAdvances (same tag list, two different GetUtcNow values).
  • The area document docs/areas/update-detection.md describes 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. Injecting TimeProvider keeps the interface stable and matches docs/UNIT_TESTS.md.
  • TimeProvider.System is thread-safe; a singleton registration is fine for the scoped service.
  • Other services still read DateTimeOffset.UtcNow; per docs/UNIT_TESTS.md they move to TimeProvider when 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


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions