using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Exceptions;
using PSProxmoxVE.Core.Services;
using Xunit;
namespace PSProxmoxVE.Core.Tests.Services
{
///
/// Proves that and
/// apply their own timeout parameter to the they
/// construct, instead of always inheriting . Each test runs a
/// real TLS server on loopback that completes the handshake and then never answers the HTTP
/// request, so the request can only end via the client-side timeout.
///
public class UploadTimeoutTests
{
private static X509Certificate2 CreateSelfSignedServerCert()
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=127.0.0.1", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
// Re-import as PKCS12 — an ephemeral CertificateRequest key set is not usable by
// SslStream's server-auth path on every platform without this round-trip.
return new X509Certificate2(cert.Export(X509ContentType.Pkcs12));
}
///
/// Starts a loopback TLS server that accepts one connection, completes the handshake,
/// and then holds the connection open without ever writing an HTTP response.
///
private static (TcpListener listener, int port) StartSilentTlsServer(X509Certificate2 cert, TimeSpan hold)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
try
{
using var tcpClient = await listener.AcceptTcpClientAsync().ConfigureAwait(false);
using var sslStream = new SslStream(tcpClient.GetStream(), leaveInnerStreamOpen: false);
await sslStream.AuthenticateAsServerAsync(cert, clientCertificateRequired: false,
checkCertificateRevocation: false).ConfigureAwait(false);
// Never read the request or write a response — the client is left waiting
// until its own HttpClient.Timeout fires, or until the test tears the
// listener down.
await Task.Delay(hold).ConfigureAwait(false);
}
catch
{
// The test disposes the listener once it has its result; ignore the resulting
// teardown exceptions on this background task.
}
});
return (listener, port);
}
private static PveSession NewSessionWithTimeout(int port, TimeSpan sessionTimeout)
{
var session = new PveSession("127.0.0.1", port, skipCertificateCheck: true,
apiToken: "root@pam!test=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
session.Timeout = sessionTimeout;
return session;
}
[Fact]
public async Task UploadOva_ExplicitTimeout_FiresBeforeSessionDefault()
{
using var cert = CreateSelfSignedServerCert();
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(60));
var tempFile = Path.GetTempFileName();
try
{
// Session default is 100s (PveSession's own built-in default); the explicit
// override below must be what actually governs this call.
var session = NewSessionWithTimeout(port, TimeSpan.FromSeconds(100));
var service = new VmService();
var sw = Stopwatch.StartNew();
var ex = await Assert.ThrowsAsync(() =>
Task.Run(() => service.UploadOva(session, "pve1", "local", tempFile,
timeout: TimeSpan.FromSeconds(1))));
sw.Stop();
Assert.Equal(HttpStatusCode.RequestTimeout, ex.StatusCode);
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30),
$"expected the 1s override to fire, not the 100s session default; took {sw.Elapsed}");
}
finally
{
listener.Stop();
File.Delete(tempFile);
}
}
[Fact]
public async Task UploadIso_ExplicitTimeout_FiresBeforeSessionDefault()
{
using var cert = CreateSelfSignedServerCert();
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(60));
var tempFile = Path.GetTempFileName();
try
{
var session = NewSessionWithTimeout(port, TimeSpan.FromSeconds(100));
var service = new StorageService();
var sw = Stopwatch.StartNew();
var ex = await Assert.ThrowsAsync(() =>
Task.Run(() => service.UploadIso(session, "pve1", "local", tempFile,
progressCallback: null, timeout: TimeSpan.FromSeconds(1))));
sw.Stop();
Assert.Equal(HttpStatusCode.RequestTimeout, ex.StatusCode);
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30),
$"expected the 1s override to fire, not the 100s session default; took {sw.Elapsed}");
}
finally
{
listener.Stop();
File.Delete(tempFile);
}
}
[Fact]
public async Task UploadOva_NoTimeoutGiven_UsesThirtyMinuteDefaultNotSessionTimeout()
{
using var cert = CreateSelfSignedServerCert();
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(4));
var tempFile = Path.GetTempFileName();
Task? uploadTask = null;
try
{
// Session default is deliberately shorter than the wait window below. If
// UploadOva let the session's own timeout govern the request (the #139 bug),
// this would throw within that window instead of still being in flight.
var session = NewSessionWithTimeout(port, TimeSpan.FromMilliseconds(300));
var service = new VmService();
uploadTask = Task.Run(() => service.UploadOva(session, "pve1", "local", tempFile));
var completedFirst = await Task.WhenAny(uploadTask, Task.Delay(TimeSpan.FromSeconds(3)))
.ConfigureAwait(false);
Assert.NotSame(uploadTask, completedFirst);
}
finally
{
listener.Stop();
// The upload still holds tempFile open until the server drops the connection;
// Windows refuses to delete an open file, so wait for the task to end first.
if (uploadTask != null)
{
try { await uploadTask.ConfigureAwait(false); }
catch (Exception) { }
}
File.Delete(tempFile);
}
}
[Fact]
public async Task UploadIso_NoTimeoutGiven_UsesThirtyMinuteDefaultNotSessionTimeout()
{
using var cert = CreateSelfSignedServerCert();
var (listener, port) = StartSilentTlsServer(cert, TimeSpan.FromSeconds(4));
var tempFile = Path.GetTempFileName();
Task? uploadTask = null;
try
{
var session = NewSessionWithTimeout(port, TimeSpan.FromMilliseconds(300));
var service = new StorageService();
uploadTask = Task.Run(() => service.UploadIso(session, "pve1", "local", tempFile));
var completedFirst = await Task.WhenAny(uploadTask, Task.Delay(TimeSpan.FromSeconds(3)))
.ConfigureAwait(false);
Assert.NotSame(uploadTask, completedFirst);
}
finally
{
listener.Stop();
// The upload still holds tempFile open until the server drops the connection;
// Windows refuses to delete an open file, so wait for the task to end first.
if (uploadTask != null)
{
try { await uploadTask.ConfigureAwait(false); }
catch (Exception) { }
}
File.Delete(tempFile);
}
}
}
}