Compare commits

..

1 Commits

Author SHA1 Message Date
kawa 4590704612 Delete TODO.md 2026-04-08 10:24:48 +02:00
243 changed files with 6783 additions and 25758 deletions
+46
View File
@@ -0,0 +1,46 @@
#Wkf
name: Release zip package
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: native
steps:
- name: Checkout
run: |
git clone "$GITEA_SERVER_URL/$GITEA_REPO.git" repo
cd repo && git checkout "$GITEA_REF_NAME"
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPO: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
- name: Build zip
run: |
cd repo
VERSION="${{ gitea.ref_name }}"
ZIP="SharePoint_ToolBox_${VERSION}.zip"
zip -r "../${ZIP}" Sharepoint_ToolBox.ps1 lang/ examples/
echo "ZIP=${ZIP}" >> "$GITHUB_ENV"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Create release
run: |
RELEASE_ID=$(curl -sf -X POST \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${{ env.VERSION }}\",\"name\":\"SharePoint ToolBox ${{ env.VERSION }}\",\"body\":\"1. Download and extract the archive\\n2. Launch Sharepoint_ToolBox.ps1 with PowerShell\\n\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_ENV"
- name: Upload asset
run: |
curl -sf -X POST \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${{ env.RELEASE_ID }}/assets" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-F "attachment=@${{ env.ZIP }}"
+8 -24
View File
@@ -1,24 +1,8 @@
# Build outputs .claude
bin/ *.html
obj/ *.json
publish/ !lang/
!lang/*.json
# IDE !wiki/
.vs/ !wiki/*.html
*.user !wiki/*.md
*.suo
release.ps1
Sharepoint_ToolBox.ps1
# Claude Code
.claude/
.planning/
# OS
Thumbs.db
Desktop.ini
# Secrets
*.pfx
appsettings.*.json
Sharepoint_Settings.json
+113 -29
View File
@@ -1,40 +1,124 @@
![SPToolbox-logo](https://git.azuze.fr/kawa/Sharepoint-Toolbox/raw/branch/main/SPToolbox-logo.png) ![SPToolbox-logo](https://git.azuze.fr/kawa/Sharepoint-Toolbox/raw/branch/main/SPToolbox-logo.png)
# SharePoint Toolbox Application PowerShell avec interface graphique (WinForms) pour administrer, auditer et exporter des données depuis des sites SharePoint Online.
Application pour administrer, auditer et exporter des donnees depuis des sites SharePoint Online. ## Prérequis
## Prerequis - **PowerShell 5.1** ou supérieur
- **Module PnP.PowerShell** (`Install-Module PnP.PowerShell`)
- **[Azure AD App Registration](https://git.azuze.fr/kawa/ps-scripts/src/commit/4ccc3de2b83295597a9212132d2f3d49afd9492b/Misc/Reg-App.ps1)** avec les permissions déléguées nécessaires (Client ID requis)
- Accès au tenant SharePoint cible
- Windows 10 ou superieur ## Lancement
- Runtime .NET 10 Desktop
- Acces au tenant SharePoint cible
## Fonctionnalites principales ```powershell
.\Sharepoint_Toolbox.ps1
```
- **Connexion & profils** — profils de connexion reutilisables, selecteur multi-sites, enregistrement Azure AD assiste, branding multi-tenant ---
- **Rapport de permissions** — audit bibliotheques/listes/dossiers, permissions heritees, mode consolidation, export CSV/HTML
- **Metriques de stockage** — utilisation par bibliotheque, taille des versions, nombre d'elements, visualisation 3D interactive, export CSV/HTML
- **Annuaire utilisateurs** — liste des utilisateurs du tenant via Microsoft Graph, filtrage/recherche, export HTML
- **Recherche de fichiers** — recherche KQL (extension, regex, plages de dates, auteur, editeur, bibliotheque)
- **Detection de doublons** — fichiers (Search API) ou dossiers (CAML), criteres combinables (nom, taille, dates, nombres), export CSV/HTML
- **Localisation** — interface complete EN/FR
## Dependances (NuGet) ## Fonctionnalités
| Paquet | Version | Role | ### Connexion et profils
|---|---|---|
| CommunityToolkit.Mvvm | 8.4.2 | Generateurs MVVM |
| CsvHelper | 33.1.0 | Lecture/ecriture CSV |
| LiveChartsCore.SkiaSharpView.WPF | 2.0.0-rc5.4 | Graphiques / vue 3D stockage |
| Microsoft.Extensions.Hosting | 10.0.0 | Host generique + DI |
| Microsoft.Graph | 5.74.0 | SDK Graph (tenant/utilisateurs) |
| Microsoft.Identity.Client | 4.83.3 | Authentification MSAL |
| Microsoft.Identity.Client.Broker | 4.82.1 | Support broker WAM |
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 | Cache de tokens persistant |
| PnP.Framework | 1.18.0 | Operations SharePoint CSOM |
| Serilog (+ Hosting, Sinks.File) | 4.3.1 / 10.0.0 / 7.0.0 | Journalisation |
## Architecture - Saisie du **Tenant URL**, **Client ID** et **Site URL**
- **Profils sauvegardés** : créez, renommez, supprimez et chargez des profils de connexion réutilisables
- **Sélecteur de sites** : parcourez et cochez plusieurs sites du tenant en une seule vue (chargement asynchrone)
- Dossier de sortie configurable pour tous les exports
MVVM (CommunityToolkit) · DI via Microsoft.Extensions.Hosting · Authentification MSAL avec cache persistant et broker WAM · Microsoft Graph SDK · PnP.Framework (CSOM) · Localisation .resx (EN/FR) · Branding configurable dans les exports HTML. ---
### Permissions Report
Audit complet des permissions d'un ou plusieurs sites.
- Scan des **bibliothèques, listes et dossiers** (profondeur configurable ou illimitée)
- Option **Recursive** pour inclure les sous-sites
- Inclusion optionnelle des permissions héritées
- Export **CSV** (données brutes, compatibles Excel) ou **HTML** (rapport visuel avec tableau interactif, filtrage, tri par colonne, regroupement par utilisateur/groupe)
---
### Storage Metrics
Analyse de l'occupation du stockage SharePoint.
- Répartition **par bibliothèque** avec profondeur de dossiers configurable
- Option d'inclusion des **sous-sites**
- Métriques : taille totale, taille des versions, nombre d'éléments, dernière modification
- Export **CSV** ou **HTML** (rapport avec graphiques de répartition et arborescence dépliable)
---
### Templates
Capture et réapplication de la structure d'un site SharePoint.
- **Capture** : arborescence (bibliothèques et dossiers), permissions (groupes et rôles), paramètres du site (titre, langue), logo
- **Création** depuis un template : nouveau site Communication ou Teams à partir d'un template capturé, avec application sélective des éléments capturés
- Templates persistés localement dans `Sharepoint_Templates.json`
---
### Recherche de fichiers
Recherche avancée de fichiers à travers les bibliothèques d'un site.
| Filtre | Description |
|---|---|
| Extension(s) | Ex : `docx pdf xlsx` |
| Nom / Regex | Expression régulière appliquée sur le chemin du fichier |
| Créé après / avant | Plage de dates de création |
| Modifié après / avant | Plage de dates de modification |
| Créé par | Nom ou email de l'auteur |
| Modifié par | Nom ou email du dernier éditeur |
| Bibliothèque | Limite la recherche à un chemin relatif |
| Max résultats | Plafond configurable (10 50 000) |
Utilise la **Search API SharePoint (KQL)** avec pagination automatique. Le filtre regex est appliqué côté client après récupération des résultats.
Export **CSV** ou **HTML** (tableau trié par colonne, filtrage en temps réel, indicateurs de tri).
---
### Doublons
Détection de fichiers ou dossiers en double au sein d'un site.
**Type de scan :**
- Fichiers en double (via Search API)
- Dossiers en double (via énumération des bibliothèques)
**Critères de comparaison (combinables) :**
- Nom — *toujours inclus comme critère principal*
- Taille identique
- Date de création identique
- Date de modification identique
- Nombre de sous-dossiers identique *(dossiers uniquement)*
- Nombre de fichiers identique *(dossiers uniquement)*
Le rapport HTML présente les doublons regroupés en **cartes dépliables** avec mise en évidence visuelle des valeurs identiques (vert) et différentes (orange), ainsi qu'un badge "Identiques" / "Différences détectées" par groupe.
Export **CSV** (avec colonne `DuplicateGroup`) ou **HTML**.
---
## Fichiers générés
| Fichier | Description |
|---|---|
| `Sharepoint_Export_profiles.json` | Profils de connexion sauvegardés |
| `Sharepoint_Templates.json` | Templates de sites capturés |
| `Permissions_<site>_<date>.csv/html` | Rapports de permissions |
| `Storage_<site>_<date>.csv/html` | Rapports de stockage |
| `FileSearch_<date>.csv/html` | Résultats de recherche de fichiers |
| `Duplicates_<mode>_<date>.csv/html` | Résultats du scan de doublons |
---
## Architecture technique
- Interface **WinForms** (PowerShell natif, aucune dépendance UI externe)
- Toutes les opérations longues s'exécutent dans des **runspaces séparés** pour ne pas bloquer l'interface
- Communication runspace → UI via **hashtable synchronisée** + timer
- Module **PnP.PowerShell** pour toutes les interactions avec l'API SharePoint
@@ -1,75 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using SharepointToolbox.Infrastructure.Auth;
using Xunit;
namespace SharepointToolbox.Tests.Auth;
[Trait("Category", "Unit")]
public class MsalClientFactoryTests : IDisposable
{
private readonly string _tempCacheDir;
public MsalClientFactoryTests()
{
_tempCacheDir = Path.Combine(Path.GetTempPath(), "MsalClientFactoryTests_" + Guid.NewGuid());
Directory.CreateDirectory(_tempCacheDir);
}
public void Dispose()
{
try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ }
}
[Fact]
public async Task GetOrCreateAsync_SameClientId_ReturnsSameInstance()
{
var factory = new MsalClientFactory(_tempCacheDir);
var pca1 = await factory.GetOrCreateAsync("clientA");
var pca2 = await factory.GetOrCreateAsync("clientA");
Assert.Same(pca1, pca2);
}
[Fact]
public async Task GetOrCreateAsync_DifferentClientIds_ReturnDifferentInstances()
{
var factory = new MsalClientFactory(_tempCacheDir);
var pcaA = await factory.GetOrCreateAsync("clientA");
var pcaB = await factory.GetOrCreateAsync("clientB");
Assert.NotSame(pcaA, pcaB);
}
[Fact]
public async Task GetOrCreateAsync_ConcurrentCalls_DoNotCreateDuplicateInstances()
{
var factory = new MsalClientFactory(_tempCacheDir);
// Run 10 concurrent calls with the same clientId
var tasks = Enumerable.Range(0, 10)
.Select(_ => factory.GetOrCreateAsync("clientConcurrent"))
.ToArray();
var results = await Task.WhenAll(tasks);
// All 10 results must be the exact same instance
var first = results[0];
Assert.All(results, r => Assert.Same(first, r));
}
[Fact]
public void CacheDirectory_ResolvesToAppData_Not_Hardcoded()
{
// The default (no-arg) constructor must use %AppData%\SharepointToolbox\auth
var factory = new MsalClientFactory();
var expectedBase = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var expectedDir = Path.Combine(expectedBase, "SharepointToolbox", "auth");
Assert.Equal(expectedDir, factory.CacheDirectory);
}
}
@@ -1,103 +0,0 @@
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;
[Trait("Category", "Unit")]
public class SessionManagerTests : IDisposable
{
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;
}
}
@@ -1,53 +0,0 @@
using System.Globalization;
using SharepointToolbox.Views.Converters;
namespace SharepointToolbox.Tests.Converters;
[Trait("Category", "Unit")]
public class Base64ToImageSourceConverterTests
{
private readonly Base64ToImageSourceConverter _converter = new();
[Fact]
public void Convert_NullValue_ReturnsNull()
{
var result = _converter.Convert(null, typeof(object), null, CultureInfo.InvariantCulture);
Assert.Null(result);
}
[Fact]
public void Convert_EmptyString_ReturnsNull()
{
var result = _converter.Convert(string.Empty, typeof(object), null, CultureInfo.InvariantCulture);
Assert.Null(result);
}
[Fact]
public void Convert_NonStringValue_ReturnsNull()
{
var result = _converter.Convert(42, typeof(object), null, CultureInfo.InvariantCulture);
Assert.Null(result);
}
[Fact]
public void Convert_MalformedString_NoBase64Marker_ReturnsNull()
{
var result = _converter.Convert("not-a-data-uri", typeof(object), null, CultureInfo.InvariantCulture);
Assert.Null(result);
}
[Fact]
public void Convert_InvalidBase64AfterMarker_ReturnsNull()
{
// Has the marker but invalid base64 content — should not throw
var result = _converter.Convert("data:image/png;base64,!!!invalid!!!", typeof(object), null, CultureInfo.InvariantCulture);
Assert.Null(result);
}
[Fact]
public void ConvertBack_ThrowsNotImplementedException()
{
Assert.Throws<NotImplementedException>(() =>
_converter.ConvertBack(null, typeof(object), null, CultureInfo.InvariantCulture));
}
}
@@ -1,33 +0,0 @@
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Tests.Helpers;
[Trait("Category", "Unit")]
public class ExecuteQueryRetryHelperTests
{
[Theory]
[InlineData("The request has been throttled -- 429")]
[InlineData("Service unavailable 503")]
[InlineData("SharePoint has throttled your request")]
public void IsThrottleException_ThrottleMessages_ReturnsTrue(string message)
{
var ex = new Exception(message);
Assert.True(ExecuteQueryRetryHelper.IsThrottleException(ex));
}
[Fact]
public void IsThrottleException_NonThrottleMessage_ReturnsFalse()
{
var ex = new Exception("File not found");
Assert.False(ExecuteQueryRetryHelper.IsThrottleException(ex));
}
[Fact]
public void IsThrottleException_NestedThrottleInInnerException_ReturnsFalse()
{
// Documents current behavior: only top-level Message is checked.
// Inner exceptions with "429" are NOT currently detected.
var ex = new Exception("outer", new Exception("429"));
Assert.False(ExecuteQueryRetryHelper.IsThrottleException(ex));
}
}
@@ -1,256 +0,0 @@
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Tests.Helpers;
/// <summary>
/// Unit tests for PermissionConsolidator static helper.
/// RPT-04: Validates consolidation logic for empty input, single entry, merging,
/// case-insensitivity, MakeKey format, the 10-row/7-row scenario, LocationCount,
/// and preservation of IsHighPrivilege / IsExternalUser flags.
/// </summary>
public class PermissionConsolidatorTests
{
// ---------------------------------------------------------------------------
// Helper factory — reduces boilerplate across all test methods
// ---------------------------------------------------------------------------
private static UserAccessEntry MakeEntry(
string userLogin = "alice@contoso.com",
string siteUrl = "https://contoso.sharepoint.com/sites/hr",
string siteTitle = "HR Site",
string objectType = "List",
string objectTitle = "Documents",
string objectUrl = "https://contoso.sharepoint.com/sites/hr/Documents",
string permissionLevel = "Contribute",
AccessType accessType = AccessType.Direct,
string grantedThrough = "Direct Permissions",
string userDisplayName = "Alice Smith",
bool isHighPrivilege = false,
bool isExternalUser = false)
{
return new UserAccessEntry(
userDisplayName, userLogin, siteUrl, siteTitle,
objectType, objectTitle, objectUrl,
permissionLevel, accessType, grantedThrough,
isHighPrivilege, isExternalUser);
}
// ---------------------------------------------------------------------------
// RPT-04-a: Empty input returns empty list
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_EmptyInput_ReturnsEmptyList()
{
var result = PermissionConsolidator.Consolidate(Array.Empty<UserAccessEntry>());
Assert.Empty(result);
}
// ---------------------------------------------------------------------------
// RPT-04-b: Single entry produces 1 consolidated row with 1 location
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_SingleEntry_ReturnsOneRowWithOneLocation()
{
var entry = MakeEntry();
var result = PermissionConsolidator.Consolidate(new[] { entry });
var row = Assert.Single(result);
Assert.Single(row.Locations);
Assert.Equal("alice@contoso.com", row.UserLogin);
}
// ---------------------------------------------------------------------------
// RPT-04-c: 3 entries with same key (different sites) merge to 1 row with 3 locations
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_ThreeEntriesSameKey_ReturnsOneRowWithThreeLocations()
{
var entries = new[]
{
MakeEntry(siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR Site"),
MakeEntry(siteUrl: "https://contoso.sharepoint.com/sites/fin", siteTitle: "Finance Site"),
MakeEntry(siteUrl: "https://contoso.sharepoint.com/sites/mkt", siteTitle: "Marketing Site"),
};
var result = PermissionConsolidator.Consolidate(entries);
Assert.Single(result);
Assert.Equal(3, result[0].Locations.Count);
}
// ---------------------------------------------------------------------------
// RPT-04-d: Entries with different keys remain as separate rows
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_DifferentKeys_RemainSeparateRows()
{
var entries = new[]
{
MakeEntry(permissionLevel: "Contribute"),
MakeEntry(permissionLevel: "Full Control"),
};
var result = PermissionConsolidator.Consolidate(entries);
Assert.Equal(2, result.Count);
}
// ---------------------------------------------------------------------------
// RPT-04-e: Case-insensitive key — "ALICE@CONTOSO.COM" and "alice@contoso.com" merge
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_CaseInsensitiveKey_MergesCorrectly()
{
var entries = new[]
{
MakeEntry(userLogin: "ALICE@CONTOSO.COM", siteUrl: "https://contoso.sharepoint.com/sites/hr"),
MakeEntry(userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/fin"),
};
var result = PermissionConsolidator.Consolidate(entries);
Assert.Single(result);
Assert.Equal(2, result[0].Locations.Count);
}
// ---------------------------------------------------------------------------
// RPT-04-f: MakeKey produces pipe-delimited lowercase format
// ---------------------------------------------------------------------------
[Fact]
public void MakeKey_ProducesPipeDelimitedLowercaseFormat()
{
var entry = MakeEntry(
userLogin: "Alice@Contoso.com",
permissionLevel: "Full Control",
accessType: AccessType.Direct,
grantedThrough: "Direct Permissions");
var key = PermissionConsolidator.MakeKey(entry);
// AccessType.ToString() preserves casing ("Direct"); all string fields are lowercased
Assert.Equal("alice@contoso.com|full control|Direct|direct permissions", key);
}
// ---------------------------------------------------------------------------
// RPT-04-g: 10-row input with 3 duplicate pairs produces 7 consolidated rows
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_TenRowsWithThreeDuplicatePairs_ReturnsSevenRows()
{
var entries = new[]
{
// alice / Contribute / Direct — 3 entries -> merges to 1
MakeEntry(userLogin: "alice@contoso.com", permissionLevel: "Contribute",
accessType: AccessType.Direct, grantedThrough: "Direct Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
MakeEntry(userLogin: "alice@contoso.com", permissionLevel: "Contribute",
accessType: AccessType.Direct, grantedThrough: "Direct Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/fin", siteTitle: "Finance"),
MakeEntry(userLogin: "alice@contoso.com", permissionLevel: "Contribute",
accessType: AccessType.Direct, grantedThrough: "Direct Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/mkt", siteTitle: "Marketing"),
// bob / Full Control / Group — 2 entries -> merges to 1
MakeEntry(userLogin: "bob@contoso.com", userDisplayName: "Bob Jones",
permissionLevel: "Full Control", accessType: AccessType.Group,
grantedThrough: "SharePoint Group: Owners",
siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
MakeEntry(userLogin: "bob@contoso.com", userDisplayName: "Bob Jones",
permissionLevel: "Full Control", accessType: AccessType.Group,
grantedThrough: "SharePoint Group: Owners",
siteUrl: "https://contoso.sharepoint.com/sites/fin", siteTitle: "Finance"),
// carol / Read / Inherited — 2 entries -> merges to 1
MakeEntry(userLogin: "carol@contoso.com", userDisplayName: "Carol White",
permissionLevel: "Read", accessType: AccessType.Inherited,
grantedThrough: "Inherited Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
MakeEntry(userLogin: "carol@contoso.com", userDisplayName: "Carol White",
permissionLevel: "Read", accessType: AccessType.Inherited,
grantedThrough: "Inherited Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/fin", siteTitle: "Finance"),
// alice / Full Control / Direct — different key from alice's Contribute -> unique row
MakeEntry(userLogin: "alice@contoso.com", permissionLevel: "Full Control",
accessType: AccessType.Direct, grantedThrough: "Direct Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
// dave — unique
MakeEntry(userLogin: "dave@contoso.com", userDisplayName: "Dave Brown",
permissionLevel: "Contribute", accessType: AccessType.Direct,
grantedThrough: "Direct Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
// eve — unique
MakeEntry(userLogin: "eve@contoso.com", userDisplayName: "Eve Green",
permissionLevel: "Read", accessType: AccessType.Direct,
grantedThrough: "Direct Permissions",
siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
// frank — unique (4th unique row)
MakeEntry(userLogin: "frank@contoso.com", userDisplayName: "Frank Black",
permissionLevel: "Contribute", accessType: AccessType.Group,
grantedThrough: "SharePoint Group: Members",
siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
};
var result = PermissionConsolidator.Consolidate(entries);
// 3 merged groups (alice-Contribute 3->1, bob 2->1, carol 2->1) + 4 unique rows
// (alice-FullControl, dave, eve, frank) = 7 total
Assert.Equal(7, result.Count);
}
// ---------------------------------------------------------------------------
// RPT-04-h: LocationCount property equals Locations.Count for a merged entry
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_MergedEntry_LocationCountMatchesLocationsCount()
{
var entries = new[]
{
MakeEntry(siteUrl: "https://contoso.sharepoint.com/sites/hr", siteTitle: "HR"),
MakeEntry(siteUrl: "https://contoso.sharepoint.com/sites/fin", siteTitle: "Finance"),
MakeEntry(siteUrl: "https://contoso.sharepoint.com/sites/mkt", siteTitle: "Marketing"),
};
var result = PermissionConsolidator.Consolidate(entries);
Assert.Single(result);
Assert.Equal(result[0].Locations.Count, result[0].LocationCount);
Assert.Equal(3, result[0].LocationCount);
}
// ---------------------------------------------------------------------------
// RPT-04-i: IsHighPrivilege and IsExternalUser from first entry are preserved
// ---------------------------------------------------------------------------
[Fact]
public void Consolidate_PreservesIsHighPrivilegeAndIsExternalUser()
{
var entries = new[]
{
MakeEntry(isHighPrivilege: true, isExternalUser: true,
siteUrl: "https://contoso.sharepoint.com/sites/hr"),
MakeEntry(isHighPrivilege: false, isExternalUser: false,
siteUrl: "https://contoso.sharepoint.com/sites/fin"),
};
var result = PermissionConsolidator.Consolidate(entries);
Assert.Single(result);
Assert.True(result[0].IsHighPrivilege);
Assert.True(result[0].IsExternalUser);
}
}
@@ -1,87 +0,0 @@
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Tests.Helpers;
/// <summary>
/// Unit tests for PermissionLevelMapping static helper.
/// SIMP-01: Validates mapping correctness for known roles, unknown fallback,
/// case insensitivity, semicolon splitting, risk ranking, and label generation.
/// </summary>
public class PermissionLevelMappingTests
{
[Theory]
[InlineData("Full Control", RiskLevel.High)]
[InlineData("Site Collection Administrator", RiskLevel.High)]
[InlineData("Contribute", RiskLevel.Medium)]
[InlineData("Edit", RiskLevel.Medium)]
[InlineData("Design", RiskLevel.Medium)]
[InlineData("Approve", RiskLevel.Medium)]
[InlineData("Manage Hierarchy", RiskLevel.Medium)]
[InlineData("Read", RiskLevel.Low)]
[InlineData("Restricted Read", RiskLevel.Low)]
[InlineData("View Only", RiskLevel.ReadOnly)]
[InlineData("Restricted View", RiskLevel.ReadOnly)]
public void GetMapping_KnownRoles_ReturnsCorrectRiskLevel(string roleName, RiskLevel expected)
{
var result = PermissionLevelMapping.GetMapping(roleName);
Assert.Equal(expected, result.RiskLevel);
Assert.NotEmpty(result.Label);
}
[Fact]
public void GetMapping_UnknownRole_ReturnsMediumRiskWithRawName()
{
var result = PermissionLevelMapping.GetMapping("Custom Permission Level");
Assert.Equal(RiskLevel.Medium, result.RiskLevel);
Assert.Equal("Custom Permission Level", result.Label);
}
[Fact]
public void GetMapping_CaseInsensitive()
{
var lower = PermissionLevelMapping.GetMapping("full control");
var upper = PermissionLevelMapping.GetMapping("FULL CONTROL");
Assert.Equal(RiskLevel.High, lower.RiskLevel);
Assert.Equal(RiskLevel.High, upper.RiskLevel);
}
[Fact]
public void GetMappings_SemicolonDelimited_SplitsAndMaps()
{
var results = PermissionLevelMapping.GetMappings("Full Control; Read");
Assert.Equal(2, results.Count);
Assert.Equal(RiskLevel.High, results[0].RiskLevel);
Assert.Equal(RiskLevel.Low, results[1].RiskLevel);
}
[Fact]
public void GetMappings_EmptyString_ReturnsEmpty()
{
var results = PermissionLevelMapping.GetMappings("");
Assert.Empty(results);
}
[Fact]
public void GetHighestRisk_MultipleLevels_ReturnsHighest()
{
// Full Control (High) + Read (Low) => High
var risk = PermissionLevelMapping.GetHighestRisk("Full Control; Read");
Assert.Equal(RiskLevel.High, risk);
}
[Fact]
public void GetHighestRisk_SingleReadOnly_ReturnsReadOnly()
{
var risk = PermissionLevelMapping.GetHighestRisk("View Only");
Assert.Equal(RiskLevel.ReadOnly, risk);
}
[Fact]
public void GetSimplifiedLabels_JoinsLabels()
{
var labels = PermissionLevelMapping.GetSimplifiedLabels("Contribute; Read");
Assert.Contains("Can edit files and list items", labels);
Assert.Contains("Can view files and pages", labels);
}
}
@@ -1,49 +0,0 @@
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Tests.Helpers;
[Trait("Category", "Unit")]
public class SharePointPaginationHelperTests
{
[Fact]
public void BuildPagedViewXml_NullInput_ReturnsViewWithRowLimit()
{
var result = SharePointPaginationHelper.BuildPagedViewXml(null, 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
}
[Fact]
public void BuildPagedViewXml_EmptyString_ReturnsViewWithRowLimit()
{
var result = SharePointPaginationHelper.BuildPagedViewXml("", 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
}
[Fact]
public void BuildPagedViewXml_WhitespaceOnly_ReturnsViewWithRowLimit()
{
var result = SharePointPaginationHelper.BuildPagedViewXml(" ", 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
}
[Fact]
public void BuildPagedViewXml_ExistingRowLimit_Replaces()
{
var input = "<View><RowLimit>100</RowLimit></View>";
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000);
Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
}
[Fact]
public void BuildPagedViewXml_NoRowLimit_AppendsBeforeClosingView()
{
var input = "<View><Query><OrderBy><FieldRef Name='Title'/></OrderBy></Query></View>";
var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000);
Assert.Contains("<RowLimit>2000</RowLimit>", result);
Assert.EndsWith("</View>", result);
// Ensure RowLimit is inserted before the closing </View>
var rowLimitIndex = result.IndexOf("<RowLimit>2000</RowLimit>", StringComparison.Ordinal);
var closingViewIndex = result.LastIndexOf("</View>", StringComparison.Ordinal);
Assert.True(rowLimitIndex < closingViewIndex, "RowLimit should appear before </View>");
}
}
@@ -1,47 +0,0 @@
using Serilog;
using Serilog.Core;
using SharepointToolbox.Infrastructure.Logging;
using System.IO;
namespace SharepointToolbox.Tests.Integration;
[Trait("Category", "Integration")]
public class LoggingIntegrationTests : IDisposable
{
private readonly string _tempLogDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
[Fact]
public async Task Serilog_WritesLogFile_WhenMessageLogged()
{
Directory.CreateDirectory(_tempLogDir);
var logFile = Path.Combine(_tempLogDir, "test-.log");
var logger = new LoggerConfiguration()
.WriteTo.File(logFile, rollingInterval: RollingInterval.Day)
.CreateLogger();
logger.Information("Test log message {Value}", 42);
await logger.DisposeAsync();
var files = Directory.GetFiles(_tempLogDir, "*.log");
Assert.Single(files);
var content = await File.ReadAllTextAsync(files[0]);
Assert.Contains("Test log message 42", content);
}
[Fact]
public void LogPanelSink_CanBeInstantiated_WithRichTextBox()
{
// Verify the sink type instantiates without throwing
// Cannot test actual UI writes without STA thread — this is structural smoke only
var sinkType = typeof(LogPanelSink);
Assert.NotNull(sinkType);
Assert.True(typeof(ILogEventSink).IsAssignableFrom(sinkType));
}
public void Dispose()
{
if (Directory.Exists(_tempLogDir))
Directory.Delete(_tempLogDir, recursive: true);
}
}
@@ -1,84 +0,0 @@
using System.Collections;
using System.Globalization;
using System.Resources;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Tests.Localization;
[Trait("Category", "Unit")]
public class LocaleCompletenessTests
{
/// <summary>
/// Verifies every EN key in Strings.resx has a non-empty, non-bracketed FR translation.
/// </summary>
[Fact]
public void AllEnKeys_HaveNonEmptyFrTranslation()
{
var rm = new ResourceManager("SharepointToolbox.Localization.Strings", typeof(Strings).Assembly);
var enResourceSet = rm.GetResourceSet(CultureInfo.InvariantCulture, true, true);
Assert.NotNull(enResourceSet);
var frCulture = new CultureInfo("fr");
var failures = new List<string>();
foreach (DictionaryEntry entry in enResourceSet)
{
var key = entry.Key?.ToString();
if (string.IsNullOrEmpty(key)) continue;
var frValue = rm.GetString(key, frCulture);
if (string.IsNullOrWhiteSpace(frValue))
{
failures.Add($" [{key}]: null or whitespace");
}
else if (frValue.StartsWith("[", StringComparison.Ordinal))
{
failures.Add($" [{key}]: bracketed fallback — '{frValue}'");
}
}
Assert.True(failures.Count == 0,
$"The following {failures.Count} key(s) are missing or invalid in Strings.fr.resx:\n" +
string.Join("\n", failures));
}
/// <summary>
/// Spot-checks 5 keys that must contain diacritics after Plan 02 fixes.
/// This test FAILS until Plan 02 corrects the FR translations.
/// </summary>
[Fact]
public void FrStrings_ContainExpectedDiacritics()
{
var rm = new ResourceManager("SharepointToolbox.Localization.Strings", typeof(Strings).Assembly);
var frCulture = new CultureInfo("fr");
var failures = new List<string>();
void CheckDiacritic(string key, char expectedChar)
{
var value = rm.GetString(key, frCulture);
if (value == null || !value.Contains(expectedChar))
{
failures.Add($" [{key}] = '{value ?? "(null)"}' — expected to contain '{expectedChar}'");
}
}
// Déplacer must contain é (currently "Deplacer")
CheckDiacritic("transfer.mode.move", 'é');
// Créer les sites must contain é (currently "Creer les sites")
CheckDiacritic("bulksites.execute", 'é');
// Modèles enregistrés must contain è (currently "Modeles enregistres")
CheckDiacritic("templates.list", 'è');
// Terminé : {0} réussis, {1} échoués must contain é (currently "Termine : ...")
CheckDiacritic("bulk.result.success", 'é');
// Bibliothèque cible must contain è (currently "Bibliotheque cible")
CheckDiacritic("folderstruct.library", 'è');
Assert.True(failures.Count == 0,
$"The following {failures.Count} key(s) are missing expected diacritics in Strings.fr.resx " +
$"(fix in Plan 02):\n" + string.Join("\n", failures));
}
}
@@ -1,83 +0,0 @@
using SharepointToolbox.Localization;
using System.ComponentModel;
using System.Globalization;
namespace SharepointToolbox.Tests.Localization;
[Trait("Category", "Unit")]
public class TranslationSourceTests : IDisposable
{
private readonly CultureInfo _originalCulture;
public TranslationSourceTests()
{
_originalCulture = TranslationSource.Instance.CurrentCulture;
// Reset to EN before each test
TranslationSource.Instance.CurrentCulture = new CultureInfo("en-US");
}
public void Dispose()
{
// Restore original culture after each test to prevent test pollution
TranslationSource.Instance.CurrentCulture = _originalCulture;
}
[Fact]
public void Instance_IsSameInstance_OnMultipleAccesses()
{
var instance1 = TranslationSource.Instance;
var instance2 = TranslationSource.Instance;
Assert.Same(instance1, instance2);
}
[Fact]
public void Indexer_ReturnsEnString_ForEnCulture()
{
TranslationSource.Instance.CurrentCulture = new CultureInfo("en-US");
var result = TranslationSource.Instance["app.title"];
Assert.Equal("SharePoint Toolbox", result);
}
[Fact]
public void Indexer_ReturnsFrOrFallback_AfterSwitchToFrFR()
{
TranslationSource.Instance.CurrentCulture = new CultureInfo("fr-FR");
var result = TranslationSource.Instance["app.title"];
// FR stub uses EN text — at minimum should not be null or empty
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.DoesNotContain("[", result); // Should not be missing-key placeholder
}
[Fact]
public void Indexer_ReturnsBracketedKey_ForMissingKey()
{
var result = TranslationSource.Instance["key.does.not.exist"];
Assert.Equal("[key.does.not.exist]", result);
}
[Fact]
public void ChangingCurrentCulture_FiresPropertyChanged_WithEmptyPropertyName()
{
string? capturedPropertyName = null;
TranslationSource.Instance.PropertyChanged += (sender, args) =>
capturedPropertyName = args.PropertyName;
TranslationSource.Instance.CurrentCulture = new CultureInfo("fr-FR");
Assert.Equal(string.Empty, capturedPropertyName);
}
[Fact]
public void SettingSameCulture_DoesNotFirePropertyChanged()
{
TranslationSource.Instance.CurrentCulture = new CultureInfo("en-US");
int fireCount = 0;
TranslationSource.Instance.PropertyChanged += (sender, args) => fireCount++;
// Set the exact same culture
TranslationSource.Instance.CurrentCulture = new CultureInfo("en-US");
Assert.Equal(0, fireCount);
}
}
@@ -1,82 +0,0 @@
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Tests.Models;
/// <summary>
/// Unit tests for PermissionSummaryBuilder and SimplifiedPermissionEntry.
/// SIMP-02: Validates summary aggregation, risk-level grouping, distinct user counting,
/// and SimplifiedPermissionEntry wrapping behavior.
/// </summary>
public class PermissionSummaryBuilderTests
{
private static PermissionEntry MakeEntry(string permLevels, string users = "User1", string logins = "user1@test.com") =>
new PermissionEntry(
ObjectType: "Site",
Title: "Test",
Url: "https://test.sharepoint.com",
HasUniquePermissions: true,
Users: users,
UserLogins: logins,
PermissionLevels: permLevels,
GrantedThrough: "Direct Permissions",
PrincipalType: "User");
[Fact]
public void Build_ReturnsAllFourRiskLevels()
{
var entries = SimplifiedPermissionEntry.WrapAll(new[]
{
MakeEntry("Full Control"),
MakeEntry("Contribute"),
MakeEntry("Read"),
MakeEntry("View Only")
});
var summaries = PermissionSummaryBuilder.Build(entries);
Assert.Equal(4, summaries.Count);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.High && s.Count == 1);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.Medium && s.Count == 1);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.Low && s.Count == 1);
Assert.Contains(summaries, s => s.RiskLevel == RiskLevel.ReadOnly && s.Count == 1);
}
[Fact]
public void Build_EmptyCollection_ReturnsZeroCounts()
{
var summaries = PermissionSummaryBuilder.Build(Array.Empty<SimplifiedPermissionEntry>());
Assert.Equal(4, summaries.Count);
Assert.All(summaries, s => Assert.Equal(0, s.Count));
}
[Fact]
public void Build_CountsDistinctUsers()
{
var entries = SimplifiedPermissionEntry.WrapAll(new[]
{
MakeEntry("Full Control", "Alice", "alice@test.com"),
MakeEntry("Full Control", "Bob", "bob@test.com"),
MakeEntry("Full Control", "Alice", "alice@test.com"), // duplicate user
});
var summaries = PermissionSummaryBuilder.Build(entries);
var high = summaries.Single(s => s.RiskLevel == RiskLevel.High);
Assert.Equal(3, high.Count); // 3 entries
Assert.Equal(2, high.DistinctUsers); // 2 distinct users
}
[Fact]
public void SimplifiedPermissionEntry_WrapAll_PreservesInner()
{
var original = MakeEntry("Contribute");
var wrapped = SimplifiedPermissionEntry.WrapAll(new[] { original });
Assert.Single(wrapped);
Assert.Same(original, wrapped[0].Inner);
Assert.Equal("Contribute", wrapped[0].PermissionLevels);
Assert.Equal(RiskLevel.Medium, wrapped[0].RiskLevel);
Assert.Contains("Can edit", wrapped[0].SimplifiedLabels);
}
}
@@ -1,178 +0,0 @@
using System.IO;
using System.Text.Json;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory;
using AppMsalClientFactory = SharepointToolbox.Infrastructure.Auth.MsalClientFactory;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Unit tests for AppRegistrationResult, TenantProfile.AppId, and AppRegistrationService.
/// Graph API calls require live Entra connectivity and are marked as Integration tests.
/// Pure logic (model behaviour, BuildRequiredResourceAccess structure) is covered here.
/// </summary>
[Trait("Category", "Unit")]
public class AppRegistrationServiceTests
{
// ────────────────────────────────────────────────────────────────────────
// AppRegistrationResult — factory method tests
// ────────────────────────────────────────────────────────────────────────
[Fact]
public void Success_CarriesAppId()
{
var result = AppRegistrationResult.Success("appId123");
Assert.True(result.IsSuccess);
Assert.False(result.IsFallback);
Assert.Equal("appId123", result.AppId);
Assert.Null(result.ErrorMessage);
}
[Fact]
public void Failure_CarriesMessage()
{
var result = AppRegistrationResult.Failure("Something went wrong");
Assert.False(result.IsSuccess);
Assert.False(result.IsFallback);
Assert.Null(result.AppId);
Assert.Equal("Something went wrong", result.ErrorMessage);
}
[Fact]
public void FallbackRequired_SetsFallback()
{
var result = AppRegistrationResult.FallbackRequired();
Assert.False(result.IsSuccess);
Assert.True(result.IsFallback);
Assert.Null(result.AppId);
Assert.Null(result.ErrorMessage);
}
// ────────────────────────────────────────────────────────────────────────
// TenantProfile.AppId — nullable field tests
// ────────────────────────────────────────────────────────────────────────
[Fact]
public void AppId_DefaultsToNull()
{
var profile = new TenantProfile();
Assert.Null(profile.AppId);
}
[Fact]
public void AppId_RoundTrips_ViaJson()
{
var profile = new TenantProfile
{
Name = "Test Tenant",
TenantUrl = "https://example.sharepoint.com",
ClientId = "client-id-abc",
AppId = "registered-app-id-xyz"
};
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(profile, options);
var loaded = JsonSerializer.Deserialize<TenantProfile>(json, options);
Assert.NotNull(loaded);
Assert.Equal("registered-app-id-xyz", loaded!.AppId);
Assert.Equal("Test Tenant", loaded.Name);
}
[Fact]
public void AppId_Null_RoundTrips_ViaJson()
{
var profile = new TenantProfile
{
Name = "Test Tenant",
TenantUrl = "https://example.sharepoint.com",
ClientId = "client-id-abc",
AppId = null
};
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(profile, options);
var loaded = JsonSerializer.Deserialize<TenantProfile>(json, options);
Assert.NotNull(loaded);
Assert.Null(loaded!.AppId);
}
// ────────────────────────────────────────────────────────────────────────
// AppRegistrationService — constructor / dependency wiring
// ────────────────────────────────────────────────────────────────────────
[Fact]
public void AppRegistrationService_ImplementsInterface()
{
// Verify that the concrete class satisfies the interface contract.
// We instantiate with a real MsalClientFactory (no-IO path) and mocked session manager / logger.
var msalFactory = new AppMsalClientFactory(Path.GetTempPath());
var graphFactory = new AppGraphClientFactory(msalFactory);
var sessionManagerMock = new Mock<ISessionManager>();
var loggerMock = new Microsoft.Extensions.Logging.Abstractions.NullLogger<AppRegistrationService>();
var service = new AppRegistrationService(graphFactory, msalFactory, sessionManagerMock.Object, loggerMock);
Assert.IsAssignableFrom<IAppRegistrationService>(service);
}
// ────────────────────────────────────────────────────────────────────────
// BuildRequiredResourceAccess — structure verification (no live calls)
// ────────────────────────────────────────────────────────────────────────
[Fact]
public void BuildRequiredResourceAccess_ContainsTwoResources()
{
var result = AppRegistrationService.BuildRequiredResourceAccess();
Assert.Equal(2, result.Count);
}
[Fact]
public void BuildRequiredResourceAccess_GraphResource_HasFourScopes()
{
const string graphAppId = "00000003-0000-0000-c000-000000000000";
var result = AppRegistrationService.BuildRequiredResourceAccess();
var graphEntry = result.Single(r => r.ResourceAppId == graphAppId);
Assert.NotNull(graphEntry.ResourceAccess);
Assert.Equal(4, graphEntry.ResourceAccess!.Count);
}
[Fact]
public void BuildRequiredResourceAccess_SharePointResource_HasOneScope()
{
const string spoAppId = "00000003-0000-0ff1-ce00-000000000000";
var result = AppRegistrationService.BuildRequiredResourceAccess();
var spoEntry = result.Single(r => r.ResourceAppId == spoAppId);
Assert.NotNull(spoEntry.ResourceAccess);
Assert.Single(spoEntry.ResourceAccess!);
}
[Fact]
public void BuildRequiredResourceAccess_AllScopes_HaveScopeType()
{
var result = AppRegistrationService.BuildRequiredResourceAccess();
var allAccess = result.SelectMany(r => r.ResourceAccess!);
Assert.All(allAccess, ra => Assert.Equal("Scope", ra.Type));
}
[Fact]
public void BuildRequiredResourceAccess_GraphResource_ContainsUserReadScope()
{
const string graphAppId = "00000003-0000-0000-c000-000000000000";
var userReadGuid = Guid.Parse("e1fe6dd8-ba31-4d61-89e7-88639da4683d"); // User.Read
var result = AppRegistrationService.BuildRequiredResourceAccess();
var graphEntry = result.Single(r => r.ResourceAppId == graphAppId);
Assert.Contains(graphEntry.ResourceAccess!, ra => ra.Id == userReadGuid);
}
}
@@ -1,130 +0,0 @@
using System.IO;
using System.Text;
using System.Text.Json;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
namespace SharepointToolbox.Tests.Services;
[Trait("Category", "Unit")]
public class BrandingRepositoryTests : IDisposable
{
private readonly string _tempFile;
public BrandingRepositoryTests()
{
_tempFile = Path.GetTempFileName();
File.Delete(_tempFile);
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private BrandingRepository CreateRepository() => new(_tempFile);
[Fact]
public async Task LoadAsync_MissingFile_ReturnsDefaultBrandingSettings()
{
var repo = CreateRepository();
var settings = await repo.LoadAsync();
Assert.Null(settings.MspLogo);
}
[Fact]
public async Task SaveAndLoad_RoundTrips_MspLogo()
{
var repo = CreateRepository();
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
var original = new BrandingSettings { MspLogo = logo };
await repo.SaveAsync(original);
var loaded = await repo.LoadAsync();
Assert.NotNull(loaded.MspLogo);
Assert.Equal("abc123==", loaded.MspLogo.Base64);
Assert.Equal("image/png", loaded.MspLogo.MimeType);
}
[Fact]
public async Task SaveAsync_CreatesDirectoryIfNotExists()
{
var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName(), "subdir");
var filePath = Path.Combine(tempDir, "branding.json");
var repo = new BrandingRepository(filePath);
try
{
await repo.SaveAsync(new BrandingSettings());
Assert.True(File.Exists(filePath), "File must be created even when directory did not exist");
}
finally
{
if (File.Exists(filePath)) File.Delete(filePath);
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task TenantProfile_WithClientLogo_SerializesAndDeserializesCorrectly()
{
var logo = new LogoData { Base64 = "xyz==", MimeType = "image/jpeg" };
var profile = new TenantProfile
{
Name = "Contoso",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "client-id-123",
ClientLogo = logo
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var json = JsonSerializer.Serialize(profile, options);
// Verify camelCase key exists
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("clientLogo", out var clientLogoElem),
"JSON must contain 'clientLogo' key (camelCase)");
Assert.Equal(JsonValueKind.Object, clientLogoElem.ValueKind);
// Deserialize back
var readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var loaded = JsonSerializer.Deserialize<TenantProfile>(json, readOptions);
Assert.NotNull(loaded?.ClientLogo);
Assert.Equal("xyz==", loaded.ClientLogo.Base64);
Assert.Equal("image/jpeg", loaded.ClientLogo.MimeType);
}
[Fact]
public async Task TenantProfile_WithoutClientLogo_SerializesWithNullAndDeserializesWithNull()
{
var profile = new TenantProfile
{
Name = "Fabrikam",
TenantUrl = "https://fabrikam.sharepoint.com",
ClientId = "client-id-456"
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var json = JsonSerializer.Serialize(profile, options);
// Deserialize back — ClientLogo should be null (forward compatible)
var readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var loaded = JsonSerializer.Deserialize<TenantProfile>(json, readOptions);
Assert.NotNull(loaded);
Assert.Null(loaded.ClientLogo);
}
}
@@ -1,244 +0,0 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
[Trait("Category", "Unit")]
public class BrandingServiceTests : IDisposable
{
private readonly string _tempRepoFile;
private readonly List<string> _tempFiles = new();
public BrandingServiceTests()
{
_tempRepoFile = Path.GetTempFileName();
File.Delete(_tempRepoFile);
}
public void Dispose()
{
if (File.Exists(_tempRepoFile)) File.Delete(_tempRepoFile);
if (File.Exists(_tempRepoFile + ".tmp")) File.Delete(_tempRepoFile + ".tmp");
foreach (var f in _tempFiles)
{
if (File.Exists(f)) File.Delete(f);
}
}
private BrandingRepository CreateRepository() => new(_tempRepoFile);
private BrandingService CreateService() => new(CreateRepository());
private string WriteTempFile(byte[] bytes)
{
var path = Path.GetTempFileName();
File.WriteAllBytes(path, bytes);
_tempFiles.Add(path);
return path;
}
// Minimal valid 1x1 PNG bytes
private static byte[] MinimalPngBytes()
{
// Full 1x1 transparent PNG (67 bytes)
return new byte[]
{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, // IHDR length
0x49, 0x48, 0x44, 0x52, // IHDR
0x00, 0x00, 0x00, 0x01, // width = 1
0x00, 0x00, 0x00, 0x01, // height = 1
0x08, 0x02, // bit depth = 8, color type = RGB
0x00, 0x00, 0x00, // compression, filter, interlace
0x90, 0x77, 0x53, 0xDE, // CRC
0x00, 0x00, 0x00, 0x0C, // IDAT length
0x49, 0x44, 0x41, 0x54, // IDAT
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // compressed data
0xE2, 0x21, 0xBC, 0x33, // CRC
0x00, 0x00, 0x00, 0x00, // IEND length
0x49, 0x45, 0x4E, 0x44, // IEND
0xAE, 0x42, 0x60, 0x82 // CRC
};
}
// Minimal valid JPEG bytes (SOI + APP0 JFIF header + EOI)
private static byte[] MinimalJpegBytes()
{
return new byte[]
{
0xFF, 0xD8, // SOI
0xFF, 0xE0, // APP0 marker
0x00, 0x10, // length = 16
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
0x01, 0x01, // version 1.1
0x00, // aspect ratio units = 0
0x00, 0x01, 0x00, 0x01, // X/Y density = 1
0x00, 0x00, // thumbnail size
0xFF, 0xD9 // EOI
};
}
[Fact]
public async Task ImportLogoAsync_ValidPng_ReturnsPngLogoData()
{
var service = CreateService();
var pngBytes = MinimalPngBytes();
var path = WriteTempFile(pngBytes);
var result = await service.ImportLogoAsync(path);
Assert.Equal("image/png", result.MimeType);
Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64);
}
[Fact]
public async Task ImportLogoAsync_ValidJpeg_ReturnsJpegLogoData()
{
var service = CreateService();
var jpegBytes = MinimalJpegBytes();
var path = WriteTempFile(jpegBytes);
var result = await service.ImportLogoAsync(path);
Assert.Equal("image/jpeg", result.MimeType);
}
[Fact]
public async Task ImportLogoAsync_BmpFile_ThrowsInvalidDataExceptionMentioningPngAndJpg()
{
var service = CreateService();
// BMP magic bytes: 0x42 0x4D
var bmpBytes = new byte[] { 0x42, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
var path = WriteTempFile(bmpBytes);
var ex = await Assert.ThrowsAsync<InvalidDataException>(() => service.ImportLogoAsync(path));
Assert.Contains("PNG", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("JPG", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ImportLogoAsync_EmptyFile_ThrowsInvalidDataException()
{
var service = CreateService();
var path = WriteTempFile(Array.Empty<byte>());
await Assert.ThrowsAsync<InvalidDataException>(() => service.ImportLogoAsync(path));
}
[Fact]
public async Task ImportLogoAsync_FileUnder512KB_ReturnOriginalBytesUnmodified()
{
var service = CreateService();
var pngBytes = MinimalPngBytes();
var path = WriteTempFile(pngBytes);
var result = await service.ImportLogoAsync(path);
Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64);
}
[Fact]
public async Task ImportLogoAsync_FileOver512KB_ReturnsCompressedUnder512KB()
{
var service = CreateService();
// Create a large PNG image in memory (400x400 random pixels)
var largePngPath = Path.GetTempFileName();
_tempFiles.Add(largePngPath);
using (var bmp = new Bitmap(400, 400))
{
var rng = new Random(42);
for (int y = 0; y < 400; y++)
for (int x = 0; x < 400; x++)
bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256)));
bmp.Save(largePngPath, ImageFormat.Png);
}
var fileSize = new FileInfo(largePngPath).Length;
// PNG with random pixels should exceed 512 KB
// If not, we'll pad it
if (fileSize <= 512 * 1024)
{
// Generate a bigger image to be sure
using var bmp = new Bitmap(800, 800);
var rng = new Random(42);
for (int y = 0; y < 800; y++)
for (int x = 0; x < 800; x++)
bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256)));
bmp.Save(largePngPath, ImageFormat.Png);
}
fileSize = new FileInfo(largePngPath).Length;
Assert.True(fileSize > 512 * 1024, $"Test setup: PNG file must be > 512 KB but was {fileSize} bytes");
var result = await service.ImportLogoAsync(largePngPath);
var decodedBytes = Convert.FromBase64String(result.Base64);
Assert.True(decodedBytes.Length <= 512 * 1024,
$"Compressed result must be <= 512 KB but was {decodedBytes.Length} bytes");
}
[Fact]
public async Task SaveMspLogoAsync_PersistsLogoInRepository()
{
var repo = CreateRepository();
var service = new BrandingService(repo);
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
await service.SaveMspLogoAsync(logo);
var loaded = await repo.LoadAsync();
Assert.NotNull(loaded.MspLogo);
Assert.Equal("abc123==", loaded.MspLogo.Base64);
Assert.Equal("image/png", loaded.MspLogo.MimeType);
}
[Fact]
public async Task ClearMspLogoAsync_SetsMspLogoToNull()
{
var repo = CreateRepository();
var service = new BrandingService(repo);
var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" };
await service.SaveMspLogoAsync(logo);
await service.ClearMspLogoAsync();
var loaded = await repo.LoadAsync();
Assert.Null(loaded.MspLogo);
}
[Fact]
public async Task GetMspLogoAsync_WhenNoLogoConfigured_ReturnsNull()
{
var service = CreateService();
var result = await service.GetMspLogoAsync();
Assert.Null(result);
}
[Fact]
public async Task ImportLogoFromBytesAsync_ValidPngBytes_ReturnsPngLogoData()
{
var service = CreateService();
var pngBytes = MinimalPngBytes();
var result = await service.ImportLogoFromBytesAsync(pngBytes);
Assert.Equal("image/png", result.MimeType);
Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64);
}
[Fact]
public async Task ImportLogoFromBytesAsync_InvalidBytes_ThrowsInvalidDataException()
{
var service = CreateService();
var invalidBytes = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 };
await Assert.ThrowsAsync<InvalidDataException>(() => service.ImportLogoFromBytesAsync(invalidBytes));
}
}
@@ -1,56 +0,0 @@
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()
{
}
}
@@ -1,105 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
public class BulkOperationRunnerTests
{
[Fact]
public async Task RunAsync_AllSucceed_ReturnsAllSuccess()
{
var items = new List<string> { "a", "b", "c" };
var progress = new Progress<OperationProgress>();
var summary = await BulkOperationRunner.RunAsync(
items,
(item, idx, ct) => Task.CompletedTask,
progress,
CancellationToken.None);
Assert.Equal(3, summary.TotalCount);
Assert.Equal(3, summary.SuccessCount);
Assert.Equal(0, summary.FailedCount);
Assert.False(summary.HasFailures);
}
[Fact]
public async Task RunAsync_SomeItemsFail_ContinuesAndReportsPerItem()
{
var items = new List<string> { "ok1", "fail", "ok2" };
var progress = new Progress<OperationProgress>();
var summary = await BulkOperationRunner.RunAsync(
items,
(item, idx, ct) =>
{
if (item == "fail") throw new InvalidOperationException("Test error");
return Task.CompletedTask;
},
progress,
CancellationToken.None);
Assert.Equal(3, summary.TotalCount);
Assert.Equal(2, summary.SuccessCount);
Assert.Equal(1, summary.FailedCount);
Assert.True(summary.HasFailures);
Assert.Contains(summary.FailedItems, r => r.ErrorMessage == "Test error");
}
[Fact]
public async Task RunAsync_Cancelled_ThrowsOperationCanceled()
{
var items = new List<string> { "a", "b", "c" };
var cts = new CancellationTokenSource();
cts.Cancel();
var progress = new Progress<OperationProgress>();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
BulkOperationRunner.RunAsync(
items,
(item, idx, ct) => Task.CompletedTask,
progress,
cts.Token));
}
[Fact]
public async Task RunAsync_CancelledMidOperation_StopsProcessing()
{
var items = new List<string> { "a", "b", "c", "d" };
var cts = new CancellationTokenSource();
var processedCount = 0;
var progress = new Progress<OperationProgress>();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
BulkOperationRunner.RunAsync(
items,
async (item, idx, ct) =>
{
Interlocked.Increment(ref processedCount);
if (idx == 1) cts.Cancel(); // cancel after second item
await Task.CompletedTask;
},
progress,
cts.Token));
Assert.True(processedCount <= 3); // should not process all 4
}
[Fact]
public async Task RunAsync_ReportsProgress()
{
var items = new List<string> { "a", "b" };
var progressReports = new List<OperationProgress>();
var progress = new Progress<OperationProgress>(p => progressReports.Add(p));
await BulkOperationRunner.RunAsync(
items,
(item, idx, ct) => Task.CompletedTask,
progress,
CancellationToken.None);
// Progress is async, give it a moment to flush
await Task.Delay(100);
Assert.True(progressReports.Count >= 2);
}
}
@@ -1,45 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services;
public class BulkResultCsvExportServiceTests
{
[Fact]
public void BuildFailedItemsCsv_WithFailedItems_IncludesErrorColumn()
{
var service = new BulkResultCsvExportService();
var items = new List<BulkItemResult<BulkMemberRow>>
{
BulkItemResult<BulkMemberRow>.Failed(
new BulkMemberRow { Email = "bad@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" },
"User not found"),
};
var csv = service.BuildFailedItemsCsv(items);
Assert.Contains("Error", csv);
Assert.Contains("Timestamp", csv);
Assert.Contains("bad@test.com", csv);
Assert.Contains("User not found", csv);
}
[Fact]
public void BuildFailedItemsCsv_SuccessItems_Excluded()
{
var service = new BulkResultCsvExportService();
var items = new List<BulkItemResult<BulkMemberRow>>
{
BulkItemResult<BulkMemberRow>.Success(
new BulkMemberRow { Email = "ok@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" }),
BulkItemResult<BulkMemberRow>.Failed(
new BulkMemberRow { Email = "bad@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" },
"Error"),
};
var csv = service.BuildFailedItemsCsv(items);
Assert.DoesNotContain("ok@test.com", csv);
Assert.Contains("bad@test.com", csv);
}
}
@@ -1,56 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
public class BulkSiteServiceTests
{
[Fact]
public void BulkSiteService_Implements_IBulkSiteService()
{
Assert.True(typeof(IBulkSiteService).IsAssignableFrom(typeof(BulkSiteService)));
}
[Fact]
public void BulkSiteRow_DefaultValues()
{
var row = new BulkSiteRow();
Assert.Equal(string.Empty, row.Name);
Assert.Equal(string.Empty, row.Alias);
Assert.Equal(string.Empty, row.Type);
Assert.Equal(string.Empty, row.Template);
Assert.Equal(string.Empty, row.Owners);
Assert.Equal(string.Empty, row.Members);
}
[Fact]
public void BulkSiteRow_ParsesCommaSeparatedEmails()
{
var row = new BulkSiteRow
{
Name = "Test Site",
Alias = "test-site",
Type = "Team",
Owners = "admin@test.com, user@test.com",
Members = "member1@test.com,member2@test.com"
};
Assert.Equal("Test Site", row.Name);
Assert.Contains("admin@test.com", row.Owners);
}
[Fact(Skip = "Requires live SharePoint admin context")]
public async Task CreateSitesAsync_TeamSite_CreatesWithOwners()
{
}
[Fact(Skip = "Requires live SharePoint admin context")]
public async Task CreateSitesAsync_CommunicationSite_CreatesWithUrl()
{
}
[Fact(Skip = "Requires live SharePoint admin context")]
public async Task CreateSitesAsync_MixedTypes_HandlesEachCorrectly()
{
}
}
@@ -1,128 +0,0 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
public class CsvValidationServiceTests
{
private readonly CsvValidationService _service = new();
private static Stream ToStream(string content)
{
return new MemoryStream(Encoding.UTF8.GetBytes(content));
}
private static Stream ToStreamWithBom(string content)
{
var preamble = Encoding.UTF8.GetPreamble();
var bytes = Encoding.UTF8.GetBytes(content);
var combined = new byte[preamble.Length + bytes.Length];
preamble.CopyTo(combined, 0);
bytes.CopyTo(combined, preamble.Length);
return new MemoryStream(combined);
}
[Fact]
public void ParseAndValidateMembers_ValidCsv_ReturnsValidRows()
{
var csv = "GroupName,GroupUrl,Email,Role\nTeam A,https://site,user@test.com,Member\n";
var rows = _service.ParseAndValidateMembers(ToStream(csv));
Assert.Single(rows);
Assert.True(rows[0].IsValid);
Assert.Equal("user@test.com", rows[0].Record!.Email);
}
[Fact]
public void ParseAndValidateMembers_InvalidEmail_ReturnsErrors()
{
var csv = "GroupName,GroupUrl,Email,Role\nTeam A,https://site,not-an-email,Member\n";
var rows = _service.ParseAndValidateMembers(ToStream(csv));
Assert.Single(rows);
Assert.False(rows[0].IsValid);
Assert.Contains(rows[0].Errors, e => e.Contains("Invalid email"));
}
[Fact]
public void ParseAndValidateMembers_MissingGroup_ReturnsError()
{
var csv = "GroupName,GroupUrl,Email,Role\n,,user@test.com,Member\n";
var rows = _service.ParseAndValidateMembers(ToStream(csv));
Assert.Single(rows);
Assert.False(rows[0].IsValid);
Assert.Contains(rows[0].Errors, e => e.Contains("GroupName or GroupUrl"));
}
[Fact]
public void ParseAndValidateSites_TeamWithoutOwner_ReturnsError()
{
var csv = "Name;Alias;Type;Template;Owners;Members\nSite A;site-a;Team;;;\n";
var rows = _service.ParseAndValidateSites(ToStream(csv));
Assert.Single(rows);
Assert.False(rows[0].IsValid);
Assert.Contains(rows[0].Errors, e => e.Contains("owner"));
}
[Fact]
public void ParseAndValidateSites_ValidTeam_ReturnsValid()
{
var csv = "Name;Alias;Type;Template;Owners;Members\nSite A;site-a;Team;;admin@test.com;user@test.com\n";
var rows = _service.ParseAndValidateSites(ToStream(csv));
Assert.Single(rows);
Assert.True(rows[0].IsValid);
Assert.Equal("Site A", rows[0].Record!.Name);
}
[Fact]
public void ParseAndValidateFolders_ValidCsv_ReturnsValidRows()
{
var csv = "Level1;Level2;Level3;Level4\nAdmin;HR;;\n";
var rows = _service.ParseAndValidateFolders(ToStream(csv));
Assert.Single(rows);
Assert.True(rows[0].IsValid);
Assert.Equal("Admin", rows[0].Record!.Level1);
Assert.Equal("HR", rows[0].Record!.Level2);
}
[Fact]
public void ParseAndValidateFolders_MissingLevel1_ReturnsError()
{
var csv = "Level1;Level2;Level3;Level4\n;SubFolder;;\n";
var rows = _service.ParseAndValidateFolders(ToStream(csv));
Assert.Single(rows);
Assert.False(rows[0].IsValid);
Assert.Contains(rows[0].Errors, e => e.Contains("Level1"));
}
[Fact]
public void ParseAndValidate_BomDetection_WorksWithAndWithoutBom()
{
var csv = "GroupName,GroupUrl,Email,Role\nTeam A,https://site,user@test.com,Member\n";
var rowsNoBom = _service.ParseAndValidateMembers(ToStream(csv));
var rowsWithBom = _service.ParseAndValidateMembers(ToStreamWithBom(csv));
Assert.Single(rowsNoBom);
Assert.Single(rowsWithBom);
Assert.True(rowsNoBom[0].IsValid);
Assert.True(rowsWithBom[0].IsValid);
}
[Fact]
public void ParseAndValidate_SemicolonDelimiter_DetectedAutomatically()
{
var csv = "Name;Alias;Type;Template;Owners;Members\nSite A;site-a;Communication;;;;\n";
var rows = _service.ParseAndValidateSites(ToStream(csv));
Assert.Single(rows);
Assert.Equal("Site A", rows[0].Record!.Name);
Assert.Equal("Communication", rows[0].Record!.Type);
}
}
@@ -1,80 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Pure-logic tests for the MakeKey composite key function (no CSOM needed).
/// Inline helper matches the implementation DuplicatesService will produce in Plan 03-04.
/// </summary>
public class DuplicatesServiceTests
{
// Inline copy of MakeKey to test logic before Plan 03-04 creates the real class
private static string MakeKey(DuplicateItem item, DuplicateScanOptions opts)
{
var parts = new System.Collections.Generic.List<string> { item.Name.ToLowerInvariant() };
if (opts.MatchSize && item.SizeBytes.HasValue) parts.Add(item.SizeBytes.Value.ToString());
if (opts.MatchCreated && item.Created.HasValue) parts.Add(item.Created.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchModified && item.Modified.HasValue) parts.Add(item.Modified.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchSubfolderCount && item.FolderCount.HasValue) parts.Add(item.FolderCount.Value.ToString());
if (opts.MatchFileCount && item.FileCount.HasValue) parts.Add(item.FileCount.Value.ToString());
return string.Join("|", parts);
}
[Fact]
public void MakeKey_NameOnly_ReturnsLowercaseName()
{
var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1000 };
var opts = new DuplicateScanOptions(MatchSize: false);
Assert.Equal("report.docx", MakeKey(item, opts));
}
[Fact]
public void MakeKey_WithSizeMatch_AppendsSizeToKey()
{
var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1024 };
var opts = new DuplicateScanOptions(MatchSize: true);
Assert.Equal("report.docx|1024", MakeKey(item, opts));
}
[Fact]
public void MakeKey_WithCreatedAndModified_AppendsDateStrings()
{
var item = new DuplicateItem
{
Name = "file.pdf",
SizeBytes = 500,
Created = new DateTime(2024, 3, 15),
Modified = new DateTime(2024, 6, 1)
};
var opts = new DuplicateScanOptions(MatchSize: false, MatchCreated: true, MatchModified: true);
Assert.Equal("file.pdf|2024-03-15|2024-06-01", MakeKey(item, opts));
}
[Fact]
public void MakeKey_SameKeyForSameItems_GroupsCorrectly()
{
var opts = new DuplicateScanOptions(MatchSize: true);
var item1 = new DuplicateItem { Name = "Budget.xlsx", SizeBytes = 2048 };
var item2 = new DuplicateItem { Name = "BUDGET.xlsx", SizeBytes = 2048 };
Assert.Equal(MakeKey(item1, opts), MakeKey(item2, opts));
}
[Fact]
public void MakeKey_DifferentSize_ProducesDifferentKeys()
{
var opts = new DuplicateScanOptions(MatchSize: true);
var item1 = new DuplicateItem { Name = "file.docx", SizeBytes = 100 };
var item2 = new DuplicateItem { Name = "file.docx", SizeBytes = 200 };
Assert.NotEqual(MakeKey(item1, opts), MakeKey(item2, opts));
}
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task ScanDuplicatesAsync_Files_GroupsByCompositeKey()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task ScanDuplicatesAsync_Folders_UsesCamlFSObjType1()
=> Task.CompletedTask;
}
@@ -1,105 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
[Trait("Category", "Unit")]
public class BrandingHtmlHelperTests
{
private static LogoData MakeLogo(string mime = "image/png", string base64 = "dGVzdA==") =>
new() { MimeType = mime, Base64 = base64 };
// Test 1: null ReportBranding returns empty string
[Fact]
public void BuildBrandingHeader_NullBranding_ReturnsEmptyString()
{
var result = BrandingHtmlHelper.BuildBrandingHeader(null);
Assert.Equal(string.Empty, result);
}
// Test 2: both logos null returns empty string
[Fact]
public void BuildBrandingHeader_BothLogosNull_ReturnsEmptyString()
{
var branding = new ReportBranding(null, null);
var result = BrandingHtmlHelper.BuildBrandingHeader(branding);
Assert.Equal(string.Empty, result);
}
// Test 3: only MspLogo — contains MSP img tag, no second img
[Fact]
public void BuildBrandingHeader_OnlyMspLogo_ReturnsHtmlWithOneImg()
{
var msp = MakeLogo("image/png", "bXNwbG9nbw==");
var branding = new ReportBranding(msp, null);
var result = BrandingHtmlHelper.BuildBrandingHeader(branding);
Assert.Contains("data:image/png;base64,bXNwbG9nbw==", result);
Assert.Single(result.Split("<img", StringSplitOptions.None).Skip(1).ToArray());
}
// Test 4: only ClientLogo — contains client img tag, no flex spacer div
[Fact]
public void BuildBrandingHeader_OnlyClientLogo_ReturnsHtmlWithOneImgNoSpacer()
{
var client = MakeLogo("image/jpeg", "Y2xpZW50bG9nbw==");
var branding = new ReportBranding(null, client);
var result = BrandingHtmlHelper.BuildBrandingHeader(branding);
Assert.Contains("data:image/jpeg;base64,Y2xpZW50bG9nbw==", result);
Assert.Single(result.Split("<img", StringSplitOptions.None).Skip(1).ToArray());
Assert.DoesNotContain("flex:1", result);
}
// Test 5: both logos — two img tags and a flex spacer div between them
[Fact]
public void BuildBrandingHeader_BothLogos_ReturnsHtmlWithTwoImgsAndSpacer()
{
var msp = MakeLogo("image/png", "bXNw");
var client = MakeLogo("image/jpeg", "Y2xpZW50");
var branding = new ReportBranding(msp, client);
var result = BrandingHtmlHelper.BuildBrandingHeader(branding);
Assert.Contains("data:image/png;base64,bXNw", result);
Assert.Contains("data:image/jpeg;base64,Y2xpZW50", result);
Assert.Equal(2, result.Split("<img", StringSplitOptions.None).Length - 1);
Assert.Contains("flex:1", result);
}
// Test 6: img tags use inline data-URI format
[Fact]
public void BuildBrandingHeader_WithMspLogo_UsesDataUriFormat()
{
var msp = MakeLogo("image/png", "dGVzdA==");
var branding = new ReportBranding(msp, null);
var result = BrandingHtmlHelper.BuildBrandingHeader(branding);
Assert.Contains("src=\"data:image/png;base64,dGVzdA==\"", result);
}
// Test 7: img tags have max-height:60px and max-width:200px styles
[Fact]
public void BuildBrandingHeader_WithLogo_ImgHasCorrectDimensions()
{
var msp = MakeLogo();
var branding = new ReportBranding(msp, null);
var result = BrandingHtmlHelper.BuildBrandingHeader(branding);
Assert.Contains("max-height:60px", result);
Assert.Contains("max-width:200px", result);
}
// Test 8: outer div uses display:flex;gap:16px;align-items:center
[Fact]
public void BuildBrandingHeader_WithLogo_OuterDivUsesFlexLayout()
{
var msp = MakeLogo();
var branding = new ReportBranding(msp, null);
var result = BrandingHtmlHelper.BuildBrandingHeader(branding);
Assert.Contains("display:flex", result);
Assert.Contains("gap:16px", result);
Assert.Contains("align-items:center", result);
}
}
@@ -1,71 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services.Export;
/// <summary>
/// Tests for PERM-05: CSV export output.
/// These tests reference CsvExportService which will be implemented in Plan 03.
/// Until Plan 03 runs they will fail to compile — that is expected.
/// </summary>
public class CsvExportServiceTests
{
private static PermissionEntry MakeEntry(
string objectType, string title, string url,
bool hasUnique, string users, string userLogins,
string permissionLevels, string grantedThrough, string principalType) =>
new(objectType, title, url, hasUnique, users, userLogins, permissionLevels, grantedThrough, principalType);
[Fact]
public void BuildCsv_WithKnownEntries_ProducesHeaderRow()
{
var entry = MakeEntry("Web", "Site A", "https://contoso.sharepoint.com/sites/A",
true, "alice@contoso.com", "i:0#.f|membership|alice@contoso.com",
"Contribute", "Direct Permissions", "User");
var svc = new CsvExportService();
var csv = svc.BuildCsv(new[] { entry });
Assert.Contains("Object", csv);
Assert.Contains("Title", csv);
Assert.Contains("URL", csv);
Assert.Contains("HasUniquePermissions", csv);
Assert.Contains("Users", csv);
Assert.Contains("UserLogins", csv);
Assert.Contains("Type", csv);
Assert.Contains("Permissions", csv);
Assert.Contains("GrantedThrough", csv);
}
[Fact]
public void BuildCsv_WithEmptyList_ReturnsHeaderOnly()
{
var svc = new CsvExportService();
var csv = svc.BuildCsv(Array.Empty<PermissionEntry>());
// Should have exactly one line (header) or header + empty body
Assert.NotEmpty(csv);
Assert.Contains("Object", csv);
}
[Fact]
public void BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations()
{
// PERM-05 Merge-PermissionRows: two entries with same Users+PermissionLevels+GrantedThrough
// but different URLs must be merged into one row with URLs pipe-joined.
var entryA = MakeEntry("Web", "Site A", "https://contoso.sharepoint.com/sites/A",
true, "alice@contoso.com", "i:0#.f|membership|alice@contoso.com",
"Contribute", "Direct Permissions", "User");
var entryB = MakeEntry("Web", "Site B", "https://contoso.sharepoint.com/sites/B",
true, "alice@contoso.com", "i:0#.f|membership|alice@contoso.com",
"Contribute", "Direct Permissions", "User");
var svc = new CsvExportService();
var csv = svc.BuildCsv(new[] { entryA, entryB });
// Merged row must contain both URLs separated by " | "
Assert.Contains("sites/A", csv);
Assert.Contains("sites/B", csv);
Assert.Contains("|", csv);
}
}
@@ -1,71 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class DuplicatesHtmlExportServiceTests
{
private static ReportBranding MakeBranding(bool msp = true, bool client = false)
{
var mspLogo = msp ? new LogoData { Base64 = "bXNw", MimeType = "image/png" } : null;
var clientLogo = client ? new LogoData { Base64 = "Y2xpZW50", MimeType = "image/jpeg" } : null;
return new ReportBranding(mspLogo, clientLogo);
}
private static DuplicateGroup MakeGroup(string name, int count) => new()
{
GroupKey = $"{name}|1024",
Name = name,
Items = Enumerable.Range(1, count).Select(i => new DuplicateItem
{
Name = name,
Path = $"https://contoso.sharepoint.com/sites/Site{i}/{name}",
Library = "Shared Documents",
SizeBytes = 1024
}).ToList()
};
[Fact]
public void BuildHtml_WithGroups_ContainsGroupCards()
{
var svc = new DuplicatesHtmlExportService();
var groups = new List<DuplicateGroup> { MakeGroup("report.docx", 3) };
var html = svc.BuildHtml(groups);
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("report.docx", html);
}
[Fact]
public void BuildHtml_WithMultipleGroups_AllGroupNamesPresent()
{
var svc = new DuplicatesHtmlExportService();
var groups = new List<DuplicateGroup>
{
MakeGroup("budget.xlsx", 2),
MakeGroup("photo.jpg", 4)
};
var html = svc.BuildHtml(groups);
Assert.Contains("budget.xlsx", html);
Assert.Contains("photo.jpg", html);
}
[Fact]
public void BuildHtml_WithEmptyList_ReturnsValidHtml()
{
var svc = new DuplicatesHtmlExportService();
var html = svc.BuildHtml(new List<DuplicateGroup>());
Assert.Contains("<!DOCTYPE html>", html);
}
// ── Branding tests ────────────────────────────────────────────────────────
[Fact]
public void BuildHtml_WithBranding_ContainsLogoImg()
{
var svc = new DuplicatesHtmlExportService();
var groups = new List<DuplicateGroup> { MakeGroup("report.docx", 2) };
var html = svc.BuildHtml(groups, MakeBranding(msp: true));
Assert.Contains("data:image/png;base64,bXNw", html);
}
}
@@ -1,171 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services.Export;
/// <summary>
/// Tests for PERM-06: HTML export output.
/// </summary>
public class HtmlExportServiceTests
{
private static PermissionEntry MakeEntry(
string users, string userLogins,
string url = "https://contoso.sharepoint.com/sites/A",
string principalType = "User") =>
new("Web", "Site A", url, true, users, userLogins, "Read", "Direct Permissions", principalType);
private static ReportBranding MakeBranding(bool msp = true, bool client = false)
{
var mspLogo = msp ? new LogoData { Base64 = "bXNw", MimeType = "image/png" } : null;
var clientLogo = client ? new LogoData { Base64 = "Y2xpZW50", MimeType = "image/jpeg" } : null;
return new ReportBranding(mspLogo, clientLogo);
}
private static IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>> MakeGroupMembers(
string groupName, params ResolvedMember[] members) =>
new Dictionary<string, IReadOnlyList<ResolvedMember>>(StringComparer.OrdinalIgnoreCase)
{
[groupName] = members.ToList()
};
[Fact]
public void BuildHtml_WithKnownEntries_ContainsUserNames()
{
var entry = MakeEntry("Bob Smith", "bob@contoso.com");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry });
Assert.Contains("Bob Smith", html);
}
[Fact]
public void BuildHtml_WithEmptyList_ReturnsValidHtml()
{
var svc = new HtmlExportService();
var html = svc.BuildHtml(Array.Empty<PermissionEntry>());
// Must be non-empty well-formed HTML even with no data rows
Assert.NotEmpty(html);
Assert.Contains("<html", html, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void BuildHtml_WithExternalUser_ContainsExtHashMarker()
{
// External users have #EXT# in their login — HTML output should make them distinguishable
var entry = MakeEntry(
users: "Ext User",
userLogins: "ext_user_domain.com#EXT#@contoso.onmicrosoft.com");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry });
// The HTML should surface the external marker so admins can identify guests
Assert.Contains("EXT", html, StringComparison.OrdinalIgnoreCase);
}
// ── Branding tests ────────────────────────────────────────────────────────
[Fact]
public void BuildHtml_WithMspBranding_ContainsMspLogoImg()
{
var entry = MakeEntry("Test", "test@contoso.com");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, MakeBranding(msp: true, client: false));
Assert.Contains("data:image/png;base64,bXNw", html);
}
[Fact]
public void BuildHtml_WithNullBranding_ContainsNoLogoImg()
{
var entry = MakeEntry("Test", "test@contoso.com");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry });
Assert.DoesNotContain("data:image/png;base64,", html);
}
[Fact]
public void BuildHtml_WithBothLogos_ContainsTwoImgs()
{
var entry = MakeEntry("Test", "test@contoso.com");
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, MakeBranding(msp: true, client: true));
Assert.Contains("data:image/png;base64,bXNw", html);
Assert.Contains("data:image/jpeg;base64,Y2xpZW50", html);
}
// ── Group expansion tests (Phase 17) ──────────────────────────────────────
[Fact]
public void BuildHtml_NoGroupMembers_IdenticalToDefault()
{
var entry = MakeEntry("Site Members", "i:0#.f|membership|group@contoso.com", principalType: "SharePointGroup");
var svc = new HtmlExportService();
var htmlDefault = svc.BuildHtml(new[] { entry });
var htmlNullNull = svc.BuildHtml(new[] { entry }, null, null);
Assert.Equal(htmlDefault, htmlNullNull);
}
[Fact]
public void BuildHtml_WithGroupMembers_RendersExpandablePill()
{
var entry = MakeEntry("Site Members", "i:0#.f|membership|group@contoso.com", principalType: "SharePointGroup");
var groupMembers = MakeGroupMembers("Site Members", new ResolvedMember("Alice", "alice@co.com"));
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html);
Assert.Contains("class=\"user-pill group-expandable\"", html);
}
[Fact]
public void BuildHtml_WithGroupMembers_RendersHiddenMemberSubRow()
{
var entry = MakeEntry("Site Members", "i:0#.f|membership|group@contoso.com", principalType: "SharePointGroup");
var groupMembers = MakeGroupMembers("Site Members", new ResolvedMember("Alice", "alice@co.com"));
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
Assert.Contains("data-group=\"grpmem0\"", html);
Assert.Contains("display:none", html);
Assert.Contains("Alice", html);
Assert.Contains("alice@co.com", html);
}
[Fact]
public void BuildHtml_WithEmptyMemberList_RendersMembersUnavailable()
{
var entry = MakeEntry("Site Members", "i:0#.f|membership|group@contoso.com", principalType: "SharePointGroup");
var groupMembers = MakeGroupMembers("Site Members"); // empty list
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
Assert.Contains("members unavailable", html);
}
[Fact]
public void BuildHtml_ContainsToggleGroupJs()
{
var entry = MakeEntry("Site Members", "i:0#.f|membership|group@contoso.com", principalType: "SharePointGroup");
var groupMembers = MakeGroupMembers("Site Members", new ResolvedMember("Alice", "alice@co.com"));
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { entry }, null, groupMembers);
Assert.Contains("function toggleGroup", html);
}
[Fact]
public void BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill()
{
var innerEntry = new PermissionEntry("Web", "Site A", "https://contoso.sharepoint.com/sites/A",
true, "Site Members", "i:0#.f|membership|group@contoso.com", "Read", "Direct Permissions", "SharePointGroup");
var simplifiedEntry = new SimplifiedPermissionEntry(innerEntry);
var groupMembers = MakeGroupMembers("Site Members", new ResolvedMember("Bob", "bob@co.com"));
var svc = new HtmlExportService();
var html = svc.BuildHtml(new[] { simplifiedEntry }, null, groupMembers);
Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html);
Assert.Contains("class=\"user-pill group-expandable\"", html);
}
}
@@ -1,99 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class SearchExportServiceTests
{
private static SearchResult MakeSample() => new()
{
Title = "Q1 Budget.xlsx",
Path = "https://contoso.sharepoint.com/sites/Finance/Shared Documents/Q1 Budget.xlsx",
FileExtension = "xlsx",
Created = new DateTime(2024, 1, 10),
LastModified = new DateTime(2024, 3, 20),
Author = "Alice Smith",
ModifiedBy = "Bob Jones",
SizeBytes = 48_000
};
// -- CSV tests -----------------------------------------------------------
[Fact]
public void BuildCsv_WithKnownResults_ContainsExpectedHeader()
{
var svc = new SearchCsvExportService();
var csv = svc.BuildCsv(new List<SearchResult> { MakeSample() });
Assert.Contains("File Name", csv);
Assert.Contains("Extension", csv);
Assert.Contains("Created", csv);
Assert.Contains("Created By", csv);
Assert.Contains("Modified By", csv);
Assert.Contains("Size", csv);
}
[Fact]
public void BuildCsv_WithEmptyList_ReturnsHeaderOnly()
{
var svc = new SearchCsvExportService();
var csv = svc.BuildCsv(new List<SearchResult>());
Assert.NotEmpty(csv);
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Single(lines);
}
[Fact]
public void BuildCsv_ResultValues_AppearInOutput()
{
var svc = new SearchCsvExportService();
var csv = svc.BuildCsv(new List<SearchResult> { MakeSample() });
Assert.Contains("Alice Smith", csv);
Assert.Contains("xlsx", csv);
}
// -- HTML tests ----------------------------------------------------------
[Fact]
public void BuildHtml_WithResults_ContainsSortableColumnScript()
{
var svc = new SearchHtmlExportService();
var html = svc.BuildHtml(new List<SearchResult> { MakeSample() });
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("sort", html); // sortable columns JS
Assert.Contains("Q1 Budget.xlsx", html);
}
[Fact]
public void BuildHtml_WithResults_ContainsFilterInput()
{
var svc = new SearchHtmlExportService();
var html = svc.BuildHtml(new List<SearchResult> { MakeSample() });
Assert.Contains("filter", html); // filter input element
}
[Fact]
public void BuildHtml_WithEmptyList_ReturnsValidHtml()
{
var svc = new SearchHtmlExportService();
var html = svc.BuildHtml(new List<SearchResult>());
Assert.Contains("<!DOCTYPE html>", html);
}
// ── Branding tests ────────────────────────────────────────────────────────
private static ReportBranding MakeBranding(bool msp = true, bool client = false)
{
var mspLogo = msp ? new LogoData { Base64 = "bXNw", MimeType = "image/png" } : null;
var clientLogo = client ? new LogoData { Base64 = "Y2xpZW50", MimeType = "image/jpeg" } : null;
return new ReportBranding(mspLogo, clientLogo);
}
[Fact]
public void BuildHtml_WithBranding_ContainsLogoImg()
{
var svc = new SearchHtmlExportService();
var html = svc.BuildHtml(new List<SearchResult> { MakeSample() }, MakeBranding(msp: true));
Assert.Contains("data:image/png;base64,bXNw", html);
}
}
@@ -1,52 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class StorageCsvExportServiceTests
{
[Fact]
public void BuildCsv_WithKnownNodes_ProducesHeaderRow()
{
var svc = new StorageCsvExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "MySite",
TotalSizeBytes = 1024, FileStreamSizeBytes = 800, TotalFileCount = 5,
LastModified = new DateTime(2024, 1, 15) }
};
var csv = svc.BuildCsv(nodes);
Assert.Contains("Library", csv);
Assert.Contains("Site", csv);
Assert.Contains("Files", csv);
Assert.Contains("Total Size", csv);
Assert.Contains("Version Size", csv);
Assert.Contains("Last Modified", csv);
}
[Fact]
public void BuildCsv_WithEmptyList_ReturnsHeaderOnly()
{
var svc = new StorageCsvExportService();
var csv = svc.BuildCsv(new List<StorageNode>());
Assert.NotEmpty(csv); // must have at least the header row
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Single(lines); // only header, no data rows
}
[Fact]
public void BuildCsv_NodeValues_AppearInOutput()
{
var svc = new StorageCsvExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Reports", Library = "Reports", SiteTitle = "ProjectSite",
TotalSizeBytes = 2048, FileStreamSizeBytes = 1024, TotalFileCount = 10 }
};
var csv = svc.BuildCsv(nodes);
Assert.Contains("Reports", csv);
Assert.Contains("ProjectSite", csv);
Assert.Contains("10", csv);
}
}
@@ -1,71 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class StorageHtmlExportServiceTests
{
private static ReportBranding MakeBranding(bool msp = true, bool client = false)
{
var mspLogo = msp ? new LogoData { Base64 = "bXNw", MimeType = "image/png" } : null;
var clientLogo = client ? new LogoData { Base64 = "Y2xpZW50", MimeType = "image/jpeg" } : null;
return new ReportBranding(mspLogo, clientLogo);
}
[Fact]
public void BuildHtml_WithNodes_ContainsToggleJs()
{
var svc = new StorageHtmlExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "Site1",
TotalSizeBytes = 5000, FileStreamSizeBytes = 4000, TotalFileCount = 20,
Children = new List<StorageNode>
{
new() { Name = "Archive", Library = "Shared Documents", SiteTitle = "Site1",
TotalSizeBytes = 1000, FileStreamSizeBytes = 800, TotalFileCount = 5 }
} }
};
var html = svc.BuildHtml(nodes);
Assert.Contains("toggle(", html);
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("Shared Documents", html);
}
[Fact]
public void BuildHtml_WithEmptyList_ReturnsValidHtml()
{
var svc = new StorageHtmlExportService();
var html = svc.BuildHtml(new List<StorageNode>());
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("<html", html);
}
[Fact]
public void BuildHtml_WithMultipleLibraries_EachLibraryAppearsInOutput()
{
var svc = new StorageHtmlExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Documents", Library = "Documents", SiteTitle = "Site1", TotalSizeBytes = 1000 },
new() { Name = "Images", Library = "Images", SiteTitle = "Site1", TotalSizeBytes = 2000 }
};
var html = svc.BuildHtml(nodes);
Assert.Contains("Documents", html);
Assert.Contains("Images", html);
}
// ── Branding tests ────────────────────────────────────────────────────────
[Fact]
public void BuildHtml_WithBranding_ContainsLogoImg()
{
var svc = new StorageHtmlExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Documents", Library = "Documents", SiteTitle = "Site1", TotalSizeBytes = 1000 }
};
var html = svc.BuildHtml(nodes, MakeBranding(msp: true));
Assert.Contains("data:image/png;base64,bXNw", html);
}
}
@@ -1,274 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services.Export;
/// <summary>
/// Unit tests for UserAccessCsvExportService (Phase 7 Plan 08).
/// Verifies: summary section, column count, RFC 4180 escaping, per-user content.
/// </summary>
public class UserAccessCsvExportServiceTests
{
// ── Helper factory ────────────────────────────────────────────────────────
private static UserAccessEntry MakeEntry(
string userDisplay = "Alice Smith",
string userLogin = "alice@contoso.com",
string siteUrl = "https://contoso.sharepoint.com",
string siteTitle = "Contoso",
string objectType = "List",
string objectTitle = "Docs",
string objectUrl = "https://contoso.sharepoint.com/Docs",
string permLevel = "Read",
AccessType accessType = AccessType.Direct,
string grantedThrough = "Direct Permissions",
bool isHighPrivilege = false,
bool isExternal = false) =>
new(userDisplay, userLogin, siteUrl, siteTitle, objectType, objectTitle, objectUrl,
permLevel, accessType, grantedThrough, isHighPrivilege, isExternal);
private static readonly UserAccessEntry DefaultEntry = MakeEntry();
// ── Test 1: BuildCsv includes summary section ─────────────────────────────
[Fact]
public void BuildCsv_includes_summary_section()
{
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice Smith", "alice@contoso.com", new[] { DefaultEntry });
Assert.Contains("User Access Audit Report", csv);
Assert.Contains("Alice Smith", csv);
Assert.Contains("alice@contoso.com", csv);
Assert.Contains("Total Accesses", csv);
Assert.Contains("Sites", csv);
}
// ── Test 2: BuildCsv includes data header line ────────────────────────────
[Fact]
public void BuildCsv_includes_data_header()
{
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice Smith", "alice@contoso.com", new[] { DefaultEntry });
Assert.Contains("Site", csv);
Assert.Contains("Object Type", csv);
Assert.Contains("Object", csv);
Assert.Contains("Permission Level", csv);
Assert.Contains("Access Type", csv);
Assert.Contains("Granted Through", csv);
}
// ── Test 3: BuildCsv escapes double quotes (RFC 4180) ─────────────────────
[Fact]
public void BuildCsv_escapes_quotes()
{
var entryWithQuotes = MakeEntry(objectTitle: "Document \"Template\" Library");
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { entryWithQuotes });
// RFC 4180: double quotes inside a quoted field are doubled
Assert.Contains("\"\"Template\"\"", csv);
}
// ── Test 4: BuildCsv data rows have correct column count ──────────────────
[Fact]
public void BuildCsv_correct_column_count()
{
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry });
// Find the header row and count its quoted comma-separated fields
// Header is: "Site","Object Type","Object","URL","Permission Level","Access Type","Granted Through"
// That is 7 fields.
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Find a data row (after the blank line separating summary from data)
// Data rows contain the entry content (not the header line itself)
// We want to count fields in the header row:
var headerLine = lines.FirstOrDefault(l => l.Contains("\"Site\",\"Object Type\""));
Assert.NotNull(headerLine);
// Count comma-separated quoted fields: split by "," boundary
var fields = CountCsvFields(headerLine!);
Assert.Equal(7, fields);
}
// ── Test 5: WriteSingleFileAsync includes entries for all users ───────────
[Fact]
public async Task WriteSingleFileAsync_includes_all_users()
{
var alice = MakeEntry(userDisplay: "Alice", userLogin: "alice@contoso.com");
var bob = MakeEntry(userDisplay: "Bob", userLogin: "bob@contoso.com");
var svc = new UserAccessCsvExportService();
var tmpFile = Path.GetTempFileName();
try
{
await svc.WriteSingleFileAsync(new[] { alice, bob }, tmpFile, CancellationToken.None);
var content = await File.ReadAllTextAsync(tmpFile);
Assert.Contains("alice@contoso.com", content);
Assert.Contains("bob@contoso.com", content);
Assert.Contains("Users Audited", content);
}
finally
{
File.Delete(tmpFile);
}
}
// ── RPT-03-f: mergePermissions=false produces identical output to default ──
[Fact]
public async Task WriteSingleFileAsync_mergePermissionsfalse_produces_identical_output()
{
var alice1 = MakeEntry(userLogin: "alice@contoso.com", siteTitle: "Contoso", permLevel: "Read");
var alice2 = MakeEntry(userLogin: "alice@contoso.com", siteTitle: "Dev Site", permLevel: "Read",
siteUrl: "https://contoso.sharepoint.com/sites/dev", objectUrl: "https://contoso.sharepoint.com/sites/dev/Docs");
var bob = MakeEntry(userDisplay: "Bob Smith", userLogin: "bob@contoso.com", permLevel: "Contribute");
var entries = new[] { alice1, alice2, bob };
var svc = new UserAccessCsvExportService();
var tmpDefault = Path.GetTempFileName();
var tmpExplicit = Path.GetTempFileName();
try
{
// Default call (no mergePermissions param)
await svc.WriteSingleFileAsync(entries, tmpDefault, CancellationToken.None);
// Explicit mergePermissions=false
await svc.WriteSingleFileAsync(entries, tmpExplicit, CancellationToken.None, mergePermissions: false);
var defaultContent = await File.ReadAllBytesAsync(tmpDefault);
var explicitContent = await File.ReadAllBytesAsync(tmpExplicit);
Assert.Equal(defaultContent, explicitContent);
}
finally
{
File.Delete(tmpDefault);
File.Delete(tmpExplicit);
}
}
// ── RPT-03-g: mergePermissions=true writes consolidated rows ──────────────
[Fact]
public async Task WriteSingleFileAsync_mergePermissionstrue_writes_consolidated_rows()
{
// alice has 2 entries with same key (same login, permLevel, accessType, grantedThrough)
// they should be merged into 1 row with 2 locations
var alice1 = MakeEntry(
userDisplay: "Alice Smith", userLogin: "alice@contoso.com",
siteUrl: "https://contoso.sharepoint.com", siteTitle: "Contoso",
permLevel: "Read", accessType: AccessType.Direct, grantedThrough: "Direct Permissions");
var alice2 = MakeEntry(
userDisplay: "Alice Smith", userLogin: "alice@contoso.com",
siteUrl: "https://dev.sharepoint.com", siteTitle: "Dev Site",
objectUrl: "https://dev.sharepoint.com/Docs",
permLevel: "Read", accessType: AccessType.Direct, grantedThrough: "Direct Permissions");
// bob has a different key — separate row
var bob = MakeEntry(
userDisplay: "Bob Smith", userLogin: "bob@contoso.com",
siteTitle: "Contoso", permLevel: "Contribute",
accessType: AccessType.Direct, grantedThrough: "Direct Permissions");
var entries = new[] { alice1, alice2, bob };
var svc = new UserAccessCsvExportService();
var tmpFile = Path.GetTempFileName();
try
{
await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true);
var content = await File.ReadAllTextAsync(tmpFile);
// Header must contain consolidated columns
Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content);
// Alice's two entries merged — locations column contains both site titles
Assert.Contains("Contoso", content);
Assert.Contains("Dev Site", content);
// Bob appears as a separate row
Assert.Contains("bob@contoso.com", content);
// The consolidated report label should appear
Assert.Contains("User Access Audit Report (Consolidated)", content);
}
finally
{
File.Delete(tmpFile);
}
}
// ── RPT-03-g edge case: single-location consolidated entry ────────────────
[Fact]
public async Task WriteSingleFileAsync_mergePermissionstrue_singleLocation_noSemicolon()
{
var entry = MakeEntry(
userDisplay: "Alice Smith", userLogin: "alice@contoso.com",
siteTitle: "Contoso", permLevel: "Read",
accessType: AccessType.Direct, grantedThrough: "Direct Permissions");
var svc = new UserAccessCsvExportService();
var tmpFile = Path.GetTempFileName();
try
{
await svc.WriteSingleFileAsync(new[] { entry }, tmpFile, CancellationToken.None, mergePermissions: true);
var content = await File.ReadAllTextAsync(tmpFile);
// Should contain exactly "1" as LocationCount
Assert.Contains("\"1\"", content);
// Locations field for a single entry should not contain a semicolon
// Find the data row for alice and verify no semicolon in Locations
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var dataRow = lines.FirstOrDefault(l => l.Contains("alice@contoso.com") && !l.StartsWith("\"Users"));
Assert.NotNull(dataRow);
// The Locations column value is "Contoso" with no semicolons
Assert.DoesNotContain("Contoso; ", dataRow);
}
finally
{
File.Delete(tmpFile);
}
}
// ── Private helpers ───────────────────────────────────────────────────────
/// <summary>
/// Counts the number of comma-separated fields in a CSV line by stripping
/// surrounding quotes from each field.
/// </summary>
private static int CountCsvFields(string line)
{
// Simple RFC 4180 field counter — works for well-formed quoted fields
int count = 1;
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
i++; // skip escaped quote
else
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
count++;
}
}
return count;
}
}
@@ -1,237 +0,0 @@
using System.Collections.Generic;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services.Export;
/// <summary>
/// Unit tests for UserAccessHtmlExportService (Phase 7 Plan 08).
/// Verifies: DOCTYPE, stats cards, dual-view sections, access type badges,
/// filter script, toggle script, HTML entity encoding.
/// </summary>
public class UserAccessHtmlExportServiceTests
{
// ── Helper factory ────────────────────────────────────────────────────────
private static ReportBranding MakeBranding(bool msp = true, bool client = false)
{
var mspLogo = msp ? new LogoData { Base64 = "bXNw", MimeType = "image/png" } : null;
var clientLogo = client ? new LogoData { Base64 = "Y2xpZW50", MimeType = "image/jpeg" } : null;
return new ReportBranding(mspLogo, clientLogo);
}
private static UserAccessEntry MakeEntry(
string userDisplay = "Alice Smith",
string userLogin = "alice@contoso.com",
string siteUrl = "https://contoso.sharepoint.com",
string siteTitle = "Contoso",
string objectType = "List",
string objectTitle = "Docs",
string objectUrl = "https://contoso.sharepoint.com/Docs",
string permLevel = "Read",
AccessType accessType = AccessType.Direct,
string grantedThrough = "Direct Permissions",
bool isHighPrivilege = false,
bool isExternal = false) =>
new(userDisplay, userLogin, siteUrl, siteTitle, objectType, objectTitle, objectUrl,
permLevel, accessType, grantedThrough, isHighPrivilege, isExternal);
private static readonly UserAccessEntry DefaultEntry = MakeEntry();
// ── Test 1: BuildHtml contains DOCTYPE ───────────────────────────────────
[Fact]
public void BuildHtml_contains_doctype()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.StartsWith("<!DOCTYPE html>", html.TrimStart());
}
// ── Test 2: BuildHtml has stats cards ─────────────────────────────────────
[Fact]
public void BuildHtml_has_stats_cards()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.Contains("Total Accesses", html);
Assert.Contains("stat-card", html);
}
// ── Test 3: BuildHtml has both view sections ──────────────────────────────
[Fact]
public void BuildHtml_has_both_views()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
// By-user view
Assert.Contains("view-user", html);
// By-site view
Assert.Contains("view-site", html);
}
// ── Test 4: BuildHtml has access type badge CSS classes ───────────────────
[Fact]
public void BuildHtml_has_access_type_badges()
{
var entries = new List<UserAccessEntry>
{
MakeEntry(accessType: AccessType.Direct),
MakeEntry(userLogin: "bob@contoso.com", accessType: AccessType.Group),
MakeEntry(userLogin: "carol@contoso.com", accessType: AccessType.Inherited)
};
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries);
Assert.Contains("access-direct", html);
Assert.Contains("access-group", html);
Assert.Contains("access-inherited", html);
}
// ── Test 5: BuildHtml has filterTable JS function ─────────────────────────
[Fact]
public void BuildHtml_has_filter_script()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.Contains("filterTable", html);
}
// ── Test 6: BuildHtml has toggleView JS function ──────────────────────────
[Fact]
public void BuildHtml_has_toggle_script()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.Contains("toggleView", html);
}
// ── Test 7: BuildHtml encodes HTML entities ───────────────────────────────
[Fact]
public void BuildHtml_encodes_html_entities()
{
var entryWithScript = MakeEntry(objectTitle: "<script>alert('xss')</script>");
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { entryWithScript });
// Raw script tag must not appear verbatim
Assert.DoesNotContain("<script>alert", html);
// Encoded form must be present
Assert.Contains("&lt;script&gt;", html);
}
// ── Branding tests ────────────────────────────────────────────────────────
[Fact]
public void BuildHtml_WithBranding_ContainsLogoImg()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry }, branding: MakeBranding(msp: true));
Assert.Contains("data:image/png;base64,bXNw", html);
}
// ── Consolidation tests (RPT-03-b through RPT-03-e) ──────────────────────
// Shared test data: 3 entries where 2 share the same consolidation key
private static IReadOnlyList<UserAccessEntry> MakeConsolidationTestEntries()
{
// Entry 1 + Entry 2: same user/permission/accesstype/grantedthrough, different sites
var e1 = MakeEntry(
userDisplay: "Bob Jones",
userLogin: "bob@contoso.com",
siteUrl: "https://contoso.sharepoint.com/sites/Alpha",
siteTitle: "Alpha Site",
permLevel: "Contribute",
accessType: AccessType.Direct,
grantedThrough: "Direct Permissions");
var e2 = MakeEntry(
userDisplay: "Bob Jones",
userLogin: "bob@contoso.com",
siteUrl: "https://contoso.sharepoint.com/sites/Beta",
siteTitle: "Beta Site",
permLevel: "Contribute",
accessType: AccessType.Direct,
grantedThrough: "Direct Permissions");
// Entry 3: different user — will have 1 location
var e3 = MakeEntry(
userDisplay: "Carol Davis",
userLogin: "carol@contoso.com",
siteUrl: "https://contoso.sharepoint.com/sites/Gamma",
siteTitle: "Gamma Site",
permLevel: "Read",
accessType: AccessType.Group,
grantedThrough: "Readers Group");
return new[] { e1, e2, e3 };
}
// RPT-03-b: BuildHtml(entries, mergePermissions: false) is byte-identical to BuildHtml(entries)
[Fact]
public void BuildHtml_mergePermissionsFalse_identical_to_default()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var defaultOutput = svc.BuildHtml(entries);
var explicitFalse = svc.BuildHtml(entries, mergePermissions: false);
Assert.Equal(defaultOutput, explicitFalse);
}
// RPT-03-c: BuildHtml(entries, mergePermissions: true) contains "Sites" column header and consolidated content
[Fact]
public void BuildHtml_mergePermissionsTrue_contains_sites_column()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries, mergePermissions: true);
Assert.Contains("Sites", html);
// Consolidated rows present for both users
Assert.Contains("Bob Jones", html);
Assert.Contains("Carol Davis", html);
}
// RPT-03-d: 2+ locations produce [N sites] badge with toggleGroup and hidden sub-rows
[Fact]
public void BuildHtml_mergePermissionsTrue_multiLocation_has_badge_and_subrows()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries, mergePermissions: true);
// Badge with onclick
Assert.Contains("onclick=\"toggleGroup('loc", html);
// Hidden sub-rows
Assert.Contains("data-group=\"loc", html);
}
// RPT-03-e: mergePermissions=true omits "By Site" button and view-site div
[Fact]
public void BuildHtml_mergePermissionsTrue_omits_bysite_view()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries, mergePermissions: true);
Assert.DoesNotContain("btn-site", html);
Assert.DoesNotContain("view-site", html);
}
}
@@ -1,57 +0,0 @@
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<IFileTransferService>(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<ConflictPolicy>().Length);
Assert.Contains(ConflictPolicy.Skip, Enum.GetValues<ConflictPolicy>());
Assert.Contains(ConflictPolicy.Overwrite, Enum.GetValues<ConflictPolicy>());
Assert.Contains(ConflictPolicy.Rename, Enum.GetValues<ConflictPolicy>());
}
[Fact]
public void TransferMode_HasAllValues()
{
Assert.Equal(2, Enum.GetValues<TransferMode>().Length);
Assert.Contains(TransferMode.Copy, Enum.GetValues<TransferMode>());
Assert.Contains(TransferMode.Move, Enum.GetValues<TransferMode>());
}
[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
}
}
@@ -1,89 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
public class FolderStructureServiceTests
{
[Fact]
public void FolderStructureService_Implements_IFolderStructureService()
{
Assert.True(typeof(IFolderStructureService).IsAssignableFrom(typeof(FolderStructureService)));
}
[Fact]
public void BuildUniquePaths_FromExampleCsv_ReturnsParentFirst()
{
var rows = new List<FolderStructureRow>
{
new() { Level1 = "Administration", Level2 = "Comptabilite", Level3 = "Factures" },
new() { Level1 = "Administration", Level2 = "Comptabilite", Level3 = "Bilans" },
new() { Level1 = "Administration", Level2 = "Ressources Humaines" },
new() { Level1 = "Projets", Level2 = "Projet Alpha", Level3 = "Documents" },
};
var paths = FolderStructureService.BuildUniquePaths(rows);
// Should contain unique paths, parent-first
Assert.Contains("Administration", paths);
Assert.Contains("Administration/Comptabilite", paths);
Assert.Contains("Administration/Comptabilite/Factures", paths);
Assert.Contains("Administration/Comptabilite/Bilans", paths);
Assert.Contains("Projets", paths);
Assert.Contains("Projets/Projet Alpha", paths);
// Parent-first: "Administration" before "Administration/Comptabilite"
var adminIdx = paths.ToList().IndexOf("Administration");
var compIdx = paths.ToList().IndexOf("Administration/Comptabilite");
Assert.True(adminIdx < compIdx);
}
[Fact]
public void BuildUniquePaths_DuplicateRows_Deduplicated()
{
var rows = new List<FolderStructureRow>
{
new() { Level1 = "A", Level2 = "B" },
new() { Level1 = "A", Level2 = "B" },
new() { Level1 = "A", Level2 = "C" },
};
var paths = FolderStructureService.BuildUniquePaths(rows);
Assert.Equal(3, paths.Count); // A, A/B, A/C (deduplicated)
}
[Fact]
public void BuildUniquePaths_EmptyLevels_StopsAtLastNonEmpty()
{
var rows = new List<FolderStructureRow>
{
new() { Level1 = "Root", Level2 = "", Level3 = "", Level4 = "" },
};
var paths = FolderStructureService.BuildUniquePaths(rows);
Assert.Single(paths);
Assert.Equal("Root", paths[0]);
}
[Fact]
public void FolderStructureRow_BuildPath_ReturnsCorrectPath()
{
var row = new FolderStructureRow
{
Level1 = "Admin",
Level2 = "HR",
Level3 = "Contracts",
Level4 = ""
};
Assert.Equal("Admin/HR/Contracts", row.BuildPath());
}
[Fact(Skip = "Requires live SharePoint tenant")]
public async Task CreateFoldersAsync_ValidRows_CreatesFolders()
{
await Task.CompletedTask;
}
}
@@ -1,192 +0,0 @@
using Microsoft.Graph.Models;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Unit tests for <see cref="GraphUserDirectoryService"/> (Phase 10 Plan 02).
///
/// Testing strategy: GraphUserDirectoryService wraps Microsoft Graph SDK's PageIterator,
/// whose constructor is internal and cannot be mocked without a real GraphServiceClient.
/// Full pagination/cancellation tests therefore require integration-level setup.
///
/// We test what IS unit-testable:
/// 1. MapUser — the static mapping method that converts a Graph User to GraphDirectoryUser.
/// This covers all 5 required fields and the DisplayName fallback logic.
/// 2. GetUsersAsync integration paths are documented with Skip tests that explain the
/// constraint and serve as living documentation of intended behaviour.
/// </summary>
[Trait("Category", "Unit")]
public class GraphUserDirectoryServiceTests
{
// ── MapUser: field mapping ────────────────────────────────────────────────
[Fact]
public void MapUser_AllFieldsPresent_MapsCorrectly()
{
var user = new User
{
DisplayName = "Alice Smith",
UserPrincipalName = "alice@contoso.com",
Mail = "alice@contoso.com",
Department = "Engineering",
JobTitle = "Senior Developer",
UserType = "Member"
};
var result = GraphUserDirectoryService.MapUser(user);
Assert.Equal("Alice Smith", result.DisplayName);
Assert.Equal("alice@contoso.com", result.UserPrincipalName);
Assert.Equal("alice@contoso.com", result.Mail);
Assert.Equal("Engineering", result.Department);
Assert.Equal("Senior Developer", result.JobTitle);
Assert.Equal("Member", result.UserType);
}
[Fact]
public void MapUser_NullDisplayName_FallsBackToUserPrincipalName()
{
var user = new User
{
DisplayName = null,
UserPrincipalName = "bob@contoso.com",
Mail = null,
Department = null,
JobTitle = null,
UserType = "Guest"
};
var result = GraphUserDirectoryService.MapUser(user);
Assert.Equal("bob@contoso.com", result.DisplayName);
Assert.Equal("bob@contoso.com", result.UserPrincipalName);
Assert.Null(result.Mail);
Assert.Null(result.Department);
Assert.Null(result.JobTitle);
Assert.Equal("Guest", result.UserType);
}
[Fact]
public void MapUser_NullDisplayNameAndNullUPN_FallsBackToEmptyString()
{
var user = new User
{
DisplayName = null,
UserPrincipalName = null,
Mail = null,
Department = null,
JobTitle = null
};
var result = GraphUserDirectoryService.MapUser(user);
Assert.Equal(string.Empty, result.DisplayName);
Assert.Equal(string.Empty, result.UserPrincipalName);
}
[Fact]
public void MapUser_NullUPN_ReturnsEmptyStringForUPN()
{
var user = new User
{
DisplayName = "Carol Jones",
UserPrincipalName = null,
Mail = "carol@contoso.com",
Department = "Marketing",
JobTitle = "Manager"
};
var result = GraphUserDirectoryService.MapUser(user);
Assert.Equal("Carol Jones", result.DisplayName);
Assert.Equal(string.Empty, result.UserPrincipalName);
Assert.Equal("carol@contoso.com", result.Mail);
}
[Fact]
public void MapUser_OptionalFieldsNull_ProducesNullableNullProperties()
{
var user = new User
{
DisplayName = "Dave Brown",
UserPrincipalName = "dave@contoso.com",
Mail = null,
Department = null,
JobTitle = null
};
var result = GraphUserDirectoryService.MapUser(user);
Assert.Null(result.Mail);
Assert.Null(result.Department);
Assert.Null(result.JobTitle);
}
// ── MapUser: UserType mapping ──────────────────────────────────────────────
[Fact]
public void MapUser_PopulatesUserType()
{
var user = new User
{
DisplayName = "Eve Wilson",
UserPrincipalName = "eve@contoso.com",
Mail = "eve@contoso.com",
Department = "Sales",
JobTitle = "Account Executive",
UserType = "Member"
};
var result = GraphUserDirectoryService.MapUser(user);
Assert.Equal("Member", result.UserType);
}
[Fact]
public void MapUser_NullUserType_ReturnsNull()
{
var user = new User
{
DisplayName = "Frank Lee",
UserPrincipalName = "frank@contoso.com",
Mail = null,
Department = null,
JobTitle = null,
UserType = null
};
var result = GraphUserDirectoryService.MapUser(user);
Assert.Null(result.UserType);
}
// ── GetUsersAsync: integration-level scenarios (skipped without live tenant) ──
[Fact(Skip = "Requires integration test with real Graph client — PageIterator.CreatePageIterator " +
"uses internal GraphServiceClient request execution that cannot be mocked via Moq. " +
"Intended behaviour: returns all users matching filter across all pages, " +
"correctly mapping all 5 fields per user.")]
public Task GetUsersAsync_SinglePage_ReturnsMappedUsers()
=> Task.CompletedTask;
[Fact(Skip = "Requires integration test with real Graph client. " +
"Intended behaviour: IProgress<int>.Report is called once per user " +
"with an incrementing count (1, 2, 3, ...).")]
public Task GetUsersAsync_ReportsProgressWithIncrementingCount()
=> Task.CompletedTask;
[Fact(Skip = "Requires integration test with real Graph client. " +
"Intended behaviour: when CancellationToken is cancelled during iteration, " +
"the callback returns false and iteration stops, returning partial results " +
"(or OperationCanceledException if cancellation fires before first page).")]
public Task GetUsersAsync_CancelledToken_StopsIteration()
=> Task.CompletedTask;
[Fact(Skip = "Requires integration test with real Graph client. " +
"Intended behaviour: when Graph returns null response, " +
"GetUsersAsync returns an empty IReadOnlyList without throwing.")]
public Task GetUsersAsync_NullResponse_ReturnsEmptyList()
=> Task.CompletedTask;
}
@@ -1,69 +0,0 @@
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
[Trait("Category", "Unit")]
public class OwnershipElevationServiceTests
{
[Fact]
public void OwnershipElevationService_ImplementsIOwnershipElevationService()
{
var service = new OwnershipElevationService();
Assert.IsAssignableFrom<IOwnershipElevationService>(service);
}
[Fact]
public void AppSettings_AutoTakeOwnership_DefaultsFalse()
{
var settings = new SharepointToolbox.Core.Models.AppSettings();
Assert.False(settings.AutoTakeOwnership);
}
[Fact]
public async Task AppSettings_AutoTakeOwnership_RoundTripsThroughJson()
{
var json = System.Text.Json.JsonSerializer.Serialize(
new SharepointToolbox.Core.Models.AppSettings { AutoTakeOwnership = true },
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
var loaded = System.Text.Json.JsonSerializer.Deserialize<SharepointToolbox.Core.Models.AppSettings>(json,
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
Assert.NotNull(loaded);
Assert.True(loaded!.AutoTakeOwnership);
}
[Fact]
public void PermissionEntry_WasAutoElevated_DefaultsFalse()
{
var entry = new SharepointToolbox.Core.Models.PermissionEntry(
"Site", "Title", "https://example.com", false,
"User", "user@example.com", "Read", "Direct Permissions", "User");
Assert.False(entry.WasAutoElevated);
}
[Fact]
public void PermissionEntry_WasAutoElevated_TrueWhenSet()
{
var entry = new SharepointToolbox.Core.Models.PermissionEntry(
"Site", "Title", "https://example.com", false,
"User", "user@example.com", "Read", "Direct Permissions", "User",
WasAutoElevated: true);
Assert.True(entry.WasAutoElevated);
}
[Fact]
public void PermissionEntry_WithExpression_CopiesWasAutoElevated()
{
var original = new SharepointToolbox.Core.Models.PermissionEntry(
"Site", "Title", "https://example.com", false,
"User", "user@example.com", "Read", "Direct Permissions", "User");
var elevated = original with { WasAutoElevated = true };
Assert.False(original.WasAutoElevated);
Assert.True(elevated.WasAutoElevated);
}
}
@@ -1,63 +0,0 @@
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Tests for PERM-03: external user detection and permission-level filtering.
/// Pure static logic — runs immediately without stubs.
/// </summary>
public class PermissionEntryClassificationTests
{
// ── IsExternalUser ─────────────────────────────────────────────────────────
[Fact]
public void IsExternalUser_WithExtHashInLoginName_ReturnsTrue()
{
// B2B guest login names contain the literal "#EXT#" fragment
Assert.True(PermissionEntryHelper.IsExternalUser("ext_user_domain.com#EXT#@contoso.onmicrosoft.com"));
}
[Fact]
public void IsExternalUser_WithNormalLoginName_ReturnsFalse()
{
Assert.False(PermissionEntryHelper.IsExternalUser("i:0#.f|membership|alice@contoso.com"));
}
// ── FilterPermissionLevels ─────────────────────────────────────────────────
[Fact]
public void PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess()
{
// A principal whose sole permission level is "Limited Access" should produce
// an empty list after filtering — used to decide whether to include the entry.
var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access" });
Assert.Empty(result);
}
[Fact]
public void FilterPermissionLevels_RetainsOtherLevels_WhenMixedWithLimitedAccess()
{
var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access", "Contribute" });
Assert.Equal(new[] { "Contribute" }, result);
}
// ── IsSharingLinksGroup ────────────────────────────────────────────────────
[Fact]
public void IsSharingLinksGroup_WithSharingLinksPrefix_ReturnsTrue()
{
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("SharingLinks.abc123.Edit"));
}
[Fact]
public void IsSharingLinksGroup_WithLimitedAccessSystemGroup_ReturnsTrue()
{
Assert.True(PermissionEntryHelper.IsSharingLinksGroup("Limited Access System Group"));
}
[Fact]
public void IsSharingLinksGroup_WithNormalGroup_ReturnsFalse()
{
Assert.False(PermissionEntryHelper.IsSharingLinksGroup("Owners"));
}
}
@@ -1,31 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Test stubs for PERM-01 and PERM-04.
/// These tests are skipped until IPermissionsService is implemented in Plan 02.
/// </summary>
public class PermissionsServiceTests
{
[Fact(Skip = "Requires live CSOM context — covered by Plan 02 implementation")]
public async Task ScanSiteAsync_ReturnsPermissionEntries_ForMockedSite()
{
// PERM-01: ScanSiteAsync returns a list of PermissionEntry records
// Arrange — requires a real or mocked ClientContext (CSOM)
// Act
// Assert
await Task.CompletedTask;
}
[Fact(Skip = "Requires live CSOM context — covered by Plan 02 implementation")]
public async Task ScanSiteAsync_WithIncludeInheritedFalse_SkipsItemsWithoutUniquePermissions()
{
// PERM-04: When IncludeInherited = false, items without unique permissions are excluded
// Arrange — requires a real or mocked ClientContext (CSOM)
// Act
// Assert
await Task.CompletedTask;
}
}
@@ -1,198 +0,0 @@
using System.IO;
using System.Text;
using System.Text.Json;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
[Trait("Category", "Unit")]
public class ProfileServiceTests : IDisposable
{
private readonly string _tempFile;
public ProfileServiceTests()
{
_tempFile = Path.GetTempFileName();
// Ensure the file doesn't exist so tests start clean
File.Delete(_tempFile);
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private ProfileRepository CreateRepository() => new(_tempFile);
private ProfileService CreateService() => new(CreateRepository());
[Fact]
public async Task SaveAndLoad_RoundTrips_Profiles()
{
var repo = CreateRepository();
var profiles = new List<TenantProfile>
{
new() { Name = "Contoso", TenantUrl = "https://contoso.sharepoint.com", ClientId = "client-id-1" },
new() { Name = "Fabrikam", TenantUrl = "https://fabrikam.sharepoint.com", ClientId = "client-id-2" }
};
await repo.SaveAsync(profiles);
var loaded = await repo.LoadAsync();
Assert.Equal(2, loaded.Count);
Assert.Equal("Contoso", loaded[0].Name);
Assert.Equal("https://contoso.sharepoint.com", loaded[0].TenantUrl);
Assert.Equal("client-id-1", loaded[0].ClientId);
Assert.Equal("Fabrikam", loaded[1].Name);
}
[Fact]
public async Task LoadAsync_MissingFile_ReturnsEmptyList()
{
var repo = CreateRepository();
var result = await repo.LoadAsync();
Assert.Empty(result);
}
[Fact]
public async Task LoadAsync_CorruptJson_ThrowsInvalidDataException()
{
await File.WriteAllTextAsync(_tempFile, "{ not valid json !!!", System.Text.Encoding.UTF8);
var repo = CreateRepository();
await Assert.ThrowsAsync<InvalidDataException>(() => repo.LoadAsync());
}
[Fact]
public async Task SaveAsync_ConcurrentCalls_DoNotCorruptFile()
{
var repo = CreateRepository();
var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
var idx = i;
tasks.Add(Task.Run(async () =>
{
var profiles = new List<TenantProfile>
{
new() { Name = $"Profile{idx}", TenantUrl = $"https://tenant{idx}.sharepoint.com", ClientId = $"cid-{idx}" }
};
await repo.SaveAsync(profiles);
}));
}
await Task.WhenAll(tasks);
// After all concurrent writes, file should be valid JSON (not corrupt)
var loaded = await repo.LoadAsync();
Assert.NotNull(loaded);
Assert.Single(loaded); // last write wins, but exactly 1 item
}
[Fact]
public async Task AddProfileAsync_PersistsNewProfile()
{
var service = CreateService();
var profile = new TenantProfile { Name = "TestTenant", TenantUrl = "https://test.sharepoint.com", ClientId = "test-cid" };
await service.AddProfileAsync(profile);
var profiles = await service.GetProfilesAsync();
Assert.Single(profiles);
Assert.Equal("TestTenant", profiles[0].Name);
}
[Fact]
public async Task RenameProfileAsync_ChangesName_AndPersists()
{
var service = CreateService();
await service.AddProfileAsync(new TenantProfile { Name = "OldName", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
await service.RenameProfileAsync("OldName", "NewName");
var profiles = await service.GetProfilesAsync();
Assert.Single(profiles);
Assert.Equal("NewName", profiles[0].Name);
}
[Fact]
public async Task RenameProfileAsync_ProfileNotFound_ThrowsKeyNotFoundException()
{
var service = CreateService();
await Assert.ThrowsAsync<KeyNotFoundException>(() => service.RenameProfileAsync("NonExistent", "NewName"));
}
[Fact]
public async Task DeleteProfileAsync_RemovesProfile()
{
var service = CreateService();
await service.AddProfileAsync(new TenantProfile { Name = "ToDelete", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
await service.DeleteProfileAsync("ToDelete");
var profiles = await service.GetProfilesAsync();
Assert.Empty(profiles);
}
[Fact]
public async Task DeleteProfileAsync_ProfileNotFound_ThrowsKeyNotFoundException()
{
var service = CreateService();
await Assert.ThrowsAsync<KeyNotFoundException>(() => service.DeleteProfileAsync("NonExistent"));
}
[Fact]
public async Task UpdateProfileAsync_UpdatesExistingProfile_AndPersists()
{
var service = CreateService();
var profile = new TenantProfile { Name = "UpdateMe", TenantUrl = "https://update.sharepoint.com", ClientId = "cid-update" };
await service.AddProfileAsync(profile);
// Mutate — set a ClientLogo to simulate logo update
profile.ClientLogo = new SharepointToolbox.Core.Models.LogoData { Base64 = "abc==", MimeType = "image/png" };
await service.UpdateProfileAsync(profile);
var profiles = await service.GetProfilesAsync();
Assert.Single(profiles);
Assert.NotNull(profiles[0].ClientLogo);
Assert.Equal("abc==", profiles[0].ClientLogo!.Base64);
}
[Fact]
public async Task UpdateProfileAsync_ProfileNotFound_ThrowsKeyNotFoundException()
{
var service = CreateService();
var profile = new TenantProfile { Name = "NonExistent", TenantUrl = "https://x.sharepoint.com", ClientId = "cid" };
await Assert.ThrowsAsync<KeyNotFoundException>(() => service.UpdateProfileAsync(profile));
}
[Fact]
public async Task SaveAsync_JsonOutput_UsesProfilesRootKey()
{
var repo = CreateRepository();
var profiles = new List<TenantProfile>
{
new() { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" }
};
await repo.SaveAsync(profiles);
var json = await File.ReadAllTextAsync(_tempFile);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("profiles", out var profilesElement),
"Root JSON object must contain 'profiles' key (camelCase)");
Assert.Equal(JsonValueKind.Array, profilesElement.ValueKind);
var first = profilesElement.EnumerateArray().First();
Assert.True(first.TryGetProperty("name", out _), "Profile must have 'name' (camelCase)");
Assert.True(first.TryGetProperty("tenantUrl", out _), "Profile must have 'tenantUrl' (camelCase)");
Assert.True(first.TryGetProperty("clientId", out _), "Profile must have 'clientId' (camelCase)");
}
}
@@ -1,20 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Services;
public class SearchServiceTests
{
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task SearchFilesAsync_WithExtensionFilter_BuildsCorrectKql()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task SearchFilesAsync_PaginationStopsAt50000()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task SearchFilesAsync_FiltersVersionHistoryPaths()
=> Task.CompletedTask;
}
@@ -1,123 +0,0 @@
using System.IO;
using System.Text;
using System.Text.Json;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
[Trait("Category", "Unit")]
public class SettingsServiceTests : IDisposable
{
private readonly string _tempFile;
public SettingsServiceTests()
{
_tempFile = Path.GetTempFileName();
File.Delete(_tempFile);
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private SettingsRepository CreateRepository() => new(_tempFile);
private SettingsService CreateService() => new(CreateRepository());
[Fact]
public async Task LoadAsync_MissingFile_ReturnsDefaultSettings()
{
var repo = CreateRepository();
var settings = await repo.LoadAsync();
Assert.Equal(string.Empty, settings.DataFolder);
Assert.Equal("en", settings.Lang);
}
[Fact]
public async Task SaveAndLoad_RoundTrips_DataFolderAndLang()
{
var repo = CreateRepository();
var original = new AppSettings { DataFolder = @"C:\Exports", Lang = "fr" };
await repo.SaveAsync(original);
var loaded = await repo.LoadAsync();
Assert.Equal(@"C:\Exports", loaded.DataFolder);
Assert.Equal("fr", loaded.Lang);
}
[Fact]
public async Task SaveAsync_SerializedJson_UsesDataFolderAndLangKeys()
{
var repo = CreateRepository();
await repo.SaveAsync(new AppSettings { DataFolder = @"C:\Exports", Lang = "fr" });
var json = await File.ReadAllTextAsync(_tempFile);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("dataFolder", out _),
"JSON must contain 'dataFolder' key (camelCase for schema compatibility)");
Assert.True(doc.RootElement.TryGetProperty("lang", out _),
"JSON must contain 'lang' key (camelCase for schema compatibility)");
}
[Fact]
public async Task SaveAsync_UsesTmpFileThenMove()
{
var repo = CreateRepository();
// The .tmp file should not exist after a successful save
await repo.SaveAsync(new AppSettings { DataFolder = @"C:\Test", Lang = "en" });
Assert.False(File.Exists(_tempFile + ".tmp"),
"Temp file should have been moved/deleted after successful save");
Assert.True(File.Exists(_tempFile), "Settings file must exist after save");
}
[Fact]
public async Task SetLanguageAsync_PersistsLang()
{
var service = CreateService();
await service.SetLanguageAsync("fr");
var settings = await service.GetSettingsAsync();
Assert.Equal("fr", settings.Lang);
}
[Fact]
public async Task SetDataFolderAsync_PersistsPath()
{
var service = CreateService();
await service.SetDataFolderAsync(@"C:\Exports");
var settings = await service.GetSettingsAsync();
Assert.Equal(@"C:\Exports", settings.DataFolder);
}
[Fact]
public async Task SetDataFolderAsync_EmptyString_IsAllowed()
{
var service = CreateService();
await service.SetDataFolderAsync(@"C:\Exports");
await service.SetDataFolderAsync(string.Empty);
var settings = await service.GetSettingsAsync();
Assert.Equal(string.Empty, settings.DataFolder);
}
[Fact]
public async Task SetLanguageAsync_InvalidCode_ThrowsArgumentException()
{
var service = CreateService();
await Assert.ThrowsAsync<ArgumentException>(() => service.SetLanguageAsync("de"));
}
}
@@ -1,166 +0,0 @@
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Unit tests for <see cref="SharePointGroupResolver"/> (Phase 17 Plan 01).
///
/// Testing strategy:
/// SharePointGroupResolver wraps CSOM (ClientContext) and Microsoft Graph SDK.
/// Both require live infrastructure that cannot be mocked without heavy ceremony.
///
/// We test what IS unit-testable without live infrastructure:
/// 1. IsAadGroup — static helper: login prefix pattern detection
/// 2. ExtractAadGroupId — static helper: GUID extraction from AAD group login
/// 3. StripClaims — static helper: UPN extraction after last pipe
/// 4. ResolveGroupsAsync with empty list — returns empty dict (no CSOM calls made)
///
/// Integration tests requiring live tenant / CSOM context are skip-marked.
/// </summary>
[Trait("Category", "Unit")]
public class SharePointGroupResolverTests
{
// ── IsAadGroup ─────────────────────────────────────────────────────────────
[Fact]
public void IsAadGroup_AadGroupLogin_ReturnsTrue()
{
var login = "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh";
Assert.True(SharePointGroupResolver.IsAadGroup(login));
}
[Fact]
public void IsAadGroup_RegularUserLogin_ReturnsFalse()
{
var login = "i:0#.f|membership|user@contoso.com";
Assert.False(SharePointGroupResolver.IsAadGroup(login));
}
[Fact]
public void IsAadGroup_SecurityGroupLogin_ReturnsFalse()
{
var login = "c:0(.s|true";
Assert.False(SharePointGroupResolver.IsAadGroup(login));
}
[Fact]
public void IsAadGroup_EmptyString_ReturnsFalse()
{
Assert.False(SharePointGroupResolver.IsAadGroup(string.Empty));
}
[Fact]
public void IsAadGroup_CaseInsensitive_ReturnsTrue()
{
// Prefix check should be case-insensitive per OrdinalIgnoreCase
var login = "C:0T.C|TENANT|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh";
Assert.True(SharePointGroupResolver.IsAadGroup(login));
}
// ── ExtractAadGroupId ──────────────────────────────────────────────────────
[Fact]
public void ExtractAadGroupId_ValidAadLogin_ExtractsGuid()
{
var login = "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh";
var result = SharePointGroupResolver.ExtractAadGroupId(login);
Assert.Equal("aaaabbbb-cccc-dddd-eeee-ffffgggghhhh", result);
}
[Fact]
public void ExtractAadGroupId_SingleSegment_ReturnsFullString()
{
// Edge: no pipe — LastIndexOf returns -1 so [(-1+1)..] = [0..] = whole string
var login = "nopipe";
var result = SharePointGroupResolver.ExtractAadGroupId(login);
Assert.Equal("nopipe", result);
}
// ── StripClaims ────────────────────────────────────────────────────────────
[Fact]
public void StripClaims_MembershipLogin_ReturnsUpn()
{
var login = "i:0#.f|membership|user@contoso.com";
var result = SharePointGroupResolver.StripClaims(login);
Assert.Equal("user@contoso.com", result);
}
[Fact]
public void StripClaims_NoClaimsPrefix_ReturnsFullString()
{
var login = "user@contoso.com";
var result = SharePointGroupResolver.StripClaims(login);
Assert.Equal("user@contoso.com", result);
}
[Fact]
public void StripClaims_MultiPipeLogin_ReturnsAfterLastPipe()
{
var login = "c:0t.c|tenant|some-guid-here";
var result = SharePointGroupResolver.StripClaims(login);
Assert.Equal("some-guid-here", result);
}
// ── ResolveGroupsAsync — empty input ──────────────────────────────────────
[Fact]
public async Task ResolveGroupsAsync_EmptyGroupNames_ReturnsEmptyDict()
{
// Arrange: create resolver without real dependencies — empty list triggers early return
// No CSOM ClientContext or GraphClientFactory is called for empty input
// We pass null! for the factory since it must not be invoked for an empty list
var resolver = new SharePointGroupResolver(null!);
// Act
var result = await resolver.ResolveGroupsAsync(
ctx: null!,
clientId: "ignored",
groupNames: Array.Empty<string>(),
ct: CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task ResolveGroupsAsync_EmptyGroupNames_DictUsesOrdinalIgnoreCase()
{
// Arrange
var resolver = new SharePointGroupResolver(null!);
// Act
var result = await resolver.ResolveGroupsAsync(
ctx: null!,
clientId: "ignored",
groupNames: Array.Empty<string>(),
ct: CancellationToken.None);
// Assert: verify the returned dict is OrdinalIgnoreCase by casting to Dictionary
// and checking its comparer, or by testing that the underlying type supports it.
// Since ResolveGroupsAsync returns a Dictionary<string,…> wrapped as IReadOnlyDictionary,
// we cast back and insert a test entry with mixed casing.
var mutable = (Dictionary<string, IReadOnlyList<SharepointToolbox.Core.Models.ResolvedMember>>)result;
mutable["Site Members"] = Array.Empty<SharepointToolbox.Core.Models.ResolvedMember>();
Assert.True(mutable.ContainsKey("site members"),
"Result dictionary comparer must be OrdinalIgnoreCase");
}
// ── Integration tests (live SP tenant required) ────────────────────────────
[Fact(Skip = "Requires live SP tenant — run manually against a real ClientContext")]
public async Task ResolveGroupsAsync_KnownGroup_ReturnsMembers()
{
// Integration test: create a real ClientContext, call with a known group name,
// verify the returned list contains at least one ResolvedMember.
await Task.CompletedTask;
}
[Fact(Skip = "Requires live SP tenant — verify case-insensitive lookup with real data")]
public async Task ResolveGroupsAsync_LookupDifferentCasing_FindsGroup()
{
// Integration test: resolver stores "Site Members" — lookup "site members" should succeed.
await Task.CompletedTask;
}
}
@@ -1,21 +0,0 @@
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Services;
public class SiteListServiceTests
{
[Fact]
public void DeriveAdminUrl_WithStandardUrl_ReturnsAdminUrl()
{
var result = SiteListService.DeriveAdminUrl("https://contoso.sharepoint.com");
Assert.Equal("https://contoso-admin.sharepoint.com", result);
}
[Fact]
public void DeriveAdminUrl_WithTrailingSlash_ReturnsAdminUrl()
{
var result = SiteListService.DeriveAdminUrl("https://contoso.sharepoint.com/");
Assert.Equal("https://contoso-admin.sharepoint.com", result);
}
}
@@ -1,31 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Services;
public class StorageServiceTests
{
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")]
public Task CollectStorageAsync_ReturnsLibraryNodes_ForDocumentLibraries()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")]
public Task CollectStorageAsync_WithFolderDepth1_ReturnsSubfolderNodes()
=> Task.CompletedTask;
[Fact]
public void StorageNode_VersionSizeBytes_IsNonNegative()
{
// VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes (never negative)
var node = new StorageNode { TotalSizeBytes = 1000L, FileStreamSizeBytes = 1200L };
Assert.Equal(0L, node.VersionSizeBytes); // Math.Max(0, -200) = 0
}
[Fact]
public void StorageNode_VersionSizeBytes_IsCorrectWhenPositive()
{
var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes);
}
}
@@ -1,107 +0,0 @@
using System.IO;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
namespace SharepointToolbox.Tests.Services;
public class TemplateRepositoryTests : IDisposable
{
private readonly string _tempDir;
private readonly TemplateRepository _repo;
public TemplateRepositoryTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"sptoolbox_test_{Guid.NewGuid():N}");
_repo = new TemplateRepository(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, true);
}
private static SiteTemplate CreateTestTemplate(string name = "Test Template")
{
return new SiteTemplate
{
Id = Guid.NewGuid().ToString(),
Name = name,
SourceUrl = "https://contoso.sharepoint.com/sites/test",
CapturedAt = DateTime.UtcNow,
SiteType = "Team",
Options = new SiteTemplateOptions(),
Settings = new TemplateSettings { Title = "Test", Description = "Desc", Language = 1033 },
Libraries = new List<TemplateLibraryInfo>
{
new() { Name = "Documents", BaseType = "DocumentLibrary", BaseTemplate = 101 }
},
};
}
[Fact]
public async Task SaveAndLoad_RoundTrips_Correctly()
{
var template = CreateTestTemplate();
await _repo.SaveAsync(template);
var loaded = await _repo.GetByIdAsync(template.Id);
Assert.NotNull(loaded);
Assert.Equal(template.Name, loaded!.Name);
Assert.Equal(template.SiteType, loaded.SiteType);
Assert.Equal(template.SourceUrl, loaded.SourceUrl);
Assert.Single(loaded.Libraries);
Assert.Equal("Documents", loaded.Libraries[0].Name);
}
[Fact]
public async Task GetAll_ReturnsAllSavedTemplates()
{
await _repo.SaveAsync(CreateTestTemplate("Template A"));
await _repo.SaveAsync(CreateTestTemplate("Template B"));
await _repo.SaveAsync(CreateTestTemplate("Template C"));
var all = await _repo.GetAllAsync();
Assert.Equal(3, all.Count);
}
[Fact]
public async Task Delete_RemovesTemplate()
{
var template = CreateTestTemplate();
await _repo.SaveAsync(template);
Assert.NotNull(await _repo.GetByIdAsync(template.Id));
await _repo.DeleteAsync(template.Id);
Assert.Null(await _repo.GetByIdAsync(template.Id));
}
[Fact]
public async Task Rename_UpdatesTemplateName()
{
var template = CreateTestTemplate("Old Name");
await _repo.SaveAsync(template);
await _repo.RenameAsync(template.Id, "New Name");
var loaded = await _repo.GetByIdAsync(template.Id);
Assert.Equal("New Name", loaded!.Name);
}
[Fact]
public async Task GetAll_EmptyDirectory_ReturnsEmptyList()
{
var all = await _repo.GetAllAsync();
Assert.Empty(all);
}
[Fact]
public async Task GetById_NonExistent_ReturnsNull()
{
var result = await _repo.GetByIdAsync("nonexistent-id");
Assert.Null(result);
}
}
@@ -1,49 +0,0 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
public class TemplateServiceTests
{
[Fact]
public void TemplateService_Implements_ITemplateService()
{
Assert.True(typeof(ITemplateService).IsAssignableFrom(typeof(TemplateService)));
}
[Fact]
public void SiteTemplate_DefaultValues_AreCorrect()
{
var template = new SiteTemplate();
Assert.NotNull(template.Id);
Assert.NotEmpty(template.Id);
Assert.NotNull(template.Libraries);
Assert.Empty(template.Libraries);
Assert.NotNull(template.PermissionGroups);
Assert.Empty(template.PermissionGroups);
Assert.NotNull(template.Options);
}
[Fact]
public void SiteTemplateOptions_AllDefaultTrue()
{
var opts = new SiteTemplateOptions();
Assert.True(opts.CaptureLibraries);
Assert.True(opts.CaptureFolders);
Assert.True(opts.CapturePermissionGroups);
Assert.True(opts.CaptureLogo);
Assert.True(opts.CaptureSettings);
}
[Fact(Skip = "Requires live SharePoint tenant")]
public async Task CaptureTemplateAsync_CapturesLibrariesAndFolders()
{
await Task.CompletedTask;
}
[Fact(Skip = "Requires live SharePoint admin context")]
public async Task ApplyTemplateAsync_CreatesTeamSiteWithStructure()
{
await Task.CompletedTask;
}
}
@@ -1,410 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.SharePoint.Client;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Unit tests for UserAccessAuditService (Phase 7 Plan 08).
/// Verifies: user filtering, claim format matching, access type classification,
/// high-privilege detection, external user flagging, semicolon splitting, multi-site scanning.
/// </summary>
public class UserAccessAuditServiceTests
{
// ── Helper factory for PermissionEntry ────────────────────────────────────
private static PermissionEntry MakeEntry(
string users = "Alice",
string logins = "alice@contoso.com",
string levels = "Read",
string grantedThrough = "Direct Permissions",
bool hasUnique = true,
string objectType = "List",
string title = "Docs",
string url = "https://contoso.sharepoint.com/Docs",
string principalType = "User") =>
new(objectType, title, url, hasUnique, users, logins, levels, grantedThrough, principalType);
private static SiteInfo MakeSite(string url = "https://contoso.sharepoint.com", string title = "Contoso") =>
new(url, title);
// ── Helper: create a configured service + mocks ───────────────────────────
private static (UserAccessAuditService svc, Mock<IPermissionsService> permSvc, Mock<ISessionManager> sessionMgr)
CreateService(IReadOnlyList<PermissionEntry> entries)
{
var mockPerm = new Mock<IPermissionsService>();
mockPerm
.Setup(p => p.ScanSiteAsync(
It.IsAny<ClientContext>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(entries);
var mockSession = new Mock<ISessionManager>();
mockSession
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var svc = new UserAccessAuditService(mockPerm.Object);
return (svc, mockPerm, mockSession);
}
private static ScanOptions DefaultOptions => new(
IncludeInherited: false,
ScanFolders: false,
FolderDepth: 1,
IncludeSubsites: false);
private static TenantProfile DefaultProfile => new()
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
// ── Test 1: Filter by target user login ───────────────────────────────────
[Fact]
public async Task Filters_by_target_user_login()
{
var entries = new List<PermissionEntry>
{
MakeEntry(users: "Alice", logins: "alice@contoso.com"),
MakeEntry(users: "Bob", logins: "bob@contoso.com"),
MakeEntry(users: "Carol", logins: "carol@contoso.com")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.All(result, r => Assert.Equal("alice@contoso.com", r.UserLogin));
Assert.DoesNotContain(result, r => r.UserLogin == "bob@contoso.com");
Assert.DoesNotContain(result, r => r.UserLogin == "carol@contoso.com");
}
// ── Test 2: Claim format matching ─────────────────────────────────────────
[Fact]
public async Task Matches_user_by_email_in_claim_format()
{
var claimLogin = "i:0#.f|membership|alice@contoso.com";
var entries = new List<PermissionEntry>
{
MakeEntry(users: "Alice", logins: claimLogin)
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Single(result);
// Claims prefix is stripped: "i:0#.f|membership|alice@contoso.com" -> "alice@contoso.com"
Assert.Equal("alice@contoso.com", result[0].UserLogin);
}
// ── Test 3: Classifies Direct access ─────────────────────────────────────
[Fact]
public async Task Classifies_direct_access()
{
var entries = new List<PermissionEntry>
{
MakeEntry(hasUnique: true, grantedThrough: "Direct Permissions")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Single(result);
Assert.Equal(AccessType.Direct, result[0].AccessType);
}
// ── Test 4: Classifies Group access ──────────────────────────────────────
[Fact]
public async Task Classifies_group_access()
{
var entries = new List<PermissionEntry>
{
MakeEntry(hasUnique: true, grantedThrough: "SharePoint Group: Members")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Single(result);
Assert.Equal(AccessType.Group, result[0].AccessType);
}
// ── Test 5: Classifies Inherited access ──────────────────────────────────
[Fact]
public async Task Classifies_inherited_access()
{
var entries = new List<PermissionEntry>
{
MakeEntry(hasUnique: false, grantedThrough: "Direct Permissions")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Single(result);
Assert.Equal(AccessType.Inherited, result[0].AccessType);
}
// ── Test 6: Detects high privilege (Full Control) ─────────────────────────
[Fact]
public async Task Detects_high_privilege()
{
var entries = new List<PermissionEntry>
{
MakeEntry(levels: "Full Control")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Single(result);
Assert.True(result[0].IsHighPrivilege);
}
// ── Test 7: Detects high privilege (Site Collection Administrator) ─────────
[Fact]
public async Task Detects_high_privilege_site_admin()
{
var entries = new List<PermissionEntry>
{
MakeEntry(levels: "Site Collection Administrator")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Single(result);
Assert.True(result[0].IsHighPrivilege);
}
// ── Test 8: Flags external user ───────────────────────────────────────────
[Fact]
public async Task Flags_external_user()
{
var extLogin = "alice_fabrikam.com#EXT#@contoso.onmicrosoft.com";
var entries = new List<PermissionEntry>
{
MakeEntry(users: "Alice (External)", logins: extLogin)
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { extLogin },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Single(result);
Assert.True(result[0].IsExternalUser);
}
// ── Test 9: Splits semicolon-joined users ─────────────────────────────────
[Fact]
public async Task Splits_semicolon_users()
{
var entries = new List<PermissionEntry>
{
MakeEntry(users: "Alice;Bob", logins: "alice@x.com;bob@x.com", levels: "Read")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@x.com", "bob@x.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
// 2 users × 1 permission level = 2 rows
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.UserLogin == "alice@x.com");
Assert.Contains(result, r => r.UserLogin == "bob@x.com");
}
// ── Test 10: Splits semicolon permission levels ───────────────────────────
[Fact]
public async Task Splits_semicolon_permission_levels()
{
var entries = new List<PermissionEntry>
{
MakeEntry(users: "Alice", logins: "alice@contoso.com", levels: "Read;Contribute")
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
// 1 user × 2 permission levels = 2 rows
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.PermissionLevel == "Read");
Assert.Contains(result, r => r.PermissionLevel == "Contribute");
}
// ── Test 11: Empty targets returns empty ──────────────────────────────────
[Fact]
public async Task Empty_targets_returns_empty()
{
var entries = new List<PermissionEntry>
{
MakeEntry()
};
var (svc, _, session) = CreateService(entries);
var result = await svc.AuditUsersAsync(
session.Object,
DefaultProfile,
Array.Empty<string>(),
new[] { MakeSite() },
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
Assert.Empty(result);
}
// ── Test 12: Scans multiple sites ─────────────────────────────────────────
[Fact]
public async Task Scans_multiple_sites()
{
var entries = new List<PermissionEntry>
{
MakeEntry(users: "Alice", logins: "alice@contoso.com")
};
var mockPerm = new Mock<IPermissionsService>();
mockPerm
.Setup(p => p.ScanSiteAsync(
It.IsAny<ClientContext>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(entries);
var mockSession = new Mock<ISessionManager>();
mockSession
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var svc = new UserAccessAuditService(mockPerm.Object);
var sites = new List<SiteInfo>
{
new("https://contoso.sharepoint.com/sites/site1", "Site 1"),
new("https://contoso.sharepoint.com/sites/site2", "Site 2")
};
var result = await svc.AuditUsersAsync(
mockSession.Object,
DefaultProfile,
new[] { "alice@contoso.com" },
sites,
DefaultOptions,
new Progress<OperationProgress>(),
CancellationToken.None);
// Entries from both sites should appear
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.SiteUrl == "https://contoso.sharepoint.com/sites/site1");
Assert.Contains(result, r => r.SiteUrl == "https://contoso.sharepoint.com/sites/site2");
// ScanSiteAsync was called exactly twice (once per site)
mockPerm.Verify(
p => p.ScanSiteAsync(
It.IsAny<ClientContext>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
}
@@ -1,31 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Suppress NU1701: LiveCharts2 transitive deps lack net10.0 targets but work at runtime -->
<NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.0-rc5.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SharepointToolbox\SharepointToolbox.csproj" />
</ItemGroup>
</Project>
@@ -1,142 +0,0 @@
using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using SharepointToolbox.Core.Models;
using SharepointToolbox.ViewModels;
namespace SharepointToolbox.Tests.ViewModels;
[Trait("Category", "Unit")]
public class FeatureViewModelBaseTests
{
private class TestViewModel : FeatureViewModelBase
{
public TestViewModel() : base(NullLogger<FeatureViewModelBase>.Instance) { }
public Func<CancellationToken, IProgress<OperationProgress>, Task>? OperationFunc { get; set; }
protected override Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
=> OperationFunc?.Invoke(ct, progress) ?? Task.CompletedTask;
}
[Fact]
public async Task IsRunning_IsTrueWhileOperationExecutes_ThenFalseAfterCompletion()
{
var vm = new TestViewModel();
var tcs = new TaskCompletionSource<bool>();
bool wasRunningDuringOperation = false;
vm.OperationFunc = async (ct, p) =>
{
wasRunningDuringOperation = vm.IsRunning;
await tcs.Task;
};
var runTask = vm.RunCommand.ExecuteAsync(null);
// Give run task time to start
await Task.Delay(10);
Assert.True(wasRunningDuringOperation);
tcs.SetResult(true);
await runTask;
Assert.False(vm.IsRunning);
}
[Fact]
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
{
var vm = new TestViewModel();
int midProgress = -1;
string? midStatus = null;
vm.OperationFunc = async (ct, progress) =>
{
progress.Report(new OperationProgress(50, 100, "halfway"));
// Let the Progress<T> callback dispatch before sampling.
await Task.Delay(20, ct);
midProgress = vm.ProgressValue;
midStatus = vm.StatusMessage;
};
await vm.RunCommand.ExecuteAsync(null);
// Mid-operation snapshot confirms IProgress reaches bound properties.
// Post-completion, FeatureViewModelBase snaps to 100% / "Complete"
// so stale "Scanning X" labels don't linger after a successful run.
Assert.Equal(50, midProgress);
Assert.Equal("halfway", midStatus);
}
[Fact]
public async Task CancelCommand_DuringOperation_SetsStatusMessageToCancelled()
{
// Ensure EN culture so TranslationSource resolves "Operation cancelled"
var prev = CultureInfo.CurrentUICulture;
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en");
try
{
var vm = new TestViewModel();
var started = new TaskCompletionSource<bool>();
vm.OperationFunc = async (ct, p) =>
{
started.SetResult(true);
await Task.Delay(5000, ct); // Will be cancelled
};
var runTask = vm.RunCommand.ExecuteAsync(null);
await started.Task;
vm.CancelCommand.Execute(null);
await runTask;
Assert.Contains("cancel", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(vm.IsRunning);
}
finally
{
CultureInfo.CurrentUICulture = prev;
}
}
[Fact]
public async Task OperationCanceledException_IsCaughtGracefully_IsRunningBecomesFalse()
{
var vm = new TestViewModel();
vm.OperationFunc = (ct, p) => throw new OperationCanceledException();
// Should not throw
await vm.RunCommand.ExecuteAsync(null);
Assert.False(vm.IsRunning);
}
[Fact]
public async Task ExceptionDuringOperation_SetsStatusMessageToErrorText_IsRunningBecomesFalse()
{
var vm = new TestViewModel();
vm.OperationFunc = (ct, p) => throw new InvalidOperationException("test error");
await vm.RunCommand.ExecuteAsync(null);
Assert.False(vm.IsRunning);
Assert.Contains("test error", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RunCommand_CannotBeInvoked_WhileIsRunning()
{
var vm = new TestViewModel();
var tcs = new TaskCompletionSource<bool>();
vm.OperationFunc = async (ct, p) => await tcs.Task;
var runTask = vm.RunCommand.ExecuteAsync(null);
await Task.Delay(10); // Let it start
Assert.False(vm.RunCommand.CanExecute(null));
tcs.SetResult(true);
await runTask;
Assert.True(vm.RunCommand.CanExecute(null));
}
}
@@ -1,211 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Auth;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Unit tests for the global site selection flow (Phase 6).
/// Covers: message broadcast, base class reception, single-site pre-fill,
/// multi-site pre-populate, local override, override reset, tenant switch clear,
/// and toolbar label update.
/// Requirements: SITE-01, SITE-02
/// </summary>
public class GlobalSiteSelectionTests
{
// ── Helper: minimal concrete subclass of FeatureViewModelBase ────────────
private class TestFeatureViewModel : FeatureViewModelBase
{
public TestFeatureViewModel(ILogger<FeatureViewModelBase> logger) : base(logger) { }
protected override Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
=> Task.CompletedTask;
/// <summary>Expose protected GlobalSites for assertions.</summary>
public IReadOnlyList<SiteInfo> TestGlobalSites => GlobalSites;
}
// ── Reset messenger between tests to avoid cross-test contamination ──────
public GlobalSiteSelectionTests()
{
WeakReferenceMessenger.Default.Reset();
}
// ── Helper factories ─────────────────────────────────────────────────────
private static StorageViewModel CreateStorageViewModel()
=> new(
Mock.Of<IStorageService>(),
Mock.Of<ISessionManager>(),
NullLogger<FeatureViewModelBase>.Instance);
private static PermissionsViewModel CreatePermissionsViewModel()
=> new(
Mock.Of<IPermissionsService>(),
Mock.Of<ISiteListService>(),
Mock.Of<ISessionManager>(),
NullLogger<FeatureViewModelBase>.Instance);
private static TransferViewModel CreateTransferViewModel()
=> new(
Mock.Of<IFileTransferService>(),
Mock.Of<ISessionManager>(),
new BulkResultCsvExportService(),
NullLogger<FeatureViewModelBase>.Instance);
private static MainWindowViewModel CreateMainWindowViewModel()
{
var tempFile = Path.GetTempFileName();
var profileRepo = new ProfileRepository(tempFile);
var profileService = new ProfileService(profileRepo);
var sessionManager = new SessionManager(new MsalClientFactory());
return new MainWindowViewModel(
profileService,
sessionManager,
NullLogger<MainWindowViewModel>.Instance);
}
private static IReadOnlyList<SiteInfo> TwoSites() =>
new List<SiteInfo>
{
new("https://contoso.sharepoint.com/sites/hr", "HR"),
new("https://contoso.sharepoint.com/sites/finance", "Finance")
}.AsReadOnly();
// ── Test 1: GlobalSitesChangedMessage carries site list ──────────────────
[Fact]
public void GlobalSitesChangedMessage_WhenSent_ReceiverGetsSites()
{
// Arrange
IReadOnlyList<SiteInfo>? received = null;
WeakReferenceMessenger.Default.Register<GlobalSitesChangedMessage>(
this, (_, m) => received = m.Value);
var sites = TwoSites();
// Act
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
// Assert
Assert.NotNull(received);
Assert.Equal(2, received!.Count);
Assert.Equal("https://contoso.sharepoint.com/sites/hr", received[0].Url);
Assert.Equal("https://contoso.sharepoint.com/sites/finance", received[1].Url);
}
// ── Test 2: FeatureViewModelBase updates GlobalSites on message receive ──
[Fact]
public void FeatureViewModelBase_OnGlobalSitesChangedMessage_UpdatesGlobalSitesProperty()
{
// Arrange
var vm = new TestFeatureViewModel(NullLogger<FeatureViewModelBase>.Instance);
var sites = TwoSites();
// Act
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
// Assert
Assert.Equal(2, vm.TestGlobalSites.Count);
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.TestGlobalSites[0].Url);
Assert.Equal("https://contoso.sharepoint.com/sites/finance", vm.TestGlobalSites[1].Url);
}
// ── Test 3: All tabs receive GlobalSites via base class ────────────────
[Fact]
public void AllTabs_ReceiveGlobalSites_ViaBaseClass()
{
// Arrange
var storageVm = CreateStorageViewModel();
var permissionsVm = CreatePermissionsViewModel();
var sites = TwoSites();
// Act
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
// Assert: base class TestGlobalSites (exposed via TestFeatureViewModel)
// is not accessible on concrete VMs, but we can verify by creating another VM
var testVm = new TestFeatureViewModel(NullLogger<FeatureViewModelBase>.Instance);
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
Assert.Equal(2, testVm.TestGlobalSites.Count);
Assert.Equal("https://contoso.sharepoint.com/sites/hr", testVm.TestGlobalSites[0].Url);
Assert.Equal("https://contoso.sharepoint.com/sites/finance", testVm.TestGlobalSites[1].Url);
}
// ── Test 4: GlobalSites updated when new message arrives ─────────────────
[Fact]
public void GlobalSites_UpdatedOnNewMessage_ReplacesOldSites()
{
// Arrange
var vm = new TestFeatureViewModel(NullLogger<FeatureViewModelBase>.Instance);
var sites = TwoSites();
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
Assert.Equal(2, vm.TestGlobalSites.Count);
// Act: send new sites
var newSites = new List<SiteInfo>
{
new("https://contoso.sharepoint.com/sites/marketing", "Marketing")
}.AsReadOnly();
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(newSites));
// Assert: old sites replaced
Assert.Single(vm.TestGlobalSites);
Assert.Equal("https://contoso.sharepoint.com/sites/marketing", vm.TestGlobalSites[0].Url);
}
// ── Test 9: TransferViewModel pre-fills SourceSiteUrl from first global ──
[Fact]
public void OnGlobalSitesChanged_WithSites_PreFillsSourceSiteUrlOnTransferTab()
{
// Arrange
var vm = CreateTransferViewModel();
var sites = TwoSites();
// Act
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
// Assert: only SourceSiteUrl is pre-filled (first global site)
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SourceSiteUrl);
}
// ── Test 10: MainWindowViewModel GlobalSitesSelectedLabel updates with count
[Fact]
public void GlobalSitesSelectedLabel_WhenSitesAdded_ReflectsCount()
{
// Arrange
var vm = CreateMainWindowViewModel();
// Initially no sites selected
var initialLabel = vm.GlobalSitesSelectedLabel;
Assert.DoesNotContain("1", initialLabel); // Should say "none" equivalent
// Act: add two sites
vm.GlobalSelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com/sites/hr", "HR"));
vm.GlobalSelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com/sites/finance", "Finance"));
// Assert: label reflects the count
var label = vm.GlobalSitesSelectedLabel;
Assert.Contains("2", label);
// Ensure label is non-empty (different from the initial "none" state)
Assert.NotEqual(initialLabel, label);
}
}
@@ -1,280 +0,0 @@
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.SharePoint.Client;
using Moq;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Tests for auto-elevation logic in PermissionsViewModel scan loop.
/// OWN-02: catch access-denied, call ElevateAsync, retry scan, tag entries.
/// </summary>
public class PermissionsViewModelOwnershipTests
{
/// <summary>
/// Creates a ServerUnauthorizedAccessException via Activator (the reference assembly
/// exposes a different ctor signature than the runtime DLL — use runtime reflection).
/// </summary>
private static ServerUnauthorizedAccessException MakeAccessDeniedException()
{
var t = typeof(ServerUnauthorizedAccessException);
var ctor = t.GetConstructors(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];
return (ServerUnauthorizedAccessException)ctor.Invoke(new object?[] { "Access Denied", "", 0, "", "ServerUnauthorizedAccessException", null, "" });
}
private static readonly string SiteUrl = "https://tenant.sharepoint.com/sites/test";
private static readonly string TenantUrl = "https://tenant.sharepoint.com";
private static PermissionsViewModel CreateVm(
Mock<IPermissionsService> permissionsSvc,
Mock<ISessionManager> sessionManager,
SettingsService? settingsService = null,
IOwnershipElevationService? ownershipService = null)
{
var siteListSvc = new Mock<ISiteListService>();
var logger = NullLogger<FeatureViewModelBase>.Instance;
var vm = new PermissionsViewModel(
permissionsSvc.Object,
siteListSvc.Object,
sessionManager.Object,
logger,
settingsService: settingsService,
ownershipService: ownershipService);
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new(SiteUrl, "Test Site") }.AsReadOnly()));
vm.SetCurrentProfile(new TenantProfile
{
Name = "Test",
TenantUrl = TenantUrl,
ClientId = "client-id"
});
return vm;
}
/// <summary>
/// When AutoTakeOwnership=false and ScanSiteAsync throws ServerUnauthorizedAccessException,
/// the exception propagates.
/// </summary>
[Fact]
public async Task ScanLoop_ToggleOff_AccessDenied_ExceptionPropagates()
{
var permSvc = new Mock<IPermissionsService>();
permSvc
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(MakeAccessDeniedException());
var sessionMgr = new Mock<ISessionManager>();
sessionMgr
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
// No settingsService => toggle OFF (null treated as false)
var vm = CreateVm(permSvc, sessionMgr, settingsService: null, ownershipService: null);
await Assert.ThrowsAsync<ServerUnauthorizedAccessException>(
() => vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>()));
}
/// <summary>
/// When AutoTakeOwnership=true and scan throws access denied,
/// ElevateAsync is called once then ScanSiteAsync is retried.
/// </summary>
[Fact]
public async Task ScanLoop_ToggleOn_AccessDenied_ElevatesAndRetries()
{
var permSvc = new Mock<IPermissionsService>();
var callCount = 0;
permSvc
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
throw MakeAccessDeniedException();
return new List<PermissionEntry>
{
new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
};
});
var sessionMgr = new Mock<ISessionManager>();
sessionMgr
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var elevationSvc = new Mock<IOwnershipElevationService>();
elevationSvc
.Setup(e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
// ElevateAsync called once
elevationSvc.Verify(
e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Once);
// ScanSiteAsync called twice (first fails, retry succeeds)
permSvc.Verify(
s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
/// <summary>
/// When ScanSiteAsync succeeds on first try, ElevateAsync is never called.
/// </summary>
[Fact]
public async Task ScanLoop_ScanSucceeds_ElevateNeverCalled()
{
var permSvc = new Mock<IPermissionsService>();
permSvc
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PermissionEntry>
{
new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
});
var sessionMgr = new Mock<ISessionManager>();
sessionMgr
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var elevationSvc = new Mock<IOwnershipElevationService>();
var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
elevationSvc.Verify(
e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
/// <summary>
/// After successful elevation+retry, returned entries have WasAutoElevated=true.
/// </summary>
[Fact]
public async Task ScanLoop_AfterElevation_EntriesTaggedWasAutoElevated()
{
var permSvc = new Mock<IPermissionsService>();
var callCount = 0;
permSvc
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
throw MakeAccessDeniedException();
return new List<PermissionEntry>
{
new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
};
});
var sessionMgr = new Mock<ISessionManager>();
sessionMgr
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var elevationSvc = new Mock<IOwnershipElevationService>();
elevationSvc
.Setup(e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
Assert.NotEmpty(vm.Results);
Assert.All(vm.Results, e => Assert.True(e.WasAutoElevated));
}
/// <summary>
/// If ElevateAsync itself throws, the exception propagates (no infinite retry).
/// </summary>
[Fact]
public async Task ScanLoop_ElevationThrows_ExceptionPropagates()
{
var permSvc = new Mock<IPermissionsService>();
permSvc
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(MakeAccessDeniedException());
var sessionMgr = new Mock<ISessionManager>();
sessionMgr
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var elevationSvc = new Mock<IOwnershipElevationService>();
elevationSvc
.Setup(e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Elevation failed"));
// ScanSiteAsync always throws access denied (does NOT succeed after elevation throws)
var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
await Assert.ThrowsAsync<InvalidOperationException>(
() => vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>()));
// ScanSiteAsync was called exactly once (no retry after elevation failure)
permSvc.Verify(
s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Validates DeriveAdminUrl logic for standard tenant URL.
/// </summary>
[Theory]
[InlineData("https://tenant.sharepoint.com", "https://tenant-admin.sharepoint.com")]
[InlineData("https://tenant.sharepoint.com/", "https://tenant-admin.sharepoint.com")]
[InlineData("https://tenant-admin.sharepoint.com", "https://tenant-admin.sharepoint.com")]
public void DeriveAdminUrl_ReturnsCorrectAdminUrl(string input, string expected)
{
var result = PermissionsViewModel.DeriveAdminUrl(input);
Assert.Equal(expected, result);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static SettingsService FakeSettingsServiceWithAutoOwnership(bool enabled)
{
// Use in-memory temp file
var tempFile = System.IO.Path.GetTempFileName();
System.IO.File.Delete(tempFile);
var repo = new SharepointToolbox.Infrastructure.Persistence.SettingsRepository(tempFile);
var svc = new SettingsService(repo);
// Seed via SetAutoTakeOwnershipAsync synchronously
svc.SetAutoTakeOwnershipAsync(enabled).GetAwaiter().GetResult();
return svc;
}
}
@@ -1,194 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.SharePoint.Client;
using Moq;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Unit tests for PermissionsViewModel.
/// PERM-02: multi-site scan loop invokes ScanSiteAsync once per URL.
/// </summary>
public class PermissionsViewModelTests
{
[Fact]
public async Task StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl()
{
// Arrange
var mockPermissionsService = new Mock<IPermissionsService>();
mockPermissionsService
.Setup(s => s.ScanSiteAsync(
It.IsAny<ClientContext>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PermissionEntry>());
var mockSiteListService = new Mock<ISiteListService>();
var mockSessionManager = new Mock<ISessionManager>();
mockSessionManager
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var vm = new PermissionsViewModel(
mockPermissionsService.Object,
mockSiteListService.Object,
mockSessionManager.Object,
new NullLogger<FeatureViewModelBase>());
// Set up two site URLs via global site selection
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo>
{
new("https://tenant1.sharepoint.com/sites/alpha", "Alpha"),
new("https://tenant1.sharepoint.com/sites/beta", "Beta")
}.AsReadOnly()));
vm.SetCurrentProfile(new TenantProfile
{
Name = "Test",
TenantUrl = "https://tenant1.sharepoint.com",
ClientId = "client-id"
});
// Act
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
// Assert: ScanSiteAsync called exactly twice (once per URL)
mockPermissionsService.Verify(
s => s.ScanSiteAsync(
It.IsAny<ClientContext>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
/// <summary>
/// Creates a PermissionsViewModel with mocked services where ScanSiteAsync returns the given results.
/// </summary>
private static PermissionsViewModel CreateViewModelWithResults(IReadOnlyList<PermissionEntry> results)
{
var mockPermissionsService = new Mock<IPermissionsService>();
mockPermissionsService
.Setup(s => s.ScanSiteAsync(
It.IsAny<ClientContext>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(results.ToList());
var mockSiteListService = new Mock<ISiteListService>();
var mockSessionManager = new Mock<ISessionManager>();
mockSessionManager
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientContext)null!);
var vm = new PermissionsViewModel(
mockPermissionsService.Object,
mockSiteListService.Object,
mockSessionManager.Object,
new NullLogger<FeatureViewModelBase>());
return vm;
}
[Fact]
public void IsSimplifiedMode_Default_IsFalse()
{
WeakReferenceMessenger.Default.Reset();
var vm = CreateViewModelWithResults(Array.Empty<PermissionEntry>());
Assert.False(vm.IsSimplifiedMode);
}
[Fact]
public async Task IsSimplifiedMode_WhenToggled_RebuildsSimplifiedResults()
{
WeakReferenceMessenger.Default.Reset();
var entries = new List<PermissionEntry>
{
new("Site", "Test", "https://test.sharepoint.com", true, "User1", "user1@test.com", "Full Control", "Direct Permissions", "User"),
new("List", "Docs", "https://test.sharepoint.com/docs", false, "User2", "user2@test.com", "Read", "Direct Permissions", "User"),
};
var vm = CreateViewModelWithResults(entries);
// Simulate scan completing
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://test.sharepoint.com", "Test") }.AsReadOnly()));
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
// Before toggle: simplified results empty
Assert.Empty(vm.SimplifiedResults);
// Toggle on
vm.IsSimplifiedMode = true;
// After toggle: simplified results populated
Assert.Equal(2, vm.SimplifiedResults.Count);
Assert.Equal(4, vm.Summaries.Count);
}
[Fact]
public async Task IsDetailView_Toggle_DoesNotChangeCounts()
{
WeakReferenceMessenger.Default.Reset();
var entries = new List<PermissionEntry>
{
new("Site", "Test", "https://test.sharepoint.com", true, "User1", "user1@test.com", "Contribute", "Direct Permissions", "User"),
};
var vm = CreateViewModelWithResults(entries);
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://test.sharepoint.com", "Test") }.AsReadOnly()));
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
vm.IsSimplifiedMode = true;
var countBefore = vm.SimplifiedResults.Count;
vm.IsDetailView = false;
Assert.Equal(countBefore, vm.SimplifiedResults.Count); // No re-computation
vm.IsDetailView = true;
Assert.Equal(countBefore, vm.SimplifiedResults.Count); // Still the same
}
[Fact]
public async Task Summaries_ContainsCorrectRiskBreakdown()
{
WeakReferenceMessenger.Default.Reset();
var entries = new List<PermissionEntry>
{
new("Site", "S1", "https://s1", true, "Admin", "admin@t.com", "Full Control", "Direct", "User"),
new("Site", "S2", "https://s2", true, "Editor", "ed@t.com", "Contribute", "Direct", "User"),
new("List", "L1", "https://l1", false, "Reader", "read@t.com", "Read", "Direct", "User"),
};
var vm = CreateViewModelWithResults(entries);
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://s1", "S1") }.AsReadOnly()));
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://s1", ClientId = "cid" });
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
vm.IsSimplifiedMode = true;
var high = vm.Summaries.Single(s => s.RiskLevel == RiskLevel.High);
var medium = vm.Summaries.Single(s => s.RiskLevel == RiskLevel.Medium);
var low = vm.Summaries.Single(s => s.RiskLevel == RiskLevel.Low);
Assert.Equal(1, high.Count);
Assert.Equal(1, medium.Count);
Assert.Equal(1, low.Count);
}
}
@@ -1,190 +0,0 @@
using System.IO;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Auth;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
namespace SharepointToolbox.Tests.ViewModels;
[Trait("Category", "Unit")]
public class ProfileManagementViewModelLogoTests : IDisposable
{
private readonly string _tempFile;
private readonly Mock<IBrandingService> _mockBranding;
private readonly Mock<IAppRegistrationService> _mockAppReg;
private readonly GraphClientFactory _graphClientFactory;
private readonly ILogger<ProfileManagementViewModel> _logger;
public ProfileManagementViewModelLogoTests()
{
_tempFile = Path.GetTempFileName();
File.Delete(_tempFile);
_mockBranding = new Mock<IBrandingService>();
_mockAppReg = new Mock<IAppRegistrationService>();
_graphClientFactory = new GraphClientFactory(new MsalClientFactory());
_logger = NullLogger<ProfileManagementViewModel>.Instance;
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private ProfileManagementViewModel CreateViewModel()
{
var profileService = new ProfileService(new ProfileRepository(_tempFile));
return new ProfileManagementViewModel(
profileService,
_mockBranding.Object,
_graphClientFactory,
_logger,
_mockAppReg.Object);
}
[Fact]
public void Constructor_BrowseClientLogoCommand_IsNotNull()
{
var vm = CreateViewModel();
Assert.NotNull(vm.BrowseClientLogoCommand);
}
[Fact]
public void Constructor_ClearClientLogoCommand_IsNotNull()
{
var vm = CreateViewModel();
Assert.NotNull(vm.ClearClientLogoCommand);
}
[Fact]
public void Constructor_AutoPullClientLogoCommand_IsNotNull()
{
var vm = CreateViewModel();
Assert.NotNull(vm.AutoPullClientLogoCommand);
}
[Fact]
public void BrowseClientLogoCommand_CannotExecute_WhenNoProfileSelected()
{
var vm = CreateViewModel();
Assert.False(vm.BrowseClientLogoCommand.CanExecute(null));
}
[Fact]
public void ClearClientLogoCommand_CannotExecute_WhenNoProfileSelected()
{
var vm = CreateViewModel();
Assert.False(vm.ClearClientLogoCommand.CanExecute(null));
}
[Fact]
public void AutoPullClientLogoCommand_CannotExecute_WhenNoProfileSelected()
{
var vm = CreateViewModel();
Assert.False(vm.AutoPullClientLogoCommand.CanExecute(null));
}
[Fact]
public async Task ClearClientLogoCommand_ClearsClientLogo_AndPersists()
{
var profileService = new ProfileService(new ProfileRepository(_tempFile));
var profile = new TenantProfile
{
Name = "TestTenant",
TenantUrl = "https://test.sharepoint.com",
ClientId = "00000000-0000-0000-0000-000000000001",
ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" }
};
await profileService.AddProfileAsync(profile);
var vm = new ProfileManagementViewModel(
profileService,
_mockBranding.Object,
_graphClientFactory,
_logger,
_mockAppReg.Object);
vm.SelectedProfile = profile;
await vm.ClearClientLogoCommand.ExecuteAsync(null);
Assert.Null(profile.ClientLogo);
// Verify persisted
var profiles = await profileService.GetProfilesAsync();
var persisted = profiles.First(p => p.Name == "TestTenant");
Assert.Null(persisted.ClientLogo);
}
[Fact]
public void ClientLogoPreview_IsNull_WhenNoProfileSelected()
{
var vm = CreateViewModel();
Assert.Null(vm.ClientLogoPreview);
}
[Fact]
public void ClientLogoPreview_UpdatesToDataUri_WhenProfileWithLogoSelected()
{
var vm = CreateViewModel();
var profile = new TenantProfile
{
Name = "WithLogo",
TenantUrl = "https://test.sharepoint.com",
ClientId = "00000000-0000-0000-0000-000000000002",
ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" }
};
vm.SelectedProfile = profile;
Assert.Equal("data:image/png;base64,dGVzdA==", vm.ClientLogoPreview);
}
[Fact]
public void ClientLogoPreview_IsNull_WhenProfileWithoutLogoSelected()
{
var vm = CreateViewModel();
var profile = new TenantProfile
{
Name = "NoLogo",
TenantUrl = "https://test.sharepoint.com",
ClientId = "00000000-0000-0000-0000-000000000003"
};
vm.SelectedProfile = profile;
Assert.Null(vm.ClientLogoPreview);
}
[Fact]
public async Task ClearClientLogoCommand_SetsClientLogoPreviewToNull()
{
var profileService = new ProfileService(new ProfileRepository(_tempFile));
var profile = new TenantProfile
{
Name = "ClearTest",
TenantUrl = "https://test.sharepoint.com",
ClientId = "00000000-0000-0000-0000-000000000004",
ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" }
};
await profileService.AddProfileAsync(profile);
var vm = new ProfileManagementViewModel(
profileService,
_mockBranding.Object,
_graphClientFactory,
_logger,
_mockAppReg.Object);
vm.SelectedProfile = profile;
Assert.NotNull(vm.ClientLogoPreview);
await vm.ClearClientLogoCommand.ExecuteAsync(null);
Assert.Null(vm.ClientLogoPreview);
}
}
@@ -1,157 +0,0 @@
using System.IO;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Auth;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
namespace SharepointToolbox.Tests.ViewModels;
[Trait("Category", "Unit")]
public class ProfileManagementViewModelRegistrationTests : IDisposable
{
private readonly string _tempFile;
private readonly Mock<IBrandingService> _mockBranding;
private readonly Mock<IAppRegistrationService> _mockAppReg;
private readonly GraphClientFactory _graphClientFactory;
public ProfileManagementViewModelRegistrationTests()
{
_tempFile = Path.GetTempFileName();
File.Delete(_tempFile);
_mockBranding = new Mock<IBrandingService>();
_mockAppReg = new Mock<IAppRegistrationService>();
_graphClientFactory = new GraphClientFactory(new MsalClientFactory());
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private ProfileManagementViewModel CreateViewModel()
{
var profileService = new ProfileService(new ProfileRepository(_tempFile));
return new ProfileManagementViewModel(
profileService,
_mockBranding.Object,
_graphClientFactory,
NullLogger<ProfileManagementViewModel>.Instance,
_mockAppReg.Object);
}
private static TenantProfile MakeProfile(string? appId = null) => new TenantProfile
{
Name = "TestTenant",
TenantUrl = "https://test.sharepoint.com",
ClientId = "00000000-0000-0000-0000-000000000001",
AppId = appId
};
[Fact]
public void RegisterAppCommand_CanExecute_WhenProfileSelected_AndNoAppId()
{
var vm = CreateViewModel();
vm.SelectedProfile = MakeProfile(appId: null);
Assert.True(vm.RegisterAppCommand.CanExecute(null));
}
[Fact]
public void RegisterAppCommand_CannotExecute_WhenNoProfile()
{
var vm = CreateViewModel();
Assert.False(vm.RegisterAppCommand.CanExecute(null));
}
[Fact]
public void RemoveAppCommand_CanExecute_WhenProfileHasAppId()
{
var vm = CreateViewModel();
vm.SelectedProfile = MakeProfile(appId: "some-app-id");
Assert.True(vm.RemoveAppCommand.CanExecute(null));
}
[Fact]
public void RemoveAppCommand_CannotExecute_WhenNoAppId()
{
var vm = CreateViewModel();
vm.SelectedProfile = MakeProfile(appId: null);
Assert.False(vm.RemoveAppCommand.CanExecute(null));
}
[Fact]
public async Task RegisterApp_ShowsFallback_WhenNotAdmin()
{
_mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var vm = CreateViewModel();
vm.SelectedProfile = MakeProfile(appId: null);
await vm.RegisterAppCommand.ExecuteAsync(null);
Assert.True(vm.ShowFallbackInstructions);
}
[Fact]
public async Task RegisterApp_SetsAppId_OnSuccess()
{
_mockAppReg
.Setup(s => s.IsGlobalAdminAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockAppReg
.Setup(s => s.RegisterAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(AppRegistrationResult.Success("new-app-id-123"));
var profileService = new ProfileService(new ProfileRepository(_tempFile));
var profile = MakeProfile(appId: null);
await profileService.AddProfileAsync(profile);
var vm = new ProfileManagementViewModel(
profileService,
_mockBranding.Object,
_graphClientFactory,
NullLogger<ProfileManagementViewModel>.Instance,
_mockAppReg.Object);
vm.SelectedProfile = profile;
await vm.RegisterAppCommand.ExecuteAsync(null);
Assert.Equal("new-app-id-123", profile.AppId);
}
[Fact]
public async Task RemoveApp_ClearsAppId()
{
_mockAppReg
.Setup(s => s.RemoveAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_mockAppReg
.Setup(s => s.ClearMsalSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
var profileService = new ProfileService(new ProfileRepository(_tempFile));
var profile = MakeProfile(appId: "existing-app-id");
await profileService.AddProfileAsync(profile);
var vm = new ProfileManagementViewModel(
profileService,
_mockBranding.Object,
_graphClientFactory,
NullLogger<ProfileManagementViewModel>.Instance,
_mockAppReg.Object);
vm.SelectedProfile = profile;
await vm.RemoveAppCommand.ExecuteAsync(null);
Assert.Null(profile.AppId);
}
}
@@ -1,72 +0,0 @@
using System.IO;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
[Trait("Category", "Unit")]
public class SettingsViewModelLogoTests : IDisposable
{
private readonly string _tempFile;
public SettingsViewModelLogoTests()
{
_tempFile = Path.GetTempFileName();
File.Delete(_tempFile);
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private SettingsViewModel CreateViewModel(IBrandingService? brandingService = null)
{
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = brandingService ?? new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
public void Constructor_BrowseMspLogoCommand_IsNotNull()
{
var vm = CreateViewModel();
Assert.NotNull(vm.BrowseMspLogoCommand);
}
[Fact]
public void Constructor_ClearMspLogoCommand_IsNotNull()
{
var vm = CreateViewModel();
Assert.NotNull(vm.ClearMspLogoCommand);
}
[Fact]
public void Constructor_MspLogoPreview_IsNullByDefault()
{
var vm = CreateViewModel();
Assert.Null(vm.MspLogoPreview);
}
[Fact]
public async Task ClearMspLogoCommand_CallsClearMspLogoAsync_AndSetsMspLogoPreviewToNull()
{
var mockBranding = new Mock<IBrandingService>();
mockBranding.Setup(b => b.ClearMspLogoAsync()).Returns(Task.CompletedTask);
var vm = CreateViewModel(mockBranding.Object);
await vm.ClearMspLogoCommand.ExecuteAsync(null);
mockBranding.Verify(b => b.ClearMspLogoAsync(), Times.Once);
Assert.Null(vm.MspLogoPreview);
}
}
@@ -1,64 +0,0 @@
using System.IO;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
[Trait("Category", "Unit")]
public class SettingsViewModelOwnershipTests : IDisposable
{
private readonly string _tempFile;
public SettingsViewModelOwnershipTests()
{
_tempFile = Path.GetTempFileName();
File.Delete(_tempFile);
}
public void Dispose()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp");
}
private SettingsViewModel CreateViewModel()
{
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
public async Task LoadAsync_AutoTakeOwnership_LoadsFalseByDefault()
{
var vm = CreateViewModel();
await vm.LoadAsync();
Assert.False(vm.AutoTakeOwnership);
}
[Fact]
public async Task SetAutoTakeOwnership_True_CallsSetAutoTakeOwnershipAsync()
{
// Use a real SettingsService backed by temp file to verify persistence
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
var vm = new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
await vm.LoadAsync();
vm.AutoTakeOwnership = true;
// Small delay to let the fire-and-forget persist
await Task.Delay(100);
var persisted = await settingsService.GetSettingsAsync();
Assert.True(persisted.AutoTakeOwnership);
}
}
@@ -1,217 +0,0 @@
using System.Collections.ObjectModel;
using System.Reflection;
using CommunityToolkit.Mvvm.Messaging;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Unit tests for StorageViewModel chart functionality (Phase 09 Plan 04).
/// Verifies: chart series from metrics, bar series structure, donut/bar toggle,
/// top-10 + Other aggregation, no-Other for <=10, tenant switch cleanup, empty data.
/// Uses reflection to set FileTypeMetrics directly, bypassing ClientContext dependency.
/// </summary>
public class StorageViewModelChartTests
{
public StorageViewModelChartTests()
{
WeakReferenceMessenger.Default.Reset();
}
// -- Helper factories --------------------------------------------------------
private static StorageViewModel CreateViewModel()
{
var mockStorage = new Mock<IStorageService>();
var mockSession = new Mock<ISessionManager>();
var vm = new StorageViewModel(
mockStorage.Object,
mockSession.Object,
NullLogger<FeatureViewModelBase>.Instance);
vm.SetCurrentProfile(new TenantProfile
{
Name = "Test",
TenantUrl = "https://test.sharepoint.com",
ClientId = "test-id"
});
return vm;
}
/// <summary>
/// Sets FileTypeMetrics via the property (private setter) using reflection,
/// which also triggers UpdateChartSeries.
/// </summary>
private static void SetFileTypeMetrics(StorageViewModel vm, IList<FileTypeMetric> metrics)
{
var prop = typeof(StorageViewModel).GetProperty(
nameof(StorageViewModel.FileTypeMetrics),
BindingFlags.Public | BindingFlags.Instance);
prop!.SetValue(vm, new ObservableCollection<FileTypeMetric>(metrics));
}
private static List<FileTypeMetric> MakeMetrics(int count)
{
var extensions = new[]
{
".docx", ".pdf", ".xlsx", ".pptx", ".jpg",
".png", ".mp4", ".zip", ".csv", ".html",
".txt", ".json", ".xml", ".msg", ".eml"
};
var metrics = new List<FileTypeMetric>();
for (int i = 0; i < count; i++)
{
string ext = i < extensions.Length ? extensions[i] : $".ext{i}";
metrics.Add(new FileTypeMetric(ext, (count - i) * 1024L * 1024, (count - i) * 10));
}
return metrics;
}
// -- Test 1: Chart series populated from metrics -----------------------------
[Fact]
public void After_setting_metrics_HasChartData_is_true_and_PieChartSeries_has_entries()
{
var vm = CreateViewModel();
var metrics = MakeMetrics(5);
SetFileTypeMetrics(vm, metrics);
Assert.True(vm.HasChartData);
Assert.NotEmpty(vm.PieChartSeries);
Assert.Equal(5, vm.PieChartSeries.Count());
}
// -- Test 2: Bar series has one ColumnSeries with correct value count --------
[Fact]
public void After_setting_metrics_BarChartSeries_has_one_ColumnSeries_with_matching_values()
{
var vm = CreateViewModel();
var metrics = MakeMetrics(5);
SetFileTypeMetrics(vm, metrics);
var barSeries = vm.BarChartSeries.ToList();
Assert.Single(barSeries);
var columnSeries = Assert.IsType<ColumnSeries<long>>(barSeries[0]);
Assert.Equal(5, columnSeries.Values!.Count());
}
// -- Test 3: Toggle IsDonutChart changes PieChartSeries InnerRadius ----------
[Fact]
public void Toggle_IsDonutChart_changes_PieChartSeries_InnerRadius()
{
var vm = CreateViewModel();
var metrics = MakeMetrics(3);
SetFileTypeMetrics(vm, metrics);
// Initially IsDonutChart=true => InnerRadius=50
var pieBefore = vm.PieChartSeries.Cast<PieSeries<double>>().ToList();
Assert.All(pieBefore, s => Assert.Equal(50, s.InnerRadius));
// Toggle to bar (not donut) => InnerRadius=0
vm.IsDonutChart = false;
var pieAfter = vm.PieChartSeries.Cast<PieSeries<double>>().ToList();
Assert.All(pieAfter, s => Assert.Equal(0, s.InnerRadius));
}
// -- Test 4: More than 10 file types => 11 entries (10 + Other) --------------
[Fact]
public void More_than_10_metrics_produces_11_series_entries_with_Other()
{
var vm = CreateViewModel();
var metrics = MakeMetrics(15);
SetFileTypeMetrics(vm, metrics);
// Pie series: 10 real + 1 "Other" = 11
Assert.Equal(11, vm.PieChartSeries.Count());
// Last pie entry should be named "OTHER" (DisplayLabel uppercases extension)
var lastPie = vm.PieChartSeries.Last();
Assert.Equal("OTHER", lastPie.Name);
// Bar series column should have 11 values
var columnSeries = Assert.IsType<ColumnSeries<long>>(vm.BarChartSeries.First());
Assert.Equal(11, columnSeries.Values!.Count());
// X-axis should have 11 labels
Assert.Equal(11, vm.BarXAxes[0].Labels!.Count);
}
// -- Test 5: 10 or fewer file types => no "Other" entry ----------------------
[Fact]
public void Ten_or_fewer_metrics_produces_no_Other_entry()
{
var vm = CreateViewModel();
var metrics = MakeMetrics(10);
SetFileTypeMetrics(vm, metrics);
Assert.Equal(10, vm.PieChartSeries.Count());
// No entry named "OTHER" (DisplayLabel uppercases)
Assert.DoesNotContain(vm.PieChartSeries, s => s.Name == "OTHER");
}
// -- Test 6: Tenant switch clears chart data ---------------------------------
[Fact]
public void OnTenantSwitched_clears_FileTypeMetrics_and_HasChartData_is_false()
{
var vm = CreateViewModel();
var metrics = MakeMetrics(5);
SetFileTypeMetrics(vm, metrics);
Assert.True(vm.HasChartData);
// Act: send TenantSwitchedMessage
var newProfile = new TenantProfile
{
Name = "NewTenant",
TenantUrl = "https://newtenant.sharepoint.com",
ClientId = "new-id"
};
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(newProfile));
Assert.False(vm.HasChartData);
Assert.Empty(vm.FileTypeMetrics);
Assert.Empty(vm.PieChartSeries);
Assert.Empty(vm.BarChartSeries);
}
// -- Test 7: Empty metrics => HasChartData false, series empty ---------------
[Fact]
public void Empty_metrics_yields_HasChartData_false_and_empty_series()
{
var vm = CreateViewModel();
SetFileTypeMetrics(vm, new List<FileTypeMetric>());
Assert.False(vm.HasChartData);
Assert.Empty(vm.PieChartSeries);
Assert.Empty(vm.BarChartSeries);
Assert.Empty(vm.BarXAxes);
Assert.Empty(vm.BarYAxes);
}
}
@@ -1,406 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Unit tests for directory browse mode in UserAccessAuditViewModel (Phase 13 Plan 02).
/// Verifies: directory load, progress, cancellation, member/guest filter, text filter,
/// sorting, tenant switch reset, and no regression on search mode.
/// </summary>
[Trait("Category", "Unit")]
public class UserAccessAuditViewModelDirectoryTests
{
public UserAccessAuditViewModelDirectoryTests()
{
WeakReferenceMessenger.Default.Reset();
}
// ── Helper factories ──────────────────────────────────────────────────────
private static GraphDirectoryUser MakeMember(string name = "Alice", string dept = "IT", string jobTitle = "Engineer") =>
new(name, $"{name.ToLower().Replace(" ", "")}@contoso.com", null, dept, jobTitle, "Member");
private static GraphDirectoryUser MakeGuest(string name = "Bob External") =>
new(name, $"{name.ToLower().Replace(" ", "")}@external.com", null, null, null, "Guest");
private static (UserAccessAuditViewModel vm, Mock<IGraphUserDirectoryService> dirMock, Mock<IUserAccessAuditService> auditMock)
CreateViewModel(IReadOnlyList<GraphDirectoryUser>? directoryResult = null)
{
var mockAudit = new Mock<IUserAccessAuditService>();
var mockGraph = new Mock<IGraphUserSearchService>();
var mockSession = new Mock<ISessionManager>();
var mockDir = new Mock<IGraphUserDirectoryService>();
mockDir.Setup(s => s.GetUsersAsync(
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<IProgress<int>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(directoryResult ?? Array.Empty<GraphDirectoryUser>());
var vm = new UserAccessAuditViewModel(
mockAudit.Object,
mockGraph.Object,
mockSession.Object,
NullLogger<FeatureViewModelBase>.Instance,
graphUserDirectoryService: mockDir.Object);
vm._currentProfile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
return (vm, mockDir, mockAudit);
}
// ── Test 1: IsBrowseMode defaults to false ───────────────────────────────
[Fact]
public void IsBrowseMode_defaults_to_false()
{
var (vm, _, _) = CreateViewModel();
Assert.False(vm.IsBrowseMode);
}
// ── Test 2: DirectoryUsers is empty by default ───────────────────────────
[Fact]
public void DirectoryUsers_empty_by_default()
{
var (vm, _, _) = CreateViewModel();
Assert.Empty(vm.DirectoryUsers);
}
// ── Test 3: Commands are not null ─────────────────────────────────────────
[Fact]
public void LoadDirectoryCommand_and_CancelDirectoryLoadCommand_not_null()
{
var (vm, _, _) = CreateViewModel();
Assert.NotNull(vm.LoadDirectoryCommand);
Assert.NotNull(vm.CancelDirectoryLoadCommand);
}
// ── Test 4: LoadDirectoryAsync populates DirectoryUsers ──────────────────
[Fact]
public async Task LoadDirectoryAsync_populates_DirectoryUsers()
{
var users = new List<GraphDirectoryUser> { MakeMember("Alice"), MakeMember("Charlie") };
var (vm, _, _) = CreateViewModel(users);
await vm.TestLoadDirectoryAsync();
Assert.Equal(2, vm.DirectoryUsers.Count);
Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Alice");
Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Charlie");
}
// ── Test 5: LoadDirectoryAsync reports progress via DirectoryLoadStatus ──
[Fact]
public async Task LoadDirectoryAsync_sets_DirectoryLoadStatus_on_completion()
{
var users = new List<GraphDirectoryUser> { MakeMember("Alice") };
var (vm, _, _) = CreateViewModel(users);
await vm.TestLoadDirectoryAsync();
Assert.Equal("1 users loaded", vm.DirectoryLoadStatus);
}
// ── Test 6: LoadDirectoryAsync with no profile sets StatusMessage ─────────
[Fact]
public async Task LoadDirectoryAsync_with_no_profile_sets_StatusMessage()
{
var (vm, _, _) = CreateViewModel();
vm._currentProfile = null;
await vm.TestLoadDirectoryAsync();
Assert.Equal("No tenant profile selected. Please connect first.", vm.StatusMessage);
Assert.Empty(vm.DirectoryUsers);
}
// ── Test 7: CancelDirectoryLoadCommand cancels in-flight load ────────────
[Fact]
public async Task CancelDirectoryLoad_cancels_inflight_load()
{
var tcs = new TaskCompletionSource<IReadOnlyList<GraphDirectoryUser>>();
var mockDir = new Mock<IGraphUserDirectoryService>();
mockDir.Setup(s => s.GetUsersAsync(
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<IProgress<int>>(),
It.IsAny<CancellationToken>()))
.Returns<string, bool, IProgress<int>?, CancellationToken>((_, _, _, ct) =>
{
var localTcs = new TaskCompletionSource<IReadOnlyList<GraphDirectoryUser>>();
ct.Register(() => localTcs.TrySetCanceled(ct));
return localTcs.Task;
});
var vm = new UserAccessAuditViewModel(
new Mock<IUserAccessAuditService>().Object,
new Mock<IGraphUserSearchService>().Object,
new Mock<ISessionManager>().Object,
NullLogger<FeatureViewModelBase>.Instance,
graphUserDirectoryService: mockDir.Object);
vm._currentProfile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
// Start load (will block on the mock)
var loadTask = vm.TestLoadDirectoryAsync();
// Cancel
vm.CancelDirectoryLoadCommand.Execute(null);
await loadTask;
Assert.Equal("Load cancelled.", vm.DirectoryLoadStatus);
Assert.False(vm.IsLoadingDirectory);
}
// ── Test 8: IncludeGuests=false filters out Guest users ──────────────────
[Fact]
public void IncludeGuests_false_filters_out_guest_users()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeGuest("Bob External"));
vm.DirectoryUsers.Add(MakeMember("Charlie"));
vm.IncludeGuests = false;
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Equal(2, visible.Count);
Assert.All(visible, u => Assert.Equal("Member", u.UserType));
}
// ── Test 9: IncludeGuests=true shows all users ───────────────────────────
[Fact]
public void IncludeGuests_true_shows_all_users()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeGuest("Bob External"));
vm.IncludeGuests = true;
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Equal(2, visible.Count);
}
// ── Test 10: DirectoryFilterText filters by DisplayName ──────────────────
[Fact]
public void DirectoryFilterText_filters_by_DisplayName()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeMember("Charlie"));
vm.IncludeGuests = true;
vm.DirectoryFilterText = "Ali";
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Single(visible);
Assert.Equal("Alice", visible[0].DisplayName);
}
// ── Test 11: DirectoryFilterText filters by Department ───────────────────
[Fact]
public void DirectoryFilterText_filters_by_Department()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice", dept: "Engineering"));
vm.DirectoryUsers.Add(MakeMember("Charlie", dept: "Marketing"));
vm.IncludeGuests = true;
vm.DirectoryFilterText = "Market";
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Single(visible);
Assert.Equal("Charlie", visible[0].DisplayName);
}
// ── Test 12: DirectoryUsersView default sort is DisplayName ascending ────
[Fact]
public void DirectoryUsersView_sorted_by_DisplayName_ascending()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Charlie"));
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeMember("Bob"));
vm.IncludeGuests = true;
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Equal("Alice", visible[0].DisplayName);
Assert.Equal("Bob", visible[1].DisplayName);
Assert.Equal("Charlie", visible[2].DisplayName);
}
// ── Test 13: OnTenantSwitched clears directory state ─────────────────────
[Fact]
public async Task OnTenantSwitched_clears_directory_state()
{
var users = new List<GraphDirectoryUser> { MakeMember("Alice") };
var (vm, _, _) = CreateViewModel(users);
// Load directory
await vm.TestLoadDirectoryAsync();
Assert.NotEmpty(vm.DirectoryUsers);
vm.IsBrowseMode = true;
vm.DirectoryFilterText = "test";
vm.IncludeGuests = true;
// Act: switch tenant
var newProfile = new TenantProfile
{
Name = "NewTenant",
TenantUrl = "https://newtenant.sharepoint.com",
ClientId = "new-client-id"
};
WeakReferenceMessenger.Default.Send(new Core.Messages.TenantSwitchedMessage(newProfile));
// Assert
Assert.Empty(vm.DirectoryUsers);
Assert.False(vm.IsBrowseMode);
Assert.Empty(vm.DirectoryFilterText);
Assert.Empty(vm.DirectoryLoadStatus);
Assert.False(vm.IsLoadingDirectory);
Assert.False(vm.IncludeGuests);
}
// ── Test 14: DirectoryUserCount reflects filtered count ───────────────────
[Fact]
public void DirectoryUserCount_reflects_filtered_count()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice"));
vm.DirectoryUsers.Add(MakeGuest("Bob External"));
vm.DirectoryUsers.Add(MakeMember("Charlie"));
// With guests hidden (default IncludeGuests=false)
vm.IncludeGuests = false;
Assert.Equal(2, vm.DirectoryUserCount);
// With guests shown
vm.IncludeGuests = true;
Assert.Equal(3, vm.DirectoryUserCount);
// With text filter
vm.DirectoryFilterText = "Ali";
Assert.Equal(1, vm.DirectoryUserCount);
}
// ── Test 15: Search mode still works (no regression) ─────────────────────
[Fact]
public void Search_mode_SelectedUsers_still_works()
{
var (vm, _, _) = CreateViewModel();
// Search mode properties should be functional
Assert.Empty(vm.SelectedUsers);
vm.SelectedUsers.Add(new GraphUserResult("Alice Smith", "alice@contoso.com", "alice@contoso.com"));
Assert.Single(vm.SelectedUsers);
Assert.Equal("1 user(s) selected", vm.SelectedUsersLabel);
}
// ── Test 16: DirectoryFilterText filters by JobTitle ─────────────────────
[Fact]
public void DirectoryFilterText_filters_by_JobTitle()
{
var (vm, _, _) = CreateViewModel();
vm.DirectoryUsers.Add(MakeMember("Alice", jobTitle: "Senior Developer"));
vm.DirectoryUsers.Add(MakeMember("Charlie", jobTitle: "Product Manager"));
vm.IncludeGuests = true;
vm.DirectoryFilterText = "Developer";
var visible = vm.DirectoryUsersView.Cast<GraphDirectoryUser>().ToList();
Assert.Single(visible);
Assert.Equal("Alice", visible[0].DisplayName);
}
// ── Test 17: SelectDirectoryUserCommand adds user to SelectedUsers ──────
[Fact]
public void SelectDirectoryUserCommand_adds_user_to_SelectedUsers()
{
var (vm, _, _) = CreateViewModel();
var dirUser = MakeMember("Alice");
vm.SelectDirectoryUserCommand.Execute(dirUser);
Assert.Single(vm.SelectedUsers);
Assert.Equal("Alice", vm.SelectedUsers[0].DisplayName);
Assert.Equal("alice@contoso.com", vm.SelectedUsers[0].UserPrincipalName);
}
// ── Test 18: SelectDirectoryUserCommand skips duplicates ─────────────────
[Fact]
public void SelectDirectoryUserCommand_skips_duplicates()
{
var (vm, _, _) = CreateViewModel();
var dirUser = MakeMember("Alice");
vm.SelectDirectoryUserCommand.Execute(dirUser);
vm.SelectDirectoryUserCommand.Execute(dirUser);
Assert.Single(vm.SelectedUsers);
}
// ── Test 19: SelectDirectoryUserCommand with null does nothing ───────────
[Fact]
public void SelectDirectoryUserCommand_with_null_does_nothing()
{
var (vm, _, _) = CreateViewModel();
vm.SelectDirectoryUserCommand.Execute(null);
Assert.Empty(vm.SelectedUsers);
}
// ── Test 20: After SelectDirectoryUser, user can be audited ──────────────
[Fact]
public void SelectDirectoryUser_adds_auditable_user_to_SelectedUsers()
{
var (vm, _, _) = CreateViewModel();
var dirUser = MakeMember("Alice");
vm.SelectDirectoryUserCommand.Execute(dirUser);
Assert.True(vm.SelectedUsers.Count > 0);
Assert.Equal("alice@contoso.com", vm.SelectedUsers[0].UserPrincipalName);
}
}
@@ -1,283 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Unit tests for UserAccessAuditViewModel (Phase 7 Plan 08).
/// Verifies: AuditUsersAsync invocation, results population, summary properties,
/// tenant switch reset, global sites message, override guard, CanExport state.
/// </summary>
public class UserAccessAuditViewModelTests
{
// ── Reset messenger between tests ─────────────────────────────────────────
public UserAccessAuditViewModelTests()
{
WeakReferenceMessenger.Default.Reset();
}
// ── Helper factories ──────────────────────────────────────────────────────
private static UserAccessEntry MakeEntry(
string userLogin = "alice@contoso.com",
string siteUrl = "https://contoso.sharepoint.com",
bool isHighPrivilege = false) =>
new("Alice", userLogin, siteUrl, "Contoso", "List", "Docs",
siteUrl + "/Docs", "Read", AccessType.Direct, "Direct Permissions",
isHighPrivilege, false);
private static GraphUserResult MakeUser(
string display = "Alice Smith",
string upn = "alice@contoso.com") =>
new(display, upn, upn);
/// <summary>Creates a ViewModel wired with mock services.</summary>
private static (UserAccessAuditViewModel vm, Mock<IUserAccessAuditService> auditMock, Mock<IGraphUserSearchService> graphMock)
CreateViewModel(IReadOnlyList<UserAccessEntry>? auditResult = null)
{
var mockAudit = new Mock<IUserAccessAuditService>();
mockAudit
.Setup(s => s.AuditUsersAsync(
It.IsAny<ISessionManager>(),
It.IsAny<TenantProfile>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<IReadOnlyList<SiteInfo>>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(auditResult ?? Array.Empty<UserAccessEntry>());
var mockGraph = new Mock<IGraphUserSearchService>();
var mockSession = new Mock<ISessionManager>();
var vm = new UserAccessAuditViewModel(
mockAudit.Object,
mockGraph.Object,
mockSession.Object,
NullLogger<FeatureViewModelBase>.Instance);
// Set a default profile so RunOperationAsync doesn't early-return
vm._currentProfile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
return (vm, mockAudit, mockGraph);
}
// ── Test 1: RunOperation calls AuditUsersAsync ────────────────────────────
[Fact]
public async Task RunOperation_calls_AuditUsersAsync()
{
var (vm, auditMock, _) = CreateViewModel();
vm.SelectedUsers.Add(MakeUser());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
auditMock.Verify(
s => s.AuditUsersAsync(
It.IsAny<ISessionManager>(),
It.IsAny<TenantProfile>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<IReadOnlyList<SiteInfo>>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
// ── Test 2: RunOperation populates Results ────────────────────────────────
[Fact]
public async Task RunOperation_populates_results()
{
var entries = new List<UserAccessEntry>
{
MakeEntry(),
MakeEntry(userLogin: "bob@contoso.com")
};
var (vm, _, _) = CreateViewModel(entries);
vm.SelectedUsers.Add(MakeUser());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
Assert.Equal(2, vm.Results.Count);
}
// ── Test 3: RunOperation updates summary properties ───────────────────────
[Fact]
public async Task RunOperation_updates_summary_properties()
{
var entries = new List<UserAccessEntry>
{
MakeEntry(userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s1", isHighPrivilege: true),
MakeEntry(userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s2", isHighPrivilege: false),
MakeEntry(userLogin: "bob@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s1", isHighPrivilege: true)
};
var (vm, _, _) = CreateViewModel(entries);
vm.SelectedUsers.Add(MakeUser());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
Assert.Equal(3, vm.TotalAccessCount);
Assert.Equal(2, vm.SitesCount);
Assert.Equal(2, vm.HighPrivilegeCount);
}
// ── Test 4: OnTenantSwitched resets state ─────────────────────────────────
[Fact]
public async Task OnTenantSwitched_resets_state()
{
var entries = new List<UserAccessEntry> { MakeEntry() };
var (vm, _, _) = CreateViewModel(entries);
// Populate state
vm.SelectedUsers.Add(MakeUser());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
Assert.NotEmpty(vm.Results);
Assert.NotEmpty(vm.SelectedUsers);
// Act: send TenantSwitchedMessage
var newProfile = new TenantProfile
{
Name = "NewTenant",
TenantUrl = "https://newtenant.sharepoint.com",
ClientId = "new-client-id"
};
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(newProfile));
// Assert: state cleared
Assert.Empty(vm.Results);
Assert.Empty(vm.SelectedUsers);
Assert.Empty(vm.FilterText);
}
// ── Test 5: RunOperation uses GlobalSites directly ─────────────────────
[Fact]
public async Task RunOperation_fails_gracefully_without_global_sites()
{
var (vm, auditMock, _) = CreateViewModel();
vm.SelectedUsers.Add(MakeUser());
// Do NOT send GlobalSitesChangedMessage — no sites selected
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
// Should not call audit service — early return with status message
auditMock.Verify(
s => s.AuditUsersAsync(
It.IsAny<ISessionManager>(),
It.IsAny<TenantProfile>(),
It.IsAny<IReadOnlyList<string>>(),
It.IsAny<IReadOnlyList<SiteInfo>>(),
It.IsAny<ScanOptions>(),
It.IsAny<IProgress<OperationProgress>>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
// ── Test 7: CanExport false when no results ───────────────────────────────
[Fact]
public void CanExport_false_when_no_results()
{
var (vm, _, _) = CreateViewModel();
// Results is empty by default
Assert.Empty(vm.Results);
Assert.False(vm.ExportCsvCommand.CanExecute(null));
Assert.False(vm.ExportHtmlCommand.CanExecute(null));
}
// ── Test 8: CanExport true when has results ───────────────────────────────
[Fact]
public async Task CanExport_true_when_has_results()
{
var entries = new List<UserAccessEntry> { MakeEntry() };
var (vm, _, _) = CreateViewModel(entries);
vm.SelectedUsers.Add(MakeUser());
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
Assert.NotEmpty(vm.Results);
Assert.True(vm.ExportCsvCommand.CanExecute(null));
Assert.True(vm.ExportHtmlCommand.CanExecute(null));
}
// ── Test 9: Debounced search triggers SearchUsersAsync ───────────────────
[Fact]
public async Task SearchQuery_debounced_calls_SearchUsersAsync()
{
var graphResults = new List<GraphUserResult>
{
new("Alice Smith", "alice@contoso.com", "alice@contoso.com")
};
var (vm, _, graphMock) = CreateViewModel();
graphMock
.Setup(s => s.SearchUsersAsync(
It.IsAny<string>(),
It.Is<string>(q => q == "Ali"),
It.IsAny<int>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(graphResults);
// Set a TenantProfile so _currentProfile is non-null
var profile = new TenantProfile
{
Name = "Test",
TenantUrl = "https://contoso.sharepoint.com",
ClientId = "test-client-id"
};
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(profile));
// Act: set SearchQuery which triggers OnSearchQueryChanged → DebounceSearchAsync
vm.SearchQuery = "Ali";
// Wait longer than 300ms debounce to allow async fire-and-forget to complete
await Task.Delay(600);
// Assert: SearchUsersAsync was called with the query
graphMock.Verify(
s => s.SearchUsersAsync(
It.IsAny<string>(),
"Ali",
It.IsAny<int>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
}
-4
View File
@@ -1,4 +0,0 @@
<Solution>
<Project Path="SharepointToolbox.Tests/SharepointToolbox.Tests.csproj" />
<Project Path="SharepointToolbox/SharepointToolbox.csproj" />
</Solution>
-26
View File
@@ -1,26 +0,0 @@
<Application x:Class="SharepointToolbox.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharepointToolbox"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Themes/LightPalette.xaml" />
<ResourceDictionary Source="/Themes/ModernTheme.xaml" />
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<conv:IndentConverter x:Key="IndentConverter" />
<conv:BytesConverter x:Key="BytesConverter" />
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
-207
View File
@@ -1,207 +0,0 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Auth;
using SharepointToolbox.Infrastructure.Logging;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
using SharepointToolbox.Views.Dialogs;
using SharepointToolbox.Views.Tabs;
using System.Windows;
namespace SharepointToolbox;
public partial class App : Application
{
[STAThread]
public static void Main(string[] args)
{
using IHost host = Host.CreateDefaultBuilder(args)
.UseSerilog((ctx, cfg) => cfg
.WriteTo.File(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "logs", "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30))
.ConfigureServices(RegisterServices)
.Build();
host.Start();
// Apply persisted language before any UI is created so bindings resolve to the saved culture.
try
{
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(settings.Lang))
Localization.TranslationSource.Instance.CurrentCulture = new System.Globalization.CultureInfo(settings.Lang);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted language at startup");
}
App app = new();
app.InitializeComponent();
// Apply persisted theme (System/Light/Dark) before MainWindow constructs so brushes resolve correctly.
try
{
var theme = host.Services.GetRequiredService<ThemeManager>();
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
theme.ApplyFromString(settings.Theme);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted theme at startup");
}
var mainWindow = host.Services.GetRequiredService<MainWindow>();
// Wire LogPanelSink now that we have the RichTextBox
Log.Logger = new LoggerConfiguration()
.WriteTo.File(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "logs", "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30)
.WriteTo.Sink(new LogPanelSink(mainWindow.GetLogPanel()))
.CreateLogger();
// Global exception handlers
app.DispatcherUnhandledException += (s, e) =>
{
Log.Fatal(e.Exception, "Unhandled UI exception");
MessageBox.Show(
$"A fatal error occurred:\n{e.Exception.Message}\n\nCheck log for details.",
"Fatal Error", MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true;
};
TaskScheduler.UnobservedTaskException += (s, e) =>
{
Log.Fatal(e.Exception, "Unobserved task exception");
e.SetObserved();
};
app.MainWindow = mainWindow;
app.MainWindow.Visibility = Visibility.Visible;
app.Run();
}
private static void RegisterServices(HostBuilderContext ctx, IServiceCollection services)
{
var appData = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox");
services.AddSingleton(_ => new ProfileRepository(Path.Combine(appData, "profiles.json")));
services.AddSingleton(_ => new SettingsRepository(Path.Combine(appData, "settings.json")));
// Phase 10: Branding Data Foundation
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
services.AddSingleton<IBrandingService, BrandingService>();
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
services.AddSingleton<MsalClientFactory>();
services.AddSingleton<SessionManager>();
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<ThemeManager>();
services.AddSingleton<MainWindowViewModel>();
// Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory
services.AddTransient<ProfileManagementViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddTransient<ProfileManagementDialog>();
services.AddTransient<SettingsView>();
// Phase 3: Storage
services.AddTransient<IStorageService, StorageService>();
services.AddTransient<StorageCsvExportService>();
services.AddTransient<StorageHtmlExportService>();
services.AddTransient<StorageViewModel>();
services.AddTransient<StorageView>();
// Phase 3: File Search
services.AddTransient<ISearchService, SearchService>();
services.AddTransient<SearchCsvExportService>();
services.AddTransient<SearchHtmlExportService>();
services.AddTransient<SearchViewModel>();
services.AddTransient<SearchView>();
// Phase 3: Duplicates
services.AddTransient<IDuplicatesService, DuplicatesService>();
services.AddTransient<DuplicatesHtmlExportService>();
services.AddTransient<DuplicatesCsvExportService>();
services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>();
// Phase 2: Permissions
services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>();
services.AddTransient<CsvExportService>();
services.AddTransient<HtmlExportService>();
services.AddTransient<PermissionsViewModel>();
services.AddTransient<PermissionsView>();
services.AddTransient<SitePickerDialog>();
services.AddTransient<Func<TenantProfile, SitePickerDialog>>(sp =>
profile => new SitePickerDialog(sp.GetRequiredService<ISiteListService>(), profile));
// Phase 4: Bulk Operations Infrastructure
var templatesDir = Path.Combine(appData, "templates");
services.AddSingleton(_ => new TemplateRepository(templatesDir));
services.AddSingleton<GraphClientFactory>();
services.AddTransient<ICsvValidationService, CsvValidationService>();
services.AddTransient<BulkResultCsvExportService>();
// Phase 4: File Transfer
services.AddTransient<IFileTransferService, FileTransferService>();
services.AddTransient<TransferViewModel>();
services.AddTransient<TransferView>();
// Phase 4: Bulk Members
services.AddTransient<IBulkMemberService, BulkMemberService>();
services.AddTransient<BulkMembersViewModel>();
services.AddTransient<BulkMembersView>();
// Phase 4: Bulk Sites
services.AddTransient<IBulkSiteService, BulkSiteService>();
services.AddTransient<BulkSitesViewModel>();
services.AddTransient<BulkSitesView>();
// Phase 4: Templates
services.AddTransient<ITemplateService, TemplateService>();
services.AddTransient<TemplatesViewModel>();
services.AddTransient<TemplatesView>();
// Phase 4: Folder Structure
services.AddTransient<IFolderStructureService, FolderStructureService>();
services.AddTransient<FolderStructureViewModel>();
services.AddTransient<FolderStructureView>();
// Phase 17: Group Expansion
services.AddTransient<ISharePointGroupResolver, SharePointGroupResolver>();
// Phase 18: Auto-Take Ownership
services.AddTransient<IOwnershipElevationService, OwnershipElevationService>();
// Phase 19: App Registration & Removal
services.AddSingleton<IAppRegistrationService, AppRegistrationService>();
// Phase 7: User Access Audit
services.AddTransient<IUserAccessAuditService, UserAccessAuditService>();
services.AddTransient<IGraphUserSearchService, GraphUserSearchService>();
services.AddTransient<UserAccessCsvExportService>();
services.AddTransient<UserAccessHtmlExportService>();
services.AddTransient<UserAccessAuditViewModel>();
services.AddTransient<UserAccessAuditView>();
services.AddSingleton<MainWindow>();
}
}
-13
View File
@@ -1,13 +0,0 @@
using System.Runtime.CompilerServices;
using System.Windows;
[assembly: InternalsVisibleTo("SharepointToolbox.Tests")]
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
@@ -1,18 +0,0 @@
using System.Globalization;
using System.Windows.Data;
namespace SharepointToolbox.Core.Converters;
/// <summary>
/// Inverts a boolean value. Used for radio button binding where
/// one option is the inverse of the bound property.
/// </summary>
[ValueConversion(typeof(bool), typeof(bool))]
public class InvertBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : value;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : value;
}
@@ -1,45 +0,0 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Helpers;
public static class ExecuteQueryRetryHelper
{
private const int MaxRetries = 5;
/// <summary>
/// Executes a SharePoint query with automatic retry on throttle (429/503).
/// Surfaces retry events via progress for user visibility ("Throttled — retrying in 30s…").
/// </summary>
public static async Task ExecuteQueryRetryAsync(
ClientContext ctx,
IProgress<OperationProgress>? progress = null,
CancellationToken ct = default)
{
int attempt = 0;
while (true)
{
ct.ThrowIfCancellationRequested();
try
{
await ctx.ExecuteQueryAsync();
return;
}
catch (Exception ex) when (IsThrottleException(ex) && attempt < MaxRetries)
{
attempt++;
int delaySeconds = (int)Math.Pow(2, attempt) * 5; // 10, 20, 40, 80, 160s
progress?.Report(OperationProgress.Indeterminate(
$"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…"));
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
}
}
}
internal static bool IsThrottleException(Exception ex)
{
var msg = ex.Message;
return msg.Contains("429") || msg.Contains("503") ||
msg.Contains("throttl", StringComparison.OrdinalIgnoreCase);
}
}
@@ -1,59 +0,0 @@
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Helpers;
/// <summary>
/// Merges a flat list of UserAccessEntry rows into consolidated entries
/// where rows with identical (UserLogin, PermissionLevel, AccessType, GrantedThrough)
/// are grouped into a single row with multiple locations.
/// </summary>
public static class PermissionConsolidator
{
/// <summary>
/// Builds a pipe-delimited, case-insensitive composite key from the four key fields.
/// </summary>
internal static string MakeKey(UserAccessEntry entry)
{
return string.Join("|",
entry.UserLogin.ToLowerInvariant(),
entry.PermissionLevel.ToLowerInvariant(),
entry.AccessType.ToString(),
entry.GrantedThrough.ToLowerInvariant());
}
/// <summary>
/// Groups entries by composite key and returns consolidated rows.
/// Each group's first entry provides UserDisplayName, IsHighPrivilege, IsExternalUser.
/// All entries in a group contribute a LocationInfo to the Locations list.
/// Results are ordered by UserLogin then PermissionLevel.
/// </summary>
public static IReadOnlyList<ConsolidatedPermissionEntry> Consolidate(
IReadOnlyList<UserAccessEntry> entries)
{
if (entries.Count == 0)
return Array.Empty<ConsolidatedPermissionEntry>();
return entries
.GroupBy(e => MakeKey(e))
.Select(g =>
{
var first = g.First();
var locations = g.Select(e => new LocationInfo(
e.SiteUrl, e.SiteTitle, e.ObjectTitle, e.ObjectUrl, e.ObjectType
)).ToList();
return new ConsolidatedPermissionEntry(
first.UserDisplayName,
first.UserLogin,
first.PermissionLevel,
first.AccessType,
first.GrantedThrough,
first.IsHighPrivilege,
first.IsExternalUser,
locations);
})
.OrderBy(c => c.UserLogin)
.ThenBy(c => c.PermissionLevel)
.ToList();
}
}
@@ -1,30 +0,0 @@
namespace SharepointToolbox.Core.Helpers;
/// <summary>
/// Pure static helpers for classifying SharePoint permission entries.
/// </summary>
public static class PermissionEntryHelper
{
/// <summary>
/// Returns true when the login name is a B2B guest (contains #EXT#).
/// </summary>
public static bool IsExternalUser(string loginName) =>
loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Removes "Limited Access" from the supplied permission levels.
/// Returns the remaining levels; returns an empty list when all are removed.
/// </summary>
public static IReadOnlyList<string> FilterPermissionLevels(IEnumerable<string> levels) =>
levels
.Where(l => !string.Equals(l, "Limited Access", StringComparison.OrdinalIgnoreCase))
.ToList();
/// <summary>
/// Returns true when the login name represents an internal sharing-link group
/// or the "Limited Access System Group" pseudo-principal.
/// </summary>
public static bool IsSharingLinksGroup(string loginName) =>
loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase)
|| loginName.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
}
@@ -1,95 +0,0 @@
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Helpers;
/// <summary>
/// Maps SharePoint built-in permission level names to human-readable labels and risk levels.
/// Used by SimplifiedPermissionEntry and export services to translate raw role names
/// into plain-language descriptions that non-technical users can understand.
/// </summary>
public static class PermissionLevelMapping
{
/// <summary>
/// Result of looking up a SharePoint role name.
/// </summary>
public record MappingResult(string Label, RiskLevel RiskLevel);
/// <summary>
/// Known SharePoint built-in permission level mappings.
/// Keys are case-insensitive via the dictionary comparer.
/// </summary>
private static readonly Dictionary<string, MappingResult> Mappings = new(StringComparer.OrdinalIgnoreCase)
{
// High risk — full administrative access
["Full Control"] = new("Full control (can manage everything)", RiskLevel.High),
["Site Collection Administrator"] = new("Site collection admin (full control)", RiskLevel.High),
// Medium risk — can modify content
["Contribute"] = new("Can edit files and list items", RiskLevel.Medium),
["Edit"] = new("Can edit files, lists, and pages", RiskLevel.Medium),
["Design"] = new("Can edit pages and use design tools", RiskLevel.Medium),
["Approve"] = new("Can approve content and list items", RiskLevel.Medium),
["Manage Hierarchy"] = new("Can create sites and manage pages", RiskLevel.Medium),
// Low risk — read access
["Read"] = new("Can view files and pages", RiskLevel.Low),
["Restricted Read"] = new("Can view pages only (no download)", RiskLevel.Low),
// Read-only — most restricted
["View Only"] = new("Can view files in browser only", RiskLevel.ReadOnly),
["Restricted View"] = new("Restricted view access", RiskLevel.ReadOnly),
};
/// <summary>
/// Gets the human-readable label and risk level for a SharePoint role name.
/// Returns the mapped result for known roles; for unknown/custom roles,
/// returns the raw name as-is with Medium risk level.
/// </summary>
public static MappingResult GetMapping(string roleName)
{
if (string.IsNullOrWhiteSpace(roleName))
return new MappingResult(roleName, RiskLevel.Low);
return Mappings.TryGetValue(roleName.Trim(), out var result)
? result
: new MappingResult(roleName.Trim(), RiskLevel.Medium);
}
/// <summary>
/// Resolves a semicolon-delimited PermissionLevels string into individual mapping results.
/// This handles the PermissionEntry.PermissionLevels format (e.g. "Full Control; Contribute").
/// </summary>
public static IReadOnlyList<MappingResult> GetMappings(string permissionLevels)
{
if (string.IsNullOrWhiteSpace(permissionLevels))
return Array.Empty<MappingResult>();
return permissionLevels
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(GetMapping)
.ToList();
}
/// <summary>
/// Returns the highest (most dangerous) risk level from a semicolon-delimited permission levels string.
/// Used for row-level color coding when an entry has multiple roles.
/// </summary>
public static RiskLevel GetHighestRisk(string permissionLevels)
{
var mappings = GetMappings(permissionLevels);
if (mappings.Count == 0) return RiskLevel.Low;
// High < Medium < Low < ReadOnly in enum order — Min gives highest risk
return mappings.Min(m => m.RiskLevel);
}
/// <summary>
/// Converts a semicolon-delimited PermissionLevels string into a simplified labels string.
/// E.g. "Full Control; Contribute" becomes "Full control (can manage everything); Can edit files and list items"
/// </summary>
public static string GetSimplifiedLabels(string permissionLevels)
{
var mappings = GetMappings(permissionLevels);
return string.Join("; ", mappings.Select(m => m.Label));
}
}
@@ -1,56 +0,0 @@
using System.Runtime.CompilerServices;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Helpers;
public static class SharePointPaginationHelper
{
/// <summary>
/// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold.
/// Uses CamlQuery with RowLimit=2000 and ListItemCollectionPosition for pagination.
/// Never call ExecuteQuery directly on a list — always use this helper.
/// </summary>
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
ClientContext ctx,
List list,
CamlQuery? baseQuery = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000);
query.ListItemCollectionPosition = null;
do
{
ct.ThrowIfCancellationRequested();
var items = list.GetItems(query);
ctx.Load(items);
await ctx.ExecuteQueryAsync();
foreach (var item in items)
yield return item;
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (query.ListItemCollectionPosition != null);
}
internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
{
// Inject or replace RowLimit in existing CAML, or create minimal view
if (string.IsNullOrWhiteSpace(existingXml))
return $"<View><RowLimit>{rowLimit}</RowLimit></View>";
// Simple replacement approach — adequate for Phase 1
if (existingXml.Contains("<RowLimit>", StringComparison.OrdinalIgnoreCase))
{
return System.Text.RegularExpressions.Regex.Replace(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
$"<RowLimit>{rowLimit}</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
return existingXml.Replace("</View>", $"<RowLimit>{rowLimit}</RowLimit></View>",
StringComparison.OrdinalIgnoreCase);
}
}
@@ -1,9 +0,0 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Messages;
public sealed class GlobalSitesChangedMessage : ValueChangedMessage<IReadOnlyList<SiteInfo>>
{
public GlobalSitesChangedMessage(IReadOnlyList<SiteInfo> sites) : base(sites) { }
}
@@ -1,8 +0,0 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SharepointToolbox.Core.Messages;
public sealed class LanguageChangedMessage : ValueChangedMessage<string>
{
public LanguageChangedMessage(string cultureCode) : base(cultureCode) { }
}
@@ -1,9 +0,0 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Messages;
public sealed class ProgressUpdatedMessage : ValueChangedMessage<OperationProgress>
{
public ProgressUpdatedMessage(OperationProgress progress) : base(progress) { }
}
@@ -1,9 +0,0 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Core.Messages;
public sealed class TenantSwitchedMessage : ValueChangedMessage<TenantProfile>
{
public TenantSwitchedMessage(TenantProfile profile) : base(profile) { }
}
@@ -1,33 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Discriminated result type for app registration operations.
/// Use the static factory methods to construct instances.
/// </summary>
public class AppRegistrationResult
{
public bool IsSuccess { get; }
public bool IsFallback { get; }
public string? AppId { get; }
public string? ErrorMessage { get; }
private AppRegistrationResult(bool isSuccess, bool isFallback, string? appId, string? errorMessage)
{
IsSuccess = isSuccess;
IsFallback = isFallback;
AppId = appId;
ErrorMessage = errorMessage;
}
/// <summary>Registration succeeded; carries the newly-created appId.</summary>
public static AppRegistrationResult Success(string appId) =>
new(isSuccess: true, isFallback: false, appId: appId, errorMessage: null);
/// <summary>Registration failed; carries an error message.</summary>
public static AppRegistrationResult Failure(string errorMessage) =>
new(isSuccess: false, isFallback: false, appId: null, errorMessage: errorMessage);
/// <summary>User lacks the required permissions — caller should show fallback instructions.</summary>
public static AppRegistrationResult FallbackRequired() =>
new(isSuccess: false, isFallback: true, appId: null, errorMessage: null);
}
@@ -1,9 +0,0 @@
namespace SharepointToolbox.Core.Models;
public class AppSettings
{
public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en";
public bool AutoTakeOwnership { get; set; } = false;
public string Theme { get; set; } = "System"; // System | Light | Dark
}
@@ -1,6 +0,0 @@
namespace SharepointToolbox.Core.Models;
public class BrandingSettings
{
public LogoData? MspLogo { get; set; }
}
@@ -1,18 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace SharepointToolbox.Core.Models;
public class BulkMemberRow
{
[Name("GroupName")]
public string GroupName { get; set; } = string.Empty;
[Name("GroupUrl")]
public string GroupUrl { get; set; } = string.Empty;
[Name("Email")]
public string Email { get; set; } = string.Empty;
[Name("Role")]
public string Role { get; set; } = string.Empty; // "Member" or "Owner"
}
@@ -1,35 +0,0 @@
namespace SharepointToolbox.Core.Models;
public class BulkItemResult<T>
{
public T Item { get; }
public bool IsSuccess { get; }
public string? ErrorMessage { get; }
public DateTime Timestamp { get; }
private BulkItemResult(T item, bool success, string? error)
{
Item = item;
IsSuccess = success;
ErrorMessage = error;
Timestamp = DateTime.UtcNow;
}
public static BulkItemResult<T> Success(T item) => new(item, true, null);
public static BulkItemResult<T> Failed(T item, string error) => new(item, false, error);
}
public class BulkOperationSummary<T>
{
public IReadOnlyList<BulkItemResult<T>> Results { get; }
public int TotalCount => Results.Count;
public int SuccessCount => Results.Count(r => r.IsSuccess);
public int FailedCount => Results.Count(r => !r.IsSuccess);
public bool HasFailures => FailedCount > 0;
public IReadOnlyList<BulkItemResult<T>> FailedItems => Results.Where(r => !r.IsSuccess).ToList();
public BulkOperationSummary(IReadOnlyList<BulkItemResult<T>> results)
{
Results = results;
}
}
@@ -1,24 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace SharepointToolbox.Core.Models;
public class BulkSiteRow
{
[Name("Name")]
public string Name { get; set; } = string.Empty;
[Name("Alias")]
public string Alias { get; set; } = string.Empty;
[Name("Type")]
public string Type { get; set; } = string.Empty; // "Team" or "Communication"
[Name("Template")]
public string Template { get; set; } = string.Empty;
[Name("Owners")]
public string Owners { get; set; } = string.Empty; // comma-separated emails
[Name("Members")]
public string Members { get; set; } = string.Empty; // comma-separated emails
}
@@ -1,8 +0,0 @@
namespace SharepointToolbox.Core.Models;
public enum ConflictPolicy
{
Skip,
Overwrite,
Rename
}
@@ -1,21 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// A consolidated permission row produced by grouping UserAccessEntry rows
/// that share the same (UserLogin, PermissionLevel, AccessType, GrantedThrough) key.
/// All distinct locations for that key are collected into <see cref="Locations"/>.
/// </summary>
public record ConsolidatedPermissionEntry(
string UserDisplayName,
string UserLogin,
string PermissionLevel,
AccessType AccessType,
string GrantedThrough,
bool IsHighPrivilege,
bool IsExternalUser,
IReadOnlyList<LocationInfo> Locations
)
{
/// <summary>Convenience count — equals Locations.Count.</summary>
public int LocationCount => Locations.Count;
}
@@ -1,25 +0,0 @@
namespace SharepointToolbox.Core.Models;
public class CsvValidationRow<T>
{
public T? Record { get; }
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; }
public string? RawRecord { get; }
public CsvValidationRow(T record, List<string> errors)
{
Record = record;
Errors = errors;
}
private CsvValidationRow(string rawRecord, string parseError)
{
Record = default;
RawRecord = rawRecord;
Errors = new List<string> { parseError };
}
public static CsvValidationRow<T> ParseError(string? rawRecord, string error)
=> new(rawRecord ?? string.Empty, error);
}
@@ -1,8 +0,0 @@
namespace SharepointToolbox.Core.Models;
public class DuplicateGroup
{
public string GroupKey { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public List<DuplicateItem> Items { get; set; } = new();
}
@@ -1,13 +0,0 @@
namespace SharepointToolbox.Core.Models;
public class DuplicateItem
{
public string Name { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string Library { get; set; } = string.Empty;
public long? SizeBytes { get; set; }
public DateTime? Created { get; set; }
public DateTime? Modified { get; set; }
public int? FolderCount { get; set; }
public int? FileCount { get; set; }
}
@@ -1,12 +0,0 @@
namespace SharepointToolbox.Core.Models;
public record DuplicateScanOptions(
string Mode = "Files", // "Files" or "Folders"
bool MatchSize = true,
bool MatchCreated = false,
bool MatchModified = false,
bool MatchSubfolderCount = false,
bool MatchFileCount = false,
bool IncludeSubsites = false,
string? Library = null
);
@@ -1,21 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Represents storage consumption for a single file extension across all scanned libraries.
/// Produced by IStorageService.CollectFileTypeMetricsAsync and consumed by chart bindings.
/// </summary>
public record FileTypeMetric(
/// <summary>File extension including dot, e.g. ".docx", ".pdf". Empty string for extensionless files.</summary>
string Extension,
/// <summary>Total size in bytes of all files with this extension.</summary>
long TotalSizeBytes,
/// <summary>Number of files with this extension.</summary>
int FileCount)
{
/// <summary>
/// Human-friendly display label: ".docx" becomes "DOCX", empty becomes "No Extension".
/// </summary>
public string DisplayLabel => string.IsNullOrEmpty(Extension)
? "No Extension"
: Extension.TrimStart('.').ToUpperInvariant();
}
@@ -1,28 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace SharepointToolbox.Core.Models;
public class FolderStructureRow
{
[Name("Level1")]
public string Level1 { get; set; } = string.Empty;
[Name("Level2")]
public string Level2 { get; set; } = string.Empty;
[Name("Level3")]
public string Level3 { get; set; } = string.Empty;
[Name("Level4")]
public string Level4 { get; set; } = string.Empty;
/// <summary>
/// Builds the folder path from non-empty level values (e.g. "Admin/HR/Contracts").
/// </summary>
public string BuildPath()
{
var parts = new[] { Level1, Level2, Level3, Level4 }
.Where(s => !string.IsNullOrWhiteSpace(s));
return string.Join("/", parts);
}
}
@@ -1,13 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Represents a directory user returned by <see cref="SharepointToolbox.Services.IGraphUserDirectoryService"/>.
/// Used by Phase 13's User Directory ViewModel to display and filter tenant members.
/// </summary>
public record GraphDirectoryUser(
string DisplayName,
string UserPrincipalName,
string? Mail,
string? Department,
string? JobTitle,
string? UserType);
@@ -1,13 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Holds the five location-related fields extracted from a UserAccessEntry
/// when permission rows are merged into a consolidated entry.
/// </summary>
public record LocationInfo(
string SiteUrl,
string SiteTitle,
string ObjectTitle,
string ObjectUrl,
string ObjectType
);
@@ -1,7 +0,0 @@
namespace SharepointToolbox.Core.Models;
public record LogoData
{
public string Base64 { get; init; } = string.Empty;
public string MimeType { get; init; } = string.Empty;
}
@@ -1,7 +0,0 @@
namespace SharepointToolbox.Core.Models;
public record OperationProgress(int Current, int Total, string Message, bool IsIndeterminate = false)
{
public static OperationProgress Indeterminate(string message) =>
new(0, 0, message, IsIndeterminate: true);
}
@@ -1,18 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Flat record representing one permission assignment on a SharePoint object.
/// Mirrors the $entry object built by the PowerShell Generate-PnPSitePermissionRpt function.
/// </summary>
public record PermissionEntry(
string ObjectType, // "Site Collection" | "Site" | "List" | "Folder"
string Title,
string Url,
bool HasUniquePermissions,
string Users, // Semicolon-joined display names
string UserLogins, // Semicolon-joined login names
string PermissionLevels, // Semicolon-joined role names (Limited Access already removed)
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType, // "SharePointGroup" | "User" | "External User"
bool WasAutoElevated = false // Set to true when site admin was auto-granted to access this entry
);
@@ -1,64 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Summary counts of permission entries grouped by risk level.
/// Displayed in the summary panel when simplified mode is active.
/// </summary>
public record PermissionSummary(
/// <summary>Label for this group (e.g. "High Risk", "Read Only").</summary>
string Label,
/// <summary>The risk level this group represents.</summary>
RiskLevel RiskLevel,
/// <summary>Number of permission entries at this risk level.</summary>
int Count,
/// <summary>Number of distinct users at this risk level.</summary>
int DistinctUsers
);
/// <summary>
/// Computes PermissionSummary groups from SimplifiedPermissionEntry collections.
/// </summary>
public static class PermissionSummaryBuilder
{
/// <summary>
/// Risk level display labels.
/// </summary>
private static readonly Dictionary<RiskLevel, string> Labels = new()
{
[RiskLevel.High] = "High Risk",
[RiskLevel.Medium] = "Medium Risk",
[RiskLevel.Low] = "Low Risk",
[RiskLevel.ReadOnly] = "Read Only",
};
/// <summary>
/// Builds summary counts grouped by risk level from a collection of simplified entries.
/// Always returns all 4 risk levels, even if count is 0, for consistent UI binding.
/// </summary>
public static IReadOnlyList<PermissionSummary> Build(
IEnumerable<SimplifiedPermissionEntry> entries)
{
var grouped = entries
.GroupBy(e => e.RiskLevel)
.ToDictionary(g => g.Key, g => g.ToList());
return Enum.GetValues<RiskLevel>()
.Select(level =>
{
var items = grouped.GetValueOrDefault(level, new List<SimplifiedPermissionEntry>());
var distinctUsers = items
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
return new PermissionSummary(
Label: Labels[level],
RiskLevel: level,
Count: items.Count,
DistinctUsers: distinctUsers);
})
.ToList();
}
}
@@ -1,8 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Bundles MSP and client logos for passing to export services.
/// Export services receive this as a simple DTO — they don't know
/// about IBrandingService or ProfileService.
/// </summary>
public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo);
@@ -1,10 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Represents a resolved leaf member of a SharePoint group or nested AAD group.
/// Used by <see cref="SharepointToolbox.Services.ISharePointGroupResolver"/> to return
/// transitive member lists for HTML report group expansion (Phase 17).
/// </summary>
/// <param name="DisplayName">The display name of the member (e.g. "Alice Smith").</param>
/// <param name="Login">The login / UPN of the member (e.g. "alice@contoso.com"), with claims prefix stripped.</param>
public record ResolvedMember(string DisplayName, string Login);
@@ -1,17 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classifies a SharePoint permission level by its access risk.
/// Used for color coding in both WPF DataGrid and HTML export.
/// </summary>
public enum RiskLevel
{
/// <summary>Full Control, Site Collection Administrator — can delete site, manage permissions.</summary>
High,
/// <summary>Contribute, Edit, Design — can modify content.</summary>
Medium,
/// <summary>Read, Restricted View — can view but not modify.</summary>
Low,
/// <summary>View Only — most restricted legitimate access.</summary>
ReadOnly
}
@@ -1,12 +0,0 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Immutable scan configuration value object.
/// Controls which SharePoint objects are included in the permission scan.
/// </summary>
public record ScanOptions(
bool IncludeInherited = false, // When false: only objects with unique permissions are returned
bool ScanFolders = true, // Include folder-level permission entries
int FolderDepth = 1, // Max folder depth to scan (999 = unlimited)
bool IncludeSubsites = false // Whether to recursively scan subsites
);
@@ -1,15 +0,0 @@
namespace SharepointToolbox.Core.Models;
public record SearchOptions(
string[] Extensions,
string? Regex,
DateTime? CreatedAfter,
DateTime? CreatedBefore,
DateTime? ModifiedAfter,
DateTime? ModifiedBefore,
string? CreatedBy,
string? ModifiedBy,
string? Library,
int MaxResults,
string SiteUrl
);
@@ -1,13 +0,0 @@
namespace SharepointToolbox.Core.Models;
public class SearchResult
{
public string Title { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string FileExtension { get; set; } = string.Empty;
public DateTime? Created { get; set; }
public DateTime? LastModified { get; set; }
public string Author { get; set; } = string.Empty;
public string ModifiedBy { get; set; } = string.Empty;
public long SizeBytes { get; set; }
}
@@ -1,61 +0,0 @@
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Presentation wrapper around PermissionEntry that adds simplified labels
/// and risk level classification without modifying the immutable source record.
/// Used as the DataGrid ItemsSource when simplified mode is active.
/// </summary>
public class SimplifiedPermissionEntry
{
/// <summary>The original immutable PermissionEntry.</summary>
public PermissionEntry Inner { get; }
/// <summary>
/// Human-readable labels for the permission levels.
/// E.g. "Can edit files and list items" instead of "Contribute".
/// </summary>
public string SimplifiedLabels { get; }
/// <summary>
/// The highest risk level across all permission levels on this entry.
/// Used for row-level color coding.
/// </summary>
public RiskLevel RiskLevel { get; }
/// <summary>
/// Individual mapping results for each permission level in the entry.
/// Used when detailed breakdown per-role is needed.
/// </summary>
public IReadOnlyList<PermissionLevelMapping.MappingResult> Mappings { get; }
// ── Passthrough properties for DataGrid binding ──
public string ObjectType => Inner.ObjectType;
public string Title => Inner.Title;
public string Url => Inner.Url;
public bool HasUniquePermissions => Inner.HasUniquePermissions;
public string Users => Inner.Users;
public string UserLogins => Inner.UserLogins;
public string PermissionLevels => Inner.PermissionLevels;
public string GrantedThrough => Inner.GrantedThrough;
public string PrincipalType => Inner.PrincipalType;
public SimplifiedPermissionEntry(PermissionEntry entry)
{
Inner = entry;
Mappings = PermissionLevelMapping.GetMappings(entry.PermissionLevels);
SimplifiedLabels = PermissionLevelMapping.GetSimplifiedLabels(entry.PermissionLevels);
RiskLevel = PermissionLevelMapping.GetHighestRisk(entry.PermissionLevels);
}
/// <summary>
/// Creates SimplifiedPermissionEntry wrappers for a collection of entries.
/// </summary>
public static IReadOnlyList<SimplifiedPermissionEntry> WrapAll(
IEnumerable<PermissionEntry> entries)
{
return entries.Select(e => new SimplifiedPermissionEntry(e)).ToList();
}
}

Some files were not shown because too many files have changed in this diff Show More