feat(01-04): SessionManager singleton holding all ClientContext instances

- SessionManager owns all ClientContexts; callers must not store references
- IsAuthenticated(tenantUrl) returns false before auth, true after GetOrCreateContextAsync
- ClearSessionAsync disposes ClientContext and removes state (idempotent for unknown tenants)
- GetOrCreateContextAsync validates null/empty TenantUrl and ClientId (ArgumentException)
- MsalClientFactory.GetCacheHelper() added — exposes helper for PnP tokenCacheCallback wiring
- 8 unit tests pass, 1 interactive-login test skipped (integration-only)
This commit is contained in:
Dev
2026-04-02 12:25:01 +02:00
parent 02955199f6
commit 158aab96b2
3 changed files with 217 additions and 3 deletions

View File

@@ -1,7 +1,103 @@
using System;
using System.IO;
using System.Threading.Tasks;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Auth;
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Auth;
public class SessionManagerTests
[Trait("Category", "Unit")]
public class SessionManagerTests : IDisposable
{
[Fact(Skip = "Wave 0 stub — implemented in plan 01-04")]
public void GetContext_Returns_ClientContext_For_ConnectedTenant() { }
private readonly string _tempCacheDir;
private readonly MsalClientFactory _factory;
private readonly SessionManager _sessionManager;
public SessionManagerTests()
{
_tempCacheDir = Path.Combine(Path.GetTempPath(), "SessionManagerTests_" + Guid.NewGuid());
Directory.CreateDirectory(_tempCacheDir);
_factory = new MsalClientFactory(_tempCacheDir);
_sessionManager = new SessionManager(_factory);
}
public void Dispose()
{
try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ }
}
// ── IsAuthenticated ──────────────────────────────────────────────────────
[Fact]
public void IsAuthenticated_BeforeAnyAuth_ReturnsFalse()
{
Assert.False(_sessionManager.IsAuthenticated("https://contoso.sharepoint.com"));
}
[Fact]
public void IsAuthenticated_NormalizesTrailingSlash()
{
Assert.False(_sessionManager.IsAuthenticated("https://contoso.sharepoint.com/"));
Assert.False(_sessionManager.IsAuthenticated("https://contoso.sharepoint.com"));
}
// ── ClearSessionAsync ────────────────────────────────────────────────────
[Fact]
public async Task ClearSessionAsync_UnknownTenantUrl_DoesNotThrow()
{
// Must be idempotent — no exception for tenants that were never authenticated
await _sessionManager.ClearSessionAsync("https://unknown.sharepoint.com");
}
[Fact]
public async Task ClearSessionAsync_MultipleCalls_DoNotThrow()
{
await _sessionManager.ClearSessionAsync("https://contoso.sharepoint.com");
await _sessionManager.ClearSessionAsync("https://contoso.sharepoint.com");
}
// ── Argument validation ──────────────────────────────────────────────────
[Fact]
public async Task GetOrCreateContextAsync_NullTenantUrl_ThrowsArgumentException()
{
var profile = new TenantProfile { TenantUrl = null!, ClientId = "clientId", Name = "Test" };
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _sessionManager.GetOrCreateContextAsync(profile));
}
[Fact]
public async Task GetOrCreateContextAsync_EmptyTenantUrl_ThrowsArgumentException()
{
var profile = new TenantProfile { TenantUrl = "", ClientId = "clientId", Name = "Test" };
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _sessionManager.GetOrCreateContextAsync(profile));
}
[Fact]
public async Task GetOrCreateContextAsync_NullClientId_ThrowsArgumentException()
{
var profile = new TenantProfile { TenantUrl = "https://contoso.sharepoint.com", ClientId = null!, Name = "Test" };
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _sessionManager.GetOrCreateContextAsync(profile));
}
[Fact]
public async Task GetOrCreateContextAsync_EmptyClientId_ThrowsArgumentException()
{
var profile = new TenantProfile { TenantUrl = "https://contoso.sharepoint.com", ClientId = "", Name = "Test" };
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _sessionManager.GetOrCreateContextAsync(profile));
}
// ── Interactive login test (skipped — requires MSAL interactive flow) ────
[Fact(Skip = "Requires interactive MSAL — integration test only")]
public Task GetOrCreateContextAsync_CreatesContext()
{
return Task.CompletedTask;
}
}

View File

