diff --git a/SharepointToolbox.Tests/Auth/SessionManagerTests.cs b/SharepointToolbox.Tests/Auth/SessionManagerTests.cs index 77e2f9c..2a57721 100644 --- a/SharepointToolbox.Tests/Auth/SessionManagerTests.cs +++ b/SharepointToolbox.Tests/Auth/SessionManagerTests.cs @@ -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( + () => _sessionManager.GetOrCreateContextAsync(profile)); + } + + [Fact] + public async Task GetOrCreateContextAsync_EmptyTenantUrl_ThrowsArgumentException() + { + var profile = new TenantProfile { TenantUrl = "", ClientId = "clientId", Name = "Test" }; + await Assert.ThrowsAnyAsync( + () => _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( + () => _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( + () => _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; + } } diff --git a/SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs b/SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs index a27335b..e69b0b0 100644 --- a/SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs +++ b/SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs @@ -15,6 +15,7 @@ namespace SharepointToolbox.Infrastructure.Auth; public class MsalClientFactory { private readonly Dictionary _clients = new(); + private readonly Dictionary _helpers = new(); private readonly SemaphoreSlim _lock = new(1, 1); /// Cache directory for MSAL token files. @@ -62,8 +63,22 @@ public class MsalClientFactory helper.RegisterCache(pca.UserTokenCache); _clients[clientId] = pca; + _helpers[clientId] = helper; return pca; } finally { _lock.Release(); } } + + /// + /// 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. + /// + 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; + } } diff --git a/SharepointToolbox/Services/SessionManager.cs b/SharepointToolbox/Services/SessionManager.cs new file mode 100644 index 0000000..5a1faa6 --- /dev/null +++ b/SharepointToolbox/Services/SessionManager.cs @@ -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; + +/// +/// 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. +/// +public class SessionManager +{ + private readonly MsalClientFactory _msalFactory; + private readonly Dictionary _contexts = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public SessionManager(MsalClientFactory msalFactory) + { + _msalFactory = msalFactory; + } + + /// Returns true if an authenticated ClientContext exists for this tenantUrl. + public bool IsAuthenticated(string tenantUrl) => + _contexts.ContainsKey(NormalizeUrl(tenantUrl)); + + /// + /// 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. + /// + public async Task 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(); } + } + + /// + /// 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. + /// + 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(); +}