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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
103
SharepointToolbox/Services/SessionManager.cs
Normal file
103
SharepointToolbox/Services/SessionManager.cs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user