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
41 changes: 32 additions & 9 deletions TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,19 +375,42 @@ private static ImmutableArray<MockTypeModel> BuildModelWithTransitiveDependencie
}

/// <summary>
/// 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 <paramref name="type"/>'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 <c>internal</c> to avoid CS9338 / CS0051 — including the
/// case where a public generic interface is closed over an internal type argument
/// (e.g. <c>ILogger&lt;InternalClass&gt;</c>). See issues #5426 and #5453.
/// </summary>
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 ────────────────────
Expand Down
69 changes: 69 additions & 0 deletions TUnit.Mocks.Tests/Issue5453Tests.cs
Original file line number Diff line number Diff line change
@@ -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<InternalClass>) 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<T>
{
void Process(T value);
T Get();
Task<T> GetAsync();
}

[Test]
public async Task Can_Mock_Public_Generic_Interface_With_Internal_Type_Argument()
{
var mock = Mock.Of<IPublicGenericProcessor<InternalConsumer>>(MockBehavior.Loose);
var instance = new InternalConsumer();

mock.Process(Arg.Any<InternalConsumer>()).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<IPublicGenericProcessor<InternalPartialConsumer>>(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<T> 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<IPublicGenericProcessor<InternalConsumer>>(MockBehavior.Loose);
var instance = new InternalConsumer();

mock.GetAsync().Returns(instance);

var result = await mock.Object.GetAsync();

await Assert.That(result).IsSameReferenceAs(instance);
}
}
Loading