Compare commits
336 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3abc39dbd5 | |||
|
|
d885431c90 | ||
|
|
d656788a9b | ||
|
|
cab8588569 | ||
| 422b56ebbe | |||
| d88b51fbff | |||
|
|
53ae3681bf | ||
|
|
06a3b5d512 | ||
|
|
5ed2f801af | ||
|
|
d8d25b967d | ||
| d41ff78e21 | |||
|
|
7af9bf2d5e | ||
|
|
baa3c7562d | ||
|
|
f41172c398 | ||
|
|
10e5ae9125 | ||
|
|
5d0b5cf85e | ||
|
|
809ac8613b | ||
|
|
42b5eda460 | ||
|
|
69c9d77be3 | ||
|
|
8083cdf7f5 | ||
|
|
93dbb8c5b0 | ||
|
|
7d200ecf3f | ||
|
|
0d087ae4cd | ||
|
|
bb3ba7b177 | ||
|
|
9549314f22 | ||
|
|
04a5b267b7 | ||
|
|
2302cad531 | ||
|
|
6270fe4605 | ||
|
|
11e835f586 | ||
|
|
20948e4bac | ||
|
|
36fb312b5a | ||
|
|
3479fff4c3 | ||
|
|
dbb59d119b | ||
|
|
997086cf07 | ||
|
|
23ed46e614 | ||
|
|
aab3aee3df | ||
|
|
07ed6e2515 | ||
|
|
c35ee76987 | ||
|
|
7bebbbcc02 | ||
|
|
1aa0d15e9a | ||
|
|
543b863283 | ||
|
|
0f8b1953e1 | ||
|
|
a374a4e1d3 | ||
|
|
57bfe3e5c1 | ||
|
|
a2c213b72d | ||
|
|
ddb1a28a9f | ||
|
|
1ff99f0bb7 | ||
|
|
0ebe707aca | ||
|
|
3d95d2aa8d | ||
|
|
8979becad2 | ||
|
|
28714fbebc | ||
|
|
4f7a6e3faa | ||
|
|
db42047db1 | ||
|
|
ed9f149b82 | ||
|
|
720a419788 | ||
|
|
68b123ff6c | ||
|
|
0336f4341f | ||
|
|
8f11699527 | ||
|
|
9c588a4389 | ||
|
|
fd67ee8b76 | ||
|
|
7b9f3e17aa | ||
|
|
9bfdfb77dd | ||
|
|
440b2474e9 | ||
|
|
270329bd82 | ||
|
|
f5b3f08f88 | ||
|
|
9031fd3473 | ||
|
|
e3ff27a673 | ||
|
|
d967a8bb65 | ||
|
|
4ad5f078c9 | ||
|
|
853f47c4a6 | ||
|
|
9318bb494d | ||
|
|
f41dbd333e | ||
|
|
b9511bd2b0 | ||
|
|
febb67ab64 | ||
|
|
1a1e83cfad | ||
|
|
f11bfefe52 | ||
|
|
d1282cea5d | ||
|
|
e6ba2d8146 | ||
|
|
381081da18 | ||
|
|
70e8d121fd | ||
|
|
df6f4949a8 | ||
|
|
4ba4de6106 | ||
|
|
cb7995ab31 | ||
|
|
9a98371edd | ||
|
|
0baa3695fe | ||
|
|
46c8467c92 | ||
|
|
ba81ea3cb7 | ||
|
|
b035e91120 | ||
|
|
c12ca4b813 | ||
|
|
6a4cd8ab56 | ||
|
|
0bc0babaf8 | ||
|
|
5d3fdee9da | ||
|
|
816fb5e3b5 | ||
|
|
e77455f03f | ||
|
|
d8b66169e6 | ||
|
|
2233fb86a9 | ||
|
|
2e8ceea279 | ||
|
|
b02b75e5bc | ||
|
|
d4fa402f04 | ||
|
|
212c43915e | ||
|
|
9e850b07f2 | ||
|
|
1ab2f2e426 | ||
|
|
0ab0a65e7a | ||
|
|
e9a1530120 | ||
|
|
9176ae7db9 | ||
|
|
7e8e228155 | ||
|
|
61d7ada945 | ||
|
|
188a8a7fff | ||
|
|
130386622f | ||
|
|
3ba574612f | ||
|
|
2280f12eab | ||
|
|
5e56a96cd0 | ||
|
|
1ffd71243e | ||
|
|
464b70ddcc | ||
|
|
e6fdccf19c | ||
|
|
59ff5184ff | ||
|
|
5ccf1688ea | ||
|
|
5f59e339ee | ||
|
|
8447e78db9 | ||
|
|
fd442f3b4c | ||
|
|
fa793c5489 | ||
|
|
713cf91d00 | ||
|
|
712b949eb2 | ||
|
|
e2321666c6 | ||
|
|
a8d79a8241 | ||
|
|
70048ddcdf | ||
|
|
3ec776ba81 | ||
|
|
81e3dcac6d | ||
|
|
18fe97f975 | ||
|
|
39c31dadfa | ||
|
|
60cbb977bf | ||
|
|
a63a698282 | ||
|
|
666e918810 | ||
|
|
22a51c05ef | ||
|
|
0f25fd67f8 | ||
|
|
a8a58f1ffc | ||
|
|
f503e6c0ca | ||
|
|
60ddcd781f | ||
|
|
1f5aa2b668 | ||
|
|
12d4932484 | ||
|
|
899ab7d175 | ||
|
|
163c506e0b | ||
|
|
fe19249f82 | ||
|
|
c970342497 | ||
|
|
e2c94bf6d1 | ||
|
|
3c70884022 | ||
|
|
6609f2a70a | ||
|
|
f1390eaa1c | ||
|
|
c871effa87 | ||
|
|
dcdbd8662d | ||
|
|
00252fd137 | ||
|
|
0af73df65c | ||
|
|
d7ff32ee94 | ||
|
|
67a2053a94 | ||
|
|
33833dce5d | ||
|
|
855e4df49b | ||
|
|
35b2c2a109 | ||
|
|
5df95032ee | ||
|
|
34c1776dcc | ||
|
|
a2531ea33f | ||
|
|
df796ee956 | ||
|
|
2ed8a0cb12 | ||
|
|
c42140db1a | ||
|
|
975762dee4 | ||
|
|
bb9ba9d310 | ||
|
|
72349d8415 | ||
|
|
3de737ac3f | ||
|
|
5c4a285473 | ||
|
|
85712ad3ba | ||
|
|
3146a04ad8 | ||
|
|
cc513777ec | ||
|
|
44b238e07a | ||
|
|
9f891aa512 | ||
|
|
026b8294de | ||
|
|
7e6f3e7fc0 | ||
|
|
1a6989a9bb | ||
|
|
e08df0f658 | ||
|
|
19e4c3852d | ||
|
|
91058bc2e4 | ||
|
|
ab253ca80a | ||
|
|
e96ca3edfe | ||
|
|
4846915c80 | ||
|
|
5666565ac1 | ||
|
|
52670bd262 | ||
|
|
9add2592b3 | ||
|
|
80ef092a2e | ||
|
|
da905b6ec0 | ||
|
|
0a91dd4ff3 | ||
|
|
9a4365bd32 | ||
|
|
6a2e4d1d89 | ||
|
|
45eb531128 | ||
|
|
467a940c6f | ||
|
|
1bf47b5c4e | ||
|
|
185642f4af | ||
|
|
a39c87d43e | ||
|
|
95bf9c2eed | ||
|
|
d4fe169bd8 | ||
|
|
a10f03edc8 | ||
|
|
7874fa8524 | ||
|
|
6ae3629301 | ||
|
|
59efdfe3f0 | ||
|
|
04a307b69c | ||
|
|
81da0f6a99 | ||
|
|
0fb35de80f | ||
|
|
724fdc550d | ||
|
|
b815c323d7 | ||
|
|
c81d8959f7 | ||
|
|
b3686cc24c | ||
|
|
e0e3d55013 | ||
|
|
0758ce9593 | ||
|
|
711f9502f2 | ||
|
|
8c6539440c | ||
|
|
39517d8956 | ||
|
|
f7829f0801 | ||
|
|
4d7e9ea02a | ||
|
|
0122a47c9e | ||
|
|
0dc2a2d8e4 | ||
|
|
af2177046f | ||
|
|
1d5dde9ceb | ||
|
|
3d62b2c48b | ||
|
|
a1c2a68cb5 | ||
|
|
988bca844b | ||
|
|
a49bbb9f98 | ||
|
|
87dd4bb3ef | ||
|
|
93218b0953 | ||
|
|
57f2c1d304 | ||
|
|
fcd5d1d938 | ||
|
|
7b78b19bf5 | ||
|
|
509c0c6843 | ||
|
|
1a2cc13224 | ||
|
|
fdb1108e76 | ||
|
|
84cd569fb7 | ||
|
|
773393c4c0 | ||
|
|
c4d8124a81 | ||
|
|
0cf6f50448 | ||
|
|
98fa16a195 | ||
|
|
f3a1c352c7 | ||
|
|
ac74d31933 | ||
|
|
b0956adaa3 | ||
|
|
fdcd4c8377 | ||
|
|
39deed9d8d | ||
|
|
d73e50948d | ||
|
|
97fc29c15e | ||
|
|
97d1e10faf | ||
|
|
6dd5faf65d | ||
|
|
43dd6ce17f | ||
|
|
1f2a49d7d3 | ||
|
|
0984a36bc7 | ||
|
|
7e6d39a3db | ||
|
|
50c7ab19f5 | ||
|
|
82acc81e13 | ||
|
|
fc1ba00aa8 | ||
|
|
e08452d1bf | ||
|
|
e174a18350 | ||
|
|
9a55c9e7d0 | ||
|
|
e83c4f34f1 | ||
|
|
47e6cf62d2 | ||
|
|
df5f79d1cb | ||
|
|
938de30437 | ||
|
|
9e3d5016e6 | ||
|
|
eafaa15459 | ||
|
|
94ff181035 | ||
|
|
3730b54527 | ||
|
|
556fad1377 | ||
|
|
b5df0641b0 | ||
|
|
08e4d2ee7d | ||
|
|
b52f60f8eb | ||
|
|
d09db015f2 | ||
|
|
20780318a3 | ||
|
|
80a3873a15 | ||
|
|
6e9a0033f2 | ||
|
|
afe69bd37f | ||
|
|
e74cffbe31 | ||
|
|
f98ca60990 | ||
|
|
c462a0b310 | ||
|
|
48ccf5891b | ||
|
|
7805e0b015 | ||
|
|
e3ab31937a | ||
|
|
44913f8075 | ||
|
|
ac86bbc302 | ||
|
|
0480f97059 | ||
|
|
9f2e2f9899 | ||
|
|
d17689cc46 | ||
|
|
c04d88882d | ||
|
|
83464a009c | ||
|
|
4a6594d9e8 | ||
|
|
57c258015b | ||
|
|
a9f6bde686 | ||
|
|
78b3d4f759 | ||
|
|
5c10840581 | ||
|
|
097d7b3326 | ||
|
|
55819bd059 | ||
|
|
031a7dbc0f | ||
|
|
27d654d86a | ||
|
|
62a7deb6e9 | ||
|
|
0b8a86a58a | ||
|
|
6211f65a5e | ||
|
|
c66efdadfa | ||
|
|
991c92e83a | ||
|
|
334a5f10ad | ||
|
|
405a013375 | ||
|
|
0665152e0d | ||
|
|
cb7cf93c52 | ||
|
|
b41599d95a | ||
|
|
5920d42614 | ||
|
|
3c09155648 | ||
|
|
fcae8f0e49 | ||
|
|
158aab96b2 | ||
|
|
02955199f6 | ||
|
|
466bef3e87 | ||
|
|
1c532d1f6b | ||
|
|
a287ed83ab | ||
|
|
8a58140f9b | ||
|
|
dd2f179c2d | ||
|
|
ac3fa5c8eb | ||
|
|
769196dabe | ||
|
|
ff29d4ec19 | ||
|
|
c2978016b0 | ||
|
|
ddb216b1fb | ||
|
|
41f8844a16 | ||
|
|
eac34e3e2c | ||
|
|
f469804810 | ||
|
|
b4a901e52a | ||
|
|
eeb9a3bcd1 | ||
|
|
ff5ac94ae2 | ||
|
|
f303a60018 | ||
|
|
eba593c7ef | ||
| 8102994aa5 | |||
| 8a393aa540 | |||
| 0c2e26e597 | |||
| d372fc10f2 | |||
| 1619cfbb7d | |||
| 63cf69f114 | |||
| 10bfe6debc | |||
| 945a4e110d | |||
| 109d0d5f1e |
@@ -1,46 +0,0 @@
|
|||||||
#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 }}"
|
|
||||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -1,8 +1,24 @@
|
|||||||
.claude
|
# Build outputs
|
||||||
*.html
|
bin/
|
||||||
*.json
|
obj/
|
||||||
!lang/
|
publish/
|
||||||
!lang/*.json
|
|
||||||
!wiki/
|
# IDE
|
||||||
!wiki/*.html
|
.vs/
|
||||||
!wiki/*.md
|
*.user
|
||||||
|
*.suo
|
||||||
|
release.ps1
|
||||||
|
Sharepoint_ToolBox.ps1
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
.planning/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.pfx
|
||||||
|
appsettings.*.json
|
||||||
|
Sharepoint_Settings.json
|
||||||
|
|||||||
131
README.md
131
README.md
@@ -1,124 +1,85 @@
|
|||||||

|

|
||||||
|
|
||||||
Application PowerShell avec interface graphique (WinForms) pour administrer, auditer et exporter des données depuis des sites SharePoint Online.
|
Application WPF (.NET 10) pour administrer, auditer et exporter des donnees depuis des sites SharePoint Online.
|
||||||
|
|
||||||
## Prérequis
|
## Installation
|
||||||
|
|
||||||
- **PowerShell 5.1** ou supérieur
|
1. Telecharger le zip depuis la [page Releases](https://git.azuze.fr/kawa/Sharepoint-Toolbox/releases)
|
||||||
- **Module PnP.PowerShell** (`Install-Module PnP.PowerShell`)
|
2. Extraire l'archive
|
||||||
- **[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)
|
3. Lancer **SharepointToolbox.exe** (necessite le runtime .NET 10)
|
||||||
- Accès au tenant SharePoint cible
|
|
||||||
|
|
||||||
## Lancement
|
## Prerequis
|
||||||
|
|
||||||
```powershell
|
- **Windows 10** ou superieur
|
||||||
.\Sharepoint_Toolbox.ps1
|
- **Runtime .NET 10** Desktop
|
||||||
```
|
- Acces au tenant SharePoint cible
|
||||||
|
|
||||||
---
|
## Fonctionnalites
|
||||||
|
|
||||||
## Fonctionnalités
|
|
||||||
|
|
||||||
### Connexion et profils
|
### Connexion et profils
|
||||||
|
|
||||||
- Saisie du **Tenant URL**, **Client ID** et **Site URL**
|
- Saisie du **Tenant URL** et **Client ID**
|
||||||
- **Profils sauvegardés** : créez, renommez, supprimez et chargez des profils de connexion réutilisables
|
- **Profils sauvegardes** : creez, renommez, supprimez et chargez des profils de connexion reutilisables
|
||||||
- **Sélecteur de sites** : parcourez et cochez plusieurs sites du tenant en une seule vue (chargement asynchrone)
|
- **Selecteur de sites** : parcourez et cochez plusieurs sites du tenant
|
||||||
- Dossier de sortie configurable pour tous les exports
|
- **Enregistrement d'app** : enregistrement automatique ou guide manuel de l'app Azure AD depuis le profil
|
||||||
|
- Support **multi-tenant** avec gestion des logos client
|
||||||
---
|
|
||||||
|
|
||||||
### Permissions Report
|
### Permissions Report
|
||||||
|
|
||||||
Audit complet des permissions d'un ou plusieurs sites.
|
Audit complet des permissions d'un ou plusieurs sites.
|
||||||
|
|
||||||
- Scan des **bibliothèques, listes et dossiers** (profondeur configurable ou illimitée)
|
- Scan des **bibliotheques, listes et dossiers** (profondeur configurable)
|
||||||
- Option **Recursive** pour inclure les sous-sites
|
- Inclusion optionnelle des permissions heritees et sous-sites
|
||||||
- Inclusion optionnelle des permissions héritées
|
- Mode **consolidation** : fusion des permissions identiques avec affichage des sites/bibliotheques
|
||||||
- Export **CSV** (données brutes, compatibles Excel) ou **HTML** (rapport visuel avec tableau interactif, filtrage, tri par colonne, regroupement par utilisateur/groupe)
|
- Export **CSV** ou **HTML** (rapport interactif avec filtrage, tri, regroupement par utilisateur/site)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Storage Metrics
|
### Storage Metrics
|
||||||
|
|
||||||
Analyse de l'occupation du stockage SharePoint.
|
Analyse de l'occupation du stockage SharePoint.
|
||||||
|
|
||||||
- Répartition **par bibliothèque** avec profondeur de dossiers configurable
|
- Repartition **par bibliotheque** avec profondeur de dossiers configurable
|
||||||
- Option d'inclusion des **sous-sites**
|
- Metriques : taille totale, taille des versions, nombre d'elements, derniere modification
|
||||||
- Métriques : taille totale, taille des versions, nombre d'éléments, dernière modification
|
- **Visualisation 3D** interactive du stockage
|
||||||
- Export **CSV** ou **HTML** (rapport avec graphiques de répartition et arborescence dépliable)
|
- Export **CSV** ou **HTML** (rapport avec graphiques de repartition)
|
||||||
|
|
||||||
---
|
### Annuaire utilisateurs
|
||||||
|
|
||||||
### Templates
|
- Liste complete des utilisateurs du tenant via Microsoft Graph
|
||||||
|
- Filtrage et recherche
|
||||||
Capture et réapplication de la structure d'un site SharePoint.
|
- Export **HTML**
|
||||||
|
|
||||||
- **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 de fichiers
|
||||||
|
|
||||||
Recherche avancée de fichiers à travers les bibliothèques d'un site.
|
Recherche avancee de fichiers a travers les bibliotheques d'un site.
|
||||||
|
|
||||||
| Filtre | Description |
|
| Filtre | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Extension(s) | Ex : `docx pdf xlsx` |
|
| Extension(s) | Ex : `docx pdf xlsx` |
|
||||||
| Nom / Regex | Expression régulière appliquée sur le chemin du fichier |
|
| Nom / Regex | Expression reguliere sur le chemin du fichier |
|
||||||
| Créé après / avant | Plage de dates de création |
|
| Cree apres / avant | Plage de dates de creation |
|
||||||
| Modifié après / avant | Plage de dates de modification |
|
| Modifie apres / avant | Plage de dates de modification |
|
||||||
| Créé par | Nom ou email de l'auteur |
|
| Cree par | Nom ou email de l'auteur |
|
||||||
| Modifié par | Nom ou email du dernier éditeur |
|
| Modifie par | Nom ou email du dernier editeur |
|
||||||
| Bibliothèque | Limite la recherche à un chemin relatif |
|
| Bibliotheque | Limite la recherche a 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.
|
Utilise la **Search API SharePoint (KQL)** avec pagination automatique.
|
||||||
|
|
||||||
Export **CSV** ou **HTML** (tableau trié par colonne, filtrage en temps réel, indicateurs de tri).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Doublons
|
### Doublons
|
||||||
|
|
||||||
Détection de fichiers ou dossiers en double au sein d'un site.
|
Detection de fichiers ou dossiers en double au sein d'un ou plusieurs sites.
|
||||||
|
|
||||||
**Type de scan :**
|
**Type de scan :** Fichiers (via Search API) ou Dossiers (via enumeration CAML)
|
||||||
- Fichiers en double (via Search API)
|
|
||||||
- Dossiers en double (via énumération des bibliothèques)
|
|
||||||
|
|
||||||
**Critères de comparaison (combinables) :**
|
**Criteres de comparaison (combinables) :** Nom, Taille, Date de creation, Date de modification, Nombre de sous-dossiers, Nombre de fichiers
|
||||||
- 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** ou **HTML** (cartes depliables avec mise en evidence des valeurs identiques/differentes).
|
||||||
|
|
||||||
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
|
## Architecture technique
|
||||||
|
|
||||||
- Interface **WinForms** (PowerShell natif, aucune dépendance UI externe)
|
- Interface **WPF** avec pattern **MVVM** (CommunityToolkit.Mvvm)
|
||||||
- Toutes les opérations longues s'exécutent dans des **runspaces séparés** pour ne pas bloquer l'interface
|
- Injection de dependances via Microsoft.Extensions.Hosting
|
||||||
- Communication runspace → UI via **hashtable synchronisée** + timer
|
- Authentification **MSAL** avec cache persistant et support broker WAM
|
||||||
- Module **PnP.PowerShell** pour toutes les interactions avec l'API SharePoint
|
- **Microsoft Graph SDK** pour les operations tenant/utilisateurs
|
||||||
|
- **PnP.Framework** (CSOM) pour les operations SharePoint
|
||||||
|
- Localisation **EN/FR** complete via fichiers .resx
|
||||||
|
- Branding configurable (logos MSP et client) dans les exports HTML
|
||||||
|
|||||||
75
SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs
Normal file
75
SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
SharepointToolbox.Tests/Auth/SessionManagerTests.cs
Normal file
103
SharepointToolbox.Tests/Auth/SessionManagerTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Infrastructure.Auth;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Auth;
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
256
SharepointToolbox.Tests/Helpers/PermissionConsolidatorTests.cs
Normal file
256
SharepointToolbox.Tests/Helpers/PermissionConsolidatorTests.cs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs
Normal file
178
SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
Normal file
130
SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
SharepointToolbox.Tests/Services/BrandingServiceTests.cs
Normal file
244
SharepointToolbox.Tests/Services/BrandingServiceTests.cs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
56
SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs
Normal file
56
SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
public class BulkMemberServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BulkMemberService_Implements_IBulkMemberService()
|
||||||
|
{
|
||||||
|
// GraphClientFactory requires MsalClientFactory which requires real MSAL setup
|
||||||
|
// Verify the type hierarchy at minimum
|
||||||
|
Assert.True(typeof(IBulkMemberService).IsAssignableFrom(typeof(BulkMemberService)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BulkMemberRow_DefaultValues()
|
||||||
|
{
|
||||||
|
var row = new BulkMemberRow();
|
||||||
|
Assert.Equal(string.Empty, row.Email);
|
||||||
|
Assert.Equal(string.Empty, row.GroupName);
|
||||||
|
Assert.Equal(string.Empty, row.GroupUrl);
|
||||||
|
Assert.Equal(string.Empty, row.Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BulkMemberRow_PropertiesSettable()
|
||||||
|
{
|
||||||
|
var row = new BulkMemberRow
|
||||||
|
{
|
||||||
|
Email = "user@test.com",
|
||||||
|
GroupName = "Marketing",
|
||||||
|
GroupUrl = "https://contoso.sharepoint.com/sites/Marketing",
|
||||||
|
Role = "Owner"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal("user@test.com", row.Email);
|
||||||
|
Assert.Equal("Marketing", row.GroupName);
|
||||||
|
Assert.Equal("Owner", row.Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint tenant and Graph permissions")]
|
||||||
|
public async Task AddMembersAsync_ValidRows_AddsToGroups()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint tenant")]
|
||||||
|
public async Task AddMembersAsync_InvalidEmail_ReportsPerItemError()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires Graph permissions - Group.ReadWrite.All")]
|
||||||
|
public async Task AddMembersAsync_M365Group_UsesGraphApi()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
105
SharepointToolbox.Tests/Services/BulkOperationRunnerTests.cs
Normal file
105
SharepointToolbox.Tests/Services/BulkOperationRunnerTests.cs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs
Normal file
56
SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
128
SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs
Normal file
128
SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
Normal file
80
SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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("<script>", 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
SharepointToolbox.Tests/Services/FileTransferServiceTests.cs
Normal file
57
SharepointToolbox.Tests/Services/FileTransferServiceTests.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
public class FileTransferServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FileTransferService_Implements_IFileTransferService()
|
||||||
|
{
|
||||||
|
var service = new FileTransferService();
|
||||||
|
Assert.IsAssignableFrom<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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
SharepointToolbox.Tests/Services/PermissionsServiceTests.cs
Normal file
31
SharepointToolbox.Tests/Services/PermissionsServiceTests.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
SharepointToolbox.Tests/Services/ProfileServiceTests.cs
Normal file
198
SharepointToolbox.Tests/Services/ProfileServiceTests.cs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
20
SharepointToolbox.Tests/Services/SearchServiceTests.cs
Normal file
20
SharepointToolbox.Tests/Services/SearchServiceTests.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
123
SharepointToolbox.Tests/Services/SettingsServiceTests.cs
Normal file
123
SharepointToolbox.Tests/Services/SettingsServiceTests.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
166
SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
Normal file
166
SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
SharepointToolbox.Tests/Services/SiteListServiceTests.cs
Normal file
21
SharepointToolbox.Tests/Services/SiteListServiceTests.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
SharepointToolbox.Tests/Services/StorageServiceTests.cs
Normal file
31
SharepointToolbox.Tests/Services/StorageServiceTests.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs
Normal file
107
SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
SharepointToolbox.Tests/Services/TemplateServiceTests.cs
Normal file
49
SharepointToolbox.Tests/Services/TemplateServiceTests.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
410
SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
Normal file
410
SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
Normal file
31
SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<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>
|
||||||
136
SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs
Normal file
136
SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
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();
|
||||||
|
vm.OperationFunc = async (ct, progress) =>
|
||||||
|
{
|
||||||
|
progress.Report(new OperationProgress(50, 100, "halfway"));
|
||||||
|
await Task.Yield();
|
||||||
|
};
|
||||||
|
|
||||||
|
await vm.RunCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// Allow dispatcher to process
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
Assert.Equal(50, vm.ProgressValue);
|
||||||
|
Assert.Equal("halfway", vm.StatusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
211
SharepointToolbox.Tests/ViewModels/GlobalSiteSelectionTests.cs
Normal file
211
SharepointToolbox.Tests/ViewModels/GlobalSiteSelectionTests.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs
Normal file
194
SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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, 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, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs
Normal file
217
SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
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
SharepointToolbox.slnx
Normal file
4
SharepointToolbox.slnx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="SharepointToolbox.Tests/SharepointToolbox.Tests.csproj" />
|
||||||
|
<Project Path="SharepointToolbox/SharepointToolbox.csproj" />
|
||||||
|
</Solution>
|
||||||
19
SharepointToolbox/App.xaml
Normal file
19
SharepointToolbox/App.xaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<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>
|
||||||
|
<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" />
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
180
SharepointToolbox/App.xaml.cs
Normal file
180
SharepointToolbox/App.xaml.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
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();
|
||||||
|
App app = new();
|
||||||
|
app.InitializeComponent();
|
||||||
|
|
||||||
|
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<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<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
SharepointToolbox/AssemblyInfo.cs
Normal file
13
SharepointToolbox/AssemblyInfo.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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)
|
||||||
|
)]
|
||||||
18
SharepointToolbox/Core/Converters/InvertBoolConverter.cs
Normal file
18
SharepointToolbox/Core/Converters/InvertBoolConverter.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
45
SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs
Normal file
45
SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
SharepointToolbox/Core/Helpers/PermissionConsolidator.cs
Normal file
59
SharepointToolbox/Core/Helpers/PermissionConsolidator.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs
Normal file
30
SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
95
SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs
Normal file
95
SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
56
SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs
Normal file
56
SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Core.Messages;
|
||||||
|
|
||||||
|
public sealed class LanguageChangedMessage : ValueChangedMessage<string>
|
||||||
|
{
|
||||||
|
public LanguageChangedMessage(string cultureCode) : base(cultureCode) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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) { }
|
||||||
|
}
|
||||||
9
SharepointToolbox/Core/Messages/TenantSwitchedMessage.cs
Normal file
9
SharepointToolbox/Core/Messages/TenantSwitchedMessage.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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) { }
|
||||||
|
}
|
||||||
33
SharepointToolbox/Core/Models/AppRegistrationResult.cs
Normal file
33
SharepointToolbox/Core/Models/AppRegistrationResult.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
8
SharepointToolbox/Core/Models/AppSettings.cs
Normal file
8
SharepointToolbox/Core/Models/AppSettings.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
6
SharepointToolbox/Core/Models/BrandingSettings.cs
Normal file
6
SharepointToolbox/Core/Models/BrandingSettings.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public class BrandingSettings
|
||||||
|
{
|
||||||
|
public LogoData? MspLogo { get; set; }
|
||||||
|
}
|
||||||
18
SharepointToolbox/Core/Models/BulkMemberRow.cs
Normal file
18
SharepointToolbox/Core/Models/BulkMemberRow.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
35
SharepointToolbox/Core/Models/BulkOperationResult.cs
Normal file
35
SharepointToolbox/Core/Models/BulkOperationResult.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
SharepointToolbox/Core/Models/BulkSiteRow.cs
Normal file
24
SharepointToolbox/Core/Models/BulkSiteRow.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
8
SharepointToolbox/Core/Models/ConflictPolicy.cs
Normal file
8
SharepointToolbox/Core/Models/ConflictPolicy.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public enum ConflictPolicy
|
||||||
|
{
|
||||||
|
Skip,
|
||||||
|
Overwrite,
|
||||||
|
Rename
|
||||||
|
}
|
||||||
21
SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs
Normal file
21
SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
25
SharepointToolbox/Core/Models/CsvValidationRow.cs
Normal file
25
SharepointToolbox/Core/Models/CsvValidationRow.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
8
SharepointToolbox/Core/Models/DuplicateGroup.cs
Normal file
8
SharepointToolbox/Core/Models/DuplicateGroup.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
13
SharepointToolbox/Core/Models/DuplicateItem.cs
Normal file
13
SharepointToolbox/Core/Models/DuplicateItem.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
12
SharepointToolbox/Core/Models/DuplicateScanOptions.cs
Normal file
12
SharepointToolbox/Core/Models/DuplicateScanOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
21
SharepointToolbox/Core/Models/FileTypeMetric.cs
Normal file
21
SharepointToolbox/Core/Models/FileTypeMetric.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
28
SharepointToolbox/Core/Models/FolderStructureRow.cs
Normal file
28
SharepointToolbox/Core/Models/FolderStructureRow.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
SharepointToolbox/Core/Models/GraphDirectoryUser.cs
Normal file
13
SharepointToolbox/Core/Models/GraphDirectoryUser.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
13
SharepointToolbox/Core/Models/LocationInfo.cs
Normal file
13
SharepointToolbox/Core/Models/LocationInfo.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
7
SharepointToolbox/Core/Models/LogoData.cs
Normal file
7
SharepointToolbox/Core/Models/LogoData.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public record LogoData
|
||||||
|
{
|
||||||
|
public string Base64 { get; init; } = string.Empty;
|
||||||
|
public string MimeType { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
7
SharepointToolbox/Core/Models/OperationProgress.cs
Normal file
7
SharepointToolbox/Core/Models/OperationProgress.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
public record OperationProgress(int Current, int Total, string Message)
|
||||||
|
{
|
||||||
|
public static OperationProgress Indeterminate(string message) =>
|
||||||
|
new(0, 0, message);
|
||||||
|
}
|
||||||
18
SharepointToolbox/Core/Models/PermissionEntry.cs
Normal file
18
SharepointToolbox/Core/Models/PermissionEntry.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
64
SharepointToolbox/Core/Models/PermissionSummary.cs
Normal file
64
SharepointToolbox/Core/Models/PermissionSummary.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
SharepointToolbox/Core/Models/ReportBranding.cs
Normal file
8
SharepointToolbox/Core/Models/ReportBranding.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
10
SharepointToolbox/Core/Models/ResolvedMember.cs
Normal file
10
SharepointToolbox/Core/Models/ResolvedMember.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
17
SharepointToolbox/Core/Models/RiskLevel.cs
Normal file
17
SharepointToolbox/Core/Models/RiskLevel.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
12
SharepointToolbox/Core/Models/ScanOptions.cs
Normal file
12
SharepointToolbox/Core/Models/ScanOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
15
SharepointToolbox/Core/Models/SearchOptions.cs
Normal file
15
SharepointToolbox/Core/Models/SearchOptions.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
13
SharepointToolbox/Core/Models/SearchResult.cs
Normal file
13
SharepointToolbox/Core/Models/SearchResult.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
61
SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs
Normal file
61
SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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
Reference in New Issue
Block a user