feat(02-03): implement ISiteListService and SiteListService with admin URL derivation

- SiteInfo record added to Core/Models
- ISiteListService interface with GetSitesAsync signature
- SiteListService derives admin URL via Regex, connects via SessionManager
- Filters to Active sites only, excludes OneDrive personal (-my.sharepoint.com)
- Access denied ServerException wrapped as InvalidOperationException with actionable message
- DeriveAdminUrl marked internal static for unit testability
- InternalsVisibleTo added to AssemblyInfo.cs to expose internal to test project
- 2 DeriveAdminUrl tests pass; full suite: 53 pass, 4 skip, 0 fail
This commit is contained in:
Dev
2026-04-02 13:50:35 +02:00
parent 5c10840581
commit 78b3d4f759
4 changed files with 96 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface ISiteListService
{
Task<IReadOnlyList<SiteInfo>> GetSitesAsync(
TenantProfile profile,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Online.SharePoint.TenantAdministration;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public class SiteListService : ISiteListService
{
private readonly SessionManager _sessionManager;
public SiteListService(SessionManager sessionManager)
{
_sessionManager = sessionManager;
}
public async Task<IReadOnlyList<SiteInfo>> GetSitesAsync(
TenantProfile profile,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
progress.Report(OperationProgress.Indeterminate("Loading sites..."));
var adminUrl = DeriveAdminUrl(profile.TenantUrl);
var adminProfile = new TenantProfile
{
Name = profile.Name,
TenantUrl = adminUrl,
ClientId = profile.ClientId
};
ClientContext adminCtx;
try
{
adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
}
catch (ServerException ex) when (ex.Message.Contains("Access denied", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Site listing requires SharePoint administrator permissions. Connect with an admin account.", ex);
}
var tenant = new Tenant(adminCtx);
var siteProps = tenant.GetSitePropertiesFromSharePoint("", true);
adminCtx.Load(siteProps);
await adminCtx.ExecuteQueryAsync();
ct.ThrowIfCancellationRequested();
return siteProps
.Where(s => s.Status == "Active"
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase))
.Select(s => new SiteInfo(s.Url, s.Title))
.OrderBy(s => s.Url)
.ToList();
}
internal static string DeriveAdminUrl(string tenantUrl)
=> Regex.Replace(tenantUrl.TrimEnd('/'),
@"(https://[^.]+)(\.sharepoint\.com)",
"$1-admin$2",
RegexOptions.IgnoreCase);
}