diff --git a/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs index 7436c02..0531ee6 100644 --- a/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs +++ b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs @@ -56,17 +56,9 @@ namespace PSProxmoxVE.Core.Authentication responseBody = httpClient.Post("/api2/json/access/ticket", formData); } - var json = JObject.Parse(responseBody); - var data = json["data"] ?? throw new InvalidOperationException("Response did not contain a 'data' field."); + var ticket = PveSession.TicketState.FromTicketResponse(responseBody, DateTime.UtcNow); - var ticket = data["ticket"]?.Value() - ?? throw new InvalidOperationException("Response did not contain a ticket."); - var csrfToken = data["CSRFPreventionToken"]?.Value() - ?? throw new InvalidOperationException("Response did not contain a CSRFPreventionToken."); - - var ticketExpiry = DateTime.UtcNow.AddHours(2); - - var session = new PveSession(hostname, port, skipCertificateCheck, ticket, csrfToken, ticketExpiry); + var session = new PveSession(hostname, port, skipCertificateCheck, username, ticket.Ticket, ticket.CsrfToken, ticket.Expiry); if (timeout.HasValue) session.Timeout = timeout.Value; diff --git a/src/PSProxmoxVE.Core/Authentication/PveSession.cs b/src/PSProxmoxVE.Core/Authentication/PveSession.cs index 10c3f6e..648d4b1 100644 --- a/src/PSProxmoxVE.Core/Authentication/PveSession.cs +++ b/src/PSProxmoxVE.Core/Authentication/PveSession.cs @@ -1,4 +1,6 @@ using System; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Models; namespace PSProxmoxVE.Core.Authentication @@ -6,6 +8,13 @@ namespace PSProxmoxVE.Core.Authentication /// Represents an authenticated session to a Proxmox VE server. public class PveSession { + /// How long PVE honours a ticket from the moment it is issued. + internal static readonly TimeSpan TicketLifetime = TimeSpan.FromHours(2); + + private readonly object _ticketLock = new object(); + private TicketState? _ticket; + private Task? _renewal; + /// The hostname or IP address of the Proxmox VE server. public string Hostname { get; } @@ -21,14 +30,17 @@ namespace PSProxmoxVE.Core.Authentication /// The API token string, when using API token authentication. public string? ApiToken { get; } + /// The user (user@realm) the ticket was issued to; null for API token sessions. + public string? Username { get; } + /// The session ticket cookie value, when using ticket authentication. - public string? Ticket { get; } + public string? Ticket => ReadTicket()?.Ticket; /// The CSRF prevention token, when using ticket authentication. - public string? CsrfToken { get; } + public string? CsrfToken => ReadTicket()?.CsrfToken; /// The UTC expiry time for the session ticket. - public DateTime TicketExpiry { get; } + public DateTime TicketExpiry => ReadTicket()?.Expiry ?? DateTime.MaxValue; /// The Proxmox VE version detected on the server at connection time. public PveVersion? ServerVersion { get; internal set; } @@ -59,6 +71,7 @@ namespace PSProxmoxVE.Core.Authentication string hostname, int port, bool skipCertificateCheck, + string username, string ticket, string csrfToken, DateTime ticketExpiry) @@ -67,9 +80,8 @@ namespace PSProxmoxVE.Core.Authentication Port = port; SkipCertificateCheck = skipCertificateCheck; AuthMode = PveAuthMode.Ticket; - Ticket = ticket; - CsrfToken = csrfToken; - TicketExpiry = ticketExpiry; + Username = username ?? throw new ArgumentNullException(nameof(username)); + _ticket = new TicketState(ticket, csrfToken, ticketExpiry); } /// Creates a session using API token authentication @@ -84,7 +96,94 @@ namespace PSProxmoxVE.Core.Authentication SkipCertificateCheck = skipCertificateCheck; AuthMode = PveAuthMode.ApiToken; ApiToken = apiToken; - TicketExpiry = DateTime.MaxValue; + } + + /// The current ticket credential as one consistent snapshot; null for API token sessions. + internal TicketState? ReadTicket() + { + lock (_ticketLock) + return _ticket; + } + + /// + /// Single-flight entry for replacing . Returns the task every + /// caller awaits for the outcome. When comes back non-null + /// the caller owns the renewal and must finish it with or + /// ; otherwise it is joining one already in flight, or + /// has already been replaced and the task is the replacement. + /// + internal Task JoinOrClaimRenewal(TicketState stale, out TaskCompletionSource? claimed) + { + lock (_ticketLock) + { + claimed = null; + if (!ReferenceEquals(_ticket, stale)) + return Task.FromResult(_ticket!); + if (_renewal != null) + return _renewal; + + claimed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _renewal = claimed.Task; + return _renewal; + } + } + + /// Installs and releases everyone awaiting the claimed renewal. + internal void CompleteRenewal(TaskCompletionSource claimed, TicketState renewed) + { + if (renewed == null) throw new ArgumentNullException(nameof(renewed)); + lock (_ticketLock) + { + _ticket = renewed; + _renewal = null; + } + claimed.SetResult(renewed); + } + + /// Leaves the ticket as it was and hands to everyone awaiting the claimed renewal. + internal void FailRenewal(TaskCompletionSource claimed, Exception failure) + { + lock (_ticketLock) + _renewal = null; + claimed.SetException(failure); + } + + /// + /// One issued ticket: the cookie value, its CSRF token and its expiry. Immutable, so a + /// request built from a snapshot never mixes fields from two different tickets. + /// + internal sealed class TicketState + { + public string Ticket { get; } + public string CsrfToken { get; } + public DateTime Expiry { get; } + + /// The instant after which a request should renew before sending: half the lifetime before expiry. + public DateTime RenewAfter => Expiry - TimeSpan.FromTicks(TicketLifetime.Ticks / 2); + + public TicketState(string ticket, string csrfToken, DateTime expiry) + { + Ticket = ticket ?? throw new ArgumentNullException(nameof(ticket)); + CsrfToken = csrfToken ?? throw new ArgumentNullException(nameof(csrfToken)); + Expiry = expiry; + } + + /// Parses the data envelope of a POST /access/ticket response. + /// The raw JSON response body. + /// When the ticket was issued; the expiry is later. + public static TicketState FromTicketResponse(string responseBody, DateTime issuedAt) + { + var json = JObject.Parse(responseBody); + if (!(json["data"] is JObject data)) + throw new InvalidOperationException("Response did not contain a 'data' field."); + + var ticket = data["ticket"]?.Value() + ?? throw new InvalidOperationException("Response did not contain a ticket."); + var csrfToken = data["CSRFPreventionToken"]?.Value() + ?? throw new InvalidOperationException("Response did not contain a CSRFPreventionToken."); + + return new TicketState(ticket, csrfToken, issuedAt + TicketLifetime); + } } } } diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs index 2f8ad84..611f05c 100644 --- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs +++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Authentication; @@ -28,10 +29,12 @@ namespace PSProxmoxVE.Core.Client private readonly TimeSpan _guestLockRetryWindow; private readonly Func _guestLockRetryDelay; + private readonly Func _utcNow; private const string ApiTokenPrefix = "PVEAPIToken="; private const string AuthCookieName = "PVEAuthCookie="; private const string CsrfHeaderName = "CSRFPreventionToken"; + private const string TicketResource = "access/ticket"; /// /// Creates an HTTP client authenticated with the specified PVE session. @@ -82,18 +85,24 @@ namespace PSProxmoxVE.Core.Client /// Cache to take the handler from when is null. Null uses /// , the same as the public constructor. /// + /// + /// Clock the ticket half-life check reads. Null uses , + /// the same as the public constructor. + /// internal PveHttpClient( PveSession session, TimeSpan? timeoutOverride, TimeSpan? guestLockRetryWindow, HttpMessageHandler? handler, Func? guestLockRetryDelay = null, - PveHandlerCache? handlerCache = null) + PveHandlerCache? handlerCache = null, + Func? utcNow = null) { _session = session ?? throw new ArgumentNullException(nameof(session)); _baseUrl = session.BaseUrl; _guestLockRetryWindow = guestLockRetryWindow ?? GuestLockRetry.DefaultWindow; _guestLockRetryDelay = guestLockRetryDelay ?? Task.Delay; + _utcNow = utcNow ?? (() => DateTime.UtcNow); var ownsHandler = handler != null; _handler = handler ?? (handlerCache ?? PveHandlerCache.Shared) @@ -121,6 +130,7 @@ namespace PSProxmoxVE.Core.Client _baseUrl = $"https://{hostname}:{port}"; _guestLockRetryWindow = GuestLockRetry.DefaultWindow; _guestLockRetryDelay = Task.Delay; + _utcNow = () => DateTime.UtcNow; _handler = PveHandlerCache.Shared.Get(hostname, port, skipCertificateCheck); _httpClient = new HttpClient(_handler, disposeHandler: false); @@ -140,7 +150,7 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task GetAsync(string resource) { - return await SendAsync(() => BuildRequest(HttpMethod.Get, resource), resource, "GET") + return await SendAsync(ticket => BuildRequest(HttpMethod.Get, resource, ticket), resource, "GET") .ConfigureAwait(false); } @@ -150,9 +160,9 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task PostAsync(string resource, Dictionary? data = null) { - return await SendAsync(() => + return await SendAsync(ticket => { - var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + var request = BuildRequest(HttpMethod.Post, resource, ticket, mutating: true); if (data != null) request.Content = BuildFormContent(data); return request; @@ -169,9 +179,9 @@ namespace PSProxmoxVE.Core.Client public async Task PostAsync(string resource, IEnumerable> data) { if (data == null) throw new ArgumentNullException(nameof(data)); - return await SendAsync(() => + return await SendAsync(ticket => { - var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + var request = BuildRequest(HttpMethod.Post, resource, ticket, mutating: true); request.Content = BuildFormContent(data); return request; }, resource, "POST").ConfigureAwait(false); @@ -183,9 +193,9 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task PutAsync(string resource, Dictionary? data = null) { - return await SendAsync(() => + return await SendAsync(ticket => { - var request = BuildRequest(HttpMethod.Put, resource, mutating: true); + var request = BuildRequest(HttpMethod.Put, resource, ticket, mutating: true); if (data != null) request.Content = BuildFormContent(data); return request; @@ -197,7 +207,7 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task DeleteAsync(string resource) { - return await SendAsync(() => BuildRequest(HttpMethod.Delete, resource, mutating: true), resource, "DELETE") + return await SendAsync(ticket => BuildRequest(HttpMethod.Delete, resource, ticket, mutating: true), resource, "DELETE") .ConfigureAwait(false); } @@ -354,6 +364,8 @@ namespace PSProxmoxVE.Core.Client multipart.Add(csPart); } + var ticket = await CurrentTicketAsync().ConfigureAwait(false); + // File part — StreamContent does not add Content-Transfer-Encoding. var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4 * 1024 * 1024, useAsync: true); @@ -374,7 +386,7 @@ namespace PSProxmoxVE.Core.Client fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/octet-stream"); multipart.Add(fileContent); - var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + var request = BuildRequest(HttpMethod.Post, resource, ticket, mutating: true); request.Content = multipart; return await SendOnceAsync(request, resource, "POST").ConfigureAwait(false); @@ -389,7 +401,12 @@ namespace PSProxmoxVE.Core.Client // Private helpers // ------------------------------------------------------------------------- - private HttpRequestMessage BuildRequest(HttpMethod method, string resource, bool mutating = false) + /// + /// Builds a request signed with on a ticket-mode session, or + /// with the API token otherwise. The ticket is passed in rather than read from the + /// session so the caller knows which ticket the request carried when it comes back 401. + /// + private HttpRequestMessage BuildRequest(HttpMethod method, string resource, PveSession.TicketState? ticket, bool mutating = false) { var url = _baseUrl + resource; var request = new HttpRequestMessage(method, url); @@ -401,12 +418,11 @@ namespace PSProxmoxVE.Core.Client { request.Headers.TryAddWithoutValidation("Authorization", $"{ApiTokenPrefix}{_session.ApiToken}"); } - else + else if (ticket != null) { - // Ticket auth - request.Headers.Add("Cookie", $"{AuthCookieName}{_session.Ticket}"); - if (mutating && !string.IsNullOrEmpty(_session.CsrfToken)) - request.Headers.Add(CsrfHeaderName, _session.CsrfToken); + request.Headers.Add("Cookie", $"{AuthCookieName}{ticket.Ticket}"); + if (mutating && !string.IsNullOrEmpty(ticket.CsrfToken)) + request.Headers.Add(CsrfHeaderName, ticket.CsrfToken); } return request; @@ -414,40 +430,152 @@ namespace PSProxmoxVE.Core.Client /// /// Sends a request, rebuilding it from for each attempt - /// while PVE rejects it for a guest's config flock. An + /// while PVE rejects it for a guest's config flock, and once more after a ticket renewal + /// when a ticket-mode session is rejected with 401. An /// cannot be resent, which is why this takes a factory rather than a request. /// - private Task SendAsync(Func buildRequest, string resource, string httpMethod) => + private Task SendAsync(Func buildRequest, string resource, string httpMethod) => GuestLockRetry.ExecuteAsync( - () => SendOnceAsync(buildRequest(), resource, httpMethod), _guestLockRetryWindow, _guestLockRetryDelay); + () => SendRenewingAsync(buildRequest, resource, httpMethod), _guestLockRetryWindow, _guestLockRetryDelay); - private async Task SendOnceAsync(HttpRequestMessage request, string resource, string httpMethod) + private async Task SendRenewingAsync(Func buildRequest, string resource, string httpMethod) + { + var ticket = await CurrentTicketAsync().ConfigureAwait(false); + try + { + return await SendOnceAsync(buildRequest(ticket), resource, httpMethod).ConfigureAwait(false); + } + catch (PveApiException ex) when (ticket != null && ex.StatusCode == HttpStatusCode.Unauthorized) + { + var renewed = await RenewTicketAsync(ticket, ex).ConfigureAwait(false); + try + { + return await SendOnceAsync(buildRequest(renewed), resource, httpMethod).ConfigureAwait(false); + } + catch (PveApiException again) when (again.StatusCode == HttpStatusCode.Unauthorized) + { + throw new PveSessionExpiredException("The renewed ticket was rejected too.", again); + } + } + } + + /// + /// The ticket a request should carry now: null for a bare client or an API token session, + /// otherwise the session's current ticket, renewed first if it is past half its lifetime. + /// A ticket past its full lifetime has nothing left to trade, so it is not sent anywhere. + /// + private async Task CurrentTicketAsync() + { + if (_session == null || _session.AuthMode != PveAuthMode.Ticket) + return null; + + var ticket = _session.ReadTicket()!; + var now = _utcNow(); + if (now < ticket.RenewAfter) + return ticket; + if (now >= ticket.Expiry) + throw new PveSessionExpiredException(); + + return await RenewTicketAsync(ticket, rejection: null).ConfigureAwait(false); + } + + /// + /// Trades for a fresh ticket by posting it as the password to + /// /access/ticket, and installs the result on the session. Concurrent callers on + /// the same session share one POST and its outcome, success or failure; a caller whose + /// ticket was already replaced gets the replacement without any POST. + /// + /// The ticket the caller holds and wants replaced. + /// + /// The 401 that prompted this renewal, when reactive. A failed reactive renewal throws + /// around it. A failed proactive renewal does + /// so only when the ticket endpoint itself answers 401 or the ticket has meanwhile + /// expired; any other failure says nothing about the ticket, which is still valid, so + /// the caller keeps using it. + /// + private async Task RenewTicketAsync(PveSession.TicketState stale, PveApiException? rejection) + { + var session = _session!; + var renewal = session.JoinOrClaimRenewal(stale, out var claimed); + if (claimed != null) + { + try + { + session.CompleteRenewal(claimed, await PostTicketRenewalAsync(session, stale).ConfigureAwait(false)); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + session.FailRenewal(claimed, ex); + } + } + + try + { + return await renewal.ConfigureAwait(false); + } + catch (Exception ex) when (ex is PveApiException or Newtonsoft.Json.JsonException or InvalidOperationException) + { + if (rejection != null) + throw new PveSessionExpiredException($"Ticket renewal failed: {ex.Message}", rejection); + if (ex is PveApiException api && api.StatusCode == HttpStatusCode.Unauthorized) + throw new PveSessionExpiredException(ex); + if (_utcNow() < stale.Expiry) + return stale; + throw new PveSessionExpiredException($"Ticket renewal failed: {ex.Message}", ex); + } + } + + /// + /// The renewal POST itself. It goes straight to so it carries + /// no cookie and cannot recurse into renewal or the guest-lock retry, and it is bounded by + /// the session's own timeout rather than this client's, which an upload may have set to + /// infinite. + /// + private async Task PostTicketRenewalAsync(PveSession session, PveSession.TicketState stale) + { + var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + TicketResource); + request.Content = BuildFormContent(new Dictionary + { + ["username"] = session.Username!, + ["password"] = stale.Ticket, + }); + + var issuedAt = _utcNow(); + var body = await SendOnceAsync(request, TicketResource, "POST", session.Timeout).ConfigureAwait(false); + return PveSession.TicketState.FromTicketResponse(body, issuedAt); + } + + private async Task SendOnceAsync(HttpRequestMessage request, string resource, string httpMethod, TimeSpan? timeout = null) { HttpResponseMessage response; string body; - try + using (var cts = timeout.HasValue ? new CancellationTokenSource(timeout.Value) : null) { - response = await _httpClient.SendAsync(request).ConfigureAwait(false); - body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - } - catch (TaskCanceledException ex) - { - // PveHttpClient.SendAsync passes no CancellationToken to HttpClient.SendAsync, - // so a TaskCanceledException reaching here can only be HttpClient.Timeout - // firing — on .NET Framework, on .NET Core, and on .NET 5+ (where it also - // carries a TimeoutException inner). Wrap it uniformly across frameworks. - var seconds = _httpClient.Timeout == System.Threading.Timeout.InfiniteTimeSpan - ? "infinite" - : _httpClient.Timeout.TotalSeconds.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + "s"; - throw new PveApiException(HttpStatusCode.RequestTimeout, - $"Request timed out after {seconds}.", resource, httpMethod, ex); - } - catch (HttpRequestException ex) - { - // Covers both a failed connection and a stream drop mid-body-read, so every - // HttpRequestException PveHttpClient can throw arrives as PveApiException. - throw new PveApiException(HttpStatusCode.ServiceUnavailable, - ex.Message, resource, httpMethod, ex); + try + { + response = await _httpClient.SendAsync(request, cts?.Token ?? CancellationToken.None).ConfigureAwait(false); + body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + catch (TaskCanceledException ex) + { + // The only token ever handed to HttpClient.SendAsync is the per-call timeout + // above, so a TaskCanceledException reaching here is always a timeout — that + // one or HttpClient.Timeout — on .NET Framework, on .NET Core, and on .NET 5+ + // (where it also carries a TimeoutException inner). Wrap it uniformly. + var limit = timeout ?? _httpClient.Timeout; + var seconds = limit == System.Threading.Timeout.InfiniteTimeSpan + ? "infinite" + : limit.TotalSeconds.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + "s"; + throw new PveApiException(HttpStatusCode.RequestTimeout, + $"Request timed out after {seconds}.", resource, httpMethod, ex); + } + catch (HttpRequestException ex) + { + // Covers both a failed connection and a stream drop mid-body-read, so every + // HttpRequestException PveHttpClient can throw arrives as PveApiException. + throw new PveApiException(HttpStatusCode.ServiceUnavailable, + ex.Message, resource, httpMethod, ex); + } } if (!response.IsSuccessStatusCode) diff --git a/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs b/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs index fb753e0..422c1cf 100644 --- a/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs +++ b/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs @@ -5,16 +5,30 @@ namespace PSProxmoxVE.Core.Exceptions /// Exception thrown when the Proxmox VE session ticket has expired. public class PveSessionExpiredException : Exception { + private const string DefaultMessage = + "Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session."; + /// Initializes a new instance indicating the session has expired. public PveSessionExpiredException() - : base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.") + : base(DefaultMessage) { } /// Initializes a new instance indicating the session has expired, with an inner exception. /// The exception that caused this failure. public PveSessionExpiredException(Exception innerException) - : base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.", innerException) + : base(DefaultMessage, innerException) + { + } + + /// + /// Initializes a new instance indicating the session has expired, appending + /// to the message, with an inner exception. + /// + /// What was attempted to keep the session alive, and how it failed. + /// The exception that caused this failure. + public PveSessionExpiredException(string detail, Exception innerException) + : base(DefaultMessage + " " + detail, innerException) { } } diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs index 25fe5fc..e9c9f7c 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs @@ -112,7 +112,8 @@ namespace PSProxmoxVE.Cmdlets.Cluster { return taskService.WaitForTask(session, nodeName, upid); } - catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) + catch (Exception ex) when (ex is PveSessionExpiredException + || (ex is PveApiException api && api.StatusCode == HttpStatusCode.Unauthorized)) { WriteVerbose("Session expired during join — waiting for auth services to restart..."); var newSession = ReauthenticateWithRetry(session, plainPassword); diff --git a/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs b/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs index cd70cd9..ab7bbea 100644 --- a/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs @@ -13,7 +13,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication public void TicketSession_NotExpired_OnCreation() { var expiry = DateTime.UtcNow.AddHours(2); - var session = new PveSession(TestHostname, TestPort, false, "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); Assert.False(session.IsExpired); } @@ -23,7 +23,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication { // Simulate a ticket that already expired an hour ago var expiry = DateTime.UtcNow.AddHours(-1); - var session = new PveSession(TestHostname, TestPort, false, "PVE:root@pam:OLDTICKET", "CSRFTOKEN", expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLDTICKET", "CSRFTOKEN", expiry); Assert.True(session.IsExpired); } @@ -49,7 +49,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication public void TicketSession_AuthMode_IsTicket() { var expiry = DateTime.UtcNow.AddHours(2); - var session = new PveSession(TestHostname, TestPort, false, "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); Assert.Equal(PveAuthMode.Ticket, session.AuthMode); } @@ -58,7 +58,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication public void SeparateSessions_DoNotShareState() { var expiry1 = DateTime.UtcNow.AddHours(2); - var session1 = new PveSession("host1", 8006, false, "PVE:root@pam:TICKET1", "CSRF1", expiry1); + var session1 = new PveSession("host1", 8006, false, "root@pam", "PVE:root@pam:TICKET1", "CSRF1", expiry1); var session2 = new PveSession("host2", 8006, false, "root@pam!token=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); Assert.NotEqual(session1.Hostname, session2.Hostname); @@ -77,12 +77,95 @@ namespace PSProxmoxVE.Core.Tests.Authentication const string ticket = "PVE:root@pam:ABCD1234"; const string csrf = "CSRFPREVENTION"; - var session = new PveSession(TestHostname, TestPort, false, ticket, csrf, expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", ticket, csrf, expiry); Assert.Equal(ticket, session.Ticket); Assert.Equal(csrf, session.CsrfToken); } + [Fact] + public void TicketSession_StoresUsername() + { + var session = new PveSession(TestHostname, TestPort, false, "admin@pve", "PVE:admin@pve:TICKET", "CSRF", DateTime.UtcNow.AddHours(2)); + + Assert.Equal("admin@pve", session.Username); + } + + [Fact] + public void ApiTokenSession_HasNoUsername() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + Assert.Null(session.Username); + } + + [Fact] + public void TicketSession_RejectsNullUsername() + { + Assert.Throws(() => + new PveSession(TestHostname, TestPort, false, null!, "PVE:root@pam:TICKET", "CSRF", DateTime.UtcNow.AddHours(2))); + } + + [Fact] + public void Renewal_SecondCallerJoinsTheClaimedRenewalAndSharesItsResult() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLD", "CSRF-OLD", DateTime.UtcNow.AddHours(2)); + var stale = session.ReadTicket()!; + + var first = session.JoinOrClaimRenewal(stale, out var claimed); + var second = session.JoinOrClaimRenewal(stale, out var secondClaim); + + Assert.NotNull(claimed); + Assert.Null(secondClaim); + Assert.Same(first, second); + Assert.False(first.IsCompleted); + + var renewed = new PveSession.TicketState("PVE:root@pam:NEW", "CSRF-NEW", DateTime.UtcNow.AddHours(2)); + session.CompleteRenewal(claimed!, renewed); + + Assert.Same(renewed, first.Result); + Assert.Same(renewed, session.ReadTicket()); + Assert.Equal("PVE:root@pam:NEW", session.Ticket); + } + + [Fact] + public void Renewal_SecondCallerJoinsTheClaimedRenewalAndSharesItsFailure() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLD", "CSRF-OLD", DateTime.UtcNow.AddHours(2)); + var stale = session.ReadTicket()!; + + var first = session.JoinOrClaimRenewal(stale, out var claimed); + var second = session.JoinOrClaimRenewal(stale, out _); + var failure = new InvalidOperationException("boom"); + session.FailRenewal(claimed!, failure); + + Assert.True(first.IsFaulted); + Assert.True(second.IsFaulted); + Assert.Same(failure, first.Exception!.InnerException); + Assert.Same(failure, second.Exception!.InnerException); + Assert.Same(stale, session.ReadTicket()); + + var retry = session.JoinOrClaimRenewal(stale, out var retryClaim); + Assert.NotNull(retryClaim); + Assert.False(retry.IsCompleted); + } + + [Fact] + public void Renewal_CallerHoldingAReplacedTicketGetsTheReplacementWithoutClaiming() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLD", "CSRF-OLD", DateTime.UtcNow.AddHours(2)); + var stale = session.ReadTicket()!; + var renewed = new PveSession.TicketState("PVE:root@pam:NEW", "CSRF-NEW", DateTime.UtcNow.AddHours(2)); + session.JoinOrClaimRenewal(stale, out var claimed); + session.CompleteRenewal(claimed!, renewed); + + var late = session.JoinOrClaimRenewal(stale, out var lateClaim); + + Assert.Null(lateClaim); + Assert.True(late.IsCompleted); + Assert.Same(renewed, late.Result); + } + [Fact] public void ApiTokenSession_StoresApiToken() { diff --git a/tests/PSProxmoxVE.Core.Tests/Client/PveHttpClientTicketRenewalTests.cs b/tests/PSProxmoxVE.Core.Tests/Client/PveHttpClientTicketRenewalTests.cs new file mode 100644 index 0000000..d5da60d --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Client/PveHttpClientTicketRenewalTests.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Exceptions; +using Xunit; + +namespace PSProxmoxVE.Core.Tests.Client +{ + public class PveHttpClientTicketRenewalTests + { + private const string Username = "root@pam"; + + // A real ticket ends in a base64 signature, so the fixture carries the three base64 + // characters the form encoder rewrites; the expected body below is the encoded form. + private const string OldTicket = "PVE:root@pam:68B7ABCD::aB+c/d=="; + private const string OldTicketEncoded = "PVE:root@pam:68B7ABCD::aB%2Bc/d%3D%3D"; + private const string OldCsrf = "CSRF-OLD"; + private const string NewTicket = "PVE:root@pam:68B7BEEF::eF+g/h=="; + private const string NewCsrf = "CSRF-NEW"; + + private const string RenewalBody = "username=" + Username + "&password=" + OldTicketEncoded; + private const string TicketOk = + "{\"data\":{\"ticket\":\"" + NewTicket + "\",\"CSRFPreventionToken\":\"" + NewCsrf + "\",\"username\":\"root@pam\"}}"; + private const string Unauthorized = "{\"data\":null,\"message\":\"authentication failure\"}"; + private const string DataOk = "{\"data\":{}}"; + + private static readonly DateTime Now = new DateTime(2026, 9, 3, 12, 0, 0, DateTimeKind.Utc); + + private static Task NoDelay(TimeSpan _) => Task.CompletedTask; + + private static PveSession TicketSession(DateTime expiry) => + new PveSession("pve.example.com", 8006, false, Username, OldTicket, OldCsrf, expiry); + + private static PveSession TokenSession() => + new PveSession("pve.example.com", 8006, false, "root@pam!token=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + private static PveHttpClient NewClient(PveSession session, HttpMessageHandler handler, + Func? clock = null, TimeSpan? timeoutOverride = null) => + new PveHttpClient(session, timeoutOverride, guestLockRetryWindow: null, handler, NoDelay, + handlerCache: null, utcNow: clock ?? (() => Now)); + + private static bool IsTicketPost(Recorded r) => + r.Method == HttpMethod.Post && r.Uri.EndsWith("/access/ticket", StringComparison.Ordinal); + + [Fact] + public async Task PutAsync_RenewsOnceAfter401AndRetriesWithTheNewTicket() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var result = await client.PutAsync("nodes/pve9a/qemu/100/config", + new Dictionary { ["cores"] = "2" }); + Assert.Equal(DataOk, result); + } + + Assert.Equal(3, handler.Requests.Count); + + var first = handler.Requests[0]; + Assert.Equal(HttpMethod.Put, first.Method); + Assert.Equal("PVEAuthCookie=" + OldTicket, first.Cookie); + Assert.Equal(OldCsrf, first.Csrf); + + var renewal = handler.Requests[1]; + Assert.True(IsTicketPost(renewal)); + Assert.Equal(RenewalBody, renewal.Body); + Assert.Null(renewal.Cookie); + Assert.Null(renewal.Csrf); + + var retry = handler.Requests[2]; + Assert.Equal(HttpMethod.Put, retry.Method); + Assert.Equal(first.Uri, retry.Uri); + Assert.Equal("cores=2", retry.Body); + Assert.Equal("PVEAuthCookie=" + NewTicket, retry.Cookie); + Assert.Equal(NewCsrf, retry.Csrf); + + Assert.Equal(NewTicket, session.Ticket); + Assert.Equal(NewCsrf, session.CsrfToken); + Assert.Equal(Now.AddHours(2), session.TicketExpiry); + } + + [Fact] + public async Task GetAsync_RenewsBeforeSendingWhenTheTicketIsPastHalfLife() + { + var session = TicketSession(Now.AddMinutes(59)); + var handler = new RecordingHandler( + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + await client.GetAsync("nodes/pve9a/status"); + await client.GetAsync("nodes/pve9a/status"); + } + + Assert.Equal(3, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + Assert.Equal(RenewalBody, handler.Requests[0].Body); + Assert.Equal(HttpMethod.Get, handler.Requests[1].Method); + Assert.Equal("PVEAuthCookie=" + NewTicket, handler.Requests[1].Cookie); + Assert.Equal(HttpMethod.Get, handler.Requests[2].Method); + Assert.Equal("PVEAuthCookie=" + NewTicket, handler.Requests[2].Cookie); + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + } + + [Fact] + public async Task GetAsync_DoesNotRenewBeforeHalfLife() + { + var session = TicketSession(Now.AddMinutes(61)); + var handler = new RecordingHandler((HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + await client.GetAsync("nodes/pve9a/status"); + } + + var only = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Get, only.Method); + Assert.Equal("PVEAuthCookie=" + OldTicket, only.Cookie); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_RenewsExactlyAtHalfLife() + { + var session = TicketSession(Now.AddHours(1)); + var handler = new RecordingHandler( + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + await client.GetAsync("nodes/pve9a/status"); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + } + + [Fact] + public async Task GetAsync_ExpiredTicketThrowsSessionExpiredWithoutSendingAnything() + { + var session = TicketSession(Now.AddMinutes(-1)); + var handler = new RecordingHandler((HttpStatusCode.OK, TicketOk)); + + using (var client = NewClient(session, handler)) + { + await Assert.ThrowsAsync(() => client.GetAsync("nodes/pve9a/status")); + } + + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task GetAsync_ThrowsSessionExpiredWhenTheRenewalIsRejectedToo() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Equal("nodes/pve9a/status", inner.Resource); + Assert.Equal("GET", inner.HttpMethod); + Assert.Contains("Ticket renewal failed", ex.Message); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[1])); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_ReactiveRenewalThatFailsForAnotherReasonStillReportsTheOriginal401() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.ServiceUnavailable, "{\"data\":null}")); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Contains("503", ex.Message); + } + + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task GetAsync_ReactiveRenewalWithAMalformedBodyReportsTheOriginal401() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, "{\"data\":null}")); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_RetriesOnlyOnceAfterRenewal() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Equal("nodes/pve9a/status", inner.Resource); + } + + Assert.Equal(3, handler.Requests.Count); + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + Assert.Equal("PVEAuthCookie=" + NewTicket, handler.Requests[2].Cookie); + } + + [Fact] + public async Task GetAsync_ProactiveRenewalThatFailsForAnotherReasonKeepsUsingTheCurrentTicket() + { + var session = TicketSession(Now.AddMinutes(30)); + var handler = new RecordingHandler( + (HttpStatusCode.ServiceUnavailable, "{\"data\":null}"), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var result = await client.GetAsync("nodes/pve9a/status"); + Assert.Equal(DataOk, result); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + Assert.Equal(HttpMethod.Get, handler.Requests[1].Method); + Assert.Equal("PVEAuthCookie=" + OldTicket, handler.Requests[1].Cookie); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_ProactiveRenewalRejectedWith401ThrowsSessionExpired() + { + var session = TicketSession(Now.AddMinutes(30)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Equal("access/ticket", inner.Resource); + } + + var only = Assert.Single(handler.Requests); + Assert.True(IsTicketPost(only)); + } + + [Fact] + public async Task GetAsync_RenewalIsBoundedByTheSessionTimeoutNotTheClientOverride() + { + var session = TicketSession(Now.AddHours(2)); + session.Timeout = TimeSpan.FromMilliseconds(200); + var handler = new RecordingHandler(async (request, cancellationToken) => + { + if (IsTicketPost(request)) + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken); + } + return (HttpStatusCode.Unauthorized, Unauthorized); + }); + + using (var client = NewClient(session, handler, timeoutOverride: System.Threading.Timeout.InfiniteTimeSpan)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + Assert.Contains("timed out after 0.2s", ex.Message); + } + + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task GetAsync_ApiTokenSessionSurfacesThe401AndNeverPostsATicket() + { + var session = TokenSession(); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + Assert.Equal(HttpStatusCode.Unauthorized, ex.StatusCode); + } + + var only = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Get, only.Method); + Assert.StartsWith("PVEAPIToken=", only.Authorization); + Assert.Null(only.Cookie); + } + + [Fact] + public async Task GetAsync_ACallerArrivingDuringARenewalJoinsItInsteadOfPostingAgain() + { + var session = TicketSession(Now.AddMinutes(10)); + var ticketPostArrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseTicketPost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new RecordingHandler(async (request, _) => + { + if (IsTicketPost(request)) + { + ticketPostArrived.TrySetResult(true); + await releaseTicketPost.Task; + return (HttpStatusCode.OK, TicketOk); + } + return (HttpStatusCode.OK, DataOk); + }); + + // The second client's first clock read happens after it has taken its ticket + // snapshot, so awaiting it proves the snapshot is the stale one. + var secondRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var shared = new PassThroughHandler(handler); + using (var first = NewClient(session, shared)) + using (var second = NewClient(session, shared, clock: () => { secondRead.TrySetResult(true); return Now; })) + { + var a = first.GetAsync("nodes/pve9a/status"); + await ticketPostArrived.Task; + + var b = second.GetAsync("nodes/pve9a/qemu/100/status/current"); + await secondRead.Task; + + releaseTicketPost.TrySetResult(true); + await Task.WhenAll(a, b); + } + + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + var gets = handler.Requests.Where(r => r.Method == HttpMethod.Get).ToList(); + Assert.Equal(2, gets.Count); + Assert.All(gets, g => Assert.Equal("PVEAuthCookie=" + NewTicket, g.Cookie)); + } + + [Fact] + public async Task GetAsync_ACallerHoldingAnAlreadyReplacedTicketTakesTheReplacementWithoutPosting() + { + var session = TicketSession(Now.AddMinutes(10)); + var handler = new RecordingHandler(request => + IsTicketPost(request) ? (HttpStatusCode.OK, TicketOk) : (HttpStatusCode.OK, DataOk)); + + var shared = new PassThroughHandler(handler); + using (var first = NewClient(session, shared)) + { + // The second client's clock runs between its ticket snapshot and its renewal + // attempt; completing the first client's request there replaces the ticket + // underneath the second client. + var renewedByFirst = false; + using (var second = NewClient(session, shared, clock: () => + { + if (!renewedByFirst) + { + renewedByFirst = true; + first.Get("nodes/pve9a/status"); + } + return Now; + })) + { + await second.GetAsync("nodes/pve9a/qemu/100/status/current"); + } + } + + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + var gets = handler.Requests.Where(r => r.Method == HttpMethod.Get).ToList(); + Assert.Equal(2, gets.Count); + Assert.All(gets, g => Assert.Equal("PVEAuthCookie=" + NewTicket, g.Cookie)); + } + + [Fact] + public async Task GetAsync_ManyParallelRequestsOnOnePastHalfLifeSessionProduceOneTicketPost() + { + const int parallelism = 16; + var session = TicketSession(Now.AddMinutes(10)); + var handler = new RecordingHandler(request => + { + if (IsTicketPost(request)) + { + Thread.Sleep(50); + return (HttpStatusCode.OK, TicketOk); + } + return (HttpStatusCode.OK, DataOk); + }); + + using (var client = NewClient(session, handler)) + using (var start = new ManualResetEventSlim(false)) + { + var workers = Enumerable.Range(0, parallelism) + .Select(i => Task.Factory.StartNew(() => + { + start.Wait(); + return client.GetAsync($"nodes/pve9a/qemu/{100 + i}/status/current"); + }, TaskCreationOptions.LongRunning).Unwrap()) + .ToArray(); + start.Set(); + await Task.WhenAll(workers); + } + + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + var gets = handler.Requests.Where(r => r.Method == HttpMethod.Get).ToList(); + Assert.Equal(parallelism, gets.Count); + Assert.All(gets, g => Assert.Equal("PVEAuthCookie=" + NewTicket, g.Cookie)); + Assert.Equal(NewTicket, session.Ticket); + } + + [Fact] + public async Task UploadFileAsync_RenewsBeforeSendingWhenTheTicketIsPastHalfLife() + { + var session = TicketSession(Now.AddMinutes(30)); + var handler = new RecordingHandler( + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".iso"); + File.WriteAllText(path, "not really an iso"); + try + { + using (var client = NewClient(session, handler)) + { + await client.UploadFileAsync("nodes/pve9a/storage/local/upload", path, + new Dictionary { ["content"] = "iso" }); + } + } + finally + { + File.Delete(path); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + var upload = handler.Requests[1]; + Assert.Equal(HttpMethod.Post, upload.Method); + Assert.Equal("PVEAuthCookie=" + NewTicket, upload.Cookie); + Assert.Equal(NewCsrf, upload.Csrf); + } + + private sealed class Recorded + { + public HttpMethod Method { get; set; } = HttpMethod.Get; + public string Uri { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string? Cookie { get; set; } + public string? Csrf { get; set; } + public string? Authorization { get; set; } + } + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Func> _respond; + private readonly object _sync = new object(); + private readonly List _requests = new List(); + + public IReadOnlyList Requests + { + get { lock (_sync) return _requests.ToList(); } + } + + public RecordingHandler(params (HttpStatusCode status, string body)[] responses) + { + var index = 0; + _respond = (_, __) => + { + var i = Interlocked.Increment(ref index) - 1; + if (i >= responses.Length) + throw new InvalidOperationException("RecordingHandler ran out of responses."); + return Task.FromResult(responses[i]); + }; + } + + public RecordingHandler(Func respond) + { + _respond = (r, _) => Task.FromResult(respond(r)); + } + + public RecordingHandler(Func> respond) + { + _respond = respond; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var recorded = new Recorded + { + Method = request.Method, + Uri = request.RequestUri!.ToString(), + Body = request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync().ConfigureAwait(false), + Cookie = Header(request, "Cookie"), + Csrf = Header(request, "CSRFPreventionToken"), + Authorization = Header(request, "Authorization"), + }; + lock (_sync) _requests.Add(recorded); + + var (status, body) = await _respond(recorded, cancellationToken).ConfigureAwait(false); + return new HttpResponseMessage(status) { Content = new StringContent(body) }; + } + + private static string? Header(HttpRequestMessage request, string name) => + request.Headers.TryGetValues(name, out var values) ? string.Join(",", values) : null; + } + + /// + /// Lets several clients, each of which disposes its own handler, share one recorder. + /// + private sealed class PassThroughHandler : HttpMessageHandler + { + private readonly HttpMessageInvoker _inner; + + public PassThroughHandler(HttpMessageHandler inner) + { + _inner = new HttpMessageInvoker(inner, disposeHandler: false); + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + _inner.SendAsync(request, cancellationToken); + } + } +}