diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index e2a7cbc..afe5b03 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -777,9 +777,89 @@ public sealed class Account : IDisposable /// Returns a snapshot of all reply subjects currently in the reverse response map. public IReadOnlyList GetReverseResponseMapKeys() => [.. _reverseResponseMap.Keys]; + /// + /// Checks whether any local subscription in this account's SubList would shadow + /// (intercept) messages on the given service import subject, preventing the import + /// from receiving them. + /// Go reference: accounts.go serviceImportShadowed (~line 2015). + /// + public bool ServiceImportShadowed(string importSubject) + { + var matchResult = SubList.Match(importSubject); + return matchResult.PlainSubs.Length > 0 || matchResult.QueueSubs.Length > 0; + } + + /// + /// Returns all service import subjects registered on this account that are currently + /// shadowed by a local subscription in the SubList. + /// Go reference: accounts.go serviceImportShadowed (~line 2015). + /// + public IReadOnlyList GetShadowedServiceImports() + { + var shadowed = new List(); + foreach (var subject in Imports.Services.Keys) + { + if (ServiceImportShadowed(subject)) + shadowed.Add(subject); + } + return shadowed; + } + + /// + /// Returns when at least one registered service import subject + /// is shadowed by a local subscription. + /// Go reference: accounts.go serviceImportShadowed (~line 2015). + /// + public bool HasShadowedImports + { + get + { + foreach (var subject in Imports.Services.Keys) + { + if (ServiceImportShadowed(subject)) + return true; + } + return false; + } + } + + /// + /// Returns a detailed for the given import subject, + /// including the list of local subscription subjects that shadow it. + /// Go reference: accounts.go serviceImportShadowed (~line 2015). + /// + public ShadowCheckResult CheckServiceImportShadowing(string importSubject) + { + var matchResult = SubList.Match(importSubject); + var shadowingSubs = new List(); + + foreach (var sub in matchResult.PlainSubs) + shadowingSubs.Add(sub.Subject); + + foreach (var queueGroup in matchResult.QueueSubs) + foreach (var sub in queueGroup) + shadowingSubs.Add(sub.Subject); + + bool isShadowed = shadowingSubs.Count > 0; + return new ShadowCheckResult(isShadowed, importSubject, shadowingSubs); + } + public void Dispose() => SubList.Dispose(); } +/// +/// Result of describing whether a service import +/// subject is intercepted by a local subscription and which subscriptions are responsible. +/// Go reference: accounts.go serviceImportShadowed (~line 2015). +/// +/// Whether any local subscription shadows the import subject. +/// The service import subject that was checked. +/// The subjects of local subscriptions that match the import subject. +public sealed record ShadowCheckResult( + bool IsShadowed, + string ImportSubject, + IReadOnlyList ShadowingSubscriptions); + /// /// Carries the result of a call. /// diff --git a/tests/NATS.Server.Tests/Auth/ImportShadowingTests.cs b/tests/NATS.Server.Tests/Auth/ImportShadowingTests.cs new file mode 100644 index 0000000..b64d992 --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/ImportShadowingTests.cs @@ -0,0 +1,170 @@ +// Tests for service import shadowing detection. +// Go reference: accounts.go serviceImportShadowed (~line 2015). + +using NATS.Server.Auth; +using NATS.Server.Imports; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Auth; + +public class ImportShadowingTests +{ + private static Account CreateAccount(string name) => new(name); + + private static Subscription MakeSub(string subject) => + new() { Subject = subject, Sid = subject }; + + /// + /// Adds a service import entry directly to the account's import map (bypassing + /// export/cycle checks) so that shadowing tests can exercise the import map iteration. + /// + private static void RegisterServiceImport(Account account, string fromSubject) + { + var dest = CreateAccount("Dest"); + var si = new ServiceImport + { + DestinationAccount = dest, + From = fromSubject, + To = fromSubject, + }; + account.Imports.AddServiceImport(si); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void ServiceImportShadowed_NoLocalSubs_ReturnsFalse() + { + var account = CreateAccount("A"); + + var result = account.ServiceImportShadowed("orders.create"); + + result.ShouldBeFalse(); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void ServiceImportShadowed_ExactMatch_ReturnsTrue() + { + var account = CreateAccount("A"); + account.SubList.Insert(MakeSub("orders.create")); + + var result = account.ServiceImportShadowed("orders.create"); + + result.ShouldBeTrue(); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void ServiceImportShadowed_WildcardMatch_ReturnsTrue() + { + // Local subscription "orders.*" shadows import on "orders.create" + var account = CreateAccount("A"); + account.SubList.Insert(MakeSub("orders.*")); + + var result = account.ServiceImportShadowed("orders.create"); + + result.ShouldBeTrue(); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void ServiceImportShadowed_GtWildcard_ReturnsTrue() + { + // Local subscription "orders.>" shadows import on "orders.create.new" + var account = CreateAccount("A"); + account.SubList.Insert(MakeSub("orders.>")); + + var result = account.ServiceImportShadowed("orders.create.new"); + + result.ShouldBeTrue(); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void ServiceImportShadowed_NoMatch_ReturnsFalse() + { + // Local subscription "users.*" does NOT shadow import on "orders.create" + var account = CreateAccount("A"); + account.SubList.Insert(MakeSub("users.*")); + + var result = account.ServiceImportShadowed("orders.create"); + + result.ShouldBeFalse(); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void GetShadowedServiceImports_ReturnsOnlyShadowed() + { + var account = CreateAccount("A"); + + // Register two service imports + RegisterServiceImport(account, "orders.create"); + RegisterServiceImport(account, "users.profile"); + + // Only add a local sub that shadows "orders.create" + account.SubList.Insert(MakeSub("orders.create")); + + var shadowed = account.GetShadowedServiceImports(); + + shadowed.Count.ShouldBe(1); + shadowed.ShouldContain("orders.create"); + shadowed.ShouldNotContain("users.profile"); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void HasShadowedImports_True_WhenShadowed() + { + var account = CreateAccount("A"); + + RegisterServiceImport(account, "orders.create"); + account.SubList.Insert(MakeSub("orders.create")); + + account.HasShadowedImports.ShouldBeTrue(); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void HasShadowedImports_False_WhenNone() + { + var account = CreateAccount("A"); + + RegisterServiceImport(account, "orders.create"); + // No local subs — nothing shadows the import + + account.HasShadowedImports.ShouldBeFalse(); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void CheckServiceImportShadowing_ReturnsShadowingSubscriptions() + { + var account = CreateAccount("A"); + account.SubList.Insert(MakeSub("orders.*")); + account.SubList.Insert(MakeSub("orders.>")); + + var result = account.CheckServiceImportShadowing("orders.create"); + + result.IsShadowed.ShouldBeTrue(); + result.ImportSubject.ShouldBe("orders.create"); + result.ShadowingSubscriptions.Count.ShouldBeGreaterThan(0); + // Both wildcard subs match "orders.create" + result.ShadowingSubscriptions.ShouldContain("orders.*"); + result.ShadowingSubscriptions.ShouldContain("orders.>"); + } + + // Go reference: accounts.go serviceImportShadowed (~line 2015). + [Fact] + public void CheckServiceImportShadowing_NotShadowed() + { + var account = CreateAccount("A"); + account.SubList.Insert(MakeSub("users.*")); + + var result = account.CheckServiceImportShadowing("orders.create"); + + result.IsShadowed.ShouldBeFalse(); + result.ImportSubject.ShouldBe("orders.create"); + result.ShadowingSubscriptions.Count.ShouldBe(0); + } +}