Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,21 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Write test appsettings
env:
APPSETTINGS: ${{ secrets.APPSETTINGS }}
run: |
if [ -n "$APPSETTINGS" ]; then
echo "$APPSETTINGS" > HybridConnectionClientProxy.Tests/appsettings.Test.json
fi
- name: Restore dependencies
run: dotnet restore
run: dotnet restore HybridConnectionClientProxy.slnx
- name: Build
run: dotnet build --no-restore
run: dotnet build HybridConnectionClientProxy.slnx --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
run: dotnet test HybridConnectionClientProxy.slnx --no-restore --verbosity normal
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\HybridConnectionClientProxy\HybridConnectionClientProxy.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="appsettings.Test.json" Condition="Exists('appsettings.Test.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
158 changes: 158 additions & 0 deletions HybridConnectionClientProxy.Tests/src/AzureIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;

using Xunit;

namespace HybridConnectionClientProxy.Tests;

/// <summary>
/// Integration tests that run against a real Azure Hybrid Connection endpoint.
///
/// These tests require <c>appsettings.Test.json</c> to be present alongside the test
/// assembly, containing valid Azure Relay credentials in the same format as
/// <c>appsettings.json</c>. When the file is absent (fork PRs, local runs without
/// credentials) every test is skipped automatically.
///
/// The endpoint referenced by the first proxy in the config must have a listener
/// registered and running for data-flow assertions to pass.
/// </summary>
public class AzureIntegrationTests
{
private const int TimeoutMs = 30_000;

// -------------------------------------------------------------------------
// Tests
// -------------------------------------------------------------------------

[Fact]
public async Task AzureProxy_WhenClientConnects_ConnectionEstablishedWithoutError()
{
var connStr = GetConnectionString();
if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" );

using var cts = new CancellationTokenSource( TimeoutMs );

var provider = new HybridConnectionClientProvider( connStr! );
var (proxyListener, proxyPort) = StartListener();
var proxyTask = ClientProxy.Create( provider, proxyListener, cts.Token );

using var client = new TcpClient();
await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token );

// Give the relay a moment to confirm the connection is live
await Task.Delay( 1_000, cts.Token );

await cts.CancelAsync();
proxyListener.Stop();
await proxyTask;
}

[Fact]
public async Task AzureProxy_WhenClientDisconnects_CleansUpPromptly()
{
var connStr = GetConnectionString();
if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" );

using var cts = new CancellationTokenSource( TimeoutMs );

var provider = new HybridConnectionClientProvider( connStr! );
var (proxyListener, proxyPort) = StartListener();
var proxyTask = ClientProxy.Create( provider, proxyListener, cts.Token );

var client = new TcpClient();
await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token );
await Task.Delay( 500, cts.Token ); // let the relay connection settle

// Disconnect the client — this is the scenario that used to hang
client.Close();

// The proxy should clean up within a few seconds; if it hangs the test
// times out and fails, confirming the regression is present.
await Task.Delay( 3_000, cts.Token );

await cts.CancelAsync();
proxyListener.Stop();
await proxyTask;
}

[Fact]
public async Task AzureProxy_SmtpEhlo_ServerResponds250()
{
var connStr = GetConnectionString();
if( connStr is null ) Assert.Skip( "No Azure credentials in appsettings.Test.json" );

using var cts = new CancellationTokenSource( TimeoutMs );

var provider = new HybridConnectionClientProvider( connStr! );
var (proxyListener, proxyPort) = StartListener();
_ = ClientProxy.Create( provider, proxyListener, cts.Token );

using var client = new TcpClient();
await client.ConnectAsync( IPAddress.Loopback, proxyPort, cts.Token );
using var reader = new StreamReader( client.GetStream(), Encoding.ASCII, leaveOpen: true );
using var writer = new StreamWriter( client.GetStream(), Encoding.ASCII, leaveOpen: true ) { NewLine = "\r\n", AutoFlush = true };

// Server must send a 220 greeting
var greeting = await ReadSmtpResponseAsync( reader, cts.Token );
Assert.StartsWith( "220", greeting );

// Send EHLO and expect 250
await writer.WriteLineAsync( "EHLO test" );
var response = await ReadSmtpResponseAsync( reader, cts.Token );
Assert.StartsWith( "250", response );

// Quit cleanly
await writer.WriteLineAsync( "QUIT" );
await ReadSmtpResponseAsync( reader, cts.Token );

await cts.CancelAsync();
proxyListener.Stop();
}

// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------

private static string? GetConnectionString()
{
var path = Path.Combine( AppContext.BaseDirectory, "appsettings.Test.json" );
if( !File.Exists( path ) ) return null;

try
{
using var doc = JsonDocument.Parse( File.ReadAllText( path ) );
var proxies = doc.RootElement
.GetProperty( "AppSettings" )
.GetProperty( "Proxies" );
if( proxies.GetArrayLength() == 0 ) return null;
return proxies[0].GetProperty( "HybridConnectionString" ).GetString();
}
catch { return null; }
}

private static (TcpListener listener, int port) StartListener()
{
var listener = new TcpListener( IPAddress.Loopback, 0 );
listener.Start();
return (listener, ((IPEndPoint) listener.LocalEndpoint).Port);
}

/// <summary>
/// Reads a (possibly multi-line) SMTP response and returns the final line.
/// Multi-line responses use "NNN-text" for continuation lines and "NNN text" for the last.
/// </summary>
private static async Task<string> ReadSmtpResponseAsync( StreamReader reader, CancellationToken ct )
{
string? line;
do
{
line = await reader.ReadLineAsync( ct );
if( line is null ) throw new EndOfStreamException( "SMTP server closed the connection." );
}
while( line.Length >= 4 && line[3] == '-' ); // continuation line

return line;
}
}
Loading
Loading