feat: add stream import cycle detection (Gap 9.3)
Add StreamImportFormsCycle DFS method to Account plus GetStreamImportSources and HasStreamImportFrom helpers. Add GetStreamImportSourceAccounts to ImportMap. 10 tests cover direct, indirect, self, diamond, and empty import scenarios.
This commit is contained in:
175
tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs
Normal file
175
tests/NATS.Server.Tests/Auth/StreamImportCycleTests.cs
Normal file
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a public stream export on <paramref name="exporter"/> for <paramref name="subject"/>
|
||||
/// and then adds a stream import on <paramref name="importer"/> from <paramref name="exporter"/>.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user