Compare commits
31 Commits
c30e67a69d
...
95e9f0a92e
| Author | SHA1 | Date | |
|---|---|---|---|
| 95e9f0a92e | |||
| 246fc7ad87 | |||
| ced5062f50 | |||
| e4d275c929 | |||
| c9b55093a4 | |||
| 139b984992 | |||
| 571c595d0a | |||
| aeb60d3c43 | |||
| 338f44b07b | |||
| 5d9d1bebd5 | |||
| 76f8ccec2e | |||
| e094846665 | |||
| 8ad2172e3c | |||
| 4853409a40 | |||
| 0e252d6ccf | |||
| 1f122bf56f | |||
| bc308a4349 | |||
| 7fbffffd05 | |||
| 78b4bc2486 | |||
| 36b9dfa654 | |||
| 0c086522a4 | |||
| edf9ed770e | |||
| 615752cdc2 | |||
| 3f7d896a34 | |||
| 9972b74bc3 | |||
| a6be5e11ed | |||
| d2c04fcca5 | |||
| 5c608f07e3 | |||
| 2a75ee534a | |||
| fb19b50231 | |||
| 6941d9275b |
@@ -25,8 +25,12 @@ dotnet test -v normal
|
||||
# Run a single test project
|
||||
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
|
||||
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)
|
||||
dotnet run --project src/NATS.Server.Host
|
||||
@@ -58,13 +62,18 @@ src/
|
||||
NATS.Server.Host/ # Executable host app
|
||||
Program.cs # Entry point, CLI arg parsing (-p port)
|
||||
tests/
|
||||
NATS.Server.Tests/ # xUnit test project
|
||||
ParserTests.cs # Protocol parser tests
|
||||
SubjectMatchTests.cs # Subject validation & matching tests
|
||||
SubListTests.cs # Subscription list trie tests
|
||||
ClientTests.cs # Client-level protocol tests
|
||||
ServerTests.cs # Server pubsub/wildcard tests
|
||||
IntegrationTests.cs # End-to-end tests using NATS.Client.Core NuGet
|
||||
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/ # End-to-end tests using NATS.Client.Core NuGet
|
||||
```
|
||||
|
||||
## Go Reference Commands
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
<!-- NATS Client (integration tests) -->
|
||||
<PackageVersion Include="NATS.Client.Core" 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>
|
||||
</Project>
|
||||
|
||||
+11
-1
@@ -4,7 +4,17 @@
|
||||
<Project Path="src/NATS.Server/NATS.Server.csproj" />
|
||||
</Folder>
|
||||
<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" />
|
||||
</Folder>
|
||||
</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"
|
||||
}
|
||||
@@ -307,7 +307,7 @@ public sealed class Account : IDisposable
|
||||
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
|
||||
{
|
||||
@@ -318,6 +318,7 @@ public sealed class Account : IDisposable
|
||||
Auth = auth,
|
||||
Account = this,
|
||||
ResponseType = responseType,
|
||||
Latency = latency,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,4 +5,40 @@ public sealed class AccountConfig
|
||||
public int MaxConnections { get; init; } // 0 = unlimited
|
||||
public int MaxSubscriptions { get; init; } // 0 = unlimited
|
||||
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 (1–100, 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);
|
||||
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:
|
||||
warnings.Add(new UnknownConfigFieldWarning(key).Message);
|
||||
break;
|
||||
@@ -975,6 +1023,8 @@ public static class ConfigProcessor
|
||||
int maxConnections = 0;
|
||||
int maxSubscriptions = 0;
|
||||
List<object?>? userList = null;
|
||||
List<ExportDefinition>? exports = null;
|
||||
List<ImportDefinition>? imports = null;
|
||||
|
||||
foreach (var (key, value) in acctDict)
|
||||
{
|
||||
@@ -989,6 +1039,21 @@ public static class ConfigProcessor
|
||||
break;
|
||||
case "max_subscriptions" or "max_subs":
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -997,6 +1062,8 @@ public static class ConfigProcessor
|
||||
{
|
||||
MaxConnections = maxConnections,
|
||||
MaxSubscriptions = maxSubscriptions,
|
||||
Exports = exports,
|
||||
Imports = imports,
|
||||
};
|
||||
|
||||
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>
|
||||
/// 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.
|
||||
@@ -1623,6 +1824,30 @@ public static class ConfigProcessor
|
||||
_ => 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)
|
||||
{
|
||||
if (value is List<object?> list)
|
||||
|
||||
@@ -210,7 +210,17 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
_options.Port = ((IPEndPoint)_listener.LocalEndPoint!).Port;
|
||||
|
||||
_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));
|
||||
|
||||
_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)
|
||||
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)
|
||||
config.Push = true;
|
||||
|
||||
|
||||
@@ -516,6 +516,16 @@ public static class StreamApiHandlers
|
||||
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))
|
||||
config.Source = sourceEl.GetString();
|
||||
|
||||
|
||||
@@ -157,6 +157,8 @@ public sealed class JetStreamApiResponse
|
||||
max_deliver = c.MaxDeliver,
|
||||
max_ack_pending = c.MaxAckPending,
|
||||
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()
|
||||
|
||||
@@ -239,6 +239,16 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
foreach (var remote in _options.Remotes.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
_ = 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);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -819,6 +829,12 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
|
||||
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);
|
||||
if (parts.Length != 2)
|
||||
throw new FormatException($"Invalid endpoint: {endpoint}");
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<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>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
@@ -529,6 +529,17 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_systemAccount = new Account(Account.SystemAccountName) { IsSystemAccount = true };
|
||||
_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
|
||||
var sysClientId = Interlocked.Increment(ref _nextClientId);
|
||||
var sysClient = new InternalClient(sysClientId, ClientKind.System, _systemAccount);
|
||||
@@ -1312,7 +1323,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
if (si.Invalid) continue;
|
||||
if (SubjectMatch.MatchLiteral(subject, si.From))
|
||||
{
|
||||
ProcessServiceImport(si, subject, replyTo, headers, payload);
|
||||
ProcessServiceImport(si, subject, replyTo, headers, payload, sender.Account);
|
||||
delivered = true;
|
||||
}
|
||||
}
|
||||
@@ -1453,7 +1464,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Reference: Go server/accounts.go addServiceImport / processServiceImport.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -1477,6 +1488,24 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
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
|
||||
var destSubList = si.DestinationAccount.SubList;
|
||||
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>
|
||||
/// 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",
|
||||
@@ -1633,11 +1692,54 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
acc.MaxConnections = config.MaxConnections;
|
||||
acc.MaxSubscriptions = config.MaxSubscriptions;
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
/// Returns true if the subject belongs to the $SYS subject space.
|
||||
/// Reference: Go server/server.go — isReservedSubject.
|
||||
@@ -1675,6 +1777,25 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
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)
|
||||
{
|
||||
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg });
|
||||
|
||||
@@ -439,9 +439,13 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
if (_routes.IsEmpty)
|
||||
return;
|
||||
|
||||
// Send once per peer (deduplicate by RemoteServerId).
|
||||
var sentToPeers = new HashSet<string>(StringComparer.Ordinal);
|
||||
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)
|
||||
return;
|
||||
|
||||
var sentToPeers = new HashSet<string>(StringComparer.Ordinal);
|
||||
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)
|
||||
@@ -459,18 +468,28 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
if (_routes.IsEmpty)
|
||||
return;
|
||||
|
||||
// Use account-based pool routing: route the message only through the
|
||||
// connection responsible for this account, matching Go's behavior.
|
||||
var route = GetRouteForAccount(account);
|
||||
if (route != null)
|
||||
// Pool routing selects among multiple connections to the SAME peer.
|
||||
// When pool routes exist, use account-based hashing to pick one.
|
||||
// Go reference: server/route.go — broadcastMsgToRoutes sends to all
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
@@ -506,6 +525,16 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -530,6 +559,16 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
|
||||
var connection = new RouteConnection(socket) { PoolIndex = poolIndex, IsSolicited = true };
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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('/', '_');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 System.Net.Http;
|
||||
|
||||
namespace NATS.E2E.Tests.Infrastructure;
|
||||
|
||||
@@ -16,7 +15,7 @@ public sealed class MonitorServerFixture : IAsyncLifetime
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_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}") };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
/// Convenience factory for creating a server with a config file.
|
||||
/// </summary>
|
||||
public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false)
|
||||
=> new(configContent: configContent, enableMonitoring: enableMonitoring);
|
||||
public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false, string[]? extraArgs = null)
|
||||
=> new(extraArgs: extraArgs, configContent: configContent, enableMonitoring: enableMonitoring);
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
@@ -100,6 +100,9 @@ public sealed class NatsServerProcess : IAsyncDisposable
|
||||
_process.BeginErrorReadLine();
|
||||
|
||||
await WaitForTcpReadyAsync();
|
||||
|
||||
if (_enableMonitoring && MonitorPort.HasValue)
|
||||
await WaitForMonitorPortReadyAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -115,9 +118,11 @@ public sealed class NatsServerProcess : IAsyncDisposable
|
||||
{
|
||||
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()
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -145,14 +152,38 @@ public sealed class NatsServerProcess : IAsyncDisposable
|
||||
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, Port), timeout.Token);
|
||||
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(
|
||||
$"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()
|
||||
|
||||
@@ -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>;
|
||||
@@ -287,9 +287,319 @@ public class JetStreamTests(JetStreamServerFixture fixture)
|
||||
var before = await js.GetStreamAsync("E2E_MAXAGE", cancellationToken: cts.Token);
|
||||
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);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="MQTTnet" />
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="NATS.Client.JetStream" />
|
||||
<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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-11
@@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountIsolationTests : IAsyncLifetime
|
||||
{
|
||||
@@ -12,17 +14,9 @@ public class AccountIsolationTests : IAsyncLifetime
|
||||
private int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
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()
|
||||
{
|
||||
_port = GetFreePort();
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Port = _port,
|
||||
@@ -100,7 +94,8 @@ public class AccountIsolationTests : IAsyncLifetime
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — no message received (timeout)
|
||||
// Expected — no message received means accounts are properly isolated
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountResolverTests
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountStatsTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountTests
|
||||
{
|
||||
+4
-13
@@ -4,7 +4,9 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Accounts;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for cross-account stream/service export/import delivery, authorization, and mapping.
|
||||
@@ -380,20 +382,9 @@ public class AccountImportExportTests
|
||||
|
||||
private static NatsServer CreateTestServer()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
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>
|
||||
/// Minimal test double for INatsClient used in import/export tests.
|
||||
/// </summary>
|
||||
+11
-13
@@ -7,7 +7,9 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Accounts;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for account creation, registration, isolation, and basic account lifecycle.
|
||||
@@ -17,16 +19,9 @@ namespace NATS.Server.Tests.Accounts;
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options ??= new NatsOptions();
|
||||
options.Port = port;
|
||||
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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
@@ -208,7 +203,8 @@ public class AccountIsolationTests
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
// Expected — timeout confirms cross-account isolation prevented delivery
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -301,7 +297,8 @@ public class AccountIsolationTests
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
// Expected — timeout confirms different-account isolation blocks delivery
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -434,7 +431,8 @@ public class AccountIsolationTests
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — accounts are isolated
|
||||
// Expected — timeout confirms subject-mapped accounts remain isolated
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
+9
-11
@@ -8,7 +8,9 @@ using NATS.Server.Imports;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Accounts;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for auth callout behavior, account limits (max connections / max subscriptions),
|
||||
@@ -19,16 +21,9 @@ namespace NATS.Server.Tests.Accounts;
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options ??= new NatsOptions();
|
||||
options.Port = port;
|
||||
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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
@@ -772,7 +767,10 @@ public class AuthCalloutTests
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -6,7 +6,9 @@ using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.Accounts;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for authentication mechanisms: username/password, token, NKey-based auth,
|
||||
@@ -16,16 +18,9 @@ namespace NATS.Server.Tests.Accounts;
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
+6
-10
@@ -5,7 +5,9 @@ using NATS.Client.Core;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Accounts;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for publish/subscribe permission enforcement, account-level limits,
|
||||
@@ -15,16 +17,9 @@ namespace NATS.Server.Tests.Accounts;
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
@@ -356,7 +351,8 @@ public class PermissionTests
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — message was blocked by permissions
|
||||
// Expected — timeout confirms permission denial blocked the message
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountClaimReloadTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
// Go reference: server/accounts.go — account expiry / SetExpirationTimer
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using ServerSubscriptions = NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Parity tests ported from Go server/accounts_test.go exercising account
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountImportExportTests
|
||||
{
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountLimitsTests
|
||||
{
|
||||
+1
-1
@@ -2,7 +2,7 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountResponseAndInterestParityBatch1Tests
|
||||
{
|
||||
+1
-1
@@ -6,7 +6,7 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Parity tests ported from Go server/accounts_test.go covering:
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ActivationExpirationTests
|
||||
{
|
||||
+5
-2
@@ -7,7 +7,7 @@ using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthExtensionParityTests
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AuthModelAndCalloutConstantsParityTests
|
||||
{
|
||||
+1
-1
@@ -2,7 +2,7 @@ using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AuthServiceParityBatch4Tests
|
||||
{
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ExternalAuthCalloutTests
|
||||
{
|
||||
@@ -51,7 +51,10 @@ public class ExternalAuthCalloutTests
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ImportShadowingTests
|
||||
{
|
||||
+2
-2
@@ -11,7 +11,7 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests.Auth
|
||||
namespace NATS.Server.Auth.Tests.Auth
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
@@ -1767,4 +1767,4 @@ internal sealed class JwtTestSubjectPerm
|
||||
[JsonPropertyName("deny")] public string[]? Deny { get; set; }
|
||||
}
|
||||
|
||||
} // namespace NATS.Server.Tests.Auth
|
||||
} // namespace NATS.Server.Auth.Tests.Auth
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class NKeyRevocationTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ProxyAuthTests
|
||||
{
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ResponseThresholdTests
|
||||
{
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ReverseResponseMapTests
|
||||
{
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ServiceLatencyTrackerTests
|
||||
{
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class StreamImportCycleTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SUB permission caching and generation-based invalidation.
|
||||
+4
-34
@@ -8,7 +8,9 @@ using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the $SYS system account functionality including:
|
||||
@@ -22,16 +24,9 @@ public class SystemAccountTests
|
||||
{
|
||||
// ─── 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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
@@ -40,31 +35,6 @@ public class SystemAccountTests
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
+1
-1
@@ -2,7 +2,7 @@ using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class TlsMapAuthParityBatch1Tests
|
||||
{
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class WildcardExportTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthConfigTests
|
||||
{
|
||||
+4
-9
@@ -5,17 +5,12 @@ using NATS.Client.Core;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
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>
|
||||
/// Checks whether any exception in the chain contains the given substring.
|
||||
/// 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)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthProtocolTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthServiceTests
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ClientPermissionsTests
|
||||
{
|
||||
+4
-13
@@ -4,7 +4,9 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ImportExportTests
|
||||
{
|
||||
@@ -298,20 +300,9 @@ public class ImportExportTests
|
||||
|
||||
private static NatsServer CreateTestServer()
|
||||
{
|
||||
var port = GetFreePort();
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
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>
|
||||
/// Minimal test double for INatsClient used in import/export tests.
|
||||
/// </summary>
|
||||
+1
-1
@@ -4,7 +4,7 @@ using NATS.Server.Auth;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class JwtAuthenticatorTests
|
||||
{
|
||||
@@ -3,7 +3,7 @@ using System.Text.Json;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
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>
|
||||
+1
-1
@@ -2,7 +2,7 @@ using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class NKeyAuthenticatorTests
|
||||
{
|
||||
+4
-10
@@ -5,7 +5,9 @@ using NATS.Client.Core;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class NKeyIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
@@ -16,17 +18,9 @@ public class NKeyIntegrationTests : IAsyncLifetime
|
||||
private KeyPair _userKeyPair = null!;
|
||||
private string _userSeed = 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()
|
||||
{
|
||||
_port = GetFreePort();
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_userKeyPair = KeyPair.CreatePair(PrefixByte.User);
|
||||
_userPublicKey = _userKeyPair.GetPublicKey();
|
||||
_userSeed = _userKeyPair.GetSeed();
|
||||
+4
-10
@@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class PermissionIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
@@ -12,17 +14,9 @@ public class PermissionIntegrationTests : IAsyncLifetime
|
||||
private int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
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()
|
||||
{
|
||||
_port = GetFreePort();
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Port = _port,
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class PermissionLruCacheTests
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class SimpleUserPasswordAuthenticatorTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class TokenAuthenticatorTests
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class UserPasswordAuthenticatorTests
|
||||
{
|
||||
+1
-1
@@ -5,7 +5,7 @@ using NATS.Server.Gateways;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Clustering.Tests;
|
||||
|
||||
// Go reference: server/route.go processImplicitRoute, server/gateway.go processImplicitGateway
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ using System.Text;
|
||||
using NATS.Server.Gateways;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Clustering.Tests;
|
||||
|
||||
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>
|
||||
+1
-1
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Route;
|
||||
namespace NATS.Server.Clustering.Tests.Route;
|
||||
|
||||
/// <summary>
|
||||
/// 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
-1
@@ -1,4 +1,4 @@
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Clustering.Tests;
|
||||
|
||||
public class RoutePoolTests
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Clustering.Tests;
|
||||
|
||||
public class RouteRmsgForwardingTests
|
||||
{
|
||||
+6
-21
@@ -3,8 +3,9 @@ using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Clustering.Tests;
|
||||
|
||||
public class RouteSubscriptionPropagationTests
|
||||
{
|
||||
@@ -90,7 +91,7 @@ internal sealed class RouteFixture : IAsyncDisposable
|
||||
|
||||
await ReadLineAsync(sock); // INFO
|
||||
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)
|
||||
@@ -123,11 +124,11 @@ internal sealed class RouteFixture : IAsyncDisposable
|
||||
_publisherOnA = sock;
|
||||
_ = await ReadLineAsync(sock); // INFO
|
||||
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 ReadUntilAsync(sock, "PONG");
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
|
||||
}
|
||||
|
||||
public async Task<string> ReadServerBMessageAsync()
|
||||
@@ -135,7 +136,7 @@ internal sealed class RouteFixture : IAsyncDisposable
|
||||
if (_subscriberOnB == null)
|
||||
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)
|
||||
@@ -184,22 +185,6 @@ internal sealed class RouteFixture : IAsyncDisposable
|
||||
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)
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace NATS.Server.Tests;
|
||||
namespace NATS.Server.Clustering.Tests;
|
||||
|
||||
public class RouteWireSubscriptionProtocolTests
|
||||
{
|
||||
+1
-1
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
namespace NATS.Server.Clustering.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for per-account dedicated route connections (Gap 13.2).
|
||||
+1
-1
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Routes;
|
||||
|
||||
namespace NATS.Server.Tests.Routes;
|
||||
namespace NATS.Server.Clustering.Tests.Routes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="RouteManager.RemoveRoute"/>,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user