diff --git a/SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs b/SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs new file mode 100644 index 0000000..66258a4 --- /dev/null +++ b/SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs @@ -0,0 +1,56 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +public class BulkMemberServiceTests +{ + [Fact] + public void BulkMemberService_Implements_IBulkMemberService() + { + // GraphClientFactory requires MsalClientFactory which requires real MSAL setup + // Verify the type hierarchy at minimum + Assert.True(typeof(IBulkMemberService).IsAssignableFrom(typeof(BulkMemberService))); + } + + [Fact] + public void BulkMemberRow_DefaultValues() + { + var row = new BulkMemberRow(); + Assert.Equal(string.Empty, row.Email); + Assert.Equal(string.Empty, row.GroupName); + Assert.Equal(string.Empty, row.GroupUrl); + Assert.Equal(string.Empty, row.Role); + } + + [Fact] + public void BulkMemberRow_PropertiesSettable() + { + var row = new BulkMemberRow + { + Email = "user@test.com", + GroupName = "Marketing", + GroupUrl = "https://contoso.sharepoint.com/sites/Marketing", + Role = "Owner" + }; + + Assert.Equal("user@test.com", row.Email); + Assert.Equal("Marketing", row.GroupName); + Assert.Equal("Owner", row.Role); + } + + [Fact(Skip = "Requires live SharePoint tenant and Graph permissions")] + public async Task AddMembersAsync_ValidRows_AddsToGroups() + { + } + + [Fact(Skip = "Requires live SharePoint tenant")] + public async Task AddMembersAsync_InvalidEmail_ReportsPerItemError() + { + } + + [Fact(Skip = "Requires Graph permissions - Group.ReadWrite.All")] + public async Task AddMembersAsync_M365Group_UsesGraphApi() + { + } +} diff --git a/SharepointToolbox.Tests/Services/FileTransferServiceTests.cs b/SharepointToolbox.Tests/Services/FileTransferServiceTests.cs new file mode 100644 index 0000000..9316706 --- /dev/null +++ b/SharepointToolbox.Tests/Services/FileTransferServiceTests.cs @@ -0,0 +1,57 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +public class FileTransferServiceTests +{ + [Fact] + public void FileTransferService_Implements_IFileTransferService() + { + var service = new FileTransferService(); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void TransferJob_DefaultValues_AreCorrect() + { + var job = new TransferJob(); + Assert.Equal(TransferMode.Copy, job.Mode); + Assert.Equal(ConflictPolicy.Skip, job.ConflictPolicy); + } + + [Fact] + public void ConflictPolicy_HasAllValues() + { + Assert.Equal(3, Enum.GetValues().Length); + Assert.Contains(ConflictPolicy.Skip, Enum.GetValues()); + Assert.Contains(ConflictPolicy.Overwrite, Enum.GetValues()); + Assert.Contains(ConflictPolicy.Rename, Enum.GetValues()); + } + + [Fact] + public void TransferMode_HasAllValues() + { + Assert.Equal(2, Enum.GetValues().Length); + Assert.Contains(TransferMode.Copy, Enum.GetValues()); + Assert.Contains(TransferMode.Move, Enum.GetValues()); + } + + [Fact(Skip = "Requires live SharePoint tenant")] + public async Task TransferAsync_CopyMode_CopiesFiles() + { + // Integration test — needs real ClientContext + } + + [Fact(Skip = "Requires live SharePoint tenant")] + public async Task TransferAsync_MoveMode_DeletesSourceAfterCopy() + { + // Integration test — needs real ClientContext + } + + [Fact(Skip = "Requires live SharePoint tenant")] + public async Task TransferAsync_SkipConflict_DoesNotOverwrite() + { + // Integration test — needs real ClientContext + } +} diff --git a/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs b/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs new file mode 100644 index 0000000..d0070ef --- /dev/null +++ b/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs @@ -0,0 +1,71 @@ +using Microsoft.Graph; +using Microsoft.Identity.Client; +using Microsoft.Kiota.Abstractions.Authentication; + +namespace SharepointToolbox.Infrastructure.Auth; + +public class GraphClientFactory +{ + private readonly MsalClientFactory _msalFactory; + + public GraphClientFactory(MsalClientFactory msalFactory) + { + _msalFactory = msalFactory; + } + + /// + /// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA + /// used for SharePoint auth, but with Graph scopes. + /// + public async Task CreateClientAsync(string clientId, CancellationToken ct) + { + var pca = await _msalFactory.GetOrCreateAsync(clientId); + var accounts = await pca.GetAccountsAsync(); + var account = accounts.FirstOrDefault(); + + var graphScopes = new[] { "https://graph.microsoft.com/.default" }; + + var tokenProvider = new MsalTokenProvider(pca, account, graphScopes); + var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider); + return new GraphServiceClient(authProvider); + } +} + +/// +/// Bridges MSAL PCA token acquisition with Graph SDK's IAccessTokenProvider interface. +/// +internal class MsalTokenProvider : IAccessTokenProvider +{ + private readonly IPublicClientApplication _pca; + private readonly IAccount? _account; + private readonly string[] _scopes; + + public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes) + { + _pca = pca; + _account = account; + _scopes = scopes; + } + + public AllowedHostsValidator AllowedHostsValidator { get; } = new(); + + public async Task GetAuthorizationTokenAsync( + Uri uri, + Dictionary? additionalAuthenticationContext = null, + CancellationToken cancellationToken = default) + { + try + { + var result = await _pca.AcquireTokenSilent(_scopes, _account) + .ExecuteAsync(cancellationToken); + return result.AccessToken; + } + catch (MsalUiRequiredException) + { + // If silent fails, try interactive + var result = await _pca.AcquireTokenInteractive(_scopes) + .ExecuteAsync(cancellationToken); + return result.AccessToken; + } + } +} diff --git a/SharepointToolbox/Services/FileTransferService.cs b/SharepointToolbox/Services/FileTransferService.cs new file mode 100644 index 0000000..d3cef2c --- /dev/null +++ b/SharepointToolbox/Services/FileTransferService.cs @@ -0,0 +1,179 @@ +using System.IO; +using Microsoft.SharePoint.Client; +using Serilog; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +public class FileTransferService : IFileTransferService +{ + public async Task> TransferAsync( + ClientContext sourceCtx, + ClientContext destCtx, + TransferJob job, + IProgress progress, + CancellationToken ct) + { + // 1. Enumerate files from source + progress.Report(new OperationProgress(0, 0, "Enumerating source files...")); + var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct); + + if (files.Count == 0) + { + progress.Report(new OperationProgress(0, 0, "No files found to transfer.")); + return new BulkOperationSummary(new List>()); + } + + // 2. Build source and destination base paths + var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath); + var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath); + + // 3. Transfer each file using BulkOperationRunner + return await BulkOperationRunner.RunAsync( + files, + async (fileRelUrl, idx, token) => + { + // Compute destination path by replacing source base with dest base + var relativePart = fileRelUrl; + if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase)) + relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/'); + + // Ensure destination folder exists + var destFolderRelative = dstBasePath; + var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/'); + if (!string.IsNullOrEmpty(fileFolder)) + { + destFolderRelative = $"{dstBasePath}/{fileFolder}"; + await EnsureFolderAsync(destCtx, destFolderRelative, progress, token); + } + + var fileName = Path.GetFileName(relativePart); + var destFileUrl = $"{destFolderRelative}/{fileName}"; + + await TransferSingleFileAsync(sourceCtx, destCtx, fileRelUrl, destFileUrl, job, progress, token); + + Log.Information("Transferred: {Source} -> {Dest}", fileRelUrl, destFileUrl); + }, + progress, + ct); + } + + private async Task TransferSingleFileAsync( + ClientContext sourceCtx, + ClientContext destCtx, + string srcFileUrl, + string dstFileUrl, + TransferJob job, + IProgress progress, + CancellationToken ct) + { + var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl); + var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl); + + bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite; + var options = new MoveCopyOptions + { + KeepBoth = job.ConflictPolicy == ConflictPolicy.Rename, + ResetAuthorAndCreatedOnCopy = false, // best-effort metadata preservation + }; + + try + { + if (job.Mode == TransferMode.Copy) + { + MoveCopyUtil.CopyFileByPath(sourceCtx, srcPath, dstPath, overwrite, options); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct); + } + else // Move + { + MoveCopyUtil.MoveFileByPath(sourceCtx, srcPath, dstPath, overwrite, options); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct); + } + } + catch (ServerException ex) when (job.ConflictPolicy == ConflictPolicy.Skip && + ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) + { + Log.Warning("Skipped (already exists): {File}", srcFileUrl); + } + } + + private async Task> EnumerateFilesAsync( + ClientContext ctx, + TransferJob job, + IProgress progress, + CancellationToken ct) + { + var list = ctx.Web.Lists.GetByTitle(job.SourceLibrary); + var rootFolder = list.RootFolder; + ctx.Load(rootFolder, f => f.ServerRelativeUrl); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/'); + if (!string.IsNullOrEmpty(job.SourceFolderPath)) + baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}"; + + var folder = ctx.Web.GetFolderByServerRelativeUrl(baseFolderUrl); + var files = new List(); + await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct); + return files; + } + + private async Task CollectFilesRecursiveAsync( + ClientContext ctx, + Folder folder, + List files, + IProgress progress, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl), + f => f.Folders); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + foreach (var file in folder.Files) + { + files.Add(file.ServerRelativeUrl); + } + + foreach (var subFolder in folder.Folders) + { + // Skip system folders + if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms") + continue; + await CollectFilesRecursiveAsync(ctx, subFolder, files, progress, ct); + } + } + + private async Task EnsureFolderAsync( + ClientContext ctx, + string folderServerRelativeUrl, + IProgress progress, + CancellationToken ct) + { + try + { + var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl); + ctx.Load(folder, f => f.Exists); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + if (folder.Exists) return; + } + catch { /* folder doesn't exist, create it */ } + + // Create folder using Folders.Add which creates intermediate folders + ctx.Web.Folders.Add(folderServerRelativeUrl); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + } + + private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath) + { + // Extract site-relative URL from context URL + var uri = new Uri(ctx.Url); + var siteRelative = uri.AbsolutePath.TrimEnd('/'); + var basePath = $"{siteRelative}/{library}"; + if (!string.IsNullOrEmpty(folderPath)) + basePath = $"{basePath}/{folderPath.TrimStart('/')}"; + return basePath; + } +}