357 Commits

Author SHA1 Message Date
a257fbba0a Update README.md 2026-04-15 11:17:51 +02:00
Dev
b33c0769d4 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-15 11:16:24 +02:00
Dev
fec5ae26e1 chore: clean repo for v2.0 publish
- Remove .planning/ (251 GSD planning files)
- Remove old PowerShell-era files (TODO.md, lang/, examples/)
- Remove accidentally tracked zip
- Rewrite README for .NET WPF app
- Update .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:16:09 +02:00
bbfb9097ce Mise a jour du readme 2026-04-15 11:15:20 +02:00
3abc39dbd5 Update README.md 2026-04-09 17:12:55 +02:00
Dev
d885431c90 chore: clean repo for v2.0 publish
- Remove .planning/ (251 GSD planning files)
- Remove old PowerShell-era files (TODO.md, lang/, examples/)
- Remove accidentally tracked zip
- Rewrite README for .NET WPF app
- Update .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:08:49 +02:00
Dev
d656788a9b Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-09 17:05:32 +02:00
Dev
cab8588569 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox
Modified Gitignore
2026-04-09 17:05:07 +02:00
422b56ebbe Delete release.ps1 2026-04-09 17:05:03 +02:00
d88b51fbff Delete Sharepoint_ToolBox.ps1 2026-04-09 17:04:56 +02:00
Dev
53ae3681bf Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-09 17:03:56 +02:00
Dev
06a3b5d512 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox
Cleaned the repo
2026-04-09 17:03:45 +02:00
Dev
5ed2f801af Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-09 16:48:32 +02:00
Dev
d8d25b967d Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-09 16:48:28 +02:00
d41ff78e21 Delete Sharepoint_Settings.json 2026-04-09 16:47:44 +02:00
Dev
7af9bf2d5e Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-09 16:46:44 +02:00
Dev
baa3c7562d chore: prepare for v2.0 release
- Remove bin/obj/publish from git tracking
- Update .gitignore for .NET project (source only)
- Add release.ps1 local publish script (replaces Gitea workflow)
- Remove .gitea/workflows/release.yml
- Fix duplicate group names to show library names
- Fix HTML export to show Name column in duplicates report
- Fix consolidated permissions HTML to show folder/library names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:46:37 +02:00
Dev
f41172c398 chore: prepare for v2.0 release
- Remove bin/obj/publish from git tracking
- Update .gitignore for .NET project (source only)
- Add release.ps1 local publish script (replaces Gitea workflow)
- Remove .gitea/workflows/release.yml
- Fix duplicate group names to show library names
- Fix HTML export to show Name column in duplicates report
- Fix consolidated permissions HTML to show folder/library names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:42:12 +02:00
Dev
10e5ae9125 docs(phase-19): complete phase execution and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:23:58 +02:00
Dev
5d0b5cf85e docs(19-02): complete register/remove app UI plan
- 19-02-SUMMARY.md created
- STATE.md: progress 100%, decisions, session updated
- ROADMAP.md: phase 19 marked complete
- REQUIREMENTS.md: APPREG-01, APPREG-04, APPREG-05 marked complete
2026-04-09 15:20:55 +02:00
Dev
809ac8613b feat(19-02): add app registration UI to profile dialog and 7 ViewModel tests
- ProfileManagementDialog.xaml: height 750, new Row 4 with Register/Remove buttons
- BooleanToVisibilityConverter added to Window.Resources
- Fallback instructions panel bound to ShowFallbackInstructions
- RegistrationStatus text block with StringToVisibilityConverter
- Buttons row shifted to Row 5
- ProfileManagementViewModelRegistrationTests: 7 unit tests, all passing
- ProfileManagementViewModelLogoTests: updated to 5-param constructor
2026-04-09 15:19:37 +02:00
Dev
42b5eda460 feat(19-02): add RegisterApp/RemoveApp commands, DI wiring, EN/FR localization
- ProfileManagementViewModel: IAppRegistrationService injected, RegisterAppCommand/RemoveAppCommand added
- IsRegistering, ShowFallbackInstructions, RegistrationStatus observable properties
- HasRegisteredApp computed property, CanRegisterApp/CanRemoveApp guards
- RegisterAppAsync: admin check, fallback panel, AppId persistence
- RemoveAppAsync: removal + MSAL clear + AppId null + persistence
- App.xaml.cs: IAppRegistrationService singleton registered
- Strings.resx/fr.resx: 16 new localization keys for register/remove/fallback flow
2026-04-09 15:17:53 +02:00
Dev
69c9d77be3 docs(19-01): complete AppRegistrationService plan execution
- 19-01-SUMMARY.md: service layer implementation with rollback pattern
- STATE.md: progress 98%, decisions added, session updated
- ROADMAP.md: phase 19 in-progress (1/2 plans)
- REQUIREMENTS.md: APPREG-02, APPREG-03, APPREG-06 marked complete
2026-04-09 15:15:16 +02:00
Dev
8083cdf7f5 test(19-01): add unit tests for AppRegistrationService and models
- AppRegistrationResult factory methods (Success/Failure/FallbackRequired)
- TenantProfile.AppId null default and JSON round-trip
- AppRegistrationService implements IAppRegistrationService
- BuildRequiredResourceAccess structure (2 resources, 4+1 scopes, all Scope type)
2026-04-09 15:14:02 +02:00
Dev
93dbb8c5b0 feat(19-01): add AppRegistrationService with rollback, model, and interface
- AppRegistrationResult discriminated result (Success/Failure/FallbackRequired)
- TenantProfile.AppId nullable string for storing registered app ID
- IAppRegistrationService interface (IsGlobalAdminAsync, RegisterAsync, RemoveAsync, ClearMsalSessionAsync)
- AppRegistrationService: sequential registration with rollback, transitiveMemberOf admin check, MSAL eviction
2026-04-09 15:12:51 +02:00
Dev
7d200ecf3f docs(19): create phase plan for app registration and removal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:48:49 +02:00
Dev
0d087ae4cd docs(phase-19): add research and validation strategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:44:07 +02:00
Dev
bb3ba7b177 docs(phase-19): research app registration & removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:43:00 +02:00
Dev
9549314f22 docs(phase-18): complete phase execution and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:37:18 +02:00
Dev
04a5b267b7 docs(18-02): complete scan-loop elevation plan
- 18-02-SUMMARY.md: elevation logic, DataGrid visual, 8 new tests
- STATE.md: position advanced, decisions recorded, session updated
- ROADMAP.md: phase 18 marked complete (2/2 summaries)
- REQUIREMENTS.md: OWN-02 marked complete
2026-04-09 14:34:34 +02:00
Dev
2302cad531 feat(18-02): DataGrid visual differentiation + localization for elevated rows
- Add WasAutoElevated DataTrigger to DataGrid.RowStyle: amber background + tooltip
- Add warning icon (U+26A0) indicator column (width 24) before Object Type column
- Icon shown via DataTrigger on WasAutoElevated, hidden by default
- Add permissions.elevated.tooltip EN key to Strings.resx
- Add permissions.elevated.tooltip FR key to Strings.fr.resx
2026-04-09 14:33:00 +02:00
Dev
6270fe4605 feat(18-02): scan-loop elevation logic + PermissionsViewModel wiring + tests
- Add _settingsService and _ownershipService fields to PermissionsViewModel
- Add SettingsService? and IOwnershipElevationService? to both constructors
- Add DeriveAdminUrl internal static helper for admin URL derivation
- Add IsAccessDenied helper catching ServerUnauthorizedAccessException + WebException 403
- Add IsAutoTakeOwnershipEnabled async helper reading toggle from SettingsService
- Refactor RunOperationAsync with try/catch elevation pattern (read toggle before loop)
- Tag elevated entries with WasAutoElevated=true via record with expression
- Add PermissionsViewModelOwnershipTests (8 tests): toggle OFF propagates, toggle ON elevates+retries, no elevation on success, WasAutoElevated tagging, elevation throw propagates, DeriveAdminUrl theory
2026-04-09 14:31:58 +02:00
Dev
11e835f586 docs(18-01): complete auto-take-ownership settings foundation plan
- 18-01-SUMMARY.md: plan execution summary
- STATE.md: progress updated to 98%, decisions recorded, stopped-at updated
- ROADMAP.md: phase 18 marked in-progress (1/2 summaries)
- REQUIREMENTS.md: OWN-01 marked complete
2026-04-09 14:25:47 +02:00
Dev
20948e4bac feat(18-01): SettingsView ownership checkbox + EN/FR localization keys
- SettingsView.xaml: Auto-Take Ownership section with CheckBox bound to AutoTakeOwnership
- Strings.resx: settings.ownership.title/auto/description keys (EN)
- Strings.fr.resx: matching French translations
2026-04-09 14:24:31 +02:00
Dev
36fb312b5a feat(18-01): models, SettingsService, OwnershipElevationService + tests
- AppSettings.AutoTakeOwnership bool property defaulting to false
- PermissionEntry.WasAutoElevated optional param (default false, last position)
- SettingsService.SetAutoTakeOwnershipAsync persists toggle
- IOwnershipElevationService interface + OwnershipElevationService wrapping Tenant.SetSiteAdmin
- SettingsViewModel.AutoTakeOwnership property loads and persists via SetAutoTakeOwnershipAsync
- DI registration in App.xaml.cs (Phase 18 section)
- 8 new tests: models, persistence, service, viewmodel
2026-04-09 14:23:08 +02:00
Dev
3479fff4c3 docs(18): complete phase research, validation, and plans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:17:00 +02:00
Dev
dbb59d119b docs(18): create phase plan for auto-take-ownership
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:15:15 +02:00
Dev
997086cf07 docs(phase-17): complete phase execution and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:13:46 +02:00
Dev
23ed46e614 docs(17-02): complete group expansion HTML reports plan
- 17-02-SUMMARY.md created
- STATE.md updated: session, decisions, progress
- ROADMAP.md: phase 17 marked complete (2/2 plans)
- REQUIREMENTS.md: RPT-01 marked complete
2026-04-09 13:11:21 +02:00
Dev
aab3aee3df feat(17-02): wire ISharePointGroupResolver into PermissionsViewModel export flow
- Add _groupResolver field (ISharePointGroupResolver?) with constructor injection
- ISharePointGroupResolver added as optional last parameter in main constructor
- ExportHtmlAsync resolves SharePoint group names before calling WriteAsync
- Gracefully handles resolution failure with LogWarning, exports without expansion
- Both WriteAsync call sites pass groupMembers dict (standard and simplified paths)
2026-04-09 13:10:31 +02:00
Dev
07ed6e2515 feat(17-02): extend HtmlExportService with expandable group pills and toggleGroup JS
- Add optional groupMembers parameter to both BuildHtml overloads and WriteAsync methods
- SharePoint group pills render as expandable with onclick toggleGroup when groupMembers provided
- Hidden member sub-rows injected after parent row with resolved member names
- Empty member list renders 'members unavailable' fallback label
- toggleGroup JS function added to inline script block in both overloads
- filterTable updated to skip data-group sub-rows
- CSS for .group-expandable added to both overloads
- Backward compatibility: null groupMembers produces identical output to pre-Phase 17
2026-04-09 13:09:38 +02:00
Dev
c35ee76987 test(17-02): add failing tests for group pill expansion and backward compatibility
- BuildHtml_NoGroupMembers_IdenticalToDefault
- BuildHtml_WithGroupMembers_RendersExpandablePill
- BuildHtml_WithGroupMembers_RendersHiddenMemberSubRow
- BuildHtml_WithEmptyMemberList_RendersMembersUnavailable
- BuildHtml_ContainsToggleGroupJs
- BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill
2026-04-09 13:07:46 +02:00
Dev
7bebbbcc02 docs(17-01): complete SharePointGroupResolver service plan - SUMMARY, STATE, ROADMAP updated 2026-04-09 13:06:16 +02:00
Dev
1aa0d15e9a feat(17-01): register ISharePointGroupResolver in DI container (App.xaml.cs) 2026-04-09 13:05:09 +02:00
Dev
543b863283 feat(17-01): ResolvedMember model, ISharePointGroupResolver interface, SharePointGroupResolver CSOM+Graph implementation
- ResolvedMember record in Core/Models with DisplayName and Login
- ISharePointGroupResolver interface with ResolveGroupsAsync contract
- SharePointGroupResolver: CSOM group user loading + Graph transitive AAD resolution
- Internal static helpers IsAadGroup, ExtractAadGroupId, StripClaims (all green unit tests)
- Graceful error handling: exceptions return empty list per group, never throw
- OrdinalIgnoreCase result dict; lazy Graph client creation on first AAD group
2026-04-09 13:04:56 +02:00
Dev
0f8b1953e1 test(17-01): add failing tests for SharePointGroupResolver static helpers and empty-list contract 2026-04-09 13:03:27 +02:00
Dev
a374a4e1d3 docs(17): create phase plan for group expansion in HTML reports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:59:12 +02:00
Dev
57bfe3e5c1 docs(phase-17): add research and validation strategy 2026-04-09 12:53:15 +02:00
Dev
a2c213b72d docs(phase-17): research group expansion in HTML reports 2026-04-09 12:51:50 +02:00
Dev
ddb1a28a9f docs(phase-16): complete phase execution and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:12 +02:00
Dev
1ff99f0bb7 docs(16-02): complete consolidated HTML export plan
- SUMMARY.md: BuildConsolidatedHtml with expandable location sub-lists, by-site view suppression, ViewModel wiring
- STATE.md: updated position, decisions, session
- ROADMAP.md: phase 16 marked Complete (2/2 plans with summaries)
2026-04-09 12:40:00 +02:00
Dev
0ebe707aca feat(16-02): implement consolidated HTML rendering path
- Add mergePermissions parameter to BuildHtml and WriteAsync
- Early-return branch calls PermissionConsolidator.Consolidate and delegates to BuildConsolidatedHtml
- BuildConsolidatedHtml: by-user table with Sites column, expandable [N sites] badge with toggleGroup, hidden sub-rows (data-group=locN), inline title for single-location entries
- By-site view and btn-site omitted when mergePermissions=true
- Wire UserAccessAuditViewModel.ExportHtmlAsync to pass MergePermissions
- Fix existing branding test call site to use named parameter
2026-04-09 12:38:19 +02:00
Dev
3d95d2aa8d test(16-02): add failing tests for RPT-03-b through RPT-03-e
- RPT-03-b: mergePermissions=false output identical to default
- RPT-03-c: mergePermissions=true contains Sites column header
- RPT-03-d: 2+ locations produce badge + hidden sub-rows with toggleGroup
- RPT-03-e: mergePermissions=true omits btn-site and view-site
2026-04-09 12:36:35 +02:00
Dev
8979becad2 docs(16-01): complete MergePermissions toggle and consolidated CSV export plan
- 16-01-SUMMARY.md created with all task outcomes and verification results
- STATE.md updated with decisions, session info, progress bar (98%)
- ROADMAP.md updated: phase 16 in-progress (1/2 summaries complete)
- REQUIREMENTS.md: RPT-03 marked complete
2026-04-09 12:35:07 +02:00
Dev
28714fbebc feat(16-01): implement consolidated CSV export path and wire ViewModel call site
- Added mergePermissions=false optional parameter to WriteSingleFileAsync
- Added early-return consolidated branch using PermissionConsolidator.Consolidate
- Consolidated CSV uses distinct header with Locations and LocationCount columns
- Locations column is semicolon-separated site titles for multi-location rows
- Existing non-consolidated code path is completely unchanged
- UserAccessAuditViewModel.ExportCsvAsync now passes MergePermissions to service
2026-04-09 12:33:54 +02:00
Dev
4f7a6e3faa test(16-01): add failing tests for RPT-03-f and RPT-03-g (consolidated CSV export)
- RPT-03-f: mergePermissions=false produces byte-identical output to default call
- RPT-03-g: mergePermissions=true writes consolidated header and merged rows
- Edge case: single-location entry has LocationCount=1 with no semicolons in Locations
2026-04-09 12:32:42 +02:00
Dev
db42047db1 feat(16-01): add Export Options GroupBox with MergePermissions checkbox to both XAML views
- Added Export Options GroupBox after Scan Options in UserAccessAuditView.xaml
- Added Export Options GroupBox after Display Options in PermissionsView.xaml
- Both checkboxes bind to MergePermissions with localized labels via TranslationSource
2026-04-09 12:32:08 +02:00
Dev
ed9f149b82 feat(16-01): add MergePermissions property to both ViewModels and localization keys
- Added [ObservableProperty] _mergePermissions (defaults false) to UserAccessAuditViewModel
- Added [ObservableProperty] _mergePermissions (no-op placeholder) to PermissionsViewModel
- Added audit.grp.export and chk.merge.permissions keys to Strings.resx (EN)
- Added audit.grp.export and chk.merge.permissions keys to Strings.fr.resx (FR)
2026-04-09 12:31:46 +02:00
Dev
720a419788 docs(16-report-consolidation-toggle): create phase plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:19:06 +02:00
Dev
68b123ff6c docs(16): add research and validation strategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:13:30 +02:00
Dev
0336f4341f docs(phase-16): research report consolidation toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:12:13 +02:00
Dev
8f11699527 docs(16): gather phase context via discuss-phase
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:07:43 +02:00
Dev
9c588a4389 docs(phase-15): complete phase execution and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:49:34 +02:00
Dev
fd67ee8b76 docs(15-02): complete PermissionConsolidator unit tests plan
- 9 tests pass covering RPT-04-a through RPT-04-i
- Full solution builds with 0 errors, 321 tests pass
- STATE.md updated, ROADMAP.md phase 15 marked Complete
2026-04-09 11:46:43 +02:00
Dev
7b9f3e17aa test(15-02): add PermissionConsolidatorTests with 9 test cases (RPT-04-a through RPT-04-i)
- RPT-04-a: empty input returns empty list
- RPT-04-b: single entry -> 1 row with 1 location
- RPT-04-c: 3 entries same key -> 1 row with 3 locations
- RPT-04-d: different PermissionLevel -> separate rows
- RPT-04-e: case-insensitive key merges ALICE@ and alice@
- RPT-04-f: MakeKey produces pipe-delimited lowercase format
- RPT-04-g: 11-row input with 3 merge groups -> 7 consolidated rows
- RPT-04-h: LocationCount equals Locations.Count
- RPT-04-i: IsHighPrivilege/IsExternalUser preserved from first entry
2026-04-09 11:45:22 +02:00
Dev
9bfdfb77dd docs(15-01): complete consolidation data model plan
- Add 15-01-SUMMARY.md with task commits, decisions, and next phase readiness
- Update STATE.md with decisions and session position
- Update ROADMAP.md phase 15 progress (1/2 plans complete)
- Mark requirement RPT-04 complete in REQUIREMENTS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:42:47 +02:00
Dev
440b2474e9 feat(15-01): add PermissionConsolidator static helper
- MakeKey builds pipe-delimited case-insensitive key from UserLogin+PermissionLevel+AccessType+GrantedThrough
- Consolidate groups UserAccessEntry list by key, merges into ConsolidatedPermissionEntry rows
- Empty input short-circuits to Array.Empty
- Output ordered by UserLogin then PermissionLevel for deterministic results
2026-04-09 11:41:26 +02:00
Dev
270329bd82 feat(15-01): add LocationInfo and ConsolidatedPermissionEntry model records
- LocationInfo record holds five location fields (SiteUrl, SiteTitle, ObjectTitle, ObjectUrl, ObjectType)
- ConsolidatedPermissionEntry record holds key fields plus IReadOnlyList<LocationInfo> Locations
- LocationCount computed property returns Locations.Count
2026-04-09 11:41:05 +02:00
Dev
f5b3f08f88 docs(15): create consolidation data model phase plans
Two plans for Phase 15: models + consolidator service (wave 1), unit tests + build verification (wave 2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:36:42 +02:00
Dev
9031fd3473 docs(15): research phase domain for consolidation data model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:32:23 +02:00
Dev
e3ff27a673 docs: create milestone v2.3 roadmap (5 phases, 15-19)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:31:54 +02:00
Dev
d967a8bb65 docs: define milestone v2.3 requirements (12 requirements)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:11:25 +02:00
Dev
4ad5f078c9 docs: synthesize v2.3 research summary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:00:02 +02:00
Dev
853f47c4a6 docs: complete v2.3 project research (STACK, FEATURES, ARCHITECTURE, PITFALLS)
Research covers all five v2.3 features: automated app registration, app removal,
auto-take ownership, group expansion in HTML reports, and report consolidation toggle.
No new NuGet packages required. Build order and phase implications documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:58:58 +02:00
Dev
9318bb494d docs: start milestone v2.3 Tenant Management & Report Enhancements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:43:56 +02:00
Dev
f41dbd333e chore: archive v2.2 Report Branding & User Directory milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
5 phases (10-14), 14 plans, 11/11 requirements complete.
Key features: HTML report branding with MSP/client logos, user directory
browse mode with paginated load and member/guest filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:27:33 +02:00
Dev
b9511bd2b0 docs(14): mark phase 14 plan checkboxes complete in roadmap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:31:52 +02:00
Dev
febb67ab64 docs(14-02): complete directory browse UI plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:31:08 +02:00
Dev
1a1e83cfad feat(14-02): add directory browse mode UI with mode toggle, DataGrid, and loading UX
- Mode toggle (Search/Browse) RadioButtons at top of left panel
- Search panel uses DataTrigger inverse visibility (collapses when IsBrowseMode=true)
- Browse panel with Load/Cancel buttons, IncludeGuests checkbox, filter TextBox, status/count
- Directory DataGrid with 5 columns (Name, Email, Department, Job Title, Type)
- Guest users highlighted in orange via DataTrigger on UserType
- SelectedUsers extracted to shared section visible in both modes
- DataGrid wired to DirectoryDataGrid_MouseDoubleClick handler
- Scan Options and Run/Export buttons remain always visible

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:29:59 +02:00
Dev
f11bfefe52 docs(14-01): complete directory UI infrastructure plan
- SUMMARY.md with 3 tasks, 4 commits, 5 files modified
- STATE.md updated with position and decisions
- ROADMAP.md updated with phase 14 progress (1/2 plans)
- REQUIREMENTS.md: UDIR-05 marked complete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:27:44 +02:00
Dev
d1282cea5d feat(14-01): add DirectoryDataGrid_MouseDoubleClick code-behind handler
- Extracts GraphDirectoryUser from DataGrid.SelectedItem on double-click
- Invokes SelectDirectoryUserCommand to add user to audit pipeline
- Using added for SharepointToolbox.Core.Models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:26:41 +02:00
Dev
e6ba2d8146 feat(14-01): add SelectDirectoryUserCommand bridging directory to audit pipeline
- RelayCommand<GraphDirectoryUser> converts to GraphUserResult and adds to SelectedUsers
- Duplicate UPN check prevents adding same user twice
- Initialized in both DI and test constructors
- 4 new tests pass (add, skip duplicate, null, auditable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:26:12 +02:00
Dev
381081da18 test(14-01): add failing tests for SelectDirectoryUserCommand
- Test 17: adds user to SelectedUsers
- Test 18: skips duplicates
- Test 19: null does nothing
- Test 20: user is auditable after selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:25:18 +02:00
Dev
70e8d121fd feat(14-01): add 14 localization keys for directory browse UI (EN + FR)
- audit.mode.search, audit.mode.browse for mode toggle labels
- directory.grp.browse, directory.btn.load, directory.btn.cancel
- directory.filter.placeholder, directory.chk.guests, directory.status.count
- directory.hint.doubleclick, directory.col.name/upn/department/jobtitle/type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:24:54 +02:00
Dev
df6f4949a8 docs(13-02): complete User Directory ViewModel plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:44:56 +02:00
Dev
4ba4de6106 feat(13-02): add directory browse mode with paginated load, member/guest filter, and sortable ICollectionView
- Inject IGraphUserDirectoryService into UserAccessAuditViewModel (both constructors)
- Add IsBrowseMode toggle, DirectoryUsers collection, DirectoryUsersView with sort/filter
- Add LoadDirectoryCommand with progress reporting, cancellation, and error handling
- Add IncludeGuests toggle for in-memory member/guest filtering (no new Graph request)
- Add DirectoryFilterText for DisplayName/UPN/Department/JobTitle text search
- Add DirectoryUserCount computed property reflecting filtered view count
- Update OnTenantSwitched to clear all directory state
- Add 16 comprehensive unit tests covering all directory browse behaviors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:07:53 +02:00
Dev
cb7995ab31 docs(13-01): complete user directory model and service extension plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:02:45 +02:00
Dev
9a98371edd feat(13-01): extend GraphDirectoryUser with UserType and add includeGuests parameter to directory service
- Add string? UserType as last positional parameter to GraphDirectoryUser record
- Add bool includeGuests = false parameter to IGraphUserDirectoryService.GetUsersAsync
- Branch Graph filter: members-only (default) vs all users when includeGuests=true
- Add userType to Graph Select array for MapUser population
- Update MapUser to include UserType from Graph User object
- Add MapUser_PopulatesUserType and MapUser_NullUserType tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:01:46 +02:00
Dev
0baa3695fe docs(12-03): complete client logo section in ProfileManagementDialog plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:21:54 +02:00
Dev
46c8467c92 docs(12-02): complete MSP logo section plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:21:34 +02:00
Dev
ba81ea3cb7 feat(12-03): add client logo section with live preview to ProfileManagementDialog
- Increase dialog height from 480 to 620 to accommodate logo section
- Add new Row 3 with logo preview, Import/Clear/Pull from Entra buttons
- Image bound to ClientLogoPreview via Base64ToImageConverter
- Placeholder text shown when no logo configured via DataTrigger
- ValidationMessage displays feedback below logo buttons
- All logo buttons auto-disable when no profile selected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:21:12 +02:00
Dev
b035e91120 feat(12-02): add MSP logo section with live preview to SettingsView
- Add Separator and MSP Logo label after data folder section
- Add Border with Grid containing Image preview and placeholder TextBlock
- Image bound to MspLogoPreview via Base64ToImageConverter with max 80x240
- DataTrigger toggles placeholder visibility when logo is null
- Import/Clear buttons bound to BrowseMspLogoCommand/ClearMspLogoCommand
- StatusMessage TextBlock in red, visible only when set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:20:47 +02:00
Dev
c12ca4b813 docs(12-01): complete Base64ToImageSourceConverter and ClientLogoPreview plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:19:42 +02:00
Dev
6a4cd8ab56 feat(12-01): add Base64ToImageSourceConverter, localization keys, and ClientLogoPreview property
- Base64ToImageSourceConverter converts data URI strings to BitmapImage with null-safe error handling
- Registered converter in App.xaml as Base64ToImageConverter global resource
- Added 9 localization keys (EN+FR) for logo UI labels in Settings and Profile dialogs
- Added ClientLogoPreview string property to ProfileManagementViewModel with FormatLogoPreview helper
- Updated OnSelectedProfileChanged, BrowseClientLogoAsync, ClearClientLogoAsync, AutoPullClientLogoAsync
- 17 tests pass (6 converter + 11 profile VM logo tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:18:38 +02:00
Dev
0bc0babaf8 docs(phase-11): complete phase execution and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:56:13 +02:00
Dev
5d3fdee9da docs(11-03): complete ViewModel branding wiring plan
- Create 11-03-SUMMARY.md: IBrandingService wired into all 5 export ViewModels
- Update STATE.md: decisions, session record, progress
- Update ROADMAP.md: Phase 11 marked complete (4/4 plans, all summaries present)
2026-04-08 14:51:56 +02:00
Dev
816fb5e3b5 feat(11-03): inject IBrandingService into all 5 export ViewModels and assemble branding in ExportHtmlAsync
- Add IBrandingService field and DI constructor parameter to all 5 ViewModels
- Add optional IBrandingService? parameter to test constructors (PermissionsViewModel, StorageViewModel, UserAccessAuditViewModel)
- Assemble ReportBranding from GetMspLogoAsync + _currentProfile.ClientLogo before each WriteAsync call
- Pass branding as last parameter to WriteAsync in all ExportHtmlAsync methods
- Guard clause: branding assembly skipped (branding = null) when _brandingService is null (test constructors)
- Build: 0 warnings, 0 errors; tests: 254 passed / 0 failed / 26 skipped
2026-04-08 14:50:54 +02:00
Dev
e77455f03f docs(11-02): complete HTML export branding injection plan
- SUMMARY.md created for 11-02 plan
- STATE.md updated with decisions and progress
- ROADMAP.md updated with phase 11 plan progress (3/4 summaries)
2026-04-08 14:46:55 +02:00
Dev
d8b66169e6 feat(11-02): extend export tests to verify branding injection across all 5 services
- HtmlExportServiceTests: 3 new tests (MSP logo only, null branding no img, both logos)
- SearchExportServiceTests: 1 new branding test (img tag present when branding provided)
- StorageHtmlExportServiceTests: 1 new branding test (img tag present)
- DuplicatesHtmlExportServiceTests: 1 new branding test (img tag present)
- UserAccessHtmlExportServiceTests: 1 new branding test (img tag present)
- MakeBranding helper added to each test class
- All 45 export tests pass; full suite 247/247 with 0 failures
2026-04-08 14:45:55 +02:00
Dev
2233fb86a9 feat(11-02): add optional ReportBranding parameter to all 5 HTML export services
- Added ReportBranding? branding = null to BuildHtml on all 5 services
- Added ReportBranding? branding = null after CancellationToken ct on all WriteAsync overloads
- Injected BrandingHtmlHelper.BuildBrandingHeader(branding) between <body> and <h1> in each
- StorageHtmlExportService both overloads updated (nodes-only and nodes+fileTypeMetrics)
- HtmlExportService both overloads updated (PermissionEntry and SimplifiedPermissionEntry)
- Build passes with 0 warnings — all existing callers compile unchanged via default null
2026-04-08 14:44:23 +02:00
Dev
2e8ceea279 docs(11-04): complete logo management commands plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:40:50 +02:00
Dev
b02b75e5bc feat(11-04): add logo management commands to SettingsViewModel and ProfileManagementViewModel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:40:08 +02:00
Dev
d4fa402f04 docs(11-01): complete ReportBranding and BrandingHtmlHelper plan
- Create 11-01-SUMMARY.md with execution results
- Update STATE.md: decisions, progress, session continuity
- Update ROADMAP.md: phase 11 in progress (1/4 plans complete)
- Mark BRAND-05 requirement complete in REQUIREMENTS.md
2026-04-08 14:36:08 +02:00
Dev
212c43915e feat(11-01): add ReportBranding model and BrandingHtmlHelper with tests
- Add ReportBranding positional record bundling MspLogo and ClientLogo
- Add BrandingHtmlHelper static class generating flex branding header HTML
- Add BrandingHtmlHelperTests covering all 4 logo states (null, both null, single, both)
- Add InternalsVisibleTo for SharepointToolbox.Tests in project file
2026-04-08 14:34:45 +02:00
Dev
9e850b07f2 feat(11-04): add UpdateProfileAsync to ProfileService and ImportLogoFromBytesAsync to BrandingService
- ProfileService.UpdateProfileAsync: replaces profile by name and persists the change
- IBrandingService: add ImportLogoFromBytesAsync to interface contract
- BrandingService.ImportLogoFromBytesAsync: validates magic bytes, compresses if > 512KB, returns LogoData
- BrandingService.ImportLogoAsync: refactored to delegate to ImportLogoFromBytesAsync
- ProfileServiceTests: 2 new tests (UpdateProfileAsync happy path + KeyNotFoundException)
- BrandingServiceTests: 2 new tests (ImportLogoFromBytesAsync valid PNG + invalid bytes)
- Tests.csproj: suppress NU1701 for pre-existing LiveCharts2/OpenTK transitive warnings
2026-04-08 14:34:11 +02:00
Dev
1ab2f2e426 docs(11): create phase plan for HTML export branding and ViewModel integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:23:01 +02:00
Dev
0ab0a65e7a docs(11): research html export branding and viewmodel integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:11:54 +02:00
Dev
e9a1530120 docs(phase-10): complete phase execution and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:30:23 +02:00
Dev
9176ae7db9 docs(10-03): complete branding-data-foundation plan 03
- 10-03-SUMMARY.md: DI registration for Phase 10 services
- STATE.md: advanced position, added decision, updated session
- ROADMAP.md: phase 10 marked complete (3/3 plans)
2026-04-08 12:37:15 +02:00
Dev
7e8e228155 feat(10-03): register Phase 10 services in DI container
- Add BrandingRepository as Singleton with branding.json path
- Add IBrandingService/BrandingService as Singleton
- Add IGraphUserDirectoryService/GraphUserDirectoryService as Transient
- 224 tests pass, 26 integration tests skipped (live Graph)
2026-04-08 12:36:12 +02:00
Dev
61d7ada945 docs(10-01): complete branding-data-foundation plan 01
- Add 10-01-SUMMARY.md with task commits, deviation doc, and dependency graph
- Update STATE.md: decisions logged, session updated
- Update ROADMAP.md: phase 10 In Progress (1/3 plans complete)
- Mark BRAND-01, BRAND-03 complete in REQUIREMENTS.md
2026-04-08 12:33:57 +02:00
Dev
188a8a7fff docs(10-02): complete Graph user directory service plan
- SUMMARY: GraphDirectoryUser model, IGraphUserDirectoryService, GraphUserDirectoryService with PageIterator
- STATE: decisions added, session updated, progress bar updated
- ROADMAP: phase 10 marked In Progress (2/3 summaries)
- REQUIREMENTS: BRAND-06 marked complete
- Deferred: BrandingServiceTests.cs blocking test compilation (pre-existing, plan 10-01 artifact)
2026-04-08 12:33:33 +02:00
Dev
130386622f feat(10-01): create BrandingService with magic byte validation and auto-compression
- Add IBrandingService interface with ImportLogoAsync, Save/Clear/GetMspLogoAsync
- Add BrandingService: PNG/JPEG magic byte detection, rejects unsupported formats with
  descriptive error, auto-compresses files over 512 KB using WPF PresentationCore imaging
- Add BrandingServiceTests: 9 tests covering validation, rejection, compression, CRUD
- Deviation: used WPF BitmapEncoder/TransformedBitmap instead of System.Drawing.Bitmap
  (System.Drawing.Common not available without new NuGet package; WPF PresentationCore
  is in the existing stack per architectural decisions)
2026-04-08 12:32:23 +02:00
Dev
3ba574612f feat(10-02): implement GraphUserDirectoryService with PageIterator and unit tests
- GraphUserDirectoryService uses PageIterator<User, UserCollectionResponse> for pagination
- Filter: accountEnabled eq true and userType eq 'Member' (no ConsistencyLevel header)
- Cancellation checked in PageIterator callback (return false stops iteration)
- Progress reported via IProgress<int> with running count per user
- MapUser extracted as internal static for direct unit test coverage
- Tests: 5 unit tests for MapUser field mapping and fallback logic
- Integration-level tests (pagination/cancellation) skipped with rationale documented
- Note: test project compilation blocked by pre-existing BrandingServiceTests.cs (10-01 artifact)
2026-04-08 12:32:04 +02:00
Dev
2280f12eab feat(10-01): create logo models, BrandingRepository, and repository tests
- Add LogoData record with Base64 and MimeType init properties
- Add BrandingSettings class with nullable MspLogo property
- Extend TenantProfile with nullable ClientLogo property (additive)
- Add BrandingRepository mirroring SettingsRepository pattern (write-then-replace)
- Add BrandingRepositoryTests: 5 tests covering load defaults, round-trip, dir creation, and TenantProfile serialization
2026-04-08 12:29:53 +02:00
Dev
5e56a96cd0 feat(10-02): add GraphDirectoryUser model and IGraphUserDirectoryService interface
- GraphDirectoryUser positional record with DisplayName, UPN, Mail, Department, JobTitle
- IGraphUserDirectoryService.GetUsersAsync with clientId, IProgress<int>?, CancellationToken
- Follows existing GraphUserSearchService namespace pattern
2026-04-08 12:29:19 +02:00
Dev
1ffd71243e docs(10): create phase plan - 3 plans in 2 waves
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:50:59 +02:00
Dev
464b70ddcc docs(phase-10): add context, research, and validation strategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:44:24 +02:00
Dev
e6fdccf19c docs(phase-10): research branding data foundation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:43:07 +02:00
Dev
59ff5184ff docs: create milestone v2.2 roadmap (5 phases, 11 requirements)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:22:05 +02:00
Dev
5ccf1688ea docs: define milestone v2.2 requirements (11 requirements)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:00:59 +02:00
Dev
5f59e339ee docs(research): synthesize v2.2 research into SUMMARY.md
Adds v2.2 milestone section (Report Branding & User Directory) while
preserving the original v1.0 summary. Covers stack additions (none),
feature table stakes vs. differentiators, architecture integration
points with dependency-aware build order, top 6 critical pitfalls with
prevention strategies, suggested roadmap phase structure, open product
questions, and confidence assessment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 10:58:57 +02:00
Dev
8447e78db9 docs: start milestone v2.2 Report Branding & User Directory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:57:27 +02:00
Dev
fd442f3b4c chore: archive v1.1 Enhanced Reports milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
v1.1 shipped with 4 phases (25 plans), 10/10 requirements complete:
- Global site selection (toolbar picker, all tabs consume)
- User access audit (Graph people-picker, direct/group/inherited)
- Simplified permissions (plain-language labels, risk levels, detail toggle)
- Storage visualization (LiveCharts2 pie/donut + bar charts)

Post-phase polish: centralized site selection (removed per-tab pickers),
claims prefix stripping, StorageMetrics backfill, chart tooltip fix,
summary stats in app + HTML exports.

205 tests passing, 10,484 LOC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:21:02 +02:00
Dev
fa793c5489 docs(phase-09): mark phase complete in roadmap — 4/4 plans executed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:42:50 +02:00
Dev
713cf91d00 docs(09-04): complete StorageViewModel chart unit tests plan
- SUMMARY.md with 7 passing tests documented
- STATE.md updated to plan 4/4, phase 9 complete
- ROADMAP.md phase 09 marked complete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:41:55 +02:00
Dev
712b949eb2 test(09-04): add StorageViewModel chart unit tests
- 7 tests covering chart series from metrics, bar series structure,
  donut/bar toggle, top-10+Other aggregation, no-Other for <=10,
  tenant switch cleanup, and empty data handling
- Added LiveChartsCore.SkiaSharpView.WPF to test project
- Uses reflection to set FileTypeMetrics (private setter) directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:40:26 +02:00
Dev
e2321666c6 docs(09-03): complete ViewModel chart properties and View XAML plan summary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:37:20 +02:00
Dev
a8d79a8241 feat(09-03): add chart panel to StorageView with toggle and localization
- Update StorageView.xaml: DataGrid top, GridSplitter, chart panel bottom
- Add PieChart and CartesianChart with MultiDataTrigger visibility
- Add radio buttons for donut/bar chart toggle in left panel
- Create BytesLabelConverter for chart tooltip formatting
- Add stor.chart.* localization keys in EN and FR resx files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:35:35 +02:00
Dev
70048ddcdf feat(09-03): extend StorageViewModel with chart data properties and toggle
- Add IsDonutChart toggle, FileTypeMetrics collection, PieChartSeries, BarChartSeries, BarXAxes, BarYAxes
- Add UpdateChartSeries method with top-10 + Other aggregation
- Call CollectFileTypeMetricsAsync after storage scan in RunOperationAsync
- Clear chart data on tenant switch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:27:54 +02:00
Dev
3ec776ba81 docs(09-02): complete CollectFileTypeMetricsAsync plan
- SUMMARY.md with implementation details and deviation log
- STATE.md updated to plan 2 of 4, 92% progress
- ROADMAP.md and REQUIREMENTS.md updated (VIZZ-02 complete)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:25:25 +02:00
Dev
81e3dcac6d feat(09-02): implement CollectFileTypeMetricsAsync in StorageService
- CamlQuery with RecursiveAll scope enumerates files across all non-hidden document libraries
- Paginated 500-item batches avoid list view threshold issues
- Files grouped by extension (case-insensitive) with summed size and count
- Results returned as IReadOnlyList<FileTypeMetric> sorted by TotalSizeBytes descending
- Existing CollectStorageAsync, LoadFolderNodeAsync, CollectSubfoldersAsync unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:24:09 +02:00
Dev
18fe97f975 docs(09-01): complete LiveCharts2 foundation plan
- Add 09-01-SUMMARY.md with task details and self-check
- Update STATE.md position to Phase 9, Plan 1 of 4
- Update ROADMAP.md and REQUIREMENTS.md progress

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:22:50 +02:00
Dev
39c31dadfa feat(09-01): extend IStorageService with CollectFileTypeMetricsAsync
- Add CollectFileTypeMetricsAsync method signature to IStorageService
- Returns IReadOnlyList<FileTypeMetric> for chart visualization data
- Existing CollectStorageAsync signature unchanged
- CS0535 expected until StorageService implements in Plan 09-02

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:21:02 +02:00
Dev
60cbb977bf feat(09-01): add LiveCharts2 NuGet and FileTypeMetric data model
- Add LiveChartsCore.SkiaSharpView.WPF 2.0.0-rc5.4 package reference
- Create FileTypeMetric record with Extension, TotalSizeBytes, FileCount
- Include DisplayLabel computed property for chart label binding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:20:38 +02:00
Dev
a63a698282 docs(09-storage-visualization): create phase plan — 4 plans in 4 waves
Wave 1: LiveCharts2 NuGet + FileTypeMetric model + IStorageService extension
Wave 2: StorageService file-type enumeration implementation
Wave 3: ViewModel chart properties + View XAML + localization
Wave 4: Unit tests for chart ViewModel behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:16:16 +02:00
Dev
666e918810 docs(08-06): complete unit tests for simplified permissions plan
- SUMMARY.md with 17 tests added across 3 test files
- STATE.md updated: Phase 08 complete (6/6 plans)
- ROADMAP.md updated: Phase 08 marked complete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:22:35 +02:00
Dev
22a51c05ef test(08-06): add simplified mode tests to PermissionsViewModelTests
- IsSimplifiedMode default false, toggle rebuilds SimplifiedResults
- IsDetailView toggle does not re-compute simplified data
- Summaries contains correct risk breakdown after toggle
- Helper method CreateViewModelWithResults for test reuse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:21:06 +02:00
Dev
0f25fd67f8 test(08-06): add PermissionLevelMapping and PermissionSummaryBuilder unit tests
- 9 tests for PermissionLevelMapping: known roles, unknown fallback, case insensitivity, splitting, risk ranking, labels
- 4 tests for PermissionSummaryBuilder: risk levels, empty input, distinct users, wrapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:20:12 +02:00
Dev
a8a58f1ffc docs(08-05): complete localization keys and export wiring plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:18:37 +02:00
Dev
f503e6c0ca feat(08-05): wire export commands to use simplified overloads
- ExportCsvAsync branches on IsSimplifiedMode to call simplified WriteAsync overload
- ExportHtmlAsync branches on IsSimplifiedMode to call simplified WriteAsync overload
- Standard PermissionEntry export path unchanged when simplified mode is off

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:17:14 +02:00
Dev
60ddcd781f feat(08-05): add EN/FR localization keys for simplified permissions UI
- Add 6 keys to Strings.resx: chk.simplified.mode, grp.display.opts, lbl.detail.level, rad.detail.detailed, rad.detail.simple, lbl.summary.users
- Add matching French translations to Strings.fr.resx with proper XML entities for accented characters
- Wire hardcoded "user(s)" text in PermissionsView.xaml summary cards to lbl.summary.users localization key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:16:40 +02:00
Dev
1f5aa2b668 docs(08-03): complete Permissions View Simplified Mode UI plan
- Created 08-03-SUMMARY.md with task results and self-check
- Updated STATE.md with metrics and decisions
- Updated ROADMAP.md plan progress for phase 08

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:14:42 +02:00
Dev
12d4932484 docs(08-04): complete export services simplified overloads plan
- SUMMARY.md with task commits and decisions
- STATE.md updated to plan 4 of 6
- ROADMAP.md progress updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:14:26 +02:00
Dev
899ab7d175 feat(08-04): add simplified export overloads to HtmlExportService
- Add RiskLevelColors helper for risk-level color coding
- Add BuildHtml(IReadOnlyList<SimplifiedPermissionEntry>) with risk summary cards, Simplified column, and color-coded Risk badges
- Add WriteAsync overload for simplified entries
- Original PermissionEntry methods unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:13:08 +02:00
Dev
163c506e0b feat(08-03): add simplified mode UI to PermissionsView
- Add Display Options GroupBox with Simplified Mode toggle and Simple/Detailed radio buttons
- Add summary panel with color-coded risk level cards bound to Summaries collection
- DataGrid binds to ActiveItemsSource, rows color-coded by RiskLevel via DataTriggers
- SimplifiedLabels column visible only in simplified mode via BooleanToVisibilityConverter
- DataGrid collapses in Simple mode via MultiDataTrigger on IsSimplifiedMode+IsDetailView
- Create InvertBoolConverter for radio button inverse binding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:12:57 +02:00
Dev
fe19249f82 feat(08-04): add simplified export overloads to CsvExportService
- Add BuildCsv(IReadOnlyList<SimplifiedPermissionEntry>) overload with SimplifiedLabels and RiskLevel columns
- Add WriteAsync overload for simplified entries
- Original PermissionEntry methods unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:12:18 +02:00
Dev
c970342497 docs(08-02): complete ViewModel Toggle Logic plan summary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:11:08 +02:00
Dev
e2c94bf6d1 feat(08-02): add simplified mode properties to PermissionsViewModel
- IsSimplifiedMode toggle switches between raw and simplified labels
- IsDetailView toggle controls individual vs summary row display
- SimplifiedResults and Summaries computed from cached Results
- ActiveItemsSource provides correct collection for DataGrid binding
- Mode toggles rebuild from cache without re-running scan
- OnTenantSwitched resets simplified state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:09:57 +02:00
Dev
3c70884022 docs(08-01): complete Permission Data Models and Mapping Layer plan
- SUMMARY.md with self-check passed
- STATE.md updated to Phase 8, Plan 1 complete
- ROADMAP.md progress updated for Phase 8
- SIMP-01 and SIMP-02 requirements marked complete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:08:03 +02:00
Dev
6609f2a70a feat(08-01): add SimplifiedPermissionEntry wrapper and PermissionSummary model
- SimplifiedPermissionEntry wraps PermissionEntry with computed labels and risk level
- Passthrough properties preserve DataGrid binding compatibility
- PermissionSummary record for grouped risk-level counts
- PermissionSummaryBuilder always returns all 4 risk levels for consistent UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:06:47 +02:00
Dev
f1390eaa1c feat(08-01): add RiskLevel enum and PermissionLevelMapping helper
- RiskLevel enum with High, Medium, Low, ReadOnly tiers
- PermissionLevelMapping maps 11 standard SharePoint roles to plain-language labels
- Case-insensitive lookup with Medium fallback for unknown roles
- GetHighestRisk and GetSimplifiedLabels for row-level formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:06:17 +02:00
Dev
c871effa87 docs(08-simplified-permissions): create phase plan (6 plans, 5 waves)
Plans cover plain-language permission labels, risk-level color coding,
summary counts, detail-level toggle, export integration, and unit tests.
PermissionEntry record is NOT modified — uses wrapper pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:00:08 +02:00
Dev
dcdbd8662d docs(phase-07): complete phase execution — human verified and approved
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:45:08 +02:00
Dev
00252fd137 fix(07): fix people picker selection and audit service authentication
People picker ListBox used MouseBinding which fires before SelectedItem
updates, causing null CommandParameter. Replaced with SelectionChanged
event handler in code-behind.

AuditUsersAsync created TenantProfile with empty ClientId, causing
ArgumentException in SessionManager. Added currentProfile parameter
to pass the authenticated tenant's ClientId through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:44:53 +02:00
Dev
0af73df65c docs(07-10): complete debounced search test plan summary
- Created 07-10-SUMMARY.md documenting gap closure for verification gap 3
- Updated STATE.md: progress 100%, metrics recorded, decision logged, session updated
- Updated ROADMAP.md: Phase 7 complete (10/10 plans, 10/10 summaries)
2026-04-07 13:16:26 +02:00
Dev
d7ff32ee94 docs(07-09): complete DataGrid visual indicators plan summary 2026-04-07 13:15:23 +02:00
Dev
67a2053a94 test(07-10): add debounced search unit test for UserAccessAuditViewModel
- Extended CreateViewModel helper to return (vm, auditMock, graphMock) 3-tuple
- Updated all 8 existing tests to use _ discard for the new graphMock slot
- Added Test 9: SearchQuery_debounced_calls_SearchUsersAsync verifying that
  setting SearchQuery to "Ali" calls SearchUsersAsync after 300ms debounce
- All 9 ViewModel tests pass; full suite 177 passed / 22 skipped
2026-04-07 13:15:16 +02:00
Dev
33833dce5d feat(07-09): add guest badge, warning icon, and ObjectType column to DataGrid
- Convert User column to DataGridTemplateColumn with orange 'Guest' pill badge on IsExternalUser=true
- Add ObjectType DataGridTextColumn between Object and Permission Level
- Convert Permission Level column to DataGridTemplateColumn with red warning icon on IsHighPrivilege=true
2026-04-07 13:14:29 +02:00
Dev
855e4df49b docs(07-08): complete unit tests plan summary
- 07-08-SUMMARY.md: 32 tests across 4 files, all passing
- STATE.md: advance plan, record metrics and decisions
- ROADMAP.md: phase 7 complete (8/8 plans)
2026-04-07 13:00:18 +02:00
Dev
35b2c2a109 test(07-08): add export and ViewModel unit tests
- UserAccessCsvExportServiceTests (5): summary section, data header, RFC 4180
  quote escaping, 7-column count, WriteSingleFileAsync multi-user output
- UserAccessHtmlExportServiceTests (7): DOCTYPE, stats cards, dual-view sections,
  access type badges, filterTable JS, toggleView JS, HTML entity encoding
- UserAccessAuditViewModelTests (8): AuditUsersAsync invocation, results population,
  summary properties computation, tenant switch reset, GlobalSitesChanged update,
  override guard, CanExport false/true states
2026-04-07 12:58:58 +02:00
Dev
5df95032ee test(07-08): add UserAccessAuditService unit tests
- 12 tests: user filtering, claim format matching, Direct/Group/Inherited
  access type classification, Full Control + SCA high-privilege detection,
  external user flagging (#EXT#), semicolon user/level splitting, multi-site scan
2026-04-07 12:57:21 +02:00
Dev
34c1776dcc docs(07-07): complete integration wiring plan summary
- Add 07-07-SUMMARY.md for MainWindow/DI/localization integration
- Update STATE.md: progress 92%, new decisions, session record
- Update ROADMAP.md: phase 7 showing 7/8 summaries
2026-04-07 12:55:02 +02:00
Dev
a2531ea33f feat(07-07): add localization keys for User Access Audit tab in English and French
- Add 17 audit.* keys and tab.userAccessAudit to Strings.resx (English)
- Add matching French translations with proper Unicode accented characters to Strings.fr.resx
2026-04-07 12:53:37 +02:00
Dev
df796ee956 feat(07-07): add UserAccessAuditTabItem to MainWindow and wire dialog factory
- Add UserAccessAuditTabItem to MainWindow.xaml TabControl before SettingsTabItem
- Wire UserAccessAuditView content and SitePickerDialog factory in MainWindow.xaml.cs
2026-04-07 12:53:04 +02:00
Dev
2ed8a0cb12 feat(07-07): add DI registrations for Phase 7 services and create UserAccessAuditView
- Register IUserAccessAuditService, IGraphUserSearchService, export services, ViewModel and View in App.xaml.cs
- Create UserAccessAuditView.xaml with two-panel layout: people picker, site picker, scan options, color-coded DataGrid with grouping, summary banner
- Create UserAccessAuditView.xaml.cs code-behind with ViewModel constructor injection
- [Rule 3] UserAccessAuditView was missing (07-05 not executed); created inline to unblock 07-07
2026-04-07 12:52:36 +02:00
Dev
c42140db1a docs(07-05): complete UserAccessAuditView plan
- 07-05-SUMMARY.md: view with people picker, summary banner, color-coded DataGrid
- STATE.md: progress updated to 85% (11/13), decisions recorded, session updated
- ROADMAP.md: phase 7 in progress with 6/8 summaries complete
2026-04-07 12:50:53 +02:00
Dev
975762dee4 feat(07-05): create UserAccessAuditView code-behind
- UserControl with UserAccessAuditViewModel constructor injection, sets DataContext
- Wires SearchResults.CollectionChanged to show/hide autocomplete ListBox
- OnSearchResultClicked handler invokes AddUserCommand for mouse-based user selection
2026-04-07 12:49:41 +02:00
Dev
bb9ba9d310 feat(07-05): create UserAccessAuditView XAML layout
- Two-panel layout (290px left + * right) following PermissionsView pattern
- Left panel: people picker with autocomplete list + removable user pills, site picker button, scan option checkboxes, run/cancel/export buttons
- Right panel: 3-card summary banner (TotalAccessCount, SitesCount, HighPrivilegeCount), filter TextBox, group-by ToggleButton, color-coded DataGrid
- DataGrid: color-coded rows by AccessType (Direct=blue, Group=green, Inherited=gray), warning icon for high privilege, Guest badge for external users, access type icons
- GroupStyle with Expander headers showing group name + item count
- Status bar with ProgressBar + StatusMessage
2026-04-07 12:49:37 +02:00
Dev
72349d8415 docs(07-04): complete UserAccessAuditViewModel plan
- Add 07-04-SUMMARY.md with task commits and decisions
- Update STATE.md: progress 77%, session record, decisions
- Update ROADMAP.md: phase 7 plan count updated to 5/8 summaries
2026-04-07 12:45:14 +02:00
Dev
3de737ac3f feat(07-04): implement UserAccessAuditViewModel
- Extends FeatureViewModelBase with RunOperationAsync calling IUserAccessAuditService.AuditUsersAsync
- People picker with 300ms debounced Graph search via IGraphUserSearchService.SearchUsersAsync
- SelectedUsers ObservableCollection<GraphUserResult> with AddUserCommand/RemoveUserCommand
- Results ObservableCollection<UserAccessEntry> with CollectionViewSource grouping (by user/site) and FilterText predicate
- Summary banner properties: TotalAccessCount, SitesCount, HighPrivilegeCount (computed from Results)
- ExportCsvCommand/ExportHtmlCommand using UserAccessCsvExportService/UserAccessHtmlExportService
- Site selection with _hasLocalSiteOverride + OnGlobalSitesChanged pattern from PermissionsViewModel
- Dual constructors (DI + internal test constructor omitting export services)
- OnTenantSwitched resets all state (results, users, search, sites)
2026-04-07 12:44:02 +02:00
Dev
5c4a285473 docs(07-06): complete export services plan
- UserAccessCsvExportService and UserAccessHtmlExportService implemented
- SUMMARY.md created with task commits, decisions, self-check
- STATE.md updated: progress 69%, session, metrics, decisions
- ROADMAP.md updated: phase 7 showing 4/8 summaries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:42:00 +02:00
Dev
85712ad3ba docs(07-02): complete UserAccessAuditService plan
- Add 07-02-SUMMARY.md with implementation details
- Update STATE.md: progress 62%, decisions, session
- Update ROADMAP.md: phase 7 now 3/8 plans complete
2026-04-07 12:40:56 +02:00
Dev
3146a04ad8 feat(07-06): implement UserAccessHtmlExportService
- BuildHtml produces self-contained HTML with inline CSS and JS
- Stats cards: Total Accesses, Users Audited, Sites Scanned, High Privilege, External Users
- Per-user summary cards with high-privilege border highlight and guest badge
- Dual-view toggle (By User / By Site) with JS toggleView()
- Collapsible group headers per user and per site via toggleGroup()
- Sortable columns via sortTable() within each group
- Text filter via filterTable() scoping to active view
- Color-coded access type badges: Direct (blue), Group (green), Inherited (gray)
- High-privilege rows with bold text and warning icon
- External user guest badge (orange pill)
- UTF-8 without BOM encoding (matching HtmlExportService pattern)
2026-04-07 12:40:51 +02:00
Dev
cc513777ec docs(07-03): complete GraphUserSearchService plan
- Add 07-03-SUMMARY.md with implementation details and decisions
- Update STATE.md: progress 54%, decisions, session, metrics
- Update ROADMAP.md: phase 07 now 2/8 summaries
2026-04-07 12:40:22 +02:00
Dev
44b238e07a feat(07-02): implement UserAccessAuditService
- Scans permissions via IPermissionsService.ScanSiteAsync per site
- Filters PermissionEntry results to matching target user logins (case-insensitive contains)
- Splits semicolon-delimited users/logins/levels into per-user UserAccessEntry rows
- Classifies AccessType: Inherited (!HasUniquePermissions), Group (GrantedThrough), Direct
- Flags IsHighPrivilege (Full Control, Site Collection Administrator) and IsExternalUser (#EXT#)
2026-04-07 12:39:57 +02:00
Dev
9f891aa512 feat(07-06): implement UserAccessCsvExportService
- BuildCsv per-user CSV with summary section (user, totals, sites, high-privilege, date)
- WriteAsync groups entries by UserLogin, writes one file per user (audit_{email}_{date}.csv)
- WriteSingleFileAsync combines all users in one file for SaveFileDialog export
- RFC 4180 CSV escaping, UTF-8 with BOM for Excel compatibility
- SanitizeFileName strips invalid path chars from email addresses
2026-04-07 12:39:35 +02:00
Dev
026b8294de feat(07-03): implement GraphUserSearchService for people-picker autocomplete
- Queries Graph /users with startsWith filter on displayName, mail, UPN
- Requires minimum 2 chars to prevent overly broad queries
- Sets ConsistencyLevel=eventual + Count=true (required for advanced filter)
- Escapes single quotes to prevent OData injection
- Returns up to maxResults (default 10) GraphUserResult records
2026-04-07 12:39:22 +02:00
Dev
7e6f3e7fc0 docs(07-01): complete data models and service interfaces plan
- UserAccessEntry, AccessType, IUserAccessAuditService, IGraphUserSearchService
- UACC-01, UACC-02 requirements marked complete
- STATE.md updated with position and decisions
- ROADMAP.md Phase 7 progress updated (1/8 plans)
2026-04-07 12:38:19 +02:00
Dev
1a6989a9bb feat(07-01): add IUserAccessAuditService and IGraphUserSearchService interfaces
- IUserAccessAuditService.AuditUsersAsync: scan sites and filter by user logins
- IGraphUserSearchService.SearchUsersAsync: Graph API people-picker autocomplete
- GraphUserResult record: DisplayName, UserPrincipalName, Mail
2026-04-07 12:37:26 +02:00
Dev
e08df0f658 feat(07-01): add UserAccessEntry model and AccessType enum
- UserAccessEntry record with 12 fields for user-centric audit results
- AccessType enum: Direct, Group, Inherited
- Pre-computed IsHighPrivilege and IsExternalUser fields for grid display
2026-04-07 12:37:00 +02:00
Dev
19e4c3852d docs(07): create phase plan - 8 plans across 5 waves
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:32:39 +02:00
Dev
91058bc2e4 docs(state): record phase 7 context session
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:22:02 +02:00
Dev
ab253ca80a docs(07): capture phase context for user access audit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:21:57 +02:00
Dev
e96ca3edfe test(06): complete UAT - 8/8 passed
All Phase 6 global site selection features verified:
- Toolbar button, site count label, single/multi-site pre-fill
- Transfer pre-fill, local override, clear-reverts, tenant switch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:01:33 +02:00
Dev
4846915c80 fix(site-list): fix parsing error and double-auth in SiteListService
- Replace GetSitePropertiesFromSharePoint("", true) with modern
  GetSitePropertiesFromSharePointByFilters using null StartIndex
- Use ctx.Clone(adminUrl) instead of creating new AuthenticationManager
  for admin URL, eliminating second browser auth prompt

Resolves: UAT issue "Must specify valid information for parsing in the string"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:00:54 +02:00
Dev
5666565ac1 test(06): complete UAT - 0 passed, 3 issues, 7 skipped
Fix two pre-existing blockers found during UAT:
- ProfileManagementViewModel: add NotifyCanExecuteChanged on property changes
- SessionManager: open browser in openBrowserCallback (was no-op)

Remaining blocker: SitePickerDialog parsing error from PnP Framework.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:41:39 +02:00
Dev
52670bd262 docs(phase-06): complete phase verification and update state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:18:14 +02:00
Dev
9add2592b3 docs(06-05): complete GlobalSiteSelectionTests plan — phase 6 done
- SUMMARY.md created with test coverage details and decision rationale
- STATE.md updated: progress 100%, decisions recorded, session logged
- ROADMAP.md phase 6 marked Complete (5/5 plans with summaries)
2026-04-07 10:14:48 +02:00
Dev
80ef092a2e test(06-05): add GlobalSiteSelectionTests with 10 passing tests
- Message broadcast: GlobalSitesChangedMessage carries site list to receivers
- Base class: FeatureViewModelBase.GlobalSites updated on message receive
- Storage tab: SiteUrl pre-filled from first global site
- Storage tab: local override prevents global from overwriting SiteUrl
- Storage tab: clearing SiteUrl reverts to global site (override reset)
- Permissions tab: SelectedSites pre-populated from global sites
- Permissions tab: local picker override blocks subsequent global updates
- Tenant switch: resets local override so new global sites apply cleanly
- Transfer tab: SourceSiteUrl pre-filled from first global site
- MainWindowViewModel: GlobalSitesSelectedLabel reflects site count
2026-04-07 10:13:31 +02:00
Dev
da905b6ec0 docs(06-04): complete tab-vms global site consumption plan
- Add 06-04-SUMMARY.md with all task details and self-check
- Update STATE.md: progress bar 80%, decisions, session record
- Update ROADMAP.md: phase 6 now 4/5 plans complete (In Progress)
- Mark SITE-02 complete in REQUIREMENTS.md
2026-04-07 10:10:18 +02:00
Dev
0a91dd4ff3 feat(06-04): update TransferViewModel for global site consumption; confirm BulkMembers excluded
- TransferViewModel: add _hasLocalSourceSiteOverride field
- Override OnGlobalSitesChanged to pre-fill SourceSiteUrl from first global site
- Add OnSourceSiteUrlChanged partial to detect local user input
- Reset _hasLocalSourceSiteOverride on tenant switch
- BulkMembersViewModel confirmed excluded: no SiteUrl field, CSV-driven, no OnGlobalSitesChanged override added
2026-04-07 10:08:52 +02:00
Dev
9a4365bd32 docs(06-03): complete toolbar UI, localization, and dialog factory wiring plan
- SUMMARY.md created for plan 06-03
- STATE.md updated: progress 60%, decisions logged, session recorded
- ROADMAP.md updated: phase 6 now 3/5 summaries (In Progress)
2026-04-07 10:08:51 +02:00
Dev
6a2e4d1d89 feat(06-04): update single-site tab VMs for global site consumption
- StorageViewModel: add OnGlobalSitesChanged, OnSiteUrlChanged, _hasLocalSiteOverride
- SearchViewModel: add OnGlobalSitesChanged, OnSiteUrlChanged, _hasLocalSiteOverride
- DuplicatesViewModel: add OnGlobalSitesChanged, OnSiteUrlChanged, _hasLocalSiteOverride
- FolderStructureViewModel: add OnGlobalSitesChanged, OnSiteUrlChanged, _hasLocalSiteOverride
- All four VMs pre-fill SiteUrl from first global site; local typing sets override flag
- Tenant switch resets _hasLocalSiteOverride in all four VMs
2026-04-07 10:08:19 +02:00
Dev
45eb531128 feat(06-03): add global site picker button and count label to toolbar
- Add Separator + Select Sites button (bound to OpenGlobalSitePickerCommand) to ToolBar
- Add TextBlock bound to GlobalSitesSelectedLabel for site count display
- Wire viewModel.OpenGlobalSitePickerDialog factory in MainWindow.xaml.cs using DI
- Add using SharepointToolbox.Core.Models for TenantProfile in code-behind
2026-04-07 10:07:35 +02:00
Dev
467a940c6f feat(06-03): localize GlobalSitesSelectedLabel in MainWindowViewModel
- Replace hardcoded EN strings with TranslationSource.Instance lookups
- Uses toolbar.globalSites.count (formatted) and toolbar.globalSites.none keys
- Follows same pattern as PermissionsViewModel.SitesSelectedLabel
2026-04-07 10:06:57 +02:00
Dev
1bf47b5c4e feat(06-04): update PermissionsViewModel for multi-site global consumption
- Add _hasLocalSiteOverride field to track local user selection
- Override OnGlobalSitesChanged to pre-populate SelectedSites from global sites
- Set _hasLocalSiteOverride=true when user picks sites via site picker dialog
- Reset _hasLocalSiteOverride=false on tenant switch (OnTenantSwitched)
2026-04-07 10:06:57 +02:00
Dev
185642f4af feat(06-03): add EN/FR localization keys for global site picker toolbar
- Add toolbar.selectSites, toolbar.selectSites.tooltip, toolbar.selectSites.tooltipDisabled
- Add toolbar.globalSites.count and toolbar.globalSites.none to both Strings.resx and Strings.fr.resx
2026-04-07 10:06:40 +02:00
Dev
a39c87d43e docs(06-01): complete GlobalSitesChangedMessage and FeatureViewModelBase plan
- 06-01-SUMMARY.md created with deviations and decisions documented
- STATE.md updated: progress 40%, decisions added, session recorded
- ROADMAP.md updated: phase 6 in-progress (2/5 summaries)
2026-04-07 10:05:16 +02:00
Dev
95bf9c2eed docs(06-02): complete MainWindowViewModel global site selection plan
- Add 06-02-SUMMARY.md with execution results and dependency graph
- Update STATE.md: progress 20%, decision logged, session recorded
- Update ROADMAP.md: phase 6 in progress (1/5 plans complete)
- Mark SITE-01 requirement complete in REQUIREMENTS.md
2026-04-07 10:04:36 +02:00
Dev
d4fe169bd8 feat(06-01): extend FeatureViewModelBase with GlobalSites support
- Add protected GlobalSites property (IReadOnlyList<SiteInfo>) initialized to Array.Empty
- Register GlobalSitesChangedMessage in OnActivated alongside TenantSwitchedMessage
- Add private OnGlobalSitesReceived to update GlobalSites and invoke virtual hook
- Add protected virtual OnGlobalSitesChanged for derived VMs to override
- [Rule 3 - Blocking] Fix MainWindowViewModel missing ExecuteOpenGlobalSitePicker and BroadcastGlobalSites stubs referenced in constructor (pre-existing partial state from earlier TODO commit)
2026-04-07 10:03:40 +02:00
Dev
a10f03edc8 feat(06-02): add global site selection state, command, and broadcast to MainWindowViewModel
- Add OpenGlobalSitePickerDialog factory property (dialog factory pattern)
- Add GlobalSelectedSites ObservableCollection<SiteInfo>
- Add GlobalSitesSelectedLabel computed property for toolbar display
- Add OpenGlobalSitePickerCommand (disabled when no profile selected)
- Broadcast GlobalSitesChangedMessage via WeakReferenceMessenger on collection change
- Clear GlobalSelectedSites on tenant switch (OnSelectedProfileChanged)
- Clear GlobalSelectedSites on session clear (ClearSessionAsync)
- Add using SharepointToolbox.Views.Dialogs for SitePickerDialog cast
2026-04-07 10:03:30 +02:00
Dev
7874fa8524 feat(06-01): create GlobalSitesChangedMessage
- New ValueChangedMessage<IReadOnlyList<SiteInfo>> following TenantSwitchedMessage pattern
- Carries snapshot of globally selected sites (IReadOnlyList — immutable by design)
2026-04-07 10:02:20 +02:00
Dev
6ae3629301 docs(06): create phase plan for global site selection (5 plans, 3 waves)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:57:15 +02:00
Dev
59efdfe3f0 docs: create milestone v1.1 roadmap (4 phases)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:41:49 +02:00
Dev
04a307b69c docs: define milestone v1.1 requirements (10 requirements)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:40:02 +02:00
Dev
81da0f6a99 docs: start milestone v1.1 Enhanced Reports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:38:28 +02:00
Dev
0fb35de80f docs: capture todo - Add global multi-site selection option
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:33:05 +02:00
Dev
724fdc550d chore: complete v1.0 milestone
Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:19:03 +02:00
Dev
b815c323d7 fix: resolve post-milestone tech debt items
- Add DataGrid RowStyle with red highlighting for invalid CSV rows
  in BulkMembersView, BulkSitesView, and FolderStructureView
- Fix cancel test locale mismatch by setting EN culture before assertion
- Remove dead FeatureTabBase placeholder (replaced by full tab views)
- Clean up unused xmlns:controls from MainWindow.xaml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:15:02 +02:00
Dev
c81d8959f7 docs(phase-05): complete phase execution — verification passed, human approved
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:51:50 +02:00
Dev
b3686cc24c docs(05-03): complete integration gate and human sign-off plan
- 134 tests pass, 22 skip, 0 fail across all three Phase 5 workstreams
- Single-file EXE (201 MB, 0 loose DLLs) verified
- Human smoke test approved: French locale correct, all 10 tabs render
- Phase 5 and project marked complete in STATE.md and ROADMAP.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:51:53 +02:00
Dev
e0e3d55013 chore(05-03): verify full test suite and publish artifact
- 134 pass, 22 skip (MSAL interactive), 0 fail — all new helper + locale tests green
- dotnet publish -p:PublishSingleFile=true produces SharepointToolbox.exe (201 MB)
- 0 loose DLL files in ./publish/ — single-file self-contained confirmed
2026-04-03 16:39:00 +02:00
Dev
0758ce9593 docs(05-01): complete helper unit tests and locale completeness plan
- SUMMARY.md: 2 tasks, 5 files, 12 new tests all passing
- STATE.md: updated progress, decisions, session
- ROADMAP.md: phase 5 progress updated (2/3 summaries)
2026-04-03 16:37:34 +02:00
Dev
711f9502f2 docs(05-02): complete French locale and single-file publish plan
- 05-02-SUMMARY.md: 27 FR diacritic fixes + conditional PublishSingleFile
- STATE.md: progress 97%, session updated, decisions recorded
- ROADMAP.md: Phase 5 progress updated (2/3 summaries)
- REQUIREMENTS.md: FOUND-11 marked complete
2026-04-03 16:37:31 +02:00
Dev
8c6539440c feat(05-01): add FR locale completeness tests
- Test 1 (AllEnKeys_HaveNonEmptyFrTranslation): verifies every EN key has a non-empty, non-bracketed FR translation
- Test 2 (FrStrings_ContainExpectedDiacritics): spot-checks 5 keys for correct diacritics (é/è)
- Both tests pass — FR file already contains correct diacritics
2026-04-03 16:36:08 +02:00
Dev
39517d8956 feat(05-02): add self-contained single-file publish configuration
- Added conditional PropertyGroup for PublishSingleFile=true
- Sets SelfContained=true, RuntimeIdentifier=win-x64,
  IncludeNativeLibrariesForSelfExtract=true
- Conditional activation avoids affecting dotnet build and dotnet test
- Produces single SharepointToolbox.exe with zero loose DLL files
- PublishTrimmed remains false (required by PnP.Framework + MSAL)
2026-04-03 16:36:07 +02:00
Dev
f7829f0801 fix(05-02): correct French diacritics in Strings.fr.resx
- Fixed 27 strings with missing accents across Transfer, BulkMembers,
  BulkSites, FolderStruct, Templates, and shared bulk operation keys
- Corrected: Bibliothèque, Déplacer, Écraser, Démarrer, transférer,
  Aperçu, Créer, Propriétaires, Modèles, Sélectionner, Terminé, etc.
2026-04-03 16:35:34 +02:00
Dev
4d7e9ea02a feat(05-01): make helper methods internal and add unit tests
- Changed IsThrottleException to internal static in ExecuteQueryRetryHelper
- Changed BuildPagedViewXml to internal static in SharePointPaginationHelper
- Created ExecuteQueryRetryHelperTests: 5 tests (throttle true x3, non-throttle false, nested false)
- Created SharePointPaginationHelperTests: 5 tests (null, empty, whitespace, replace, append)
2026-04-03 16:34:54 +02:00
Dev
0122a47c9e docs(05): create phase plan for distribution and hardening
3 plans in 2 waves: helper tests + locale completeness (W1), FR diacritics + publish config (W1), verification + human checkpoint (W2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:40:41 +02:00
Dev
0dc2a2d8e4 docs(phase-5): add research and validation strategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:29:51 +02:00
Dev
af2177046f docs(phase-05): research distribution and hardening phase
Verified single-file publish works with IncludeNativeLibrariesForSelfExtract,
documented 25+ FR diacritic gaps, and mapped retry/pagination test coverage gaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:27:51 +02:00
Dev
1d5dde9ceb docs(phase-04): complete phase execution — verification passed, human approved
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:52:45 +02:00
Dev
3d62b2c48b fix(04): resolve null-reference crashes in CsvValidationService and TransferView
- Add null-conditional on CsvReader.Context.Parser to fix CS8602 warnings
- Guard ConflictCombo_SelectionChanged against null ViewModel during XAML init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:06:25 +02:00
Dev
a1c2a68cb5 docs(04-10): complete TemplatesViewModel + DI registration + MainWindow wiring plan — Phase 4 complete
- SUMMARY.md: 7 files created/modified, 1 auto-fix (missing converter classes), checkpoint:human-verify pending
- STATE.md: Phase 4 complete (10/10 plans), progress 100%, decisions recorded
- ROADMAP.md: Phase 4 marked Complete (10/10 summaries)
2026-04-03 10:26:44 +02:00
Dev
988bca844b feat(04-10): register Phase 4 DI + wire MainWindow tabs + TemplatesView
- App.xaml.cs: register TemplateRepository, GraphClientFactory, ICsvValidationService, BulkResultCsvExportService
- App.xaml.cs: register BulkMemberService, BulkSiteService, ITemplateService, IFolderStructureService
- App.xaml.cs: register all 5 Phase 4 ViewModels and Views (Transfer, BulkMembers, BulkSites, FolderStructure, Templates)
- MainWindow.xaml: replace 3 FeatureTabBase stub tabs with 5 named TabItems (tab.transfer through tab.templates)
- MainWindow.xaml.cs: wire all 5 new TabItem.Content from DI-resolved Views
2026-04-03 10:24:32 +02:00
Dev
a49bbb9f98 feat(04-10): create TemplatesViewModel and TemplatesView
- TemplatesViewModel: list, capture with 5 options, apply, rename, delete, refresh
- TemplatesView: capture section with checkboxes, apply section, template DataGrid
- RenameInputDialog: simple WPF dialog (no Microsoft.VisualBasic dependency)
- Capture/Apply are separate async commands from RunCommand
2026-04-03 10:24:23 +02:00
Dev
87dd4bb3ef feat(04-08,04-09): create Transfer/BulkMembers/BulkSites/FolderStructure ViewModels and Views
- TransferViewModel: source/dest site selection, Copy/Move mode, conflict policy, export failed
- TransferView: SitePickerDialog and FolderBrowserDialog wiring, confirm dialog
- BulkMembersViewModel, BulkSitesViewModel: CSV import, validate, preview, execute, retry, export
- FolderStructureViewModel: CSV import, site URL + library inputs, folder creation
- All 3 bulk Views: ConfirmBulkOperationDialog wiring, DataGrid preview with validation status
- Added EnumBoolConverter, StringToVisibilityConverter, ListToStringConverter to converters file
2026-04-03 10:23:54 +02:00
Dev
93218b0953 docs(04-09): complete BulkMembers, BulkSites, and FolderStructure ViewModels + Views plan
- SUMMARY.md: 9 files created, 2 auto-fixed deviations documented
- STATE.md: position advanced to plan 09 of 10, 2 new decisions recorded
- ROADMAP.md: phase 4 progress updated to 9/10 summaries
2026-04-03 10:22:07 +02:00
Dev
57f2c1d304 docs(04-08): complete TransferViewModel + TransferView plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:20:32 +02:00
Dev
fcd5d1d938 feat(04-09): create BulkMembers, BulkSites, and FolderStructure ViewModels and Views
- BulkMembersViewModel: CSV import, validate, preview, confirm, execute, retry failed, export failed
- BulkSitesViewModel: same flow using IBulkSiteService.CreateSitesAsync
- FolderStructureViewModel: site URL + library inputs, CSV folders, FolderStructureService.BuildUniquePaths
- BulkMembersView/BulkSitesView/FolderStructureView: XAML + code-behind wiring ConfirmBulkOperationDialog
- [Rule 3] Fixed duplicate converter definitions: removed untracked standalone EnumBoolConverter/StringToVisibilityConverter/ListToStringConverter files (already defined in IndentConverter.cs)
2026-04-03 10:20:23 +02:00
Dev
7b78b19bf5 feat(04-08): create TransferViewModel and TransferView
- TransferViewModel: source/dest site selection, transfer mode, conflict policy, confirmation dialog, per-item results, failed-items CSV export
- TransferView.xaml: DockPanel layout with GroupBoxes for source/dest, mode radio buttons, conflict policy ComboBox, progress bar, cancel button, export failed items button
- TransferView.xaml.cs: code-behind wires SitePickerDialog + FolderBrowserDialog for source and dest browsing
- Added EnumBoolConverter and StringToVisibilityConverter to IndentConverter.cs
- Registered converters in App.xaml; registered TransferViewModel, TransferView, IFileTransferService, BulkResultCsvExportService in App.xaml.cs
2026-04-03 10:19:16 +02:00
Dev
509c0c6843 docs(04-07): complete Localization + Shared Dialogs + Example CSV Resources plan
- Create 04-07-SUMMARY.md with task details, decisions, and self-check results
- Update STATE.md: progress 91%, decisions recorded, session updated
- Update ROADMAP.md: phase 4 progress (7/10 summaries)
2026-04-03 10:15:06 +02:00
Dev
1a2cc13224 feat(04-07): add Phase 4 localization, shared dialogs, and example CSV resources
- Add 80+ Phase 4 EN/FR localization keys to Strings.resx and Strings.fr.resx (tabs, transfer, bulkmembers, bulksites, folderstruct, templates, bulk-shared, folderbrowser)
- Add ResourceManager property accessors for all new keys to Strings.Designer.cs
- Create ConfirmBulkOperationDialog (XAML + code-behind) with Proceed/Cancel buttons
- Create FolderBrowserDialog (XAML + code-behind) with lazy-loading TreeView of SharePoint libraries/folders
- Bundle bulk_add_members.csv, bulk_create_sites.csv, folder_structure.csv as EmbeddedResource in csproj
2026-04-03 10:13:39 +02:00
Dev
fdb1108e76 docs(04-06): complete TemplateService + FolderStructureService plan — CSOM template capture/apply and CSV folder hierarchy creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:09:31 +02:00
Dev
84cd569fb7 feat(04-06): implement TemplateService and FolderStructureService
- FolderStructureService.CreateFoldersAsync creates folder hierarchy from CSV rows using BulkOperationRunner
- FolderStructureService.BuildUniquePaths deduplicates and sorts paths parent-first by slash depth
- TemplateService already committed; verified compilation and interface compliance
- FolderStructureServiceTests: 4 unit tests pass (BuildUniquePaths edge cases, deduplication, empty levels, BuildPath) + 1 skip
- TemplateServiceTests: 3 unit tests pass (interface impl, SiteTemplate defaults, SiteTemplateOptions defaults) + 2 skip
2026-04-03 10:07:49 +02:00
Dev
773393c4c0 docs(04-04): complete BulkMemberService plan — Graph API member addition with CSOM fallback
- Create 04-04-SUMMARY.md with full execution details and deviation docs
- Update STATE.md: plan 04 complete, new decisions, session record
- Update REQUIREMENTS.md: BULK-02 marked complete (BULK-04/05 already done in 04-01)
2026-04-03 10:06:37 +02:00
Dev
c4d8124a81 docs(04-02): complete CsvValidationService + TemplateRepository plan
- 9 CsvValidationService tests passing (delimiter detection, BOM, member/site/folder validation)
- 6 TemplateRepository tests passing (round-trip, GetAll, delete, rename, empty dir, non-existent)
- All 10 previously-skipped scaffold tests now active and passing (15 total)
- Requirements TMPL-03, TMPL-04, FOLD-02 marked complete
2026-04-03 10:05:21 +02:00
Dev
0cf6f50448 docs(04-03): complete FileTransferService plan — CSOM file transfer with conflict policies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:05:00 +02:00
Dev
98fa16a195 docs(04-05): complete BulkSiteService plan — PnP Framework Team + Communication site creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:04:03 +02:00
Dev
f3a1c352c7 feat(04-02): implement CsvValidationService and TemplateRepository with tests
- CsvValidationService: CsvHelper-based parsing with DetectDelimiter, BOM detection,
  per-row validation for BulkMemberRow/BulkSiteRow/FolderStructureRow
- TemplateRepository: atomic JSON write (tmp + File.Move) with SemaphoreSlim,
  supports GetAll/GetById/Save/Delete/Rename operations
- CsvValidationServiceTests: 9 passing tests (email validation, delimiter detection,
  BOM handling, folder/site/member validation)
- TemplateRepositoryTests: 6 passing tests (round-trip, GetAll, delete, rename,
  empty directory, non-existent id)
- All previously-skipped scaffold tests now active and passing (15 total)
2026-04-03 10:03:41 +02:00
Dev
ac74d31933 feat(04-03): implement FileTransferService with MoveCopyUtil and conflict policies
- FileTransferService.cs: CSOM copy/move via MoveCopyUtil.CopyFileByPath/MoveFileByPath
- Conflict policies: Skip (catch ServerException), Overwrite (overwrite=true), Rename (KeepBoth=true)
- ResourcePath.FromDecodedUrl for special character support
- Recursive folder enumeration with system folder filtering
- EnsureFolderAsync creates intermediate destination folders
- Best-effort metadata preservation (ResetAuthorAndCreatedOnCopy=false)
- FileTransferServiceTests.cs: 4 passing tests, 3 skipped (integration)
2026-04-03 10:02:57 +02:00
Dev
b0956adaa3 feat(04-05): implement BulkSiteService with PnP Framework site creation
- BulkSiteService creates Team sites via TeamSiteCollectionCreationInformation with owners/members
- BulkSiteService creates Communication sites via CommunicationSiteCollectionCreationInformation with generated URL
- Per-site error handling via BulkOperationRunner with continue-on-error semantics
- SanitizeAlias generates URL-safe aliases from site names for Communication sites
- BulkSiteServiceTests: 3 pass (interface check + model defaults + CSV parsing), 3 skip (live SP)
- Fixed pre-existing BulkMemberService.cs Group type ambiguity (MSCSC.Group vs Graph.Models.Group)
2026-04-03 10:02:09 +02:00
Dev
fdcd4c8377 docs(04-01): complete Phase 4 Plan 01 — models, interfaces, BulkOperationRunner
- Create 04-01-SUMMARY.md with full execution details and deviation docs
- Update STATE.md: progress 73%, new decisions, session record
- Update ROADMAP.md: Phase 4 In Progress, 1/10 plans complete
- Mark requirements BULK-04 and BULK-05 complete in REQUIREMENTS.md
2026-04-03 09:55:26 +02:00
Dev
39deed9d8d feat(04-01): add Phase 4 models, interfaces, BulkOperationRunner, and test scaffolds
- Install CsvHelper 33.1.0 and Microsoft.Graph 5.74.0 (main + test projects)
- Add 14 core model/enum files (BulkOperationResult, BulkMemberRow, BulkSiteRow, TransferJob, FolderStructureRow, SiteTemplate, SiteTemplateOptions, TemplateLibraryInfo, TemplateFolderInfo, TemplatePermissionGroup, ConflictPolicy, TransferMode, CsvValidationRow)
- Add 6 service interfaces (IFileTransferService, IBulkMemberService, IBulkSiteService, ITemplateService, IFolderStructureService, ICsvValidationService)
- Add BulkOperationRunner with continue-on-error and cancellation support
- Add BulkResultCsvExportService stub (compile-ready)
- Add test scaffolds: BulkOperationRunnerTests (5 passing), BulkResultCsvExportServiceTests (2 passing), CsvValidationServiceTests (6 skipped), TemplateRepositoryTests (4 skipped)
2026-04-03 09:53:05 +02:00
Dev
d73e50948d docs(04): create Phase 4 plan — 10 plans for Bulk Operations and Provisioning
Wave 0: models, interfaces, BulkOperationRunner, test scaffolds
Wave 1: CsvValidationService, TemplateRepository, FileTransferService,
        BulkMemberService, BulkSiteService, TemplateService, FolderStructureService
Wave 2: Localization, shared dialogs, example CSV resources
Wave 3: TransferVM+View, BulkMembers/BulkSites/FolderStructure VMs+Views,
        TemplatesVM+View, DI registration, MainWindow wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:38:33 +02:00
Dev
97fc29c15e docs(04): research phase domain for Bulk Operations and Provisioning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:17:41 +02:00
Dev
97d1e10faf docs(state): record phase 4 context session
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:07:43 +02:00
Dev
6dd5faf65d docs(04): capture phase context for Bulk Operations and Provisioning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:07:34 +02:00
Dev
43dd6ce17f docs(03-08): complete SearchViewModel + DuplicatesViewModel + Views plan — Phase 3 complete
- 3 tasks completed, 9 files created/modified
- Visual checkpoint pending: all three Phase 3 tabs wired and ready for UI verification
2026-04-02 16:10:54 +02:00
Dev
1f2a49d7d3 feat(03-08): DI registration + MainWindow wiring for Search and Duplicates tabs
- App.xaml.cs: register ISearchService, SearchCsvExportService, SearchHtmlExportService, SearchViewModel, SearchView, IDuplicatesService, DuplicatesHtmlExportService, DuplicatesViewModel, DuplicatesView
- MainWindow.xaml: add x:Name to SearchTabItem and DuplicatesTabItem (remove FeatureTabBase stubs)
- MainWindow.xaml.cs: wire SearchTabItem.Content and DuplicatesTabItem.Content via DI
2026-04-02 15:45:29 +02:00
Dev
0984a36bc7 feat(03-08): create DuplicatesViewModel, DuplicatesView XAML and code-behind
- DuplicatesViewModel: ModeFiles/Folders, criteria checkboxes, group flattening to DuplicateRow
- Uses TenantProfile site URL override pattern (ctx.Url is read-only)
- ExportHtmlCommand exports DuplicateGroup list via DuplicatesHtmlExportService
- DuplicatesView.xaml: type selector, criteria panel + flattened DataGrid
- DuplicatesView.xaml.cs: DI constructor with DataContext wiring
2026-04-02 15:44:26 +02:00
Dev
7e6d39a3db feat(03-08): create SearchViewModel, SearchView XAML and code-behind
- SearchViewModel: full filter props, RunOperationAsync via ISearchService
- Uses TenantProfile site URL override pattern (ctx.Url is read-only)
- ExportCsvCommand + ExportHtmlCommand with CanExport guard
- SearchView.xaml: filter panel + DataGrid with all 8 columns
- SearchView.xaml.cs: DI constructor with DataContext wiring
2026-04-02 15:43:22 +02:00
Dev
50c7ab19f5 docs(03-05): complete Search and Duplicate export services plan
- 9/9 export tests pass (6 Search + 3 Duplicates)
- SearchCsvExportService, SearchHtmlExportService, DuplicatesHtmlExportService fully implemented
- Requirements SRCH-03, SRCH-04, DUPL-03 satisfied
2026-04-02 15:40:30 +02:00
Dev
82acc81e13 docs(03-07): complete StorageViewModel and StorageView plan — SUMMARY, STATE, ROADMAP updated 2026-04-02 15:40:07 +02:00
Dev
fc1ba00aa8 feat(03-05): implement DuplicatesHtmlExportService with grouped cards
- Replace stub with full grouped HTML export (port of PS Export-DuplicatesToHTML)
- One collapsible card per DuplicateGroup with item count badge and path table
- Uses System.IO.File explicitly per WPF project pattern
- 3/3 DuplicatesHtmlExportServiceTests pass; 9/9 total export tests pass
2026-04-02 15:38:43 +02:00
Dev
e08452d1bf feat(03-07): create StorageView XAML, DI registration, and MainWindow wiring
- StorageView.xaml: DataGrid with IndentLevel-based name indentation
- StorageView.xaml.cs: code-behind wiring DataContext to StorageViewModel
- IndentConverter.cs: IndentConverter, BytesConverter, InverseBoolConverter
- App.xaml: register converters and RightAlignStyle as Application.Resources
- App.xaml.cs: register IStorageService, StorageCsvExportService, StorageHtmlExportService, StorageViewModel, StorageView
- MainWindow.xaml: add x:Name=StorageTabItem to Storage TabItem
- MainWindow.xaml.cs: wire StorageTabItem.Content from DI
2026-04-02 15:38:20 +02:00
Dev
e174a18350 feat(03-07): create StorageViewModel with IStorageService orchestration and export commands
- Rule 1: Fixed ctx.Url read-only bug — use new TenantProfile with site URL for GetOrCreateContextAsync
- Rule 3: Added missing System.IO using to SearchCsvExportService and SearchHtmlExportService
2026-04-02 15:36:27 +02:00
Dev
9a55c9e7d0 docs(03-04): complete SearchService and DuplicatesService plan — 2/2 tasks, 5 MakeKey tests pass 2026-04-02 15:33:47 +02:00
Dev
e83c4f34f1 docs(03-06): complete Phase 3 localization plan — 54 EN/FR keys added for Storage, Search, Duplicates tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:32:56 +02:00
Dev
47e6cf62d2 docs(03-03): complete Storage export services plan — CSV and HTML exporters
- Add 03-03-SUMMARY.md: StorageCsvExportService and StorageHtmlExportService
- Update STATE.md: advance to plan 03-04, record metrics and decision
- Update ROADMAP.md: phase 3 progress 3/8 plans complete
2026-04-02 15:32:03 +02:00
Dev
df5f79d1cb feat(03-04): implement DuplicatesService composite key grouping for files and folders
- File mode: Search API KQL pagination matching SearchService pattern
- Folder mode: CAML FSObjType=1 via SharePointPaginationHelper.GetAllItemsAsync
- MakeKey composite key (name+size+dates+counts) matches DuplicatesServiceTests scaffold
- Groups only items with count >= 2, ordered by group size then name
- ExtractLibraryFromPath derives library name from path relative to site URL
- SelectProperties added per-item (StringCollection has no AddRange)
2026-04-02 15:31:57 +02:00
Dev
938de30437 feat(03-06): add Phase 3 EN/FR localization keys for Storage, Search, and Duplicates tabs
- Added 14 Storage tab keys (chk.per.lib, chk.subsites, stor.note, btn.gen.storage, btn.open.storage, stor.col.*, stor.rad.*)
- Added 26 File Search tab keys (grp.search.filters, lbl.extensions, ph.extensions, lbl.regex, ph.regex, date filters, lbl/ph.library, lbl.max.results, lbl.site.url, btn.run.search, btn.open.search, srch.col.*, srch.rad.*)
- Added 14 Duplicates tab keys (grp.dup.type, rad.dup.*, grp.dup.criteria, lbl.dup.note, chk.dup.*, chk.include.subsites, ph.dup.lib, btn.run.scan, btn.open.results)
- Matching FR translations in Strings.fr.resx with proper French text
- 54 new static properties in Strings.Designer.cs (dot-to-underscore naming convention)
2026-04-02 15:31:25 +02:00
Dev
9e3d5016e6 feat(03-04): implement SearchService KQL pagination with 500-row batches and 50,000 hard cap
- KQL builder for extension, date, creator, editor, library filters
- Pagination via StartRow += 500, stops at MaxStartRow or MaxResults
- Filters _vti_history/ version history paths from results
- Client-side Regex filter on file name and title
- ValidateKqlLength enforces 4096-char SharePoint limit
- SelectProperties added one-by-one (StringCollection has no AddRange)
2026-04-02 15:30:44 +02:00
Dev
eafaa15459 feat(03-03): implement StorageHtmlExportService
- Replace string.Empty stub with full BuildHtml implementation
- Self-contained HTML with inline CSS and JS — no external dependencies
- toggle(i) JS function with collapsible subfolder rows (sf-{i} IDs)
- _togIdx counter reset at start of each BuildHtml call (per PS pattern)
- RenderNode/RenderChildNode for recursive tree rendering
- FormatSize helper: B/KB/MB/GB adaptive display
- HtmlEncode via System.Net.WebUtility
- Add explicit System.IO using (required in WPF project)
2026-04-02 15:30:34 +02:00
Dev
94ff181035 feat(03-03): implement StorageCsvExportService
- Replace string.Empty stub with full BuildCsv implementation
- UTF-8 BOM header row: Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified
- RFC 4180 CSV quoting via Csv() helper
- FormatMb() converts bytes to MB with 2 decimal places
- Add explicit System.IO using (required in WPF project)
2026-04-02 15:29:45 +02:00
Dev
3730b54527 docs(03-02): complete StorageService plan — CSOM scan engine implemented
- Create 03-02-SUMMARY.md with full deviation and test documentation
- Update STATE.md: position = 03-02 complete, Wave 2 next (03-03/04/06)
- Update ROADMAP.md: Phase 3 at 2/8 plans complete
- Note auto-fixed Rule 3 blocker: created missing 03-01 export stubs and test scaffolds
2026-04-02 15:28:08 +02:00
Dev
556fad1377 docs(03-01): complete Wave 0 plan — models, interfaces, export stubs, test scaffolds
- 7 pure-logic tests pass (VersionSizeBytes + MakeKey composite key)
- 0 build errors, 15 export tests fail as expected (stubs)
- 12 requirements marked complete (STOR-01/05, SRCH-01/04, DUPL-01/03)
2026-04-02 15:27:35 +02:00
Dev
b5df0641b0 feat(03-02): implement StorageService CSOM StorageMetrics scan engine
- Add StorageService implementing IStorageService
- Load Folder.StorageMetrics, TimeLastModified, Name, ServerRelativeUrl in one CSOM round-trip per folder
- CollectStorageAsync returns one StorageNode per document library at IndentLevel=0
- With FolderDepth>0, CollectSubfoldersAsync recurses into child folders
- All CSOM calls use ExecuteQueryRetryHelper.ExecuteQueryRetryAsync (3 call sites)
- System/hidden lists skipped (Hidden=true or BaseType != DocumentLibrary)
- Forms/ and _-prefixed system folders skipped during subfolder recursion
- ct.ThrowIfCancellationRequested() called at top of every recursive step
2026-04-02 15:26:16 +02:00
Dev
08e4d2ee7d feat(03-01): create Phase 3 export stubs and test scaffolds
- Add StorageCsvExportService, StorageHtmlExportService stub (Plan 03-03)
- Add SearchCsvExportService, SearchHtmlExportService stub (Plan 03-05)
- Add DuplicatesHtmlExportService stub (Plan 03-05)
- Add StorageServiceTests, SearchServiceTests, DuplicatesServiceTests scaffolds
- Add export test scaffolds for all 4 Phase 3 export services
- 7 pure-logic tests pass (VersionSizeBytes + MakeKey); 4 CSOM stubs skip
2026-04-02 15:25:20 +02:00
Dev
b52f60f8eb feat(03-01): create 7 core models and 3 service interfaces for Phase 3
- StorageNode, StorageScanOptions models
- SearchResult, SearchOptions models
- DuplicateItem, DuplicateGroup, DuplicateScanOptions models
- IStorageService, ISearchService, IDuplicatesService interfaces
2026-04-02 15:23:04 +02:00
Dev
d09db015f2 docs(phase-03): research storage, search, and duplicate detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:41:39 +02:00
Dev
20780318a3 docs(phase-02): complete phase execution — 7/7 verified, advancing to phase 03
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:29:22 +02:00
Dev
80a3873a15 fix(02-07): bind export buttons to localization keys (rad.csv.perms, rad.html.perms)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:29:03 +02:00
Dev
6e9a0033f2 docs(02-07): complete Permissions integration plan — Phase 2 done
- Created 02-07-SUMMARY.md: PermissionsView XAML wired into MainWindow, all Phase 2 DI registered, human-verified
- Updated STATE.md: Phase 2 complete, 16/22 plans done, new decisions recorded
- Updated ROADMAP.md: Phase 2 all 7 plans checked, status Complete 2026-04-02

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:21:18 +02:00
Dev
afe69bd37f feat(02-07): create PermissionsView XAML + code-behind and register DI
- Created PermissionsView.xaml with left scan-config panel and right results DataGrid
- Created PermissionsView.xaml.cs wiring ViewModel via IServiceProvider, factory for SitePickerDialog
- Updated App.xaml.cs: registered IPermissionsService, ISiteListService, CsvExportService,
  HtmlExportService, PermissionsViewModel, PermissionsView, SitePickerDialog, and
  Func<TenantProfile, SitePickerDialog> factory; also registered ISessionManager -> SessionManager
- Updated MainWindow.xaml: replaced FeatureTabBase stub with named PermissionsTabItem
- Updated MainWindow.xaml.cs: wires PermissionsTabItem.Content from DI-resolved PermissionsView
- Added CurrentProfile public accessor, SitesSelectedLabel computed property, and
  IsMaxDepth toggle property to PermissionsViewModel
- Build: 0 errors, 0 warnings. Tests: 60 passed, 3 skipped (live/interactive)
2026-04-02 14:13:45 +02:00
Dev
e74cffbe31 docs(02-06): complete PermissionsViewModel and SitePickerDialog plan
- Add 02-06-SUMMARY.md with TDD results and deviation documentation
- Update STATE.md: progress bar 87%, record metrics, ISessionManager decision
- Update ROADMAP.md: phase 02-permissions now 6/7 summaries (In Progress)
2026-04-02 14:09:06 +02:00
Dev
f98ca60990 feat(02-06): implement PermissionsViewModel with multi-site scan and SitePickerDialog
- PermissionsViewModel extends FeatureViewModelBase, implements RunOperationAsync
- Multi-site mode: loops SelectedSites; single-site mode: uses SiteUrl
- ExportCsvCommand and ExportHtmlCommand enabled only when Results.Count > 0
- OpenSitePickerCommand uses dialog factory pattern (Func<Window>?)
- OnTenantSwitched clears Results, SiteUrl, SelectedSites
- Flat ObservableProperty booleans (IncludeInherited, ScanFolders, etc.) build ScanOptions record
- SitePickerDialog XAML: filterable list with CheckBox column, Title, URL columns
- SitePickerDialog code-behind: loads sites on Window.Loaded, exposes SelectedUrls
- ISessionManager interface extracted for testability (SessionManager implements it)
- StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl test passes (60/60 + 3 skip)
2026-04-02 14:06:39 +02:00
Dev
c462a0b310 test(02-06): add failing test for PermissionsViewModel multi-site scan
- Write StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl test (RED)
- Create ISessionManager interface for testability
- Implement ISessionManager on SessionManager
- Add PermissionsViewModel stub (NotImplementedException) to satisfy compile
2026-04-02 14:04:22 +02:00
Dev
48ccf5891b docs(02-04): add self-check result to SUMMARY.md 2026-04-02 14:01:24 +02:00
Dev
7805e0b015 docs(02-04): complete export services plan — CsvExportService and HtmlExportService
- SUMMARY.md created for plan 02-04
- STATE.md: progress updated to 87%, session recorded, decision added
- ROADMAP.md: phase 02 progress updated (5/7 plans complete)
2026-04-02 14:01:12 +02:00
Dev
e3ab31937a feat(02-04): implement HtmlExportService with self-contained interactive HTML report
- Stats cards: Total Entries, Unique Permission Sets, Distinct Users/Groups
- Type badges: site-coll (blue), site (green), list (amber), folder (gray)
- Unique/Inherited badges based on HasUniquePermissions flag
- User pills with external-user CSS class for #EXT# logins
- Inline JS filterTable() function for client-side row filtering
- WriteAsync uses UTF-8 without BOM for HTML
- All 3 HtmlExportServiceTests pass
2026-04-02 13:59:46 +02:00
Dev
44913f8075 feat(02-04): implement CsvExportService with Merge-PermissionRows port
- GroupBy (Users, PermissionLevels, GrantedThrough) to merge duplicate entries
- Pipe-joins URLs and Titles for merged rows
- RFC 4180 CSV escaping: all fields double-quoted, internal quotes doubled
- WriteAsync uses UTF-8 with BOM for Excel compatibility
- All 3 CsvExportServiceTests pass
2026-04-02 13:58:39 +02:00
Dev
ac86bbc302 docs(02-02): complete PermissionsService plan — models, interface, scan engine
- Created 02-02-SUMMARY.md with full execution record
- Updated STATE.md with decisions (CSOM type constraints) and progress
- Updated ROADMAP.md (phase 02: 4/7 summaries, In Progress)
- Marked PERM-07 complete in REQUIREMENTS.md
2026-04-02 13:56:53 +02:00
Dev
0480f97059 docs(02-01): complete Wave 0 test scaffold plan
- 02-01-SUMMARY.md: classification helper + 5 test scaffolds across PERM-01..06
- STATE.md: progress 73%, decisions logged, session updated
- ROADMAP.md: phase 02 progress 4/7 summaries
2026-04-02 13:56:02 +02:00
Dev
9f2e2f9899 fix(02-01): add export service stubs and fix PermissionsService compile errors
[Rule 3 - Blocking] CsvExportService/HtmlExportService stubs added so export test
files compile. [Rule 1 - Bug] PermissionsService: removed Principal.Email (not on
Principal, only on User) and changed folder param from Folder to ListItem (SecurableObject).
2026-04-02 13:53:45 +02:00
Dev
d17689cc46 docs(02-03): complete SiteListService plan
- 02-03-SUMMARY.md created
- STATE.md: progress updated 67%, decisions added, session recorded
- ROADMAP.md: phase 2 progress updated (2/7 summaries)
2026-04-02 13:52:17 +02:00
Dev
c04d88882d docs(02-05): complete Phase 2 localization keys plan
- Added 02-05-SUMMARY.md with 15 EN+FR localization keys plan results
- Updated STATE.md progress (67%), session, and metrics
- Updated ROADMAP.md phase 02 progress (2/7 summaries)
- Marked PERM-01, PERM-02, PERM-04, PERM-05, PERM-06 requirements complete
2026-04-02 13:52:03 +02:00
Dev
83464a009c test(02-01): scaffold export service test stubs for PERM-05 and PERM-06
- CsvExportServiceTests.cs: 3 real [Fact] tests (header row, empty list, merge rows) — PERM-05
- HtmlExportServiceTests.cs: 3 real [Fact] tests (user names, empty HTML, external user marker) — PERM-06
- Both files reference CsvExportService/HtmlExportService from Plan 03 — compile errors expected until Plan 03 creates the services
2026-04-02 13:51:54 +02:00
Dev
4a6594d9e8 feat(02-02): define PermissionEntry, ScanOptions, and IPermissionsService
- PermissionEntry record with 9 fields matching PS Generate-PnPSitePermissionRpt
- ScanOptions record with defaults: IncludeInherited=false, ScanFolders=true, FolderDepth=1, IncludeSubsites=false
- IPermissionsService interface with ScanSiteAsync method enabling ViewModel mocking
2026-04-02 13:51:15 +02:00
Dev
57c258015b feat(02-05): add 15 Phase 2 localization keys to EN/FR resx and Designer
- Added 15 keys to Strings.resx with English values (grp.scan.opts, chk.scan.folders, chk.recursive, lbl.folder.depth, chk.max.depth, chk.inherited.perms, grp.export.fmt, rad.csv.perms, rad.html.perms, btn.gen.perms, btn.open.perms, btn.view.sites, perm.site.url, perm.or.select, perm.sites.selected)
- Added same 15 keys to Strings.fr.resx with genuine French translations (no English fallback)
- Added 15 static properties to Strings.Designer.cs following dot-to-underscore naming pattern
2026-04-02 13:50:43 +02:00
Dev
a9f6bde686 test(02-01): scaffold PermissionsService, ViewModel, and classification test stubs
- PermissionEntryHelper.cs: pure static IsExternalUser, FilterPermissionLevels, IsSharingLinksGroup
- PermissionEntryClassificationTests.cs: 7 real [Fact] tests — all passing immediately
- PermissionsServiceTests.cs: 2 stubs (PERM-01, PERM-04) skipped until Plan 02 CSOM impl
- PermissionsViewModelTests.cs: 1 stub (PERM-02) skipped until Plan 02 ViewModel impl
2026-04-02 13:50:41 +02:00
Dev
78b3d4f759 feat(02-03): implement ISiteListService and SiteListService with admin URL derivation
- SiteInfo record added to Core/Models
- ISiteListService interface with GetSitesAsync signature
- SiteListService derives admin URL via Regex, connects via SessionManager
- Filters to Active sites only, excludes OneDrive personal (-my.sharepoint.com)
- Access denied ServerException wrapped as InvalidOperationException with actionable message
- DeriveAdminUrl marked internal static for unit testability
- InternalsVisibleTo added to AssemblyInfo.cs to expose internal to test project
- 2 DeriveAdminUrl tests pass; full suite: 53 pass, 4 skip, 0 fail
2026-04-02 13:50:35 +02:00
Dev
5c10840581 test(02-03): add failing tests for SiteListService.DeriveAdminUrl
- Two tests for DeriveAdminUrl: standard URL and trailing-slash URL
- Tests fail (RED) — SiteListService not yet implemented
2026-04-02 13:49:16 +02:00
Dev
097d7b3326 docs(phase-02): add research, validation strategy, and 7 plans for Permissions phase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:40:33 +02:00
Dev
55819bd059 docs(02-permissions): create phase 2 plan — 7 plans across 4 waves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:38:09 +02:00
Dev
031a7dbc0f docs(phase-02): research permissions phase domain
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:25:38 +02:00
Dev
27d654d86a docs(phase-01): complete phase execution — 11/11 verified, advancing to phase 02 2026-04-02 13:02:50 +02:00
Dev
62a7deb6e9 docs(01-08): complete plan — human visual checkpoint approved, Phase 1 Foundation done
- Task 2 (human-verify checkpoint) approved: all 7 visual checks passed
- Updated SUMMARY to document 3 runtime fix commits (DI registration, FR translations)
- STATE.md: plan complete, session updated
- ROADMAP.md: Phase 1 confirmed Complete 8/8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:55:22 +02:00
Dev
0b8a86a58a fix(01-08): add real French translations (stubs were identical to English) 2026-04-02 12:52:16 +02:00
Dev
6211f65a5e fix(01-08): provide file paths to ProfileRepository and SettingsRepository via factory registration 2026-04-02 12:47:11 +02:00
Dev
c66efdadfa fix(01-08): register ProfileRepository and SettingsRepository in DI container 2026-04-02 12:45:59 +02:00
Dev
991c92e83a docs(01-08): complete phase 1 final verification plan — awaiting human checkpoint
- 44/44 non-interactive tests pass, 1 MSAL interactive skip (expected)
- Build: 0 errors, 0 warnings with -warnaserror
- ROADMAP.md: Phase 1 marked Complete (8/8 summaries)
- STATE.md: progress 100%, decisions recorded, checkpoint pause noted
- SUMMARY.md created for 01-08

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:43:28 +02:00
Dev
334a5f10ad chore(01-08): run full test suite — 44 passed, 1 skipped, 0 failed
- dotnet build with -warnaserror: 0 warnings, 0 errors
- 44 unit/integration tests pass
- 1 interactive MSAL test skipped (expected)
- Build clean on SharepointToolbox.slnx
2026-04-02 12:41:55 +02:00
Dev
405a013375 docs(01-07): complete ProfileManagementDialog + SettingsView plan
- Create 01-07-SUMMARY.md with full documentation
- Update STATE.md: progress 88%, decisions added, session recorded
- Update ROADMAP.md: Phase 1 at 7/8 plans
2026-04-02 12:40:36 +02:00
Dev
0665152e0d feat(01-07): add SettingsView and wire into MainWindow Settings tab
- Create Views/Tabs/SettingsView.xaml (UserControl with language ComboBox en/fr, DataFolder TextBox and Browse button using TranslationSource)
- Create Views/Tabs/SettingsView.xaml.cs (DI constructor injection of SettingsViewModel, LoadAsync on Loaded)
- Update MainWindow.xaml to add xmlns:views namespace and clear placeholder TextBlock from SettingsTabItem
- Register SettingsView as Transient in DI; resolve and set as SettingsTabItem.Content from MainWindow constructor
- All 42 unit tests pass, 0 build errors
2026-04-02 12:38:38 +02:00
Dev
cb7cf93c52 feat(01-07): add ProfileManagementDialog with DI factory wiring
- Create Views/Dialogs/ProfileManagementDialog.xaml (modal Window with Name/TenantUrl/ClientId fields and TranslationSource bindings)
- Create Views/Dialogs/ProfileManagementDialog.xaml.cs (DI constructor injection, LoadAsync on Loaded)
- Add OpenProfileManagementDialog factory delegate to MainWindowViewModel
- Wire ManageProfilesCommand to open dialog via factory, reload profiles after close
- Register ProfileManagementDialog as Transient in DI (App.xaml.cs)
- Inject IServiceProvider into MainWindow constructor for DI-resolved dialog factory
2026-04-02 12:38:31 +02:00
Dev
b41599d95a docs(01-06): complete WPF shell plan — SUMMARY, STATE, ROADMAP updated
- 2 tasks completed, 12 files modified
- 6 FeatureViewModelBase unit tests added and passing
- Full WPF shell with FeatureTabBase, MainWindowViewModel, LogPanelSink wiring
2026-04-02 12:34:58 +02:00
Dev
5920d42614 feat(01-06): build WPF shell — MainWindow XAML, ViewModels, LogPanelSink wiring
- Add FeatureTabBase UserControl with ProgressBar/TextBlock/CancelButton strip
  (Visibility bound to IsRunning, shown only during operations)
- Add MainWindowViewModel with TenantProfiles ObservableCollection, ConnectCommand,
  ClearSessionCommand, ManageProfilesCommand, ProgressUpdatedMessage subscription
- Add ProfileManagementViewModel wrapping ProfileService CRUD with input validation
- Add SettingsViewModel (extends FeatureViewModelBase) with language/folder settings
- Update MainWindow.xaml: DockPanel shell with Toolbar, TabControl (8 tabs), 150px
  RichTextBox LogPanel, StatusBar (tenant name | ProgressStatus | ProgressPercentage)
- MainWindow.xaml.cs: DI constructor, DataContext=viewModel, LoadProfilesAsync on Loaded
- App.xaml.cs: register all services, wire LogPanelSink after MainWindow resolved,
  register DispatcherUnhandledException and UnobservedTaskException global handlers
- App.xaml: add BoolToVisibilityConverter resource
2026-04-02 12:32:41 +02:00
Dev
3c09155648 feat(01-06): implement FeatureViewModelBase with async/cancel/progress pattern
- Add ProgressUpdatedMessage ValueChangedMessage for StatusBar live updates
- Add FeatureViewModelBase with CancellationTokenSource lifecycle, IsRunning,
  IProgress<OperationProgress>, OperationCanceledException handling
- Add 6 unit tests covering lifecycle, progress, cancellation, error handling
  and CanExecute guard
2026-04-02 12:29:38 +02:00
Dev
fcae8f0e49 docs(01-04): complete auth layer plan
- 01-04-SUMMARY.md: MsalClientFactory + SessionManager auth layer
- STATE.md: progress updated to 63%, decisions added, session recorded
- ROADMAP.md: phase 1 progress updated (5/8 summaries)
- REQUIREMENTS.md: FOUND-03 and FOUND-04 marked complete
2026-04-02 12:26:55 +02:00
Dev
158aab96b2 feat(01-04): SessionManager singleton holding all ClientContext instances
- SessionManager owns all ClientContexts; callers must not store references
- IsAuthenticated(tenantUrl) returns false before auth, true after GetOrCreateContextAsync
- ClearSessionAsync disposes ClientContext and removes state (idempotent for unknown tenants)
- GetOrCreateContextAsync validates null/empty TenantUrl and ClientId (ArgumentException)
- MsalClientFactory.GetCacheHelper() added — exposes helper for PnP tokenCacheCallback wiring
- 8 unit tests pass, 1 interactive-login test skipped (integration-only)
2026-04-02 12:25:01 +02:00
Dev
02955199f6 feat(01-04): MsalClientFactory with per-clientId PCA and MsalCacheHelper
- Creates one IPublicClientApplication per ClientId (never shared)
- Persists token cache to configurable directory (default: %AppData%\SharepointToolbox\auth\msal_{clientId}.cache)
- SemaphoreSlim(1,1) prevents duplicate creation under concurrent calls
- CacheDirectory property exposed for test injection
- 4 unit tests: same-instance, different-instance, concurrent, AppData path
2026-04-02 12:22:54 +02:00
Dev
466bef3e87 docs(01-05): complete localization and logging plan
- 01-05-SUMMARY.md: TranslationSource + EN/FR resx + Serilog integration tests
- STATE.md: progress 50% (4/8 plans), metrics recorded, decisions added
- ROADMAP.md: phase 1 progress updated (4/8 summaries)
- REQUIREMENTS.md: FOUND-09 marked complete
2026-04-02 12:20:08 +02:00
Dev
1c532d1f6b feat(01-05): add Serilog integration tests and App.xaml.cs LogPanelSink comment
- LoggingIntegrationTests: verifies Serilog writes rolling log file with correct content
- LogPanelSink structural smoke test: confirms type implements ILogEventSink
- App.xaml.cs: added comment for LogPanelSink DI registration deferred to plan 01-06
2026-04-02 12:18:02 +02:00
Dev
a287ed83ab feat(01-05): implement TranslationSource singleton + EN/FR resx files
- TranslationSource singleton with INotifyPropertyChanged indexer binding
- PropertyChanged fires with string.Empty on culture switch (signals all bindings refresh)
- Missing key returns [key] placeholder (prevents null in WPF bindings)
- Strings.resx with 27 Phase 1 UI string keys (EN)
- Strings.fr.resx with same 27 keys stubbed with EN text (FR completeness Phase 5)
- Strings.Designer.cs ResourceManager for dotnet build compatibility
- SharepointToolbox.csproj updated with EmbeddedResource metadata
2026-04-02 12:16:57 +02:00
Dev
8a58140f9b test(01-05): add failing tests for TranslationSource singleton
- Instance singleton test
- EN string lookup test
- FR culture fallback test
- Missing key returns bracketed key
- PropertyChanged fires with empty string on culture switch
- Same culture does not fire PropertyChanged
2026-04-02 12:14:49 +02:00
Dev
dd2f179c2d docs(01-03): complete persistence layer plan — ProfileService + SettingsService
- SUMMARY.md: 7 files, 18 tests, write-then-replace pattern documented
- STATE.md: plan 03 complete, progress 38%, decisions recorded
- ROADMAP.md: phase 1 progress updated (3/8 plans done)
- REQUIREMENTS.md: FOUND-02, FOUND-10, FOUND-12 marked complete
2026-04-02 12:13:35 +02:00
Dev
ac3fa5c8eb feat(01-03): SettingsRepository and SettingsService with write-then-replace
- AppSettings model: DataFolder + Lang with camelCase JSON serialization
- SettingsRepository: SemaphoreSlim write lock + write-then-replace (tmp→validate→move)
- SettingsService: GetSettings/SetLanguage/SetDataFolder; SetLanguage validates en/fr only
- All 8 SettingsServiceTests pass; all 18 Unit tests pass
2026-04-02 12:12:02 +02:00
Dev
769196dabe feat(01-03): ProfileRepository and ProfileService with write-then-replace
- ProfileRepository: SemaphoreSlim write lock + write-then-replace (tmp→validate→move)
- ProfileRepository: camelCase JSON serialization matching existing schema
- ProfileService: CRUD operations (Add/Rename/Delete) with validation
- All 10 ProfileServiceTests pass (round-trip, missing file, corrupt JSON, concurrency, schema check)
2026-04-02 12:10:56 +02:00
Dev
ff29d4ec19 docs(01-02): complete core models and helpers plan — SUMMARY, STATE, ROADMAP updated
- 01-02-SUMMARY.md created with 7 files, 2 tasks, 1 auto-fix deviation
- STATE.md: progress 25% (2/8 plans), 2 new decisions added
- ROADMAP.md: phase 1 updated (2/8 summaries, In Progress)
- REQUIREMENTS.md: FOUND-05/06/07/08 marked complete
2026-04-02 12:08:16 +02:00
Dev
c2978016b0 feat(01-02): add SharePointPaginationHelper, ExecuteQueryRetryHelper, LogPanelSink
- SharePointPaginationHelper: async iterator with ListItemCollectionPosition loop (bypasses 5k limit); RowLimit=2000; [EnumeratorCancellation] for correct WithCancellation support
- ExecuteQueryRetryHelper: exponential backoff on 429/503/throttle; surfaces retry events via IProgress<OperationProgress>; max 5 retries
- LogPanelSink: custom Serilog ILogEventSink writing color-coded entries to RichTextBox via Dispatcher.InvokeAsync for thread safety
2026-04-02 12:06:39 +02:00
Dev
ddb216b1fb feat(01-02): add Core models and WeakReferenceMessenger messages
- TenantProfile (plain class, mutable, fields match JSON schema: Name/TenantUrl/ClientId)
- OperationProgress (record with Indeterminate factory, used by all feature services via IProgress<T>)
- TenantSwitchedMessage (ValueChangedMessage<TenantProfile>, broadcast-ready)
- LanguageChangedMessage (ValueChangedMessage<string>, broadcast-ready)
2026-04-02 12:05:27 +02:00
Dev
41f8844a16 docs(01-01): complete solution scaffold plan — SUMMARY, STATE, ROADMAP updated
- 01-01-SUMMARY.md created with deviations, decisions, and metrics
- STATE.md: progress 13% (1/8 plans), 3 decisions added, metrics recorded
- ROADMAP.md: Phase 1 marked In Progress (1/8 summaries)
- REQUIREMENTS.md: FOUND-01 marked complete
2026-04-02 12:04:19 +02:00
Dev
eac34e3e2c feat(01-01): add xUnit test project with 7 stub test files
- SharepointToolbox.Tests targeting net10.0-windows with UseWPF=true
- Moq 4.20.72 added for future mocking
- 7 stub test files across Services, Auth, ViewModels, Localization, Integration
- All tests marked [Fact(Skip)] referencing implementation plan — 0 failed, 7 skipped
- Solution build: 0 errors, 0 warnings
2026-04-02 12:02:30 +02:00
Dev
f469804810 feat(01-01): create WPF solution with Generic Host entry point and NuGet packages
- SharepointToolbox.slnx solution with WPF project
- net10.0-windows target, PublishTrimmed=false, StartupObject set
- App.xaml StartupUri removed, App demoted from ApplicationDefinition to Page
- App.xaml.cs: [STAThread] Main with Host.CreateDefaultBuilder + Serilog rolling file
- All NuGet packages: CommunityToolkit.Mvvm 8.4.2, MSAL 4.83.3, PnP.Framework 1.18.0, Serilog 4.3.1
- Build: 0 errors, 0 warnings
2026-04-02 12:00:47 +02:00
Dev
b4a901e52a fix(01-foundation): revise plans based on checker feedback
- 01-04: wave 3 → 4 (01-03 is also wave 3; parallel executor would race)
- 01-06: wave 4 → 5 (cascades from 01-04 fix); add FeatureTabBase UserControl
  for per-tab progress/cancel strip; bind StatusBar middle item to ProgressStatus
  instead of ConnectionStatus per locked CONTEXT.md decision
- 01-07: wave 5 → 6 (cascades)
- 01-08: wave 6 → 7 (cascades)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:53:41 +02:00
Dev
eeb9a3bcd1 fix(01-foundation): revise plans based on checker feedback
- 01-03: wave 2 → wave 3 (depends on 01-02 which is also wave 2; must be wave 3)
- 01-06: add ProgressUpdatedMessage.cs to files_modified; add third StatusBarItem (progress %) to XAML per locked CONTEXT.md decision; add ProgressUpdatedMessage subscription in MainWindowViewModel.OnActivated()
- 01-08: add comment to empty <files> element (auto task with no file output)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:44:54 +02:00
Dev
ff5ac94ae2 docs(01-foundation): create phase plan (8 plans, 6 waves)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:38:35 +02:00
Dev
f303a60018 docs(phase-1): add research and validation strategy for foundation phase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:29:00 +02:00
Dev
eba593c7ef docs(01-foundation): research phase 1 foundation
Research covering WPF Generic Host wiring, MSAL per-tenant token cache
(MsalCacheHelper), CommunityToolkit.Mvvm async patterns, dynamic resx
localization, Serilog setup, JSON write-then-replace, and ObservableCollection
threading rules. Includes validation architecture and test gap list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:27:50 +02:00
8102994aa5 docs: create roadmap (5 phases), research complete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:14:13 +02:00
8a393aa540 docs: define v1 requirements (42 requirements across 8 categories)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:11:11 +02:00
0c2e26e597 docs: complete project research for SharePoint Toolbox rewrite
Research covers stack (NET10/WPF/PnP.Framework), features (v1 parity + v1.x
differentiators), architecture (MVVM four-layer pattern), and pitfalls
(10 critical pitfalls all addressed in foundation phase). SUMMARY.md
synthesizes findings with phase-structured roadmap implications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:07:47 +02:00
d372fc10f2 chore: add project config, update gitignore for .planning/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:54:02 +02:00
1619cfbb7d docs: initialize project
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:52:41 +02:00
63cf69f114 docs: map existing codebase
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:28:40 +02:00
10bfe6debc Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox
All checks were successful
Release zip package / release (push) Successful in 1s
2026-04-01 17:12:30 +02:00
945a4e110d Update TODO.md 2026-04-01 17:12:24 +02:00
109d0d5f1e Update TODO.md 2026-03-27 09:57:13 +01:00
b4f0fecad2 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox
All checks were successful
Release zip package / release (push) Successful in 1s
2026-03-27 09:54:11 +01:00
903fa17f8a Updated workflow to include CSV examples folder 2026-03-27 09:54:01 +01:00
693f21915d Updated workflow to include CSV examples folder
All checks were successful
Release zip package / release (push) Successful in 1s
2026-03-17 11:03:23 +01:00
ab39e55194 Added mass-transfer
All checks were successful
Release zip package / release (push) Successful in 1s
2026-03-17 10:57:11 +01:00
a1edea3007 Cleanup2
All checks were successful
Release zip package / release (push) Successful in 1s
2026-03-17 10:36:59 +01:00
db0f87dc00 Cleanup 2026-03-17 10:36:41 +01:00
28e4c21e80 Added version cleanup feature 2026-03-17 10:35:39 +01:00
5c5e4b1415 Buttons size fix 2026-03-16 16:55:27 +01:00
086804edf9 Added functionnality : you can vcreate a whole folder tree by importing a CSV (see examples fodler) 2026-03-16 16:39:37 +01:00
9dc85c8057 Added sample CSV files for user/sites importation.
All checks were successful
Release zip package / release (push) Successful in 1s
Fixed a few bugs.
2026-03-16 13:45:00 +01:00
9bcbad5d5b Ajoute de barres de recherches dans les rapports HTML de permissions et stockage
All checks were successful
Release zip package / release (push) Successful in 1s
2026-03-16 11:44:17 +01:00
0e5f67bfa4 Added 2 new features :
All checks were successful
Release zip package / release (push) Successful in 1s
- File/folder transfer betrween sites
- Bulk site creation
2026-03-16 11:22:01 +01:00
14bb1a7c13 Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-03-11 11:05:52 +01:00
a256e30c59 New logo 2026-03-11 11:05:17 +01:00
2856964858 Update README.md 2026-03-10 17:11:07 +01:00
3ca58a4da0 Updated Workflow 2026-03-10 17:09:08 +01:00
2cc9d91f5a Do not upload wiki files here 2026-03-10 15:29:05 +01:00
235 changed files with 22829 additions and 4507 deletions

View File

@@ -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/
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\":\"### Installation\\n1. Télécharger et extraire le zip\\n2. Lancer Sharepoint_ToolBox.ps1 avec PowerShell\\n3. Prérequis : Install-Module PnP.PowerShell\"}" \
| 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 }}"

29
.gitignore vendored
View File

@@ -1,5 +1,24 @@
.claude # Build outputs
*.html bin/
*.json obj/
!lang/ publish/
!lang/*.json
# IDE
.vs/
*.user
*.suo
release.ps1
Sharepoint_ToolBox.ps1
# Claude Code
.claude/
.planning/
# OS
Thumbs.db
Desktop.ini
# Secrets
*.pfx
appsettings.*.json
Sharepoint_Settings.json

144
README.md
View File

@@ -1,124 +1,40 @@
![SPToolbox-logo](https://git.azuze.fr/kawa/Sharepoint-Toolbox/raw/branch/main/SPToolbox-logo-dark.png) ![SPToolbox-logo](https://git.azuze.fr/kawa/Sharepoint-Toolbox/raw/branch/main/SPToolbox-logo.png)
Application PowerShell avec interface graphique (WinForms) pour administrer, auditer et exporter des données depuis des sites SharePoint Online. # SharePoint Toolbox
## Prérequis Application pour administrer, auditer et exporter des donnees depuis des sites SharePoint Online.
- **PowerShell 5.1** ou supérieur ## Prerequis
- **Module PnP.PowerShell** (`Install-Module PnP.PowerShell`)
- **[Azure AD App Registration](https://git.azuze.fr/kawa/ps-scripts/src/commit/4ccc3de2b83295597a9212132d2f3d49afd9492b/Misc/Reg-App.ps1)** avec les permissions déléguées nécessaires (Client ID requis)
- Accès au tenant SharePoint cible
## Lancement - Windows 10 ou superieur
- Runtime .NET 10 Desktop
- Acces au tenant SharePoint cible
```powershell ## Fonctionnalites principales
.\Sharepoint_Toolbox.ps1
```
--- - **Connexion & profils** — profils de connexion reutilisables, selecteur multi-sites, enregistrement Azure AD assiste, branding multi-tenant
- **Rapport de permissions** — audit bibliotheques/listes/dossiers, permissions heritees, mode consolidation, export CSV/HTML
- **Metriques de stockage** — utilisation par bibliotheque, taille des versions, nombre d'elements, visualisation 3D interactive, export CSV/HTML
- **Annuaire utilisateurs** — liste des utilisateurs du tenant via Microsoft Graph, filtrage/recherche, export HTML
- **Recherche de fichiers** — recherche KQL (extension, regex, plages de dates, auteur, editeur, bibliotheque)
- **Detection de doublons** — fichiers (Search API) ou dossiers (CAML), criteres combinables (nom, taille, dates, nombres), export CSV/HTML
- **Localisation** — interface complete EN/FR
## Fonctionnalités ## Dependances (NuGet)
### Connexion et profils | Paquet | Version | Role |
|---|---|---|
| CommunityToolkit.Mvvm | 8.4.2 | Generateurs MVVM |
| CsvHelper | 33.1.0 | Lecture/ecriture CSV |
| LiveChartsCore.SkiaSharpView.WPF | 2.0.0-rc5.4 | Graphiques / vue 3D stockage |
| Microsoft.Extensions.Hosting | 10.0.0 | Host generique + DI |
| Microsoft.Graph | 5.74.0 | SDK Graph (tenant/utilisateurs) |
| Microsoft.Identity.Client | 4.83.3 | Authentification MSAL |
| Microsoft.Identity.Client.Broker | 4.82.1 | Support broker WAM |
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 | Cache de tokens persistant |
| PnP.Framework | 1.18.0 | Operations SharePoint CSOM |
| Serilog (+ Hosting, Sinks.File) | 4.3.1 / 10.0.0 / 7.0.0 | Journalisation |
- Saisie du **Tenant URL**, **Client ID** et **Site URL** ## Architecture
- **Profils sauvegardés** : créez, renommez, supprimez et chargez des profils de connexion réutilisables
- **Sélecteur de sites** : parcourez et cochez plusieurs sites du tenant en une seule vue (chargement asynchrone)
- Dossier de sortie configurable pour tous les exports
--- MVVM (CommunityToolkit) · DI via Microsoft.Extensions.Hosting · Authentification MSAL avec cache persistant et broker WAM · Microsoft Graph SDK · PnP.Framework (CSOM) · Localisation .resx (EN/FR) · Branding configurable dans les exports HTML.
### Permissions Report
Audit complet des permissions d'un ou plusieurs sites.
- Scan des **bibliothèques, listes et dossiers** (profondeur configurable ou illimitée)
- Option **Recursive** pour inclure les sous-sites
- Inclusion optionnelle des permissions héritées
- Export **CSV** (données brutes, compatibles Excel) ou **HTML** (rapport visuel avec tableau interactif, filtrage, tri par colonne, regroupement par utilisateur/groupe)
---
### Storage Metrics
Analyse de l'occupation du stockage SharePoint.
- Répartition **par bibliothèque** avec profondeur de dossiers configurable
- Option d'inclusion des **sous-sites**
- Métriques : taille totale, taille des versions, nombre d'éléments, dernière modification
- Export **CSV** ou **HTML** (rapport avec graphiques de répartition et arborescence dépliable)
---
### Templates
Capture et réapplication de la structure d'un site SharePoint.
- **Capture** : arborescence (bibliothèques et dossiers), permissions (groupes et rôles), paramètres du site (titre, langue), logo
- **Création** depuis un template : nouveau site Communication ou Teams à partir d'un template capturé, avec application sélective des éléments capturés
- Templates persistés localement dans `Sharepoint_Templates.json`
---
### Recherche de fichiers
Recherche avancée de fichiers à travers les bibliothèques d'un site.
| Filtre | Description |
|---|---|
| Extension(s) | Ex : `docx pdf xlsx` |
| Nom / Regex | Expression régulière appliquée sur le chemin du fichier |
| Créé après / avant | Plage de dates de création |
| Modifié après / avant | Plage de dates de modification |
| Créé par | Nom ou email de l'auteur |
| Modifié par | Nom ou email du dernier éditeur |
| Bibliothèque | Limite la recherche à un chemin relatif |
| Max résultats | Plafond configurable (10 50 000) |
Utilise la **Search API SharePoint (KQL)** avec pagination automatique. Le filtre regex est appliqué côté client après récupération des résultats.
Export **CSV** ou **HTML** (tableau trié par colonne, filtrage en temps réel, indicateurs de tri).
---
### Doublons
Détection de fichiers ou dossiers en double au sein d'un site.
**Type de scan :**
- Fichiers en double (via Search API)
- Dossiers en double (via énumération des bibliothèques)
**Critères de comparaison (combinables) :**
- Nom — *toujours inclus comme critère principal*
- Taille identique
- Date de création identique
- Date de modification identique
- Nombre de sous-dossiers identique *(dossiers uniquement)*
- Nombre de fichiers identique *(dossiers uniquement)*
Le rapport HTML présente les doublons regroupés en **cartes dépliables** avec mise en évidence visuelle des valeurs identiques (vert) et différentes (orange), ainsi qu'un badge "Identiques" / "Différences détectées" par groupe.
Export **CSV** (avec colonne `DuplicateGroup`) ou **HTML**.
---
## Fichiers générés
| Fichier | Description |
|---|---|
| `Sharepoint_Export_profiles.json` | Profils de connexion sauvegardés |
| `Sharepoint_Templates.json` | Templates de sites capturés |
| `Permissions_<site>_<date>.csv/html` | Rapports de permissions |
| `Storage_<site>_<date>.csv/html` | Rapports de stockage |
| `FileSearch_<date>.csv/html` | Résultats de recherche de fichiers |
| `Duplicates_<mode>_<date>.csv/html` | Résultats du scan de doublons |
---
## Architecture technique
- Interface **WinForms** (PowerShell natif, aucune dépendance UI externe)
- Toutes les opérations longues s'exécutent dans des **runspaces séparés** pour ne pas bloquer l'interface
- Communication runspace → UI via **hashtable synchronisée** + timer
- Module **PnP.PowerShell** pour toutes les interactions avec l'API SharePoint

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

BIN
SPToolbox-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

View 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);
}
}

View 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;
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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>");
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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()
{
}
}

View 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);
}
}

View File

@@ -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);
}
}

View 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()
{
}
}

View 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);
}
}

View 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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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("&lt;script&gt;", html);
}
// ── Branding tests ────────────────────────────────────────────────────────
[Fact]
public void BuildHtml_WithBranding_ContainsLogoImg()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry }, branding: MakeBranding(msp: true));
Assert.Contains("data:image/png;base64,bXNw", html);
}
// ── Consolidation tests (RPT-03-b through RPT-03-e) ──────────────────────
// Shared test data: 3 entries where 2 share the same consolidation key
private static IReadOnlyList<UserAccessEntry> MakeConsolidationTestEntries()
{
// Entry 1 + Entry 2: same user/permission/accesstype/grantedthrough, different sites
var e1 = MakeEntry(
userDisplay: "Bob Jones",
userLogin: "bob@contoso.com",
siteUrl: "https://contoso.sharepoint.com/sites/Alpha",
siteTitle: "Alpha Site",
permLevel: "Contribute",
accessType: AccessType.Direct,
grantedThrough: "Direct Permissions");
var e2 = MakeEntry(
userDisplay: "Bob Jones",
userLogin: "bob@contoso.com",
siteUrl: "https://contoso.sharepoint.com/sites/Beta",
siteTitle: "Beta Site",
permLevel: "Contribute",
accessType: AccessType.Direct,
grantedThrough: "Direct Permissions");
// Entry 3: different user — will have 1 location
var e3 = MakeEntry(
userDisplay: "Carol Davis",
userLogin: "carol@contoso.com",
siteUrl: "https://contoso.sharepoint.com/sites/Gamma",
siteTitle: "Gamma Site",
permLevel: "Read",
accessType: AccessType.Group,
grantedThrough: "Readers Group");
return new[] { e1, e2, e3 };
}
// RPT-03-b: BuildHtml(entries, mergePermissions: false) is byte-identical to BuildHtml(entries)
[Fact]
public void BuildHtml_mergePermissionsFalse_identical_to_default()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var defaultOutput = svc.BuildHtml(entries);
var explicitFalse = svc.BuildHtml(entries, mergePermissions: false);
Assert.Equal(defaultOutput, explicitFalse);
}
// RPT-03-c: BuildHtml(entries, mergePermissions: true) contains "Sites" column header and consolidated content
[Fact]
public void BuildHtml_mergePermissionsTrue_contains_sites_column()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries, mergePermissions: true);
Assert.Contains("Sites", html);
// Consolidated rows present for both users
Assert.Contains("Bob Jones", html);
Assert.Contains("Carol Davis", html);
}
// RPT-03-d: 2+ locations produce [N sites] badge with toggleGroup and hidden sub-rows
[Fact]
public void BuildHtml_mergePermissionsTrue_multiLocation_has_badge_and_subrows()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries, mergePermissions: true);
// Badge with onclick
Assert.Contains("onclick=\"toggleGroup('loc", html);
// Hidden sub-rows
Assert.Contains("data-group=\"loc", html);
}
// RPT-03-e: mergePermissions=true omits "By Site" button and view-site div
[Fact]
public void BuildHtml_mergePermissionsTrue_omits_bysite_view()
{
var entries = MakeConsolidationTestEntries();
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries, mergePermissions: true);
Assert.DoesNotContain("btn-site", html);
Assert.DoesNotContain("view-site", html);
}
}

View 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
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View 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;
}
}

View 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)");
}
}

View 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;
}

View 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"));
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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));
}
}

View 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>

View 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));
}
}

View 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);
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="SharepointToolbox.Tests/SharepointToolbox.Tests.csproj" />
<Project Path="SharepointToolbox/SharepointToolbox.csproj" />
</Solution>

View 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>

View 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>();
}
}

View 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)
)]

View 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;
}

View 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);
}
}

View 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();
}
}

View 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);
}

View 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));
}
}

View 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);
}
}

View File

@@ -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) { }
}

View File

@@ -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) { }
}

View File

@@ -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) { }
}

View 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) { }
}

View 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);
}

View 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;
}

View File

@@ -0,0 +1,6 @@
namespace SharepointToolbox.Core.Models;
public class BrandingSettings
{
public LogoData? MspLogo { get; set; }
}

View 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"
}

View 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;
}
}

View 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
}

View File

@@ -0,0 +1,8 @@
namespace SharepointToolbox.Core.Models;
public enum ConflictPolicy
{
Skip,
Overwrite,
Rename
}

View 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;
}

View 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);
}

View 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();
}

View 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; }
}

View 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
);

View 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();
}

View 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);
}
}

View 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);

View 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
);

View 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;
}

View 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);
}

View 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
);

View 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();
}
}

View 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);

View 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);

View 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
}

View 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
);

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