@@ -15,6 +15,7 @@ namespace SharepointToolbox.Infrastructure.Auth;
public class MsalClientFactory
{
private readonly Dictionary<string, IPublicClientApplication> _clients = new();
private readonly Dictionary<string, MsalCacheHelper> _helpers = new();
private readonly SemaphoreSlim _lock = new(1, 1);
/// <summary>Cache directory for MSAL token files.</summary>
@@ -62,8 +63,22 @@ public class MsalClientFactory
helper.RegisterCache(pca.UserTokenCache);
_clients[clientId] = pca;
_helpers[clientId] = helper;
return pca;
}
finally { _lock.Release(); }
}
/// <summary>
/// Returns the MsalCacheHelper for the given clientId.
/// GetOrCreateAsync must be called first — throws InvalidOperationException otherwise.
/// Used by SessionManager to wire PnP's internal token cache to the same persistent cache file.
/// </summary>
public MsalCacheHelper GetCacheHelper(string clientId)
{
if (!_helpers.TryGetValue(clientId, out var helper))
throw new InvalidOperationException(
$"No cache helper found for clientId '{clientId}'. Call GetOrCreateAsync first.");
return helper;
}
}

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.SharePoint.Client;
using PnP.Framework;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Auth;
namespace SharepointToolbox.Services;
/// <summary>
/// Singleton that owns all live ClientContext instances.
/// Every SharePoint operation goes through this class — callers MUST NOT store
/// ClientContext references; request it fresh each time via GetOrCreateContextAsync.
/// </summary>
public class SessionManager
{
private readonly MsalClientFactory _msalFactory;
private readonly Dictionary<string, ClientContext> _contexts = new();
private readonly SemaphoreSlim _lock = new(1, 1);
public SessionManager(MsalClientFactory msalFactory)
{
_msalFactory = msalFactory;
}
/// <summary>Returns true if an authenticated ClientContext exists for this tenantUrl.</summary>
public bool IsAuthenticated(string tenantUrl) =>
_contexts.ContainsKey(NormalizeUrl(tenantUrl));
/// <summary>
/// Returns an existing ClientContext or creates a new one via interactive MSAL login.
/// Uses MsalClientFactory so the token cache is registered per-clientId before PnP acquires tokens.
/// Only SessionManager holds ClientContext instances — never return to callers for storage.
/// </summary>
public async Task<ClientContext> GetOrCreateContextAsync(
TenantProfile profile,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
ArgumentException.ThrowIfNullOrEmpty(profile.ClientId);
var key = NormalizeUrl(profile.TenantUrl);
await _lock.WaitAsync(ct);
try
{
if (_contexts.TryGetValue(key, out var existing))
return existing;
// Ensure the per-clientId PCA + MsalCacheHelper is created BEFORE PnP acquires tokens.
// MsalClientFactory.GetOrCreateAsync creates the PCA and registers MsalCacheHelper on it.
// We then wire the same helper to PnP's internal token cache via tokenCacheCallback,
// so both PCA and PnP share the same persistent cache file per clientId.
await _msalFactory.GetOrCreateAsync(profile.ClientId);
var cacheHelper = _msalFactory.GetCacheHelper(profile.ClientId);
var authManager = AuthenticationManager.CreateWithInteractiveLogin(
clientId: profile.ClientId,
openBrowserCallback: (url, port) =>
{
// The browser/WAM flow is handled by MSAL; this callback receives the
// local redirect URL and port. No action needed here — MSAL opens the browser.
},
tokenCacheCallback: tokenCache =>
{
// Wire the same MsalCacheHelper to PnP's internal token cache so both
// PnP and our PCA (from MsalClientFactory) share the same persistent
// cache file at %AppData%\SharepointToolbox\auth\msal_{clientId}.cache
cacheHelper.RegisterCache(tokenCache);
});
var ctx = await authManager.GetContextAsync(profile.TenantUrl, ct);
_contexts[key] = ctx;
return ctx;
}
finally { _lock.Release(); }
}
/// <summary>
/// Disposes the ClientContext and clears MSAL state for the given tenant.
/// Idempotent — safe to call for tenants that were never authenticated.
/// Called by "Clear Session" button and on tenant profile deletion.
/// </summary>
public async Task ClearSessionAsync(string tenantUrl)
{
var key = NormalizeUrl(tenantUrl);
await _lock.WaitAsync();
try
{
if (_contexts.TryGetValue(key, out var ctx))
{
ctx.Dispose();
_contexts.Remove(key);
}
}
finally { _lock.Release(); }
}
private static string NormalizeUrl(string url) =>
url.TrimEnd('/').ToLowerInvariant();
}