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;
|
namespace SharepointToolbox.Tests.Auth;
|
||||||
|
|
||||||
public class SessionManagerTests
|
[Trait("Category", "Unit")]
|
||||||
|
public class SessionManagerTests : IDisposable
|
||||||
{
|
{
|
||||||
[Fact(Skip = "Wave 0 stub — implemented in plan 01-04")]
|
private readonly string _tempCacheDir;
|
||||||
public void GetContext_Returns_ClientContext_For_ConnectedTenant() { }
|
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
|
public class MsalClientFactory
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, IPublicClientApplication> _clients = new();
|
private readonly Dictionary<string, IPublicClientApplication> _clients = new();
|
||||||
|
private readonly Dictionary<string, MsalCacheHelper> _helpers = new();
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
/// <summary>Cache directory for MSAL token files.</summary>
|
/// <summary>Cache directory for MSAL token files.</summary>
|
||||||
@@ -62,8 +63,22 @@ public class MsalClientFactory
|
|||||||
helper.RegisterCache(pca.UserTokenCache);
|
helper.RegisterCache(pca.UserTokenCache);
|
||||||
|
|
||||||
_clients[clientId] = pca;
|
_clients[clientId] = pca;
|
||||||
|
_helpers[clientId] = helper;
|
||||||
return pca;
|
return pca;
|
||||||
}
|
}
|
||||||
finally { _lock.Release(); }
|
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