Files
PSProxmoxVE/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs
T
goodolclint-claude[bot] 8d955a0ea8 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>
2026-09-03 20:50:07 +00:00

197 lines
7.5 KiB
C#

using System;
using Xunit;
using PSProxmoxVE.Core.Authentication;
namespace PSProxmoxVE.Core.Tests.Authentication
{
public class PveSessionTests
{
private const string TestHostname = "pve.example.com";
private const int TestPort = 8006;
[Fact]
public void TicketSession_NotExpired_OnCreation()
{
var expiry = DateTime.UtcNow.AddHours(2);
var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:TICKET", "CSRFTOKEN", expiry);
Assert.False(session.IsExpired);
}
[Fact]
public void TicketSession_IsExpired_After2Hours()
{
// Simulate a ticket that already expired an hour ago
var expiry = DateTime.UtcNow.AddHours(-1);
var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLDTICKET", "CSRFTOKEN", expiry);
Assert.True(session.IsExpired);
}
[Fact]
public void ApiTokenSession_NeverExpires()
{
var session = new PveSession(TestHostname, TestPort, false, "root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.False(session.IsExpired);
Assert.Equal(PveAuthMode.ApiToken, session.AuthMode);
}
[Fact]
public void BaseUrl_CorrectFormat()
{
var session = new PveSession(TestHostname, TestPort, false, "root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.Equal($"https://{TestHostname}:{TestPort}/api2/json/", session.BaseUrl);
}
[Fact]
public void TicketSession_AuthMode_IsTicket()
{
var expiry = DateTime.UtcNow.AddHours(2);
var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:TICKET", "CSRFTOKEN", expiry);
Assert.Equal(PveAuthMode.Ticket, session.AuthMode);
}
[Fact]
public void SeparateSessions_DoNotShareState()
{
var expiry1 = DateTime.UtcNow.AddHours(2);
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);
Assert.Equal(PveAuthMode.Ticket, session1.AuthMode);
Assert.Equal(PveAuthMode.ApiToken, session2.AuthMode);
Assert.Equal("PVE:root@pam:TICKET1", session1.Ticket);
Assert.Null(session1.ApiToken);
Assert.Null(session2.Ticket);
Assert.NotNull(session2.ApiToken);
}
[Fact]
public void TicketSession_StoresTicketAndCsrf()
{
var expiry = DateTime.UtcNow.AddHours(2);
const string ticket = "PVE:root@pam:ABCD1234";
const string csrf = "CSRFPREVENTION";
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()
{
const string token = "root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
var session = new PveSession(TestHostname, TestPort, false, token);
Assert.Equal(token, session.ApiToken);
}
[Fact]
public void SkipCertificateCheck_IsStored()
{
var session = new PveSession(TestHostname, TestPort, skipCertificateCheck: true,
"root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.True(session.SkipCertificateCheck);
}
[Fact]
public void Timeout_DefaultIs100Seconds()
{
var session = new PveSession(TestHostname, TestPort, false,
"root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.Equal(TimeSpan.FromSeconds(100), session.Timeout);
}
}
}