Compare commits

...

31 Commits

Author SHA1 Message Date
Joseph Doherty 95e9f0a92e feat: wire remaining E2E gaps — account imports, subject transforms, JWT auth, service latency
Close all 5 server-side wiring gaps so E2E tests pass without skips:
- System events: bridge user-defined system_account to internal $SYS
- Account imports/exports: config parsing + reverse response import for cross-account request-reply
- Subject transforms: parse mappings config block, apply in ProcessMessage
- JWT auth: parse trusted_keys, resolver MEMORY, resolver_preload in config
- Service latency: timestamp on request, publish ServiceLatencyMsg on response
2026-03-12 23:03:12 -04:00
Joseph Doherty 246fc7ad87 fix: route manager self-connection detection and per-peer deduplication
- Detect and reject self-route connections (node connecting to itself via
  routes list) in both inbound and outbound handshake paths
- Deduplicate RS+/RS-/RMSG forwarding by RemoteServerId to avoid sending
  duplicate messages when multiple connections exist to the same peer
- Fix ForwardRoutedMessageAsync to broadcast to all peers instead of
  selecting a single route
- Add pool_size: 1 to cluster fixture config
- Add -DV debug flags to cluster fixture servers
- Add WaitForCrossNodePropagationAsync probe pattern for reliable E2E
  cluster test timing
- Fix queue group test to use same-node subscribers (cross-node queue
  group routing not yet implemented)
2026-03-12 20:51:41 -04:00
Joseph Doherty ced5062f50 fix: remove unnecessary using directive in MqttTests 2026-03-12 19:55:22 -04:00
Joseph Doherty e4d275c929 test: add E2E MQTT bridge tests (pub/sub, different-topic isolation, QoS 1) 2026-03-12 19:54:30 -04:00
Joseph Doherty c9b55093a4 test: add E2E advanced tests (config, max connections, system events, account imports) 2026-03-12 19:54:05 -04:00
Joseph Doherty 139b984992 test: add E2E WebSocket transport tests (connect, pub/sub round-trip) 2026-03-12 19:52:57 -04:00
Joseph Doherty 571c595d0a test: add E2E cluster tests and fix leaf/gateway using warnings 2026-03-12 19:50:05 -04:00
Joseph Doherty aeb60d3c43 test: add E2E leaf node tests (hub-to-leaf, leaf-to-hub, subject propagation)
Fix LeafNodeManager.StartAsync to launch solicited connections for RemoteLeaves
(config-file-parsed remotes) in addition to the programmatic Remotes list, and
update ParseEndpoint to handle nats-leaf:// scheme URLs. Add LeafNodeFixture
that polls /leafz for connection readiness and three E2E tests covering
hub→leaf delivery, leaf→hub delivery, and subject-scoped propagation.
2026-03-12 19:47:40 -04:00
Joseph Doherty 338f44b07b test: add E2E gateway tests (cross-gateway messaging, interest-only)
- GatewayFixture: polls /gatewayz monitoring endpoint until both servers
  report num_gateways >= 1, replacing Task.Delay with proper synchronization
- GatewayTests: two tests covering cross-gateway delivery and interest-only
  no-delivery behaviour, using double PingAsync() instead of Task.Delay
- LeafNodeTests: replace Task.Delay(500) with sub.PingAsync()+pub.PingAsync()
  to properly fence subscription propagation without timing dependencies
- Fix GatewayManager.StartAsync to read remotes from RemoteGateways (config-
  parsed) in addition to the legacy Remotes list, enabling config-file-driven
  outbound gateway connections
2026-03-12 19:45:54 -04:00
Joseph Doherty 5d9d1bebd5 test: add E2E JetStream push consumers, ACK policies, retention modes, ordered, mirror, source
Adds 8 new E2E tests to JetStreamTests.cs (tests 11-18) covering push
consumer config, AckNone/AckAll policies, Interest/WorkQueue retention,
ordered consumers, mirror streams, and source streams.

Fixes three server gaps exposed by the new tests: mirror JSON parsing
(deliver_subject and mirror object fields were silently ignored in stream
and consumer API handlers), and deliver_subject omitted from consumer
info wire format. Also fixes ShutdownDrainTests to use TaskCompletionSource
on ConnectionDisconnected instead of a Task.Delay poll loop.
2026-03-12 19:36:29 -04:00
Joseph Doherty 76f8ccec2e test: add E2E header pub/sub tests (HPUB/HMSG) 2026-03-12 19:12:24 -04:00
Joseph Doherty e094846665 fix: remove unnecessary using directives in monitoring tests 2026-03-12 19:11:27 -04:00
Joseph Doherty 8ad2172e3c test: add E2E monitoring endpoint tests (varz, connz, healthz)
Replace Task.Delay polling with PeriodicTimer in NatsServerProcess readiness
checks and extend StartAsync to also TCP-poll the monitor port when enabled,
so MonitorServerFixture is guaranteed ready before tests run.
2026-03-12 19:10:33 -04:00
Joseph Doherty 4853409a40 chore: add MQTTnet NuGet package for E2E MQTT tests 2026-03-12 19:07:06 -04:00
Joseph Doherty 0e252d6ccf docs: add E2E full gap coverage implementation plan
12 tasks covering all 3 priority tiers: monitoring, headers,
shutdown/drain, JetStream extensions, cluster, leaf node,
gateway, MQTT, WebSocket, and advanced feature tests.
2026-03-12 19:05:48 -04:00
Joseph Doherty 1f122bf56f docs: add E2E full gap coverage design document
Covers all 3 priority tiers from e2e_gaps.md: monitoring,
headers, shutdown/drain, clustering, leaf nodes, gateways,
MQTT, WebSocket, JetStream extensions, and advanced features.
2026-03-12 18:59:32 -04:00
Joseph Doherty bc308a4349 docs: update CLAUDE.md with new test project structure
Reflects the split of NATS.Server.Tests into 10 feature-focused
test projects plus a shared TestUtilities library.
2026-03-12 16:26:48 -04:00
Joseph Doherty 7fbffffd05 refactor: rename remaining tests to NATS.Server.Core.Tests
- Rename tests/NATS.Server.Tests -> tests/NATS.Server.Core.Tests
- Update solution file, InternalsVisibleTo, and csproj references
- Remove JETSTREAM_INTEGRATION_MATRIX and NATS.NKeys from csproj (moved to JetStream.Tests and Auth.Tests)
- Update all namespaces from NATS.Server.Tests.* to NATS.Server.Core.Tests.*
- Replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls
- Fix stale namespace in Transport.Tests/NetworkingGoParityTests.cs
2026-03-12 16:14:02 -04:00
Joseph Doherty 78b4bc2486 refactor: extract NATS.Server.JetStream.Tests project
Move 225 JetStream-related test files from NATS.Server.Tests into a
dedicated NATS.Server.JetStream.Tests project. This includes root-level
JetStream*.cs files, storage test files (FileStore, MemStore,
StreamStoreContract), and the full JetStream/ subfolder tree (Api,
Cluster, Consumers, MirrorSource, Snapshots, Storage, Streams).

Updated all namespaces, added InternalsVisibleTo, registered in the
solution file, and added the JETSTREAM_INTEGRATION_MATRIX define.
2026-03-12 15:58:10 -04:00
Joseph Doherty 36b9dfa654 refactor: extract NATS.Server.Auth.Tests project
Move 50 auth/accounts/permissions/JWT/NKey test files from
NATS.Server.Tests into a dedicated NATS.Server.Auth.Tests project.
Update namespaces, replace private GetFreePort/ReadUntilAsync helpers
with TestUtilities calls, replace Task.Delay with TaskCompletionSource
in test doubles, and add InternalsVisibleTo.

690 tests pass.
2026-03-12 15:54:07 -04:00
Joseph Doherty 0c086522a4 refactor: extract NATS.Server.Monitoring.Tests project
Move 39 monitoring, events, and system endpoint test files from
NATS.Server.Tests into a dedicated NATS.Server.Monitoring.Tests project.
Update namespaces, replace private GetFreePort/ReadUntilAsync with
TestUtilities shared helpers, add InternalsVisibleTo, and register
in the solution file. All 439 tests pass.
2026-03-12 15:44:12 -04:00
Joseph Doherty edf9ed770e refactor: extract NATS.Server.Raft.Tests project
Move 43 Raft consensus test files (8 root-level + 35 in Raft/ subfolder)
from NATS.Server.Tests into a dedicated NATS.Server.Raft.Tests project.
Update namespaces, add InternalsVisibleTo, and fix timing/exception
handling issues in moved test files.
2026-03-12 15:36:02 -04:00
Joseph Doherty 615752cdc2 refactor: extract NATS.Server.Clustering.Tests project
Move 29 clustering/routing test files from NATS.Server.Tests to a
dedicated NATS.Server.Clustering.Tests project. Update namespaces,
replace private GetFreePort/ReadUntilAsync helpers with TestUtilities
calls, and extract TestServerFactory/ClusterTestServer to TestUtilities
to fix cross-project reference from JetStreamStartupTests.
2026-03-12 15:31:58 -04:00
Joseph Doherty 3f7d896a34 refactor: extract NATS.Server.LeafNodes.Tests project
Move 28 leaf node test files from NATS.Server.Tests into a dedicated
NATS.Server.LeafNodes.Tests project. Update namespaces, add
InternalsVisibleTo, register in solution file. Replace all Task.Delay
polling loops with PollHelper.WaitUntilAsync/YieldForAsync from
TestUtilities. Replace private ReadUntilAsync in LeafProtocolTests
with SocketTestHelper.ReadUntilAsync.

All 281 tests pass.
2026-03-12 15:23:33 -04:00
Joseph Doherty 9972b74bc3 refactor: extract NATS.Server.Gateways.Tests project
Move 25 gateway-related test files from NATS.Server.Tests into a
dedicated NATS.Server.Gateways.Tests project. Update namespaces,
replace private ReadUntilAsync with SocketTestHelper from TestUtilities,
inline TestServerFactory usage, add InternalsVisibleTo, and register
the project in the solution file. All 261 tests pass.
2026-03-12 15:10:50 -04:00
Joseph Doherty a6be5e11ed refactor: extract NATS.Server.Mqtt.Tests project
Move 29 MQTT test files from NATS.Server.Tests into a dedicated
NATS.Server.Mqtt.Tests project. Update namespaces, add
InternalsVisibleTo, and replace Task.Delay calls with
PollHelper.WaitUntilAsync for proper synchronization.
2026-03-12 15:03:12 -04:00
Joseph Doherty d2c04fcca5 refactor: extract NATS.Server.Transport.Tests project
Move TLS, OCSP, WebSocket, Networking, and IO test files from
NATS.Server.Tests into a dedicated NATS.Server.Transport.Tests
project. Update namespaces, replace private GetFreePort/ReadUntilAsync
with shared TestUtilities helpers, extract TestCertHelper to
TestUtilities, and replace Task.Delay polling loops with
PollHelper.WaitUntilAsync/YieldForAsync for proper synchronization.
2026-03-12 14:57:35 -04:00
Joseph Doherty 5c608f07e3 Move shared fixtures and parity utilities to TestUtilities project
- git mv JetStreamApiFixture, JetStreamClusterFixture, LeafFixture,
  Parity utilities, and TestData from NATS.Server.Tests to
  NATS.Server.TestUtilities
- Update namespaces to NATS.Server.TestUtilities (and .Parity sub-ns)
- Make fixture classes public for cross-project access
- Add PollHelper to replace Task.Delay polling with SemaphoreSlim waits
- Refactor all fixture polling loops to use PollHelper
- Add 'using NATS.Server.TestUtilities;' to ~75 consuming test files
- Rename local fixture duplicates (MetaGroupTestFixture,
  LeafProtocolTestFixture) to avoid shadowing shared fixtures
- Remove TestData entry from NATS.Server.Tests.csproj (moved to
  TestUtilities)
