diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index e48537d..00bd613 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -212,6 +212,65 @@ public sealed class Account : IDisposable /// Records a service request latency sample on this account's tracker. public void RecordServiceLatency(double latencyMs) => LatencyTracker.RecordLatency(latencyMs); + /// + /// Returns all service exports registered on this account. + /// Go reference: accounts.go exports.services iteration. + /// + public IReadOnlyList GetAllServiceExports() + { + var result = new List(Exports.Services.Count); + foreach (var (subject, se) in Exports.Services) + result.Add(ToServiceExportInfo(subject, se)); + return result; + } + + /// + /// Returns the service export for an exact subject match, or null if not found. + /// Does not apply wildcard matching. + /// Go reference: accounts.go getServiceExport (direct map lookup only). + /// + public ServiceExportInfo? GetExactServiceExport(string subject) + { + if (Exports.Services.TryGetValue(subject, out var se)) + return ToServiceExportInfo(subject, se); + return null; + } + + /// + /// Finds a service export whose subject pattern matches the given subject using + /// wildcard matching. Returns null when no export pattern matches. + /// Go reference: accounts.go getWildcardServiceExport (line 2849). + /// + public ServiceExportInfo? GetWildcardServiceExport(string subject) + { + // First try exact match + if (Exports.Services.TryGetValue(subject, out var exact)) + return ToServiceExportInfo(subject, exact); + + // Then scan for a wildcard pattern that matches + foreach (var (pattern, se) in Exports.Services) + { + if (SubjectMatch.MatchLiteral(subject, pattern)) + return ToServiceExportInfo(pattern, se); + } + return null; + } + + /// + /// Returns true when any service export (exact or wildcard) matches the given subject. + /// Go reference: accounts.go getServiceExport. + /// + public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null; + + private static ServiceExportInfo ToServiceExportInfo(string subject, ServiceExport se) + { + IReadOnlyList approved = se.Auth.ApprovedAccounts != null + ? [.. se.Auth.ApprovedAccounts] + : []; + bool isWildcard = subject.Contains('*') || subject.Contains('>'); + return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard); + } + public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable? approved) { var auth = new ExportAuth diff --git a/src/NATS.Server/Imports/ImportMap.cs b/src/NATS.Server/Imports/ImportMap.cs index a136c54..2d553ed 100644 --- a/src/NATS.Server/Imports/ImportMap.cs +++ b/src/NATS.Server/Imports/ImportMap.cs @@ -1,3 +1,5 @@ +using NATS.Server.Auth; + namespace NATS.Server.Imports; public sealed class ImportMap @@ -15,4 +17,23 @@ public sealed class ImportMap list.Add(si); } + + /// + /// Returns the distinct set of source accounts referenced by stream imports. + /// Go reference: accounts.go imports.streams — each streamImport has an acc field. + /// + public IReadOnlyList GetStreamImportSourceAccounts() + { + if (Streams.Count == 0) + return []; + + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(Streams.Count); + foreach (var si in Streams) + { + if (seen.Add(si.SourceAccount.Name)) + result.Add(si.SourceAccount); + } + return result; + } } diff --git a/src/NATS.Server/Imports/ServiceExportInfo.cs b/src/NATS.Server/Imports/ServiceExportInfo.cs new file mode 100644 index 0000000..7b62b65 --- /dev/null +++ b/src/NATS.Server/Imports/ServiceExportInfo.cs @@ -0,0 +1,12 @@ +namespace NATS.Server.Imports; + +/// +/// Immutable view of a service export, returned by Account query methods. +/// IsWildcard is true when the subject contains '*' or '>'. +/// Go reference: accounts.go serviceExport struct. +/// +public sealed record ServiceExportInfo( + string Subject, + ServiceResponseType ResponseType, + IReadOnlyList ApprovedAccounts, + bool IsWildcard); diff --git a/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs b/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs new file mode 100644 index 0000000..a090073 --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs @@ -0,0 +1,175 @@ +// Tests for stream import cycle detection via DFS on Account. +// Go reference: accounts_test.go — TestAccountStreamImportCycles (accounts.go:1627 streamImportFormsCycle). + +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Auth; + +public class StreamImportCycleTests +{ + private static Account CreateAccount(string name) => new(name); + + /// + /// Sets up a public stream export on for + /// and then adds a stream import on from . + /// + private static void SetupStreamImport(Account importer, Account exporter, string subject) + { + exporter.AddStreamExport(subject, approved: null); // public export + importer.AddStreamImport(exporter, subject, subject); + } + + // 1. No cycle when the proposed source has no imports leading back to this account. + // A imports from B; checking whether B can import from C — no path C→A exists. + [Fact] + public void StreamImportFormsCycle_NoCycle_ReturnsFalse() + { + // Go ref: accounts.go streamImportFormsCycle + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "events.>"); // A imports from B + c.AddStreamExport("other.>", approved: null); + + // B importing from C: does C→...→A exist? No. + a.StreamImportFormsCycle(c).ShouldBeFalse(); + } + + // 2. Direct cycle: A already imports from B; proposing B imports from A = cycle. + [Fact] + public void StreamImportFormsCycle_DirectCycle_ReturnsTrue() + { + // Go ref: accounts.go streamImportFormsCycle + var a = CreateAccount("A"); + var b = CreateAccount("B"); + + SetupStreamImport(a, b, "stream.>"); // A imports from B + + // Now check: would A importing from B (again, or B's perspective) form a cycle? + // We ask account B: does proposing A as source form a cycle? + // i.e. b.StreamImportFormsCycle(a) — does a chain from A lead back to B? + // A imports from B, so A→B, meaning following A's imports we reach B. Cycle confirmed. + b.StreamImportFormsCycle(a).ShouldBeTrue(); + } + + // 3. Indirect cycle: A→B→C; proposing C import from A would create C→A→B→C. + [Fact] + public void StreamImportFormsCycle_IndirectCycle_ReturnsTrue() + { + // Go ref: accounts.go checkStreamImportsForCycles + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "s.>"); // A imports from B + SetupStreamImport(b, c, "t.>"); // B imports from C + + // Would C importing from A form a cycle? Path: A imports from B, B imports from C → cycle. + c.StreamImportFormsCycle(a).ShouldBeTrue(); + } + + // 4. Self-import: A importing from A is always a cycle. + [Fact] + public void StreamImportFormsCycle_SelfImport_ReturnsTrue() + { + // Go ref: accounts.go streamImportFormsCycle — proposedSource == this + var a = CreateAccount("A"); + + a.StreamImportFormsCycle(a).ShouldBeTrue(); + } + + // 5. Account with no imports at all — no cycle possible. + [Fact] + public void StreamImportFormsCycle_NoImports_ReturnsFalse() + { + // Go ref: accounts.go streamImportFormsCycle — empty imports.streams + var a = CreateAccount("A"); + var b = CreateAccount("B"); + + // Neither account has any stream imports; proposing B as source for A is safe. + a.StreamImportFormsCycle(b).ShouldBeFalse(); + } + + // 6. Diamond topology: A→B, A→C, B→D, C→D — no cycle, just shared descendant. + [Fact] + public void StreamImportFormsCycle_DiamondNoCycle_ReturnsFalse() + { + // Go ref: accounts.go checkStreamImportsForCycles — visited set prevents false positives + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + var d = CreateAccount("D"); + + SetupStreamImport(a, b, "b.>"); // A imports from B + SetupStreamImport(a, c, "c.>"); // A imports from C + SetupStreamImport(b, d, "d1.>"); // B imports from D + SetupStreamImport(c, d, "d2.>"); // C imports from D + + // Proposing D import from A: does A→...→D path exist? Yes (via B and C). + d.StreamImportFormsCycle(a).ShouldBeTrue(); + + // Proposing E (new account) import from D: D has no imports, so no cycle. + var e = CreateAccount("E"); + e.StreamImportFormsCycle(d).ShouldBeFalse(); + } + + // 7. GetStreamImportSources returns names of source accounts. + [Fact] + public void GetStreamImportSources_ReturnsSourceNames() + { + // Go ref: accounts.go imports.streams acc field + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "x.>"); + SetupStreamImport(a, c, "y.>"); + + var sources = a.GetStreamImportSources(); + + sources.Count.ShouldBe(2); + sources.ShouldContain("B"); + sources.ShouldContain("C"); + } + + // 8. GetStreamImportSources returns empty list when no imports exist. + [Fact] + public void GetStreamImportSources_Empty_ReturnsEmpty() + { + // Go ref: accounts.go imports.streams — empty slice + var a = CreateAccount("A"); + + var sources = a.GetStreamImportSources(); + + sources.ShouldBeEmpty(); + } + + // 9. HasStreamImportFrom returns true when a matching import exists. + [Fact] + public void HasStreamImportFrom_True() + { + // Go ref: accounts.go imports.streams — acc.Name lookup + var a = CreateAccount("A"); + var b = CreateAccount("B"); + + SetupStreamImport(a, b, "events.>"); + + a.HasStreamImportFrom("B").ShouldBeTrue(); + } + + // 10. HasStreamImportFrom returns false when no import from that account exists. + [Fact] + public void HasStreamImportFrom_False() + { + // Go ref: accounts.go imports.streams — acc.Name lookup miss + var a = CreateAccount("A"); + var b = CreateAccount("B"); + var c = CreateAccount("C"); + + SetupStreamImport(a, b, "events.>"); + + a.HasStreamImportFrom("C").ShouldBeFalse(); + a.HasStreamImportFrom(c.Name).ShouldBeFalse(); + } +} diff --git a/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs b/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs new file mode 100644 index 0000000..0f7cdea --- /dev/null +++ b/tests/NATS.Server.Tests/Auth/WildcardExportTests.cs @@ -0,0 +1,169 @@ +// Tests for wildcard service export matching on Account. +// Go reference: accounts_test.go — getWildcardServiceExport, getServiceExport (accounts.go line 2849). + +using NATS.Server.Auth; +using NATS.Server.Imports; + +namespace NATS.Server.Tests.Auth; + +public class WildcardExportTests +{ + private static Account CreateAccount(string name = "TestAccount") => new(name); + + // ────────────────────────────────────────────────────────────────────────── + // GetWildcardServiceExport + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetWildcardServiceExport_ExactMatch_ReturnsExport() + { + // Go ref: accounts.go getWildcardServiceExport — exact key in exports.services map + var acct = CreateAccount(); + acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null); + + var result = acct.GetWildcardServiceExport("orders.create"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.create"); + result.ResponseType.ShouldBe(ServiceResponseType.Singleton); + } + + [Fact] + public void GetWildcardServiceExport_StarWildcard_ReturnsExport() + { + // Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '*' wildcard + var acct = CreateAccount(); + acct.AddServiceExport("orders.*", ServiceResponseType.Streamed, null); + + var result = acct.GetWildcardServiceExport("orders.create"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.*"); + result.ResponseType.ShouldBe(ServiceResponseType.Streamed); + result.IsWildcard.ShouldBeTrue(); + } + + [Fact] + public void GetWildcardServiceExport_GtWildcard_ReturnsExport() + { + // Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '>' wildcard + var acct = CreateAccount(); + acct.AddServiceExport("orders.>", ServiceResponseType.Chunked, null); + + var result = acct.GetWildcardServiceExport("orders.create.new"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.>"); + result.ResponseType.ShouldBe(ServiceResponseType.Chunked); + result.IsWildcard.ShouldBeTrue(); + } + + [Fact] + public void GetWildcardServiceExport_NoMatch_ReturnsNull() + { + // Go ref: accounts.go getWildcardServiceExport — returns nil when no pattern matches + var acct = CreateAccount(); + acct.AddServiceExport("payments.*", ServiceResponseType.Singleton, null); + + var result = acct.GetWildcardServiceExport("orders.create"); + + result.ShouldBeNull(); + } + + // ────────────────────────────────────────────────────────────────────────── + // GetAllServiceExports + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetAllServiceExports_ReturnsAll() + { + // Go ref: accounts.go — exports.services map contains all registered exports + var acct = CreateAccount(); + acct.AddServiceExport("svc.a", ServiceResponseType.Singleton, null); + acct.AddServiceExport("svc.b.*", ServiceResponseType.Streamed, null); + acct.AddServiceExport("svc.>", ServiceResponseType.Chunked, null); + + var all = acct.GetAllServiceExports(); + + all.Count.ShouldBe(3); + all.Select(e => e.Subject).ShouldContain("svc.a"); + all.Select(e => e.Subject).ShouldContain("svc.b.*"); + all.Select(e => e.Subject).ShouldContain("svc.>"); + } + + // ────────────────────────────────────────────────────────────────────────── + // GetExactServiceExport + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetExactServiceExport_Found() + { + // Go ref: accounts.go getServiceExport — direct map lookup, no wildcard scan + var acct = CreateAccount(); + acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null); + + var result = acct.GetExactServiceExport("orders.create"); + + result.ShouldNotBeNull(); + result.Subject.ShouldBe("orders.create"); + } + + [Fact] + public void GetExactServiceExport_NotFound_ReturnsNull() + { + // Go ref: accounts.go getServiceExport — map lookup misses wildcard patterns + var acct = CreateAccount(); + acct.AddServiceExport("orders.*", ServiceResponseType.Singleton, null); + + // "orders.create" is not an exact key in the map — only "orders.*" is + var result = acct.GetExactServiceExport("orders.create"); + + result.ShouldBeNull(); + } + + // ────────────────────────────────────────────────────────────────────────── + // HasServiceExport + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void HasServiceExport_ExactMatch_ReturnsTrue() + { + // Go ref: accounts.go — exact subject registered as an export + var acct = CreateAccount(); + acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null); + + acct.HasServiceExport("orders.create").ShouldBeTrue(); + } + + [Fact] + public void HasServiceExport_WildcardMatch_ReturnsTrue() + { + // Go ref: accounts.go — wildcard pattern covers the queried literal subject + var acct = CreateAccount(); + acct.AddServiceExport("orders.>", ServiceResponseType.Singleton, null); + + acct.HasServiceExport("orders.create.urgent").ShouldBeTrue(); + } + + // ────────────────────────────────────────────────────────────────────────── + // IsWildcard flag + // ────────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("orders.*", true)] + [InlineData("orders.>", true)] + [InlineData("orders.*.create", true)] + [InlineData("orders.create", false)] + [InlineData("svc", false)] + public void IsWildcard_DetectsWildcardSubjects(string subject, bool expectedWildcard) + { + // Go ref: accounts.go — wildcard subjects contain '*' or '>' + var acct = CreateAccount(); + acct.AddServiceExport(subject, ServiceResponseType.Singleton, null); + + var result = acct.GetExactServiceExport(subject); + + result.ShouldNotBeNull(); + result.IsWildcard.ShouldBe(expectedWildcard); + } +}