From cd8a283fe886bd777dadadb9d590c77af58a4da4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:19:24 +0100 Subject: [PATCH] fix(mocks): respect generic type argument accessibility (#5453) Extends the effective-accessibility check from #5426 to also walk generic type arguments and array element types. Previously, mocking a public generic interface closed over an internal type argument (e.g. Mock.Of>()) emitted a public wrapper whose base signature leaked the internal type, producing CS9338/CS0051. IsEffectivelyPublic now switches on the type kind and recurses into TypeArguments/ElementType, collapsing the previous two helpers into one. --- .../Discovery/MockTypeDiscovery.cs | 41 ++++++++--- TUnit.Mocks.Tests/Issue5453Tests.cs | 69 +++++++++++++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 TUnit.Mocks.Tests/Issue5453Tests.cs diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs index ff317f3f00..17bdaf635e 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs @@ -375,19 +375,42 @@ private static ImmutableArray BuildModelWithTransitiveDependencie } /// - /// Returns true if the type's effective accessibility is public — i.e., the type itself - /// and all its containing types are declared public. Generated wrapper/extension classes - /// for types that are not effectively public must themselves be internal to avoid - /// CS9338 / CS0051 inconsistent accessibility errors. (See issue #5426.) + /// True if every part of 's signature is publicly accessible: the + /// type itself, every enclosing type, and (recursively) every generic type argument and + /// array element. Mock wrappers built for types that are not effectively public must + /// themselves be emitted as internal to avoid CS9338 / CS0051 — including the + /// case where a public generic interface is closed over an internal type argument + /// (e.g. ILogger<InternalClass>). See issues #5426 and #5453. /// - private static bool IsEffectivelyPublic(INamedTypeSymbol type) + private static bool IsEffectivelyPublic(ITypeSymbol type) { - for (INamedTypeSymbol? t = type; t is not null; t = t.ContainingType) + switch (type) { - if (t.DeclaredAccessibility != Accessibility.Public) - return false; + case ITypeParameterSymbol: + // Bound at use site by the consumer; not the discovery point's concern. + return true; + + case IArrayTypeSymbol array: + return IsEffectivelyPublic(array.ElementType); + + case INamedTypeSymbol named: + for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType) + { + if (t.DeclaredAccessibility != Accessibility.Public) + return false; + } + foreach (var typeArg in named.TypeArguments) + { + if (!IsEffectivelyPublic(typeArg)) + return false; + } + return true; + + default: + // Pointers, function pointers, dynamic, error types — not expected in + // mockable signatures. + return true; } - return true; } // ─── IFoo.Mock() static extension discovery ──────────────────── diff --git a/TUnit.Mocks.Tests/Issue5453Tests.cs b/TUnit.Mocks.Tests/Issue5453Tests.cs new file mode 100644 index 0000000000..ae11b39c3b --- /dev/null +++ b/TUnit.Mocks.Tests/Issue5453Tests.cs @@ -0,0 +1,69 @@ +namespace TUnit.Mocks.Tests; + +// Compile-time regression test for https://github.com/thomhurst/TUnit/issues/5453 +// CS9338 / CS0051: a public generic interface closed over an internal type argument +// (e.g. ILogger) used to emit a `public` mock wrapper, leaking the +// internal type through the wrapper's base signature. +public class Issue5453Tests +{ + internal sealed class InternalConsumer + { + } + + // `partial` doesn't affect DeclaredAccessibility today, but the original report's class + // was `internal sealed partial` (for [LoggerMessage]) — kept as a distinct test so a + // future regression in partial-symbol handling is attributable. + internal sealed partial class InternalPartialConsumer + { + } + + public interface IPublicGenericProcessor + { + void Process(T value); + T Get(); + Task GetAsync(); + } + + [Test] + public async Task Can_Mock_Public_Generic_Interface_With_Internal_Type_Argument() + { + var mock = Mock.Of>(MockBehavior.Loose); + var instance = new InternalConsumer(); + + mock.Process(Arg.Any()).Returns(); + mock.Get().Returns(instance); + + mock.Object.Process(instance); + var result = mock.Object.Get(); + + await Assert.That(result).IsSameReferenceAs(instance); + } + + [Test] + public async Task Can_Mock_Public_Generic_Interface_With_Internal_Partial_Type_Argument() + { + var mock = Mock.Of>(MockBehavior.Loose); + var instance = new InternalPartialConsumer(); + + mock.Get().Returns(instance); + + var result = mock.Object.Get(); + + await Assert.That(result).IsSameReferenceAs(instance); + } + + // Async path goes through a different generated extension overload (Task Returns) than + // the sync Get() above; both must be emitted with the correct visibility. + [Test] + public async Task Can_Configure_Method_Overload_With_Internal_Type_Argument() + { + var mock = Mock.Of>(MockBehavior.Loose); + var instance = new InternalConsumer(); + + mock.GetAsync().Returns(instance); + + var result = await mock.Object.GetAsync(); + + await Assert.That(result).IsSameReferenceAs(instance); + } +}