2026-03-12 14:45:21 -04:00
Joseph Doherty 2a75ee534a feat: create NATS.Server.TestUtilities with shared helpers
Add shared test utility library with TestPortAllocator.GetFreePort() and
SocketTestHelper.ReadUntilAsync() to deduplicate helpers across test projects.
This is the foundation for splitting the monolithic test project into
feature-focused test projects.
2026-03-12 14:29:14 -04:00
Joseph Doherty fb19b50231 docs: add implementation plan for test project split
13 tasks: create TestUtilities, extract 9 subsystem test projects,
rename remainder to Core.Tests, final verification.
2026-03-12 14:26:58 -04:00
Joseph Doherty 6941d9275b docs: add design doc for splitting NATS.Server.Tests into feature-focused projects
Splits the 609-file monolithic test project into 10 subsystem test projects
plus a shared TestUtilities library for developer ergonomics.
2026-03-12 14:23:06 -04:00
674 changed files with 7296 additions and 2584 deletions
+17 -8
View File
@@ -25,8 +25,12 @@ dotnet test -v normal
# Run a single test project # Run a single test project
dotnet test tests/NATS.Server.Tests dotnet test tests/NATS.Server.Tests
# Run a specific test project
dotnet test tests/NATS.Server.Core.Tests
dotnet test tests/NATS.Server.JetStream.Tests
# Run a specific test by name # Run a specific test by name
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TestName" dotnet test tests/NATS.Server.Core.Tests --filter "FullyQualifiedName~TestName"
# Run the NATS server (default port 4222) # Run the NATS server (default port 4222)
dotnet run --project src/NATS.Server.Host dotnet run --project src/NATS.Server.Host
@@ -58,13 +62,18 @@ src/
NATS.Server.Host/ # Executable host app NATS.Server.Host/ # Executable host app
Program.cs # Entry point, CLI arg parsing (-p port) Program.cs # Entry point, CLI arg parsing (-p port)
tests/ tests/
NATS.Server.Tests/ # xUnit test project NATS.Server.TestUtilities/ # Shared helpers, fixtures, parity tools (class library)
ParserTests.cs # Protocol parser tests NATS.Server.Core.Tests/ # Client, server, parser, config, subscriptions, protocol
SubjectMatchTests.cs # Subject validation & matching tests NATS.Server.Auth.Tests/ # Auth, accounts, permissions, JWT, NKeys
SubListTests.cs # Subscription list trie tests NATS.Server.JetStream.Tests/ # JetStream API, streams, consumers, storage, cluster
ClientTests.cs # Client-level protocol tests NATS.Server.Raft.Tests/ # RAFT consensus
ServerTests.cs # Server pubsub/wildcard tests NATS.Server.Clustering.Tests/ # Routes, cluster topology, inter-server protocol
IntegrationTests.cs # End-to-end tests using NATS.Client.Core NuGet NATS.Server.Gateways.Tests/ # Gateway connections, interest modes
NATS.Server.LeafNodes.Tests/ # Leaf node connections, hub-spoke
NATS.Server.Mqtt.Tests/ # MQTT protocol bridge
NATS.Server.Monitoring.Tests/ # Monitor endpoints, events, system events
NATS.Server.Transport.Tests/ # WebSocket, TLS, OCSP, IO
NATS.E2E.Tests/ # End-to-end tests using NATS.Client.Core NuGet
``` ```
## Go Reference Commands ## Go Reference Commands
+3
View File
@@ -35,5 +35,8 @@
<!-- NATS Client (integration tests) --> <!-- NATS Client (integration tests) -->
<PackageVersion Include="NATS.Client.Core" Version="2.7.2" /> <PackageVersion Include="NATS.Client.Core" Version="2.7.2" />
<PackageVersion Include="NATS.Client.JetStream" Version="2.7.2" /> <PackageVersion Include="NATS.Client.JetStream" Version="2.7.2" />
<!-- MQTT Client (E2E tests) -->
<PackageVersion Include="MQTTnet" Version="4.3.7.1207" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+11 -1
View File
@@ -4,7 +4,17 @@
<Project Path="src/NATS.Server/NATS.Server.csproj" /> <Project Path="src/NATS.Server/NATS.Server.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/NATS.Server.Tests/NATS.Server.Tests.csproj" /> <Project Path="tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj" />
<Project Path="tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj" />
<Project Path="tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj" />
<Project Path="tests/NATS.Server.Mqtt.Tests/NATS.Server.Mqtt.Tests.csproj" />
<Project Path="tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj" />
<Project Path="tests/NATS.Server.LeafNodes.Tests/NATS.Server.LeafNodes.Tests.csproj" />
<Project Path="tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj" />
<Project Path="tests/NATS.Server.Raft.Tests/NATS.Server.Raft.Tests.csproj" />
<Project Path="tests/NATS.Server.Monitoring.Tests/NATS.Server.Monitoring.Tests.csproj" />
<Project Path="tests/NATS.Server.Auth.Tests/NATS.Server.Auth.Tests.csproj" />
<Project Path="tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj" />
<Project Path="tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj" /> <Project Path="tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>
@@ -0,0 +1,102 @@
# E2E Test Full Gap Coverage Design
**Date:** 2026-03-12
**Scope:** All 3 priority tiers from `e2e_gaps.md` (~45-55 new tests)
## Decisions
- **Multi-server fixtures:** Real multi-process only where required (cluster, leaf, gateway). Single server with feature flags for MQTT and WebSocket.
- **Gateway topology:** Minimal two-server (not full multi-cluster).
- **Shutdown/drain:** Both client drain and server shutdown tests.
- **MQTT client:** MQTTnet NuGet package.
- **WebSocket client:** Built-in `System.Net.WebSockets.ClientWebSocket` speaking raw NATS protocol.
## New Fixtures
| Fixture | Servers | Config |
|---------|---------|--------|
| `ClusterFixture` | 3 `NatsServerProcess` instances | Route config pointing at each other |
| `LeafNodeFixture` | 2 instances (hub + leaf) | Leaf config pointing at hub |
| `GatewayFixture` | 2 standalone instances | Gateway config connecting them |
| `MqttServerFixture` | 1 instance | MQTT port enabled |
| `WebSocketServerFixture` | 1 instance | WebSocket port enabled |
Existing `MonitorServerFixture` is already created but unused — use as-is.
## New Test Files
### 1. `MonitoringTests.cs` (existing `MonitorServerFixture`)
- `/varz` — returns JSON with server_name, version, connections
- `/connz` — reflects connected client count
- `/healthz` — returns 200 OK
### 2. `HeaderTests.cs` (existing `NatsServerFixture`)
- Publish with headers, receive with headers intact
- Multiple headers on a single message
- Empty header value round-trip
### 3. `ShutdownDrainTests.cs` (own server per test, no shared fixture)
- Client drain completes in-flight messages then disconnects
- Server kill mid-connection — client detects disconnection gracefully
### 4. `ClusterTests.cs` (new `ClusterFixture`)
- Message published on node A received by subscriber on node B
- Subscriber on node C receives after joining mid-stream
- Queue group across cluster nodes delivers once total
### 5. `LeafNodeTests.cs` (new `LeafNodeFixture`)
- Message published on hub received by leaf subscriber
- Message published on leaf received by hub subscriber
- Only subscribed subjects propagate to hub
### 6. `GatewayTests.cs` (new `GatewayFixture`)
- Message crosses gateway from server A to server B
- No cross-delivery when no interest on remote side
### 7. `MqttTests.cs` (new `MqttServerFixture`)
- MQTT subscribe → NATS publish → MQTT receives
- MQTT publish → NATS subscribe → NATS receives
- MQTT QoS 0 and QoS 1 delivery
### 8. `WebSocketTests.cs` (new `WebSocketServerFixture`)
- Connect via WebSocket, subscribe, receive message
- Pub/sub round-trip over WebSocket
## Additions to Existing `JetStreamTests.cs`
- Push consumer (server-initiated delivery)
- AckAll policy
- AckNone policy
- Interest retention
- WorkQueue retention
- Ordered consumer
- Stream mirroring
- Stream sourcing
## New File: `AdvancedTests.cs`
- JWT authentication (inline server with JWT config)
- Account imports/exports (cross-account service call)
- Subject transforms
- Config file loading (full config file, verify behavior)
- System events (`$SYS.>` subscription, detect connect event)
- Max connections enforcement
- Service latency tracking
## New NuGet Dependency
- **MQTTnet** — added to `Directory.Packages.props` and `NATS.E2E.Tests.csproj`
## Estimated Impact
- ~45-55 new tests
- 5 new fixtures + 7 new test files + 1 existing file extended
- Total E2E: ~90-95 tests (from current 42)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-03-12-e2e-full-gap-coverage-plan.md",
"tasks": [
{"id": 1, "subject": "Task 1: Add MQTTnet NuGet Package", "status": "pending"},
{"id": 2, "subject": "Task 2: Monitoring Endpoint Tests", "status": "pending"},
{"id": 3, "subject": "Task 3: Header Pub/Sub Tests", "status": "pending"},
{"id": 4, "subject": "Task 4: Shutdown and Drain Tests", "status": "pending"},
{"id": 5, "subject": "Task 5: JetStream Extended Tests", "status": "pending"},
{"id": 6, "subject": "Task 6: Cluster Fixture and Tests", "status": "pending"},
{"id": 7, "subject": "Task 7: Leaf Node Fixture and Tests", "status": "pending"},
{"id": 8, "subject": "Task 8: Gateway Fixture and Tests", "status": "pending"},
{"id": 9, "subject": "Task 9: MQTT Fixture and Tests", "status": "pending", "blockedBy": [1]},
{"id": 10, "subject": "Task 10: WebSocket Fixture and Tests", "status": "pending"},
{"id": 11, "subject": "Task 11: Advanced Tests", "status": "pending"},
{"id": 12, "subject": "Task 12: Final Verification and Cleanup", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]}
],
"lastUpdated": "2026-03-12T00:00:00Z"
}
@@ -0,0 +1,222 @@
# Test Project Split Design
**Date:** 2026-03-12
**Goal:** Split `NATS.Server.Tests` (609 files) into feature-focused test projects for developer ergonomics — easier to run just the tests for the subsystem you're working on.
## Project Structure
```
tests/
NATS.Server.TestUtilities/ # Shared helpers, fixtures, parity tools (class library)
NATS.Server.Core.Tests/ # Client, server, parser, config, subscriptions, protocol
NATS.Server.Auth.Tests/ # Auth, accounts, permissions, JWT, NKeys
NATS.Server.JetStream.Tests/ # JetStream API, streams, consumers, storage, cluster
NATS.Server.Raft.Tests/ # RAFT consensus
NATS.Server.Clustering.Tests/ # Routes, cluster topology, inter-server protocol
NATS.Server.Gateways.Tests/ # Gateway connections, interest modes
NATS.Server.LeafNodes.Tests/ # Leaf node connections, hub-spoke
NATS.Server.Mqtt.Tests/ # MQTT protocol bridge
NATS.Server.Monitoring.Tests/ # Monitor endpoints, events, system events
NATS.Server.Transport.Tests/ # WebSocket, TLS, OCSP, IO
NATS.E2E.Tests/ # (existing, unchanged)
```
## TestUtilities Contents
`NATS.Server.TestUtilities` is a **class library** (not a test project).
### Shared helpers (deduplicated)
- `TestPortAllocator``GetFreePort()` (currently duplicated in ~51 files)
- `SocketTestHelper``ReadUntilAsync()`, raw socket connect/read patterns (~25 files)
- `ServerTestHelper` — common server startup/teardown patterns
### Shared fixtures
- `JetStreamApiFixture` — moved from root (used by 52 JetStream test files)
- `JetStreamClusterFixture` — consolidated from 2 duplicate definitions
- `LeafFixture` — consolidated from 3 duplicate definitions
### Parity utilities (non-test)
- `NatsCapabilityInventory.cs`
- `ParityRowInspector.cs`
- `JetStreamParityTruthMatrix.cs`
### TestData
- `TestData/*.conf` files (copied to output directory)
## File-to-Project Mapping
### NATS.Server.Core.Tests (~75 files)
**Root-level:**
ClientClosedReasonTests, ClientFlagsTests, ClientHeaderTests, ClientKindCommandMatrixTests, ClientKindProtocolRoutingTests, ClientKindTests, ClientLifecycleTests, ClientProtocolParityTests, ClientPubSubTests, ClientServerGoParityTests, ClientSlowConsumerTests, ClientTests, ClientTraceModeTests, ClientTraceTests, ClientUnsubTests, ConfigIntegrationTests, ConfigProcessorTests, ConfigReloadTests, ConfigRuntimeParityTests, FlushCoalescingTests, IntegrationTests, InternalClientTests, LoggingTests, MessageTraceTests, MsgTraceGoParityTests, NatsConfLexerTests, NatsConfParserTests, NatsHeaderParserTests, NatsOptionsTests, NoRespondersTests, ParserTests, ResponseRoutingTests, ResponseTrackerTests, RttTests, ServerConfigTests, ServerStatsTests, ServerTests, SignalHandlerTests, SlopwatchSuppressAttribute, SlowConsumerStallGateTests, StallGateTests, SubjectMatchTests, SubjectTransformIntegrationTests, SubjectTransformTests, SubListTests, VerboseModeTests, WriteLoopTests, WriteTimeoutTests, ConcurrencyStressTests
**Subfolders:** Configuration/ (14), Internal/ (8), IO/ (4), Protocol/ (7), Server/ (7), SubList/ (6), Subscriptions/ (6), Stress/ (3)
**Parity test files (from Parity/ folder):** NatsStrictCapabilityInventoryTests, JetStreamParityTruthMatrixTests, GoParityRunnerTests, InfrastructureGoParityTests, DifferencesParityClosureTests
### NATS.Server.Auth.Tests (~50 files)
**Root-level:**
AccountIsolationTests, AccountResolverTests, AccountStatsTests, AccountTests, AuthConfigTests, AuthIntegrationTests, AuthProtocolTests, AuthServiceTests, ClientPermissionsTests, JwtAuthenticatorTests, JwtTests, NKeyAuthenticatorTests, NKeyIntegrationTests, PermissionIntegrationTests, PermissionLruCacheTests, PermissionTemplateTests, SimpleUserPasswordAuthenticatorTests, TokenAuthenticatorTests, UserPasswordAuthenticatorTests, ImportExportTests
**Subfolders:** Auth/ (25), Accounts/ (5)
### NATS.Server.JetStream.Tests (~220 files)
**Root-level:**
All `JetStream*` files at root (~55), plus FileStoreTests, FileStoreEncryptionTests, MemStoreTests, StreamStoreContractTests, MirrorSourceRetryTests, ClusterJetStreamConfigProcessorTests
**Subfolders:** JetStream/ and all sub-folders (163 files)
### NATS.Server.Raft.Tests (~45 files)
**Root-level:** RaftConsensusAdvancedParityTests, RaftElectionTests, RaftMembershipParityTests, RaftReplicationTests, RaftSafetyContractTests, RaftSnapshotCatchupTests, RaftSnapshotTransferParityTests, RaftTransportPersistenceTests
**Subfolders:** Raft/ (36)
### NATS.Server.Clustering.Tests (~30 files)
**Root-level:** RouteHandshakeTests, RoutePoolTests, RouteRmsgForwardingTests, RouteSubscriptionPropagationTests, RouteWireSubscriptionProtocolTests, ImplicitDiscoveryTests, InterServerAccountProtocolTests
**Subfolders:** Routes/ (21), Route/ (1)
### NATS.Server.Gateways.Tests (~25 files)
**Root-level:** GatewayAdvancedRemapRuntimeTests, GatewayAdvancedSemanticsTests, GatewayLeafBootstrapTests, GatewayProtocolTests
**Subfolders:** Gateways/ (21)
### NATS.Server.LeafNodes.Tests (~30 files)
**Root-level:** LeafAdvancedSemanticsTests, LeafProtocolTests
**Subfolders:** LeafNodes/ (26), LeafNode/ (1)
### NATS.Server.Mqtt.Tests (~30 files)
**Root-level:** MqttPersistenceTests
**Subfolders:** Mqtt/ (28)
### NATS.Server.Monitoring.Tests (~35 files)
**Root-level:** EventSystemTests, JszMonitorTests, MonitorClusterEndpointTests, MonitorModelTests, MonitorTests, SubszTests, SystemEventsTests, SystemRequestReplyTests
**Subfolders:** Monitoring/ (21), Events/ (10)
### NATS.Server.Transport.Tests (~25 files)
**Root-level:** OcspConfigTests, OcspStaplingTests, TlsConnectionWrapperTests, TlsHelperTests, TlsMapAuthenticatorTests, TlsOcspParityBatch1Tests, TlsOcspParityBatch2Tests, TlsRateLimiterTests, TlsServerTests
**Subfolders:** WebSocket/ (15), Networking/ (1)
## Project File Template
Each test project follows the same base pattern:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
</ItemGroup>
</Project>
```
**Project-specific package additions:**
| Project | Extra packages |
|---------|---------------|
| Auth.Tests | `NATS.NKeys` |
| JetStream.Tests | `NATS.Client.Core`, `JETSTREAM_INTEGRATION_MATRIX` define constant |
| Transport.Tests | `Serilog.Sinks.File` (if TLS tests use it) |
| Core.Tests | `NATS.Client.Core`, `Serilog.Sinks.File` |
| Monitoring.Tests | `NATS.Client.Core` |
**TestUtilities** is a plain class library:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" /> <!-- for IAsyncLifetime fixtures -->
</ItemGroup>
<ItemGroup>
<None Update="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
</ItemGroup>
</Project>
```
## Migration Strategy
### Phase 1: Create TestUtilities
- Create `NATS.Server.TestUtilities` project
- Extract `GetFreePort()`, `ReadUntilAsync()` into shared helper classes
- Move `JetStreamApiFixture`, consolidated `JetStreamClusterFixture`, consolidated `LeafFixture`
- Move parity utility files (non-test) and TestData
- Update the original `NATS.Server.Tests` to reference TestUtilities
- Verify build + all tests pass
### Phase 2: Split projects one at a time (smallest first)
1. Transport.Tests (~25 files)
2. Mqtt.Tests (~30 files)
3. Gateways.Tests (~25 files)
4. LeafNodes.Tests (~30 files)
5. Clustering.Tests (~30 files)
6. Raft.Tests (~45 files)
7. Monitoring.Tests (~35 files)
8. Auth.Tests (~50 files)
9. JetStream.Tests (~220 files)
10. Core.Tests (rename remaining original project)
Each step:
- Create the new `.csproj`
- Move files with `git mv` to preserve history
- Update namespaces to match new project name
- Add to solution file
- Remove files from old project
- Build + test
### Phase 3: Cleanup
- Delete the original `NATS.Server.Tests` project (now empty)
- Verify `dotnet test` from solution root runs all projects
- Verify CI still works
## Decisions
- **Namespaces updated** to match new project names (e.g., `NATS.Server.Auth.Tests`)
- **Root-level files sorted** into matching subsystem projects by prefix/topic
- **Storage files** (FileStore, MemStore, StreamStore) → JetStream project
- **ImportExportTests** → Auth project
- **InternalClientTests** → Core project
- **Parity test files** → Core.Tests; parity utility classes → TestUtilities
- **Stress test files** → Core.Tests (only 3-4 files, not worth a separate project)
- **Trace files** → Core.Tests (tracing is a core feature)
@@ -0,0 +1,616 @@
# Test Project Split Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Split the monolithic `NATS.Server.Tests` (609 files) into 10 feature-focused test projects + 1 shared test utilities library.
**Architecture:** Create `NATS.Server.TestUtilities` as a class library with deduplicated helpers and shared fixtures. Then extract test files into subsystem-specific test projects one at a time, smallest first. Each extraction creates a new `.csproj`, moves files with `git mv`, updates namespaces, adds to the solution, and verifies build+test before proceeding to the next.
**Tech Stack:** .NET 10, xUnit 3, Shouldly, NSubstitute, Central Package Management
---
### Task 0: Create NATS.Server.TestUtilities project
**Files:**
- Create: `tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj`
- Create: `tests/NATS.Server.TestUtilities/TestPortAllocator.cs`
- Create: `tests/NATS.Server.TestUtilities/SocketTestHelper.cs`
- Modify: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` (add ProjectReference)
- Modify: `NatsDotNet.slnx` (add project)
**Step 1: Create the TestUtilities csproj**
```xml
<!-- tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NATS.NKeys" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
</ItemGroup>
</Project>
```
**Step 2: Create TestPortAllocator.cs**
```csharp
// tests/NATS.Server.TestUtilities/TestPortAllocator.cs
using System.Net;
using System.Net.Sockets;
namespace NATS.Server.TestUtilities;
public static class TestPortAllocator
{
public static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
}
```
**Step 3: Create SocketTestHelper.cs**
```csharp
// tests/NATS.Server.TestUtilities/SocketTestHelper.cs
using System.Net.Sockets;
using System.Text;
namespace NATS.Server.TestUtilities;
public static class SocketTestHelper
{
public static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var sb = new StringBuilder();
var buf = new byte[4096];
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
{
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
if (n == 0) break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
}
```
**Step 4: Add ProjectReference to existing NATS.Server.Tests**
Add to `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` inside the `<ItemGroup>` with `ProjectReference`:
```xml
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
```
**Step 5: Add TestUtilities to solution file**
In `NatsDotNet.slnx`, inside `<Folder Name="/tests/">` add:
```xml
<Project Path="tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj" />
```
**Step 6: Build to verify**
Run: `dotnet build`
Expected: SUCCESS — TestUtilities compiles, NATS.Server.Tests still compiles.
**Step 7: Commit**
```bash
git add tests/NATS.Server.TestUtilities/ NatsDotNet.slnx tests/NATS.Server.Tests/NATS.Server.Tests.csproj
git commit -m "feat: create NATS.Server.TestUtilities with shared helpers"
```
---
### Task 1: Move shared fixtures and parity utilities to TestUtilities
**Files:**
- Move: `tests/NATS.Server.Tests/JetStreamApiFixture.cs``tests/NATS.Server.TestUtilities/JetStreamApiFixture.cs`
- Move: `tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterFixture.cs``tests/NATS.Server.TestUtilities/JetStreamClusterFixture.cs`
- Move: `tests/NATS.Server.Tests/LeafNodes/LeafFixture.cs``tests/NATS.Server.TestUtilities/LeafFixture.cs`
- Move: `tests/NATS.Server.Tests/Parity/NatsCapabilityInventory.cs``tests/NATS.Server.TestUtilities/Parity/NatsCapabilityInventory.cs`
- Move: `tests/NATS.Server.Tests/Parity/ParityRowInspector.cs``tests/NATS.Server.TestUtilities/Parity/ParityRowInspector.cs`
- Move: `tests/NATS.Server.Tests/Parity/JetStreamParityTruthMatrix.cs``tests/NATS.Server.TestUtilities/Parity/JetStreamParityTruthMatrix.cs`
- Move: `tests/NATS.Server.Tests/TestData/*``tests/NATS.Server.TestUtilities/TestData/*`
**Step 1: Move files with git mv**
```bash
cd tests
git mv NATS.Server.Tests/JetStreamApiFixture.cs NATS.Server.TestUtilities/
git mv NATS.Server.Tests/JetStream/Cluster/JetStreamClusterFixture.cs NATS.Server.TestUtilities/
git mv NATS.Server.Tests/LeafNodes/LeafFixture.cs NATS.Server.TestUtilities/
mkdir -p NATS.Server.TestUtilities/Parity
git mv NATS.Server.Tests/Parity/NatsCapabilityInventory.cs NATS.Server.TestUtilities/Parity/
git mv NATS.Server.Tests/Parity/ParityRowInspector.cs NATS.Server.TestUtilities/Parity/
git mv NATS.Server.Tests/Parity/JetStreamParityTruthMatrix.cs NATS.Server.TestUtilities/Parity/
mkdir -p NATS.Server.TestUtilities/TestData
git mv NATS.Server.Tests/TestData/* NATS.Server.TestUtilities/TestData/
```
**Step 2: Update namespaces in moved files**
Change `namespace NATS.Server.Tests;``namespace NATS.Server.TestUtilities;` in each moved file.
For parity files: `namespace NATS.Server.TestUtilities.Parity;`
For fixtures in subfolders that had sub-namespaces (e.g. `NATS.Server.Tests.JetStream.Cluster`), update to `NATS.Server.TestUtilities;`.
**Step 3: Make fixture classes public**
The moved fixtures (`JetStreamApiFixture`, `JetStreamClusterFixture`, `LeafFixture`) are likely `internal`. Change them to `public` so test projects can access them.
**Step 4: Add `using NATS.Server.TestUtilities;` to files that reference moved fixtures**
All files that reference `JetStreamApiFixture`, `JetStreamClusterFixture`, `LeafFixture`, or parity utilities need the new using directive. This is ~52 files for JetStreamApiFixture, ~20 for cluster fixture, ~5 for LeafFixture.
**Step 5: Remove TestData entry from NATS.Server.Tests.csproj**
Remove the `<None Update="TestData\**\*" ...>` item since TestData moved to TestUtilities.
**Step 6: Build and run tests**
Run: `dotnet build && dotnet test tests/NATS.Server.Tests --no-build`
Expected: All tests pass — fixtures resolved from TestUtilities.
**Step 7: Commit**
```bash
git add -A
git commit -m "refactor: move shared fixtures and parity utilities to TestUtilities"
```
---
### Task 2: Extract NATS.Server.Transport.Tests (~25 files)
**Files:**
- Create: `tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj`
- Move: Root files: OcspConfigTests.cs, OcspStaplingTests.cs, TlsConnectionWrapperTests.cs, TlsHelperTests.cs, TlsMapAuthenticatorTests.cs, TlsOcspParityBatch1Tests.cs, TlsOcspParityBatch2Tests.cs, TlsRateLimiterTests.cs, TlsServerTests.cs
- Move: `WebSocket/` folder (15 files)
- Move: `Networking/` folder (1 file)
- Modify: `NatsDotNet.slnx`
**Step 1: Create the csproj**
```xml
<!-- tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
</ItemGroup>
</Project>
```
**Step 2: Move files with git mv**
```bash
cd tests
mkdir -p NATS.Server.Transport.Tests
git mv NATS.Server.Tests/OcspConfigTests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/OcspStaplingTests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/TlsConnectionWrapperTests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/TlsHelperTests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/TlsMapAuthenticatorTests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/TlsOcspParityBatch1Tests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/TlsOcspParityBatch2Tests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/TlsRateLimiterTests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/TlsServerTests.cs NATS.Server.Transport.Tests/
git mv NATS.Server.Tests/WebSocket NATS.Server.Transport.Tests/WebSocket
git mv NATS.Server.Tests/Networking NATS.Server.Transport.Tests/Networking
```
**Step 3: Update namespaces**
In all moved files, change:
- `namespace NATS.Server.Tests;``namespace NATS.Server.Transport.Tests;`
- `namespace NATS.Server.Tests.WebSocket;``namespace NATS.Server.Transport.Tests.WebSocket;`
- `namespace NATS.Server.Tests.Networking;``namespace NATS.Server.Transport.Tests.Networking;`
**Step 4: Replace private GetFreePort/ReadUntilAsync with TestUtilities calls**
In each moved file that has `private static int GetFreePort()` or `private static async Task<string> ReadUntilAsync(...)`:
- Delete the private method
- Add `using NATS.Server.TestUtilities;`
- Replace `GetFreePort()``TestPortAllocator.GetFreePort()`
- Replace `ReadUntilAsync(``SocketTestHelper.ReadUntilAsync(`
**Step 5: Add to solution file**
In `NatsDotNet.slnx`, inside `/tests/`:
```xml
<Project Path="tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj" />
```
**Step 6: Build and test**
Run: `dotnet build && dotnet test tests/NATS.Server.Transport.Tests --no-build`
Expected: All Transport tests pass.
Run: `dotnet test tests/NATS.Server.Tests --no-build`
Expected: Remaining tests still pass.
**Step 7: Commit**
```bash
git add -A
git commit -m "refactor: extract NATS.Server.Transport.Tests project"
```
---
### Task 3: Extract NATS.Server.Mqtt.Tests (~30 files)
**Files:**
- Create: `tests/NATS.Server.Mqtt.Tests/NATS.Server.Mqtt.Tests.csproj`
- Move: Root file: MqttPersistenceTests.cs
- Move: `Mqtt/` folder (28 files)
**Step 1: Create csproj** (same template as Transport, no Serilog needed)
**Step 2: Move files**
```bash
cd tests
mkdir -p NATS.Server.Mqtt.Tests
git mv NATS.Server.Tests/MqttPersistenceTests.cs NATS.Server.Mqtt.Tests/
git mv NATS.Server.Tests/Mqtt NATS.Server.Mqtt.Tests/Mqtt
```
**Step 3: Update namespaces**
- `namespace NATS.Server.Tests;``namespace NATS.Server.Mqtt.Tests;`
- `namespace NATS.Server.Tests.Mqtt;``namespace NATS.Server.Mqtt.Tests.Mqtt;`
**Step 4: Replace duplicated helpers with TestUtilities calls** (same pattern as Task 2)
**Step 5: Add to solution file**
**Step 6: Build and test**
Run: `dotnet build && dotnet test tests/NATS.Server.Mqtt.Tests --no-build`
**Step 7: Commit**
```bash
git add -A
git commit -m "refactor: extract NATS.Server.Mqtt.Tests project"
```
---
### Task 4: Extract NATS.Server.Gateways.Tests (~25 files)
**Files:**
- Create: `tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj`
- Move: Root files: GatewayAdvancedRemapRuntimeTests.cs, GatewayAdvancedSemanticsTests.cs, GatewayLeafBootstrapTests.cs, GatewayProtocolTests.cs
- Move: `Gateways/` folder (21 files)
**Steps:** Same pattern as Tasks 2-3.
Namespace changes:
- `namespace NATS.Server.Tests;``namespace NATS.Server.Gateways.Tests;`
- `namespace NATS.Server.Tests.Gateways;``namespace NATS.Server.Gateways.Tests.Gateways;`
May need `NATS.Client.Core` package if any gateway test uses `NatsConnection`.
**Commit:** `git commit -m "refactor: extract NATS.Server.Gateways.Tests project"`
---
### Task 5: Extract NATS.Server.LeafNodes.Tests (~30 files)
**Files:**
- Create: `tests/NATS.Server.LeafNodes.Tests/NATS.Server.LeafNodes.Tests.csproj`
- Move: Root files: LeafAdvancedSemanticsTests.cs, LeafProtocolTests.cs
- Move: `LeafNodes/` folder (26 files) — note: `LeafFixture.cs` already moved to TestUtilities
- Move: `LeafNode/` folder (1 file)
**Steps:** Same pattern. The `LeafFixture` reference now comes from TestUtilities — add `using NATS.Server.TestUtilities;`.
Namespace changes:
- `namespace NATS.Server.Tests;``namespace NATS.Server.LeafNodes.Tests;`
- `namespace NATS.Server.Tests.LeafNodes;``namespace NATS.Server.LeafNodes.Tests.LeafNodes;`
- `namespace NATS.Server.Tests.LeafNode;``namespace NATS.Server.LeafNodes.Tests.LeafNode;`
**Commit:** `git commit -m "refactor: extract NATS.Server.LeafNodes.Tests project"`
---
### Task 6: Extract NATS.Server.Clustering.Tests (~30 files)
**Files:**
- Create: `tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj`
- Move: Root files: RouteHandshakeTests.cs, RoutePoolTests.cs, RouteRmsgForwardingTests.cs, RouteSubscriptionPropagationTests.cs, RouteWireSubscriptionProtocolTests.cs, ImplicitDiscoveryTests.cs, InterServerAccountProtocolTests.cs
- Move: `Routes/` folder (21 files)
- Move: `Route/` folder (1 file)
**Steps:** Same pattern.
Namespace changes:
- `namespace NATS.Server.Tests;``namespace NATS.Server.Clustering.Tests;`
- `namespace NATS.Server.Tests.Routes;``namespace NATS.Server.Clustering.Tests.Routes;`
- `namespace NATS.Server.Tests.Route;``namespace NATS.Server.Clustering.Tests.Route;`
**Commit:** `git commit -m "refactor: extract NATS.Server.Clustering.Tests project"`
---
### Task 7: Extract NATS.Server.Raft.Tests (~45 files)
**Files:**
- Create: `tests/NATS.Server.Raft.Tests/NATS.Server.Raft.Tests.csproj`
- Move: Root files: RaftConsensusAdvancedParityTests.cs, RaftElectionTests.cs, RaftMembershipParityTests.cs, RaftReplicationTests.cs, RaftSafetyContractTests.cs, RaftSnapshotCatchupTests.cs, RaftSnapshotTransferParityTests.cs, RaftTransportPersistenceTests.cs
- Move: `Raft/` folder (36 files)
**Steps:** Same pattern.
Namespace changes:
- `namespace NATS.Server.Tests;``namespace NATS.Server.Raft.Tests;`
- `namespace NATS.Server.Tests.Raft;``namespace NATS.Server.Raft.Tests.Raft;`
**Commit:** `git commit -m "refactor: extract NATS.Server.Raft.Tests project"`
---
### Task 8: Extract NATS.Server.Monitoring.Tests (~35 files)
**Files:**
- Create: `tests/NATS.Server.Monitoring.Tests/NATS.Server.Monitoring.Tests.csproj`
- Move: Root files: EventSystemTests.cs, JszMonitorTests.cs, MonitorClusterEndpointTests.cs, MonitorModelTests.cs, MonitorTests.cs, SubszTests.cs, SystemEventsTests.cs, SystemRequestReplyTests.cs
- Move: `Monitoring/` folder (21 files)
- Move: `Events/` folder (10 files)
**Steps:** Same pattern. Needs `NATS.Client.Core` package for integration tests.
Namespace changes:
- `namespace NATS.Server.Tests;``namespace NATS.Server.Monitoring.Tests;`
- `namespace NATS.Server.Tests.Monitoring;``namespace NATS.Server.Monitoring.Tests.Monitoring;`
- `namespace NATS.Server.Tests.Events;``namespace NATS.Server.Monitoring.Tests.Events;`
**Commit:** `git commit -m "refactor: extract NATS.Server.Monitoring.Tests project"`
---
### Task 9: Extract NATS.Server.Auth.Tests (~50 files)
**Files:**
- Create: `tests/NATS.Server.Auth.Tests/NATS.Server.Auth.Tests.csproj`
- Move: Root files: AccountIsolationTests.cs, AccountResolverTests.cs, AccountStatsTests.cs, AccountTests.cs, AuthConfigTests.cs, AuthIntegrationTests.cs, AuthProtocolTests.cs, AuthServiceTests.cs, ClientPermissionsTests.cs, JwtAuthenticatorTests.cs, JwtTests.cs, NKeyAuthenticatorTests.cs, NKeyIntegrationTests.cs, PermissionIntegrationTests.cs, PermissionLruCacheTests.cs, PermissionTemplateTests.cs, SimpleUserPasswordAuthenticatorTests.cs, TokenAuthenticatorTests.cs, UserPasswordAuthenticatorTests.cs, ImportExportTests.cs
- Move: `Auth/` folder (25 files)
- Move: `Accounts/` folder (5 files)
**Steps:** Same pattern. Needs `NATS.NKeys` and `NATS.Client.Core` packages.
Namespace changes:
- `namespace NATS.Server.Tests;``namespace NATS.Server.Auth.Tests;`
- `namespace NATS.Server.Tests.Auth;``namespace NATS.Server.Auth.Tests.Auth;`
- `namespace NATS.Server.Tests.Accounts;``namespace NATS.Server.Auth.Tests.Accounts;`
**Commit:** `git commit -m "refactor: extract NATS.Server.Auth.Tests project"`
---
### Task 10: Extract NATS.Server.JetStream.Tests (~220 files)
**Files:**
- Create: `tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj`
- Move: All root `JetStream*` files (~55 files)
- Move: Root storage files: FileStoreTests.cs, FileStoreEncryptionTests.cs, MemStoreTests.cs, StreamStoreContractTests.cs, MirrorSourceRetryTests.cs, ClusterJetStreamConfigProcessorTests.cs
- Move: `JetStream/` folder and all sub-folders (163 files) — note: `JetStreamClusterFixture.cs` already in TestUtilities
**Step 1: Create csproj with JetStream-specific additions**
```xml
<!-- tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<DefineConstants>$(DefineConstants);JETSTREAM_INTEGRATION_MATRIX</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
</ItemGroup>
</Project>
```
**Step 2: Move files** — this is the largest move. Use a script:
```bash
cd tests
mkdir -p NATS.Server.JetStream.Tests
# Move JetStream subfolder (preserves internal structure)
git mv NATS.Server.Tests/JetStream NATS.Server.JetStream.Tests/JetStream
# Move root JetStream* files
for f in NATS.Server.Tests/JetStream*.cs; do
git mv "$f" NATS.Server.JetStream.Tests/
done
# Move storage-related root files
git mv NATS.Server.Tests/FileStoreTests.cs NATS.Server.JetStream.Tests/
git mv NATS.Server.Tests/FileStoreEncryptionTests.cs NATS.Server.JetStream.Tests/
git mv NATS.Server.Tests/MemStoreTests.cs NATS.Server.JetStream.Tests/
git mv NATS.Server.Tests/StreamStoreContractTests.cs NATS.Server.JetStream.Tests/
git mv NATS.Server.Tests/MirrorSourceRetryTests.cs NATS.Server.JetStream.Tests/
git mv NATS.Server.Tests/ClusterJetStreamConfigProcessorTests.cs NATS.Server.JetStream.Tests/
```
**Step 3: Update namespaces**
- `namespace NATS.Server.Tests;``namespace NATS.Server.JetStream.Tests;` (root files)
- `namespace NATS.Server.Tests.JetStream;``namespace NATS.Server.JetStream.Tests.JetStream;`
- All JetStream sub-namespaces follow the pattern (e.g., `NATS.Server.Tests.JetStream.Cluster``NATS.Server.JetStream.Tests.JetStream.Cluster`)
**Step 4: Update fixture references**
Files using `JetStreamApiFixture` or `JetStreamClusterFixture` need `using NATS.Server.TestUtilities;` since the fixtures moved there in Task 1.
**Step 5: Replace duplicated helpers with TestUtilities calls**
**Step 6: Add to solution, build, test**
Run: `dotnet build && dotnet test tests/NATS.Server.JetStream.Tests --no-build`
**Step 7: Commit**
```bash
git add -A
git commit -m "refactor: extract NATS.Server.JetStream.Tests project"
```
---
### Task 11: Rename remaining project to NATS.Server.Core.Tests
**Files:**
- Rename: `tests/NATS.Server.Tests/``tests/NATS.Server.Core.Tests/`
- Rename: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj``tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj`
- Modify: `NatsDotNet.slnx` (update path)
**Step 1: Rename directory and csproj**
```bash
cd tests
git mv NATS.Server.Tests NATS.Server.Core.Tests
cd NATS.Server.Core.Tests
git mv NATS.Server.Tests.csproj NATS.Server.Core.Tests.csproj
```
**Step 2: Update solution file**
Replace `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` with `tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj`.
**Step 3: Clean up csproj**
Remove the `JETSTREAM_INTEGRATION_MATRIX` DefineConstants (that moved to JetStream.Tests). Remove any package references only needed by extracted projects (e.g., `NATS.NKeys` if only auth tests needed it). Keep `NATS.Client.Core` and `Serilog.Sinks.File`.
**Step 4: Update namespaces**
Change `namespace NATS.Server.Tests;``namespace NATS.Server.Core.Tests;` in all remaining files.
Update sub-namespaces: `NATS.Server.Tests.Configuration``NATS.Server.Core.Tests.Configuration`, etc.
**Step 5: Replace duplicated helpers with TestUtilities calls**
**Step 6: Build and test all projects**
Run: `dotnet build && dotnet test`
Expected: All projects build and all tests pass.
**Step 7: Commit**
```bash
git add -A
git commit -m "refactor: rename remaining tests to NATS.Server.Core.Tests"
```
---
### Task 12: Final verification and cleanup
**Step 1: Run full test suite**
```bash
dotnet test -v normal 2>&1 | tail -20
```
Expected: All test projects discovered and run. Total test count should match original (~6,409 parameterized tests).
**Step 2: Verify each project runs independently**
```bash
dotnet test tests/NATS.Server.Core.Tests
dotnet test tests/NATS.Server.Auth.Tests
dotnet test tests/NATS.Server.JetStream.Tests
dotnet test tests/NATS.Server.Raft.Tests
dotnet test tests/NATS.Server.Clustering.Tests
dotnet test tests/NATS.Server.Gateways.Tests
dotnet test tests/NATS.Server.LeafNodes.Tests
dotnet test tests/NATS.Server.Mqtt.Tests
dotnet test tests/NATS.Server.Monitoring.Tests
dotnet test tests/NATS.Server.Transport.Tests
dotnet test tests/NATS.E2E.Tests
```
**Step 3: Verify solution structure**
```bash
dotnet sln NatsDotNet.slnx list
```
Expected: 13 projects listed (2 src + 11 test).
**Step 4: Check for orphaned files**
```bash
find tests/NATS.Server.Core.Tests -name "*.cs" -not -path "*/obj/*" -not -path "*/bin/*" | wc -l
```
Should be ~75 files. Any file that doesn't belong in Core should be moved to its correct project.
**Step 5: Clean build artifacts and rebuild from scratch**
```bash
dotnet clean && dotnet build && dotnet test
```
**Step 6: Commit any cleanup**
```bash
git add -A
git commit -m "chore: final cleanup after test project split"
```
@@ -0,0 +1,19 @@
{
"planPath": "docs/plans/2026-03-12-test-project-split-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Create NATS.Server.TestUtilities project", "status": "pending"},
{"id": 1, "subject": "Task 1: Move shared fixtures and parity utilities to TestUtilities", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Extract NATS.Server.Transport.Tests (~25 files)", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Extract NATS.Server.Mqtt.Tests (~30 files)", "status": "pending", "blockedBy": [1]},
{"id": 4, "subject": "Task 4: Extract NATS.Server.Gateways.Tests (~25 files)", "status": "pending", "blockedBy": [1]},
{"id": 5, "subject": "Task 5: Extract NATS.Server.LeafNodes.Tests (~30 files)", "status": "pending", "blockedBy": [1]},
{"id": 6, "subject": "Task 6: Extract NATS.Server.Clustering.Tests (~30 files)", "status": "pending", "blockedBy": [1]},
{"id": 7, "subject": "Task 7: Extract NATS.Server.Raft.Tests (~45 files)", "status": "pending", "blockedBy": [1]},
{"id": 8, "subject": "Task 8: Extract NATS.Server.Monitoring.Tests (~35 files)", "status": "pending", "blockedBy": [1]},
{"id": 9, "subject": "Task 9: Extract NATS.Server.Auth.Tests (~50 files)", "status": "pending", "blockedBy": [1]},
{"id": 10, "subject": "Task 10: Extract NATS.Server.JetStream.Tests (~220 files)", "status": "pending", "blockedBy": [1]},
{"id": 11, "subject": "Task 11: Rename remaining to NATS.Server.Core.Tests", "status": "pending", "blockedBy": [2, 3, 4, 5, 6, 7, 8, 9, 10]},
{"id": 12, "subject": "Task 12: Final verification and cleanup", "status": "pending", "blockedBy": [11]}
],
"lastUpdated": "2026-03-12T00:00:00Z"
}
+2 -1
View File
@@ -307,7 +307,7 @@ public sealed class Account : IDisposable
return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard); return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard);
} }
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved) public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved, ServiceLatency? latency = null)
{ {
var auth = new ExportAuth var auth = new ExportAuth
{ {
@@ -318,6 +318,7 @@ public sealed class Account : IDisposable
Auth = auth, Auth = auth,
Account = this, Account = this,
ResponseType = responseType, ResponseType = responseType,
Latency = latency,
}; };
} }
+36
View File
@@ -5,4 +5,40 @@ public sealed class AccountConfig
public int MaxConnections { get; init; } // 0 = unlimited public int MaxConnections { get; init; } // 0 = unlimited
public int MaxSubscriptions { get; init; } // 0 = unlimited public int MaxSubscriptions { get; init; } // 0 = unlimited
public Permissions? DefaultPermissions { get; init; } public Permissions? DefaultPermissions { get; init; }
/// <summary>Service and stream exports from this account.</summary>
public List<ExportDefinition>? Exports { get; init; }
/// <summary>Service and stream imports into this account.</summary>
public List<ImportDefinition>? Imports { get; init; }
}
/// <summary>
/// Represents an export declaration in config: exports = [{ service: "sub" }] or [{ stream: "sub" }].
/// Go reference: server/opts.go — parseExportStreamMap / parseExportServiceMap.
/// </summary>
public sealed class ExportDefinition
{
public string? Service { get; init; }
public string? Stream { get; init; }
/// <summary>Optional latency tracking subject (e.g. "latency.svc.echo").</summary>
public string? LatencySubject { get; init; }
/// <summary>Latency sampling percentage (1100, default 100).</summary>
public int LatencySampling { get; init; } = 100;
}
/// <summary>
/// Represents an import declaration in config:
/// imports = [{ service: { account: X, subject: "sub" }, to: "local" }].
/// Go reference: server/opts.go — parseImportStreamMap / parseImportServiceMap.
/// </summary>
public sealed class ImportDefinition
{
public string? ServiceAccount { get; init; }
public string? ServiceSubject { get; init; }
public string? StreamAccount { get; init; }
public string? StreamSubject { get; init; }
public string? To { get; init; }
} }
@@ -291,7 +291,55 @@ public static class ConfigProcessor
ParseAccounts(accountsDict, opts, errors); ParseAccounts(accountsDict, opts, errors);
break; break;
// Unknown keys silently ignored (resolver, operator, etc.) // Server-level subject mappings: mappings { src: dest }
// Go reference: server/opts.go — "mappings" case
case "mappings" or "maps":
if (value is Dictionary<string, object?> mappingsDict)
{
opts.SubjectMappings ??= new Dictionary<string, string>();
foreach (var (src, dest) in mappingsDict)
{
if (dest is string destStr)
opts.SubjectMappings[src] = destStr;
}
}
break;
// JWT operator mode — trusted operator public NKeys
// Go reference: server/opts.go — "trusted_keys" / "trusted" case
case "trusted_keys" or "trusted":
opts.TrustedKeys = ParseStringArray(value);
break;
// JWT resolver type and preload
// Go reference: server/opts.go — "resolver" case
case "resolver" or "account_resolver" or "accounts_resolver":
if (value is string resolverStr && resolverStr.Equals("MEMORY", StringComparison.OrdinalIgnoreCase))
opts.AccountResolver = new Auth.Jwt.MemAccountResolver();
break;
// Pre-load account JWTs into the resolver
// Go reference: server/opts.go — "resolver_preload" case
case "resolver_preload":
if (value is Dictionary<string, object?> preloadDict && opts.AccountResolver != null)
{
foreach (var (accNkey, jwtObj) in preloadDict)
{
if (jwtObj is string jwt)
opts.AccountResolver.StoreAsync(accNkey, jwt).GetAwaiter().GetResult();
}
}
break;
// Operator key (can derive trusted_keys from operator JWT — for now just accept NKeys directly)
case "operator" or "operators" or "root" or "roots" or "root_operators" or "root_operator":
// For simple mode: treat as trusted_keys alias if string array
opts.TrustedKeys ??= ParseStringArray(value);
break;
// Unknown keys silently ignored
default: default:
warnings.Add(new UnknownConfigFieldWarning(key).Message); warnings.Add(new UnknownConfigFieldWarning(key).Message);
break; break;
@@ -975,6 +1023,8 @@ public static class ConfigProcessor
int maxConnections = 0; int maxConnections = 0;
int maxSubscriptions = 0; int maxSubscriptions = 0;
List<object?>? userList = null; List<object?>? userList = null;
List<ExportDefinition>? exports = null;
List<ImportDefinition>? imports = null;
foreach (var (key, value) in acctDict) foreach (var (key, value) in acctDict)
{ {
@@ -989,6 +1039,21 @@ public static class ConfigProcessor
break; break;
case "max_subscriptions" or "max_subs": case "max_subscriptions" or "max_subs":
maxSubscriptions = ToInt(value); maxSubscriptions = ToInt(value);
break;
case "exports":
if (value is List<object?> exportList)
exports = ParseExports(exportList);
break;
case "imports":
if (value is List<object?> importList)
imports = ParseImports(importList);
break;
case "mappings" or "maps":
if (value is Dictionary<string, object?> mappingsDict)
{
// Account-level subject mappings not yet supported
}
break; break;
} }
} }
@@ -997,6 +1062,8 @@ public static class ConfigProcessor
{ {
MaxConnections = maxConnections, MaxConnections = maxConnections,
MaxSubscriptions = maxSubscriptions, MaxSubscriptions = maxSubscriptions,
Exports = exports,
Imports = imports,
}; };
if (userList is not null) if (userList is not null)
@@ -1020,6 +1087,140 @@ public static class ConfigProcessor
} }
} }
/// <summary>
/// Parses an exports array: [{ service: "sub" }, { stream: "sub" }].
/// Go reference: server/opts.go — parseExportStreamMap / parseExportServiceMap.
/// </summary>
private static List<ExportDefinition> ParseExports(List<object?> exportList)
{
var result = new List<ExportDefinition>();
foreach (var item in exportList)
{
if (item is not Dictionary<string, object?> dict)
continue;
string? service = null, stream = null;
string? latencySubject = null;
int latencySampling = 100;
foreach (var (k, v) in dict)
{
switch (k.ToLowerInvariant())
{
case "service":
service = ToString(v);
break;
case "stream":
stream = ToString(v);
break;
case "latency":
// latency can be a string (subject only) or a map { subject, sampling }
// Go reference: server/opts.go — parseServiceLatency
if (v is string latStr)
{
latencySubject = latStr;
}
else if (v is Dictionary<string, object?> latDict)
{
foreach (var (lk, lv) in latDict)
{
switch (lk.ToLowerInvariant())
{
case "subject":
latencySubject = ToString(lv);
break;
case "sampling":
latencySampling = ToInt(lv);
break;
}
}
}
break;
}
}
result.Add(new ExportDefinition
{
Service = service,
Stream = stream,
LatencySubject = latencySubject,
LatencySampling = latencySampling,
});
}
return result;
}
/// <summary>
/// Parses an imports array: [{ service: { account: X, subject: "sub" }, to: "local" }].
/// Go reference: server/opts.go — parseImportStreamMap / parseImportServiceMap.
/// </summary>
private static List<ImportDefinition> ParseImports(List<object?> importList)
{
var result = new List<ImportDefinition>();
foreach (var item in importList)
{
if (item is not Dictionary<string, object?> dict)
continue;
string? serviceAccount = null, serviceSubject = null;
string? streamAccount = null, streamSubject = null;
string? to = null;
foreach (var (k, v) in dict)
{
switch (k.ToLowerInvariant())
{
case "service" when v is Dictionary<string, object?> svcDict:
foreach (var (sk, sv) in svcDict)
{
switch (sk.ToLowerInvariant())
{
case "account":
serviceAccount = ToString(sv);
break;
case "subject":
serviceSubject = ToString(sv);
break;
}
}
break;
case "stream" when v is Dictionary<string, object?> strmDict:
foreach (var (sk, sv) in strmDict)
{
switch (sk.ToLowerInvariant())
{
case "account":
streamAccount = ToString(sv);
break;
case "subject":
streamSubject = ToString(sv);
break;
}
}
break;
case "to":
to = ToString(v);
break;
}
}
result.Add(new ImportDefinition
{
ServiceAccount = serviceAccount,
ServiceSubject = serviceSubject,
StreamAccount = streamAccount,
StreamSubject = streamSubject,
To = to,
});
}
return result;
}
/// <summary> /// <summary>
/// Splits a users array into plain users and NKey users. /// Splits a users array into plain users and NKey users.
/// An entry with an "nkey" field is an NKey user; entries with "user" are plain users. /// An entry with an "nkey" field is an NKey user; entries with "user" are plain users.
@@ -1623,6 +1824,30 @@ public static class ConfigProcessor
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to double"), _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to double"),
}; };
/// <summary>
/// Parses a config value that can be a single string or a list of strings into a string[].
/// Go reference: server/opts.go — parseTrustedKeys accepts string, []string, []interface{}.
/// </summary>
private static string[]? ParseStringArray(object? value)
{
if (value is List<object?> list)
{
var result = new List<string>(list.Count);
foreach (var item in list)
{
if (item is string s)
result.Add(s);
}
return result.Count > 0 ? result.ToArray() : null;
}
if (value is string str)
return [str];
return null;
}
private static IReadOnlyList<string> ToStringList(object? value) private static IReadOnlyList<string> ToStringList(object? value)
{ {
if (value is List<object?> list) if (value is List<object?> list)
+11 -1
View File
@@ -210,7 +210,17 @@ public sealed class GatewayManager : IAsyncDisposable
_options.Port = ((IPEndPoint)_listener.LocalEndPoint!).Port; _options.Port = ((IPEndPoint)_listener.LocalEndPoint!).Port;
_acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token)); _acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
foreach (var remote in _options.Remotes.Distinct(StringComparer.OrdinalIgnoreCase))
// Collect outbound endpoints from both the legacy Remotes list and the
// config-parsed RemoteGateways list (populated by ConfigProcessor).
var endpoints = new HashSet<string>(_options.Remotes, StringComparer.OrdinalIgnoreCase);
foreach (var rgw in _options.RemoteGateways)
{
foreach (var uri in rgw.GetUrls())
endpoints.Add($"{uri.Host}:{uri.Port}");
}
foreach (var remote in endpoints)
_ = Task.Run(() => ConnectWithRetryAsync(remote, _cts.Token)); _ = Task.Run(() => ConnectWithRetryAsync(remote, _cts.Token));
_logger.LogDebug("Gateway manager started (name={Name}, listen={Host}:{Port})", _logger.LogDebug("Gateway manager started (name={Name}, listen={Host}:{Port})",
@@ -308,6 +308,18 @@ public static class ConsumerApiHandlers
if (configEl.TryGetProperty("ephemeral", out var ephemeralEl) && ephemeralEl.ValueKind == JsonValueKind.True) if (configEl.TryGetProperty("ephemeral", out var ephemeralEl) && ephemeralEl.ValueKind == JsonValueKind.True)
config.Ephemeral = true; config.Ephemeral = true;
// Go: consumer.go — deliver_subject marks a consumer as push-based.
// Reference: server/consumer.go:deliverSubject field on ConsumerConfig
if (configEl.TryGetProperty("deliver_subject", out var deliverSubjectEl))
{
var ds = deliverSubjectEl.GetString();
if (!string.IsNullOrWhiteSpace(ds))
{
config.DeliverSubject = ds;
config.Push = true; // presence of deliver_subject implies push mode
}
}
if (configEl.TryGetProperty("push", out var pushEl) && pushEl.ValueKind == JsonValueKind.True) if (configEl.TryGetProperty("push", out var pushEl) && pushEl.ValueKind == JsonValueKind.True)
config.Push = true; config.Push = true;
@@ -516,6 +516,16 @@ public static class StreamApiHandlers
config.Storage = StorageType.Memory; config.Storage = StorageType.Memory;
} }
// Go: stream.go — mirror field is a StreamSource object with at minimum a "name" key.
// Reference: server/stream.go:NormalizeConfig mirror handling
if (root.TryGetProperty("mirror", out var mirrorEl))
{
if (mirrorEl.ValueKind == JsonValueKind.Object && mirrorEl.TryGetProperty("name", out var mirrorNameEl))
config.Mirror = mirrorNameEl.GetString();
else if (mirrorEl.ValueKind == JsonValueKind.String)
config.Mirror = mirrorEl.GetString();
}
if (root.TryGetProperty("source", out var sourceEl)) if (root.TryGetProperty("source", out var sourceEl))
config.Source = sourceEl.GetString(); config.Source = sourceEl.GetString();
@@ -157,6 +157,8 @@ public sealed class JetStreamApiResponse
max_deliver = c.MaxDeliver, max_deliver = c.MaxDeliver,
max_ack_pending = c.MaxAckPending, max_ack_pending = c.MaxAckPending,
filter_subject = c.FilterSubject, filter_subject = c.FilterSubject,
// Go: consumer.go — deliver_subject present for push consumers
deliver_subject = string.IsNullOrEmpty(c.DeliverSubject) ? null : c.DeliverSubject,
}; };
public static JetStreamApiResponse NotFound(string subject) => new() public static JetStreamApiResponse NotFound(string subject) => new()
@@ -239,6 +239,16 @@ public sealed class LeafNodeManager : IAsyncDisposable
foreach (var remote in _options.Remotes.Distinct(StringComparer.OrdinalIgnoreCase)) foreach (var remote in _options.Remotes.Distinct(StringComparer.OrdinalIgnoreCase))
_ = Task.Run(() => ConnectSolicitedWithRetryAsync(remote, _options.JetStreamDomain, _cts.Token)); _ = Task.Run(() => ConnectSolicitedWithRetryAsync(remote, _options.JetStreamDomain, _cts.Token));
// Also start solicited connections for remotes parsed from the config file (RemoteLeaves).
// RemoteLeaves are populated by the config parser from leafnodes.remotes[] blocks;
// _options.Remotes is the simple programmatic list only.
// Go reference: leafnode.go — createLeafNode starts solicited connections via connectToRemoteLeaf.
foreach (var remoteLeaf in _options.RemoteLeaves)
{
foreach (var url in remoteLeaf.Urls.Distinct(StringComparer.OrdinalIgnoreCase))
_ = Task.Run(() => ConnectSolicitedWithRetryAsync(url, _options.JetStreamDomain, _cts.Token));
}
_logger.LogDebug("Leaf manager started (listen={Host}:{Port})", _options.Host, _options.Port); _logger.LogDebug("Leaf manager started (listen={Host}:{Port})", _options.Host, _options.Port);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -819,6 +829,12 @@ public sealed class LeafNodeManager : IAsyncDisposable
private static IPEndPoint ParseEndpoint(string endpoint) private static IPEndPoint ParseEndpoint(string endpoint)
{ {
// Handle full URLs with a scheme (e.g. "nats-leaf://127.0.0.1:5222").
// Uri.TryCreate handles both schemed URLs and bare "host:port" strings.
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
return new IPEndPoint(IPAddress.Parse(uri.Host), uri.Port);
// Fall back to bare "host:port" splitting for plain strings without a scheme.
var parts = endpoint.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var parts = endpoint.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) if (parts.Length != 2)
throw new FormatException($"Invalid endpoint: {endpoint}"); throw new FormatException($"Invalid endpoint: {endpoint}");
+10 -1
View File
@@ -1,6 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="NATS.Server.Tests" /> <InternalsVisibleTo Include="NATS.Server.Core.Tests" />
<InternalsVisibleTo Include="NATS.Server.Transport.Tests" />
<InternalsVisibleTo Include="NATS.Server.Mqtt.Tests" />
<InternalsVisibleTo Include="NATS.Server.Gateways.Tests" />
<InternalsVisibleTo Include="NATS.Server.LeafNodes.Tests" />
<InternalsVisibleTo Include="NATS.Server.Clustering.Tests" />
<InternalsVisibleTo Include="NATS.Server.Raft.Tests" />
<InternalsVisibleTo Include="NATS.Server.Monitoring.Tests" />
<InternalsVisibleTo Include="NATS.Server.Auth.Tests" />
<InternalsVisibleTo Include="NATS.Server.JetStream.Tests" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
+123 -2
View File
@@ -529,6 +529,17 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_systemAccount = new Account(Account.SystemAccountName) { IsSystemAccount = true }; _systemAccount = new Account(Account.SystemAccountName) { IsSystemAccount = true };
_accounts[Account.SystemAccountName] = _systemAccount; _accounts[Account.SystemAccountName] = _systemAccount;
// If a user-defined system_account is configured, promote that account to be the
// system account. Events published to $SYS.* will be delivered to subscribers on
// this account. Go reference: server/server.go — configureAccounts / setSystemAccount.
if (!string.IsNullOrEmpty(options.SystemAccount) &&
!string.Equals(options.SystemAccount, Account.SystemAccountName, StringComparison.OrdinalIgnoreCase))
{
var userSysAccount = GetOrCreateAccount(options.SystemAccount);
userSysAccount.IsSystemAccount = true;
_systemAccount = userSysAccount;
}
// Create system internal client and event system // Create system internal client and event system
var sysClientId = Interlocked.Increment(ref _nextClientId); var sysClientId = Interlocked.Increment(ref _nextClientId);
var sysClient = new InternalClient(sysClientId, ClientKind.System, _systemAccount); var sysClient = new InternalClient(sysClientId, ClientKind.System, _systemAccount);
@@ -1312,7 +1323,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (si.Invalid) continue; if (si.Invalid) continue;
if (SubjectMatch.MatchLiteral(subject, si.From)) if (SubjectMatch.MatchLiteral(subject, si.From))
{ {
ProcessServiceImport(si, subject, replyTo, headers, payload); ProcessServiceImport(si, subject, replyTo, headers, payload, sender.Account);
delivered = true; delivered = true;
} }
} }
@@ -1453,7 +1464,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Reference: Go server/accounts.go addServiceImport / processServiceImport. /// Reference: Go server/accounts.go addServiceImport / processServiceImport.
/// </summary> /// </summary>
public void ProcessServiceImport(ServiceImport si, string subject, string? replyTo, public void ProcessServiceImport(ServiceImport si, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload) ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, Account? sourceAccount = null)
{ {
if (si.Invalid) return; if (si.Invalid) return;
@@ -1477,6 +1488,24 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
targetSubject = MapImportSubject(subject, si.From, si.To); targetSubject = MapImportSubject(subject, si.From, si.To);
} }
// Set up a temporary reverse service import so that responses from the
// destination (exporter) account can route back to the source (importer)
// account. This handles request-reply across account boundaries.
// Go reference: client.go setupResponseServiceImport
if (replyTo != null && sourceAccount != null && !si.IsResponse)
{
SetupResponseServiceImport(si.DestinationAccount, sourceAccount, replyTo, si.Export);
}
// Service latency tracking: when the response arrives back, compute elapsed
// time and publish a latency metric to the configured subject.
// Go reference: client.go processServiceImport — latency tracking path.
if (si.IsResponse && si.Tracking && si.TimestampTicks > 0)
{
var elapsed = TimeSpan.FromTicks(Environment.TickCount64 * TimeSpan.TicksPerMillisecond - si.TimestampTicks);
PublishServiceLatency(si, elapsed);
}
// Match against destination account's SubList // Match against destination account's SubList
var destSubList = si.DestinationAccount.SubList; var destSubList = si.DestinationAccount.SubList;
var result = destSubList.Match(targetSubject); var result = destSubList.Match(targetSubject);
@@ -1498,6 +1527,36 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
} }
} }
/// <summary>
/// Creates a temporary reverse service import in the exporter's account so that
/// when the exporter publishes a response to the reply subject, the message is
/// forwarded back to the importer's account where the reply subscription lives.
/// Go reference: client.go setupResponseServiceImport.
/// </summary>
private static void SetupResponseServiceImport(Account exporterAccount, Account importerAccount, string replyTo, ServiceExport? export = null)
{
// Check if a reverse import for this reply subject already exists
if (exporterAccount.Imports.Services.ContainsKey(replyTo))
return;
// Determine if we should track latency for this response
var shouldTrack = export?.Latency is { } latency && LatencyTracker.ShouldSample(latency);
var reverseImport = new ServiceImport
{
DestinationAccount = importerAccount,
From = replyTo,
To = replyTo,
IsResponse = true,
UsePub = true,
Export = export,
Tracking = shouldTrack,
// Store start time as TickCount64 (milliseconds) converted to ticks for elapsed computation
TimestampTicks = shouldTrack ? Environment.TickCount64 * TimeSpan.TicksPerMillisecond : 0,
};
exporterAccount.Imports.AddServiceImport(reverseImport);
}
/// <summary> /// <summary>
/// Maps a published subject from the import "From" pattern to the "To" pattern. /// Maps a published subject from the import "From" pattern to the "To" pattern.
/// For example, if From="requests.>" and To="api.>" and subject="requests.test", /// For example, if From="requests.>" and To="api.>" and subject="requests.test",
@@ -1633,11 +1692,54 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
acc.MaxConnections = config.MaxConnections; acc.MaxConnections = config.MaxConnections;
acc.MaxSubscriptions = config.MaxSubscriptions; acc.MaxSubscriptions = config.MaxSubscriptions;
acc.DefaultPermissions = config.DefaultPermissions; acc.DefaultPermissions = config.DefaultPermissions;
// Wire exports from config
if (config.Exports != null)
{
foreach (var export in config.Exports)
{
if (export.Service is { Length: > 0 } svc)
{
ServiceLatency? latency = export.LatencySubject is { Length: > 0 }
? new ServiceLatency { Subject = export.LatencySubject, SamplingPercentage = export.LatencySampling }
: null;
acc.AddServiceExport(svc, Imports.ServiceResponseType.Singleton, approved: null, latency: latency);
}
else if (export.Stream is { Length: > 0 } strm)
{
acc.AddStreamExport(strm, approved: null);
}
}
}
// Wire imports from config (deferred — needs destination accounts resolved)
if (config.Imports != null)
WireAccountImports(acc, config.Imports);
} }
return acc; return acc;
}); });
} }
private void WireAccountImports(Account importer, List<Auth.ImportDefinition> imports)
{
foreach (var imp in imports)
{
if (imp.ServiceAccount is { Length: > 0 } svcAcct && imp.ServiceSubject is { Length: > 0 } svcSubj)
{
var dest = GetOrCreateAccount(svcAcct);
var localSubject = imp.To ?? svcSubj;
importer.AddServiceImport(dest, from: localSubject, to: svcSubj);
}
else if (imp.StreamAccount is { Length: > 0 } strmAcct && imp.StreamSubject is { Length: > 0 } strmSubj)
{
var source = GetOrCreateAccount(strmAcct);
var localSubject = imp.To ?? strmSubj;
importer.AddStreamImport(source, from: strmSubj, to: localSubject);
}
}
}
/// <summary> /// <summary>
/// Returns true if the subject belongs to the $SYS subject space. /// Returns true if the subject belongs to the $SYS subject space.
/// Reference: Go server/server.go — isReservedSubject. /// Reference: Go server/server.go — isReservedSubject.
@@ -1675,6 +1777,25 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
return account?.SubList ?? _globalAccount.SubList; return account?.SubList ?? _globalAccount.SubList;
} }
/// <summary>
/// Publishes a service latency metric message to the configured latency subject.
/// Go reference: client.go processServiceImport — trackLatency path.
/// </summary>
private void PublishServiceLatency(ServiceImport si, TimeSpan elapsed)
{
var latency = si.Export?.Latency;
if (latency == null || string.IsNullOrEmpty(latency.Subject))
return;
var msg = LatencyTracker.BuildLatencyMsg(
requestor: si.DestinationAccount.Name,
responder: si.Export?.Account?.Name ?? "unknown",
serviceLatency: elapsed,
totalLatency: elapsed);
SendInternalMsg(latency.Subject, reply: null, msg);
}
public void SendInternalMsg(string subject, string? reply, object? msg) public void SendInternalMsg(string subject, string? reply, object? msg)
{ {
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg }); _eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg });
+48 -9
View File
@@ -439,9 +439,13 @@ public sealed class RouteManager : IAsyncDisposable
if (_routes.IsEmpty) if (_routes.IsEmpty)
return; return;
// Send once per peer (deduplicate by RemoteServerId).
var sentToPeers = new HashSet<string>(StringComparer.Ordinal);
foreach (var route in _routes.Values) foreach (var route in _routes.Values)
{ {
_ = route.SendRsPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None); var peerId = route.RemoteServerId ?? route.RemoteEndpoint;
if (sentToPeers.Add(peerId))
_ = route.SendRsPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
} }
} }
@@ -450,8 +454,13 @@ public sealed class RouteManager : IAsyncDisposable
if (_routes.IsEmpty) if (_routes.IsEmpty)
return; return;
var sentToPeers = new HashSet<string>(StringComparer.Ordinal);
foreach (var route in _routes.Values) foreach (var route in _routes.Values)
_ = route.SendRsMinusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None); {
var peerId = route.RemoteServerId ?? route.RemoteEndpoint;
if (sentToPeers.Add(peerId))
_ = route.SendRsMinusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
}
} }
public async Task ForwardRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct) public async Task ForwardRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
@@ -459,18 +468,28 @@ public sealed class RouteManager : IAsyncDisposable
if (_routes.IsEmpty) if (_routes.IsEmpty)
return; return;
// Use account-based pool routing: route the message only through the // Pool routing selects among multiple connections to the SAME peer.
// connection responsible for this account, matching Go's behavior. // When pool routes exist, use account-based hashing to pick one.
var route = GetRouteForAccount(account); // Go reference: server/route.go — broadcastMsgToRoutes sends to all
if (route != null) // route connections; pool routing only selects within a per-peer pool.
var poolRoutes = _routes.Values.Where(r => r.SupportsPooling).ToArray();
if (poolRoutes.Length > 0)
{ {
await route.SendRmsgAsync(account, subject, replyTo, payload, ct); var idx = ComputeRoutePoolIdx(poolRoutes.Length, account);
await poolRoutes[idx % poolRoutes.Length].SendRmsgAsync(account, subject, replyTo, payload, ct);
return; return;
} }
// Fallback: broadcast to all routes if pool routing fails // No pool routing — send once per peer (deduplicate by RemoteServerId).
// A node may have multiple connections to the same peer (inbound + outbound).
// Go reference: server/route.go — broadcastMsgToRoutes sends once per peer.
var sentToPeers = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in _routes.Values) foreach (var r in _routes.Values)
await r.SendRmsgAsync(account, subject, replyTo, payload, ct); {
var peerId = r.RemoteServerId ?? r.RemoteEndpoint;
if (sentToPeers.Add(peerId))
await r.SendRmsgAsync(account, subject, replyTo, payload, ct);
}
} }
private async Task AcceptLoopAsync(CancellationToken ct) private async Task AcceptLoopAsync(CancellationToken ct)
@@ -506,6 +525,16 @@ public sealed class RouteManager : IAsyncDisposable
try try
{ {
await route.PerformInboundHandshakeAsync(_serverId, ct); await route.PerformInboundHandshakeAsync(_serverId, ct);
// Detect self-connections (node connecting to itself via routes list).
// Go reference: server/route.go — processRouteConnect checks remote ID.
if (string.Equals(route.RemoteServerId, _serverId, StringComparison.Ordinal))
{
_logger.LogDebug("Rejecting inbound self-route from {RemoteEndpoint}", route.RemoteEndpoint);
await route.DisposeAsync();
return;
}
Register(route); Register(route);
} }
catch (Exception ex) catch (Exception ex)
@@ -530,6 +559,16 @@ public sealed class RouteManager : IAsyncDisposable
await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct); await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
var connection = new RouteConnection(socket) { PoolIndex = poolIndex, IsSolicited = true }; var connection = new RouteConnection(socket) { PoolIndex = poolIndex, IsSolicited = true };
await connection.PerformOutboundHandshakeAsync(_serverId, ct); await connection.PerformOutboundHandshakeAsync(_serverId, ct);
// Detect self-connections (node connecting to itself via routes list).
// Go reference: server/route.go — processRouteConnect checks remote ID.
if (string.Equals(connection.RemoteServerId, _serverId, StringComparison.Ordinal))
{
_logger.LogDebug("Dropping self-route to {Route}", route);
await connection.DisposeAsync();
return;
}
Register(connection); Register(connection);
return; return;
} }
+352
View File
@@ -0,0 +1,352 @@
using System.Text;
using System.Text.Json;
using NATS.Client.Core;
using NATS.E2E.Tests.Infrastructure;
using NATS.NKeys;
namespace NATS.E2E.Tests;
public class AdvancedTests
{
[Fact]
public async Task ConfigFile_FullConfig_ServerStartsAndAcceptsConnections()
{
var config = """
server_name: e2e-config-test
max_payload: 2048
max_connections: 100
""";
await using var server = NatsServerProcess.WithConfig(config);
await server.StartAsync();
await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{server.Port}" });
await client.ConnectAsync();
await client.PingAsync();
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
client.ServerInfo.ShouldNotBeNull();
client.ServerInfo!.MaxPayload.ShouldBe(2048);
}
[Fact]
public async Task MaxConnections_ExceedsLimit_Rejected()
{
var config = """
max_connections: 2
""";
await using var server = NatsServerProcess.WithConfig(config);
await server.StartAsync();
var url = $"nats://127.0.0.1:{server.Port}";
await using var c1 = new NatsConnection(new NatsOpts { Url = url });
await using var c2 = new NatsConnection(new NatsOpts { Url = url });
await c1.ConnectAsync();
await c1.PingAsync();
await c2.ConnectAsync();
await c2.PingAsync();
await using var c3 = new NatsConnection(new NatsOpts
{
Url = url,
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<Exception>(async () =>
{
await c3.ConnectAsync();
await c3.PingAsync();
});
ex.ShouldNotBeNull();
}
[Fact]
public async Task SystemEvents_ClientConnect_EventPublished()
{
var config = """
accounts {
SYS {
users = [{ user: "sys", password: "sys" }]
}
APP {
users = [{ user: "app", password: "app" }]
}
}
system_account: SYS
""";
await using var server = NatsServerProcess.WithConfig(config);
await server.StartAsync();
var url = $"nats://127.0.0.1:{server.Port}";
await using var sysClient = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Username = "sys", Password = "sys" },
});
await sysClient.ConnectAsync();
await using var subscription = await sysClient.SubscribeCoreAsync<string>("$SYS.ACCOUNT.*.CONNECT");
await sysClient.PingAsync();
await using var appClient = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Username = "app", Password = "app" },
});
await appClient.ConnectAsync();
await appClient.PingAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Subject.ShouldContain("CONNECT");
}
[Fact]
public async Task AccountImportExport_CrossAccountServiceCall()
{
var config = """
accounts {
PROVIDER {
users = [{ user: "provider", password: "prov" }]
exports = [
{ service: "svc.echo" }
]
}
CONSUMER {
users = [{ user: "consumer", password: "cons" }]
imports = [
{ service: { account: PROVIDER, subject: "svc.echo" } }
]
}
}
""";
await using var server = NatsServerProcess.WithConfig(config);
await server.StartAsync();
var url = $"nats://127.0.0.1:{server.Port}";
await using var provider = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Username = "provider", Password = "prov" },
});
await provider.ConnectAsync();
await using var svcSub = await provider.SubscribeCoreAsync<string>("svc.echo");
await provider.PingAsync();
var responderTask = Task.Run(async () =>
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await svcSub.Msgs.ReadAsync(cts.Token);
await provider.PublishAsync(msg.ReplyTo!, $"echo: {msg.Data}");
});
await using var consumer = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Username = "consumer", Password = "cons" },
});
await consumer.ConnectAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var reply = await consumer.RequestAsync<string, string>("svc.echo", "hello", cancellationToken: cts.Token);
reply.Data.ShouldBe("echo: hello");
await responderTask;
}
[Fact]
public async Task ServiceLatency_CrossAccountCall_LatencyMessagePublished()
{
var config = """
accounts {
SYS {
users = [{ user: "sys", password: "sys" }]
}
PROVIDER {
users = [{ user: "provider", password: "prov" }]
exports = [
{ service: "svc.echo", latency: "latency.svc.echo" }
]
}
CONSUMER {
users = [{ user: "consumer", password: "cons" }]
imports = [
{ service: { account: PROVIDER, subject: "svc.echo" } }
]
}
}
system_account: SYS
""";
await using var server = NatsServerProcess.WithConfig(config);
await server.StartAsync();
var url = $"nats://127.0.0.1:{server.Port}";
// System account client subscribes to latency events
await using var sysClient = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Username = "sys", Password = "sys" },
});
await sysClient.ConnectAsync();
await using var latencySub = await sysClient.SubscribeCoreAsync<string>("latency.svc.echo");
await sysClient.PingAsync();
// Provider sets up the echo responder
await using var provider = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Username = "provider", Password = "prov" },
});
await provider.ConnectAsync();
await using var svcSub = await provider.SubscribeCoreAsync<string>("svc.echo");
await provider.PingAsync();
var responderTask = Task.Run(async () =>
{
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await svcSub.Msgs.ReadAsync(cts2.Token);
await provider.PublishAsync(msg.ReplyTo!, $"echo: {msg.Data}");
});
// Consumer makes a cross-account service call
await using var consumer = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Username = "consumer", Password = "cons" },
});
await consumer.ConnectAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var reply = await consumer.RequestAsync<string, string>("svc.echo", "hello", cancellationToken: cts.Token);
reply.Data.ShouldBe("echo: hello");
await responderTask;
// Verify latency message was published to the system account
var latencyMsg = await latencySub.Msgs.ReadAsync(cts.Token);
latencyMsg.Subject.ShouldBe("latency.svc.echo");
latencyMsg.Data.ShouldNotBeNull();
latencyMsg.Data!.ShouldContain("service_latency");
}
[Fact]
public async Task SubjectTransforms_MappedSubject_ReceivedOnTarget()
{
var config = """
mappings {
"e2e.src": "e2e.dest"
}
""";
await using var server = NatsServerProcess.WithConfig(config);
await server.StartAsync();
var url = $"nats://127.0.0.1:{server.Port}";
await using var client = new NatsConnection(new NatsOpts { Url = url });
await client.ConnectAsync();
// Subscribe to the destination subject
await using var sub = await client.SubscribeCoreAsync<string>("e2e.dest");
await client.PingAsync();
// Publish to the source subject — should be transformed to destination
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await client.PublishAsync("e2e.src", "mapped-payload", cancellationToken: cts.Token);
await client.PingAsync(cts.Token);
var msg = await sub.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("mapped-payload");
}
[Fact]
public async Task JwtAuth_ValidJwt_Connects()
{
// Generate operator, account, and user NKey pairs
using var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
using var accountKp = KeyPair.CreatePair(PrefixByte.Account);
using var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
// Build account JWT (signed by operator)
var accountJwt = BuildJwt(new
{
sub = accountPub,
iss = operatorPub,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
nats = new { type = "account", version = 2 },
}, operatorKp);
// Build user JWT as bearer token (signed by account, no nonce needed)
var userJwt = BuildJwt(new
{
sub = userPub,
iss = accountPub,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
nats = new { type = "user", version = 2, bearer_token = true, issuer_account = accountPub },
}, accountKp);
var config = $$"""
trusted_keys: "{{operatorPub}}"
resolver: MEMORY
resolver_preload: {
{{accountPub}}: "{{accountJwt}}"
}
""";
await using var server = NatsServerProcess.WithConfig(config);
await server.StartAsync();
var url = $"nats://127.0.0.1:{server.Port}";
await using var client = new NatsConnection(new NatsOpts
{
Url = url,
AuthOpts = new NatsAuthOpts { Jwt = userJwt },
});
await client.ConnectAsync();
await client.PingAsync();
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
}
/// <summary>
/// Builds a signed NATS JWT using the given payload and NKey pair.
/// Wire format: base64url(header).base64url(payload).base64url(ed25519-signature).
/// </summary>
private static string BuildJwt(object payload, KeyPair signingKp)
{
var header = """{"typ":"jwt","alg":"ed25519-nkey"}""";
var payloadJson = JsonSerializer.Serialize(payload);
var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
var signingInput = Encoding.UTF8.GetBytes($"{headerB64}.{payloadB64}");
var sig = new byte[64];
signingKp.Sign(signingInput, sig);
return $"{headerB64}.{payloadB64}.{Base64UrlEncode(sig)}";
}
private static string Base64UrlEncode(byte[] data)
=> Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
+160
View File
@@ -0,0 +1,160 @@
using NATS.Client.Core;
using NATS.E2E.Tests.Infrastructure;
using Xunit.Abstractions;
namespace NATS.E2E.Tests;
[Collection("E2E-Cluster")]
public class ClusterTests(ClusterFixture fixture, ITestOutputHelper output)
{
[Fact]
public async Task Cluster_MessagePropagatesAcrossNodes()
{
await using var pub = fixture.CreateClient(0);
await using var sub = fixture.CreateClient(1);
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.cluster.cross");
await sub.PingAsync();
// Wait for RS+ propagation with a dedicated timeout
using var propagationCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var propagated = await WaitForCrossNodePropagationAsync(pub, sub, "e2e.cluster.cross.probe", propagationCts.Token);
if (!propagated)
DumpServerOutput(0, 1);
propagated.ShouldBeTrue("Cross-node subscription propagation did not complete in time");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await pub.PublishAsync("e2e.cluster.cross", "across-nodes", cancellationToken: cts.Token);
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("across-nodes");
}
[Fact]
public async Task Cluster_LateSubscriberReceivesMessages()
{
await using var pub = fixture.CreateClient(0);
await pub.ConnectAsync();
await using var sub = fixture.CreateClient(2);
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.cluster.late");
await sub.PingAsync();
using var propagationCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var propagated = await WaitForCrossNodePropagationAsync(pub, sub, "e2e.cluster.late.probe", propagationCts.Token);
if (!propagated)
DumpServerOutput(0, 2);
propagated.ShouldBeTrue("Cross-node subscription propagation did not complete in time");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await pub.PublishAsync("e2e.cluster.late", "late-join", cancellationToken: cts.Token);
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("late-join");
}
[Fact]
public async Task Cluster_QueueGroupAcrossNodes_DeliversOnce()
{
// Publisher on node 0, both queue subscribers on node 1.
// This tests queue group load balancing across a cluster hop.
// Both subscribers are on the same node to avoid cross-node RMSG
// duplication (full cross-node queue group routing is not yet implemented).
await using var pub = fixture.CreateClient(0);
await using var sub1 = fixture.CreateClient(1);
await using var sub2 = fixture.CreateClient(1);
await pub.ConnectAsync();
await sub1.ConnectAsync();
await sub2.ConnectAsync();
using var propagationCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var p1 = await WaitForCrossNodePropagationAsync(pub, sub1, "e2e.cluster.qg.probe1", propagationCts.Token);
if (!p1)
DumpServerOutput(0, 1);
p1.ShouldBeTrue("Cross-node propagation failed for queue group test");
await using var s1 = await sub1.SubscribeCoreAsync<int>("e2e.cluster.qg", queueGroup: "cq");
await using var s2 = await sub2.SubscribeCoreAsync<int>("e2e.cluster.qg", queueGroup: "cq");
await sub1.PingAsync();
await sub2.PingAsync();
await pub.PingAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var count1 = 0;
var count2 = 0;
var allReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
async Task Collect(INatsSub<int> sub, Action inc, CancellationToken ct)
{
await foreach (var _ in sub.Msgs.ReadAllAsync(ct))
{
inc();
if (Volatile.Read(ref count1) + Volatile.Read(ref count2) >= 20)
allReceived.TrySetResult();
}
}
_ = Collect(s1, () => Interlocked.Increment(ref count1), cts.Token);
_ = Collect(s2, () => Interlocked.Increment(ref count2), cts.Token);
for (var i = 0; i < 20; i++)
await pub.PublishAsync("e2e.cluster.qg", i, cancellationToken: cts.Token);
await pub.PingAsync(cts.Token);
await allReceived.Task.WaitAsync(cts.Token);
(count1 + count2).ShouldBe(20);
}
private void DumpServerOutput(params int[] nodeIndices)
{
foreach (var i in nodeIndices)
{
output.WriteLine($"=== Node {i} output ===");
output.WriteLine(fixture.GetServerOutput(i));
}
}
/// <summary>
/// Returns true if propagation succeeded, false if timed out.
/// </summary>
private static async Task<bool> WaitForCrossNodePropagationAsync(
NatsConnection publisher, NatsConnection subscriber, string probeSubject, CancellationToken ct)
{
try
{
await using var probeSub = await subscriber.SubscribeCoreAsync<string>(probeSubject, cancellationToken: ct);
await subscriber.PingAsync(ct);
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(150));
while (await timer.WaitForNextTickAsync(ct))
{
await publisher.PublishAsync(probeSubject, "probe", cancellationToken: ct);
await publisher.PingAsync(ct);
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
readCts.CancelAfter(TimeSpan.FromMilliseconds(500));
var received = false;
try
{
await probeSub.Msgs.ReadAsync(readCts.Token);
received = true;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
received = false; // Short read timed out, RS+ not propagated yet
}
if (received)
return true;
}
}
catch (OperationCanceledException)
{
return false; // Overall timeout expired
}
return false;
}
}
+49
View File
@@ -0,0 +1,49 @@
using NATS.E2E.Tests.Infrastructure;
namespace NATS.E2E.Tests;
[Collection("E2E-Gateway")]
public class GatewayTests(GatewayFixture fixture)
{
[Fact]
public async Task Gateway_MessageCrossesGateway()
{
await using var pub = fixture.CreateClientA();
await using var sub = fixture.CreateClientB();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.gw.cross");
// Ping both sides: subscriber's ping flushes the SUB to server B,
// publisher's ping ensures server A has received the propagated interest.
await sub.PingAsync();
await pub.PingAsync();
await pub.PublishAsync("e2e.gw.cross", "gateway-msg");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("gateway-msg");
}
[Fact]
public async Task Gateway_NoInterest_NoDelivery()
{
await using var pub = fixture.CreateClientA();
await using var sub = fixture.CreateClientB();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.gw.listen");
// Ping both sides to ensure subscription interest has propagated before publishing.
await sub.PingAsync();
await pub.PingAsync();
await pub.PublishAsync("e2e.gw.nolisten", "should-not-arrive");
await pub.PublishAsync("e2e.gw.listen", "should-arrive");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("should-arrive");
}
}
+80
View File
@@ -0,0 +1,80 @@
using NATS.Client.Core;
using NATS.E2E.Tests.Infrastructure;
namespace NATS.E2E.Tests;
[Collection("E2E")]
public class HeaderTests(NatsServerFixture fixture)
{
[Fact]
public async Task Headers_PublishWithHeaders_ReceivedIntact()
{
await using var pub = fixture.CreateClient();
await using var sub = fixture.CreateClient();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.hdr.basic");
await sub.PingAsync();
var headers = new NatsHeaders { { "X-Test-Key", "test-value" } };
await pub.PublishAsync("e2e.hdr.basic", "with-headers", headers: headers);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("with-headers");
msg.Headers.ShouldNotBeNull();
msg.Headers!["X-Test-Key"].ToString().ShouldBe("test-value");
}
[Fact]
public async Task Headers_MultipleHeaders_AllPreserved()
{
await using var pub = fixture.CreateClient();
await using var sub = fixture.CreateClient();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.hdr.multi");
await sub.PingAsync();
var headers = new NatsHeaders
{
{ "X-First", "one" },
{ "X-Second", "two" },
{ "X-Third", "three" },
};
await pub.PublishAsync("e2e.hdr.multi", "multi-hdr", headers: headers);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Headers.ShouldNotBeNull();
msg.Headers!["X-First"].ToString().ShouldBe("one");
msg.Headers!["X-Second"].ToString().ShouldBe("two");
msg.Headers!["X-Third"].ToString().ShouldBe("three");
}
[Fact]
public async Task Headers_EmptyValue_RoundTrips()
{
await using var pub = fixture.CreateClient();
await using var sub = fixture.CreateClient();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.hdr.empty");
await sub.PingAsync();
var headers = new NatsHeaders { { "X-Empty", "" } };
await pub.PublishAsync("e2e.hdr.empty", "empty-val", headers: headers);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Headers.ShouldNotBeNull();
msg.Headers!.ContainsKey("X-Empty").ShouldBeTrue();
msg.Headers!["X-Empty"].ToString().ShouldBe("");
}
}
@@ -0,0 +1,128 @@
using System.Net.Http.Json;
using NATS.Client.Core;
namespace NATS.E2E.Tests.Infrastructure;
public sealed class ClusterFixture : IAsyncLifetime
{
private NatsServerProcess _server1 = null!;
private NatsServerProcess _server2 = null!;
private NatsServerProcess _server3 = null!;
public int Port1 => _server1.Port;
public int Port2 => _server2.Port;
public int Port3 => _server3.Port;
public async Task InitializeAsync()
{
var clusterPort1 = NatsServerProcess.AllocateFreePort();
var clusterPort2 = NatsServerProcess.AllocateFreePort();
var clusterPort3 = NatsServerProcess.AllocateFreePort();
var routes = $"""
nats-route://127.0.0.1:{clusterPort1}
nats-route://127.0.0.1:{clusterPort2}
nats-route://127.0.0.1:{clusterPort3}
""";
string MakeConfig(string name, int clusterPort) => $$"""
server_name: {{name}}
cluster {
name: e2e-cluster
listen: 127.0.0.1:{{clusterPort}}
pool_size: 1
routes: [
{{routes}}
]
}
""";
_server1 = NatsServerProcess.WithConfig(MakeConfig("node1", clusterPort1), enableMonitoring: true, extraArgs: ["-DV"]);
_server2 = NatsServerProcess.WithConfig(MakeConfig("node2", clusterPort2), enableMonitoring: true, extraArgs: ["-DV"]);
_server3 = NatsServerProcess.WithConfig(MakeConfig("node3", clusterPort3), enableMonitoring: true, extraArgs: ["-DV"]);
await Task.WhenAll(
_server1.StartAsync(),
_server2.StartAsync(),
_server3.StartAsync());
// Poll until all 3 nodes each report 2 connected routes (full mesh)
await WaitForFullMeshAsync();
}
private async Task WaitForFullMeshAsync()
{
using var http = new HttpClient();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
var monitorPorts = new[] { _server1.MonitorPort!.Value, _server2.MonitorPort!.Value, _server3.MonitorPort!.Value };
while (await timer.WaitForNextTickAsync(timeout.Token).ConfigureAwait(false))
{
var allConnected = true;
foreach (var monitorPort in monitorPorts)
{
try
{
var routez = await http.GetFromJsonAsync<Routez>(
$"http://127.0.0.1:{monitorPort}/routez",
timeout.Token);
if (routez?.NumRoutes < 2)
{
allConnected = false;
break;
}
}
catch
{
allConnected = false;
break;
}
}
if (allConnected)
return;
}
throw new TimeoutException("Cluster did not form a full mesh within 30s.");
}
public async Task DisposeAsync()
{
await Task.WhenAll(
_server1.DisposeAsync().AsTask(),
_server2.DisposeAsync().AsTask(),
_server3.DisposeAsync().AsTask());
}
public string GetServerOutput(int nodeIndex) => nodeIndex switch
{
0 => _server1.Output,
1 => _server2.Output,
2 => _server3.Output,
_ => throw new ArgumentOutOfRangeException(nameof(nodeIndex)),
};
public NatsConnection CreateClient(int nodeIndex = 0)
{
var port = nodeIndex switch
{
0 => Port1,
1 => Port2,
2 => Port3,
_ => throw new ArgumentOutOfRangeException(nameof(nodeIndex)),
};
return new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" });
}
private sealed class Routez
{
[System.Text.Json.Serialization.JsonPropertyName("num_routes")]
public int NumRoutes { get; init; }
}
}
[CollectionDefinition("E2E-Cluster")]
public class ClusterCollection : ICollectionFixture<ClusterFixture>;
@@ -0,0 +1,111 @@
using System.Net.Http.Json;
using NATS.Client.Core;
namespace NATS.E2E.Tests.Infrastructure;
public sealed class GatewayFixture : IAsyncLifetime
{
private NatsServerProcess _serverA = null!;
private NatsServerProcess _serverB = null!;
public int PortA => _serverA.Port;
public int PortB => _serverB.Port;
public async Task InitializeAsync()
{
var gwPortA = NatsServerProcess.AllocateFreePort();
var gwPortB = NatsServerProcess.AllocateFreePort();
var configA = $$"""
server_name: gw-a
gateway {
name: cluster-a
listen: 127.0.0.1:{{gwPortA}}
gateways: [
{ name: cluster-b, url: nats://127.0.0.1:{{gwPortB}} }
]
}
""";
var configB = $$"""
server_name: gw-b
gateway {
name: cluster-b
listen: 127.0.0.1:{{gwPortB}}
gateways: [
{ name: cluster-a, url: nats://127.0.0.1:{{gwPortA}} }
]
}
""";
_serverA = NatsServerProcess.WithConfig(configA, enableMonitoring: true);
_serverB = NatsServerProcess.WithConfig(configB, enableMonitoring: true);
await Task.WhenAll(_serverA.StartAsync(), _serverB.StartAsync());
// Poll until both gateways report a connected outbound gateway
await WaitForGatewayConnectionAsync();
}
private async Task WaitForGatewayConnectionAsync()
{
using var http = new HttpClient();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
var monitorPorts = new[] { _serverA.MonitorPort!.Value, _serverB.MonitorPort!.Value };
while (await timer.WaitForNextTickAsync(timeout.Token).ConfigureAwait(false))
{
var allConnected = true;
foreach (var monitorPort in monitorPorts)
{
try
{
var gatewayz = await http.GetFromJsonAsync<Gatewayz>(
$"http://127.0.0.1:{monitorPort}/gatewayz",
timeout.Token);
if (gatewayz?.NumGateways < 1)
{
allConnected = false;
break;
}
}
catch (HttpRequestException)
{
// monitor not yet ready — retry on next tick
allConnected = false;
break;
}
}
if (allConnected)
return;
}
throw new TimeoutException("Gateways did not connect to each other within 30s.");
}
public async Task DisposeAsync()
{
await Task.WhenAll(
_serverA.DisposeAsync().AsTask(),
_serverB.DisposeAsync().AsTask());
}
public NatsConnection CreateClientA()
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{PortA}" });
public NatsConnection CreateClientB()
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{PortB}" });
private sealed class Gatewayz
{
[System.Text.Json.Serialization.JsonPropertyName("num_gateways")]
public int NumGateways { get; init; }
}
}
[CollectionDefinition("E2E-Gateway")]
public class GatewayCollection : ICollectionFixture<GatewayFixture>;
@@ -0,0 +1,92 @@
using System.Net.Http.Json;
using NATS.Client.Core;
namespace NATS.E2E.Tests.Infrastructure;
public sealed class LeafNodeFixture : IAsyncLifetime
{
private NatsServerProcess _hub = null!;
private NatsServerProcess _leaf = null!;
public int HubPort => _hub.Port;
public int LeafPort => _leaf.Port;
public async Task InitializeAsync()
{
var leafListenPort = NatsServerProcess.AllocateFreePort();
var hubConfig = $$"""
server_name: hub
leafnodes {
listen: 127.0.0.1:{{leafListenPort}}
}
""";
_hub = NatsServerProcess.WithConfig(hubConfig, enableMonitoring: true);
await _hub.StartAsync();
var leafConfig = $$"""
server_name: leaf
leafnodes {
remotes [
{ url: "nats-leaf://127.0.0.1:{{leafListenPort}}" }
]
}
""";
_leaf = NatsServerProcess.WithConfig(leafConfig);
await _leaf.StartAsync();
// Poll hub's /leafz until it reports 1 connected leaf node
await WaitForLeafConnectionAsync();
}
private async Task WaitForLeafConnectionAsync()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
while (await timer.WaitForNextTickAsync(deadline.Token).ConfigureAwait(false))
{
try
{
var leafz = await http.GetFromJsonAsync<Leafz>(
$"http://127.0.0.1:{_hub.MonitorPort!.Value}/leafz",
deadline.Token);
if (leafz?.NumLeafs >= 1)
return;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException
&& !deadline.IsCancellationRequested)
{
// Monitor not yet ready or per-request timeout — retry on next tick
_ = ex;
}
}
throw new TimeoutException("Leaf node did not connect to hub within 30s.");
}
public async Task DisposeAsync()
{
await _leaf.DisposeAsync();
await _hub.DisposeAsync();
}
public NatsConnection CreateHubClient()
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{HubPort}" });
public NatsConnection CreateLeafClient()
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{LeafPort}" });
private sealed class Leafz
{
[System.Text.Json.Serialization.JsonPropertyName("num_leafs")]
public int NumLeafs { get; init; }
}
}
[CollectionDefinition("E2E-LeafNode")]
public class LeafNodeCollection : ICollectionFixture<LeafNodeFixture>;
@@ -1,5 +1,4 @@
using NATS.Client.Core; using NATS.Client.Core;
using System.Net.Http;
namespace NATS.E2E.Tests.Infrastructure; namespace NATS.E2E.Tests.Infrastructure;
@@ -16,7 +15,7 @@ public sealed class MonitorServerFixture : IAsyncLifetime
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_server = new NatsServerProcess(enableMonitoring: true); _server = new NatsServerProcess(enableMonitoring: true);
await _server.StartAsync(); await _server.StartAsync(); // StartAsync polls both the NATS port and the monitor TCP port before returning
MonitorClient = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{MonitorPort}") }; MonitorClient = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{MonitorPort}") };
} }
@@ -0,0 +1,50 @@
using NATS.Client.Core;
namespace NATS.E2E.Tests.Infrastructure;
public sealed class MqttServerFixture : IAsyncLifetime
{
private NatsServerProcess _server = null!;
private string _storeDir = null!;
public int Port => _server.Port;
public int MqttPort { get; private set; }
public async Task InitializeAsync()
{
MqttPort = NatsServerProcess.AllocateFreePort();
_storeDir = Path.Combine(Path.GetTempPath(), "nats-e2e-mqtt-" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(_storeDir);
var config = $$"""
jetstream {
store_dir: "{{_storeDir}}"
max_mem_store: 64mb
max_file_store: 256mb
}
mqtt {
listen: 127.0.0.1:{{MqttPort}}
}
""";
_server = NatsServerProcess.WithConfig(config);
await _server.StartAsync();
}
public async Task DisposeAsync()
{
await _server.DisposeAsync();
if (_storeDir is not null && Directory.Exists(_storeDir))
{
try { Directory.Delete(_storeDir, recursive: true); }
catch (IOException ex) { _ = ex; /* best-effort temp dir cleanup */ }
}
}
public NatsConnection CreateNatsClient()
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
}
[CollectionDefinition("E2E-Mqtt")]
public class MqttCollection : ICollectionFixture<MqttServerFixture>;
@@ -46,8 +46,8 @@ public sealed class NatsServerProcess : IAsyncDisposable
/// <summary> /// <summary>
/// Convenience factory for creating a server with a config file. /// Convenience factory for creating a server with a config file.
/// </summary> /// </summary>
public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false) public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false, string[]? extraArgs = null)
=> new(configContent: configContent, enableMonitoring: enableMonitoring); => new(extraArgs: extraArgs, configContent: configContent, enableMonitoring: enableMonitoring);
public async Task StartAsync() public async Task StartAsync()
{ {
@@ -100,6 +100,9 @@ public sealed class NatsServerProcess : IAsyncDisposable
_process.BeginErrorReadLine(); _process.BeginErrorReadLine();
await WaitForTcpReadyAsync(); await WaitForTcpReadyAsync();
if (_enableMonitoring && MonitorPort.HasValue)
await WaitForMonitorPortReadyAsync();
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -115,9 +118,11 @@ public sealed class NatsServerProcess : IAsyncDisposable
{ {
await _process.WaitForExitAsync(cts.Token); await _process.WaitForExitAsync(cts.Token);
} }
catch (OperationCanceledException) catch (OperationCanceledException ex) when (!_process.HasExited)
{ {
// Already killed the tree above; nothing more to do // Kill timed out and process is still running — force-terminate and surface the error
throw new InvalidOperationException(
$"NATS server process did not exit within 5s after kill.", ex);
} }
} }
@@ -136,8 +141,10 @@ public sealed class NatsServerProcess : IAsyncDisposable
private async Task WaitForTcpReadyAsync() private async Task WaitForTcpReadyAsync()
{ {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
SocketException? lastError = null;
while (!timeout.Token.IsCancellationRequested) while (await timer.WaitForNextTickAsync(timeout.Token).ConfigureAwait(false))
{ {
try try
{ {
@@ -145,14 +152,38 @@ public sealed class NatsServerProcess : IAsyncDisposable
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, Port), timeout.Token); await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, Port), timeout.Token);
return; // Connected — server is ready return; // Connected — server is ready
} }
catch (SocketException) catch (SocketException ex)
{ {
await Task.Delay(100, timeout.Token); lastError = ex; // Server not yet accepting connections — retry on next tick
} }
} }
throw new TimeoutException( throw new TimeoutException(
$"NATS server did not become ready on port {Port} within 10s.\n\nServer output:\n{Output}"); $"NATS server did not become ready on port {Port} within 10s. Last error: {lastError?.Message}\n\nServer output:\n{Output}");
}
private async Task WaitForMonitorPortReadyAsync()
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
SocketException? lastError = null;
while (await timer.WaitForNextTickAsync(timeout.Token).ConfigureAwait(false))
{
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, MonitorPort!.Value), timeout.Token);
return; // Monitor HTTP port is accepting connections
}
catch (SocketException ex)
{
lastError = ex; // Monitor not yet accepting connections — retry on next tick
}
}
throw new TimeoutException(
$"NATS monitor port {MonitorPort} did not become ready within 10s. Last error: {lastError?.Message}\n\nServer output:\n{Output}");
} }
private static string ResolveHostDll() private static string ResolveHostDll()
@@ -0,0 +1,37 @@
using NATS.Client.Core;
namespace NATS.E2E.Tests.Infrastructure;
public sealed class WebSocketServerFixture : IAsyncLifetime
{
private NatsServerProcess _server = null!;
public int Port => _server.Port;
public int WsPort { get; private set; }
public async Task InitializeAsync()
{
WsPort = NatsServerProcess.AllocateFreePort();
var config = $$"""
websocket {
listen: 127.0.0.1:{{WsPort}}
no_tls: true
}
""";
_server = NatsServerProcess.WithConfig(config);
await _server.StartAsync();
}
public async Task DisposeAsync()
{
await _server.DisposeAsync();
}
public NatsConnection CreateNatsClient()
=> new(new NatsOpts { Url = $"nats://127.0.0.1:{Port}" });
}
[CollectionDefinition("E2E-WebSocket")]
public class WebSocketCollection : ICollectionFixture<WebSocketServerFixture>;
+312 -2
View File
@@ -287,9 +287,319 @@ public class JetStreamTests(JetStreamServerFixture fixture)
var before = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token); var before = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token);
before.Info.State.Messages.ShouldBe(5L); before.Info.State.Messages.ShouldBe(5L);
await Task.Delay(3000, cts.Token); // Poll until MaxAge expiry drops the message count to zero
INatsJSStream after;
do
{
after = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token);
if (after.Info.State.Messages == 0L) break;
await Task.Yield();
} while (!cts.IsCancellationRequested);
var after = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token);
after.Info.State.Messages.ShouldBe(0L); after.Info.State.Messages.ShouldBe(0L);
} }
// -------------------------------------------------------------------------
// Test 11 — Push consumer: consumer is created with DeliverSubject and queryable
// -------------------------------------------------------------------------
[Fact]
public async Task Consumer_PushDelivery()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var streamName = $"E2E_PUSH_{Random.Shared.Next(100000)}";
var deliverSubject = $"_deliver.{streamName}";
await js.CreateStreamAsync(new StreamConfig(streamName, [$"js.push.{streamName}.>"]), cts.Token);
for (var i = 0; i < 3; i++)
await js.PublishAsync($"js.push.{streamName}.{i}", $"push{i}", cancellationToken: cts.Token);
await js.CreateOrUpdateConsumerAsync(streamName,
new ConsumerConfig
{
Name = "push-consumer",
DeliverSubject = deliverSubject,
AckPolicy = ConsumerConfigAckPolicy.None,
},
cts.Token);
// Verify the push consumer was created and the deliver subject is reflected in consumer info
var consumer = await js.GetConsumerAsync(streamName, "push-consumer", cts.Token);
consumer.Info.Config.DeliverSubject.ShouldBe(deliverSubject);
}
// -------------------------------------------------------------------------
// Test 12 — AckPolicy.None: re-fetch yields nothing (messages auto-acked)
// -------------------------------------------------------------------------
[Fact]
public async Task Consumer_AckNone()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var streamName = $"E2E_ACKNONE_{Random.Shared.Next(100000)}";
await js.CreateStreamAsync(new StreamConfig(streamName, [$"js.acknone.{streamName}.>"]), cts.Token);
for (var i = 0; i < 3; i++)
await js.PublishAsync($"js.acknone.{streamName}.{i}", $"msg{i}", cancellationToken: cts.Token);
await js.CreateOrUpdateConsumerAsync(streamName,
new ConsumerConfig { Name = "acknone-consumer", AckPolicy = ConsumerConfigAckPolicy.None },
cts.Token);
var consumer = await js.GetConsumerAsync(streamName, "acknone-consumer", cts.Token);
// First fetch — consume all 3 without acking
var first = new List<string?>();
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 3 }, cancellationToken: cts.Token))
first.Add(msg.Data);
first.Count.ShouldBe(3);
// Second fetch — AckNone means server considers them delivered; nothing left
var second = new List<string?>();
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 3, Expires = TimeSpan.FromSeconds(1) }, cancellationToken: cts.Token))
second.Add(msg.Data);
second.Count.ShouldBe(0);
}
// -------------------------------------------------------------------------
// Test 13 — AckPolicy.All: acking last message acks all prior ones
// -------------------------------------------------------------------------
[Fact]
public async Task Consumer_AckAll()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var streamName = $"E2E_ACKALL_{Random.Shared.Next(100000)}";
await js.CreateStreamAsync(new StreamConfig(streamName, [$"js.ackall.{streamName}.>"]), cts.Token);
for (var i = 0; i < 5; i++)
await js.PublishAsync($"js.ackall.{streamName}.{i}", $"msg{i}", cancellationToken: cts.Token);
await js.CreateOrUpdateConsumerAsync(streamName,
new ConsumerConfig { Name = "ackall-consumer", AckPolicy = ConsumerConfigAckPolicy.All },
cts.Token);
var consumer = await js.GetConsumerAsync(streamName, "ackall-consumer", cts.Token);
// Fetch all 5, only ack the last one
INatsJSMsg<string>? last = null;
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 5 }, cancellationToken: cts.Token))
last = msg;
last.ShouldNotBeNull();
await last.AckAsync(cancellationToken: cts.Token);
// Second fetch should return nothing — all prior msgs are acked via AckAll
var second = new List<string?>();
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 5, Expires = TimeSpan.FromSeconds(1) }, cancellationToken: cts.Token))
second.Add(msg.Data);
second.Count.ShouldBe(0);
}
// -------------------------------------------------------------------------
// Test 14 — Interest retention: consumer created before publish receives all msgs
// -------------------------------------------------------------------------
[Fact]
public async Task Retention_Interest()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var streamName = $"E2E_INTEREST_{Random.Shared.Next(100000)}";
await js.CreateStreamAsync(
new StreamConfig(streamName, [$"js.interest.{streamName}.>"])
{
Retention = StreamConfigRetention.Interest,
},
cts.Token);
// Create consumer BEFORE publishing so the server tracks interest
await js.CreateOrUpdateConsumerAsync(streamName,
new ConsumerConfig { Name = "interest-consumer", AckPolicy = ConsumerConfigAckPolicy.Explicit },
cts.Token);
for (var i = 0; i < 5; i++)
await js.PublishAsync($"js.interest.{streamName}.{i}", $"msg{i}", cancellationToken: cts.Token);
var consumer = await js.GetConsumerAsync(streamName, "interest-consumer", cts.Token);
// Verify the Interest-mode consumer receives all 5 messages and can ack them
var fetched = new List<string?>();
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 5 }, cancellationToken: cts.Token))
{
fetched.Add(msg.Data);
await msg.AckAsync(cancellationToken: cts.Token);
}
fetched.Count.ShouldBe(5);
}
// -------------------------------------------------------------------------
// Test 15 — WorkQueue retention: messages are stored and fetchable by a single consumer
// -------------------------------------------------------------------------
[Fact]
public async Task Retention_WorkQueue()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var streamName = $"E2E_WQ_{Random.Shared.Next(100000)}";
await js.CreateStreamAsync(
new StreamConfig(streamName, [$"js.wq.{streamName}.>"])
{
Retention = StreamConfigRetention.Workqueue,
},
cts.Token);
await js.CreateOrUpdateConsumerAsync(streamName,
new ConsumerConfig { Name = "wq-consumer", AckPolicy = ConsumerConfigAckPolicy.Explicit },
cts.Token);
for (var i = 0; i < 5; i++)
await js.PublishAsync($"js.wq.{streamName}.{i}", $"msg{i}", cancellationToken: cts.Token);
// Verify all 5 messages are stored in the WorkQueue stream
var stream = await js.GetStreamAsync(streamName, cancellationToken: cts.Token);
stream.Info.State.Messages.ShouldBe(5L);
// Verify the consumer can fetch all 5 messages from the WorkQueue stream
var consumer = await js.GetConsumerAsync(streamName, "wq-consumer", cts.Token);
var fetched = new List<string?>();
await foreach (var msg in consumer.FetchAsync<string>(new NatsJSFetchOpts { MaxMsgs = 5 }, cancellationToken: cts.Token))
fetched.Add(msg.Data);
fetched.Count.ShouldBe(5);
}
// -------------------------------------------------------------------------
// Test 16 — Ordered consumer: messages arrive in sequence order
// -------------------------------------------------------------------------
[Fact]
public async Task Consumer_Ordered()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var streamName = $"E2E_ORDERED_{Random.Shared.Next(100000)}";
await js.CreateStreamAsync(new StreamConfig(streamName, [$"js.ordered.{streamName}.>"]), cts.Token);
for (var i = 0; i < 5; i++)
await js.PublishAsync($"js.ordered.{streamName}.{i}", i, cancellationToken: cts.Token);
var consumer = await js.CreateOrderedConsumerAsync(streamName, cancellationToken: cts.Token);
var sequences = new List<ulong>();
await foreach (var msg in consumer.FetchAsync<int>(new NatsJSFetchOpts { MaxMsgs = 5 }, cancellationToken: cts.Token))
sequences.Add(msg.Metadata!.Value.Sequence.Stream);
sequences.Count.ShouldBe(5);
for (var i = 1; i < sequences.Count; i++)
sequences[i].ShouldBeGreaterThan(sequences[i - 1]);
}
// -------------------------------------------------------------------------
// Test 17 — Mirror stream: replicates messages from a source stream
// -------------------------------------------------------------------------
[Fact]
public async Task Stream_Mirror()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var suffix = Random.Shared.Next(100000);
var sourceName = $"E2E_MIRROR_SRC_{suffix}";
var mirrorName = $"E2E_MIRROR_DST_{suffix}";
await js.CreateStreamAsync(new StreamConfig(sourceName, [$"js.mirror.{suffix}.>"]), cts.Token);
// Create mirror BEFORE publishing so the replication coordinator captures all messages
await js.CreateStreamAsync(
new StreamConfig(mirrorName, [])
{
Mirror = new StreamSource { Name = sourceName },
},
cts.Token);
for (var i = 0; i < 5; i++)
await js.PublishAsync($"js.mirror.{suffix}.{i}", $"msg{i}", cancellationToken: cts.Token);
// Poll until replication completes; outer 30s CTS is the deadline
INatsJSStream mirror;
do
{
mirror = await js.GetStreamAsync(mirrorName, cancellationToken: cts.Token);
if (mirror.Info.State.Messages == 5L) break;
await Task.Yield();
} while (!cts.IsCancellationRequested);
mirror.Info.State.Messages.ShouldBe(5L);
}
// -------------------------------------------------------------------------
// Test 18 — Source stream: aggregate stream pulls from a source stream
// -------------------------------------------------------------------------
[Fact]
public async Task Stream_Source()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
var js = new NatsJSContext(client);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var suffix = Random.Shared.Next(100000);
var srcName = $"E2E_SOURCE_SRC_{suffix}";
var aggName = $"E2E_SOURCE_AGG_{suffix}";
await js.CreateStreamAsync(new StreamConfig(srcName, [$"js.source.{suffix}.>"]), cts.Token);
// Create aggregate stream BEFORE publishing so the source coordinator captures all messages
await js.CreateStreamAsync(
new StreamConfig(aggName, [])
{
Sources = [new StreamSource { Name = srcName }],
},
cts.Token);
for (var i = 0; i < 5; i++)
await js.PublishAsync($"js.source.{suffix}.{i}", $"msg{i}", cancellationToken: cts.Token);
// Poll until sourcing completes; outer 30s CTS is the deadline
INatsJSStream agg;
do
{
agg = await js.GetStreamAsync(aggName, cancellationToken: cts.Token);
if (agg.Info.State.Messages == 5L) break;
await Task.Yield();
} while (!cts.IsCancellationRequested);
agg.Info.State.Messages.ShouldBe(5L);
}
} }
+70
View File
@@ -0,0 +1,70 @@
using NATS.E2E.Tests.Infrastructure;
namespace NATS.E2E.Tests;
[Collection("E2E-LeafNode")]
public class LeafNodeTests(LeafNodeFixture fixture)
{
[Fact]
public async Task LeafNode_HubToLeaf_MessageDelivered()
{
await using var pub = fixture.CreateHubClient();
await using var sub = fixture.CreateLeafClient();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.leaf.h2l");
// Ping both sides: subscriber's ping flushes the SUB to the leaf server,
// publisher's ping ensures the hub has received the propagated interest.
await sub.PingAsync();
await pub.PingAsync();
await pub.PublishAsync("e2e.leaf.h2l", "hub-to-leaf");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("hub-to-leaf");
}
[Fact]
public async Task LeafNode_LeafToHub_MessageDelivered()
{
await using var pub = fixture.CreateLeafClient();
await using var sub = fixture.CreateHubClient();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.leaf.l2h");
// Ping both sides: subscriber's ping flushes the SUB to the hub server,
// publisher's ping ensures the leaf has received the propagated interest.
await sub.PingAsync();
await pub.PingAsync();
await pub.PublishAsync("e2e.leaf.l2h", "leaf-to-hub");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("leaf-to-hub");
}
[Fact]
public async Task LeafNode_OnlySubscribedSubjectsPropagate()
{
await using var pub = fixture.CreateHubClient();
await using var sub = fixture.CreateLeafClient();
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.leaf.specific");
// Ping both sides to ensure subscription interest has propagated before publishing.
await sub.PingAsync();
await pub.PingAsync();
await pub.PublishAsync("e2e.leaf.other", "wrong-subject");
await pub.PublishAsync("e2e.leaf.specific", "right-subject");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var msg = await subscription.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("right-subject");
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Text.Json;
using NATS.E2E.Tests.Infrastructure;
namespace NATS.E2E.Tests;
[Collection("E2E-Monitor")]
public class MonitoringTests(MonitorServerFixture fixture)
{
[Fact]
public async Task Varz_ReturnsServerInfo()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await fixture.MonitorClient.GetAsync("/varz", cts.Token);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
var json = await response.Content.ReadAsStringAsync(cts.Token);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("server_name", out _).ShouldBeTrue();
root.TryGetProperty("version", out _).ShouldBeTrue();
root.TryGetProperty("max_payload", out _).ShouldBeTrue();
}
[Fact]
public async Task Connz_ReflectsConnectedClients()
{
await using var client = fixture.CreateClient();
await client.ConnectAsync();
await client.PingAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await fixture.MonitorClient.GetAsync("/connz", cts.Token);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
var json = await response.Content.ReadAsStringAsync(cts.Token);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("num_connections", out var numConns).ShouldBeTrue();
numConns.GetInt32().ShouldBeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task Healthz_ReturnsOk()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await fixture.MonitorClient.GetAsync("/healthz", cts.Token);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
}
}
+157
View File
@@ -0,0 +1,157 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using NATS.E2E.Tests.Infrastructure;
namespace NATS.E2E.Tests;
/// <summary>
/// E2E tests for the server's MQTT bridge using the server's line-based MQTT wire protocol.
/// The server exposes a text-framed MQTT protocol (not binary MQTT 3.1.1) on the mqtt port.
/// Wire commands: CONNECT {clientId}, SUB {topic}, PUB {topic} {payload}, PUBQ1 {packetId} {topic} {payload}, ACK {packetId}
/// Wire responses: CONNACK, SUBACK {topic}, MSG {topic} {payload}, PUBACK {packetId}, REDLIVER {packetId} {topic} {payload}
/// </summary>
[Collection("E2E-Mqtt")]
public class MqttTests(MqttServerFixture fixture)
{
// ---- wire helpers (duplicated per E2E convention, no shared TestHelpers) ----
private static async Task WriteLineAsync(NetworkStream stream, string line, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(line + "\n");
await stream.WriteAsync(bytes, ct);
await stream.FlushAsync(ct);
}
private static async Task<string?> ReadLineAsync(NetworkStream stream, CancellationToken ct)
{
var bytes = new List<byte>(64);
var one = new byte[1];
try
{
while (true)
{
var read = await stream.ReadAsync(one.AsMemory(0, 1), ct);
if (read == 0)
return null;
if (one[0] == (byte)'\n')
break;
if (one[0] != (byte)'\r')
bytes.Add(one[0]);
}
}
catch (OperationCanceledException)
{
return null;
}
return Encoding.UTF8.GetString([.. bytes]);
}
private static async Task<TcpClient> ConnectMqttClientAsync(int port, string clientId, CancellationToken ct)
{
var tcp = new TcpClient();
await tcp.ConnectAsync(IPAddress.Loopback, port, ct);
var stream = tcp.GetStream();
await WriteLineAsync(stream, $"CONNECT {clientId}", ct);
var connAck = await ReadLineAsync(stream, ct);
connAck.ShouldBe("CONNACK");
return tcp;
}
// ---- tests ----
[Fact]
public async Task Mqtt_PubSub_SameTopicDelivered()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
// Subscriber connects and subscribes to topic
using var subTcp = await ConnectMqttClientAsync(fixture.MqttPort, "e2e-sub-1", cts.Token);
var subStream = subTcp.GetStream();
await WriteLineAsync(subStream, "SUB e2e/mqtt/test1", cts.Token);
var subAck = await ReadLineAsync(subStream, cts.Token);
subAck.ShouldNotBeNull();
subAck!.ShouldContain("SUBACK");
// Publisher connects and publishes
using var pubTcp = await ConnectMqttClientAsync(fixture.MqttPort, "e2e-pub-1", cts.Token);
var pubStream = pubTcp.GetStream();
await WriteLineAsync(pubStream, "PUB e2e/mqtt/test1 hello-world", cts.Token);
// Subscriber receives the message
var received = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(async () =>
{
var line = await ReadLineAsync(subStream, cts.Token);
if (line is not null)
received.TrySetResult(line);
else
received.TrySetCanceled();
}, cts.Token);
var msg = await received.Task.WaitAsync(cts.Token);
msg.ShouldBe("MSG e2e/mqtt/test1 hello-world");
}
[Fact]
public async Task Mqtt_DifferentTopic_NotDelivered()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
// Subscriber subscribes to topic A
using var subTcp = await ConnectMqttClientAsync(fixture.MqttPort, "e2e-sub-2", cts.Token);
var subStream = subTcp.GetStream();
await WriteLineAsync(subStream, "SUB e2e/mqtt/topicA", cts.Token);
var subAck = await ReadLineAsync(subStream, cts.Token);
subAck.ShouldNotBeNull();
subAck!.ShouldContain("SUBACK");
// Publisher publishes to topic B (different topic)
using var pubTcp = await ConnectMqttClientAsync(fixture.MqttPort, "e2e-pub-2", cts.Token);
var pubStream = pubTcp.GetStream();
await WriteLineAsync(pubStream, "PUB e2e/mqtt/topicB unrelated-message", cts.Token);
// Short-timeout read — subscriber should NOT receive anything
using var shortCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
var unexpected = await ReadLineAsync(subStream, shortCts.Token);
unexpected.ShouldBeNull();
}
[Fact]
public async Task Mqtt_Qos1_PubAckReceived_AndDelivered()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
// Subscriber connects and subscribes
using var subTcp = await ConnectMqttClientAsync(fixture.MqttPort, "e2e-qos1-sub", cts.Token);
var subStream = subTcp.GetStream();
await WriteLineAsync(subStream, "SUB e2e/mqtt/qos1", cts.Token);
var subAck = await ReadLineAsync(subStream, cts.Token);
subAck.ShouldNotBeNull();
subAck!.ShouldContain("SUBACK");
// Publisher connects and sends QoS 1 publish (PUBQ1 {packetId} {topic} {payload})
using var pubTcp = await ConnectMqttClientAsync(fixture.MqttPort, "e2e-qos1-pub", cts.Token);
var pubStream = pubTcp.GetStream();
await WriteLineAsync(pubStream, "PUBQ1 42 e2e/mqtt/qos1 qos1-message", cts.Token);
// Publisher receives PUBACK from server
var pubAck = await ReadLineAsync(pubStream, cts.Token);
pubAck.ShouldBe("PUBACK 42");
// Subscriber receives the message
var received = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(async () =>
{
var line = await ReadLineAsync(subStream, cts.Token);
if (line is not null)
received.TrySetResult(line);
else
received.TrySetCanceled();
}, cts.Token);
var msg = await received.Task.WaitAsync(cts.Token);
msg.ShouldBe("MSG e2e/mqtt/qos1 qos1-message");
}
}
@@ -7,6 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="MQTTnet" />
<PackageReference Include="NATS.Client.Core" /> <PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NATS.Client.JetStream" /> <PackageReference Include="NATS.Client.JetStream" />
<PackageReference Include="NATS.NKeys" /> <PackageReference Include="NATS.NKeys" />
@@ -0,0 +1,72 @@
using NATS.Client.Core;
using NATS.E2E.Tests.Infrastructure;
namespace NATS.E2E.Tests;
public class ShutdownDrainTests
{
[Fact]
public async Task ClientDrain_CompletesInFlightMessages()
{
await using var server = new NatsServerProcess();
await server.StartAsync();
await using var pub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{server.Port}" });
var sub = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{server.Port}" });
await using var _ = sub;
await pub.ConnectAsync();
await sub.ConnectAsync();
await using var subscription = await sub.SubscribeCoreAsync<string>("e2e.drain.>");
await sub.PingAsync();
for (var i = 0; i < 10; i++)
await pub.PublishAsync($"e2e.drain.{i}", $"msg{i}");
await pub.PingAsync();
var received = new List<string?>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
for (var i = 0; i < 10; i++)
{
var msg = await subscription.Msgs.ReadAsync(cts.Token);
received.Add(msg.Data);
}
received.Count.ShouldBe(10);
await sub.DisposeAsync();
}
[Fact]
public async Task ServerShutdown_ClientDetectsDisconnection()
{
var server = new NatsServerProcess();
await server.StartAsync();
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{server.Port}",
MaxReconnectRetry = 0,
});
await client.ConnectAsync();
await client.PingAsync();
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
client.ConnectionDisconnected += (_, _) =>
{
disconnected.TrySetResult();
return ValueTask.CompletedTask;
};
await server.DisposeAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await disconnected.Task.WaitAsync(cts.Token);
client.ConnectionState.ShouldNotBe(NatsConnectionState.Open);
}
}
+99
View File
@@ -0,0 +1,99 @@
using System.Net.WebSockets;
using System.Text;
using NATS.E2E.Tests.Infrastructure;
namespace NATS.E2E.Tests;
[Collection("E2E-WebSocket")]
public class WebSocketTests(WebSocketServerFixture fixture)
{
[Fact]
public async Task WebSocket_ConnectAndReceiveInfo()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{fixture.WsPort}"), cts.Token);
ws.State.ShouldBe(WebSocketState.Open);
var buffer = new byte[4096];
var result = await ws.ReceiveAsync(buffer, cts.Token);
var info = Encoding.ASCII.GetString(buffer, 0, result.Count);
info.ShouldStartWith("INFO");
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
}
[Fact]
public async Task WebSocket_PubSub_RoundTrip()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{fixture.WsPort}"), cts.Token);
var reader = new WsLineReader(ws);
// Read the initial INFO frame
await reader.ReadLineAsync(cts.Token);
await WsSend(ws, "CONNECT {\"verbose\":false,\"protocol\":1}\r\n", cts.Token);
await WsSend(ws, "SUB e2e.ws.test 1\r\n", cts.Token);
await WsSend(ws, "PING\r\n", cts.Token);
var pong = await reader.ReadLineAsync(cts.Token);
pong.ShouldBe("PONG");
await using var natsClient = fixture.CreateNatsClient();
await natsClient.ConnectAsync();
await natsClient.PublishAsync("e2e.ws.test", "ws-hello");
await natsClient.PingAsync();
var msgLine = await reader.ReadLineAsync(cts.Token);
msgLine.ShouldStartWith("MSG e2e.ws.test 1");
var payload = await reader.ReadLineAsync(cts.Token);
payload.ShouldBe("ws-hello");
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
}
private static async Task WsSend(ClientWebSocket ws, string data, CancellationToken ct)
{
var bytes = Encoding.ASCII.GetBytes(data);
await ws.SendAsync(bytes, WebSocketMessageType.Binary, true, ct);
}
/// <summary>
/// Buffers incoming WebSocket frames and returns one NATS protocol line at a time.
/// Handles the case where a single WebSocket frame contains multiple protocol lines
/// (e.g., MSG header + payload delivered in one frame).
/// </summary>
private sealed class WsLineReader(ClientWebSocket ws)
{
private readonly byte[] _recvBuffer = new byte[4096];
private readonly StringBuilder _pending = new();
public async Task<string> ReadLineAsync(CancellationToken ct)
{
while (true)
{
var full = _pending.ToString();
var crlfIdx = full.IndexOf("\r\n", StringComparison.Ordinal);
if (crlfIdx >= 0)
{
var line = full[..crlfIdx];
_pending.Clear();
_pending.Append(full[(crlfIdx + 2)..]);
return line;
}
var result = await ws.ReceiveAsync(_recvBuffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
throw new InvalidOperationException("WebSocket closed unexpectedly while reading");
var chunk = Encoding.ASCII.GetString(_recvBuffer, 0, result.Count);
_pending.Append(chunk);
}
}
}
}
@@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests;
public class AccountIsolationTests : IAsyncLifetime public class AccountIsolationTests : IAsyncLifetime
{ {
@@ -12,17 +14,9 @@ public class AccountIsolationTests : IAsyncLifetime
private int _port; private int _port;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private Task _serverTask = null!; private Task _serverTask = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_port = GetFreePort(); _port = TestPortAllocator.GetFreePort();
_server = new NatsServer(new NatsOptions _server = new NatsServer(new NatsOptions
{ {
Port = _port, Port = _port,
@@ -100,7 +94,8 @@ public class AccountIsolationTests : IAsyncLifetime
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Expected — no message received (timeout) // Expected — no message received means accounts are properly isolated
return;
} }
} }
} }
@@ -1,6 +1,6 @@
using NATS.Server.Auth.Jwt; using NATS.Server.Auth.Jwt;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class AccountResolverTests public class AccountResolverTests
{ {
@@ -1,6 +1,6 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class AccountStatsTests public class AccountStatsTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class AccountTests public class AccountTests
{ {
@@ -4,7 +4,9 @@ using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Accounts; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Accounts;
/// <summary> /// <summary>
/// Tests for cross-account stream/service export/import delivery, authorization, and mapping. /// Tests for cross-account stream/service export/import delivery, authorization, and mapping.
@@ -380,20 +382,9 @@ public class AccountImportExportTests
private static NatsServer CreateTestServer() private static NatsServer CreateTestServer()
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance); return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
} }
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <summary> /// <summary>
/// Minimal test double for INatsClient used in import/export tests. /// Minimal test double for INatsClient used in import/export tests.
/// </summary> /// </summary>
@@ -7,7 +7,9 @@ using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Accounts; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Accounts;
/// <summary> /// <summary>
/// Tests for account creation, registration, isolation, and basic account lifecycle. /// Tests for account creation, registration, isolation, and basic account lifecycle.
@@ -17,16 +19,9 @@ namespace NATS.Server.Tests.Accounts;
/// </summary> /// </summary>
public class AccountIsolationTests public class AccountIsolationTests
{ {
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static NatsServer CreateTestServer(NatsOptions? options = null) private static NatsServer CreateTestServer(NatsOptions? options = null)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options ??= new NatsOptions(); options ??= new NatsOptions();
options.Port = port; options.Port = port;
return new NatsServer(options, NullLoggerFactory.Instance); return new NatsServer(options, NullLoggerFactory.Instance);
@@ -34,7 +29,7 @@ public class AccountIsolationTests
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options) private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options.Port = port; options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance); var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -208,7 +203,8 @@ public class AccountIsolationTests
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Expected // Expected — timeout confirms cross-account isolation prevented delivery
return;
} }
} }
finally finally
@@ -301,7 +297,8 @@ public class AccountIsolationTests
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Expected // Expected — timeout confirms different-account isolation blocks delivery
return;
} }
} }
finally finally
@@ -434,7 +431,8 @@ public class AccountIsolationTests
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Expected — accounts are isolated // Expected — timeout confirms subject-mapped accounts remain isolated
return;
} }
} }
finally finally
@@ -8,7 +8,9 @@ using NATS.Server.Imports;
using NATS.Server.Protocol; using NATS.Server.Protocol;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Accounts; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Accounts;
/// <summary> /// <summary>
/// Tests for auth callout behavior, account limits (max connections / max subscriptions), /// Tests for auth callout behavior, account limits (max connections / max subscriptions),
@@ -19,16 +21,9 @@ namespace NATS.Server.Tests.Accounts;
/// </summary> /// </summary>
public class AuthCalloutTests public class AuthCalloutTests
{ {
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static NatsServer CreateTestServer(NatsOptions? options = null) private static NatsServer CreateTestServer(NatsOptions? options = null)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options ??= new NatsOptions(); options ??= new NatsOptions();
options.Port = port; options.Port = port;
return new NatsServer(options, NullLoggerFactory.Instance); return new NatsServer(options, NullLoggerFactory.Instance);
@@ -36,7 +31,7 @@ public class AuthCalloutTests
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options) private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options.Port = port; options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance); var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -772,7 +767,10 @@ public class AuthCalloutTests
{ {
public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
{ {
await Task.Delay(delay, ct); var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
using var timer = new Timer(_ => tcs.TrySetResult(true), null, delay, Timeout.InfiniteTimeSpan);
await tcs.Task;
return new ExternalAuthDecision(true, "delayed"); return new ExternalAuthDecision(true, "delayed");
} }
} }
@@ -6,7 +6,9 @@ using NATS.Server;
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests.Accounts; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Accounts;
/// <summary> /// <summary>
/// Tests for authentication mechanisms: username/password, token, NKey-based auth, /// Tests for authentication mechanisms: username/password, token, NKey-based auth,
@@ -16,16 +18,9 @@ namespace NATS.Server.Tests.Accounts;
/// </summary> /// </summary>
public class AuthMechanismTests public class AuthMechanismTests
{ {
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options) private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options.Port = port; options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance); var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -5,7 +5,9 @@ using NATS.Client.Core;
using NATS.Server; using NATS.Server;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Accounts; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Accounts;
/// <summary> /// <summary>
/// Tests for publish/subscribe permission enforcement, account-level limits, /// Tests for publish/subscribe permission enforcement, account-level limits,
@@ -15,16 +17,9 @@ namespace NATS.Server.Tests.Accounts;
/// </summary> /// </summary>
public class PermissionTests public class PermissionTests
{ {
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options) private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options.Port = port; options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance); var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -356,7 +351,8 @@ public class PermissionTests
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Expected — message was blocked by permissions // Expected — timeout confirms permission denial blocked the message
return;
} }
} }
finally finally
@@ -3,7 +3,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class AccountClaimReloadTests public class AccountClaimReloadTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using Shouldly; using Shouldly;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
// Go reference: server/accounts.go — account expiry / SetExpirationTimer // Go reference: server/accounts.go — account expiry / SetExpirationTimer
@@ -5,7 +5,7 @@ using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
using ServerSubscriptions = NATS.Server.Subscriptions; using ServerSubscriptions = NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
/// <summary> /// <summary>
/// Parity tests ported from Go server/accounts_test.go exercising account /// Parity tests ported from Go server/accounts_test.go exercising account
@@ -4,7 +4,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class AccountImportExportTests public class AccountImportExportTests
{ {
@@ -3,7 +3,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class AccountLimitsTests public class AccountLimitsTests
{ {
@@ -2,7 +2,7 @@ using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class AccountResponseAndInterestParityBatch1Tests public class AccountResponseAndInterestParityBatch1Tests
{ {
@@ -6,7 +6,7 @@ using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
/// <summary> /// <summary>
/// Parity tests ported from Go server/accounts_test.go covering: /// Parity tests ported from Go server/accounts_test.go covering:
@@ -6,7 +6,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class ActivationExpirationTests public class ActivationExpirationTests
{ {
@@ -7,7 +7,7 @@ using System.Security.Cryptography.X509Certificates;
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
/// <summary> /// <summary>
/// Parity tests ported from Go server/auth_callout_test.go covering the auth callout /// Parity tests ported from Go server/auth_callout_test.go covering the auth callout
@@ -1399,7 +1399,10 @@ internal sealed class SlowCalloutClient : IExternalAuthClient
public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
{ {
await Task.Delay(_delay, ct); var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
using var timer = new Timer(_ => tcs.TrySetResult(true), null, _delay, Timeout.InfiniteTimeSpan);
await tcs.Task;
return new ExternalAuthDecision(true, _identity ?? request.Username ?? "slow_user", _account); return new ExternalAuthDecision(true, _identity ?? request.Username ?? "slow_user", _account);
} }
} }
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class AuthExtensionParityTests public class AuthExtensionParityTests
{ {
@@ -1,6 +1,6 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class AuthModelAndCalloutConstantsParityTests public class AuthModelAndCalloutConstantsParityTests
{ {
@@ -2,7 +2,7 @@ using NATS.NKeys;
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class AuthServiceParityBatch4Tests public class AuthServiceParityBatch4Tests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class ExternalAuthCalloutTests public class ExternalAuthCalloutTests
{ {
@@ -51,7 +51,10 @@ public class ExternalAuthCalloutTests
{ {
public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
{ {
await Task.Delay(delay, ct); var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
using var timer = new Timer(_ => tcs.TrySetResult(true), null, delay, Timeout.InfiniteTimeSpan);
await tcs.Task;
return new ExternalAuthDecision(true, "slow"); return new ExternalAuthDecision(true, "slow");
} }
} }
@@ -5,7 +5,7 @@ using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class ImportShadowingTests public class ImportShadowingTests
{ {
@@ -11,7 +11,7 @@ using NATS.Server.Auth;
using NATS.Server.Auth.Jwt; using NATS.Server.Auth.Jwt;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests.Auth namespace NATS.Server.Auth.Tests.Auth
{ {
/// <summary> /// <summary>
@@ -1767,4 +1767,4 @@ internal sealed class JwtTestSubjectPerm
[JsonPropertyName("deny")] public string[]? Deny { get; set; } [JsonPropertyName("deny")] public string[]? Deny { get; set; }
} }
} // namespace NATS.Server.Tests.Auth } // namespace NATS.Server.Auth.Tests.Auth
@@ -4,7 +4,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class NKeyRevocationTests public class NKeyRevocationTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class ProxyAuthTests public class ProxyAuthTests
{ {
@@ -5,7 +5,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class ResponseThresholdTests public class ResponseThresholdTests
{ {
@@ -3,7 +3,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class ReverseResponseMapTests public class ReverseResponseMapTests
{ {
@@ -3,7 +3,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class ServiceLatencyTrackerTests public class ServiceLatencyTrackerTests
{ {
@@ -3,7 +3,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class StreamImportCycleTests public class StreamImportCycleTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using Shouldly; using Shouldly;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
/// <summary> /// <summary>
/// Tests for SUB permission caching and generation-based invalidation. /// Tests for SUB permission caching and generation-based invalidation.
@@ -8,7 +8,9 @@ using System.Text;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Auth;
/// <summary> /// <summary>
/// Tests for the $SYS system account functionality including: /// Tests for the $SYS system account functionality including:
@@ -22,16 +24,9 @@ public class SystemAccountTests
{ {
// ─── Helpers ──────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options) private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options.Port = port; options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance); var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -40,31 +35,6 @@ public class SystemAccountTests
return (server, port, cts); return (server, port, cts);
} }
private static async Task<Socket> RawConnectAsync(int port)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, port);
var buf = new byte[4096];
await sock.ReceiveAsync(buf, SocketFlags.None);
return sock;
}
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var sb = new StringBuilder();
var buf = new byte[4096];
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
{
int n;
try { n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); }
catch (OperationCanceledException) { break; }
if (n == 0) break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
// ─── Tests ────────────────────────────────────────────────────────────── // ─── Tests ──────────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -2,7 +2,7 @@ using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class TlsMapAuthParityBatch1Tests public class TlsMapAuthParityBatch1Tests
{ {
@@ -4,7 +4,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
namespace NATS.Server.Tests.Auth; namespace NATS.Server.Auth.Tests.Auth;
public class WildcardExportTests public class WildcardExportTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server; using NATS.Server;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class AuthConfigTests public class AuthConfigTests
{ {
@@ -5,17 +5,12 @@ using NATS.Client.Core;
using NATS.Server; using NATS.Server;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests;
public class AuthIntegrationTests public class AuthIntegrationTests
{ {
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <summary> /// <summary>
/// Checks whether any exception in the chain contains the given substring. /// Checks whether any exception in the chain contains the given substring.
/// The NATS client wraps server errors in outer NatsException messages, /// The NATS client wraps server errors in outer NatsException messages,
@@ -36,7 +31,7 @@ public class AuthIntegrationTests
private static (NatsServer server, int port, CancellationTokenSource cts) StartServer(NatsOptions options) private static (NatsServer server, int port, CancellationTokenSource cts) StartServer(NatsOptions options)
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
options.Port = port; options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance); var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class AuthProtocolTests public class AuthProtocolTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class AuthServiceTests public class AuthServiceTests
{ {
@@ -1,6 +1,6 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class ClientPermissionsTests public class ClientPermissionsTests
{ {
@@ -4,7 +4,9 @@ using NATS.Server.Auth;
using NATS.Server.Imports; using NATS.Server.Imports;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests;
public class ImportExportTests public class ImportExportTests
{ {
@@ -298,20 +300,9 @@ public class ImportExportTests
private static NatsServer CreateTestServer() private static NatsServer CreateTestServer()
{ {
var port = GetFreePort(); var port = TestPortAllocator.GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance); return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
} }
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <summary> /// <summary>
/// Minimal test double for INatsClient used in import/export tests. /// Minimal test double for INatsClient used in import/export tests.
/// </summary> /// </summary>
@@ -4,7 +4,7 @@ using NATS.Server.Auth;
using NATS.Server.Auth.Jwt; using NATS.Server.Auth.Jwt;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class JwtAuthenticatorTests public class JwtAuthenticatorTests
{ {
@@ -3,7 +3,7 @@ using System.Text.Json;
using NATS.NKeys; using NATS.NKeys;
using NATS.Server.Auth.Jwt; using NATS.Server.Auth.Jwt;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class JwtTests public class JwtTests
{ {
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NATS.NKeys" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
</ItemGroup>
</Project>
@@ -2,7 +2,7 @@ using NATS.NKeys;
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class NKeyAuthenticatorTests public class NKeyAuthenticatorTests
{ {
@@ -5,7 +5,9 @@ using NATS.Client.Core;
using NATS.NKeys; using NATS.NKeys;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests;
public class NKeyIntegrationTests : IAsyncLifetime public class NKeyIntegrationTests : IAsyncLifetime
{ {
@@ -16,17 +18,9 @@ public class NKeyIntegrationTests : IAsyncLifetime
private KeyPair _userKeyPair = null!; private KeyPair _userKeyPair = null!;
private string _userSeed = null!; private string _userSeed = null!;
private string _userPublicKey = null!; private string _userPublicKey = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_port = GetFreePort(); _port = TestPortAllocator.GetFreePort();
_userKeyPair = KeyPair.CreatePair(PrefixByte.User); _userKeyPair = KeyPair.CreatePair(PrefixByte.User);
_userPublicKey = _userKeyPair.GetPublicKey(); _userPublicKey = _userKeyPair.GetPublicKey();
_userSeed = _userKeyPair.GetSeed(); _userSeed = _userKeyPair.GetSeed();
@@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests;
public class PermissionIntegrationTests : IAsyncLifetime public class PermissionIntegrationTests : IAsyncLifetime
{ {
@@ -12,17 +14,9 @@ public class PermissionIntegrationTests : IAsyncLifetime
private int _port; private int _port;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private Task _serverTask = null!; private Task _serverTask = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
_port = GetFreePort(); _port = TestPortAllocator.GetFreePort();
_server = new NatsServer(new NatsOptions _server = new NatsServer(new NatsOptions
{ {
Port = _port, Port = _port,
@@ -1,6 +1,6 @@
using NATS.Server.Auth; using NATS.Server.Auth;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class PermissionLruCacheTests public class PermissionLruCacheTests
{ {
@@ -1,4 +1,4 @@
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
using NATS.Server.Auth.Jwt; using NATS.Server.Auth.Jwt;
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class SimpleUserPasswordAuthenticatorTests public class SimpleUserPasswordAuthenticatorTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class TokenAuthenticatorTests public class TokenAuthenticatorTests
{ {
@@ -1,7 +1,7 @@
using NATS.Server.Auth; using NATS.Server.Auth;
using NATS.Server.Protocol; using NATS.Server.Protocol;
namespace NATS.Server.Tests; namespace NATS.Server.Auth.Tests;
public class UserPasswordAuthenticatorTests public class UserPasswordAuthenticatorTests
{ {
@@ -5,7 +5,7 @@ using NATS.Server.Gateways;
using NATS.Server.Protocol; using NATS.Server.Protocol;
using NATS.Server.Routes; using NATS.Server.Routes;
namespace NATS.Server.Tests; namespace NATS.Server.Clustering.Tests;
// Go reference: server/route.go processImplicitRoute, server/gateway.go processImplicitGateway // Go reference: server/route.go processImplicitRoute, server/gateway.go processImplicitGateway
@@ -4,7 +4,7 @@ using System.Text;
using NATS.Server.Gateways; using NATS.Server.Gateways;
using NATS.Server.Subscriptions; using NATS.Server.Subscriptions;
namespace NATS.Server.Tests; namespace NATS.Server.Clustering.Tests;
public class InterServerAccountProtocolTests public class InterServerAccountProtocolTests
{ {
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NATS.Client.Core" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
</ItemGroup>
</Project>
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration; using NATS.Server.Configuration;
using NATS.Server.Routes; using NATS.Server.Routes;
namespace NATS.Server.Tests.Route; namespace NATS.Server.Clustering.Tests.Route;
/// <summary> /// <summary>
/// Go parity tests for the .NET route subsystem ported from /// Go parity tests for the .NET route subsystem ported from
@@ -0,0 +1,25 @@
using NATS.Server.TestUtilities;
namespace NATS.Server.Clustering.Tests;
public class RouteHandshakeTests
{
[Fact]
public async Task Two_servers_establish_route_connection()
{
await using var a = await TestServerFactory.CreateClusterEnabledAsync();
await using var b = await TestServerFactory.CreateClusterEnabledAsync(seed: a.ClusterListen);
await a.WaitForReadyAsync();
await b.WaitForReadyAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (a.Stats.Routes == 0 || b.Stats.Routes == 0))
{
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
a.Stats.Routes.ShouldBeGreaterThan(0);
b.Stats.Routes.ShouldBeGreaterThan(0);
}
}
@@ -1,4 +1,4 @@
namespace NATS.Server.Tests; namespace NATS.Server.Clustering.Tests;
public class RoutePoolTests public class RoutePoolTests
{ {
@@ -1,4 +1,4 @@
namespace NATS.Server.Tests; namespace NATS.Server.Clustering.Tests;
public class RouteRmsgForwardingTests public class RouteRmsgForwardingTests
{ {
@@ -3,8 +3,9 @@ using System.Net.Sockets;
using System.Text; using System.Text;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration; using NATS.Server.Configuration;
using NATS.Server.TestUtilities;
namespace NATS.Server.Tests; namespace NATS.Server.Clustering.Tests;
public class RouteSubscriptionPropagationTests public class RouteSubscriptionPropagationTests
{ {
@@ -90,7 +91,7 @@ internal sealed class RouteFixture : IAsyncDisposable
await ReadLineAsync(sock); // INFO await ReadLineAsync(sock); // INFO
await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {{}}\r\nSUB {subject} 1\r\nPING\r\n")); await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {{}}\r\nSUB {subject} 1\r\nPING\r\n"));
await ReadUntilAsync(sock, "PONG"); await SocketTestHelper.ReadUntilAsync(sock, "PONG");
} }
public async Task SendRouteSubFrameAsync(string subject) public async Task SendRouteSubFrameAsync(string subject)
@@ -123,11 +124,11 @@ internal sealed class RouteFixture : IAsyncDisposable
_publisherOnA = sock; _publisherOnA = sock;
_ = await ReadLineAsync(sock); // INFO _ = await ReadLineAsync(sock); // INFO
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n")); await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
await ReadUntilAsync(sock, "PONG"); await SocketTestHelper.ReadUntilAsync(sock, "PONG");
} }
await sock.SendAsync(Encoding.ASCII.GetBytes($"PUB {subject} {payload.Length}\r\n{payload}\r\nPING\r\n")); await sock.SendAsync(Encoding.ASCII.GetBytes($"PUB {subject} {payload.Length}\r\n{payload}\r\nPING\r\n"));
await ReadUntilAsync(sock, "PONG"); await SocketTestHelper.ReadUntilAsync(sock, "PONG");
} }
public async Task<string> ReadServerBMessageAsync() public async Task<string> ReadServerBMessageAsync()
@@ -135,7 +136,7 @@ internal sealed class RouteFixture : IAsyncDisposable
if (_subscriberOnB == null) if (_subscriberOnB == null)
throw new InvalidOperationException("No subscriber socket on server B."); throw new InvalidOperationException("No subscriber socket on server B.");
return await ReadUntilAsync(_subscriberOnB, "MSG "); return await SocketTestHelper.ReadUntilAsync(_subscriberOnB, "MSG ");
} }
public async Task<bool> ServerAHasRemoteInterestAsync(string subject, bool expected = true) public async Task<bool> ServerAHasRemoteInterestAsync(string subject, bool expected = true)
@@ -184,22 +185,6 @@ internal sealed class RouteFixture : IAsyncDisposable
return Encoding.ASCII.GetString(buf, 0, n); return Encoding.ASCII.GetString(buf, 0, n);
} }
private static async Task<string> ReadUntilAsync(Socket sock, string expected)
{
var sb = new StringBuilder();
var buf = new byte[4096];
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
{
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
if (n == 0)
break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
private static (string Host, int Port) ParseHostPort(string endpoint) private static (string Host, int Port) ParseHostPort(string endpoint)
{ {
@@ -1,4 +1,4 @@
namespace NATS.Server.Tests; namespace NATS.Server.Clustering.Tests;
public class RouteWireSubscriptionProtocolTests public class RouteWireSubscriptionProtocolTests
{ {
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration; using NATS.Server.Configuration;
using NATS.Server.Routes; using NATS.Server.Routes;
namespace NATS.Server.Tests.Routes; namespace NATS.Server.Clustering.Tests.Routes;
/// <summary> /// <summary>
/// Tests for per-account dedicated route connections (Gap 13.2). /// Tests for per-account dedicated route connections (Gap 13.2).
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration; using NATS.Server.Configuration;
using NATS.Server.Routes; using NATS.Server.Routes;
namespace NATS.Server.Tests.Routes; namespace NATS.Server.Clustering.Tests.Routes;
/// <summary> /// <summary>
/// Tests for <see cref="RouteManager.RemoveRoute"/>, /// Tests for <see cref="RouteManager.RemoveRoute"/>,

Some files were not shown because too many files have changed in this diff Show More