Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.Extensions.Options;
using RunCoach.Api.Infrastructure.Idempotency;
using RunCoach.Api.Modules.Coaching;
using RunCoach.Api.Modules.Coaching.Prompts;
Expand Down Expand Up @@ -43,21 +42,18 @@ public static IServiceCollection AddApplicationModules(
// Coaching module — scoped services (per-request lifetime).
services.AddScoped<ICoachingLlm, ClaudeCoachingLlm>();

// ContextAssembler exposes two public constructors — a 3-arg legacy
// form for plan-only tests and a 6-arg onboarding-aware form. The
// default container's "most-resolvable parameters" heuristic picked
// the 3-arg constructor at runtime in this project, leaving
// `_sanitizer` null and breaking `ComposeForOnboardingAsync` with an
// InvalidOperationException on every onboarding turn. Explicit
// factory registration locks the production path to the 6-arg
// constructor regardless of constructor-selection quirks.
services.AddScoped<IContextAssembler>(sp => new ContextAssembler(
sp.GetRequiredService<IPromptStore>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<IPromptSanitizer>(),
sp.GetRequiredService<IHostEnvironment>(),
sp.GetRequiredService<IOptions<PromptStoreSettings>>(),
sp.GetRequiredService<ILogger<ContextAssembler>>()));
// ContextAssembler's 3-arg legacy constructor is `internal` (test-only via
// InternalsVisibleTo); the public surface is the single 6-arg
// onboarding-aware constructor, so the container unambiguously selects it
// from a plain implementation-type registration. This MUST stay a type
// registration, not an `sp => new ContextAssembler(...)` lambda factory:
// Wolverine 6 handler codegen (ServiceLocationPolicy.NotAllowed, DEC-071)
// cannot statically construct an opaque Scoped lambda factory, falls back
// to service location, and rejects it — breaking the OnboardingTurnHandler
// chain with an HTTP 500 on every onboarding turn. The no-opaque-factory
// rule is guarded by WolverineCodegenCompositionTests; correct 6-arg
// constructor selection is guarded by ContextAssemblerDiResolutionTests.
services.AddScoped<IContextAssembler, ContextAssembler>();

// Prompt-injection sanitizer (Slice 1 § Unit 6 / DEC-059 / R-068).
// Stateless layered sanitizer — singleton-safe; the pattern catalog
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using RunCoach.Api.Infrastructure;

namespace RunCoach.Api.Tests.Infrastructure;

/// <summary>
/// Regression guard (DEC-071) for Wolverine 6 message-handler code generation.
/// Wolverine 6 defaults to <c>ServiceLocationPolicy.NotAllowed</c>: when a handler
/// depends on a service registered as an "opaque" lambda factory with a Scoped or
/// Transient lifetime, Wolverine cannot construct it statically, falls back to
/// service location, and throws <c>InvalidServiceLocationException</c> while
/// compiling the handler chain — surfacing as an HTTP 500 on the affected endpoint
/// (the <c>OnboardingTurnHandler</c> / POST <c>/api/v1/onboarding/turns</c> chain).
///
/// This blind spot existed because no green test compiled the production handler
/// graph: the <c>*DiResolutionTests</c> only assert <c>GetRequiredService</c>
/// succeeds (which an opaque lambda factory satisfies), the onboarding handler unit
/// tests call the static <c>Handle</c> method directly with mocks (bypassing
/// Wolverine codegen), and the HTTP/bus integration host swaps
/// <c>IPlanGenerationService</c> for a stub that severs the failing dependency edge.
///
/// This test inspects the production registrations directly (no host boot, no DB,
/// no LLM) and fails if any module service uses an opaque Scoped/Transient lambda
/// factory, which would re-break handler codegen. If a future registration genuinely
/// needs a factory, allow-list the service via
/// <c>opts.CodeGeneration.AlwaysUseServiceLocationFor&lt;T&gt;()</c> in the Wolverine
/// configuration AND add it to <see cref="KnownServiceLocationAllowList"/> in the
/// same change.
/// </summary>
public sealed class WolverineCodegenCompositionTests
{
/// <summary>
/// Service types intentionally permitted to use Wolverine service location via
/// <c>CodeGeneration.AlwaysUseServiceLocationFor&lt;T&gt;()</c>. Empty by design —
/// the module convention is concrete implementation-type registrations so handler
/// codegen stays static.
/// </summary>
private static readonly HashSet<Type> KnownServiceLocationAllowList = new();

Check warning on line 40 in backend/tests/RunCoach.Api.Tests/Infrastructure/WolverineCodegenCompositionTests.cs

View check run for this annotation

SonarQubeCloud / [runcoach-backend] SonarCloud Code Analysis

Collection initialization can be simplified

See more on https://sonarcloud.io/project/issues?id=runcoach-backend&issues=AZ56ZDUHLoLvvXVavRG_&open=AZ56ZDUHLoLvvXVavRG_&pullRequest=129

[Fact]
public void AddApplicationModules_RegistersNoOpaqueScopedOrTransientLambdaFactories()
{
// Arrange: build the exact production registration graph Program.cs assembles
// (see Program.cs `builder.Services.AddApplicationModules(builder.Configuration)`).
var services = new ServiceCollection();
services.AddApplicationModules(new ConfigurationBuilder().Build());

// Act: find Scoped/Transient registrations that use an opaque implementation
// factory (lambda) rather than a concrete implementation type. Singletons are
// exempt — Wolverine resolves them as cached instances, not via per-handler
// constructor codegen.
var opaqueLambdaFactories = services
.Where(descriptor =>
descriptor.ImplementationFactory is not null
&& descriptor.Lifetime != ServiceLifetime.Singleton
&& !KnownServiceLocationAllowList.Contains(descriptor.ServiceType))
.Select(descriptor => descriptor.ServiceType.FullName)
.ToList();

// Assert.
opaqueLambdaFactories.Should()
.BeEmpty(
because: "Wolverine 6 handler codegen (ServiceLocationPolicy.NotAllowed) cannot " +
"statically construct an opaque Scoped/Transient lambda factory and throws " +
"InvalidServiceLocationException while compiling any handler that depends on " +
"it (DEC-071). Register a concrete implementation type, or allow-list the " +
"service via CodeGeneration.AlwaysUseServiceLocationFor<T>().");
}
}
Loading