345 Commits
v1.0.4 ... main

Author SHA1 Message Date
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
233 changed files with 22632 additions and 5901 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\":\"### How to use\\n1. Download and extract the archive\\n2. Launch Sharepoint_ToolBox.ps1 with PowerShell\\n\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_ENV"
- name: Upload asset
run: |
curl -sf -X POST \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${{ env.RELEASE_ID }}/assets" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-F "attachment=@${{ env.ZIP }}"

32
.gitignore vendored
View File

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

131
README.md
View File

@@ -1,124 +1,85 @@
![SPToolbox-logo](https://git.azuze.fr/kawa/Sharepoint-Toolbox/raw/branch/main/SPToolbox-logo.png) ![SPToolbox-logo](https://git.azuze.fr/kawa/Sharepoint-Toolbox/raw/branch/main/SPToolbox-logo.png)
Application PowerShell avec interface graphique (WinForms) pour administrer, auditer et exporter des données depuis des sites SharePoint Online. Application WPF (.NET 10) pour administrer, auditer et exporter des donnees depuis des sites SharePoint Online.
## Prérequis ## Installation
- **PowerShell 5.1** ou supérieur 1. Telecharger le zip depuis la [page Releases](https://git.azuze.fr/kawa/Sharepoint-Toolbox/releases)
- **Module PnP.PowerShell** (`Install-Module PnP.PowerShell`) 2. Extraire l'archive
- **[Azure AD App Registration](https://git.azuze.fr/kawa/ps-scripts/src/commit/4ccc3de2b83295597a9212132d2f3d49afd9492b/Misc/Reg-App.ps1)** avec les permissions déléguées nécessaires (Client ID requis) 3. Lancer **SharepointToolbox.exe** (necessite le runtime .NET 10)
- Accès au tenant SharePoint cible
## Lancement ## Prerequis
```powershell - **Windows 10** ou superieur
.\Sharepoint_Toolbox.ps1 - **Runtime .NET 10** Desktop
``` - Acces au tenant SharePoint cible
--- ## Fonctionnalites
## Fonctionnalités
### Connexion et profils ### Connexion et profils
- Saisie du **Tenant URL**, **Client ID** et **Site URL** - Saisie du **Tenant URL** et **Client ID**
- **Profils sauvegardés** : créez, renommez, supprimez et chargez des profils de connexion réutilisables - **Profils sauvegardes** : creez, renommez, supprimez et chargez des profils de connexion reutilisables
- **Sélecteur de sites** : parcourez et cochez plusieurs sites du tenant en une seule vue (chargement asynchrone) - **Selecteur de sites** : parcourez et cochez plusieurs sites du tenant
- Dossier de sortie configurable pour tous les exports - **Enregistrement d'app** : enregistrement automatique ou guide manuel de l'app Azure AD depuis le profil
- Support **multi-tenant** avec gestion des logos client
---
### Permissions Report ### Permissions Report
Audit complet des permissions d'un ou plusieurs sites. Audit complet des permissions d'un ou plusieurs sites.
- Scan des **bibliothèques, listes et dossiers** (profondeur configurable ou illimitée) - Scan des **bibliotheques, listes et dossiers** (profondeur configurable)
- Option **Recursive** pour inclure les sous-sites - Inclusion optionnelle des permissions heritees et sous-sites
- Inclusion optionnelle des permissions héritées - Mode **consolidation** : fusion des permissions identiques avec affichage des sites/bibliotheques
- Export **CSV** (données brutes, compatibles Excel) ou **HTML** (rapport visuel avec tableau interactif, filtrage, tri par colonne, regroupement par utilisateur/groupe) - Export **CSV** ou **HTML** (rapport interactif avec filtrage, tri, regroupement par utilisateur/site)
---
### Storage Metrics ### Storage Metrics
Analyse de l'occupation du stockage SharePoint. Analyse de l'occupation du stockage SharePoint.
- Répartition **par bibliothèque** avec profondeur de dossiers configurable - Repartition **par bibliotheque** avec profondeur de dossiers configurable
- Option d'inclusion des **sous-sites** - Metriques : taille totale, taille des versions, nombre d'elements, derniere modification
- Métriques : taille totale, taille des versions, nombre d'éléments, dernière modification - **Visualisation 3D** interactive du stockage
- Export **CSV** ou **HTML** (rapport avec graphiques de répartition et arborescence dépliable) - Export **CSV** ou **HTML** (rapport avec graphiques de repartition)
--- ### Annuaire utilisateurs
### Templates - Liste complete des utilisateurs du tenant via Microsoft Graph
- Filtrage et recherche
Capture et réapplication de la structure d'un site SharePoint. - Export **HTML**
- **Capture** : arborescence (bibliothèques et dossiers), permissions (groupes et rôles), paramètres du site (titre, langue), logo
- **Création** depuis un template : nouveau site Communication ou Teams à partir d'un template capturé, avec application sélective des éléments capturés
- Templates persistés localement dans `Sharepoint_Templates.json`
---
### Recherche de fichiers ### Recherche de fichiers
Recherche avancée de fichiers à travers les bibliothèques d'un site. Recherche avancee de fichiers a travers les bibliotheques d'un site.
| Filtre | Description | | Filtre | Description |
|---|---| |---|---|
| Extension(s) | Ex : `docx pdf xlsx` | | Extension(s) | Ex : `docx pdf xlsx` |
| Nom / Regex | Expression régulière appliquée sur le chemin du fichier | | Nom / Regex | Expression reguliere sur le chemin du fichier |
| Créé après / avant | Plage de dates de création | | Cree apres / avant | Plage de dates de creation |
| Modifié après / avant | Plage de dates de modification | | Modifie apres / avant | Plage de dates de modification |
| Créé par | Nom ou email de l'auteur | | Cree par | Nom ou email de l'auteur |
| Modifié par | Nom ou email du dernier éditeur | | Modifie par | Nom ou email du dernier editeur |
| Bibliothèque | Limite la recherche à un chemin relatif | | Bibliotheque | Limite la recherche a un chemin relatif |
| Max résultats | Plafond configurable (10 50 000) |
Utilise la **Search API SharePoint (KQL)** avec pagination automatique. Le filtre regex est appliqué côté client après récupération des résultats. Utilise la **Search API SharePoint (KQL)** avec pagination automatique.
Export **CSV** ou **HTML** (tableau trié par colonne, filtrage en temps réel, indicateurs de tri).
---
### Doublons ### Doublons
Détection de fichiers ou dossiers en double au sein d'un site. Detection de fichiers ou dossiers en double au sein d'un ou plusieurs sites.
**Type de scan :** **Type de scan :** Fichiers (via Search API) ou Dossiers (via enumeration CAML)
- Fichiers en double (via Search API)
- Dossiers en double (via énumération des bibliothèques)
**Critères de comparaison (combinables) :** **Criteres de comparaison (combinables) :** Nom, Taille, Date de creation, Date de modification, Nombre de sous-dossiers, Nombre de fichiers
- Nom — *toujours inclus comme critère principal*
- Taille identique
- Date de création identique
- Date de modification identique
- Nombre de sous-dossiers identique *(dossiers uniquement)*
- Nombre de fichiers identique *(dossiers uniquement)*
Le rapport HTML présente les doublons regroupés en **cartes dépliables** avec mise en évidence visuelle des valeurs identiques (vert) et différentes (orange), ainsi qu'un badge "Identiques" / "Différences détectées" par groupe. Export **CSV** ou **HTML** (cartes depliables avec mise en evidence des valeurs identiques/differentes).
Export **CSV** (avec colonne `DuplicateGroup`) ou **HTML**.
---
## Fichiers générés
| Fichier | Description |
|---|---|
| `Sharepoint_Export_profiles.json` | Profils de connexion sauvegardés |
| `Sharepoint_Templates.json` | Templates de sites capturés |
| `Permissions_<site>_<date>.csv/html` | Rapports de permissions |
| `Storage_<site>_<date>.csv/html` | Rapports de stockage |
| `FileSearch_<date>.csv/html` | Résultats de recherche de fichiers |
| `Duplicates_<mode>_<date>.csv/html` | Résultats du scan de doublons |
---
## Architecture technique ## Architecture technique
- Interface **WinForms** (PowerShell natif, aucune dépendance UI externe) - Interface **WPF** avec pattern **MVVM** (CommunityToolkit.Mvvm)
- Toutes les opérations longues s'exécutent dans des **runspaces séparés** pour ne pas bloquer l'interface - Injection de dependances via Microsoft.Extensions.Hosting
- Communication runspace → UI via **hashtable synchronisée** + timer - Authentification **MSAL** avec cache persistant et support broker WAM
- Module **PnP.PowerShell** pour toutes les interactions avec l'API SharePoint - **Microsoft Graph SDK** pour les operations tenant/utilisateurs
- **PnP.Framework** (CSOM) pour les operations SharePoint
- Localisation **EN/FR** complete via fichiers .resx
- Branding configurable (logos MSP et client) dans les exports HTML

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

View File

@@ -0,0 +1,15 @@
namespace SharepointToolbox.Core.Models;
public record SearchOptions(
string[] Extensions,
string? Regex,
DateTime? CreatedAfter,
DateTime? CreatedBefore,
DateTime? ModifiedAfter,
DateTime? ModifiedBefore,
string? CreatedBy,
string? ModifiedBy,
string? Library,
int MaxResults,
string SiteUrl
);

View File

@@ -0,0 +1,13 @@
namespace SharepointToolbox.Core.Models;
public class SearchResult
{
public string Title { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string FileExtension { get; set; } = string.Empty;
public DateTime? Created { get; set; }
public DateTime? LastModified { get; set; }
public string Author { get; set; } = string.Empty;
public string ModifiedBy { get; set; } = string.Empty;
public long SizeBytes { get; set; }
}

View File

@@ -0,0 +1,61 @@
using SharepointToolbox.Core.Helpers;
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Presentation wrapper around PermissionEntry that adds simplified labels
/// and risk level classification without modifying the immutable source record.
/// Used as the DataGrid ItemsSource when simplified mode is active.
/// </summary>
public class SimplifiedPermissionEntry
{
/// <summary>The original immutable PermissionEntry.</summary>
public PermissionEntry Inner { get; }
/// <summary>
/// Human-readable labels for the permission levels.
/// E.g. "Can edit files and list items" instead of "Contribute".
/// </summary>
public string SimplifiedLabels { get; }
/// <summary>
/// The highest risk level across all permission levels on this entry.
/// Used for row-level color coding.
/// </summary>
public RiskLevel RiskLevel { get; }
/// <summary>
/// Individual mapping results for each permission level in the entry.
/// Used when detailed breakdown per-role is needed.
/// </summary>
public IReadOnlyList<PermissionLevelMapping.MappingResult> Mappings { get; }
// ── Passthrough properties for DataGrid binding ──
public string ObjectType => Inner.ObjectType;
public string Title => Inner.Title;
public string Url => Inner.Url;
public bool HasUniquePermissions => Inner.HasUniquePermissions;
public string Users => Inner.Users;
public string UserLogins => Inner.UserLogins;
public string PermissionLevels => Inner.PermissionLevels;
public string GrantedThrough => Inner.GrantedThrough;
public string PrincipalType => Inner.PrincipalType;
public SimplifiedPermissionEntry(PermissionEntry entry)
{
Inner = entry;
Mappings = PermissionLevelMapping.GetMappings(entry.PermissionLevels);
SimplifiedLabels = PermissionLevelMapping.GetSimplifiedLabels(entry.PermissionLevels);
RiskLevel = PermissionLevelMapping.GetHighestRisk(entry.PermissionLevels);
}
/// <summary>
/// Creates SimplifiedPermissionEntry wrappers for a collection of entries.
/// </summary>
public static IReadOnlyList<SimplifiedPermissionEntry> WrapAll(
IEnumerable<PermissionEntry> entries)
{
return entries.Select(e => new SimplifiedPermissionEntry(e)).ToList();
}
}

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