mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-07 20:45:40 +00:00
fix: renew ticket sessions inside PveHttpClient before expiry and once on a 401 (#230)
* fix: renew ticket sessions inside PveHttpClient before expiry and once on a 401 A ticket session died two hours after Connect-PveServer, and any -Wait that crossed that line surfaced as a raw 401 from the status poll. PveHttpClient now renews a ticket past half its lifetime by posting it as the password to /access/ticket, and after a 401 on a ticket-mode request renews once and retries once; a failed renewal is PveSessionExpiredException with the 401 inner. Renewals are single-flighted per session through an in-flight task so concurrent callers share one POST and its outcome, the credential is one immutable snapshot so no request mixes two tickets, and the renewal POST is bounded by the session timeout rather than the calling client's. API-token sessions never renew. Implements ADR 0027. * fix: keep the cluster-join password re-auth fallback across the client's ticket renewal A cluster join rotates the auth key, so the 401 the status poll gets back now reaches Add-PveClusterMember as PveSessionExpiredException after the client's own renewal fails. Widen the fallback's catch so the password re-authentication, the only path that survives a key rotation, still runs. --------- Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0026ef2b57
commit
8d955a0ea8
@@ -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<ArgumentNullException>(() =>
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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<DateTime>? 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<string, string> { ["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<PveSessionExpiredException>(() => 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<PveSessionExpiredException>(
|
||||
() => client.GetAsync("nodes/pve9a/status"));
|
||||
|
||||
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
|
||||
() => client.GetAsync("nodes/pve9a/status"));
|
||||
|
||||
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
|
||||
() => client.GetAsync("nodes/pve9a/status"));
|
||||
|
||||
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
|
||||
() => client.GetAsync("nodes/pve9a/status"));
|
||||
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
|
||||
() => client.GetAsync("nodes/pve9a/status"));
|
||||
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
|
||||
() => 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<PveApiException>(
|
||||
() => 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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseTicketPost = new TaskCompletionSource<bool>(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<bool>(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<string, string> { ["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<Recorded, CancellationToken, Task<(HttpStatusCode status, string body)>> _respond;
|
||||
private readonly object _sync = new object();
|
||||
private readonly List<Recorded> _requests = new List<Recorded>();
|
||||
|
||||
public IReadOnlyList<Recorded> 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<Recorded, (HttpStatusCode status, string body)> respond)
|
||||
{
|
||||
_respond = (r, _) => Task.FromResult(respond(r));
|
||||
}
|
||||
|
||||
public RecordingHandler(Func<Recorded, CancellationToken, Task<(HttpStatusCode status, string body)>> respond)
|
||||
{
|
||||
_respond = respond;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets several clients, each of which disposes its own handler, share one recorder.
|
||||
/// </summary>
|
||||
private sealed class PassThroughHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpMessageInvoker _inner;
|
||||
|
||||
public PassThroughHandler(HttpMessageHandler inner)
|
||||
{
|
||||
_inner = new HttpMessageInvoker(inner, disposeHandler: false);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
_inner.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user