From e02ac3724a2568b50a2cfc817d824ac455ee3d82 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 21:55:34 +0000 Subject: [PATCH] Merge pull request #295 from CyberDrain/dev fix: dev to hotfix Synced from CyberDrain/CIPP@674a80db2185e024df0bb9367c347e7e5442f97a --- Config/DocsPublishedPages.txt | 431 +++++++++++ Config/DocsSynonyms.json | 75 ++ Config/openapi.json | 151 +++- .../AzBobbyTables/3.6.2/AzBobbyTables.PS.dll | Bin 55296 -> 57856 bytes .../AzBobbyTables/3.6.2/AzBobbyTables.psd1 | 1 + Modules/AzBobbyTables/3.6.2/CHANGELOG.md | 2 + .../3.6.2/dependencies/AzBobbyTables.Core.dll | Bin 97792 -> 104960 bytes .../3.6.2/en-US/AzBobbyTables.PS.dll-Help.xml | 232 +++++- .../Push-ExecScheduledCommand.ps1 | 55 +- .../Alerts/Get-CIPPAlertHuntressRogueApps.ps1 | 49 +- .../Public/Add-CIPPAzDataTableEntity.ps1 | 18 +- .../CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 23 +- .../Public/Authentication/Test-CIPPAccess.ps1 | 4 +- .../Public/DeltaQueries/Get-DeltaQueryUrl.ps1 | 23 +- .../DeltaQueries/New-CIPPTaskDeltaQuery.ps1 | 49 ++ .../DeltaQueries/New-GraphDeltaQuery.ps1 | 2 +- .../Start-CIPPDBTestsRun.ps1 | 8 + .../Public/Get-CIPPAzDatatableEntity.ps1 | 21 + .../Public/Get-CIPPIntuneAssignmentTarget.ps1 | 22 +- .../Public/Get-CIPPOneDriveUsageReport.ps1 | 18 +- Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 | 16 +- .../Get-CIPPSharePointSiteUsageReport.ps1 | 31 +- .../Public/Get-CippPartnerTenantInfo.ps1 | 26 + .../Public/GraphHelper/Repair-CIPPTable.ps1 | 121 ++++ .../GraphHelper/Test-CIPPTableNotFound.ps1 | 71 ++ .../MCP/ConvertFrom-CippDocMarkdown.ps1 | 122 ++++ .../Public/MCP/ConvertTo-CippDocToken.ps1 | 20 + Modules/CIPPCore/Public/MCP/Find-CippDoc.ps1 | 218 ++++++ .../CIPPCore/Public/MCP/Get-CippDocLink.ps1 | 125 ++++ .../CIPPCore/Public/MCP/Get-CippDocsIndex.ps1 | 203 ++++++ .../Public/MCP/Get-CippMcpToolCatalog.ps1 | 6 +- .../Public/MCP/Get-CippMcpToolList.ps1 | 40 +- .../Public/MCP/Get-CippMcpToolResult.ps1 | 26 +- Modules/CIPPCore/Public/New-CIPPBackup.ps1 | 1 + .../Public/Remove-CIPPAzDataTableEntity.ps1 | 23 +- .../CIPPCore/Public/Set-CIPPIntunePolicy.ps1 | 10 + .../CIPPCore/Public/Set-CIPPMobileDevice.ps1 | 26 +- .../Public/Update-CIPPAzDataTableEntity.ps1 | 94 +++ .../DBCache/Set-CIPPDBCacheSPOTenant.ps1 | 26 +- ...PDBCacheSPOTenantSyncClientRestriction.ps1 | 34 +- .../CIPP/Core/Invoke-ListCippDocs.ps1 | 52 ++ .../Core/Invoke-ListPartnerTenantInfo.ps1 | 37 + .../Extensions/Invoke-ExecExtensionTest.ps1 | 9 +- .../CIPP/MCP/Invoke-ExecMcp.ps1 | 2 +- .../Resources/Invoke-ListRooms.ps1 | 2 +- .../Endpoint/MEM/Invoke-AddPolicy.ps1 | 8 +- .../MEM/Invoke-ListIntuneTemplates.ps1 | 26 +- .../Invoke-ListSafeLinksPolicy.ps1 | 8 + .../Invoke-ListSharepointQuota.ps1 | 19 + .../Teams-Sharepoint/Invoke-ListSites.ps1 | 7 +- .../GitHub/Invoke-ListGitHubReleaseNotes.ps1 | 9 +- .../Invoke-CIPPStandardDevicePrepProfile.ps1 | 108 +-- .../Identity/Invoke-CippTestSMB1001_2_12.ps1 | 5 +- .../Public/GitHub/Invoke-GitHubApiRequest.ps1 | 39 +- .../Public/PwPush/Get-PwPushAccount.ps1 | 19 +- Shared/CIPPSharp/CippDocsIndex.cs | 673 ++++++++++++++++++ Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 49664 -> 64512 bytes .../Get-CIPPAlertHuntressRogueApps.Tests.ps1 | 106 +++ .../Set-CIPPDBCacheSPOTenant.Tests.ps1 | 126 ++++ Tests/DBCache/Start-CIPPDBTestsRun.Tests.ps1 | 147 ++++ Tests/GraphHelper/Get-CIPPSPOTenant.Tests.ps1 | 78 ++ Tests/Mcp/CippDocs.Tests.ps1 | 429 +++++++++++ ...Add-CIPPScheduledTask.DeltaQuery.Tests.ps1 | 113 +++ .../Get-CIPPIntuneAssignmentTarget.Tests.ps1 | 34 + Tests/Private/Get-DeltaQueryUrl.Tests.ps1 | 111 +++ .../Private/New-CIPPTaskDeltaQuery.Tests.ps1 | 112 +++ ...xecScheduledCommand.DeltaTrigger.Tests.ps1 | 203 ++++++ Tests/Private/Repair-CIPPTable.Tests.ps1 | 279 ++++++++ ...ardDevicePrepProfile.Assignments.Tests.ps1 | 209 ++++++ version_latest.txt | 2 +- 70 files changed, 5196 insertions(+), 199 deletions(-) create mode 100644 Config/DocsPublishedPages.txt create mode 100644 Config/DocsSynonyms.json create mode 100644 Modules/CIPPCore/Public/DeltaQueries/New-CIPPTaskDeltaQuery.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CippPartnerTenantInfo.ps1 create mode 100644 Modules/CIPPCore/Public/GraphHelper/Repair-CIPPTable.ps1 create mode 100644 Modules/CIPPCore/Public/GraphHelper/Test-CIPPTableNotFound.ps1 create mode 100644 Modules/CIPPCore/Public/MCP/ConvertFrom-CippDocMarkdown.ps1 create mode 100644 Modules/CIPPCore/Public/MCP/ConvertTo-CippDocToken.ps1 create mode 100644 Modules/CIPPCore/Public/MCP/Find-CippDoc.ps1 create mode 100644 Modules/CIPPCore/Public/MCP/Get-CippDocLink.ps1 create mode 100644 Modules/CIPPCore/Public/MCP/Get-CippDocsIndex.ps1 create mode 100644 Modules/CIPPCore/Public/Update-CIPPAzDataTableEntity.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCippDocs.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListPartnerTenantInfo.ps1 create mode 100644 Shared/CIPPSharp/CippDocsIndex.cs create mode 100644 Tests/Alerts/Get-CIPPAlertHuntressRogueApps.Tests.ps1 create mode 100644 Tests/DBCache/Set-CIPPDBCacheSPOTenant.Tests.ps1 create mode 100644 Tests/DBCache/Start-CIPPDBTestsRun.Tests.ps1 create mode 100644 Tests/GraphHelper/Get-CIPPSPOTenant.Tests.ps1 create mode 100644 Tests/Mcp/CippDocs.Tests.ps1 create mode 100644 Tests/Private/Add-CIPPScheduledTask.DeltaQuery.Tests.ps1 create mode 100644 Tests/Private/Get-DeltaQueryUrl.Tests.ps1 create mode 100644 Tests/Private/New-CIPPTaskDeltaQuery.Tests.ps1 create mode 100644 Tests/Private/Push-ExecScheduledCommand.DeltaTrigger.Tests.ps1 create mode 100644 Tests/Private/Repair-CIPPTable.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardDevicePrepProfile.Assignments.Tests.ps1 diff --git a/Config/DocsPublishedPages.txt b/Config/DocsPublishedPages.txt new file mode 100644 index 0000000000000..b9d3e4eecdfd4 --- /dev/null +++ b/Config/DocsPublishedPages.txt @@ -0,0 +1,431 @@ +# Slugs published on docs.cipp.app, snapshotted from llms.txt. +# Generated by build/tools/Update-DocsPublishedPages.ps1 - do not hand-edit. +# Read by Get-CippDocsPublishedSet so the docs search index never emits a URL that 404s. +# 427 pages. +api-documentation/endpoints +api-documentation/setup-and-authentication +demos/showcases +demos/tutorials +dev-documentation/cipp-dev-guide +dev-documentation/cipp-dev-guide/frontend-testing +dev-documentation/cipp-dev-guide/project-structure +dev-documentation/cipp-dev-guide/setting-up-for-local-development +dev-documentation/contributing-to-the-code +dev-documentation/contributing-to-the-documentation +msp-adoption-toolkit/implementing-cipp +msp-adoption-toolkit/implementing-cipp/msp-adoption-toolkit-building-a-cipp-business-case +msp-adoption-toolkit/implementing-cipp/why-cipp-doesnt-do-demos +msp-adoption-toolkit/sales-enablement-materials +msp-adoption-toolkit/sales-enablement-materials/content-templates-precooked +msp-adoption-toolkit/sales-enablement-materials/m365-management-package +msp-adoption-toolkit/sales-enablement-materials/security-packages +readme +security/cipp-community-vulnerability-disclosure-policy +security/cipp-security-and-compliance +security/cipp-security-and-compliance/security-policy +security/cipp-security-and-compliance/security-reports +setup/implementation-guide +setup/implementation-guide/recommended-first-steps +setup/implementation-guide/standards-setup +setup/installation +setup/installation/conditionalaccess +setup/installation/creating-the-cipp-service-account-gdap-ready +setup/installation/executing-the-setup-wizard +setup/installation/gdap-invite-wizard +setup/installation/owntenant +setup/maintaining-cipp +setup/maintaining-cipp/migrating-to-hosted-cipp +setup/maintaining-cipp/migrating-to-the-latest-version-of-cipp +setup/maintaining-cipp/recommended-roles +setup/maintaining-cipp/updating +setup/resources +setup/resources/how-cipp-evaluates-roles +setup/resources/professional-onboarding-services +setup/resources/sponsor-quick-start +setup/setting-up-cipp +setup/setting-up-cipp/customdomain +setup/setting-up-cipp/index +setup/setting-up-cipp/install +setup/setting-up-cipp/roles +sip-and-cipp/autopilot-and-intune +sip-and-cipp/conditional-access +sip-and-cipp/from-fork-to-feature +sip-and-cipp/rise-of-the-cipps +troubleshooting/frequently-asked-questions +troubleshooting/frequently-asked-questions/how-do-i-migrate-my-csp-to-a-new-tenant-in-cipp +troubleshooting/frequently-asked-questions/i-got-a-potential-phishing-page-detected-alert.-what-do-i-do-with-that +troubleshooting/frequently-asked-questions/standards-v-drift +troubleshooting/troubleshooting +troubleshooting/troubleshooting-instructions +troubleshooting/troubleshooting-instructions/refreshing-a-specific-tenants-permissions-via-cpv-api +troubleshooting/troubleshooting-instructions/repairing-missing-function-app-settings +user-documentation/cipp +user-documentation/cipp/advanced +user-documentation/cipp/advanced/authentication +user-documentation/cipp/advanced/authentication/cipp-roles +user-documentation/cipp/advanced/authentication/cipp-roles/add +user-documentation/cipp/advanced/authentication/cipp-users +user-documentation/cipp/advanced/authentication/sam-app-permissions +user-documentation/cipp/advanced/authentication/sam-app-roles +user-documentation/cipp/advanced/authentication/sso +user-documentation/cipp/advanced/container-management +user-documentation/cipp/advanced/container-management/custom-domains +user-documentation/cipp/advanced/container-management/logs +user-documentation/cipp/advanced/container-management/status +user-documentation/cipp/advanced/container-management/worker-health +user-documentation/cipp/advanced/diagnostics +user-documentation/cipp/advanced/exchange-cmdlets +user-documentation/cipp/advanced/super-admin +user-documentation/cipp/advanced/super-admin/function-offloading +user-documentation/cipp/advanced/super-admin/tenant-mode +user-documentation/cipp/advanced/super-admin/time-settings +user-documentation/cipp/advanced/table-maintenance +user-documentation/cipp/advanced/timers +user-documentation/cipp/custom-data +user-documentation/cipp/custom-data/directory-extensions +user-documentation/cipp/custom-data/directory-extensions/add +user-documentation/cipp/custom-data/mappings +user-documentation/cipp/custom-data/mappings/add +user-documentation/cipp/custom-data/mappings/edit +user-documentation/cipp/custom-data/schema-extensions +user-documentation/cipp/custom-data/schema-extensions/add +user-documentation/cipp/integrations +user-documentation/cipp/integrations/cipp-api +user-documentation/cipp/integrations/cloudflare +user-documentation/cipp/integrations/github +user-documentation/cipp/integrations/gradient +user-documentation/cipp/integrations/halopsa +user-documentation/cipp/integrations/have-i-been-pwned +user-documentation/cipp/integrations/hudu +user-documentation/cipp/integrations/integration-sync +user-documentation/cipp/integrations/ninjaone +user-documentation/cipp/integrations/passwordpusher +user-documentation/cipp/integrations/sherweb +user-documentation/cipp/logs +user-documentation/cipp/logs/logentry +user-documentation/cipp/sam-setup-wizard +user-documentation/cipp/settings +user-documentation/cipp/settings/backend +user-documentation/cipp/settings/backup +user-documentation/cipp/settings/branding +user-documentation/cipp/settings/features +user-documentation/cipp/settings/licenses +user-documentation/cipp/settings/notifications +user-documentation/cipp/settings/partner-webhooks +user-documentation/cipp/settings/password-config +user-documentation/cipp/settings/permissions +user-documentation/cipp/settings/siem +user-documentation/cipp/settings/tenants +user-documentation/copilot +user-documentation/copilot/agent365 +user-documentation/copilot/agent365/packages +user-documentation/copilot/reports +user-documentation/copilot/reports/copilot-adoption +user-documentation/copilot/reports/copilot-trend +user-documentation/copilot/reports/copilot-usage +user-documentation/copilot/settings +user-documentation/copilot/shadow-ai +user-documentation/dashboard +user-documentation/dashboard/custom +user-documentation/dashboard/dashboard +user-documentation/dashboard/devices +user-documentation/dashboard/identity +user-documentation/email +user-documentation/email/administration +user-documentation/email/administration/contacts +user-documentation/email/administration/contacts-template +user-documentation/email/administration/contacts-template/add +user-documentation/email/administration/contacts-template/edit +user-documentation/email/administration/contacts/edit +user-documentation/email/administration/deleted-mailboxes +user-documentation/email/administration/exchange-retention +user-documentation/email/administration/exchange-retention/policies +user-documentation/email/administration/exchange-retention/policies/policy +user-documentation/email/administration/exchange-retention/tags +user-documentation/email/administration/exchange-retention/tags/tag +user-documentation/email/administration/hve-accounts +user-documentation/email/administration/mailbox-rules +user-documentation/email/administration/mailboxes +user-documentation/email/administration/quarantine +user-documentation/email/administration/restricted-users +user-documentation/email/administration/tenant-allow-block-list-templates +user-documentation/email/administration/tenant-allow-block-lists +user-documentation/email/management +user-documentation/email/management/equipment +user-documentation/email/management/equipment/edit +user-documentation/email/management/list-rooms +user-documentation/email/management/list-rooms/edit +user-documentation/email/management/room-lists +user-documentation/email/management/room-lists/edit +user-documentation/email/reports +user-documentation/email/reports/activesync-devices +user-documentation/email/reports/antiphishing-filters +user-documentation/email/reports/calendar-permissions +user-documentation/email/reports/global-address-list +user-documentation/email/reports/mailbox-activity +user-documentation/email/reports/mailbox-cas-settings +user-documentation/email/reports/mailbox-forwarding +user-documentation/email/reports/mailbox-permissions +user-documentation/email/reports/mailbox-statistics +user-documentation/email/reports/malware-filters +user-documentation/email/reports/safeattachments-filters +user-documentation/email/reports/sharedmailboxenabledaccount +user-documentation/email/spamfilter +user-documentation/email/spamfilter/list-connectionfilter +user-documentation/email/spamfilter/list-connectionfilter-templates +user-documentation/email/spamfilter/list-connectionfilter/add +user-documentation/email/spamfilter/list-quarantine-policies +user-documentation/email/spamfilter/list-quarantine-policies/add +user-documentation/email/spamfilter/list-spamfilter +user-documentation/email/spamfilter/list-spamfilter/add +user-documentation/email/spamfilter/list-templates +user-documentation/email/transport +user-documentation/email/transport/list-connector-templates +user-documentation/email/transport/list-connectors +user-documentation/email/transport/list-rules +user-documentation/email/transport/list-templates +user-documentation/endpoint +user-documentation/endpoint/applications +user-documentation/endpoint/applications/application-templates +user-documentation/endpoint/applications/list +user-documentation/endpoint/applications/queue +user-documentation/endpoint/autopilot +user-documentation/endpoint/autopilot/add-device +user-documentation/endpoint/autopilot/enrollment-profiles +user-documentation/endpoint/autopilot/enrollment-profiles/android-enterprise +user-documentation/endpoint/autopilot/enrollment-profiles/apple-ade +user-documentation/endpoint/autopilot/list-devices +user-documentation/endpoint/autopilot/list-status-pages +user-documentation/endpoint/mem +user-documentation/endpoint/mem/approval-requests +user-documentation/endpoint/mem/assignment-filter-templates +user-documentation/endpoint/mem/assignment-filter-templates/add +user-documentation/endpoint/mem/assignment-filter-templates/deploy +user-documentation/endpoint/mem/assignment-filter-templates/edit-assignment-filter-template +user-documentation/endpoint/mem/assignment-filters +user-documentation/endpoint/mem/assignment-filters/add +user-documentation/endpoint/mem/assignment-filters/edit +user-documentation/endpoint/mem/bitlocker-search +user-documentation/endpoint/mem/devices +user-documentation/endpoint/mem/devices/device +user-documentation/endpoint/mem/list-appprotection-policies +user-documentation/endpoint/mem/list-compliance-policies +user-documentation/endpoint/mem/list-policies +user-documentation/endpoint/mem/list-scripts +user-documentation/endpoint/mem/list-templates +user-documentation/endpoint/mem/list-templates/edit +user-documentation/endpoint/mem/reusable-settings +user-documentation/endpoint/mem/reusable-settings-templates +user-documentation/endpoint/mem/reusable-settings-templates/add +user-documentation/endpoint/mem/reusable-settings-templates/edit-reusable-settings-template +user-documentation/endpoint/mem/reusable-settings/edit +user-documentation/endpoint/reports +user-documentation/endpoint/reports/analyticsdevicescore +user-documentation/endpoint/reports/autopilot-deployment +user-documentation/endpoint/reports/detected-apps +user-documentation/endpoint/reports/work-from-anywhere +user-documentation/identity +user-documentation/identity/administration +user-documentation/identity/administration/deleted-items +user-documentation/identity/administration/devices +user-documentation/identity/administration/group-templates +user-documentation/identity/administration/group-templates/add +user-documentation/identity/administration/group-templates/deploy +user-documentation/identity/administration/group-templates/edit +user-documentation/identity/administration/groups +user-documentation/identity/administration/groups/add +user-documentation/identity/administration/groups/edit +user-documentation/identity/administration/groups/group +user-documentation/identity/administration/jit-admin +user-documentation/identity/administration/jit-admin-templates +user-documentation/identity/administration/jit-admin-templates/add-jit-admin-template +user-documentation/identity/administration/jit-admin-templates/edit-jit-admin-template +user-documentation/identity/administration/jit-admin/add +user-documentation/identity/administration/offboarding-wizard +user-documentation/identity/administration/risky-users +user-documentation/identity/administration/roles +user-documentation/identity/administration/users +user-documentation/identity/administration/users/patch-wizard +user-documentation/identity/administration/users/user +user-documentation/identity/administration/users/user/bec +user-documentation/identity/administration/users/user/conditional-access +user-documentation/identity/administration/users/user/edit +user-documentation/identity/administration/users/user/exchange +user-documentation/identity/administration/vacation-mode +user-documentation/identity/administration/vacation-mode/add-vacation-schedule +user-documentation/identity/reports +user-documentation/identity/reports/azure-ad-connect-report +user-documentation/identity/reports/inactive-users-report +user-documentation/identity/reports/mfa-report +user-documentation/identity/reports/risk-detections +user-documentation/identity/reports/signin-report +user-documentation/security +user-documentation/security/compliance +user-documentation/security/compliance/dlp +user-documentation/security/compliance/dlp-templates +user-documentation/security/compliance/labels +user-documentation/security/compliance/labels-templates +user-documentation/security/compliance/retention +user-documentation/security/compliance/retention-templates +user-documentation/security/compliance/sit +user-documentation/security/compliance/sit-templates +user-documentation/security/defender +user-documentation/security/defender/defender-cve-exceptions +user-documentation/security/defender/deployment +user-documentation/security/defender/list-defender +user-documentation/security/defender/list-defender-tvm +user-documentation/security/incidents +user-documentation/security/incidents/list-alerts +user-documentation/security/incidents/list-check-alerts +user-documentation/security/incidents/list-incidents +user-documentation/security/incidents/list-mdo-alerts +user-documentation/security/reports +user-documentation/security/reports/cve-report +user-documentation/security/reports/list-device-compliance +user-documentation/security/reports/mde-onboarding +user-documentation/security/safelinks +user-documentation/security/safelinks/safelinks +user-documentation/security/safelinks/safelinks-template +user-documentation/security/safelinks/safelinks-template/add +user-documentation/security/safelinks/safelinks-template/create +user-documentation/security/safelinks/safelinks-template/edit +user-documentation/security/safelinks/safelinks/add +user-documentation/security/safelinks/safelinks/edit +user-documentation/shared-features +user-documentation/shared-features/breadcrumb-navigation +user-documentation/shared-features/get-help +user-documentation/shared-features/global-page-icon +user-documentation/shared-features/keyboard-shortcuts +user-documentation/shared-features/menu-bar +user-documentation/shared-features/menu-bar/bookmarks +user-documentation/shared-features/menu-bar/display-mode +user-documentation/shared-features/menu-bar/search +user-documentation/shared-features/menu-bar/tenant-select +user-documentation/shared-features/menu-bar/universal-search +user-documentation/shared-features/menu-bar/user-settings +user-documentation/shared-features/release-notes-notification +user-documentation/shared-features/speed-dial +user-documentation/shared-features/table-features +user-documentation/shared-features/variable-auto-complete +user-documentation/teams-share +user-documentation/teams-share/deleted-sites +user-documentation/teams-share/external-users +user-documentation/teams-share/onedrive +user-documentation/teams-share/permissions-report +user-documentation/teams-share/sharepoint +user-documentation/teams-share/sharepoint-templates +user-documentation/teams-share/sharepoint-templates/add +user-documentation/teams-share/sharepoint/add-site +user-documentation/teams-share/sharepoint/bulk-add-site +user-documentation/teams-share/sharing-report +user-documentation/teams-share/teams +user-documentation/teams-share/teams/business-voice +user-documentation/teams-share/teams/list-team +user-documentation/teams-share/teams/list-team/add +user-documentation/teams-share/teams/teams-activity +user-documentation/tenant +user-documentation/tenant/administration +user-documentation/tenant/administration/alert-configuration +user-documentation/tenant/administration/alert-configuration/alert +user-documentation/tenant/administration/alert-configuration/snoozed-alerts +user-documentation/tenant/administration/app-consent-requests +user-documentation/tenant/administration/applications +user-documentation/tenant/administration/applications/app-registrations +user-documentation/tenant/administration/applications/app-registrations/appid +user-documentation/tenant/administration/applications/enterprise-apps +user-documentation/tenant/administration/applications/enterprise-apps/spid +user-documentation/tenant/administration/applications/permission-sets +user-documentation/tenant/administration/applications/templates +user-documentation/tenant/administration/applications/templates/add +user-documentation/tenant/administration/applications/templates/edit +user-documentation/tenant/administration/audit-logs +user-documentation/tenant/administration/audit-logs/directory-audits +user-documentation/tenant/administration/audit-logs/log +user-documentation/tenant/administration/audit-logs/manual-searches +user-documentation/tenant/administration/audit-logs/search-results +user-documentation/tenant/administration/audit-logs/searches +user-documentation/tenant/administration/authentication-methods +user-documentation/tenant/administration/authentication-methods/registration-campaign +user-documentation/tenant/administration/domains +user-documentation/tenant/administration/partner-relationships +user-documentation/tenant/administration/securescore +user-documentation/tenant/administration/securescore/table +user-documentation/tenant/administration/tenants +user-documentation/tenant/administration/tenants/global-variables +user-documentation/tenant/administration/tenants/groups +user-documentation/tenant/administration/tenants/groups/edit +user-documentation/tenant/conditional +user-documentation/tenant/conditional/list-named-locations +user-documentation/tenant/conditional/list-named-locations/add +user-documentation/tenant/conditional/list-policies +user-documentation/tenant/conditional/list-policies/edit-ca-policy +user-documentation/tenant/conditional/list-template +user-documentation/tenant/conditional/list-template/create-ca-template +user-documentation/tenant/conditional/list-template/edit +user-documentation/tenant/gdap-management +user-documentation/tenant/gdap-management/invites +user-documentation/tenant/gdap-management/invites/add +user-documentation/tenant/gdap-management/offboarding +user-documentation/tenant/gdap-management/onboarding +user-documentation/tenant/gdap-management/onboarding/start +user-documentation/tenant/gdap-management/relationships +user-documentation/tenant/gdap-management/relationships/relationship +user-documentation/tenant/gdap-management/relationships/relationship/mappings +user-documentation/tenant/gdap-management/role-templates +user-documentation/tenant/gdap-management/role-templates/add +user-documentation/tenant/gdap-management/role-templates/edit +user-documentation/tenant/gdap-management/roles +user-documentation/tenant/gdap-management/roles/add +user-documentation/tenant/manage +user-documentation/tenant/manage/applied-standards +user-documentation/tenant/manage/backup +user-documentation/tenant/manage/drift +user-documentation/tenant/manage/edit +user-documentation/tenant/manage/history +user-documentation/tenant/manage/policies-deployed +user-documentation/tenant/manage/user-defaults +user-documentation/tenant/reports +user-documentation/tenant/reports/application-consent +user-documentation/tenant/reports/custom-test-report +user-documentation/tenant/reports/graph-office-reports +user-documentation/tenant/reports/list-csp-licenses +user-documentation/tenant/reports/list-csp-licenses/add-subscription +user-documentation/tenant/reports/list-licenses +user-documentation/tenant/standards +user-documentation/tenant/standards/alignment +user-documentation/tenant/standards/alignment/templates +user-documentation/tenant/standards/alignment/templates/available-standards +user-documentation/tenant/standards/bpa-report +user-documentation/tenant/standards/bpa-report/best-practice-templates +user-documentation/tenant/standards/bpa-report/builder +user-documentation/tenant/standards/domains-analyser +user-documentation/tenant/standards/domains-analyser/domain-analyser-updates-and-data-refreshing +user-documentation/tenant/standards/template +user-documentation/tools +user-documentation/tools/community-repos +user-documentation/tools/community-repos/browse-all-templates +user-documentation/tools/custom-tests +user-documentation/tools/custom-tests/add +user-documentation/tools/custom-tests/versions +user-documentation/tools/dark-web-tools +user-documentation/tools/dark-web-tools/breach-lookup +user-documentation/tools/dark-web-tools/tenant-breach-lookup +user-documentation/tools/email-tools +user-documentation/tools/email-tools/mailbox-restores +user-documentation/tools/email-tools/message-trace +user-documentation/tools/email-tools/message-viewer +user-documentation/tools/intune-tools +user-documentation/tools/intune-tools/compare-policies +user-documentation/tools/report-builder +user-documentation/tools/report-builder/builder +user-documentation/tools/report-builder/generated +user-documentation/tools/report-builder/templates +user-documentation/tools/scheduler +user-documentation/tools/scheduler/task +user-documentation/tools/templatelib +user-documentation/tools/tenant-tools +user-documentation/tools/tenant-tools/appapproval +user-documentation/tools/tenant-tools/geoiplookup +user-documentation/tools/tenant-tools/graph-explorer +user-documentation/tools/tenant-tools/individual-domains +user-documentation/tools/tenant-tools/tenantlookup diff --git a/Config/DocsSynonyms.json b/Config/DocsSynonyms.json new file mode 100644 index 0000000000000..91affbe50f91e --- /dev/null +++ b/Config/DocsSynonyms.json @@ -0,0 +1,75 @@ +{ + "_comment": "Query-expansion map for the SearchDocs MCP tool (Find-CippDoc). Keys are matched against the caller's query after tokenisation and stemming, so write them as ordinary words; the loader stems them. Values are expansion phrases scored at a damped weight, which is what lets a search for 'CA policy' reach pages that only ever say 'conditional access'. This is where most of the perceived semantic behaviour comes from without an embedding model, so it is worth extending whenever a real search misses.", + "expansions": { + "ca": ["conditional access policy"], + "mfa": ["multifactor authentication", "multi factor"], + "2fa": ["multifactor authentication"], + "sso": ["single sign on", "saml", "identity provider"], + "gdap": ["granular delegated admin privileges", "delegated access", "relationship"], + "dap": ["delegated admin privileges"], + "sam": ["secure application model", "service account", "application registration"], + "bec": ["business email compromise", "compromise remediation", "indicators of compromise"], + "bpa": ["best practice analyser", "report builder"], + "cis": ["compliance benchmark test"], + "spf": ["domain analyser", "email authentication", "dns record"], + "dkim": ["domain analyser", "email authentication", "dns record"], + "dmarc": ["domain analyser", "email authentication", "dns record"], + "dns": ["domain analyser", "domain health"], + "offboard": ["offboarding user removal"], + "onboard": ["onboarding tenant setup wizard"], + "standard": ["drift remediation baseline template"], + "drift": ["standards deviation baseline"], + "alert": ["alerting notification webhook"], + "tenant": ["customer client organisation"], + "intune": ["endpoint manager device management"], + "autopilot": ["device enrolment provisioning"], + "defender": ["security threat protection antivirus"], + "exchange": ["exchange online mailbox email"], + "exo": ["exchange online"], + "spam": ["spamfilter quarantine mail flow"], + "quarantine": ["spamfilter released message"], + "mailbox": ["exchange mailbox permissions shared"], + "license": ["licence sku subscription assignment"], + "licence": ["license sku subscription assignment"], + "sku": ["license subscription"], + "role": ["permission access rbac custom role"], + "permission": ["role access rbac consent"], + "rbac": ["role based access control permission"], + "log": ["audit log logbook activity history"], + "audit": ["log logbook activity history"], + "webhook": ["notification alert subscription"], + "psa": ["integration halo autotask connectwise"], + "rmm": ["integration ninja datto syncro"], + "backup": ["restore recovery export"], + "restore": ["backup recovery import"], + "template": ["policy blueprint preset"], + "policy": ["template configuration profile"], + "group": ["distribution list security group team"], + "user": ["account identity member"], + "password": ["credential reset passwordless authentication method"], + "device": ["endpoint computer workstation managed device"], + "app": ["application enterprise application service principal"], + "application": ["app enterprise application service principal"], + "sharepoint": ["onedrive site document library"], + "onedrive": ["sharepoint site storage"], + "teams": ["team channel meeting collaboration"], + "report": ["reporting export dashboard analytics"], + "dashboard": ["overview home report"], + "scheduler": ["scheduled task recurring job cron"], + "queue": ["scheduled task job processing"], + "error": ["troubleshooting failure issue problem"], + "fail": ["troubleshooting error issue problem"], + "troubleshoot": ["error failure diagnostic issue"], + "install": ["deployment setup provisioning"], + "deploy": ["installation setup provisioning"], + "upgrade": ["update version migration"], + "update": ["upgrade version release"], + "api": ["endpoint integration rest client"], + "mcp": ["model context protocol tool integration"], + "copilot": ["microsoft copilot ai"], + "hosted": ["cyberdrain hosted managed instance sponsor"], + "selfhost": ["self hosted azure deployment"], + "azure": ["subscription resource group function app"], + "graph": ["microsoft graph api request"] + } +} diff --git a/Config/openapi.json b/Config/openapi.json index 2f995bd299344..454444db4e9e7 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -4670,10 +4670,20 @@ } }, "TemplateGuid": { - "type": "string" + "type": "string", + "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." }, "TemplateID": { - "type": "string" + "type": "string", + "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." + }, + "TemplateList": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not as TemplateID. Template display names are not unique - re-imports create same-named twins - so resolving by display name below can land on a different row than the one the user picked. The selected RowKey must win whenever the request carries one. String rather than Guid: built-in templates are stored with their filename as RowKey." }, "TemplateType": { "type": "string" @@ -38916,6 +38926,86 @@ "x-cipp-role": "CIPP.Core.Read" } }, + "/api/ListCippDocs": { + "get": { + "summary": "Search the CIPP documentation, or fetch one documentation page in full.", + "operationId": "ListCippDocs", + "tags": [ + "CIPP > Core" + ], + "description": "Searches the GitBook documentation shipped with this build and returns matching sections,\neach with an excerpt and links back to docs.cipp.app and to the file on GitHub. Pages under\nuser-documentation also report the CIPP route they document, so a screen can be traced to\nits docs and back.\n\nPass path on its own to list the pages under a documentation subtree or a CIPP route, or\nwith full=true to return one page's entire text. This backs the SearchDocs and GetDoc MCP\ntools and is available to the UI and API clients on the same terms.", + "parameters": [ + { + "name": "full", + "in": "query", + "description": "Return the whole page rather than matching sections. Requires path.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum results to return (default 8, max 25).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "A documentation subtree ('user-documentation/identity') or a CIPP route ('/identity/administration/users').", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "description": "Keywords or a plain-language question, e.g. 'how do I set up GDAP'.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Core.Read", + "x-cipp-any-tenant": true + } + }, "/api/ListCippQueue": { "get": { "summary": "ListCippQueue", @@ -46309,21 +46399,24 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "corrupt": { + "x-cipp-field-source": "backend" + }, "description": { - "x-cipp-field-source": "frontend" + "x-cipp-field-source": "backend,frontend" }, "displayName": { - "x-cipp-field-source": "frontend" + "x-cipp-field-source": "backend,frontend" }, "ETag": { "type": "string", "x-cipp-field-source": "storage" }, "guid": { - "x-cipp-field-source": "storage" + "x-cipp-field-source": "storage,backend" }, "isSynced": { - "x-cipp-field-source": "frontend" + "x-cipp-field-source": "backend,frontend" }, "JSON": { "x-cipp-field-source": "storage" @@ -46332,7 +46425,7 @@ "x-cipp-field-source": "backend" }, "package": { - "x-cipp-field-source": "storage,frontend" + "x-cipp-field-source": "storage,backend,frontend" }, "PartitionKey": { "x-cipp-field-source": "storage" @@ -46348,7 +46441,7 @@ "x-cipp-field-source": "storage" }, "source": { - "x-cipp-field-source": "storage" + "x-cipp-field-source": "storage,backend" }, "templateCount": { "x-cipp-field-source": "backend" @@ -49144,6 +49237,48 @@ "x-cipp-role": "Tenant.Relationship.Read" } }, + "/api/ListPartnerTenantInfo": { + "get": { + "summary": "ListPartnerTenantInfo", + "operationId": "ListPartnerTenantInfo", + "tags": [ + "CIPP > Core" + ], + "description": "Reports whether the CIPP host tenant is a Microsoft Partner tenant, so the frontend can\ndecide whether partner-only flows (GDAP onboarding, reseller invites, GDAP permission\nchecks) apply to this instance.\n\nMarked AnyTenant deliberately. This answers a question about the CIPP instance, not\nabout a tenant the caller wants to act on, and Get-CippPartnerTenantInfo pins the lookup\nto $env:TenantID. Without the flag, Test-CIPPAccess falls back to $env:TenantID as the\ntenant filter and denies any custom role that blocks the partner tenant, which silently\ngreys out partner-only UI for roles that are otherwise fully permitted.", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Core.Read", + "x-cipp-any-tenant": true + } + }, "/api/ListPendingWebhooks": { "get": { "summary": "ListPendingWebhooks", diff --git a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll index 9bcca4d40c687fee902a6faa82171503bc35f58d..34d9a7f3281b2ecfdfc458e7f1131191e6281cd2 100644 GIT binary patch delta 36109 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%0ATI*05(SAVmBnGp}h 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 diff --git a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.psd1 b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.psd1 index b89f33f606e05..0ed923e457119 100644 --- a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.psd1 +++ b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.psd1 @@ -75,6 +75,7 @@ CmdletsToExport = @( 'Remove-AzDataTableEntity' 'Remove-AzDataTableLargeEntity' 'Update-AzDataTableEntity' + 'Update-AzDataTableLargeEntity' 'New-AzDataTableContext' 'Remove-AzDataTable' 'New-AzDataTable' diff --git a/Modules/AzBobbyTables/3.6.2/CHANGELOG.md b/Modules/AzBobbyTables/3.6.2/CHANGELOG.md index bc47fa0f8cf99..cff01ea818e54 100644 --- a/Modules/AzBobbyTables/3.6.2/CHANGELOG.md +++ b/Modules/AzBobbyTables/3.6.2/CHANGELOG.md @@ -7,10 +7,12 @@ The format is based on and uses the types of changes according to [Keep a Change ### Added - Added `Add-AzDataTableLargeEntity`, `Get-AzDataTableLargeEntity` and `Remove-AzDataTableLargeEntity` for working with entities that exceed the Azure Table Storage size limits (64 KiB per string property, 1 MiB per entity). Oversized string properties are split into chunk properties recorded in a `SplitOverProps` JSON manifest, and entities that are still too large are distributed over multiple rows marked with `OriginalEntityId` and `PartIndex`; reads reassemble the original entity transparently and removes delete all part rows. The existing entity cmdlets are unaffected. +- Added `Update-AzDataTableLargeEntity` for updating entities that exceed the Azure Table Storage size limits. Unlike the upsert operation types of `Add-AzDataTableLargeEntity`, entities that do not exist cause an error instead of being created. `UpdateReplace` rewrites the logical entity and removes part rows the new version no longer uses; `UpdateMerge` merges a plain single-row entity in place, and reads, merges and rewrites entities that were split for size, since merging onto physical rows directly would corrupt reassembly. ### Fixed - `Get-AzDataTableEntity` no longer fails with `400 InvalidInput` when `-First` is given a value above 1000. The page-size hint introduced in 3.6.1 was passed to the service unclamped, and Azure Table Storage rejects a page size over its limit of 1000 rather than capping it. The hint is now clamped to that limit. Results are unchanged: the hint only sizes each page, so requests for more than 1000 entities are served by paging, as they were before 3.6.1. +- `Update-AzDataTableEntity` no longer creates entities that do not exist when called with `-Force` or with entities that carry no ETag. Batched update actions were submitted without an `If-Match` header in those cases, which the table service treats as an insert-or-merge. Update actions now always carry `If-Match` (`*` when no ETag applies), matching the non-batched path and the documented behavior. ## [3.6.1] - 2026-07-29 diff --git a/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll index 61d72989610527c5efb9dd08ab7d7a785c1139e0..51d22dfd646008e8230fd4db446b9fe7cc60e606 100644 GIT binary patch literal 104960 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⩔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

t1^dMon)cMGxtfCLgJd?q|@iwUBkaB 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 delta 66846 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 z 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 bmy?$PZV8Y901N;C0Pr=p5tsq*0g*5V(5EH2 diff --git a/Modules/AzBobbyTables/3.6.2/en-US/AzBobbyTables.PS.dll-Help.xml b/Modules/AzBobbyTables/3.6.2/en-US/AzBobbyTables.PS.dll-Help.xml index 3ee3d4314d013..2d642c9e9dc79 100644 --- a/Modules/AzBobbyTables/3.6.2/en-US/AzBobbyTables.PS.dll-Help.xml +++ b/Modules/AzBobbyTables/3.6.2/en-US/AzBobbyTables.PS.dll-Help.xml @@ -292,11 +292,9 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType Add one or more entities to an Azure Table, as an array of either Hashtables, PSObjects, or SortedLists. Unlike Add-AzDataTableEntity, this cmdlet accepts entities that exceed the Azure Table Storage size limits (64 KiB per string property, 1 MiB per entity) and splits them transparently: - - A string property larger than 32256 characters is stored as multiple chunk properties named `{Property}_Part0`, `{Property}_Part1`, and so on. A JSON manifest in the `SplitOverProps` property records which chunks belong to which original property. - - An entity that is still too large after property splitting is distributed over multiple rows. The first row keeps the original RowKey, additional rows are named `{RowKey}-part1`, `{RowKey}-part2`, and so on. Each row carries an `OriginalEntityId` property with the original RowKey and a `PartIndex` property with the row order. - + - An entity that is still too large after property splitting is distributed over multiple rows. The first row keeps the original RowKey, additional rows are named `{RowKey}-part1`, `{RowKey}-part2`, and so on. Each row carries an `OriginalEntityId` property with the original RowKey, a `PartIndex` property with the row order, and a `PartCount` property with the total number of rows. Use Get-AzDataTableLargeEntity to read the entities back in their original shape, and Remove-AzDataTableLargeEntity to delete them including all part rows. - Entities in one call are deduplicated by PartitionKey and RowKey, with the last occurrence winning, since the underlying transactions reject multiple operations on the same key. After writing a split entity, leftover part rows from an earlier larger version of the same entity are cleaned up automatically. + If the same PartitionKey/RowKey combination appears more than once in a single call, only the last occurrence is written. This is required because Azure Table Storage rejects batch transactions that contain duplicate keys. When an entity is split across multiple rows, any part rows left from a previous larger version of that entity are deleted automatically after the new rows are written. Note that when an entity is split over multiple rows, its rows are written as full replacements even with an OperationType of UpsertMerge, since the distribution of properties over rows changes between writes and merging would leave stale values behind. Entities small enough to fit in one row are written with the requested operation type. When the sizes of entities vary between writes across the splitting threshold, prefer UpsertReplace (or Force). @@ -520,7 +518,7 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType - System.Object + None @@ -530,7 +528,7 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType Only string properties are split. A non-string property that individually exceeds the service limits, such as a byte array over 64 KiB, is rejected by the service. - The storage format reserves some naming patterns in tables used with the large-entity cmdlets: property names ending in `_Part{n}` of another property, the property names `SplitOverProps`, `OriginalEntityId` and `PartIndex`, and RowKeys of the form `{OtherRowKey}-part{n}`. Entities using such names can collide with the split representation of other entities. + The storage format reserves some naming patterns in tables used with the large-entity cmdlets: property names ending in `_Part{n}` of another property, the property names `SplitOverProps`, `OriginalEntityId`, `PartIndex` and `PartCount`, and RowKeys of the form `{OtherRowKey}-part{n}`. Entities using such names can collide with the split representation of other entities. @@ -1120,14 +1118,14 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co Get AzDataTableLargeEntity - Get one or more entities from an Azure Table, reassembling entities that were split by Add-AzDataTableLargeEntity. + Get one or more entities from an Azure Table, reassembling large entities that were split by Add-AzDataTableLargeEntity. - Get one or more entities from an Azure Table, reassembling entities that were split across multiple properties or rows because they exceeded the Azure Table Storage size limits. + Get one or more entities from an Azure Table, reassembling entities added with `Add-AzDataTableLargeEntity` that were split across multiple properties or rows because they exceeded the Azure Table Storage size limits. Rows belonging to one logical entity are recognized by their `OriginalEntityId` property and merged in `PartIndex` order, after which chunked properties recorded in the `SplitOverProps` manifest are joined back into their original single property. Entities that were never split are returned as-is. If both a plain row and leftover part rows exist for the same RowKey, the plain row wins, so an entity that was rewritten smaller after having been split still reads correctly. - Since split entities span multiple physical rows, use filters that do not separate an entity from its parts; filtering on the PartitionKey level is safe. First, Skip, Sort and Count operate on physical rows, before reassembly. A Property selection that excludes the split markers (OriginalEntityId, PartIndex, SplitOverProps and the chunk properties) prevents reassembly. + Since split entities span multiple rows, First, Skip, Sort and Count operate on rows before reassembly. If a filter matches only some rows of a split entity, the cmdlet fetches the missing rows before reassembly. A Property selection that excludes the split markers (OriginalEntityId, PartIndex, SplitOverProps, PartCount and the chunk properties) prevents reassembly. @@ -1221,7 +1219,7 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co Property - The properties to return for the entities. Selecting properties that exclude the split markers prevents reassembly of split entities. + The properties to return for the entities. Selecting properties that exclude the split markers (OriginalEntityId, PartIndex, SplitOverProps, PartCount and chunk properties) prevents reassembly of split entities. String[] @@ -1320,7 +1318,7 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co Property - The properties to return for the entities. Selecting properties that exclude the split markers prevents reassembly of split entities. + The properties to return for the entities. Selecting properties that exclude the split markers (OriginalEntityId, PartIndex, SplitOverProps, PartCount and chunk properties) prevents reassembly of split entities. String[] @@ -2281,12 +2279,12 @@ PS C:\> # OK - The -Force switch overrides ETag validation Remove AzDataTableLargeEntity - Remove one or more entities from an Azure Table, including any part rows they were split into by Add-AzDataTableLargeEntity. + Remove one or more entities from an Azure Table, including any part rows they were split into by `Add-AzDataTableLargeEntity`. Remove one or more entities from an Azure Table, based on PartitionKey and RowKey. - In addition to the entity's own row, any part rows that the entity was split into on write (rows whose `OriginalEntityId` matches the entity's RowKey) are looked up and removed as well, so no orphaned parts are left behind. Part rows are removed without ETag validation; ETag validation, when not skipped with Force, applies to the entity's own row. + In addition to the entity's own row, any part rows that the entity was split into when added by `Add-AzDataTableLargeEntity` (rows whose `OriginalEntityId` matches the entity's RowKey) are found and removed as well, so no orphaned parts are left behind. Part rows are removed without ETag validation; ETag validation, when not skipped with the `Force` parameter, applies only to the entity's own row. @@ -2403,7 +2401,7 @@ PS C:\> # OK - The -Force switch overrides ETag validation - System.Object + None @@ -2422,7 +2420,7 @@ PS C:\> # OK - The -Force switch overrides ETag validation PS C:\> $Entity = Get-AzDataTableLargeEntity -Context $Context -Filter "RowKey eq 'tenant1'" PS C:\> Remove-AzDataTableLargeEntity -Entity $Entity -Context $Context -Force - Remove an entity and all rows it was split into. + Remove an entity and all rows it was split into when added by `Add-AzDataTableLargeEntity`. @@ -2636,4 +2634,208 @@ PS C:\> # OK - The -Force switch overrides ETag validation + + + Update-AzDataTableLargeEntity + Update + AzDataTableLargeEntity + + Update one or more entities that already exist in an Azure Table, transparently handling entities that were split across multiple properties or rows by `Add-AzDataTableLargeEntity`. + + + + Update one or more entities that already exist in an Azure Table, based on PartitionKey and RowKey. Entities that do not exist cause an error and are never created, unlike the upsert operation types of `Add-AzDataTableLargeEntity`. + `UpdateReplace` replaces the whole logical entity: the entity's own row is updated (and fails if missing), any additional part rows the new version needs are upserted, and part rows the new version no longer uses are removed. + `UpdateMerge` merges the given properties into the logical entity. A plain single-row entity is merged in place. An entity that was split for size, or given properties that are themselves oversized, are read, merged in memory and rewritten, since merging onto the physical rows directly would corrupt the split-entity format. + ETag validation, when not skipped with the `Force` parameter, applies to the entity's own row. The read-merge-rewrite path is not atomic. + + + + Update-AzDataTableLargeEntity + + Context + + A context object created by New-AzDataTableContext, with authentication information for the table to operate on. + + AzDataTableContext + + AzDataTableContext + + + None + + + Entity + + The entities to update in the table. + + Object[] + + Object[] + + + None + + + Force + + Skips ETag validation and updates entity even if it has changed. + + + SwitchParameter + + + False + + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + + + OperationType + + The type of operation to perform on the entities, either UpdateMerge or UpdateReplace. Defaults to UpdateMerge. + + + UpdateMerge + UpdateReplace + + String + + String + + + UpdateMerge + + + + + + Context + + A context object created by New-AzDataTableContext, with authentication information for the table to operate on. + + AzDataTableContext + + AzDataTableContext + + + None + + + Entity + + The entities to update in the table. + + Object[] + + Object[] + + + None + + + Force + + Skips ETag validation and updates entity even if it has changed. + + SwitchParameter + + SwitchParameter + + + False + + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + + + OperationType + + The type of operation to perform on the entities, either UpdateMerge or UpdateReplace. Defaults to UpdateMerge. + + String + + String + + + UpdateMerge + + + + + + System.Collections.Hashtable[] or System.Management.Automation.PSObject[] or System.Collections.SortedList[] + + + This cmdlet takes either an array of hashtables, psobjects, or sorted lists as input to the Entity parameter, which can also be provided through the pipeline. + + + + + + + None + + + + + + + + + + + + + + -------------------------- Example 1 -------------------------- + PS C:\> $Context = New-AzDataTableContext -TableName $TableName -ConnectionString $ConnectionString +PS C:\> Update-AzDataTableLargeEntity -Context $Context -Entity @{ PartitionKey = 'tenant1'; RowKey = 'record1'; Processed = $true } + + Merge a property onto an existing entity. If the entity does not exist, the update fails instead of creating it. + + + + -------------------------- Example 2 -------------------------- + PS C:\> $Entity = Get-AzDataTableLargeEntity -Context $Context -Filter "RowKey eq 'record1'" +PS C:\> $Entity.Data = $NewLargeValue +PS C:\> Update-AzDataTableLargeEntity -Context $Context -Entity $Entity -OperationType UpdateReplace + + Replace an existing entity with a new version, splitting it over properties and rows as needed and removing part rows the new version no longer uses. The ETag from the read is validated, so a concurrent change fails the update. + + + + + + Add-AzDataTableLargeEntity + + + + Get-AzDataTableLargeEntity + + + + Remove-AzDataTableLargeEntity + + + + \ No newline at end of file diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index e9b90f9692e6c..9862a22073f6c 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -79,14 +79,11 @@ function Push-ExecScheduledCommand { $TriggerType = $Trigger.Type.value ?? $Trigger.Type if ($TriggerType -eq 'DeltaQuery') { $IsTriggerTask = $true - $DeltaUrl = Get-DeltaQueryUrl -TenantFilter $Tenant -PartitionKey $task.RowKey - $DeltaQuery = @{ - DeltaUrl = $DeltaUrl - TenantFilter = $Tenant - PartitionKey = $task.RowKey - } - $Query = New-GraphDeltaQuery @DeltaQuery + #if recurrence is just a number, add it in days. + if ($task.Recurrence -match '^\d+$') { + $task.Recurrence = $task.Recurrence + 'd' + } $secondsToAdd = switch -Regex ($task.Recurrence) { '(\d+)m$' { [int64]$matches[1] * 60 } '(\d+)h$' { [int64]$matches[1] * 3600 } @@ -96,13 +93,45 @@ function Push-ExecScheduledCommand { $Minutes = [int]($secondsToAdd / 60) - $DeltaQueryConditions = @{ - Query = $Query - Trigger = $Trigger - TenantFilter = $Tenant - LastTrigger = [datetime]::UtcNow.AddMinutes(-$Minutes) + # Without this a throw here escapes the entrypoint, leaving the task on the orchestrator's + # 'Pending' claim with nothing recorded, to be re-picked as a stale claim every hour. + try { + $DeltaUrl = Get-DeltaQueryUrl -TenantFilter $Tenant -PartitionKey $task.RowKey + $DeltaQuery = @{ + DeltaUrl = $DeltaUrl + TenantFilter = $Tenant + PartitionKey = $task.RowKey + } + $Query = New-GraphDeltaQuery @DeltaQuery + + $DeltaQueryConditions = @{ + Query = $Query + Trigger = $Trigger + TenantFilter = $Tenant + LastTrigger = [datetime]::UtcNow.AddMinutes(-$Minutes) + } + $DeltaResults = Test-DeltaQueryConditions @DeltaQueryConditions + } catch { + $ExceptionData = Get-CippException -Exception $_ + if (!$IsMultiTenantExecution) { + if ($secondsToAdd -gt 0) { + $unixtimeNow = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + if ([int64]$task.ScheduledTime -lt ($unixtimeNow - $secondsToAdd)) { + $task.ScheduledTime = $unixtimeNow + } + } + $null = Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $task.PartitionKey + RowKey = $task.RowKey + Results = "$($ExceptionData.NormalizedError)" + ScheduledTime = [string]([int64]$task.ScheduledTime + [int64]$secondsToAdd) + TaskState = $secondsToAdd -gt 0 ? 'Failed - Planned' : 'Failed' + } + } + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Failed to evaluate the delta query trigger for task $($task.Name): $($ExceptionData.NormalizedError)" -sev Error -LogData $ExceptionData + Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + return } - $DeltaResults = Test-DeltaQueryConditions @DeltaQueryConditions if (-not $DeltaResults.ConditionsMet) { Write-Information "Delta query conditions not met for tenant $Tenant. Skipping execution." diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index a66e081963e03..0e5886f927a22 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 @@ -18,10 +18,36 @@ function Get-CIPPAlertHuntressRogueApps { ) try { + # Skip the run rather than alert if the feed is down. A GitHub Pages error page parses + # without throwing, so the shape is checked too - otherwise this silently degrades to + # comparing against the CIPP list alone. $RogueApps = Invoke-RestMethod -Uri 'https://huntresslabs.github.io/rogueapps/rogueapps.json' - $CippRogueApps = (Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\MaliciousApps.json') | ConvertFrom-Json).applications.appId + if (-not @($RogueApps).Where({ $_.appId }, 'First')) { return } + + $CippApps = @((Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\MaliciousApps.json') | ConvertFrom-Json).applications) + $CippRogueApps = $CippApps.appId $HuntressRogueApps = $RogueApps.appId $RogueAppIds = @($CippRogueApps) + @($HuntressRogueApps) | Where-Object { $_ } | Select-Object -Unique + + # Describe a match from whichever list it came from. Most of the CIPP entries are not in + # the Huntress feed, and looking those up there alone left every field blank. + $AppDetails = @{} + foreach ($App in @($RogueApps)) { + if ($App.appId) { + $AppDetails[[string]$App.appId] = [pscustomobject]@{ + Name = $App.appDisplayName; Description = $App.description + Tags = $App.tags; References = $App.references; Added = $App.dateAdded; Source = 'Huntress' + } + } + } + foreach ($App in $CippApps) { + if ($App.appId -and -not $AppDetails.ContainsKey([string]$App.appId)) { + $AppDetails[[string]$App.appId] = [pscustomobject]@{ + Name = $App.name; Description = $App.description + Tags = $App.tags; References = $App.references; Added = $null; Source = 'CIPP' + } + } + } $Requests = for ($i = 0; $i -lt $RogueAppIds.Count; $i += 15) { $Chunk = $RogueAppIds[$i..([Math]::Min($i + 14, $RogueAppIds.Count - 1))] @{ @@ -43,16 +69,19 @@ function Get-CIPPAlertHuntressRogueApps { if (($ServicePrincipals | Measure-Object).Count -gt 0) { $AlertData = foreach ($ServicePrincipal in $ServicePrincipals) { - $RogueApp = $RogueApps | Where-Object { $_.appId -eq $ServicePrincipal.appId } + $RogueApp = $AppDetails[[string]$ServicePrincipal.appId] [pscustomobject]@{ - 'App Name' = $RogueApp.appDisplayName - 'App Id' = $RogueApp.appId - 'Description' = $RogueApp.description - 'Enabled' = $ServicePrincipal.accountEnabled - 'Created' = $ServicePrincipal.createdDateTime - 'Tags' = $RogueApp.tags -join ', ' - 'References' = $RogueApp.references -join ', ' - 'Huntress Added' = $RogueApp.dateAdded + # Fall back to the service principal for the identity fields so a row is never + # anonymous, even if a list entry is missing. + 'App Name' = $RogueApp.Name ?? $ServicePrincipal.appDisplayName + 'App Id' = $ServicePrincipal.appId + 'Description' = $RogueApp.Description + 'Enabled' = $ServicePrincipal.accountEnabled + 'Created' = $ServicePrincipal.createdDateTime + 'Tags' = $RogueApp.Tags -join ', ' + 'References' = $RogueApp.References -join ', ' + 'Source' = $RogueApp.Source + 'Listed On' = $RogueApp.Added } } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData diff --git a/Modules/CIPPCore/Public/Add-CIPPAzDataTableEntity.ps1 b/Modules/CIPPCore/Public/Add-CIPPAzDataTableEntity.ps1 index 620367566227a..af1721135c04a 100644 --- a/Modules/CIPPCore/Public/Add-CIPPAzDataTableEntity.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPAzDataTableEntity.ps1 @@ -13,6 +13,9 @@ function Add-CIPPAzDataTableEntity { Kept as a wrapper for backward compatibility with existing call sites and to strip null-valued properties, which the table service cannot store and the binary module rejects. + + On TableNotFound, invalidates the CreateTable cache, recreates the table, and + retries once so a stale CIPPEnsuredTables entry cannot permanently break writes. #> [CmdletBinding(DefaultParameterSetName = 'OperationType')] param( @@ -89,5 +92,18 @@ function Add-CIPPAzDataTableEntity { $Parameters.OperationType = $OperationType } - Add-AzDataTableLargeEntity @Parameters -ErrorAction Stop + try { + Add-AzDataTableLargeEntity @Parameters -ErrorAction Stop + } catch { + if ($script:CIPPRepairingTable -or -not (Test-CIPPTableNotFound $_)) { + throw + } + $script:CIPPRepairingTable = $true + try { + Repair-CIPPTable -Context $Context + Add-AzDataTableLargeEntity @Parameters -ErrorAction Stop + } finally { + $script:CIPPRepairingTable = $false + } + } } diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 2ab37de99fc87..89a9ffc0cdee9 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -274,31 +274,14 @@ function Add-CIPPScheduledTask { $entity.Trigger = [string]($task.Trigger | ConvertTo-Json -Compress) $TriggerType = $task.Trigger.Type.value ?? $task.Trigger.Type if ($TriggerType -eq 'DeltaQuery') { - $Parameters = @{} - if ($task.Trigger.WatchedAttributes -and ($task.Trigger.WatchedAttributes | Measure-Object).Count -gt 0) { - $Parameters.'$select' = $task.Trigger.WatchedAttributes | ForEach-Object { $_.value ?? $_ } -join ',' - } - if ($task.Trigger.ResourceFilter) { - $ResourceFilterValues = $task.Trigger.ResourceFilter | ForEach-Object { $_.value ?? $_ } - $Parameters.'$filter' = "id eq '" + ($ResourceFilterValues -join "' or id eq '") + "'" - } $Resource = $task.Trigger.DeltaResource.value ?? $task.Trigger.DeltaResource - - if ($entity.TenantGroup) { - $tenantFilter = $entity.TenantGroup | ConvertFrom-Json - } - $DeltaQuery = @{ - TenantFilter = $tenantFilter - Resource = $Resource - Parameters = $Parameters - PartitionKey = $RowKey - } + $DeltaTenantFilter = if ($entity.TenantGroup) { $entity.TenantGroup | ConvertFrom-Json } else { $tenantFilter } try { - $null = New-GraphDeltaQuery @DeltaQuery + $null = New-CIPPTaskDeltaQuery -Trigger $task.Trigger -TenantFilter $DeltaTenantFilter -PartitionKey $RowKey Write-Information "Created delta query for resource $($Resource)" } catch { - Write-Warning "Failed to create delta query for resource $($Resource): $($_.Exception.Message)" + throw "Failed to create delta query for resource $($Resource): $($_.Exception.Message)" } } } diff --git a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 index aabed3ad9a2c6..c85843371a0eb 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 @@ -124,7 +124,7 @@ function Test-CIPPAccess { appId = $Request.Headers.'x-ms-client-principal-name' appRole = $CustomRoles } - 'permissions' = $Permissions + 'permissions' = @($Permissions) } | ConvertTo-Json -Depth 5) }) } @@ -222,7 +222,7 @@ function Test-CIPPAccess { # Include SSO migration status for admins with AppSettings permissions $MeResponse = @{ 'clientPrincipal' = $User - 'permissions' = $Permissions + 'permissions' = @($Permissions) } # Hosted payment status checks — shown to all users (no permission gating) diff --git a/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 b/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 index 84acde0362093..c2ad699215d15 100644 --- a/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 +++ b/Modules/CIPPCore/Public/DeltaQueries/Get-DeltaQueryUrl.ps1 @@ -4,10 +4,11 @@ function Get-DeltaQueryUrl { Retrieves the URL for Delta Queries .DESCRIPTION This helper function constructs the URL for Delta Query requests based on the resource and parameters. + If the DeltaQueries row is missing it is rebuilt from the owning scheduled task's trigger. .PARAMETER TenantFilter The tenant to filter the query on. .PARAMETER PartitionKey - The partition key for the delta query. + The partition key for the delta query. This is the RowKey of the scheduled task that owns it. #> [CmdletBinding()] @@ -23,7 +24,21 @@ function Get-DeltaQueryUrl { if ($DeltaQueryEntity) { return $DeltaQueryEntity.DeltaUrl - } else { - throw "Delta Query not found for Tenant '$TenantFilter' and PartitionKey '$PartitionKey'." } -} \ No newline at end of file + + $TaskTable = Get-CIPPTable -TableName 'ScheduledTasks' + $Task = Get-CIPPAzDataTableEntity @TaskTable -Filter "PartitionKey eq 'ScheduledTask' and RowKey eq '$PartitionKey'" + if (!$Task.Trigger) { + throw "Delta Query not found for Tenant '$TenantFilter' and PartitionKey '$PartitionKey', and no scheduled task with a trigger exists to rebuild it from." + } + + Write-Warning "Delta Query missing for Tenant '$TenantFilter' and PartitionKey '$PartitionKey'. Rebuilding it from task '$($Task.Name)'." + $Rebuilt = New-CIPPTaskDeltaQuery -Trigger $Task.Trigger -TenantFilter $TenantFilter -PartitionKey $PartitionKey + $DeltaUrl = $Rebuilt.'@odata.deltaLink' + if (!$DeltaUrl) { + throw "Delta Query not found for Tenant '$TenantFilter' and PartitionKey '$PartitionKey' and could not be rebuilt." + } + + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $TenantFilter -message "Rebuilt the missing delta query for task '$($Task.Name)'. Changes from before the rebuild were not captured and will not trigger this task." -sev Warning + return $DeltaUrl +} diff --git a/Modules/CIPPCore/Public/DeltaQueries/New-CIPPTaskDeltaQuery.ps1 b/Modules/CIPPCore/Public/DeltaQueries/New-CIPPTaskDeltaQuery.ps1 new file mode 100644 index 0000000000000..93b8cb307c854 --- /dev/null +++ b/Modules/CIPPCore/Public/DeltaQueries/New-CIPPTaskDeltaQuery.ps1 @@ -0,0 +1,49 @@ +function New-CIPPTaskDeltaQuery { + <# + .SYNOPSIS + Creates the delta query a DeltaQuery-triggered scheduled task runs against. + .DESCRIPTION + Builds the delta query parameters from a task's trigger and hands them to New-GraphDeltaQuery, + so task creation, the rebuild in Get-DeltaQueryUrl and the offline repair script all key the + DeltaQueries row identically. + .PARAMETER Trigger + The task's Trigger. Accepts the live object from the API and the JSON stored on the task row. + .PARAMETER TenantFilter + The tenant to create the delta query for. 'AllTenants' or a tenant group object fans out. + .PARAMETER PartitionKey + The RowKey of the scheduled task that owns this delta query. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Trigger, + + [Parameter(Mandatory = $true)] + $TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$PartitionKey + ) + + if ($Trigger -is [string]) { + $Trigger = $Trigger | ConvertFrom-Json + } + + $Resource = $Trigger.DeltaResource.value ?? $Trigger.DeltaResource + if (!$Resource) { + throw "Trigger for scheduled task '$PartitionKey' has no DeltaResource; the delta query cannot be built." + } + + $Parameters = @{} + if ($Trigger.WatchedAttributes -and ($Trigger.WatchedAttributes | Measure-Object).Count -gt 0) { + $Parameters.'$select' = ($Trigger.WatchedAttributes | ForEach-Object { $_.value ?? $_ }) -join ',' + } + if ($Trigger.ResourceFilter) { + $ResourceFilterValues = $Trigger.ResourceFilter | ForEach-Object { $_.value ?? $_ } + $Parameters.'$filter' = "id eq '" + ($ResourceFilterValues -join "' or id eq '") + "'" + } + + return New-GraphDeltaQuery -TenantFilter $TenantFilter -Resource $Resource -Parameters $Parameters -PartitionKey $PartitionKey +} diff --git a/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 b/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 index d5000ac434466..22891bf8d6a75 100644 --- a/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 +++ b/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 @@ -177,8 +177,8 @@ function New-GraphDeltaQuery { # Always return full response with deltaLink return $result } catch { - Write-Error "Failed to create Delta Query: $(Get-NormalizedError -Message $_.Exception.message)" Write-Warning $_.InvocationInfo.PositionMessage + throw "Failed to create Delta Query: $(Get-NormalizedError -Message $_.Exception.message)" } } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 index 944cd07190f38..a87b7112937d4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 @@ -48,6 +48,14 @@ function Start-CIPPDBTestsRun { $AllTenantsList = if ($TenantFilter -eq 'allTenants') { $DbCounts = Get-CIPPDbItem -CountsOnly -TenantFilter 'allTenants' $TenantsWithData = $DbCounts | Where-Object { (($_.DataCount ?? $_.Count) ?? 0) -gt 0 } | Select-Object -ExpandProperty PartitionKey -Unique + $ActiveTenants = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@((Get-Tenants).defaultDomainName | Where-Object { $_ }), + [System.StringComparer]::OrdinalIgnoreCase) + $SkippedCount = @($TenantsWithData | Where-Object { -not $ActiveTenants.Contains($_) }).Count + $TenantsWithData = @($TenantsWithData | Where-Object { $ActiveTenants.Contains($_) }) + if ($SkippedCount -gt 0) { + Write-Information "Skipped $SkippedCount tenant(s) with cached data that are excluded or no longer managed" + } Write-Information "Found $($TenantsWithData.Count) tenants with data in database" $TenantsWithData } else { diff --git a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 index f932f73db4df5..b00155004225c 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 @@ -12,6 +12,9 @@ function Get-CIPPAzDataTableEntity { Kept as a wrapper for backward compatibility with existing call sites, to default MaxRetries to 3 for throttled requests, and to record entities the module could not reassemble. + + On TableNotFound, invalidates the CreateTable cache, recreates the table, and + retries once so a stale CIPPEnsuredTables entry cannot permanently break reads. #> [CmdletBinding()] param( @@ -34,6 +37,24 @@ function Get-CIPPAzDataTableEntity { $Results = Get-AzDataTableLargeEntity @Parameters -ErrorAction SilentlyContinue -ErrorVariable TableErrors + # Do not pipe $null/$empty into Where-Object - PowerShell invokes the block once with $_ = $null. + $NotFoundErrors = [System.Collections.Generic.List[object]]::new() + foreach ($Candidate in @($TableErrors)) { + if ($null -ne $Candidate -and (Test-CIPPTableNotFound $Candidate)) { + $NotFoundErrors.Add($Candidate) + } + } + if ($NotFoundErrors.Count -and -not $script:CIPPRepairingTable) { + $script:CIPPRepairingTable = $true + try { + Repair-CIPPTable -Context $Context + $TableErrors = $null + $Results = Get-AzDataTableLargeEntity @Parameters -ErrorAction SilentlyContinue -ErrorVariable TableErrors + } finally { + $script:CIPPRepairingTable = $false + } + } + foreach ($TableError in $TableErrors) { # An entity whose rows cannot be reassembled is skipped by the module and reported without # failing the query, so one row orphaned by a pre-part-aware delete cannot empty a whole diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 index 190c67315103b..155867ffa82a9 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 @@ -18,6 +18,12 @@ function Get-CIPPIntuneAssignmentTarget { "Add all users" writes. A MAM policy protects a user's apps and has no device audience at all, so All Devices has no equivalent and is reported as unsupported rather than guessed at. + Device Preparation profiles (Autopilot device preparation) have the same shape for a + different reason: the deployment starts when an assigned user signs in during OOBE, so the + assignment surface is user groups only - the portal picker offers the same well-known All + Users virtual group and no All Devices equivalent. The broad virtual targets are not what + the portal writes for them and leave the profile without an effective assignment. + 'customGroup' and 'On' produce no broad target: the caller resolves group names itself. .PARAMETER AssignTo @@ -50,7 +56,8 @@ function Get-CIPPIntuneAssignmentTarget { [string]$PolicyType ) - # Intune's well-known virtual group for "all users", the only way to express it on a MAM policy. + # Intune's well-known virtual group for "all users", the only way to express it on a policy + # type whose assignment surface is groups only (MAM, Device Preparation). $MamAllUsersGroupId = 'acacacac-9df4-4c7d-9d50-4ef0226f57a9' $MamPolicyTypes = @( @@ -63,7 +70,14 @@ function Get-CIPPIntuneAssignmentTarget { ) $IsMam = $MamPolicyTypes -contains $PolicyType - $AllUsersTarget = if ($IsMam) { + # Policy types whose assignment surface is user groups only. Device Preparation deployments + # trigger on the enrolling user, so a device audience cannot be expressed for them at all. + $UserGroupOnlyTypes = @( + 'DevicePrepProfile' + ) + $IsUserGroupOnly = $IsMam -or ($UserGroupOnlyTypes -contains $PolicyType) + + $AllUsersTarget = if ($IsUserGroupOnly) { @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = $MamAllUsersGroupId } } else { @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } @@ -81,12 +95,14 @@ function Get-CIPPIntuneAssignmentTarget { 'AllDevices' { if ($IsMam) { $Unsupported = "'$PolicyType' policies protect a user's apps and have no device audience, so they cannot be assigned to All Devices. Assign to all users or to a group instead." + } elseif ($IsUserGroupOnly) { + $Unsupported = "'$PolicyType' deployments start when an assigned user signs in, so they cannot be assigned to All Devices. Assign to all users instead." } else { $Targets.Add($AllDevicesTarget) } } 'AllDevicesAndUsers' { - if ($IsMam) { + if ($IsUserGroupOnly) { # The users half is still expressible, so honour it rather than failing the whole # assignment over a target this policy type has no concept of. $Dropped.Add('All Devices') diff --git a/Modules/CIPPCore/Public/Get-CIPPOneDriveUsageReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPOneDriveUsageReport.ps1 index 79f3b3c7bb29a..a8a21d74a46be 100644 --- a/Modules/CIPPCore/Public/Get-CIPPOneDriveUsageReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPOneDriveUsageReport.ps1 @@ -44,10 +44,11 @@ function Get-CIPPOneDriveUsageReport { throw 'No OneDrive site listing data found in reporting database. Sync OneDriveUsage cache first.' } + # No usage rows is a valid cached result, not a missing cache: getOneDriveUsageAccountDetail + # returns an empty set for tenants Microsoft has no usage report for yet. The site listing + # is the backbone of this payload and the usage merge below is a left join, so an empty + # usage set yields the same rows-with-null-usage the live path returns. $UsageItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveUsage' | Where-Object { $_.RowKey -ne 'OneDriveUsage-Count' }) - if (-not $UsageItems) { - throw 'No OneDrive usage data found in reporting database. Sync OneDriveUsage cache first.' - } $LatestSiteTimestamp = ($SiteItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp $LatestUsageTimestamp = ($UsageItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp @@ -75,8 +76,11 @@ function Get-CIPPOneDriveUsageReport { $SiteUsage = $null [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId, [ref]$SiteUsage) - $StorageUsedInBytes = [double]($SiteUsage.storageUsedInBytes ?? 0) - $StorageAllocatedInBytes = [double]($SiteUsage.storageAllocatedInBytes ?? 0) + # A drive with no usage row has UNKNOWN storage, not zero storage. Coercing the null + # to 0 made those rows render an authoritative-looking '0' that is indistinguishable + # from a genuinely empty drive. + $StorageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } + $StorageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } $ReportItem = [PSCustomObject]@{ siteId = $Site.sharepointIds.siteId @@ -88,8 +92,8 @@ function Get-CIPPOneDriveUsageReport { ownerPrincipalName = $SiteUsage.ownerPrincipalName lastActivityDate = $SiteUsage.lastActivityDate fileCount = $SiteUsage.fileCount - storageUsedInGigabytes = [math]::round($StorageUsedInBytes / 1GB, 2) - storageAllocatedInGigabytes = [math]::round($StorageAllocatedInBytes / 1GB, 2) + storageUsedInGigabytes = $StorageUsedInGigabytes + storageAllocatedInGigabytes = $StorageAllocatedInGigabytes storageUsedInBytes = $SiteUsage.storageUsedInBytes storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes rootWebTemplate = $SiteUsage.rootWebTemplate diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 index bc42246db9842..60e31c9f0245a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 @@ -46,7 +46,21 @@ function Get-CIPPSPOTenant { } # $AdminUrl, not $SharePointInfo.AdminUrl - the latter is empty when a prefix was supplied. - $Results = New-GraphPostRequest -scope "$($AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + try { + $Results = New-GraphPostRequest -scope "$($AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + } catch { + # The admin endpoint answers a bare 401 when the CIPP service principal holds no SharePoint + # app-only consent in the tenant - the token is issued fine, SharePoint just refuses it. That + # is a standing configuration state, not a transient fault: every retry and every nightly run + # gets the same answer until someone resets the CPV permissions. Flag it so callers can tell + # it apart from a real failure, and say what fixes it - 'Failed: 401 UNAUTHORIZED' does not. + if ($_.Exception.Message -match '\b401\b|unauthorized') { + $AccessDenied = [System.Exception]::new("SharePoint admin access denied for $TenantFilter ($AdminUrl returned 401). The CIPP service principal is missing SharePoint app-only consent in this tenant - reset its CPV permissions to restore it.", $_.Exception) + $AccessDenied.Data['SPOAccessDenied'] = $true + throw $AccessDenied + } + throw + } # SharepointDomain rides along with the prefix so Set-CIPPSPOTenant can rebuild the same # admin URL from the pipeline (and from this cache row) without assuming .com. diff --git a/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 index 89e9eb3c196d7..f5b2def74674a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSharePointSiteUsageReport.ps1 @@ -46,8 +46,12 @@ function Get-CIPPSharePointSiteUsageReport { $SiteUsage = $null [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId, [ref]$SiteUsage) - $StorageUsedInBytes = [double]($SiteUsage.storageUsedInBytes ?? 0) - $StorageAllocatedInBytes = [double]($SiteUsage.storageAllocatedInBytes ?? 0) + # A site with no usage row has UNKNOWN storage, not zero storage. Coercing the + # null to 0 made those sites render an authoritative-looking '0' that is + # indistinguishable from a genuinely empty site, so leave them null and let the + # table show them as having no data. + $StorageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } + $StorageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } $AllResults.Add([PSCustomObject]@{ Tenant = $Tenant @@ -60,8 +64,8 @@ function Get-CIPPSharePointSiteUsageReport { ownerPrincipalName = $SiteUsage.ownerPrincipalName lastActivityDate = $SiteUsage.lastActivityDate fileCount = $SiteUsage.fileCount - storageUsedInGigabytes = [math]::round($StorageUsedInBytes / 1GB, 2) - storageAllocatedInGigabytes = [math]::round($StorageAllocatedInBytes / 1GB, 2) + storageUsedInGigabytes = $StorageUsedInGigabytes + storageAllocatedInGigabytes = $StorageAllocatedInGigabytes storageUsedInBytes = $SiteUsage.storageUsedInBytes storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes rootWebTemplate = $SiteUsage.rootWebTemplate @@ -77,10 +81,13 @@ function Get-CIPPSharePointSiteUsageReport { throw 'No SharePoint site listing data found in reporting database. Sync SharePointSiteUsage cache first.' } + # No usage rows is a valid cached result, not a missing cache: getSharePointSiteUsageDetail + # returns an empty set for tenants Microsoft has no usage report for yet. The site listing + # is the backbone of this payload and the usage merge below is a left join, so an empty + # usage set yields the same rows-with-null-usage the live path returns. Throwing here made + # the single-tenant cached view fail on tenants the live view and the AllTenants branch of + # this same function both render fine. $UsageItems = @(Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSiteUsage' | Where-Object { $_.RowKey -ne 'SharePointSiteUsage-Count' }) - if (-not $UsageItems) { - throw 'No SharePoint site usage data found in reporting database. Sync SharePointSiteUsage cache first.' - } $LatestSiteTimestamp = ($SiteItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp $LatestUsageTimestamp = ($UsageItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp @@ -108,8 +115,10 @@ function Get-CIPPSharePointSiteUsageReport { $SiteUsage = $null [void]$UsageBySiteId.TryGetValue([string]$Site.sharepointIds.siteId, [ref]$SiteUsage) - $StorageUsedInBytes = [double]($SiteUsage.storageUsedInBytes ?? 0) - $StorageAllocatedInBytes = [double]($SiteUsage.storageAllocatedInBytes ?? 0) + # Unknown storage stays null rather than becoming a misleading 0 - see the + # AllTenants branch above. + $StorageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } + $StorageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } $ReportItem = [PSCustomObject]@{ siteId = $Site.sharepointIds.siteId @@ -121,8 +130,8 @@ function Get-CIPPSharePointSiteUsageReport { ownerPrincipalName = $SiteUsage.ownerPrincipalName lastActivityDate = $SiteUsage.lastActivityDate fileCount = $SiteUsage.fileCount - storageUsedInGigabytes = [math]::round($StorageUsedInBytes / 1GB, 2) - storageAllocatedInGigabytes = [math]::round($StorageAllocatedInBytes / 1GB, 2) + storageUsedInGigabytes = $StorageUsedInGigabytes + storageAllocatedInGigabytes = $StorageAllocatedInGigabytes storageUsedInBytes = $SiteUsage.storageUsedInBytes storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes rootWebTemplate = $SiteUsage.rootWebTemplate diff --git a/Modules/CIPPCore/Public/Get-CippPartnerTenantInfo.ps1 b/Modules/CIPPCore/Public/Get-CippPartnerTenantInfo.ps1 new file mode 100644 index 0000000000000..345961dcc0a24 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippPartnerTenantInfo.ps1 @@ -0,0 +1,26 @@ +function Get-CippPartnerTenantInfo { + <# + .SYNOPSIS + Get the Microsoft Partner status of the CIPP host tenant + .DESCRIPTION + Reads the organization object of the CIPP host tenant and reports whether it is a + Microsoft Partner tenant. + + The tenant is pinned to $env:TenantID here rather than taken from the caller, so the + answer never depends on request input. That is what lets callers that need nothing but + the partner flag be marked AnyTenant: the question is about the CIPP instance itself, + not about a tenant the user is asking to act on. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Org = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/organization?$select=displayName,partnerTenantType' -tenantid $env:TenantID -NoAuthCheck $true | Select-Object -First 1 + + return [PSCustomObject]@{ + isPartnerTenant = [bool]$Org.partnerTenantType + partnerTenantType = $Org.partnerTenantType + orgName = $Org.displayName + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Repair-CIPPTable.ps1 b/Modules/CIPPCore/Public/GraphHelper/Repair-CIPPTable.ps1 new file mode 100644 index 0000000000000..0d88b91349e86 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Repair-CIPPTable.ps1 @@ -0,0 +1,121 @@ +function Repair-CIPPTable { + <# + .SYNOPSIS + Recreates a missing Azure table and refreshes the Get-CIPPTable ensure-cache. + + .DESCRIPTION + Used when entity operations fail with TableNotFound because CIPPEnsuredTables still claims + the table exists (migration, external delete, or a drop without Unregister-CIPPTable). + Invalidates the cache entry, issues CreateTable, then marks the table ensured again. + Concurrent creates that 409 are treated as success. + + Do not call Write-LogMessage from here - logging reads tables and would re-enter the wrappers. + + .PARAMETER Context + AzBobbyTables context for the missing table (must include TableName). + + .PARAMETER TableName + Table to recreate when a context is not already available. + #> + [CmdletBinding(DefaultParameterSetName = 'ByContext')] + param( + [Parameter(ParameterSetName = 'ByContext', Mandatory)] + $Context, + + [Parameter(ParameterSetName = 'ByName', Mandatory)] + [string]$TableName + ) + + if ($PSCmdlet.ParameterSetName -eq 'ByContext') { + if ($null -eq $Context) { + throw 'Context parameter cannot be null' + } + $TableName = [string]$Context.TableName + if ([string]::IsNullOrWhiteSpace($TableName)) { + throw 'Context.TableName is required' + } + $TableContext = $Context + } else { + $ContextParams = @{ + ConnectionString = $env:AzureWebJobsStorage + TableName = $TableName + } + $ContextParams['MaxConnectionsPerServer'] = if ($env:AZBOBBY_MAX_CONNECTIONS_PER_SERVER) { [int]$env:AZBOBBY_MAX_CONNECTIONS_PER_SERVER } else { 30 } + $TableContext = New-AzDataTableContext @ContextParams + } + + Unregister-CIPPTable -TableName $TableName + + # Same cache contract as Get-CIPPTable: mutate the shared hashtable, never reassign. + if (-not $script:CIPPEnsuredTables) { $script:CIPPEnsuredTables = [HashTable]::Synchronized(@{}) } + $Account = if ($env:AzureWebJobsStorage -match 'AccountName=([^;]+)') { $Matches[1] } else { 'default' } + $CacheKey = '{0}/{1}' -f $Account, $TableName + + try { + New-AzDataTable -Context $TableContext | Out-Null + } catch { + # Another worker may have created it between unregister and create. + if (-not (Test-CIPPTableAlreadyExists $_)) { + throw + } + } + + $script:CIPPEnsuredTables[$CacheKey] = $true + Write-Information "[Tables-Repair] Recreated missing table $TableName on $Account" +} + +function Test-CIPPTableAlreadyExists { + <# + .SYNOPSIS + Returns true when CreateTable failed because the table already exists (HTTP 409). + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory, Position = 0)] + $ErrorRecord + ) + + $Messages = [System.Collections.Generic.List[string]]::new() + $Exceptions = [System.Collections.Generic.List[System.Exception]]::new() + + if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { + $Messages.Add([string]$ErrorRecord.FullyQualifiedErrorId) + if ($ErrorRecord.Exception) { $Exceptions.Add($ErrorRecord.Exception) } + } elseif ($ErrorRecord -is [System.Exception]) { + $Exceptions.Add($ErrorRecord) + } else { + $Messages.Add([string]$ErrorRecord) + } + + foreach ($Exception in $Exceptions) { + $Current = $Exception + while ($Current) { + $Messages.Add([string]$Current.Message) + foreach ($PropName in @('ErrorCode', 'Code')) { + $Prop = $Current.PSObject.Properties[$PropName] + if ($Prop -and $Prop.Value) { $Messages.Add([string]$Prop.Value) } + } + foreach ($PropName in @('Status', 'StatusCode', 'HttpStatusCode')) { + $Prop = $Current.PSObject.Properties[$PropName] + if ($Prop -and $null -ne $Prop.Value) { + $Status = $Prop.Value + if ($Status -is [enum]) { $Status = [int]$Status } + if ([string]$Status -eq '409' -or [int]$Status -eq 409) { + return $true + } + } + } + $Current = $Current.InnerException + } + } + + foreach ($Message in $Messages) { + if ([string]::IsNullOrWhiteSpace($Message)) { continue } + if ($Message -match '(?i)TableAlreadyExists|already exists|Conflict|\b409\b') { + return $true + } + } + + $false +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Test-CIPPTableNotFound.ps1 b/Modules/CIPPCore/Public/GraphHelper/Test-CIPPTableNotFound.ps1 new file mode 100644 index 0000000000000..645c94c72e7c9 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Test-CIPPTableNotFound.ps1 @@ -0,0 +1,71 @@ +function Test-CIPPTableNotFound { + <# + .SYNOPSIS + Returns true when an error indicates the Azure table does not exist. + + .DESCRIPTION + Shared by the CIPP table entity wrappers so a stale CreateTable cache can self-heal on + TableNotFound. Matches ErrorCode, HTTP 404 status, and the table service message text. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory, Position = 0)] + $ErrorRecord + ) + + $Messages = [System.Collections.Generic.List[string]]::new() + + $Exceptions = [System.Collections.Generic.List[System.Exception]]::new() + if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { + $Messages.Add([string]$ErrorRecord.FullyQualifiedErrorId) + if ($ErrorRecord.Exception) { $Exceptions.Add($ErrorRecord.Exception) } + if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { + $Messages.Add([string]$ErrorRecord.ErrorDetails.Message) + } + } elseif ($ErrorRecord -is [System.Exception]) { + $Exceptions.Add($ErrorRecord) + } else { + $Messages.Add([string]$ErrorRecord) + } + + foreach ($Exception in $Exceptions) { + $Current = $Exception + while ($Current) { + $Messages.Add([string]$Current.Message) + $Messages.Add([string]$Current.GetType().FullName) + + foreach ($PropName in @('ErrorCode', 'Code', 'ErrorCodeString')) { + $Prop = $Current.PSObject.Properties[$PropName] + if ($Prop -and $Prop.Value) { + $Messages.Add([string]$Prop.Value) + } + } + + foreach ($PropName in @('Status', 'StatusCode', 'HttpStatusCode')) { + $Prop = $Current.PSObject.Properties[$PropName] + if ($Prop -and $null -ne $Prop.Value) { + $Status = $Prop.Value + if ($Status -is [enum]) { $Status = [int]$Status } + if ([string]$Status -eq '404' -or [int]$Status -eq 404) { + # 404 alone is not enough (blob/other resources), but with table context below. + $Messages.Add('HTTP404') + } + } + } + + $Current = $Current.InnerException + } + } + + foreach ($Message in $Messages) { + if ([string]::IsNullOrWhiteSpace($Message)) { continue } + if ($Message -match '(?i)TableNotFound|table specified does not exist') { + return $true + } + } + + # RequestFailedException often exposes ErrorCode=TableNotFound; if we only saw HTTP 404 plus + # table-ish wording elsewhere, the regex above already caught it. + $false +} diff --git a/Modules/CIPPCore/Public/MCP/ConvertFrom-CippDocMarkdown.ps1 b/Modules/CIPPCore/Public/MCP/ConvertFrom-CippDocMarkdown.ps1 new file mode 100644 index 0000000000000..8ca637c9c5678 --- /dev/null +++ b/Modules/CIPPCore/Public/MCP/ConvertFrom-CippDocMarkdown.ps1 @@ -0,0 +1,122 @@ +function ConvertFrom-CippDocMarkdown { + <# + .SYNOPSIS + Parses a GitBook markdown page into a title, description and heading-delimited chunks. + .DESCRIPTION + Strips the machinery GitBook layers on top of markdown so it does not end up in the search + index or in a returned snippet: YAML frontmatter (the description is kept), '{% ... %}' + block tags such as {% stepper %} / {% hint %}, raw

//
embeds, and the + markdown link/emphasis syntax. Link labels survive because they are real prose; the URLs + do not, because a query should not match a page on the strength of a href. + + Splits at '##' and '###' headings. Text before the first heading becomes the intro chunk, + which is what a query about the page as a whole should match. Fenced code blocks are kept + as text - PowerShell examples in the docs are frequently the thing being searched for - but + their fence markers and language hints are dropped. Headings inside a fence are ignored, so + a '# comment' in a shell example does not split the page. Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Markdown, + [Parameter(Mandatory)][string]$RelativePath + ) + + $Description = '' + $Body = $Markdown + + # YAML frontmatter, only when it opens the file. + if ($Body -match '(?s)^?---\r?\n(.*?)\r?\n---\r?\n?(.*)$') { + $FrontMatter = $Matches[1] + $Body = $Matches[2] + if ($FrontMatter -match '(?m)^description:\s*(.+?)\s*$') { + $Description = $Matches[1].Trim().Trim('"', "'") + } + } + + # GitBook block tags: {% stepper %}, {% hint style="info" %}, {% endstep %}, {% include ... %}. + $Body = $Body -replace '(?s)\{%.*?%\}', ' ' + # Raw HTML embeds - figures, images and the sponsor
grids - carry no searchable prose. + $Body = $Body -replace '(?s)
.*?
', ' ' + $Body = $Body -replace '(?s)<(script|style)\b.*?', ' ' + $Body = $Body -replace '<[^>]+>', ' ' + # Images before links, so an image's alt text does not survive as if it were a link label. + $Body = $Body -replace '!\[[^\]]*\]\([^)]*\)', ' ' + $Body = $Body -replace '\[([^\]]*)\]\([^)]*\)', '$1' + $Body = $Body -replace ' ', ' ' + + $Title = '' + $Chunks = [System.Collections.Generic.List[object]]::new() + + $CurrentHeading = '' + $CurrentText = [System.Text.StringBuilder]::new() + $InFence = $false + + $AddChunk = { + $Text = $CurrentText.ToString() + $Text = ($Text -replace '[ \t]+', ' ' -replace '(\r?\n\s*){2,}', "`n").Trim() + if ($Text -or $CurrentHeading) { + $Chunks.Add([pscustomobject]@{ Heading = $CurrentHeading; Text = $Text }) + } + } + + foreach ($Line in ($Body -split '\r?\n')) { + if ($Line -match '^\s*(```|~~~)') { + $InFence = -not $InFence + continue + } + + if (-not $InFence -and $Line -match '^(#{1,6})\s+(.*\S)\s*$') { + $Level = $Matches[1].Length + $Text = ($Matches[2] -replace '[`*_~]', '').Trim() + + if ($Level -eq 1 -and -not $Title) { + # The page's own H1 titles the page; it does not start a chunk. + $Title = $Text + continue + } + + if ($Level -le 3) { + & $AddChunk + $CurrentHeading = $Text + $CurrentText = [System.Text.StringBuilder]::new() + continue + } + # H4+ stays inside the current chunk as ordinary emphasised prose. + [void]$CurrentText.AppendLine($Text) + continue + } + + [void]$CurrentText.AppendLine($Line) + } + & $AddChunk + + if (-not $Title) { + # No H1: fall back to the file (or folder, for a README) name, title-cased. + $Leaf = [System.IO.Path]::GetFileNameWithoutExtension($RelativePath) + if ($Leaf -match '(?i)^README$') { + $Parent = ($RelativePath -replace '\\', '/') -replace '/[^/]+$', '' + $Leaf = ($Parent -split '/')[-1] + } + $Title = (($Leaf -replace '[-_]', ' ') -split ' ' | Where-Object { $_ } | ForEach-Object { + $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) + }) -join ' ' + } + + # Breadcrumb from the folder chain, for display: 'User Documentation > Identity > Users'. + $Segments = @(($RelativePath -replace '\\', '/') -split '/') + $Folders = @($Segments | Select-Object -SkipLast 1) + $Breadcrumb = (@($Folders | ForEach-Object { + (($_ -replace '[-_]', ' ') -split ' ' | Where-Object { $_ } | ForEach-Object { + $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) + }) -join ' ' + }) -join ' > ') + + return [pscustomobject]@{ + Title = $Title + Description = $Description + Breadcrumb = $Breadcrumb + Chunks = $Chunks + } +} diff --git a/Modules/CIPPCore/Public/MCP/ConvertTo-CippDocToken.ps1 b/Modules/CIPPCore/Public/MCP/ConvertTo-CippDocToken.ps1 new file mode 100644 index 0000000000000..8738523156970 --- /dev/null +++ b/Modules/CIPPCore/Public/MCP/ConvertTo-CippDocToken.ps1 @@ -0,0 +1,20 @@ +function ConvertTo-CippDocToken { + <# + .SYNOPSIS + Tokenises text for the docs index: lowercase, split, de-stopped, lightly stemmed. + .DESCRIPTION + A thin wrapper over CIPPSharp's tokeniser, which is the single implementation. Indexing + and querying must tokenise identically - a term indexed as 'standard' is never found by a + query for 'Standards' otherwise - so there is deliberately no second copy of these rules + in PowerShell. The rules themselves are documented on CIPP.DocsIndex.Tokenize. + + This exists so callers and tests can tokenise without reaching for the type name, and so + the query path reads the same as the rest of the MCP code. Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([AllowEmptyString()][string]$Text) + + return @([CIPP.DocsIndex]::Tokenize($Text)) +} diff --git a/Modules/CIPPCore/Public/MCP/Find-CippDoc.ps1 b/Modules/CIPPCore/Public/MCP/Find-CippDoc.ps1 new file mode 100644 index 0000000000000..9b431e70c31c2 --- /dev/null +++ b/Modules/CIPPCore/Public/MCP/Find-CippDoc.ps1 @@ -0,0 +1,218 @@ +function Find-CippDoc { + <# + .SYNOPSIS + Searches the CIPP documentation and returns ranked, deep-linked sections. + .DESCRIPTION + Backs the SearchDocs core tool. Scoring is BM25 over CIPPSharp's inverted index, with + three expansions layered on top of the caller's literal terms. None of them is a vector + model - CIPP has no embedding provider, and requiring an API key to search the docs would + be a poor trade - but together they cover most of what a caller means rather than types: + + - Domain synonyms (Config/DocsSynonyms.json), damped to 0.55. This is the one that + matters: 'CA policy' reaches pages that only ever write 'conditional access'. + - Fuzzy vocabulary matching, damped to 0.4, for terms the corpus does not contain at + all. 'conditonal' is a typo, not a different question. + - Path queries. A query that looks like a CIPP route ('/identity/administration/users') + is matched against the page's own appPath, so an agent looking at a screen can ask for + that screen's documentation directly. + + The caller is itself a model and rephrases well, so the remaining gap to true semantic + recall is smaller in practice than it looks. Ranking stays behind this one function and + CIPP.DocsIndex.Search, which is where an embedding reranker would slot in if one ever + becomes available. + + Results are sections, not whole pages, and carry the heading anchor so the link lands on + the part that matched. Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [string]$Query, + # Restrict to a subtree, e.g. 'user-documentation/identity' or a CIPP route. + [string]$Path, + [int]$Limit = 8 + ) + + if ($Limit -lt 1) { $Limit = 8 } + if ($Limit -gt 25) { $Limit = 25 } + + $null = Get-CippDocsIndex + + if ([string]::IsNullOrWhiteSpace($Query) -and [string]::IsNullOrWhiteSpace($Path)) { + return [ordered]@{ + error = 'Provide a query (keywords or a question) and/or a path to scope the search.' + hint = 'Example: { "query": "how do I set up GDAP" } or { "path": "/identity/administration/users" }.' + } + } + + # A bare path with no keywords: return the pages under it directly. + if ([string]::IsNullOrWhiteSpace($Query)) { + $ByPath = [CIPP.DocsIndex]::ByPath($Path, $Limit) + if ($ByPath.MatchCount -eq 0) { + return [ordered]@{ + matchCount = 0 + results = @() + hint = "No documentation page matches path '$Path'. Search by keywords instead, or drop the path filter." + } + } + return [ordered]@{ + matchCount = $ByPath.MatchCount + results = @($ByPath.Hits | ForEach-Object { ConvertTo-CippDocSearchResult -Hit $_ }) + hint = 'Matched by path. Call GetDoc with a result''s path for the full page text.' + } + } + + $Primary = @(ConvertTo-CippDocToken -Text $Query) + if ($Primary.Count -eq 0) { + return [ordered]@{ + matchCount = 0 + results = @() + hint = 'The query reduced to no searchable terms. Try more specific keywords.' + } + } + + # Domain expansion happens here rather than in C# because the map is CIPP configuration, + # not an indexing concern; the index just takes the extra terms and damps them. + $Synonyms = Get-CippDocSynonym + $Expanded = [System.Collections.Generic.List[string]]::new() + foreach ($Token in $Primary) { + foreach ($Phrase in @($Synonyms[$Token])) { + if (-not $Phrase) { continue } + foreach ($Term in (ConvertTo-CippDocToken -Text $Phrase)) { + if ($Term -notin $Primary) { $Expanded.Add($Term) } + } + } + } + + $Search = [CIPP.DocsIndex]::Search([string[]]$Primary, [string[]]$Expanded.ToArray(), $Path, $Limit) + + if ($Search.MatchCount -eq 0) { + $Response = [ordered]@{ matchCount = 0; results = @() } + if ($Search.Suggestions.Count -gt 0) { + $Response['suggestions'] = @($Search.Suggestions) + $Response['hint'] = 'Nothing matched. The suggestions are the closest terms that do appear in the docs - try one of those.' + } else { + $Response['hint'] = 'Nothing matched. Try broader keywords, or the words a page would actually use.' + } + return $Response + } + + Write-Information "[MCP] SearchDocs query='$Query' path='$Path' -> $($Search.MatchCount) sections, returning $($Search.Hits.Count)" + + return [ordered]@{ + matchCount = $Search.MatchCount + results = @($Search.Hits | ForEach-Object { ConvertTo-CippDocSearchResult -Hit $_ }) + hint = 'Each result deep-links to the section that matched. Call GetDoc with a result''s path for the page''s full text.' + } +} + +function ConvertTo-CippDocSearchResult { + <# + .SYNOPSIS + Shapes a CIPPSharp search hit into the object returned to the MCP caller. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([Parameter(Mandatory)]$Hit) + + $Result = [ordered]@{ + title = $Hit.Title + section = $Hit.Section + path = $Hit.Path + excerpt = $Hit.Excerpt + docsUrl = $Hit.DocsUrl + } + if (-not $Hit.Published) { + $Result['note'] = 'Not published on docs.cipp.app; use the GitHub link.' + } + $Result['githubUrl'] = $Hit.GitHubUrl + if ($Hit.AppPath) { $Result['appPath'] = $Hit.AppPath } + $Result['breadcrumb'] = $Hit.Breadcrumb + if ($Hit.Score -gt 0) { $Result['score'] = $Hit.Score } + + return $Result +} + +function Get-CippDocSynonym { + <# + .SYNOPSIS + Loads and caches the docs query-expansion map, keyed by stemmed token. + .DESCRIPTION + Config/DocsSynonyms.json is authored with ordinary words; the keys are stemmed here with + the same rule the indexer uses, so an author does not have to predict the stemmer. A + missing or malformed file degrades to no expansion rather than breaking search. + Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([switch]$Force) + + if ($script:CippDocSynonym -and -not $Force) { return $script:CippDocSynonym } + + $Map = @{} + $Path = Join-Path -Path $env:CIPPRootPath -ChildPath 'Config/DocsSynonyms.json' + if (Test-Path -LiteralPath $Path) { + try { + $Json = [System.IO.File]::ReadAllText($Path) | ConvertFrom-Json -AsHashtable + foreach ($Pair in $Json.expansions.GetEnumerator()) { + $Map[[CIPP.DocsIndex]::Stem(([string]$Pair.Key).ToLowerInvariant())] = @($Pair.Value) + } + } catch { + Write-Information "[MCP] DocsSynonyms.json could not be read, continuing without query expansion: $($_.Exception.Message)" + } + } + + $script:CippDocSynonym = $Map + return $Map +} + +function Get-CippDoc { + <# + .SYNOPSIS + Returns the full text of one documentation page. + .DESCRIPTION + Backs the GetDoc core tool, for when a SearchDocs excerpt is not enough. Accepts whatever + identifier the caller happens to be holding - the repo-relative path from a search result, + the published slug, or the CIPP route the page documents - because an agent that found a + page one way should not have to convert it to another. Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + + $null = Get-CippDocsIndex + + $Page = [CIPP.DocsIndex]::FindPage($Path) + if (-not $Page) { + # Deduplicated by page: a search returns up to two sections per page, and offering the + # same path twice as a 'did you mean' just wastes one of three suggestions. + $Near = @((Find-CippDoc -Query ($Path -replace '[/\-_.]', ' ') -Limit 6).results | + ForEach-Object { $_.path } | Select-Object -Unique | Select-Object -First 3) + return [ordered]@{ + error = "No documentation page matches '$Path'." + suggestions = @($Near) + hint = 'Find a page with SearchDocs first; its "path" is what this tool takes.' + } + } + + $Result = [ordered]@{ + title = $Page.Title + description = $Page.Description + path = $Page.RelativePath + breadcrumb = $Page.Breadcrumb + docsUrl = $Page.DocsUrl + githubUrl = $Page.GitHubUrl + } + if ($Page.AppPath) { $Result['appPath'] = $Page.AppPath } + if (-not $Page.Published) { + $Result['note'] = 'Not published on docs.cipp.app; use the GitHub link.' + } + $Result['sections'] = @($Page.Headings) + $Result['content'] = [CIPP.DocsIndex]::GetPageText($Page.RelativePath) + + return $Result +} diff --git a/Modules/CIPPCore/Public/MCP/Get-CippDocLink.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippDocLink.ps1 new file mode 100644 index 0000000000000..1848f726c1934 --- /dev/null +++ b/Modules/CIPPCore/Public/MCP/Get-CippDocLink.ps1 @@ -0,0 +1,125 @@ +function Get-CippDocLink { + <# + .SYNOPSIS + Derives the published docs.cipp.app URL, GitHub source URL and in-app route for a docs file. + .DESCRIPTION + The docs tree is a GitBook git-sync source, so a page's published URL is its file path with + the '.md' dropped - there is no slug table to consult. Three link forms come out of that: + + docs/setup/setting-up-cipp/install.md + -> https://docs.cipp.app/setup/setting-up-cipp/install + -> https://github.com/CyberDrain/CIPP/blob/dev/docs/setup/setting-up-cipp/install.md + + docs/setup/setting-up-cipp/README.md (a section index) + -> https://docs.cipp.app/setup/setting-up-cipp + + Pages under 'user-documentation/' mirror the frontend's own routing one-for-one, which is + the same assumption _app.js:259 already makes when it builds its "docs for this page" link. + That makes the mapping reversible: a doc knows which CIPP screen it documents, and a screen + can be traced back to its doc. Only paths under user-documentation get an AppPath; nothing + else in the tree corresponds to a route. + + Anchors are GitBook's heading slugs (lowercased, non-alphanumerics collapsed to hyphens), + which is what makes a chunk-level result deep-link to the exact section it matched rather + than to the top of a 25 KB page. + + One rule is not guessable from the path alone: a folder with no README.md is a grouping + folder, not a page, and GitBook drops it from the URL entirely. 'email/resources' has no + README and publishes nothing, so its children move up a level - + email/resources/management/equipment/edit.md is served at email/management/equipment/edit. + Without this, seven pages get confidently wrong links. -SectionFolder supplies the set of + folders that do own a README; the index builder computes it once for the whole tree. + + Only the docs URL is rewritten. The GitHub URL always keeps the real repo path, because + that is where the file actually lives. Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + # Path of the markdown file relative to the docs root, e.g. 'setup/setting-up-cipp/install.md'. + [Parameter(Mandatory)] + [string]$RelativePath, + + # Optional heading text to deep-link to within the page. + [string]$Heading, + + # Folder paths (relative to the docs root, '/'-separated) that contain a README.md. + # Any ancestor folder absent from this set is elided from the published URL. + [System.Collections.Generic.HashSet[string]]$SectionFolder + ) + + $Clean = $RelativePath -replace '\\', '/' -replace '^\./', '' -replace '^/', '' + $Slug = $Clean -replace '(?i)\.md$', '' + + # A README is the index of the folder it sits in, so it publishes at the folder's own URL. + # The docs root README is the site root, which GitBook publishes as /readme rather than /. + if ($Slug -match '(?i)^README$') { + $Slug = 'readme' + } elseif ($Slug -match '(?i)/README$') { + $Slug = $Slug -replace '(?i)/README$', '' + } + + # Drop grouping folders. The final segment is the page itself and always survives. A + # top-level folder is a SUMMARY.md '## Group' and always contributes its slug even though + # it owns no README ('setup' has none, yet every setup page is served under /setup). + # Below that, a folder only earns a URL slot by owning a README. + if ($SectionFolder -and $Slug -ne 'readme') { + $Segments = @($Slug -split '/') + if ($Segments.Count -gt 1) { + $Kept = [System.Collections.Generic.List[string]]::new() + for ($i = 0; $i -lt $Segments.Count - 1; $i++) { + $Folder = ($Segments[0..$i] -join '/') + if ($i -eq 0 -or $SectionFolder.Contains($Folder)) { $Kept.Add($Segments[$i]) } + } + $Kept.Add($Segments[-1]) + $Slug = $Kept -join '/' + } + } + + $Anchor = if ($Heading) { Get-CippDocAnchor -Heading $Heading } else { '' } + $Fragment = if ($Anchor) { "#$Anchor" } else { '' } + + $AppPath = if ($Slug -match '^user-documentation/(.+)$') { "/$($Matches[1])" } else { $null } + + return [ordered]@{ + docsUrl = "https://docs.cipp.app/$Slug$Fragment" + githubUrl = "https://github.com/CyberDrain/CIPP/blob/dev/docs/$Clean$Fragment" + appPath = $AppPath + slug = $Slug + anchor = $Anchor + } +} + +function Get-CippDocAnchor { + <# + .SYNOPSIS + Converts a markdown heading to the anchor slug GitBook publishes for it. + .DESCRIPTION + Mirrors GitBook's slug rules: strip inline markdown, lowercase, drop anything that is not + alphanumeric or a space/hyphen, then collapse whitespace runs to single hyphens. Apostrophes + are removed rather than replaced, so "Confirm You've Met All Prerequisites" becomes + 'confirm-youve-met-all-prerequisites' and not 'confirm-you-ve-...'. Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([string]$Heading) + + if ([string]::IsNullOrWhiteSpace($Heading)) { return '' } + + $Text = $Heading -replace '^#+\s*', '' + # Inline markdown that renders away before the slug is taken: links keep their label only. + $Text = $Text -replace '\[([^\]]*)\]\([^)]*\)', '$1' + $Text = $Text -replace '[`*_~]', '' + # Straight and typographic apostrophes both vanish rather than becoming separators. + # Written as regex escapes, not literals: PowerShell reads a bare U+2019 as a quote + # delimiter, so a literal here is one encoding round-trip away from a parser error. + $Text = $Text -replace '[\u2018\u2019'']', '' + $Text = $Text.ToLowerInvariant() + $Text = $Text -replace '[^a-z0-9 \-]', ' ' + $Text = ($Text -replace '\s+', ' ').Trim() + $Text = $Text -replace '[\s\-]+', '-' + + return $Text.Trim('-') +} diff --git a/Modules/CIPPCore/Public/MCP/Get-CippDocsIndex.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippDocsIndex.ps1 new file mode 100644 index 0000000000000..8adb01b635069 --- /dev/null +++ b/Modules/CIPPCore/Public/MCP/Get-CippDocsIndex.ps1 @@ -0,0 +1,203 @@ +function Get-CippDocsIndex { + <# + .SYNOPSIS + Builds, once per host, the searchable index over the shipped CIPP documentation tree. + .DESCRIPTION + Backs the SearchDocs / GetDoc MCP tools. The docs are shipped in the image (see the docs/ + COPY in build/Dockerfile) rather than fetched from docs.cipp.app, because there is no + GitBook search API, llms-full.txt is capped at 100 of the 427 pages, and a crawl would put + an outbound-internet dependency in the request path. Shipping them also version-matches the + docs to the running build, and results still carry live docs.cipp.app links, so a caller + who needs the very latest text can always follow one. + + This function does discovery, markdown parsing and link derivation; CIPPSharp's + CIPP.DocsIndex owns tokenisation, the postings map and scoring. That split is deliberate: + the parsing is cheap and reads better in PowerShell, while tokenising 2 MB of prose in + PowerShell measured at 26 seconds against well under a second in .NET. Just as importantly + the C# index is a host-scoped static, so it is built once for every worker on the host + rather than once per runspace - the IsBuilt check below is what lets the other workers skip + all of this. + + Pages are split into chunks at '##'/'###' headings. A chunk, not a page, is the unit of + retrieval: pages here run to 25 KB, and returning a whole one to answer a question about a + single section wastes the caller's context and buries the answer. Each chunk carries its + heading anchor, so a hit deep-links to the exact section. + + Two page classes are excluded outright rather than ranked down: + - legacy-setup-hidden-from-nav/ 33 superseded 'Copy of ...' duplicates of the setup + guide. Near-identical text to the live pages, so they + would double every setup hit with a dead link. + - .gitbook/includes/ reusable snippets that are not pages at all. + Pages that exist but GitBook does not publish keep a GitHub link and get no docsUrl, + rather than being handed a docs.cipp.app URL that 404s. + + Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + # Overrides the docs root. Defaults to the shipped copy, then the repo tree for local dev. + [string]$DocsRoot, + [switch]$Force + ) + + if (-not $DocsRoot) { $DocsRoot = Get-CippDocsRoot } + if (-not $DocsRoot) { + throw [pscustomobject]@{ code = -32603; message = 'CIPP documentation not found in this deployment; docs search is unavailable.' } + } + + $DocsRoot = (Resolve-Path -LiteralPath $DocsRoot).Path.TrimEnd('\', '/') + + if (-not $Force -and [CIPP.DocsIndex]::IsBuilt($DocsRoot)) { + return Get-CippDocsIndexStatus -DocsRoot $DocsRoot + } + + $RootLength = $DocsRoot.Length + + # Folders owning a README are real pages and contribute a URL segment; the rest are elided. + $SectionFolder = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Readme in [System.IO.Directory]::EnumerateFiles($DocsRoot, 'README.md', [System.IO.SearchOption]::AllDirectories)) { + $Dir = [System.IO.Path]::GetDirectoryName($Readme) + if ($Dir.Length -le $RootLength) { continue } + $SectionFolder.Add(($Dir.Substring($RootLength).TrimStart('\', '/') -replace '\\', '/')) | Out-Null + } + + # What GitBook actually publishes, so the index never invents a link. + $Published = Get-CippDocsPublishedSet + + $Builder = [CIPP.DocsIndex]::BeginBuild($DocsRoot) + + foreach ($FullPath in [System.IO.Directory]::EnumerateFiles($DocsRoot, '*.md', [System.IO.SearchOption]::AllDirectories)) { + $Rel = $FullPath.Substring($RootLength).TrimStart('\', '/') -replace '\\', '/' + + if ($Rel -eq 'SUMMARY.md') { continue } + if ($Rel -like '.gitbook/*') { continue } + if ($Rel -like 'legacy-setup-hidden-from-nav/*') { continue } + + $Parsed = ConvertFrom-CippDocMarkdown -Markdown ([System.IO.File]::ReadAllText($FullPath)) -RelativePath $Rel + $Link = Get-CippDocLink -RelativePath $Rel -SectionFolder $SectionFolder + + $IsPublished = if ($null -eq $Published) { $true } else { $Published.Contains($Link.slug) } + + $PageIndex = $Builder.AddPage( + $Rel, + $Parsed.Title, + $Parsed.Description, + $Parsed.Breadcrumb, + $Link.slug, + $(if ($IsPublished) { $Link.docsUrl } else { $null }), + $Link.githubUrl, + $(if ($IsPublished) { $Link.appPath } else { $null }), + $IsPublished) + + foreach ($Chunk in $Parsed.Chunks) { + if ([string]::IsNullOrWhiteSpace($Chunk.Text) -and -not $Chunk.Heading) { continue } + $Anchor = if ($Chunk.Heading) { Get-CippDocAnchor -Heading $Chunk.Heading } else { '' } + $Builder.AddChunk($PageIndex, $Chunk.Heading, $Anchor, $Chunk.Text) + } + } + + [CIPP.DocsIndex]::CommitBuild($Builder) + + $Status = Get-CippDocsIndexStatus -DocsRoot $DocsRoot + Write-Information "[MCP] docs index built: $($Status.pageCount) pages, $($Status.chunkCount) chunks, $($Status.termCount) terms from $DocsRoot" + return $Status +} + +function Get-CippDocsIndexStatus { + <# + .SYNOPSIS + Reports the current host-scoped docs index counts. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([string]$DocsRoot) + + return [ordered]@{ + docsRoot = $DocsRoot + pageCount = [CIPP.DocsIndex]::PageCount + chunkCount = [CIPP.DocsIndex]::ChunkCount + termCount = [CIPP.DocsIndex]::TermCount + } +} + +function Get-CippDocsRoot { + <# + .SYNOPSIS + Locates the documentation tree, in the container or in a source checkout. + .DESCRIPTION + Checks CIPPDocsPath first (the dev compose files set it), then the image's own + $env:CIPPRootPath/Docs, then the repo layout so local dev and Pester runs work without a + build. Returns $null when none holds documentation. + + A candidate has to actually contain markdown to win, which is not the pedantry it looks + like. Bind-mounting the docs at /app/API/Docs - inside the ../backend mount - makes Docker + create the nested mountpoint on the *host*, leaving an empty backend/Docs in the working + tree. That directory then satisfies a bare existence check and shadows the real docs for + everything running outside the container, so every search silently returns nothing against + a perfectly healthy index of zero pages. The dev mount now lives at /app/Docs to avoid + creating it at all; this check is the backstop. Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Candidates = [System.Collections.Generic.List[string]]::new() + if ($env:CIPPDocsPath) { $Candidates.Add($env:CIPPDocsPath) } + if ($env:CIPPRootPath) { + foreach ($Relative in 'Docs', '../docs', '../../docs') { + $Candidates.Add((Join-Path -Path $env:CIPPRootPath -ChildPath $Relative)) + } + } + + foreach ($Candidate in $Candidates) { + if (-not (Test-Path -LiteralPath $Candidate -PathType Container)) { continue } + # Select-Object -First 1 short-circuits the enumeration, so this stops at the first hit + # rather than walking the whole tree. + $Markdown = @([System.IO.Directory]::EnumerateFiles($Candidate, '*.md', [System.IO.SearchOption]::AllDirectories) | + Select-Object -First 1) + if ($Markdown.Count -gt 0) { return $Candidate } + } + return $null +} + +function Get-CippDocsPublishedSet { + <# + .SYNOPSIS + Reads the set of slugs GitBook actually publishes, from the committed snapshot. + .DESCRIPTION + A page can exist in docs/ and still not be live: nine current pages under + setup/implementation-guide/your-route-to-a-secure-tenant/ are in SUMMARY.md but + unpublished, so SUMMARY is not the discriminator. docs.cipp.app/llms.txt is the only + authoritative statement, and Config/DocsPublishedPages.txt is a snapshot of it - which + keeps the check offline, so the index never has to guess and never hands back a + docs.cipp.app URL that 404s. + + The container build refreshes that snapshot from the live site (the build-docspages stage), + so the committed copy is a fallback rather than the source of truth - it is what ships only + when the fetch fails. Refresh the committed one with + build/tools/Update-DocsPublishedPages.ps1. + + Returns $null when the snapshot is missing, which the caller reads as 'assume everything is + published' - a stale link is a better failure than no docs search at all. + Not an HTTP entrypoint. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $SnapshotPath = Join-Path -Path $env:CIPPRootPath -ChildPath 'Config/DocsPublishedPages.txt' + if (-not (Test-Path -LiteralPath $SnapshotPath)) { return $null } + + $Set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Line in [System.IO.File]::ReadAllLines($SnapshotPath)) { + $Trimmed = $Line.Trim() + if (-not $Trimmed -or $Trimmed.StartsWith('#')) { continue } + $Set.Add($Trimmed) | Out-Null + } + return $(if ($Set.Count -gt 0) { $Set } else { $null }) +} diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1 index c817a0191717c..329ade7d60344 100644 --- a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1 +++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolCatalog.ps1 @@ -38,7 +38,11 @@ function Get-CippMcpToolCatalog { # backs the in-app documentation browser: it returns the whole ~1.5 MB OpenAPI # document, which would flood the caller's context to tell it what SearchTools # already answers. - if ($Endpoint -in @('ExecMcp', 'ListOpenApiSpec')) { continue } + # + # ListCippDocs is excluded for a different reason: it is already advertised as the + # SearchDocs and GetDoc core tools, and leaving it in the catalog would offer a + # third name for the same thing with a different argument shape. + if ($Endpoint -in @('ExecMcp', 'ListOpenApiSpec', 'ListCippDocs')) { continue } foreach ($MethodEntry in $PathEntry.Value.GetEnumerator()) { $Method = [string]$MethodEntry.Key diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 index 46e6a4fa11c32..69d75ba30bdc1 100644 --- a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 +++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 @@ -1,15 +1,24 @@ function Get-CippMcpToolList { <# .SYNOPSIS - Returns the fixed five-tool gateway advertised to MCP clients. + Returns the fixed core-tool gateway advertised to MCP clients. .DESCRIPTION tools/list never dumps the full read-only catalog (200+ tool schemas would flood the - client's context window). Instead every connection is offered the same five core tools: + client's context window). Instead every connection is offered the same core tools: - ListTenants direct passthrough; the entry point for tenant resolution - ListGraphRequest direct passthrough; arbitrary Microsoft Graph GET proxy - SearchTools browse/search the catalog (compact results, no schemas) - GetToolInfo full description + inputSchema for named catalog tools - ExecTool execute any catalog tool by name + - SearchDocs search the CIPP documentation + - GetDoc fetch one documentation page in full + + SearchDocs and GetDoc are advertised rather than left to be discovered through + SearchTools because they answer a different kind of question from the rest of the + catalog. Every other tool returns tenant data; these explain what CIPP does and how to + drive it, which is exactly what an agent needs *before* it knows which data tool to + reach for. A model that has to already suspect the docs exist in order to find them + will simply guess at CIPP's behaviour instead. The connector URL's query filters (?tags=, ?tools=, ?first=) scope the catalog visible to SearchTools/GetToolInfo/ExecTool; the five core tools themselves are always advertised. Passthrough schemas are projected live from the catalog so they track openapi.json. @@ -82,6 +91,33 @@ function Get-CippMcpToolList { annotations = [ordered]@{ title = 'ExecTool'; readOnlyHint = $true } }) + $Tools.Add([ordered]@{ + name = 'SearchDocs' + description = 'Search the CIPP documentation (docs.cipp.app) by keywords or a plain-language question. Use this to find out how a CIPP feature works, how to configure something, or what a screen does - it answers "how do I" and "what is", where the other tools return tenant data. Results are individual sections with an excerpt and a link that deep-links to the matching heading. Pass path to scope to one area, or to look up the documentation for a CIPP screen by its route (e.g. "/identity/administration/users").' + inputSchema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + query = @{ type = 'string'; description = 'Keywords or a question, e.g. "how do I set up GDAP" or "conditional access templates".' } + path = @{ type = 'string'; description = 'Optional. Restrict to a documentation subtree ("user-documentation/identity") or a CIPP route ("/identity/administration/users").' } + limit = @{ type = 'integer'; description = 'Maximum results (default 8, max 25).' } + } + } + annotations = [ordered]@{ title = 'SearchDocs'; readOnlyHint = $true } + }) + + $Tools.Add([ordered]@{ + name = 'GetDoc' + description = 'Fetch one CIPP documentation page in full, when a SearchDocs excerpt is not enough. Takes the "path" from a SearchDocs result, a published docs.cipp.app slug, or the CIPP route the page documents.' + inputSchema = [ordered]@{ + type = 'object' + properties = [ordered]@{ + path = @{ type = 'string'; description = 'The page to fetch, as returned in a SearchDocs result''s "path".' } + } + required = @('path') + } + annotations = [ordered]@{ title = 'GetDoc'; readOnlyHint = $true } + }) + Write-Information "[MCP] tools/list -> $($Tools.Count) core tools (catalog=$FilteredCount)" return @($Tools) diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolResult.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolResult.ps1 index 4190c5b9c60fa..e52e26ef01c76 100644 --- a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolResult.ps1 +++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolResult.ps1 @@ -3,7 +3,8 @@ function Get-CippMcpToolResult { .SYNOPSIS Dispatches a single MCP 'tools/call' through the five-tool gateway. .DESCRIPTION - SearchTools and GetToolInfo are answered locally from the read-only tool catalog. + SearchTools and GetToolInfo are answered locally from the read-only tool catalog, and + SearchDocs / GetDoc from the documentation index shipped in the image. ListTenants, ListGraphRequest and ExecTool targets are re-dispatched through New-CippCoreRequest via Invoke-CippMcpApiRequest, so RBAC and tenant scoping are enforced on every execution. Direct calls to bare catalog tool names are still accepted for @@ -77,6 +78,29 @@ function Get-CippMcpToolResult { isError = $false } } + 'SearchDocs' { + # Answered locally, like SearchTools: the documentation is shipped in the image and + # is not tenant data, so there is nothing to re-dispatch through the API and no + # tenant scoping to enforce. + $Limit = $ArgHash['limit'] -as [int] + if (-not $Limit) { $Limit = 8 } + $DocResult = Find-CippDoc -Query ([string]$ArgHash['query']) -Path ([string]$ArgHash['path']) -Limit $Limit + return [ordered]@{ + content = @(@{ type = 'text'; text = ($DocResult | ConvertTo-Json -Depth 10 -Compress) }) + isError = [bool]$DocResult.error + } + } + 'GetDoc' { + $DocPath = [string]($ArgHash['path'] ?? $ArgHash['name']) + if ([string]::IsNullOrWhiteSpace($DocPath)) { + throw [pscustomobject]@{ code = -32602; message = 'Invalid params: path (from a SearchDocs result) is required' } + } + $Doc = Get-CippDoc -Path $DocPath + return [ordered]@{ + content = @(@{ type = 'text'; text = ($Doc | ConvertTo-Json -Depth 10 -Compress) }) + isError = [bool]$Doc.error + } + } 'ExecTool' { $TargetName = [string]$ArgHash['name'] if ([string]::IsNullOrWhiteSpace($TargetName)) { diff --git a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 index d46ae49a96bb5..d31069a4f9207 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 @@ -54,6 +54,7 @@ function New-CIPPBackup { 'Extensions' 'WebhookRules' 'ScheduledTasks' + 'DeltaQueries' 'TenantProperties' 'TenantGroups' 'TenantGroupMembers' diff --git a/Modules/CIPPCore/Public/Remove-CIPPAzDataTableEntity.ps1 b/Modules/CIPPCore/Public/Remove-CIPPAzDataTableEntity.ps1 index 9e1989641774f..a363f190fdaf4 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPAzDataTableEntity.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPAzDataTableEntity.ps1 @@ -11,6 +11,9 @@ function Remove-CIPPAzDataTableEntity { Kept as a wrapper for consistency with the other CIPP table helpers and to default MaxRetries to 3 for throttled requests. + + On TableNotFound, invalidates the CreateTable cache, recreates the table, and + retries once so a stale CIPPEnsuredTables entry cannot permanently break deletes. #> [CmdletBinding()] param( @@ -20,6 +23,22 @@ function Remove-CIPPAzDataTableEntity { [int]$MaxRetries = 3 ) - $PSBoundParameters['MaxRetries'] = $MaxRetries - Remove-AzDataTableLargeEntity @PSBoundParameters + $Parameters = @{} + $PSBoundParameters + $Parameters['MaxRetries'] = $MaxRetries + $null = $Parameters.Remove('ErrorAction') + + try { + Remove-AzDataTableLargeEntity @Parameters -ErrorAction Stop + } catch { + if ($script:CIPPRepairingTable -or -not (Test-CIPPTableNotFound $_)) { + throw + } + $script:CIPPRepairingTable = $true + try { + Repair-CIPPTable -Context $Context + Remove-AzDataTableLargeEntity @Parameters -ErrorAction Stop + } finally { + $script:CIPPRepairingTable = $false + } + } } diff --git a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 index 6b03232ea5746..c4580f5b99a97 100644 --- a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 @@ -28,6 +28,9 @@ function Set-CIPPIntunePolicy { } try { + if ([string]::IsNullOrWhiteSpace($RawJSON)) { + throw "The template contains no policy JSON (RAWJson is empty). The stored template row is corrupt, or a same-named duplicate row shadowed the one selected. Delete the broken copy of this template and recreate it." + } switch ($TemplateType) { 'AppProtection' { $PlatformType = 'deviceAppManagement' @@ -144,6 +147,10 @@ function Set-CIPPIntunePolicy { $PlatformType = 'deviceManagement' $TemplateTypeURL = 'deviceConfigurations' $PolicyFile = $RawJSON | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace($DisplayName)) { $DisplayName = $PolicyFile.displayName ?? $PolicyFile.name } + if ([string]::IsNullOrWhiteSpace($DisplayName)) { + throw "This device configuration template has no name - the template's Displayname column and the payload's displayName are both empty. Recreate the template." + } $Null = $PolicyFile | Add-Member -MemberType NoteProperty -Name 'description' -Value "$Description" -Force $null = $PolicyFile | Add-Member -MemberType NoteProperty -Name 'displayName' -Value $DisplayName -Force $CheckExististing = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter @@ -172,6 +179,9 @@ function Set-CIPPIntunePolicy { $PlatformType = 'deviceManagement' $TemplateTypeURL = 'configurationPolicies' $DisplayName = Get-CIPPIntunePolicyName -TemplateType 'Catalog' -RawJSON $RawJSON -DisplayName $DisplayName + if ([string]::IsNullOrWhiteSpace($DisplayName)) { + throw "This Settings Catalog template has no name - the payload's 'name' and the template's Displayname column are both empty. The stored template row is corrupt (often a duplicate created by a re-import); delete and recreate it." + } if ($ReusableSettings) { Write-Verbose "Catalog: ReusableSettings count $($ReusableSettings.Count)" Write-Verbose ('Catalog: ReusableSettings detail ' + ($ReusableSettings | ConvertTo-Json -Depth 5 -Compress)) diff --git a/Modules/CIPPCore/Public/Set-CIPPMobileDevice.ps1 b/Modules/CIPPCore/Public/Set-CIPPMobileDevice.ps1 index 9726efad1b3f1..6aced131c0c7c 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMobileDevice.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMobileDevice.ps1 @@ -9,6 +9,20 @@ function Set-CIPPMobileDevice( [string]$APIName = 'Mobile Device' ) { + # Delete is evaluated first: the caller sends Quarantine 'false' alongside Delete 'true', so + # testing Quarantine up front would allow the device and return before ever reaching the removal. + try { + if ($Delete -eq 'true') { + New-ExoRequest -tenant $TenantFilter -cmdlet 'Remove-MobileDevice' -cmdParams @{Identity = $Guid; Confirm = $false } -UseSystemMailbox $true + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted Active Sync Device for $UserId" -Sev 'Info' + return "Deleted Active Sync Device for $UserId" + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to delete Mobile Device $($Guid): $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return "Failed to delete Mobile Device $($Guid): $($ErrorMessage.NormalizedError)" + } + try { if ($Quarantine -eq 'false') { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CASMailbox' -cmdParams @{Identity = $UserId; ActiveSyncAllowedDeviceIDs = @{'@odata.type' = '#Exchange.GenericHashTable'; add = $DeviceId } } @@ -29,16 +43,4 @@ function Set-CIPPMobileDevice( return "Failed to Block Active Sync Device for $($UserId): $($ErrorMessage.NormalizedError)" } } - - try { - if ($Delete -eq 'true') { - New-ExoRequest -tenant $TenantFilter -cmdlet 'Remove-MobileDevice' -cmdParams @{Identity = $Guid; Confirm = $false } -UseSystemMailbox $true - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted Active Sync Device for $UserId" -Sev 'Info' - return "Deleted Active Sync Device for $UserId" - } - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to delete Mobile Device $($Guid): $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage - return "Failed to delete Mobile Device $($Guid): $($ErrorMessage.NormalizedError)" - } } diff --git a/Modules/CIPPCore/Public/Update-CIPPAzDataTableEntity.ps1 b/Modules/CIPPCore/Public/Update-CIPPAzDataTableEntity.ps1 new file mode 100644 index 0000000000000..265c53fd11c7c --- /dev/null +++ b/Modules/CIPPCore/Public/Update-CIPPAzDataTableEntity.ps1 @@ -0,0 +1,94 @@ +function Update-CIPPAzDataTableEntity { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Updates entities that already exist in an Azure Table, without creating missing ones. + .DESCRIPTION + Thin wrapper around Update-AzDataTableEntity (AzBobbyTables), which merges into or + replaces existing entities and fails for entities that do not exist. Use it instead + of Add-CIPPAzDataTableEntity's UpsertMerge when recreating a concurrently deleted + row would corrupt the table (e.g. flag stamps on rows another worker may be deleting). + + Not split-aware: this writes to the physical row only. Merging small scalar + properties is safe even on a split entity (scalars live on the base row, which keeps + the logical RowKey), but rewriting a property that may have been chunked for size + must go through Update-AzDataTableLargeEntity or Add-CIPPAzDataTableEntity, or the + stale chunks survive and corrupt reassembly on read. + + Kept in the style of the other CIPP table helpers: defaults MaxRetries to 3 for + throttled requests and strips null-valued properties, which the table service cannot + store and the binary module rejects. + #> + [CmdletBinding()] + param( + $Context, + $Entity, + [ValidateSet('UpdateMerge', 'UpdateReplace')] + [string]$OperationType = 'UpdateMerge', + [switch]$Force, + [int]$MaxRetries = 3 + ) + + if ($null -eq $Context) { + throw 'Context parameter cannot be null' + } + + if ($null -eq $Entity) { + Write-Warning 'Entity parameter is null - nothing to process' + return + } + + $Entities = [System.Collections.Generic.List[object]]::new() + foreach ($SingleEnt in @($Entity)) { + if ($null -eq $SingleEnt) { + Write-Warning 'Skipping null entity' + continue + } + + # Remove null-valued properties before handing the entity to the binary module + if ($SingleEnt -is [hashtable]) { + if ($SingleEnt.Count -eq 0) { + Write-Warning 'Skipping empty hashtable entity' + continue + } + foreach ($key in @($SingleEnt.Keys)) { + if ($null -eq $SingleEnt[$key]) { + $SingleEnt.Remove($key) + } + } + } elseif ($SingleEnt -is [PSCustomObject]) { + if (($SingleEnt.PSObject.Properties | Measure-Object).Count -eq 0) { + Write-Warning 'Skipping empty PSCustomObject entity' + continue + } + $propsToRemove = [System.Collections.Generic.List[string]]::new() + foreach ($prop in $SingleEnt.PSObject.Properties) { + if ($null -eq $prop.Value) { + $propsToRemove.Add($prop.Name) + } + } + foreach ($propName in $propsToRemove) { + $SingleEnt.PSObject.Properties.Remove($propName) + } + } + + $Entities.Add($SingleEnt) + } + + if ($Entities.Count -eq 0) { + return + } + + $Parameters = @{ + Context = $Context + Entity = $Entities.ToArray() + OperationType = $OperationType + MaxRetries = $MaxRetries + } + if ($Force) { + $Parameters.Force = $Force + } + + Update-AzDataTableEntity @Parameters -ErrorAction Stop +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 index 0fcd2cd9f808a..b3c720bcfe7e1 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1 @@ -28,14 +28,32 @@ function Set-CIPPDBCacheSPOTenant { $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter -SkipCache - if ($SPOTenant) { - $SPOTenantArray = @($SPOTenant) - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenant' -Data $SPOTenantArray -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint Online tenant configuration' -sev Debug + # An empty response is a failure too: this collection only runs for SharePoint-licensed + # tenants, so there is always a configuration object to return. Falling through quietly + # left the stored count row untouched and indistinguishable from a successful run. + if (-not $SPOTenant) { + throw 'The SharePoint admin endpoint returned no tenant configuration' } + + $SPOTenantArray = @($SPOTenant) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenant' -Data $SPOTenantArray -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint Online tenant configuration' -sev Debug $SPOTenant = $null } catch { + # A tenant with no SharePoint consent is not a failed collection - it is a tenant nobody has + # consented yet, and it will answer 401 every night until that changes. Failing the activity + # for it would bury genuine failures under a permanent one, so record it and move on. The + # data still reads as stale on the dashboard, because it is. + if ($_.Exception.Data['SPOAccessDenied']) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $_.Exception.Message -sev Warning + return + } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SPO tenant configuration: $($_.Exception.Message)" -sev Error + # Anything else is unexpected, so let the caller count it. Swallowing left + # Invoke-CIPPDBCacheCollection reporting 'N succeeded, 0 failed' and the queue Completed + # while the count row silently kept its old timestamp. + throw } } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 index 65b6b242fa9bc..7293f528b2e6f 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1 @@ -30,21 +30,33 @@ function Set-CIPPDBCacheSPOTenantSyncClientRestriction { $SPOTenant = Get-CIPPSPOTenant -TenantFilter $TenantFilter - if ($SPOTenant) { - $SyncRestriction = [PSCustomObject]@{ - TenantRestrictionEnabled = $SPOTenant.TenantRestrictionEnabled - AllowedDomainList = $SPOTenant.AllowedDomainList - BlockMacSync = $SPOTenant.BlockMacSync - ConditionalAccessPolicy = $SPOTenant.ConditionalAccessPolicy - TenantFilter = $TenantFilter - } - $Data = @($SyncRestriction) - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenantSyncClientRestriction' -Data $Data -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint sync client restriction' -sev Debug + # An empty response is a failure too - see Set-CIPPDBCacheSPOTenant. + if (-not $SPOTenant) { + throw 'The SharePoint admin endpoint returned no tenant configuration' } + + $SyncRestriction = [PSCustomObject]@{ + TenantRestrictionEnabled = $SPOTenant.TenantRestrictionEnabled + AllowedDomainList = $SPOTenant.AllowedDomainList + BlockMacSync = $SPOTenant.BlockMacSync + ConditionalAccessPolicy = $SPOTenant.ConditionalAccessPolicy + TenantFilter = $TenantFilter + } + $Data = @($SyncRestriction) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SPOTenantSyncClientRestriction' -Data $Data -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint sync client restriction' -sev Debug $SPOTenant = $null } catch { + # Missing SharePoint consent is a tenant state, not a collection failure - see + # Set-CIPPDBCacheSPOTenant. + if ($_.Exception.Data['SPOAccessDenied']) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $_.Exception.Message -sev Warning + return + } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SPO sync client restriction: $($_.Exception.Message)" -sev Error + # Anything else is unexpected, so let the caller count it. + throw } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCippDocs.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCippDocs.ps1 new file mode 100644 index 0000000000000..298a59d63351d --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCippDocs.ps1 @@ -0,0 +1,52 @@ +function Invoke-ListCippDocs { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Core.Read + .SYNOPSIS + Search the CIPP documentation, or fetch one documentation page in full. + .DESCRIPTION + Searches the GitBook documentation shipped with this build and returns matching sections, + each with an excerpt and links back to docs.cipp.app and to the file on GitHub. Pages under + user-documentation also report the CIPP route they document, so a screen can be traced to + its docs and back. + + Pass path on its own to list the pages under a documentation subtree or a CIPP route, or + with full=true to return one page's entire text. This backs the SearchDocs and GetDoc MCP + tools and is available to the UI and API clients on the same terms. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Headers = $Request.Headers + + # Keywords or a plain-language question, e.g. 'how do I set up GDAP'. + $Query = $Request.Query.query ?? $Request.Body.query + # A documentation subtree ('user-documentation/identity') or a CIPP route + # ('/identity/administration/users'). + $Path = $Request.Query.path ?? $Request.Body.path + # Return the whole page rather than matching sections. Requires path. + $Full = [bool]($Request.Query.full ?? $Request.Body.full) + # Maximum results to return (default 8, max 25). + $Limit = ($Request.Query.limit ?? $Request.Body.limit) -as [int] + + try { + if ($Full) { + if (-not $Path) { throw 'path is required when full=true.' } + $Result = Get-CippDoc -Path $Path + } else { + $Result = Find-CippDoc -Query ([string]$Query) -Path ([string]$Path) -Limit $Limit + } + $StatusCode = [HttpStatusCode]::OK + } catch { + Write-LogMessage -API 'ListCippDocs' -message "Documentation search failed: $($_.Exception.Message)" -sev Error -headers $Headers + $StatusCode = [HttpStatusCode]::InternalServerError + $Result = @{ error = $_.Exception.Message } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Result + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListPartnerTenantInfo.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListPartnerTenantInfo.ps1 new file mode 100644 index 0000000000000..2d41115a34cd2 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListPartnerTenantInfo.ps1 @@ -0,0 +1,37 @@ +function Invoke-ListPartnerTenantInfo { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Core.Read + .DESCRIPTION + Reports whether the CIPP host tenant is a Microsoft Partner tenant, so the frontend can + decide whether partner-only flows (GDAP onboarding, reseller invites, GDAP permission + checks) apply to this instance. + + Marked AnyTenant deliberately. This answers a question about the CIPP instance, not + about a tenant the caller wants to act on, and Get-CippPartnerTenantInfo pins the lookup + to $env:TenantID. Without the flag, Test-CIPPAccess falls back to $env:TenantID as the + tenant filter and denies any custom role that blocks the partner tenant, which silently + greys out partner-only UI for roles that are otherwise fully permitted. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + try { + $StatusCode = [HttpStatusCode]::OK + $Body = Get-CippPartnerTenantInfo + } catch { + Write-LogMessage -API 'ListPartnerTenantInfo' -message "Failed to retrieve partner tenant info: $($_.Exception.Message)" -LogData (Get-CippException -Exception $_) -sev 'Error' + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ + error = $_.Exception.Message + details = $_.Exception + } + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 index bd02099c08964..24cc7bd2de1d6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 @@ -80,7 +80,14 @@ Function Invoke-ExecExtensionTest { $Results = [pscustomobject]@{'Results' = 'Successfully Connected to HIBP' } } 'GitHub' { - $GitHubResponse = Invoke-GitHubApiRequest -Method 'GET' -Path 'user' -ReturnHeaders + # NoFallback: the test must judge the configured token itself - the anonymous + # function-app fallback would turn a rejected PAT into a false success. + try { + $GitHubResponse = Invoke-GitHubApiRequest -Method 'GET' -Path 'user' -ReturnHeaders -NoFallback + } catch { + $Results = [pscustomobject]@{ 'Results' = "GitHub rejected the configured API token: $($_.Exception.Message). Check that the API key is valid and has not expired, then try again." } + break + } if ($GitHubResponse.login) { if ($GitHubResponse.Headers.'x-oauth-scopes') { $Results = [pscustomobject]@{ 'Results' = "Successfully connected to GitHub user: $($GitHubResponse.login) with scopes: $($GitHubResponse.Headers.'x-oauth-scopes')" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 index 3028c8616c640..f27909e8ff085 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-ExecMcp.ps1 @@ -82,7 +82,7 @@ function Invoke-ExecMcp { name = 'CIPP' version = $Request.Headers.'X-CIPP-Version' ?? 'unknown' } - instructions = 'CIPP is a gateway to the read-only CIPP API. Five tools are exposed: ListTenants (enumerate managed tenants; most tools need a tenantFilter — use the tenant''s defaultDomainName), ListGraphRequest (proxy an arbitrary Microsoft Graph GET), SearchTools (browse or keyword-search the full tool catalog), GetToolInfo (fetch a tool''s input schema), and ExecTool (run any discovered tool by name). Typical flow: ListTenants -> SearchTools -> GetToolInfo -> ExecTool.' + instructions = 'CIPP is a gateway to the read-only CIPP API. Seven tools are exposed: ListTenants (enumerate managed tenants; most tools need a tenantFilter — use the tenant''s defaultDomainName), ListGraphRequest (proxy an arbitrary Microsoft Graph GET), SearchTools (browse or keyword-search the full tool catalog), GetToolInfo (fetch a tool''s input schema), ExecTool (run any discovered tool by name), SearchDocs (search the CIPP documentation) and GetDoc (fetch one documentation page in full). Typical flow for data: ListTenants -> SearchTools -> GetToolInfo -> ExecTool. For questions about how CIPP works or how to configure it, start with SearchDocs rather than guessing.' } } 'ping' { $Result = @{} } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 index b7ee98fb8848e..d7d7b4941a82d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 @@ -153,7 +153,7 @@ function Invoke-ListRooms { mailNickname = $Room.Alias accountDisabled = $Room.AccountDisabled hiddenFromAddressListsEnabled = $Room.HiddenFromAddressListsEnabled - isDirSynced = $RoomMailbox.IsDirSynced + isDirSynced = $Room.IsDirSynced # Room Booking Settings bookingType = $PlaceDetails.BookingType diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 index 305c4e790098a..33a20da436103 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 @@ -38,7 +38,13 @@ function Invoke-AddPolicy { if (-not $reusableSettings -or $reusableSettings.Count -eq 0) { try { $templatesTable = Get-CippTable -tablename 'templates' - $templateEntity = Get-CIPPAzDataTableEntity @templatesTable -Filter "PartitionKey eq 'IntuneTemplate' and RowKey eq '$($Request.Body.TemplateID ?? $Request.Body.TemplateId ?? $Request.Body.TemplateGuid ?? $Request.Body.TemplateGUID)'" | Select-Object -First 1 + # The deploy drawer and wizard send the chosen row's GUID as TemplateList.value, not + # as TemplateID. Template display names are not unique - re-imports create same-named + # twins - so resolving by display name below can land on a different row than the one + # the user picked. The selected RowKey must win whenever the request carries one. + # String rather than Guid: built-in templates are stored with their filename as RowKey. + $SelectedTemplateId = ConvertTo-CIPPODataFilterValue -Value ($Request.Body.TemplateID ?? $Request.Body.TemplateId ?? $Request.Body.TemplateGuid ?? $Request.Body.TemplateGUID ?? $Request.Body.TemplateList.value) -Type String + $templateEntity = Get-CIPPAzDataTableEntity @templatesTable -Filter "PartitionKey eq 'IntuneTemplate' and RowKey eq '$SelectedTemplateId'" | Select-Object -First 1 if (-not $templateEntity -and $DisplayName) { $templateEntity = Get-CIPPAzDataTableEntity @templatesTable -Filter "PartitionKey eq 'IntuneTemplate'" | Where-Object { ($_.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue).Displayname -eq $DisplayName } | Select-Object -First 1 } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 index 72869201c555c..f7cc6f7a511dc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 @@ -33,20 +33,34 @@ function Invoke-ListIntuneTemplates { $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) if ($Request.query.View) { $Templates = $RawTemplates | ForEach-Object { + $Row = $_ try { - $JSONData = $_.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + $JSONData = $Row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + if ($null -eq $data) { throw 'RAWJson is empty or not valid JSON' } $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force - $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force - $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $_.Package -Force - $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($_.SHA)) -Force - $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $_.Source -Force + $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $Row.RowKey -Force + $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $Row.Package -Force + $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($Row.SHA)) -Force + $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $Row.Source -Force $data | Add-Member -NotePropertyName 'reusableSettings' -NotePropertyValue $JSONData.ReusableSettings -Force $data } catch { - + # A row that fails to parse used to be dropped from this list entirely, so a corrupt + # template stayed selectable in the deploy pickers (which use the raw list) while + # being invisible here. Surface a stub so the broken row can be found and deleted. + [PSCustomObject]@{ + displayName = "$($JSONData.Displayname ?? $Row.RowKey) [corrupt template: $($_.Exception.Message)]" + description = 'This template row failed to parse and cannot be deployed. Delete it from the template list and recreate it.' + Type = $JSONData.Type + GUID = $Row.RowKey + package = $Row.Package + isSynced = (![string]::IsNullOrEmpty($Row.SHA)) + source = $Row.Source + corrupt = $true + } } } | Sort-Object -Property displayName diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ListSafeLinksPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ListSafeLinksPolicy.ps1 index e977907af7a17..eee5d242c0a53 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ListSafeLinksPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Safe-Links-Policy/Invoke-ListSafeLinksPolicy.ps1 @@ -82,6 +82,8 @@ Function Invoke-ListSafeLinksPolicy { Description = $policy.AdminDisplayName IsBuiltIn = ($null -ne $matchingBuiltInRule) IsValid = $policy.IsValid + WhenCreated = $policy.WhenCreated + WhenChanged = $policy.WhenChanged ConfigurationStatus = if ($associatedRule) { "Complete" } else { "Policy Only (Missing Rule)" } } $Output.Add($OutputItem) @@ -121,6 +123,9 @@ Function Invoke-ListSafeLinksPolicy { ExceptIfRecipientDomainIs = $rule.ExceptIfRecipientDomainIs Description = $rule.Comments IsBuiltIn = $false + IsValid = $rule.IsValid + WhenCreated = $rule.WhenCreated + WhenChanged = $rule.WhenChanged ConfigurationStatus = "Rule Only (Missing Policy: $($rule.SafeLinksPolicy))" } $Output.Add($OutputItem) @@ -162,6 +167,9 @@ Function Invoke-ListSafeLinksPolicy { ExceptIfRecipientDomainIs = $builtInRule.ExceptIfRecipientDomainIs Description = $builtInRule.Comments IsBuiltIn = $true + IsValid = $builtInRule.IsValid + WhenCreated = $builtInRule.WhenCreated + WhenChanged = $builtInRule.WhenChanged ConfigurationStatus = "Built-In Rule Only (No Associated Policy)" } $Output.Add($OutputItem) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 index 8f6edf979b7b9..58fd78d7854d4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 @@ -11,6 +11,7 @@ Function Invoke-ListSharepointQuota { param($Request, $TriggerMetadata) # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter + $GeoLocations = @() if ($TenantFilter -eq 'AllTenants') { $UsedStoragePercentage = 'Not Supported' @@ -20,10 +21,27 @@ Function Invoke-ListSharepointQuota { $extraHeaders = @{ 'Accept' = 'application/json' } + # StorageQuotas returns one row per geo location: on a Multi-Geo tenant this is a + # collection, on every other tenant a single row. Used storage is therefore the sum + # across geos, while TenantStorageMB is the shared tenant pool repeated identically + # on every row and must be taken once rather than summed. $SharePointQuota = New-GraphGetRequest -extraHeaders $extraHeaders -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -uri "$($SharePointInfo.AdminUrl)/_api/StorageQuotas()?api-version=1.3.2" $GeoUsedStorageMB = ($SharePointQuota.GeoUsedStorageMB | Measure-Object -Sum).Sum $TenantStorageMB = $SharePointQuota.TenantStorageMB | Select-Object -First 1 + # Per-geo detail so a Multi-Geo tenant can see where the used storage actually sits + # rather than only a tenant-wide total. The API types every figure as a string, so + # cast here and let callers work with numbers. + $GeoLocations = @(foreach ($Geo in @($SharePointQuota)) { + if ($null -eq $Geo) { continue } + [PSCustomObject]@{ + GeoLocation = $Geo.GeoLocation + GeoUsedStorageMB = [double]($Geo.GeoUsedStorageMB ?? 0) + GeoAllocatedStorageMB = [double]($Geo.GeoAllocatedStorageMB ?? 0) + GeoAvailableStorageMB = [double]($Geo.GeoAvailableStorageMB ?? 0) + } + }) + if ($TenantStorageMB) { $UsedStoragePercentage = [int](($GeoUsedStorageMB / $TenantStorageMB) * 100) } @@ -37,6 +55,7 @@ Function Invoke-ListSharepointQuota { TenantStorageMB = $TenantStorageMB Percentage = $UsedStoragePercentage Dashboard = "$($UsedStoragePercentage) / 100" + GeoLocations = @($GeoLocations) } $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 index 116569ec7ed28..7c70a742e9b2e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSites.ps1 @@ -104,8 +104,11 @@ function Invoke-ListSites { ownerPrincipalName = $SiteUsage.ownerPrincipalName lastActivityDate = $SiteUsage.lastActivityDate fileCount = $SiteUsage.fileCount - storageUsedInGigabytes = [math]::round($SiteUsage.storageUsedInBytes / 1GB, 2) - storageAllocatedInGigabytes = [math]::round($SiteUsage.storageAllocatedInBytes / 1GB, 2) + # Null, not 0, when the usage report has no row for this site: '0' reads as an + # authoritative "this site is empty" and is indistinguishable from a real empty + # site, which is exactly the confusion an absent usage report should not create. + storageUsedInGigabytes = if ($null -ne $SiteUsage.storageUsedInBytes) { [math]::round([double]$SiteUsage.storageUsedInBytes / 1GB, 2) } else { $null } + storageAllocatedInGigabytes = if ($null -ne $SiteUsage.storageAllocatedInBytes) { [math]::round([double]$SiteUsage.storageAllocatedInBytes / 1GB, 2) } else { $null } storageUsedInBytes = $SiteUsage.storageUsedInBytes storageAllocatedInBytes = $SiteUsage.storageAllocatedInBytes rootWebTemplate = $SiteUsage.rootWebTemplate diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 index 681107df31a54..992a798ecfc30 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 @@ -74,8 +74,13 @@ } } catch { - $ErrorMessage = "Failed to retrieve release information: $($_)" - throw $ErrorMessage + # A failed refresh shouldn't 500 the dialog when we still hold a cached catalog - serve + # stale releases and let the log carry the reason the refresh failed. + if (-not $Releases) { + $ErrorMessage = "Failed to retrieve release information: $($_)" + throw $ErrorMessage + } + Write-LogMessage -API 'GitHub' -tenant 'CIPP' -Sev 'Warning' -message "Failed to refresh GitHub release notes, serving cached releases instead. Error: $($_.Exception.Message)" } if (-not $Releases) { diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDevicePrepProfile.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDevicePrepProfile.ps1 index cfd22352bd49f..1febdcda205bf 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDevicePrepProfile.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDevicePrepProfile.ps1 @@ -28,7 +28,7 @@ function Invoke-CIPPStandardDevicePrepProfile { {"type":"switch","name":"standards.DevicePrepProfile.AllowDiagnostics","label":"Allow users to collect diagnostics","defaultValue":false} {"type":"textField","name":"standards.DevicePrepProfile.DeviceGroupName","label":"Device Security Group Name (wildcard match)","required":false} {"type":"switch","name":"standards.DevicePrepProfile.CreateNewGroup","label":"Create new group if group is not found","defaultValue":false} - {"type":"radio","name":"standards.DevicePrepProfile.AssignTo","label":"Policy Assignment","options":[{"label":"Do not assign","value":"none"},{"label":"All devices","value":"AllDevices"},{"label":"All users and devices","value":"AllDevicesAndUsers"}]} + {"type":"radio","name":"standards.DevicePrepProfile.AssignTo","label":"Policy Assignment","options":[{"label":"Do not assign","value":"none"},{"label":"All users (Device Preparation profiles deploy to the enrolling user, so device targets do not apply)","value":"AllDevicesAndUsers"}]} IMPACT High Impact ADDEDDATE @@ -72,6 +72,19 @@ function Invoke-CIPPStandardDevicePrepProfile { $AllowDiagnostics = if ($Settings.AllowDiagnostics -eq $true) { '1' } else { '0' } $AssignTo = $Settings.AssignTo.value ?? $Settings.AssignTo ?? 'none' + # Device Preparation profiles deploy to the enrolling user, so Intune only accepts group + # targets for them - the broad virtual targets leave the profile without an effective + # assignment. Both the assignment write and the comparison read the targets from here. + $AssignmentTarget = Get-CIPPIntuneAssignmentTarget -AssignTo $AssignTo -PolicyType 'DevicePrepProfile' + $AssignmentBody = if (@($AssignmentTarget.Targets).Count -gt 0) { + @{ assignments = @($AssignmentTarget.Targets | ForEach-Object { @{ target = $_ } }) } | ConvertTo-Json -Compress -Depth 10 + } + if ($AssignmentTarget.Unsupported) { + # A target this policy type cannot express is a configuration error the operator has to + # fix, and no branch below can act on it - say so whether or not anything else drifted. + Write-LogMessage -API 'Standards' -tenant $Tenant -message "DevicePrepProfile: $($AssignmentTarget.Unsupported)" -sev Warning + } + # Resolve device security group ID $DeviceGroupId = '' if (-not [string]::IsNullOrWhiteSpace($Settings.DeviceGroupName)) { @@ -311,6 +324,23 @@ function Invoke-CIPPStandardDevicePrepProfile { } } + # Read the assignment state alongside the settings. A profile whose settings match but whose + # assignment is missing is half-deployed - nobody gets it - and without this read that state + # is invisible, so no run could ever detect or repair it. + $AssignmentsMatch = $null + $AssignmentDetail = $null + if ($PolicyExists -and $AssignTo -ne 'none') { + try { + $ExistingAssignments = Get-CIPPIntunePolicyAssignments -PolicyId $ExistingPolicy.id -TemplateType 'Catalog' -TenantFilter $Tenant + $AssignmentDetail = Compare-CIPPIntuneAssignments -ExistingAssignments $ExistingAssignments -ExpectedAssignTo $AssignTo -PolicyType 'DevicePrepProfile' -TenantFilter $Tenant + # Unknown stays $null: a failed lookup is not a deviation. + $AssignmentsMatch = if ($AssignmentDetail.Unknown) { $null } else { $AssignmentDetail.Matched } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "DevicePrepProfile: Failed to read policy assignments: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } + } + $CurrentValue = [PSCustomObject]@{ PolicyExists = $PolicyExists DeploymentMode = [string]($CurrentParsed.DeploymentMode ?? '') @@ -337,25 +367,54 @@ function Invoke-CIPPStandardDevicePrepProfile { DeviceGroupId = $DeviceGroupId } - # Determine compliance - $StateIsCorrect = $PolicyExists + # A failed assignment lookup is unknown, not a deviation: leave the dimension out of the + # comparison entirely until it can be read, or drift records a deviation no run can clear. + if ($AssignTo -ne 'none' -and $null -ne $AssignmentsMatch) { + $CurrentValue | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue $AssignmentsMatch + $ExpectedValue | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue $true + if (-not $AssignmentsMatch) { + # Carry the actual delta into the report - "assignments differ" alone is unactionable + # when the portal looks correct. + $AssignmentReason = @($AssignmentDetail.Reasons) -join '; ' + if ($AssignmentReason) { + $CurrentValue | Add-Member -NotePropertyName 'assignmentDifferences' -NotePropertyValue $AssignmentReason + } + } + } + + # Determine compliance. The settings verdict stays separate from the assignment verdict so + # remediation can repair a wrong assignment in place instead of recreating the whole profile. + $SettingsAreCorrect = $PolicyExists if ($PolicyExists) { $PropertiesToCompare = @('DeploymentMode', 'DeploymentType', 'JoinType', 'AccountType', 'AllowSkip', 'AllowDiagnostics') foreach ($Prop in $PropertiesToCompare) { if ([string]$CurrentValue.$Prop -ne [string]$ExpectedValue.$Prop) { - $StateIsCorrect = $false + $SettingsAreCorrect = $false break } } - if ($StateIsCorrect -and [int]$CurrentValue.Timeout -ne $ExpectedValue.Timeout) { $StateIsCorrect = $false } - if ($StateIsCorrect -and $CurrentValue.CustomErrorMessage -ne $ExpectedValue.CustomErrorMessage) { $StateIsCorrect = $false } - if ($StateIsCorrect -and $CurrentValue.DeviceGroupId -ne $ExpectedValue.DeviceGroupId) { $StateIsCorrect = $false } + if ($SettingsAreCorrect -and [int]$CurrentValue.Timeout -ne $ExpectedValue.Timeout) { $SettingsAreCorrect = $false } + if ($SettingsAreCorrect -and $CurrentValue.CustomErrorMessage -ne $ExpectedValue.CustomErrorMessage) { $SettingsAreCorrect = $false } + if ($SettingsAreCorrect -and $CurrentValue.DeviceGroupId -ne $ExpectedValue.DeviceGroupId) { $SettingsAreCorrect = $false } } + $StateIsCorrect = $SettingsAreCorrect -and $AssignmentsMatch -ne $false # Remediate if ($Settings.remediate -eq $true) { if ($StateIsCorrect) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "DevicePrepProfile: Profile '$ProfileName' already correctly configured" -sev Info + } elseif ($SettingsAreCorrect) { + # Only the assignment differs. Repair it in place - recreating the profile would sever + # the enrollment-time device group linkage over a delta the /assign endpoint can fix. + try { + if ($AssignmentBody) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($ExistingPolicy.id)')/assign" -tenantid $Tenant -body $AssignmentBody -type POST + Write-LogMessage -API 'Standards' -tenant $Tenant -message "DevicePrepProfile: Repaired assignment for profile '$ProfileName' ($(@($AssignmentDetail.Reasons) -join '; '))" -sev Info + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "DevicePrepProfile: Failed to repair assignment for profile '$ProfileName': $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } } else { try { # Delete drifted policy before recreating @@ -368,39 +427,8 @@ function Invoke-CIPPStandardDevicePrepProfile { $NewPolicy = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -tenantid $Tenant -body $Body -type POST # Assign the policy if requested - if ($AssignTo -ne 'none' -and $NewPolicy.id) { - $AssignBody = switch ($AssignTo) { - 'AllDevices' { - @{ - assignments = @( - @{ - target = @{ - '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' - } - } - ) - } - } - 'AllDevicesAndUsers' { - @{ - assignments = @( - @{ - target = @{ - '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' - } - } - @{ - target = @{ - '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' - } - } - ) - } - } - } - if ($AssignBody) { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($NewPolicy.id)')/assign" -tenantid $Tenant -body ($AssignBody | ConvertTo-Json -Compress -Depth 10) -type POST - } + if ($NewPolicy.id -and $AssignmentBody) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($NewPolicy.id)')/assign" -tenantid $Tenant -body $AssignmentBody -type POST } Write-LogMessage -API 'Standards' -tenant $Tenant -message "DevicePrepProfile: Successfully deployed profile '$ProfileName'" -sev Info diff --git a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 index 8b372ec564f92..af1eb0037b5d7 100644 --- a/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 +++ b/Modules/CIPPTests/Public/Tests/SMB1001/Identity/Invoke-CippTestSMB1001_2_12.ps1 @@ -30,8 +30,9 @@ function Invoke-CippTestSMB1001_2_12 { $A = $Analyser | Where-Object { $_.Domain -eq $D.DomainName } | Select-Object -First 1 $K = $Dkim | Where-Object { $_.Domain -eq $D.DomainName } | Select-Object -First 1 $Spf = $A.ActualSPFRecord -match 'v=spf1' - $Dmarc = $A.DMARCRecord -match 'v=DMARC1' - $DmarcStrong = $A.DMARCRecord -match 'p=(reject|quarantine)' + # Domain Analyser exposes DMARC as DMARCPresent / DMARCFullPolicy / DMARCActionPolicy - there is no DMARCRecord property + $Dmarc = ($A.DMARCPresent -eq $true) -or ($A.DMARCFullPolicy -match 'v=DMARC1') + $DmarcStrong = ($A.DMARCActionPolicy -in @('Reject', 'Quarantine')) -or ($A.DMARCFullPolicy -match 'p\s*=\s*(reject|quarantine)') $DkimEnabled = ($K -and $K.Enabled -eq $true) $DomainIssues = @( if (-not $Spf) { 'no SPF' } diff --git a/Modules/CippExtensions/Public/GitHub/Invoke-GitHubApiRequest.ps1 b/Modules/CippExtensions/Public/GitHub/Invoke-GitHubApiRequest.ps1 index df7b1dec76a4d..85e61f6b9dec2 100644 --- a/Modules/CippExtensions/Public/GitHub/Invoke-GitHubApiRequest.ps1 +++ b/Modules/CippExtensions/Public/GitHub/Invoke-GitHubApiRequest.ps1 @@ -8,7 +8,10 @@ function Invoke-GitHubApiRequest { [Parameter()] $Body, [string]$Accept = 'application/vnd.github+json', - [switch]$ReturnHeaders + [switch]$ReturnHeaders, + # Skip the anonymous function-app fallback so token failures surface to the caller - + # the test endpoint needs the real verdict on the configured PAT, not a masked success. + [switch]$NoFallback ) $Table = Get-CIPPTable -TableName Extensionsconfig @@ -19,6 +22,19 @@ function Invoke-GitHubApiRequest { $Configuration = @{ Enabled = $false } } + function Invoke-GitHubFunctionAppRequest { + param($Method, $Path, $Body, $Accept) + $Action = @{ + Action = 'ApiCall' + Path = $Path + Method = $Method + Body = $Body + Accept = $Accept + } + $ActionBody = $Action | ConvertTo-Json -Depth 10 + (Invoke-RestMethod -Uri 'https://cippy.azurewebsites.net/api/ExecGitHubAction' -Method POST -Body $ActionBody -ContentType 'application/json').Results + } + if ($Configuration.Enabled) { $APIKey = Get-ExtensionAPIKey -Extension 'GitHub' $Headers = @{ @@ -54,18 +70,19 @@ function Invoke-GitHubApiRequest { return $Response } } catch { + # A bad or rate-limited PAT shouldn't take down read paths the function app can serve + # anonymously. Writes stay on the PAT - the function app would run them as its own + # identity, not the user's. + $StatusCode = $_.Exception.Response.StatusCode.value__ + if ($StatusCode -in 401, 403, 429) { + Write-LogMessage -API 'GitHub' -tenant 'CIPP' -Sev 'Error' -message "GitHub rejected the configured API token (status $StatusCode) for [$Method] $Path. Verify the GitHub integration API key is valid and has not expired. Error: $($_.Exception.Message)" + if ($Method -eq 'GET' -and -not $NoFallback) { + return Invoke-GitHubFunctionAppRequest -Method $Method -Path $Path -Body $Body -Accept $Accept + } + } throw $_.Exception.Message } } else { - $Action = @{ - Action = 'ApiCall' - Path = $Path - Method = $Method - Body = $Body - Accept = $Accept - } - $Body = $Action | ConvertTo-Json -Depth 10 - - (Invoke-RestMethod -Uri 'https://cippy.azurewebsites.net/api/ExecGitHubAction' -Method POST -Body $Body -ContentType 'application/json').Results + Invoke-GitHubFunctionAppRequest -Method $Method -Path $Path -Body $Body -Accept $Accept } } diff --git a/Modules/CippExtensions/Public/PwPush/Get-PwPushAccount.ps1 b/Modules/CippExtensions/Public/PwPush/Get-PwPushAccount.ps1 index 22c92dde601b3..036bc184444be 100644 --- a/Modules/CippExtensions/Public/PwPush/Get-PwPushAccount.ps1 +++ b/Modules/CippExtensions/Public/PwPush/Get-PwPushAccount.ps1 @@ -3,8 +3,23 @@ function Get-PwPushAccount { $ParsedConfig = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ErrorAction SilentlyContinue $Configuration = $ParsedConfig.PWPush if ($Configuration.Enabled -eq $true -and $Configuration.UseBearerAuth -eq $true) { - Set-PwPushConfig -Configuration $Configuration -FullConfiguration $ParsedConfig - Get-PushAccount + # The accounts endpoint only works on the hosted service with a Pro/Premium bearer token. + # Anything else fails or returns nothing - surface that as a placeholder row instead of + # letting the error escape (500) or returning null, which the frontend cannot render. + try { + Set-PwPushConfig -Configuration $Configuration -FullConfiguration $ParsedConfig + $Accounts = @(Get-PushAccount -ErrorAction Stop | Where-Object { $null -ne $_ }) + } catch { + Write-Information "Failed to retrieve PWPush accounts: $($_.Exception.Message)" + $Accounts = @() + } + if ($Accounts.Count -eq 0) { + return @(@{ + name = 'Could not retrieve accounts. Check that your API key is a valid bearer token for a Pro/Premium subscription on the hosted service, or disable Bearer Authentication.'; + id = '' + }) + } + return $Accounts } else { return @(@{ name = 'PWPush Pro is not enabled or configured. Make sure to save the configuration first.'; diff --git a/Shared/CIPPSharp/CippDocsIndex.cs b/Shared/CIPPSharp/CippDocsIndex.cs new file mode 100644 index 0000000000000..0e177c9fb1f8d --- /dev/null +++ b/Shared/CIPPSharp/CippDocsIndex.cs @@ -0,0 +1,673 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace CIPP +{ + /// + /// Host-scoped inverted index over the shipped CIPP documentation, backing the SearchDocs + /// and GetDoc MCP tools. + /// + /// This lives in C# for two reasons, both measured rather than assumed. Tokenising the + /// 2 MB corpus in PowerShell took 26 seconds, because the inner loop runs some 400k times + /// and PowerShell pays interpreter and call overhead on every iteration; the same work here + /// is well under a second. More importantly the index is static, so - exactly as with + /// TestDataCache - the DLL is loaded once per host and every PowerShell worker on that host + /// shares this one instance. A PowerShell $script: cache is per-runspace, so a worker pool + /// would have rebuilt the whole index once per worker, and warming it on a timer would only + /// ever have warmed the single runspace the timer happened to run in. + /// + /// The caller supplies pages and chunks already parsed and linked (that logic stays in + /// PowerShell, where it is cheap and readable); this class owns tokenisation, the postings + /// map, BM25 scoring, fuzzy vocabulary matching and excerpting. + /// + public static class DocsIndex + { + // ── BM25 parameters ── + private const double K1 = 1.2; + private const double B = 0.75; + + // Damping for expanded terms. A synonym or a typo-correction can promote a page but must + // never outrank a chunk that matched what the caller actually typed. + private const double SynonymWeight = 0.55; + private const double FuzzyWeight = 0.40; + + // ── State ── + private static readonly object _buildLock = new(); + private static volatile IndexData? _current; + + public sealed class PageRecord + { + public string RelativePath = ""; + public string Title = ""; + public string Description = ""; + public string Breadcrumb = ""; + public string Slug = ""; + public string? DocsUrl; + public string GitHubUrl = ""; + public string? AppPath; + public bool Published; + public List Headings = new(); + } + + internal sealed class ChunkRecord + { + public int PageIndex; + public string Heading = ""; + public string Anchor = ""; + public string Text = ""; + public int Length; + } + + internal sealed class IndexData + { + public string Key = ""; + public List Pages = new(); + public List Chunks = new(); + public Dictionary> Postings = + new(StringComparer.Ordinal); + public double AverageLength = 1; + } + + /// + /// Accumulates an index. Handed back to the caller as an instance rather than kept in a + /// static: a half-built index must never be reachable from another worker, and an + /// abandoned build (an exception mid-loop) has to collect rather than wedge the host. + /// + public sealed class Builder + { + internal readonly IndexData Data; + internal Builder(string key) { Data = new IndexData { Key = key }; } + + public int PageCount => Data.Pages.Count; + public int ChunkCount => Data.Chunks.Count; + + public int AddPage(string relativePath, string title, string description, + string breadcrumb, string slug, string? docsUrl, string gitHubUrl, string? appPath, + bool published) + { + Data.Pages.Add(new PageRecord + { + RelativePath = relativePath ?? "", + Title = title ?? "", + Description = description ?? "", + Breadcrumb = breadcrumb ?? "", + Slug = slug ?? "", + DocsUrl = string.IsNullOrWhiteSpace(docsUrl) ? null : docsUrl, + GitHubUrl = gitHubUrl ?? "", + AppPath = string.IsNullOrWhiteSpace(appPath) ? null : appPath, + Published = published + }); + return Data.Pages.Count - 1; + } + + /// + /// Adds one heading-delimited chunk. The page's title, description and slug words are + /// folded in at a boost weight rather than being repeated into the text by the caller: + /// a 20-section page would otherwise have its title tokenised 20 times over. + /// + public void AddChunk(int pageIndex, string? heading, string? anchor, string? text) + { + if (pageIndex < 0 || pageIndex >= Data.Pages.Count) return; + + var page = Data.Pages[pageIndex]; + heading ??= ""; + text ??= ""; + + var frequency = new Dictionary(StringComparer.Ordinal); + int length = 0; + + // Weights: what a chunk is *about* outranks a word it merely contains. + length += Accumulate(frequency, page.Title, 3); + length += Accumulate(frequency, heading, 3); + length += Accumulate(frequency, page.Description, 2); + length += Accumulate(frequency, page.Slug.Replace('/', ' ').Replace('-', ' '), 1); + length += Accumulate(frequency, text, 1); + + int chunkId = Data.Chunks.Count; + foreach (var pair in frequency) + { + if (!Data.Postings.TryGetValue(pair.Key, out var list)) + { + list = new List<(int, int)>(); + Data.Postings[pair.Key] = list; + } + list.Add((chunkId, pair.Value)); + } + + if (!string.IsNullOrEmpty(heading)) page.Headings.Add(heading); + + Data.Chunks.Add(new ChunkRecord + { + PageIndex = pageIndex, + Heading = heading, + Anchor = anchor ?? "", + Text = text, + Length = length + }); + } + + private static int Accumulate(Dictionary frequency, string text, int weight) + { + int added = 0; + foreach (var token in Tokenize(text)) + { + frequency.TryGetValue(token, out int current); + frequency[token] = current + weight; + added += weight; + } + return added; + } + } + + /// + /// A search's hits plus the total that matched before the per-page cap and limit. + /// Returned rather than exposed as a static counter: this class is shared by every + /// worker on the host, so a mutable static would be clobbered by concurrent searches. + /// + public sealed class SearchResult + { + public int MatchCount; + public SearchHit[] Hits = Array.Empty(); + public string[] Suggestions = Array.Empty(); + } + + /// Result row handed back to PowerShell, already shaped for the MCP response. + public sealed class SearchHit + { + public string Title = ""; + public string? Section; + public string Path = ""; + public string Excerpt = ""; + public string? DocsUrl; + public string GitHubUrl = ""; + public string? AppPath; + public string Breadcrumb = ""; + public bool Published; + public double Score; + } + + // ────────────────────────────────── Build ────────────────────────────────── + + /// True when an index for this key is already available on this host. + public static bool IsBuilt(string key) + { + var current = _current; + return current != null && string.Equals(current.Key, key, StringComparison.OrdinalIgnoreCase); + } + + /// Starts a new index build. The returned builder is the caller's to hold. + public static Builder BeginBuild(string key) => new Builder(key); + + /// + /// Publishes a completed index. The swap is a single reference assignment to a volatile + /// field, so a concurrent reader sees either the previous index or the new one, never a + /// half-populated one. Two workers racing to build simply duplicate the work and the last + /// to finish wins - both produce the same index. + /// + public static void CommitBuild(Builder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.Data.AverageLength = builder.Data.Chunks.Count > 0 + ? builder.Data.Chunks.Average(c => (double)c.Length) + : 1; + + lock (_buildLock) { _current = builder.Data; } + } + + public static void Clear() + { + lock (_buildLock) { _current = null; } + } + + public static int PageCount => _current?.Pages.Count ?? 0; + public static int ChunkCount => _current?.Chunks.Count ?? 0; + public static int TermCount => _current?.Postings.Count ?? 0; + + // ──────────────────────────────── Tokenising ──────────────────────────────── + + private static readonly HashSet StopWords = new(StringComparer.Ordinal) + { + "the","and","for","are","but","wa","were","been","being","have","ha","had", + "that","thi","these","those","with","from","into","onto","your","you","their", + "them","they","it","be","is","of","to","in","on","at","by","or","as", + "an","if","then","than","so","such","via","per","each","any","more","most", + "other","some","will","can","may","must","should","would","could","when","where", + "which","who","what","how","why","here","there","also","about","over","under", + "after","before","between","both","only","own","same","too","very","just","do", + "doe","did","done","get","got","make","made","use","used","using","want" + }; + + /// + /// The single tokenisation rule, shared by indexing and querying - the two must agree + /// exactly or a term indexed as 'standard' is never found by a query for 'Standards'. + /// + /// Compound identifiers are kept whole *and* split, because both spellings get searched: + /// 'ListUsers' yields listuser + list + user, 'Identity.User.ReadWrite' the whole string + /// plus its parts. Without the split a search for 'user permissions' misses a page that + /// only writes the role name; without the whole form, an exact search for the role name + /// ranks no better than one for 'user'. + /// + public static string[] Tokenize(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return Array.Empty(); + + var tokens = new List(); + int i = 0; + int length = text!.Length; + + while (i < length) + { + // Scan one raw word: letters, digits, and the identifier punctuation we keep. + while (i < length && !IsWordChar(text[i])) i++; + int start = i; + while (i < length && IsWordChar(text[i])) i++; + if (i == start) continue; + + var raw = text.Substring(start, i - start).Trim('.', '-', '_'); + if (raw.Length < 2) continue; + + bool compound = false; + bool hasLowerUpper = false; + for (int k = 0; k < raw.Length; k++) + { + char c = raw[k]; + if (c == '.' || c == '-' || c == '_') compound = true; + if (k > 0 && char.IsUpper(c) && char.IsLower(raw[k - 1])) hasLowerUpper = true; + } + + var whole = raw.ToLowerInvariant(); + AddStemmed(tokens, whole); + + if (!compound && !hasLowerUpper) continue; + + // Split camelCase, then on the identifier punctuation. + var spaced = new StringBuilder(raw.Length + 8); + for (int k = 0; k < raw.Length; k++) + { + char c = raw[k]; + if (k > 0 && char.IsUpper(c) && (char.IsLower(raw[k - 1]) || char.IsDigit(raw[k - 1]))) + spaced.Append(' '); + spaced.Append(c == '.' || c == '-' || c == '_' ? ' ' : c); + } + + foreach (var part in spaced.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (part.Length < 2) continue; + var lower = part.ToLowerInvariant(); + if (lower == whole) continue; + AddStemmed(tokens, lower); + } + } + + return tokens.ToArray(); + } + + private static bool IsWordChar(char c) => + char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_'; + + private static void AddStemmed(List tokens, string lower) + { + var stem = Stem(lower); + if (!StopWords.Contains(stem)) tokens.Add(stem); + } + + /// + /// Light stemming - plural 's'/'es' only. An aggressive stemmer conflates CIPP vocabulary + /// that has to stay distinct, and the corpus is small enough that recall is not the + /// problem the stemmer would be solving. + /// + public static string Stem(string token) + { + // Too short to suffix-strip without destroying the word ('ies' -> '', 'use' -> 'us'). + if (token.Length <= 4) return token; + if (token.EndsWith("ies", StringComparison.Ordinal)) + return string.Concat(token.AsSpan(0, token.Length - 3), "y"); + if (token.EndsWith("sses", StringComparison.Ordinal) || token.EndsWith("shes", StringComparison.Ordinal) + || token.EndsWith("ches", StringComparison.Ordinal) || token.EndsWith("xes", StringComparison.Ordinal)) + return token.Substring(0, token.Length - 2); + if (token.EndsWith("ss", StringComparison.Ordinal)) return token; + if (token.EndsWith("s", StringComparison.Ordinal)) return token.Substring(0, token.Length - 1); + return token; + } + + // ────────────────────────────────── Search ────────────────────────────────── + + /// + /// Ranks chunks for a query. come from the caller's + /// domain expansion map; fuzzy correction is applied here, only for primary terms the + /// corpus does not contain at all - a term that matched exactly needs no help, and + /// fuzzing it would drag in neighbours that dilute a perfectly good query. + /// + public static SearchResult Search(string[]? primaryTerms, string[]? synonymTerms, + string? pathFilter, int limit, int perPageCap = 2) + { + var empty = new SearchResult(); + var data = _current; + if (data == null || data.Chunks.Count == 0) return empty; + if (limit < 1) limit = 8; + + primaryTerms ??= Array.Empty(); + synonymTerms ??= Array.Empty(); + + var allowedPages = ResolvePathFilter(data, pathFilter); + if (allowedPages != null && allowedPages.Count == 0) return empty; + + var weighted = new Dictionary(StringComparer.Ordinal); + foreach (var term in primaryTerms) + if (!string.IsNullOrEmpty(term)) weighted[term] = 1.0; + + foreach (var term in synonymTerms) + if (!string.IsNullOrEmpty(term) && !weighted.ContainsKey(term)) + weighted[term] = SynonymWeight; + + foreach (var term in primaryTerms) + { + if (string.IsNullOrEmpty(term) || data.Postings.ContainsKey(term)) continue; + foreach (var near in FuzzyMatches(data, term, 3)) + if (!weighted.ContainsKey(near)) weighted[near] = FuzzyWeight; + } + + if (weighted.Count == 0) return empty; + + var scores = new Dictionary(); + var covered = new Dictionary>(); + double chunkCount = data.Chunks.Count; + + foreach (var (term, weight) in weighted) + { + if (!data.Postings.TryGetValue(term, out var postings)) continue; + + double documentFrequency = postings.Count; + double idf = Math.Log(1 + (chunkCount - documentFrequency + 0.5) / (documentFrequency + 0.5)); + + foreach (var (chunkId, frequency) in postings) + { + if (allowedPages != null && !allowedPages.Contains(data.Chunks[chunkId].PageIndex)) continue; + + double len = data.Chunks[chunkId].Length; + double denominator = frequency + K1 * (1 - B + B * (len / Math.Max(data.AverageLength, 1))); + double contribution = weight * idf * (frequency * (K1 + 1) / Math.Max(denominator, 0.0001)); + + scores.TryGetValue(chunkId, out double running); + scores[chunkId] = running + contribution; + + if (!covered.TryGetValue(chunkId, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + covered[chunkId] = set; + } + set.Add(term); + } + } + + if (scores.Count == 0) + { + return new SearchResult { Suggestions = Suggest(primaryTerms) }; + } + + // Coverage bonus: a chunk hitting three of the query's terms is answering the whole + // question; one hitting the same term three times is a page that just says it a lot. + double primaryCount = Math.Max(primaryTerms.Length, 1); + foreach (var chunkId in scores.Keys.ToArray()) + { + double coverage = covered[chunkId].Count / primaryCount; + scores[chunkId] *= 1 + 0.35 * Math.Min(coverage, 1.5); + } + + var hits = new List(); + var takenPerPage = new Dictionary(); + + foreach (var entry in scores.OrderByDescending(e => e.Value)) + { + if (hits.Count >= limit) break; + var chunk = data.Chunks[entry.Key]; + + // One page should not occupy the whole result set with five of its own sections. + takenPerPage.TryGetValue(chunk.PageIndex, out int taken); + if (taken >= perPageCap) continue; + takenPerPage[chunk.PageIndex] = taken + 1; + + hits.Add(BuildHit(data.Pages[chunk.PageIndex], chunk, Math.Round(entry.Value, 3), + primaryTerms.Concat(synonymTerms).ToArray())); + } + + return new SearchResult { MatchCount = scores.Count, Hits = hits.ToArray() }; + } + + private static HashSet? ResolvePathFilter(IndexData data, string? pathFilter) + { + if (string.IsNullOrWhiteSpace(pathFilter)) return null; + + var needle = pathFilter!.Replace('\\', '/').Trim().Trim('/').ToLowerInvariant(); + var allowed = new HashSet(); + + for (int i = 0; i < data.Pages.Count; i++) + { + var page = data.Pages[i]; + var slug = page.Slug.ToLowerInvariant(); + var appPath = (page.AppPath ?? "").Trim('/').ToLowerInvariant(); + + if (slug == needle || slug.StartsWith(needle + "/", StringComparison.Ordinal) + || slug.EndsWith("/" + needle, StringComparison.Ordinal) + || (appPath.Length > 0 && (appPath == needle + || appPath.StartsWith(needle + "/", StringComparison.Ordinal)))) + { + allowed.Add(i); + } + } + return allowed; + } + + /// Pages matching a path, for a path-only query with no keywords. + public static SearchResult ByPath(string pathFilter, int limit) + { + var data = _current; + if (data == null) return new SearchResult(); + + var allowed = ResolvePathFilter(data, pathFilter); + if (allowed == null || allowed.Count == 0) return new SearchResult(); + + var hits = new List(); + // Shortest slug first: the section index is a better first answer than a leaf page. + foreach (var pageIndex in allowed.OrderBy(p => data.Pages[p].Slug.Length).Take(limit)) + { + var intro = data.Chunks.FirstOrDefault(c => c.PageIndex == pageIndex) + ?? new ChunkRecord { PageIndex = pageIndex }; + hits.Add(BuildHit(data.Pages[pageIndex], intro, 0, Array.Empty())); + } + return new SearchResult { MatchCount = allowed.Count, Hits = hits.ToArray() }; + } + + private static SearchHit BuildHit(PageRecord page, ChunkRecord chunk, double score, string[] terms) + { + var fragment = string.IsNullOrEmpty(chunk.Anchor) ? "" : "#" + chunk.Anchor; + return new SearchHit + { + Title = page.Title, + Section = string.IsNullOrEmpty(chunk.Heading) ? null : chunk.Heading, + Path = page.RelativePath, + Excerpt = Excerpt(chunk.Text, terms), + DocsUrl = page.DocsUrl == null ? null : page.DocsUrl + fragment, + GitHubUrl = page.GitHubUrl + fragment, + AppPath = page.AppPath, + Breadcrumb = page.Breadcrumb, + Published = page.Published, + Score = score + }; + } + + // ─────────────────────────────── Fuzzy matching ─────────────────────────────── + + private static List FuzzyMatches(IndexData data, string token, int maxResults) + { + var results = new List<(string Term, double Distance, int Frequency)>(); + if (token.Length < 4) return new List(); + + int budget = token.Length >= 7 ? 2 : 1; + char first = token[0]; + + foreach (var candidate in data.Postings.Keys) + { + if (Math.Abs(candidate.Length - token.Length) > budget) + { + // A longer vocabulary term the query prefixes is still a good lead: + // 'conditional' should reach 'conditionalaccess'. + if (candidate.Length > token.Length && candidate.StartsWith(token, StringComparison.Ordinal)) + results.Add((candidate, 0.5, data.Postings[candidate].Count)); + continue; + } + if (candidate.Length == 0 || candidate[0] != first) continue; + + int distance = EditDistance(token, candidate, budget); + if (distance <= budget) results.Add((candidate, distance, data.Postings[candidate].Count)); + } + + return results + .OrderBy(r => r.Distance) + .ThenByDescending(r => r.Frequency) + .Take(maxResults) + .Select(r => r.Term) + .ToList(); + } + + /// Levenshtein distance, abandoning the row once it exceeds the ceiling. + public static int EditDistance(string first, string second, int ceiling = 2) + { + if (first.Length == 0) return second.Length; + if (second.Length == 0) return first.Length; + + var previous = new int[second.Length + 1]; + var current = new int[second.Length + 1]; + for (int j = 0; j <= second.Length; j++) previous[j] = j; + + for (int i = 1; i <= first.Length; i++) + { + current[0] = i; + int rowMinimum = current[0]; + for (int j = 1; j <= second.Length; j++) + { + int cost = first[i - 1] == second[j - 1] ? 0 : 1; + current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost); + if (current[j] < rowMinimum) rowMinimum = current[j]; + } + if (rowMinimum > ceiling) return ceiling + 1; + (previous, current) = (current, previous); + } + return previous[second.Length]; + } + + /// Closest real vocabulary terms, for the "did you mean" path on a zero-result query. + public static string[] Suggest(string[] terms, int maxResults = 5) + { + var data = _current; + if (data == null) return Array.Empty(); + + var suggestions = new List(); + foreach (var term in terms) + { + foreach (var near in FuzzyMatches(data, term, 2)) + { + if (suggestions.Count >= maxResults) break; + if (!suggestions.Contains(near)) suggestions.Add(near); + } + } + return suggestions.ToArray(); + } + + // ─────────────────────────────────── Pages ─────────────────────────────────── + + /// Fetches a page by repo-relative path, slug or app route, for GetDoc. + public static PageRecord? FindPage(string pathOrSlug) + { + var data = _current; + if (data == null || string.IsNullOrWhiteSpace(pathOrSlug)) return null; + + var needle = pathOrSlug.Replace('\\', '/').Trim().Trim('/').ToLowerInvariant(); + var withoutExtension = needle.EndsWith(".md", StringComparison.Ordinal) + ? needle.Substring(0, needle.Length - 3) : needle; + + PageRecord? bySlug = null, byApp = null, bySuffix = null; + foreach (var page in data.Pages) + { + var relative = page.RelativePath.ToLowerInvariant(); + if (relative == needle || relative == needle + ".md") return page; + + var slug = page.Slug.ToLowerInvariant(); + if (slug == withoutExtension) bySlug ??= page; + if ((page.AppPath ?? "").Trim('/').ToLowerInvariant() == withoutExtension) byApp ??= page; + if (relative.EndsWith("/" + withoutExtension + ".md", StringComparison.Ordinal)) bySuffix ??= page; + } + return bySlug ?? byApp ?? bySuffix; + } + + /// The full text of a page, reassembled from its chunks with headings restored. + public static string GetPageText(string relativePath) + { + var data = _current; + if (data == null) return ""; + + var builder = new StringBuilder(); + for (int i = 0; i < data.Pages.Count; i++) + { + if (!string.Equals(data.Pages[i].RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)) + continue; + + builder.Append("# ").AppendLine(data.Pages[i].Title); + foreach (var chunk in data.Chunks.Where(c => c.PageIndex == i)) + { + if (!string.IsNullOrEmpty(chunk.Heading)) + builder.AppendLine().Append("## ").AppendLine(chunk.Heading); + if (!string.IsNullOrEmpty(chunk.Text)) builder.AppendLine(chunk.Text); + } + break; + } + return builder.ToString().Trim(); + } + + public static PageRecord[] GetPages() => _current?.Pages.ToArray() ?? Array.Empty(); + + // ────────────────────────────────── Excerpts ────────────────────────────────── + + /// + /// A readable window of the chunk centred on the query's terms, so the caller can judge + /// relevance without a second call. Falls back to the opening sentence, which is a fair + /// summary of a section. + /// + private static string Excerpt(string text, string[] terms, int width = 320) + { + if (string.IsNullOrWhiteSpace(text)) return ""; + + var flat = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim(); + if (flat.Length <= width) return flat; + + int best = 0; + if (terms.Length > 0) + { + var lower = flat.ToLowerInvariant(); + int bestHits = -1; + // Sampled window starts, not every offset: the excerpt only needs to be + // representative, and this keeps a 25 KB section cheap to summarise. + for (int start = 0; start < flat.Length - 1; start += 40) + { + int span = Math.Min(width, lower.Length - start); + var slice = lower.Substring(start, span); + int hits = terms.Count(t => t.Length > 0 && slice.Contains(t, StringComparison.Ordinal)); + if (hits > bestHits) { bestHits = hits; best = start; } + } + if (bestHits <= 0) best = 0; + } + + if (best > 0) + { + int space = flat.LastIndexOf(' ', Math.Min(best, flat.Length - 1)); + if (space > 0) best = space + 1; + } + + var excerpt = flat.Substring(best, Math.Min(width, flat.Length - best)).Trim(); + return (best > 0 ? "..." : "") + excerpt + (best + width < flat.Length ? "..." : ""); + } + } +} diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index ba68cf80f0eb194effcb05ea4de6ba25b6d4b3f4..f66102fb695bf864c453fa3b9eb897f3457efaca 100644 GIT binary patch literal 64512 zcmd4434B!5**|{nGI!QYa%am%$U4a-GZR)7aDfPlC@Lt_09uKF3Zhps;1&{sEP|p) z;YC3dP~34xs}?O(v~}O8+6oAYw(hl}6}9;PKF_(6+^Fr__y7L>zt4X(&v~}g(y)QXnb7gS8UpknyRr&gSAPnp_~mlvpUOpiWBh*63r_WW?`ow?NZ zh^mU9(npB>;24towW$bE0lWgC5Cx2@(r;pr|NNT-Ir#L`#H59!O8<9-4oNKhHp1@7 zguw2FR1iP@4OK)4yv5M#@nhM)qbr2)x#T|3$GPZ+SyL~W1-jtx0FZHB9lt^GuR@4P z4K3N0$)MOb_{bUkTFP^9D5|TWW$FwYisY++&s+xI<}_qDREVRybA)({$`wEIO%)z- z{~YjE3L&{H`X7OwYHOhofqH~R6#hcBiV><>8?jZrjk!WZ`)HyOmXVIB%?2zDs7Av8 zBi-97Aw{-gFtkg-NEccte()*2!O$)PTA!#dtbeWI{JEi9D|(F^ck2XKbh{@MnjCgl zY`dJ;_2h$BoW=PTW~>E1Tv&8<7%n9fGWkG6Bxj;wl`bWa(Q9L=;K2mpb_Gn_oGg^? z1S3jvrib(hI8-bRts zu&0e8sbQmyBXPOjjy!2xuIM~eu7_U-jxlH%pi?|#(qMmN>0a!VE~R0#66DfNpJ`cS zzLgR+7}~vnrbk#PKk&&3gQ1-wes3$yd~(WYC1_`eUuZQjpPV#W3EI5@xc1oRYvCLH zDvpe|T-U88$BNA%>(JW?c(QzYC`ELFKaqMnE+IWtU(1agXmOU2GsJOJ6D)_e$sUHzNb zXr;{U>fh)E=->IU@}vGu>P9QU?){tM8*JO%zhe*VJi1$};D|r%-xT{_^zVTf;Jf-a z#s5$0jtu^Hb=O%kheDZ3<}fE5=!AouaIg~|?u17;;gJ+lnW;|H<^OH>!E15*+ zCyUhi$s%=rvPhkuEUIB|8(CDtzBaZGjn7ds;Yd1kZ{lq15K&WJ#s8m$E7h%mw#5y*3+ zl^~A@7(tE#uC`K642Jd)pcp}hGM{p0v=X!%0ptiG+uM_HoPHIVEgM#KYb~6)pSBjt zo@_1M#t_cbPsb3>(@)0`&eBiE5Y7>gA&%{?Y}rR6-Q2u16u2t$GmO~W4!q(Q7<0Tl z=3It$+p}}bp`83D?Uu6k-?dvBb11L>$(Tc#|4+soDvkeW%%Sr6Z^xWu57`i+M#MU#0v0x|q1TpN> z?{7m=y`3diG_MkwJiYFUI-9K1dWJdjf7mRaa_P5T6-R{tYGXNG?)m`^cAQ4EZ0y7(OVf^(*7od_gtSSs>apT*KUfIO=9Xdt11 zzok}R*c9~odWTi7JqAoGg#t={a#0BD$=jMp>N%M$C?0~qJ0z$`$9 zPKRd3XP5;tYhl<7n_)BWnrZ!>_WFh#b?}ED4Eqcy)lXNgUy^cx3a^4Hm8)8Q=0OFE zb~)c+o)K8xn3|PADFxb#QE7&KCdKiphCPXlQ2V(bdi0YmrKFFl)~hY+&H?hpFI_(N#8;{4`j~)$Wx;MYDRTtzznX5hR}M>B_*`TQ0FjYPXh~$ zNj880D(h8EM(Zo1<(YXhTB+a6v!^=-@~YRYrr+KnKQ-`sC*4`n02+Zb@Jnwi^?x&L zpGQ_s_L|{h3v;Al&j20GPt`>7t@8;j@S6plNfkIr{YfSToeL=X>$}5|{DX-y8pLfd zc6Q6e0>6!(&IOX4NhbAd3k4om>CT86emhHCbKpYs{)lPKA}SV%MdFc!H5-H(k4DTy zH0rn>i4@uwfjwv(^vy#1V&F5PMV+N-8fMXWhuSlw$;?TBPi(R-A?wA6b}1nx1NWop zMoO&9KqXsSiC5Y>hk_oybeT_jYL?n{*=|*5l)M4=owuC`QXM z#*{`X><+}MCETnq%bVl{UuxK&DW(!1=(Vl_W4LZ6q8pE6s?FSus$HJTF2x z7<)@UpcYFGCg7A)i>QPvNM>WWfXqCYsOXeod|C`yCdz##i3xcCT2C8zdcAthv_fkM zq%)#k=v@iS(lDDutEdZ1GONts%4jv*skWCwW?xOJrgvhr#?+%pvpQO9CQbgcuYstu zl&a6>-y1{B8i)7I5NjDUP;fHqxxQ0{zH@!{T8N^)>~ab)^0)sNoO4a=FT>&NuOLiz zki^0ukg`{ja@|0rE6h3@{6N&g5WEVAr=MpROga@Fv8?OBsa zvp%f%KB1Fs))zL}t06GamQYK&qi(MuUN0EC0Z6g67EskJ4_vVf0$9!(_D!TzXRkvr z_-v}EkXe`Zg2SCn)__wXZKzE>36Ix9xee8)*9*}&h1MTnpr^o~I2>UZA^nK|Eod+P zu+7(vPd)K^{FVKP??+K>K)kyUhR6th^enRnVII#FCnB-58mrgJPcQFnLpNaG3;_HV z!fC1sWH$l^6QO}91EF%M__#cht?2G@e$Hz}M@6L>wJmRB7V88`bYuKR%PvT* zTVb%oz6~&Qq@P)a!)qp9FK1Fs^sO^>CoYx?KZ%_7d42Znh*9e+Yzq0LYrMD`G7hEh z#hl?0^(YsVp_zRhH54$MNuv#QtQ)NaGX_^oy`)s09x$i)ymESQq})hDUZWL-fP5C! z8pc6m2er&Q;6O5}w~@fqEiYPwX8EX%-VI4!sx|vZsA2Wv@!OlgX+sNglIB^<^c*za zAVc>+Xkn?DN@k+9+24VcG(D}P-9{r>)bL3cWxJFT*)9#Io8fU2&BWCh{jx1yMr|?R zPh;$MwM3ag*$}(8L%D{%L|myV0n|C#j7PkJkk=Qj7+ZBYJ4IUdqB=Fnp-N48xcURx z=gE7u74wJQh7m?jYBWka(R`9fDX}(;IXd((-RNysNj(hMx~F0&s?8b*D{UBhNCi`f zo({`Uwib$yA&G>TQ_xVzgEou^#G&TchB1fmS%hOmARN<(-iDDzPwH(LQ*>@4bCFm+ zwUPT^A)xVEt)#cXi|Tqwmi5*OQ%X@#OYq_{r1HFM-;W6P0|0pg??eGbH0wbSol9gH zcf9ownBKm_opvp`eOpaklf58)p<(7GnLR_KY3ObyYb8oKCcZ35+++bYiq)6m<{4sCpy&DAbc3 zB(cyYQtj7u*Ljt69%CJnSZFFK-NK-gYQMg_=4+(+8`dO=g@Gt#p@*jGooA;b#FlU5 z@D}Z3h7_kZ{WW^TNE; z9B;yZlQ*$4s^i%tb*h?s8qrLT^PF%{NgPz7h8KlOyp@Gbc>Dmgu$~A21@cGw7b<}c^Yc1$W)kZvWEogY{-@-$!pSnARhb4O}h>VZ0Nx-0W0Wn_1 zTEXL94YFT8Ov&rL@}WbH1N}t*sj@!k*?kE5O2qA7+UGoqVj5BZ^)wRnw!GiDz|Uyj zRK7Ri<;p9TYy40wFBa8fQIEj`oV^V`MvZ}(B}BxqFdX6`(Dd?9As?Z9n4{&y5(CE8 zc?e)$+HZQJ{$1$e0+BlBDW4P?{)}dN^B|#d;%BEVN7HBO86Bo{)5jB)Pp0z}+;JY5 ze0U-y4G+BB4))-aWS=q)CJycFQ~qSeUuz;It=9C^oBmu|KSw-Pp`h_Y5oLQM$~NjQ z+l;~EX?^x-7!GFNMAr%V5^Y$0=mYY~pN7j?+X=KuSZt!K&A5 zR{-UuOFfE3w%d(j;tz5U=E}}J7&>|i$oBODVo=Rn7+o;-&>UBU@{ghXb9*bY@YR;@ zkeL=u!^-8wTrvTJ+pgXRkB^Cz82MLf=nC(X>syuh;a^ND?$XF?T(Ajbc(D^O8ceTrg45>03V9QKyqrtS++JkR=hd5} zZoh7PiyA!k4>U1dM*-uPEe5BfkZSX6VA=Tq$(DTBkPj@$mO>X^?!xO_cyAYemOW!kb8E_|vB$7y$Ge`$5$SGe#ME_{Ovf53(R#)Uua!e4aZZ@TbzT=<7B z{7V;pz=bPDm!EzY9(CcRF1*@>XI%JUE_{d!AL+u!b>bZuWAyqeHD(=-@sZ9BG(pSk zIFHfiV#MKR*R-x(lhJUeK(nw64P;*f2&nBPG&bR>FA((Fcv7R&84HhMfujCnvu_~G z1C1G#4^J#ic38Hj3M0COfg1}CzlmdkBap0(u{i+t+Xymyux8Q^CTcOq$VVx^y_3|v zyW>8o4wQ}*yi#m>c|Phv&+p?z&82c3rHZt;aBc34K@;JjE9$P=T0_PZb)9)kBkv8^l?(gFK@5-QdQ z2r`c&FUrA$)tMKRaBl9b6UWcYt@$+38}lMY`ka55l=Q;!$KXj9_gsX)@Gl<0-yZy7 z>JcbY0e{87ZvndHfKHz{3z#6&ScdL_8`#;GE_*o}5%15s?2#@%FPE%tm$@o~C?c*( zf5b1V(w9m^e6lJ7UYt2lamt$X$(q~?Q$BZ1dbuX+^973eFH}B082u}ddf!qM40hU= z;z3fWPskufnws??6w+S*Qmil)aIB9&FTIF}7}j`P?V$&L*X;IY_yRYijTBZJ$pP_0$l;pRSzxyWJz!gSWHsP%Tz6DcYdgpHP|7^X_)`KQ_53zk||)4-MWQBvp+Obs7Th0MTS_=()UBM=POO|y|S%LJ==-kITH@fX` zLa(~)T*W%O{;keAtds9^`STMy!&s-kTc?hEJ`!dF5T5!WiN83{r{rs-zsZ+fz z^EocpQM5fx3L9Jsc=)~Z-zu2x8H)3AR~(-+A7W31?l+riz-W1=%Z6SUOr37DcmthP zL7d}_mQ;7nK%-?$cTTO*vZ6aDX0*K6okLpzoW4g)MEdk=M@P}s!s+en5%Ou;Iv@v) zT_}?*4w$f+OAw6?uJPz}l(x+`NDms2K*RF-=sS5qSRY3bhWeITQxZtfoYovzp< zk+D;m_P&n#E#1}kbg7fX@oWH49kw@iSKdp?A^#aw{p0QZK#(i9c2|G5%Qi`z zj70nR1QtF5>mXCrXqpdu!baFjI~s-^hDqEIv+@yAo>h^BE>=`D_7s5G_B2dIgxdCd z4z;#FaHzLE$DyYU8)cB=mLayp2=TOG|A-Ky4ZBZ-c-x*~B;K~2!#UEV6_w)`QC6fg z{UJyn{!;o;N`DK-QQMy2P-}aV!+6_oIGiKJR!rI=%8EO-0^(_mh4kYAD#knA++^Bs zgPyCsyhkOMK(u5BNh~~jrP^=ruCuR8ha~NHbQix*;zBk-5(|$yDQ+|Ec$jmi^+A^% zlE}2~>aO#bE*+Az-`!pO*RHfkBGaM<*S@K{;)h*!NFvj^r@PKaT{{y=vz*5J7^B#BImdTslI-4#FYQY49m^>wQK zq3$|Zp6BY6B)rPE@F0>ZFQ%6aQtg|&>wiJ|C0v0dq3fA^ZAY5#S1@qQAQv6v(lI%q1}6Lc0cQ6O7El0v%@frb8O7^<*;t3;bIU(yI=K2w zJVN4?i_WJS-s0t5X}n>f{Vcmk&v;wDLu~B-<_E=vHz$j`o{HS@OTwr==V{5smydRI zuMA_KsGxCF-b*Z+dpLZxa0@d~OSg%PWP+|N8TIW0DGzu?>;+NgU#eL+x91&?6hed6 z^+rPD%PP~y2fh9JI%YIGO)1Phsxz^FmG0cZohs6wLiQ(V*x$jneF3>B)1g~X2A)kl z;#B0RvNKPaJdN{~@#u-^eH14rSv;p1NsS(OjHFIaI!2P6r>TRSxA8b<*co7RkLYwk zKGW5}epRRabj~CShjNt;&ONd#0_}ZuK)TsWp z4IH|3Z1q8Cl=97sbPek`%!n?N%%`iNu4H|Dfgtn5?tm|C_~f3J*SRN~5hG@G?O~0E zZ^)0C2y~?#Or^Y>c1WhBvI7xWY3Y2N=>%M9d1YEJy7$ivC#^hj*zm5j79g!8(&BQa zd{H?g*9nzdh)j)hk1f4paAHpR$2~SW5Ixbr##3L=+@^ff$MSeogTe=S=5MAE|OPls4pA~|LB)H7){i-4?<0;cjBG#n zlI$Rf-5=;&O!7{@(d+?W)YI9ZM)?)BU3HJP!Y0vH4uMiSWY~uytlh2E?iPr`XJE9u z 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 diff --git a/Tests/Alerts/Get-CIPPAlertHuntressRogueApps.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertHuntressRogueApps.Tests.ps1 new file mode 100644 index 0000000000000..fc9a6a1c20ee1 --- /dev/null +++ b/Tests/Alerts/Get-CIPPAlertHuntressRogueApps.Tests.ps1 @@ -0,0 +1,106 @@ +# Pester tests for Get-CIPPAlertHuntressRogueApps +# Covers the feed guard and describing matches from either rogue app list. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $AlertPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPAlertHuntressRogueApps.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $AlertPath) { throw 'Could not locate Get-CIPPAlertHuntressRogueApps.ps1 under Modules/' } + + # The real Config/MaliciousApps.json is read through $env:CIPPRootPath. + $env:CIPPRootPath = $RepoRoot + + function Invoke-RestMethod { param($Uri, $ErrorAction) } + function New-GraphBulkRequest { param($Requests, $tenantid) } + function Write-AlertTrace { param($cmdletName, $tenantFilter, $data) } + function Write-AlertMessage { param($tenant, $message) } + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + + . $AlertPath + + # An appId that is in Config/MaliciousApps.json but deliberately not in the mocked feed. + $script:CippApps = (Get-Content -Raw (Join-Path $RepoRoot 'Config/MaliciousApps.json') | ConvertFrom-Json).applications + $script:CippOnly = $script:CippApps | Where-Object { $_.appId -eq '77468577-4f6e-40e7-b745-11d3d0c28095' } | Select-Object -First 1 + $script:Feed = @([pscustomobject]@{ + appId = 'feed-app-0001'; appDisplayName = 'Feed App'; description = 'From the feed' + tags = @('BEC'); references = @('https://example.test'); dateAdded = '2026-01-01' + }) + + function script:New-Principals { + param([string[]]$AppIds) + $Values = foreach ($Id in $AppIds) { + [pscustomobject]@{ appId = $Id; appDisplayName = "SP $Id"; accountEnabled = $true; createdDateTime = '2026-01-01T00:00:00Z' } + } + @([pscustomobject]@{ body = [pscustomobject]@{ value = @($Values) } }) + } +} + +Describe 'Get-CIPPAlertHuntressRogueApps' { + BeforeEach { + $script:Alerted = $null + Mock -CommandName Write-AlertTrace -MockWith { param($cmdletName, $tenantFilter, $data) $script:Alerted = $data } + Mock -CommandName Invoke-RestMethod -MockWith { $script:Feed } + Mock -CommandName New-GraphBulkRequest -MockWith { script:New-Principals -AppIds @('feed-app-0001') } + } + + Context 'feed availability' { + It 'skips without alerting when the feed throws' { + Mock -CommandName Invoke-RestMethod -MockWith { throw 'GitHub Pages is down' } + + { Get-CIPPAlertHuntressRogueApps -TenantFilter 'contoso.onmicrosoft.com' } | Should -Not -Throw + Should -Invoke -CommandName Write-AlertTrace -Times 0 + } + + It 'skips when the feed returns an error page rather than JSON' { + # An HTML error page parses without throwing, so the shape has to be checked. + Mock -CommandName Invoke-RestMethod -MockWith { '404' } + + Get-CIPPAlertHuntressRogueApps -TenantFilter 'contoso.onmicrosoft.com' + Should -Invoke -CommandName Write-AlertTrace -Times 0 + Should -Invoke -CommandName New-GraphBulkRequest -Times 0 + } + + It 'skips when the feed is an empty array' { + Mock -CommandName Invoke-RestMethod -MockWith { @() } + + Get-CIPPAlertHuntressRogueApps -TenantFilter 'contoso.onmicrosoft.com' + Should -Invoke -CommandName Write-AlertTrace -Times 0 + } + } + + Context 'describing a match' { + It 'fills in an app that is only on the CIPP list' { + Mock -CommandName New-GraphBulkRequest -MockWith { script:New-Principals -AppIds @($script:CippOnly.appId) } + + Get-CIPPAlertHuntressRogueApps -TenantFilter 'contoso.onmicrosoft.com' + + $Row = @($script:Alerted)[0] + $Row.'App Name' | Should -BeExactly $script:CippOnly.name + $Row.'App Id' | Should -BeExactly $script:CippOnly.appId + $Row.'Description' | Should -Not -BeNullOrEmpty + $Row.'Source' | Should -BeExactly 'CIPP' + } + + It 'still fills in an app from the feed' { + Get-CIPPAlertHuntressRogueApps -TenantFilter 'contoso.onmicrosoft.com' + + $Row = @($script:Alerted)[0] + $Row.'App Name' | Should -BeExactly 'Feed App' + $Row.'Source' | Should -BeExactly 'Huntress' + $Row.'Listed On' | Should -BeExactly '2026-01-01' + } + + It 'never emits a row without an app id' { + Mock -CommandName New-GraphBulkRequest -MockWith { + script:New-Principals -AppIds @($script:CippOnly.appId, 'feed-app-0001') + } + + Get-CIPPAlertHuntressRogueApps -TenantFilter 'contoso.onmicrosoft.com' + + foreach ($Row in @($script:Alerted)) { + $Row.'App Id' | Should -Not -BeNullOrEmpty + $Row.'App Name' | Should -Not -BeNullOrEmpty + } + } + } +} diff --git a/Tests/DBCache/Set-CIPPDBCacheSPOTenant.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCacheSPOTenant.Tests.ps1 new file mode 100644 index 0000000000000..bd25fbfd9c3b6 --- /dev/null +++ b/Tests/DBCache/Set-CIPPDBCacheSPOTenant.Tests.ps1 @@ -0,0 +1,126 @@ +# The two SPO collectors split their failures in two, and these tests hold that split in place. +# +# Both read the SharePoint admin SOAP endpoint, which answers 401 when the service principal has no +# SharePoint consent in the tenant. That is a standing state - it answers 401 every night until the +# CPV permissions are reset - so it is recorded and skipped rather than failing the activity. +# Everything else rethrows: swallowing it left Invoke-CIPPDBCacheCollection counting the type as a +# success and the queue reporting Completed / 0 failed while the '-Count' row kept its old +# timestamp, so a genuinely broken collector looked identical to a working one. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPSPOTenant { param($TenantFilter, [switch]$SkipCache) } + function Add-CIPPDbItem { param($TenantFilter, $Type, $Data, [switch]$AddCount) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + + . (Join-Path $RepoRoot 'Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenant.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSPOTenantSyncClientRestriction.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' + + # The shape Get-CIPPSPOTenant raises for a tenant with no SharePoint consent. + function New-SPOAccessDeniedException { + $Exception = [System.Exception]::new('SharePoint admin access denied for contoso.onmicrosoft.com') + $Exception.Data['SPOAccessDenied'] = $true + return $Exception + } +} + +Describe 'Set-CIPPDBCacheSPOTenant' { + BeforeEach { + Mock Write-LogMessage {} + Mock Add-CIPPDbItem {} + } + + It 'records missing SharePoint consent as a warning without failing the activity' { + Mock Get-CIPPSPOTenant { throw (New-SPOAccessDeniedException) } + + { Set-CIPPDBCacheSPOTenant -TenantFilter $script:Tenant } | Should -Not -Throw + Should -Invoke Add-CIPPDbItem -Times 0 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Warning' -and $message -like '*SharePoint admin access denied*' + } + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { $sev -eq 'Error' } + } + + It 'rethrows any other failure from the admin endpoint' { + Mock Get-CIPPSPOTenant { throw 'The remote server returned an error: (500) Internal Server Error' } + + { Set-CIPPDBCacheSPOTenant -TenantFilter $script:Tenant } | Should -Throw '*500*' + Should -Invoke Add-CIPPDbItem -Times 0 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Error' -and $message -like '*Failed to cache SPO tenant configuration*' + } + } + + It 'rethrows when the endpoint answers with nothing' { + Mock Get-CIPPSPOTenant { $null } + + { Set-CIPPDBCacheSPOTenant -TenantFilter $script:Tenant } | Should -Throw '*no tenant configuration*' + Should -Invoke Add-CIPPDbItem -Times 0 + } + + It 'writes the configuration and stays quiet on success' { + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ TenantRestrictionEnabled = $true } } + + { Set-CIPPDBCacheSPOTenant -TenantFilter $script:Tenant } | Should -Not -Throw + Should -Invoke Add-CIPPDbItem -Times 1 -ParameterFilter { $Type -eq 'SPOTenant' } + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { $sev -eq 'Error' } + } +} + +Describe 'Set-CIPPDBCacheSPOTenantSyncClientRestriction' { + BeforeEach { + Mock Write-LogMessage {} + Mock Add-CIPPDbItem {} + } + + It 'records missing SharePoint consent as a warning without failing the activity' { + Mock Get-CIPPSPOTenant { throw (New-SPOAccessDeniedException) } + + { Set-CIPPDBCacheSPOTenantSyncClientRestriction -TenantFilter $script:Tenant } | Should -Not -Throw + Should -Invoke Add-CIPPDbItem -Times 0 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Warning' -and $message -like '*SharePoint admin access denied*' + } + } + + It 'rethrows any other failure from the admin endpoint' { + Mock Get-CIPPSPOTenant { throw 'The remote server returned an error: (500) Internal Server Error' } + + { Set-CIPPDBCacheSPOTenantSyncClientRestriction -TenantFilter $script:Tenant } | + Should -Throw '*500*' + Should -Invoke Add-CIPPDbItem -Times 0 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Error' -and $message -like '*Failed to cache SPO sync client restriction*' + } + } + + It 'rethrows when the endpoint answers with nothing' { + Mock Get-CIPPSPOTenant { $null } + + { Set-CIPPDBCacheSPOTenantSyncClientRestriction -TenantFilter $script:Tenant } | + Should -Throw '*no tenant configuration*' + Should -Invoke Add-CIPPDbItem -Times 0 + } + + It 'projects the restriction fields and stays quiet on success' { + Mock Get-CIPPSPOTenant { + [PSCustomObject]@{ + TenantRestrictionEnabled = $true + AllowedDomainList = @('contoso.com') + BlockMacSync = $false + ConditionalAccessPolicy = 'AllowLimitedAccess' + } + } + + { Set-CIPPDBCacheSPOTenantSyncClientRestriction -TenantFilter $script:Tenant } | Should -Not -Throw + Should -Invoke Add-CIPPDbItem -Times 1 -ParameterFilter { + $Type -eq 'SPOTenantSyncClientRestriction' -and + $Data[0].TenantRestrictionEnabled -eq $true -and + $Data[0].ConditionalAccessPolicy -eq 'AllowLimitedAccess' -and + $Data[0].TenantFilter -eq 'contoso.onmicrosoft.com' + } + } +} diff --git a/Tests/DBCache/Start-CIPPDBTestsRun.Tests.ps1 b/Tests/DBCache/Start-CIPPDBTestsRun.Tests.ps1 new file mode 100644 index 0000000000000..fd6228938923b --- /dev/null +++ b/Tests/DBCache/Start-CIPPDBTestsRun.Tests.ps1 @@ -0,0 +1,147 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeAll, [switch]$IncludeErrors, [switch]$SkipList) } + function Test-CIPPRerun { param($TenantFilter, $Type, $API, [switch]$Clear) } + function Start-CIPPOrchestrator { param($InputObject) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1') + + # CippReportingDB stores one count row per tenant+type, keyed by defaultDomainName. + function New-CountRow { + param([string]$Tenant, [string]$Type = 'Users', [int]$DataCount = 10) + + [PSCustomObject]@{ + PartitionKey = $Tenant + RowKey = "$Type-Count" + DataCount = $DataCount + Timestamp = [DateTimeOffset]::UtcNow + } + } + + function New-TenantRow { + param([string]$DefaultDomainName) + + [PSCustomObject]@{ + defaultDomainName = $DefaultDomainName + customerId = [guid]::NewGuid().Guid + displayName = $DefaultDomainName + } + } +} + +Describe 'Start-CIPPDBTestsRun tenant selection' { + BeforeEach { + # Captured out of the Start-CIPPOrchestrator mock rather than asserted with a + # -ParameterFilter, so a failure reports which tenants were queued. + $script:QueuedTenants = $null + $script:OrchestratorCalls = 0 + + Mock Test-CIPPRerun { return $false } + Mock Write-LogMessage { } + Mock Start-CIPPOrchestrator { + $script:OrchestratorCalls++ + $script:QueuedTenants = @($InputObject.Batch.TenantFilter) + return 'instance-1' + } + } + + Context 'when a tenant with cached data has been excluded' { + BeforeEach { + # The excluded tenant still has rows in CippReportingDB: exclusion only flips the + # Excluded flag on the Tenants row, it never purges the cache. Get-Tenants applies + # 'Excluded eq false', so it is absent from the live tenant list. + Mock Get-CIPPDbItem { + @( + (New-CountRow -Tenant 'active.onmicrosoft.com'), + (New-CountRow -Tenant 'excluded.onmicrosoft.com') + ) + } + Mock Get-Tenants { @(New-TenantRow -DefaultDomainName 'active.onmicrosoft.com') } + } + + It 'does not queue the excluded tenant for testing' { + Start-CIPPDBTestsRun -TenantFilter 'allTenants' | Out-Null + + $script:QueuedTenants | Should -Not -Contain 'excluded.onmicrosoft.com' + } + + It 'still queues the tenants that remain active' { + Start-CIPPDBTestsRun -TenantFilter 'allTenants' | Out-Null + + $script:QueuedTenants | Should -Be @('active.onmicrosoft.com') + } + } + + It 'drops tenants that are no longer managed but still have cached data' { + # Same failure mode as exclusion: the GDAP relationship is gone, so Get-Tenants no longer + # returns the tenant, but its cache rows survive until the 30-day table cleanup. + Mock Get-CIPPDbItem { + @( + (New-CountRow -Tenant 'active.onmicrosoft.com'), + (New-CountRow -Tenant 'offboarded.onmicrosoft.com') + ) + } + Mock Get-Tenants { @(New-TenantRow -DefaultDomainName 'active.onmicrosoft.com') } + + Start-CIPPDBTestsRun -TenantFilter 'allTenants' | Out-Null + + $script:QueuedTenants | Should -Be @('active.onmicrosoft.com') + } + + It 'matches tenants case-insensitively' { + # Table PartitionKey casing is not guaranteed to match the Tenants row, and an ordinal + # comparison here would silently drop every active tenant whose casing differs. + Mock Get-CIPPDbItem { @(New-CountRow -Tenant 'ACTIVE.onmicrosoft.com') } + Mock Get-Tenants { @(New-TenantRow -DefaultDomainName 'active.onmicrosoft.com') } + + Start-CIPPDBTestsRun -TenantFilter 'allTenants' | Out-Null + + $script:QueuedTenants | Should -Be @('ACTIVE.onmicrosoft.com') + } + + It 'starts no orchestration when every tenant with cached data is excluded' { + Mock Get-CIPPDbItem { @(New-CountRow -Tenant 'excluded.onmicrosoft.com') } + Mock Get-Tenants { @() } + + Start-CIPPDBTestsRun -TenantFilter 'allTenants' | Out-Null + + $script:OrchestratorCalls | Should -Be 0 + } + + It 'ignores tenant rows with no defaultDomainName rather than filtering everything out' { + Mock Get-CIPPDbItem { @(New-CountRow -Tenant 'active.onmicrosoft.com') } + Mock Get-Tenants { + @( + (New-TenantRow -DefaultDomainName 'active.onmicrosoft.com'), + (New-TenantRow -DefaultDomainName $null) + ) + } + + Start-CIPPDBTestsRun -TenantFilter 'allTenants' | Out-Null + + $script:QueuedTenants | Should -Be @('active.onmicrosoft.com') + } + + It 'skips tenants whose cached rows are all empty' { + Mock Get-CIPPDbItem { + @( + (New-CountRow -Tenant 'active.onmicrosoft.com'), + (New-CountRow -Tenant 'nodata.onmicrosoft.com' -DataCount 0) + ) + } + Mock Get-Tenants { + @( + (New-TenantRow -DefaultDomainName 'active.onmicrosoft.com'), + (New-TenantRow -DefaultDomainName 'nodata.onmicrosoft.com') + ) + } + + Start-CIPPDBTestsRun -TenantFilter 'allTenants' | Out-Null + + $script:QueuedTenants | Should -Be @('active.onmicrosoft.com') + } +} diff --git a/Tests/GraphHelper/Get-CIPPSPOTenant.Tests.ps1 b/Tests/GraphHelper/Get-CIPPSPOTenant.Tests.ps1 new file mode 100644 index 0000000000000..558bb8e44f42f --- /dev/null +++ b/Tests/GraphHelper/Get-CIPPSPOTenant.Tests.ps1 @@ -0,0 +1,78 @@ +# Get-CIPPSPOTenant is the only caller of the SharePoint admin SOAP endpoint, so it is where a 401 +# gets a name. SharePoint issues the token happily and then refuses it when the CIPP service +# principal has no app-only consent in the tenant, which no amount of retrying changes - the whole +# point of the SPOAccessDenied flag is that callers can skip that case instead of failing on it. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-SharePointAdminLink { param($Public, $tenantFilter) } + function New-GraphPostRequest { param($scope, $tenantid, $Uri, $Type, $Body, $ContentType, $AddedHeaders) } + function Get-CippTable { param($tablename) } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' +} + +Describe 'Get-CIPPSPOTenant' { + BeforeEach { + Mock Get-SharePointAdminLink { + @{ + TenantName = 'contoso' + AdminUrl = 'https://contoso-admin.sharepoint.com' + SharePointDomain = 'sharepoint.com' + } + } + Mock Get-CippTable { @{} } + Mock ConvertTo-CIPPODataFilterValue { $Value } + Mock Get-CIPPAzDataTableEntity { $null } + Mock Add-CIPPAzDataTableEntity {} + } + + It 'flags a 401 as missing consent and says what fixes it' { + Mock New-GraphPostRequest { throw '401 UNAUTHORIZED' } + + $Thrown = $null + try { + Get-CIPPSPOTenant -TenantFilter $script:Tenant -SkipCache + } catch { + $Thrown = $_.Exception + } + + $Thrown | Should -Not -BeNullOrEmpty + $Thrown.Data['SPOAccessDenied'] | Should -BeTrue + $Thrown.Message | Should -BeLike '*SharePoint admin access denied for contoso.onmicrosoft.com*' + $Thrown.Message | Should -BeLike '*CPV permissions*' + # The original failure is kept for anyone reading the exception chain. + $Thrown.InnerException.Message | Should -BeLike '*401*' + } + + It 'leaves any other failure exactly as it was raised' { + Mock New-GraphPostRequest { throw 'The remote server returned an error: (500) Internal Server Error' } + + $Thrown = $null + try { + Get-CIPPSPOTenant -TenantFilter $script:Tenant -SkipCache + } catch { + $Thrown = $_.Exception + } + + $Thrown.Message | Should -BeLike '*500*' + $Thrown.Data['SPOAccessDenied'] | Should -BeNullOrEmpty + } + + It 'returns and caches the configuration on success' { + Mock New-GraphPostRequest { [PSCustomObject]@{ TenantRestrictionEnabled = $true } } + + $Result = Get-CIPPSPOTenant -TenantFilter $script:Tenant -SkipCache + + $Result.TenantRestrictionEnabled | Should -BeTrue + $Result.SharepointPrefix | Should -Be 'contoso' + $Result.TenantFilter | Should -Be $script:Tenant + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 + } +} diff --git a/Tests/Mcp/CippDocs.Tests.ps1 b/Tests/Mcp/CippDocs.Tests.ps1 new file mode 100644 index 0000000000000..850f5385b51a8 --- /dev/null +++ b/Tests/Mcp/CippDocs.Tests.ps1 @@ -0,0 +1,429 @@ +# Pester tests for the docs search tools (SearchDocs / GetDoc). +# +# The load-bearing claim these tools make is that every result links somewhere real. A search +# result is only useful if its docs.cipp.app URL resolves, and the URL is derived from the file's +# path rather than looked up - so the derivation is what gets pinned hardest here, against the +# live list of published slugs in Config/DocsPublishedPages.txt. That check caught a rule that is +# not guessable from a path: a folder with no README.md is a grouping folder and GitBook drops it +# from the URL entirely, which silently moved seven pages up a level. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:BackendRoot = $BackendRoot + $script:RepoRoot = Split-Path -Parent $BackendRoot + $script:DocsRoot = Join-Path $script:RepoRoot 'docs' + + Add-Type -Path (Join-Path $BackendRoot 'Shared/CIPPSharp/bin/CIPPSharp.dll') -ErrorAction SilentlyContinue + + $McpRoot = Join-Path $BackendRoot 'Modules/CIPPCore/Public/MCP' + foreach ($Leaf in 'Get-CippDocLink.ps1', 'ConvertTo-CippDocToken.ps1', 'ConvertFrom-CippDocMarkdown.ps1', + 'Get-CippDocsIndex.ps1', 'Find-CippDoc.ps1') { + . (Join-Path $McpRoot $Leaf) + } + + $env:CIPPRootPath = $BackendRoot + + # Folders owning a README contribute a URL segment; the rest are elided. + $script:SectionFolder = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Readme in [System.IO.Directory]::EnumerateFiles($script:DocsRoot, 'README.md', [System.IO.SearchOption]::AllDirectories)) { + $Dir = [System.IO.Path]::GetDirectoryName($Readme) + if ($Dir.Length -le $script:DocsRoot.Length) { continue } + $script:SectionFolder.Add(($Dir.Substring($script:DocsRoot.Length).TrimStart('\', '/') -replace '\\', '/')) | Out-Null + } + + function Resolve-TestLink { + param([string]$Path, [string]$Heading) + return Get-CippDocLink -RelativePath $Path -Heading $Heading -SectionFolder $script:SectionFolder + } +} + +Describe 'Get-CippDocAnchor' { + + It 'slugifies a heading the way GitBook does' { + Get-CippDocAnchor -Heading 'Self-Hosted Deployment' | Should -Be 'self-hosted-deployment' + } + + It 'deletes apostrophes rather than turning them into separators' { + # 'you-ve-met' would be a dead anchor on every page with a contraction in a heading. + Get-CippDocAnchor -Heading "Confirm You've Met All Prerequisites" | Should -Be 'confirm-youve-met-all-prerequisites' + Get-CippDocAnchor -Heading "Confirm You$([char]0x2019)ve Met All Prerequisites" | Should -Be 'confirm-youve-met-all-prerequisites' + } + + It 'strips leading hashes and inline markdown' { + Get-CippDocAnchor -Heading '### Open the **Management** Portal' | Should -Be 'open-the-management-portal' + Get-CippDocAnchor -Heading 'See [the guide](https://example.com)' | Should -Be 'see-the-guide' + } + + It 'returns empty for no heading' { + Get-CippDocAnchor -Heading '' | Should -BeNullOrEmpty + } +} + +Describe 'Get-CippDocLink' { + + It 'maps a page to its published URL and GitHub source' { + $Link = Resolve-TestLink -Path 'setup/setting-up-cipp/install.md' + $Link.docsUrl | Should -Be 'https://docs.cipp.app/setup/setting-up-cipp/install' + $Link.githubUrl | Should -Be 'https://github.com/CyberDrain/CIPP/blob/dev/docs/setup/setting-up-cipp/install.md' + } + + It 'collapses a README to the folder it indexes' { + (Resolve-TestLink -Path 'setup/setting-up-cipp/README.md').docsUrl | + Should -Be 'https://docs.cipp.app/setup/setting-up-cipp' + } + + It 'publishes the docs root README at /readme' { + (Resolve-TestLink -Path 'README.md').docsUrl | Should -Be 'https://docs.cipp.app/readme' + } + + It 'elides a folder that owns no README' { + # docs/user-documentation/email/resources has no README, so GitBook drops the segment. + (Resolve-TestLink -Path 'user-documentation/email/resources/management/equipment/edit.md').docsUrl | + Should -Be 'https://docs.cipp.app/user-documentation/email/management/equipment/edit' + } + + It 'keeps a top-level folder even though it owns no README' { + # 'setup' is a SUMMARY.md '## Group', which always contributes its slug. + $script:SectionFolder.Contains('setup') | Should -BeFalse + (Resolve-TestLink -Path 'setup/installation/owntenant.md').docsUrl | + Should -Be 'https://docs.cipp.app/setup/installation/owntenant' + } + + It 'keeps the real repo path in the GitHub link even when the docs URL elides a folder' { + $Link = Resolve-TestLink -Path 'user-documentation/email/resources/management/equipment/edit.md' + $Link.githubUrl | Should -Match 'docs/user-documentation/email/resources/management/equipment/edit\.md$' + } + + It 'derives the CIPP route for a user-documentation page' { + (Resolve-TestLink -Path 'user-documentation/identity/administration/users/README.md').appPath | + Should -Be '/identity/administration/users' + } + + It 'gives no route to pages that do not document a screen' { + (Resolve-TestLink -Path 'setup/setting-up-cipp/install.md').appPath | Should -BeNullOrEmpty + } + + It 'appends the heading anchor to both links' { + $Link = Resolve-TestLink -Path 'setup/setting-up-cipp/install.md' -Heading 'Self-Hosted Deployment' + $Link.docsUrl | Should -Be 'https://docs.cipp.app/setup/setting-up-cipp/install#self-hosted-deployment' + $Link.githubUrl | Should -Match '#self-hosted-deployment$' + } + + It 'derives a URL matching the live site for every published page' { + # The whole-corpus check. Config/DocsPublishedPages.txt is a snapshot of docs.cipp.app's + # own llms.txt index, so a mismatch here means the tool would hand out a URL that 404s. + $Snapshot = Join-Path $env:CIPPRootPath 'Config/DocsPublishedPages.txt' + $Snapshot | Should -Exist + + $Published = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Line in (Get-Content $Snapshot)) { + $Trimmed = $Line.Trim() + if ($Trimmed -and -not $Trimmed.StartsWith('#')) { $Published.Add($Trimmed) | Out-Null } + } + + $Generated = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($File in [System.IO.Directory]::EnumerateFiles($script:DocsRoot, '*.md', [System.IO.SearchOption]::AllDirectories)) { + $Rel = $File.Substring($script:DocsRoot.Length).TrimStart('\', '/') -replace '\\', '/' + if ($Rel -eq 'SUMMARY.md' -or $Rel -like '.gitbook/*' -or $Rel -like 'legacy-setup-hidden-from-nav/*') { continue } + $Generated.Add((Resolve-TestLink -Path $Rel).slug) | Out-Null + } + + $Unreachable = @($Published | Where-Object { -not $Generated.Contains($_) }) + $Unreachable | Should -BeNullOrEmpty -Because "every published page must be reachable from a repo path; missing: $($Unreachable -join ', ')" + } +} + +Describe 'ConvertFrom-CippDocMarkdown' { + + It 'takes the title from the H1 and the description from frontmatter' { + $Parsed = ConvertFrom-CippDocMarkdown -RelativePath 'a/b.md' -Markdown @' +--- +description: Installing Your CIPP +--- + +# Installation + +Intro prose. + +## First Section + +Body text. +'@ + $Parsed.Title | Should -Be 'Installation' + $Parsed.Description | Should -Be 'Installing Your CIPP' + $Parsed.Chunks.Count | Should -Be 2 + $Parsed.Chunks[0].Heading | Should -BeNullOrEmpty + $Parsed.Chunks[1].Heading | Should -Be 'First Section' + } + + It 'strips GitBook block tags and HTML embeds' { + $Parsed = ConvertFrom-CippDocMarkdown -RelativePath 'a/b.md' -Markdown @' +# Page + +{% stepper %} +{% step %} +Real content here. +{% endstep %} +{% endstepper %} + +
+'@ + $Parsed.Chunks[0].Text | Should -Match 'Real content here' + $Parsed.Chunks[0].Text | Should -Not -Match 'stepper' + $Parsed.Chunks[0].Text | Should -Not -Match 'figure' + } + + It 'does not split a page on a comment inside a fenced code block' { + # A '# Install the module' comment in a PowerShell sample is not a heading. + $Parsed = ConvertFrom-CippDocMarkdown -RelativePath 'a/b.md' -Markdown @' +# Page + +## Real Section + +```powershell +# Install the module +Install-Module Foo +``` +'@ + @($Parsed.Chunks | Where-Object { $_.Heading }).Count | Should -Be 1 + $Parsed.Chunks[-1].Text | Should -Match 'Install-Module Foo' + } + + It 'keeps a link label but discards its URL' { + $Parsed = ConvertFrom-CippDocMarkdown -RelativePath 'a/b.md' -Markdown @' +# Page + +See the [Offboarding Wizard](https://example.com/some-unrelated-slug). +'@ + $Parsed.Chunks[0].Text | Should -Match 'Offboarding Wizard' + $Parsed.Chunks[0].Text | Should -Not -Match 'unrelated-slug' + } + + It 'falls back to the folder name when a README has no H1' { + (ConvertFrom-CippDocMarkdown -RelativePath 'user-documentation/gdap-management/README.md' -Markdown 'Just prose.').Title | + Should -Be 'Gdap Management' + } +} + +Describe 'ConvertTo-CippDocToken' { + + It 'lowercases, drops stop words and stems plurals' { + ConvertTo-CippDocToken -Text 'The Standards are here' | Should -Be @('standard') + } + + It 'indexes a compound identifier whole and in parts' { + $Tokens = ConvertTo-CippDocToken -Text 'ListUsers' + $Tokens | Should -Contain 'listuser' + $Tokens | Should -Contain 'list' + $Tokens | Should -Contain 'user' + } + + It 'splits a dotted role name without losing the full string' { + # 'ReadWrite' splits again on the camel-case boundary, so the parts are read + write. + $Tokens = ConvertTo-CippDocToken -Text 'Identity.User.ReadWrite' + $Tokens | Should -Contain 'identity.user.readwrite' + $Tokens | Should -Contain 'identity' + $Tokens | Should -Contain 'read' + $Tokens | Should -Contain 'write' + } + + It 'tokenises a query and the indexed text identically' { + # If these ever diverge, a term indexed one way is unfindable the other. + (ConvertTo-CippDocToken -Text 'Conditional Access Policies') | + Should -Be (ConvertTo-CippDocToken -Text 'conditional access policy') + } + + It 'returns nothing for empty input' { + ConvertTo-CippDocToken -Text '' | Should -BeNullOrEmpty + } +} + +Describe 'Get-CippDocsRoot' { + + AfterEach { + $env:CIPPDocsPath = $null + $env:CIPPRootPath = $script:BackendRoot + } + + It 'finds the docs in a source checkout' { + (Resolve-Path (Get-CippDocsRoot)).Path.TrimEnd('\', '/') | + Should -Be (Resolve-Path $script:DocsRoot).Path.TrimEnd('\', '/') + } + + It 'prefers CIPPDocsPath when it is set' { + $env:CIPPDocsPath = $script:DocsRoot + Get-CippDocsRoot | Should -Be $script:DocsRoot + } + + It 'skips a directory that exists but holds no markdown' { + # Docker leaves exactly this behind: bind-mounting the docs at /app/API/Docs creates an + # empty backend/Docs on the host, which a bare existence check accepts and then indexes + # to zero pages - every search silently returns nothing. + $Empty = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $Empty | Out-Null + try { + $env:CIPPDocsPath = $Empty + Get-CippDocsRoot | Should -Not -Be $Empty + (Resolve-Path (Get-CippDocsRoot)).Path.TrimEnd('\', '/') | + Should -Be (Resolve-Path $script:DocsRoot).Path.TrimEnd('\', '/') + } finally { + Remove-Item -LiteralPath $Empty -Recurse -Force + } + } + + It 'returns null when nothing holds documentation' { + $env:CIPPDocsPath = $null + $env:CIPPRootPath = [System.IO.Path]::GetTempPath() + Get-CippDocsRoot | Should -BeNullOrEmpty + } +} + +Describe 'Find-CippDoc' { + + BeforeAll { + [CIPP.DocsIndex]::Clear() + $null = Get-CippDocsIndex -DocsRoot $script:DocsRoot -Force + } + + It 'builds an index over the shipped docs' { + [CIPP.DocsIndex]::PageCount | Should -BeGreaterThan 300 + [CIPP.DocsIndex]::ChunkCount | Should -BeGreaterThan 1000 + } + + It 'excludes the superseded legacy tree and GitBook includes' { + $Paths = @([CIPP.DocsIndex]::GetPages() | ForEach-Object { $_.RelativePath }) + @($Paths | Where-Object { $_ -like 'legacy-setup-hidden-from-nav/*' }) | Should -BeNullOrEmpty + @($Paths | Where-Object { $_ -like '.gitbook/*' }) | Should -BeNullOrEmpty + } + + It 'withholds a docs URL from pages GitBook does not publish' { + # These exist in docs/ and in SUMMARY.md but are not live, so linking to them would 404. + $Unpublished = @([CIPP.DocsIndex]::GetPages() | Where-Object { -not $_.Published }) + $Unpublished.Count | Should -BeGreaterThan 0 + foreach ($Page in $Unpublished) { + $Page.DocsUrl | Should -BeNullOrEmpty + $Page.GitHubUrl | Should -Not -BeNullOrEmpty + } + } + + It 'finds a page by its own vocabulary' { + $Result = Find-CippDoc -Query 'offboarding wizard' -Limit 5 + @($Result.results | ForEach-Object { $_.path }) | Should -Contain 'user-documentation/identity/administration/offboarding-wizard.md' + } + + It 'reaches conditional access from the abbreviation via synonym expansion' { + # 'CA' appears nowhere in the prose of the pages this has to find. + $Result = Find-CippDoc -Query 'CA policy' -Limit 5 + @($Result.results | ForEach-Object { $_.title }) -join ' ' | Should -Match 'Conditional Access|CA Polic|CA Template' + } + + It 'recovers from typos' { + $Result = Find-CippDoc -Query 'conditonal acces' -Limit 5 + $Result.matchCount | Should -BeGreaterThan 0 + @($Result.results | ForEach-Object { $_.title }) -join ' ' | Should -Match 'Conditional|CA ' + } + + It 'deep-links to the matching section' { + $Result = Find-CippDoc -Query 'offboarding options' -Limit 5 + $Hit = @($Result.results | Where-Object { $_.section -and $_.docsUrl -match '#' })[0] + $Hit | Should -Not -BeNullOrEmpty + $Hit.docsUrl | Should -Match '#' + } + + It 'looks up documentation by CIPP route' { + $Result = Find-CippDoc -Path '/identity/administration/users' -Limit 5 + $Result.matchCount | Should -BeGreaterThan 0 + @($Result.results | ForEach-Object { $_.path }) | Should -Contain 'user-documentation/identity/administration/users/README.md' + } + + It 'scopes a keyword search to a route' { + $Result = Find-CippDoc -Query 'permissions' -Path '/identity/administration/users' -Limit 5 + $Result.matchCount | Should -BeGreaterThan 0 + foreach ($Hit in $Result.results) { + $Hit.path | Should -BeLike 'user-documentation/identity/administration/users*' + } + } + + It 'does not fill the results with one page' { + # Without a per-page cap a single long page crowds out every other answer. + # Grouped through a script block deliberately: results are ordered hashtables, and + # Group-Object -Property path does not resolve a hashtable key - it silently returns + # one group of everything, which reads as a failing cap when the cap is fine. + $Result = Find-CippDoc -Query 'user' -Limit 8 + $Counts = @($Result.results | Group-Object -Property { $_.path } | ForEach-Object { $_.Count }) + ($Counts | Measure-Object -Maximum).Maximum | Should -BeLessOrEqual 2 + } + + It 'returns nothing rather than noise for a nonsense query' { + (Find-CippDoc -Query 'zzzqqqxyz wibblefrotz' -Limit 5).matchCount | Should -Be 0 + } + + It 'asks for input when given neither query nor path' { + (Find-CippDoc -Limit 5).error | Should -Not -BeNullOrEmpty + } + + It 'reports an unmatched path instead of silently searching everything' { + $Result = Find-CippDoc -Path '/no/such/route' -Limit 5 + $Result.matchCount | Should -Be 0 + $Result.hint | Should -Match 'No documentation page matches' + } + + It 'never returns a result without a usable link' { + foreach ($Hit in (Find-CippDoc -Query 'standards drift' -Limit 8).results) { + ($Hit.docsUrl ?? $Hit.githubUrl) | Should -Not -BeNullOrEmpty + } + } +} + +Describe 'Get-CippDoc' { + + BeforeAll { + [CIPP.DocsIndex]::Clear() + $null = Get-CippDocsIndex -DocsRoot $script:DocsRoot -Force + } + + It 'accepts a repo path, a published slug or a CIPP route' { + foreach ($Identifier in 'user-documentation/identity/administration/users/README.md', + 'user-documentation/identity/administration/users', + '/identity/administration/users') { + $Doc = Get-CippDoc -Path $Identifier + $Doc.error | Should -BeNullOrEmpty -Because "'$Identifier' should resolve" + $Doc.title | Should -Be 'Users' + $Doc.content | Should -Not -BeNullOrEmpty + } + } + + It 'returns the page text with its headings' { + $Doc = Get-CippDoc -Path 'setup/setting-up-cipp/install.md' + $Doc.content | Should -Match 'Self-Hosted Deployment' + $Doc.sections | Should -Contain 'Self-Hosted Deployment' + } + + It 'suggests alternatives for an unknown page, without repeating one' { + $Doc = Get-CippDoc -Path 'no/such/page' + $Doc.error | Should -Not -BeNullOrEmpty + $Doc.suggestions.Count | Should -Be @($Doc.suggestions | Select-Object -Unique).Count + } +} + +Describe 'MCP gateway exposes the docs tools' { + + BeforeAll { + $McpRoot = Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))) 'Modules/CIPPCore/Public/MCP' + . (Join-Path $McpRoot 'Get-CippMcpToolList.ps1') + + function Get-CippMcpToolCatalog { return @() } + } + + It 'advertises SearchDocs and GetDoc alongside the API gateway tools' { + $Names = @((Get-CippMcpToolList -Request ([pscustomobject]@{ Query = @{} }) -InformationAction SilentlyContinue) | ForEach-Object { $_.name }) + $Names | Should -Contain 'SearchDocs' + $Names | Should -Contain 'GetDoc' + $Names | Should -Contain 'SearchTools' + } + + It 'gives GetDoc a required path parameter' { + $Tool = @((Get-CippMcpToolList -Request ([pscustomobject]@{ Query = @{} }) -InformationAction SilentlyContinue) | Where-Object { $_.name -eq 'GetDoc' })[0] + $Tool.inputSchema.required | Should -Contain 'path' + } +} diff --git a/Tests/Private/Add-CIPPScheduledTask.DeltaQuery.Tests.ps1 b/Tests/Private/Add-CIPPScheduledTask.DeltaQuery.Tests.ps1 new file mode 100644 index 0000000000000..565fe8d0c50d6 --- /dev/null +++ b/Tests/Private/Add-CIPPScheduledTask.DeltaQuery.Tests.ps1 @@ -0,0 +1,113 @@ +# Pester tests for the DeltaQuery branch of Add-CIPPScheduledTask +# A DeltaQuery-triggered task is worthless without its DeltaQueries row: every dispatch fails the +# lookup in Get-DeltaQueryUrl. Pins that a delta query failure fails the task creation rather than +# persisting an orphan, and that the delta query is keyed by the new task's RowKey. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Add-CIPPScheduledTask.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Add-CIPPScheduledTask.ps1 under Modules/' } + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CippQueueMessage { param($Cmdlet, $Parameters) } + function New-CIPPTaskDeltaQuery { param($Trigger, $TenantFilter, $PartitionKey) } + function Get-CIPPSchedulerBlockedCommands { } + function Get-NormalizedError { param($Message) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $Tenant) } + + . $FunctionPath + + function New-DeltaTaskRequest { + param([string]$DeltaResource = 'users') + [pscustomobject]@{ + TenantFilter = 'contoso.com' + Name = 'Clear Immutable ID: bob' + Command = @{ value = 'Clear-CIPPImmutableID' } + Parameters = [pscustomobject]@{ UserID = 'user-1'; TenantFilter = 'contoso.com' } + Trigger = @{ + Type = 'DeltaQuery' + DeltaResource = $DeltaResource + ResourceFilter = @('user-1') + EventType = 'deleted' + ExecutePerResource = $true + ExecutionMode = 'once' + } + ScheduledTime = 0 + Recurrence = '15m' + PostExecution = @{ Webhook = $false; Email = $false; PSA = $false } + } + } +} + +Describe 'Add-CIPPScheduledTask DeltaQuery trigger' { + BeforeEach { + $script:Persisted = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'ScheduledTasks' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:Persisted.Add($Entity) } + Mock -CommandName Add-CippQueueMessage -MockWith { } + Mock -CommandName Get-CIPPSchedulerBlockedCommands -MockWith { @() } + Mock -CommandName Get-NormalizedError -MockWith { $Message } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName New-CIPPTaskDeltaQuery -MockWith { @{ '@odata.deltaLink' = 'https://graph/deltalink' } } + Mock -CommandName Get-Command -ParameterFilter { $Name -eq 'Clear-CIPPImmutableID' } -MockWith { + [pscustomobject]@{ Name = 'Clear-CIPPImmutableID'; Module = 'CIPPCore'; Parameters = @{ TenantFilter = 1; UserID = 1 } } + } + } + + Context 'The delta query is created' { + It 'persists the task and keys the delta query by its RowKey' { + $Result = Add-CIPPScheduledTask -Task (New-DeltaTaskRequest) -hidden $true + + $Result | Should -BeLike 'Successfully added task*' + $script:Persisted.Count | Should -Be 1 + Should -Invoke New-CIPPTaskDeltaQuery -Times 1 -Exactly + $script:Persisted[0].RowKey | Should -Not -BeNullOrEmpty + } + + It 'creates the delta query before the task row is written' { + $script:Order = [System.Collections.Generic.List[string]]::new() + Mock -CommandName New-CIPPTaskDeltaQuery -MockWith { + $script:Order.Add('delta') + @{ '@odata.deltaLink' = 'https://graph/deltalink' } + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:Order.Add('task') } + + $null = Add-CIPPScheduledTask -Task (New-DeltaTaskRequest) -hidden $true + + $script:Order -join ',' | Should -Be 'delta,task' + } + } + + Context 'The delta query cannot be created' { + BeforeEach { + Mock -CommandName New-CIPPTaskDeltaQuery -MockWith { throw 'Delta Query failed for tenant.' } + } + + It 'does not persist an orphaned task row' { + { Add-CIPPScheduledTask -Task (New-DeltaTaskRequest) -hidden $true } | Should -Throw + $script:Persisted.Count | Should -Be 0 + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'surfaces the delta query error to the caller' { + { Add-CIPPScheduledTask -Task (New-DeltaTaskRequest) -hidden $true } | + Should -Throw -ExpectedMessage '*Delta Query failed for tenant*' + } + } + + Context 'A task with no trigger' { + It 'is persisted without touching the delta query path' { + $Task = New-DeltaTaskRequest + $Task.PSObject.Properties.Remove('Trigger') + + $null = Add-CIPPScheduledTask -Task $Task -hidden $true + + $script:Persisted.Count | Should -Be 1 + Should -Invoke New-CIPPTaskDeltaQuery -Times 0 -Exactly + } + } +} diff --git a/Tests/Private/Get-CIPPIntuneAssignmentTarget.Tests.ps1 b/Tests/Private/Get-CIPPIntuneAssignmentTarget.Tests.ps1 index 227f8120ae82f..650b695f6fb5d 100644 --- a/Tests/Private/Get-CIPPIntuneAssignmentTarget.Tests.ps1 +++ b/Tests/Private/Get-CIPPIntuneAssignmentTarget.Tests.ps1 @@ -99,4 +99,38 @@ Describe 'Get-CIPPIntuneAssignmentTarget' { } } } + + Context 'Device Preparation profiles' { + # Device Preparation deployments start when an assigned user signs in during OOBE, so the + # assignment surface is user groups only - the broad virtual targets leave the profile + # without an effective assignment even when Graph accepts the assign call. + + It 'expresses the users half of AllDevicesAndUsers as a group assignment on the All Users virtual group' { + $Result = Get-CIPPIntuneAssignmentTarget -AssignTo 'AllDevicesAndUsers' -PolicyType 'DevicePrepProfile' + + @($Result.Targets).Count | Should -Be 1 + $Result.Targets[0].'@odata.type' | Should -Be '#microsoft.graph.groupAssignmentTarget' + $Result.Targets[0].groupId | Should -Be $script:AllUsersGroupId + $Result.Unsupported | Should -BeNullOrEmpty + $Result.Dropped | Should -Be @('All Devices') + } + + It 'reports All Devices as unsupported rather than writing a target that never triggers' { + $Result = Get-CIPPIntuneAssignmentTarget -AssignTo 'AllDevices' -PolicyType 'DevicePrepProfile' + + $Result.Targets | Should -BeNullOrEmpty + $Result.Unsupported | Should -BeLike '*cannot be assigned to All Devices*' + } + + It 'names the virtual group so it is not reported as a bare GUID' { + $Result = Get-CIPPIntuneAssignmentTarget -AssignTo 'AllDevicesAndUsers' -PolicyType 'DevicePrepProfile' + + $Result.GroupNames[$script:AllUsersGroupId] | Should -Be 'All Users' + } + + It 'is not treated as MAM' { + (Get-CIPPIntuneAssignmentTarget -AssignTo 'allLicensedUsers' -PolicyType 'DevicePrepProfile').IsMam | + Should -BeFalse + } + } } diff --git a/Tests/Private/Get-DeltaQueryUrl.Tests.ps1 b/Tests/Private/Get-DeltaQueryUrl.Tests.ps1 new file mode 100644 index 0000000000000..f04dd05782873 --- /dev/null +++ b/Tests/Private/Get-DeltaQueryUrl.Tests.ps1 @@ -0,0 +1,111 @@ +# Pester tests for Get-DeltaQueryUrl +# A DeltaQuery-triggered task and its delta link live in two tables joined only by the task RowKey, +# so the DeltaQueries row can go missing on its own - a restore or instance copy that carried +# ScheduledTasks but not DeltaQueries. Pins the rebuild from the owning task's trigger, and that an +# unrebuildable row still throws rather than returning a null URL. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-DeltaQueryUrl.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-DeltaQueryUrl.ps1 under Modules/' } + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function New-CIPPTaskDeltaQuery { param($Trigger, $TenantFilter, $PartitionKey) } + function Write-LogMessage { param($API, $tenant, $message, $sev) } + + . $FunctionPath + + $script:TaskTrigger = '{"Type":"DeltaQuery","DeltaResource":"users","ResourceFilter":["user-1"],"EventType":"deleted"}' +} + +Describe 'Get-DeltaQueryUrl' { + BeforeEach { + # Get-CIPPTable hands back the table name as the context, so the entity mock can tell the + # DeltaQueries lookup apart from the ScheduledTasks lookup. + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = $TableName } } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName New-CIPPTaskDeltaQuery -MockWith { + @{ '@odata.deltaLink' = 'https://graph.microsoft.com/beta/users/delta?$deltatoken=rebuilt' } + } + } + + Context 'The delta query row exists' { + It 'returns the stored DeltaUrl without rebuilding' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'task-1'; RowKey = 'contoso.com'; DeltaUrl = 'https://stored/deltalink' } + } + + Get-DeltaQueryUrl -TenantFilter 'contoso.com' -PartitionKey 'task-1' | Should -Be 'https://stored/deltalink' + Should -Invoke New-CIPPTaskDeltaQuery -Times 0 -Exactly + } + } + + Context 'The delta query row is missing' { + It 'rebuilds it from the owning task trigger and returns the new link' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Context -eq 'DeltaQueries') { return @() } + [pscustomobject]@{ PartitionKey = 'ScheduledTask'; RowKey = 'task-1'; Name = 'Clear Immutable ID: bob'; Trigger = $script:TaskTrigger } + } + + $Result = Get-DeltaQueryUrl -TenantFilter 'contoso.com' -PartitionKey 'task-1' 3>$null + + $Result | Should -Be 'https://graph.microsoft.com/beta/users/delta?$deltatoken=rebuilt' + Should -Invoke New-CIPPTaskDeltaQuery -Times 1 -Exactly -ParameterFilter { + $TenantFilter -eq 'contoso.com' -and $PartitionKey -eq 'task-1' + } + } + + It 'records that changes before the rebuild were not captured' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Context -eq 'DeltaQueries') { return @() } + [pscustomobject]@{ RowKey = 'task-1'; Name = 'Clear Immutable ID: bob'; Trigger = $script:TaskTrigger } + } + + $null = Get-DeltaQueryUrl -TenantFilter 'contoso.com' -PartitionKey 'task-1' 3>$null + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $message -like '*not captured*' -and $sev -eq 'Warning' } + } + + It 'throws when there is no scheduled task to rebuild from' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + + { Get-DeltaQueryUrl -TenantFilter 'contoso.com' -PartitionKey 'task-1' } | + Should -Throw -ExpectedMessage '*no scheduled task with a trigger*' + Should -Invoke New-CIPPTaskDeltaQuery -Times 0 -Exactly + } + + It 'throws when the owning task has no trigger' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Context -eq 'DeltaQueries') { return @() } + [pscustomobject]@{ RowKey = 'task-1'; Name = 'no trigger' } + } + + { Get-DeltaQueryUrl -TenantFilter 'contoso.com' -PartitionKey 'task-1' } | + Should -Throw -ExpectedMessage '*no scheduled task with a trigger*' + } + + It 'propagates a rebuild failure rather than returning nothing' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Context -eq 'DeltaQueries') { return @() } + [pscustomobject]@{ RowKey = 'task-1'; Name = 'Clear Immutable ID: bob'; Trigger = $script:TaskTrigger } + } + Mock -CommandName New-CIPPTaskDeltaQuery -MockWith { throw 'Delta Query failed for tenant.' } + + { Get-DeltaQueryUrl -TenantFilter 'contoso.com' -PartitionKey 'task-1' 3>$null } | + Should -Throw -ExpectedMessage '*Delta Query failed for tenant*' + } + + It 'throws when the rebuild returns no delta link' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($Context -eq 'DeltaQueries') { return @() } + [pscustomobject]@{ RowKey = 'task-1'; Name = 'Clear Immutable ID: bob'; Trigger = $script:TaskTrigger } + } + Mock -CommandName New-CIPPTaskDeltaQuery -MockWith { @{} } + + { Get-DeltaQueryUrl -TenantFilter 'contoso.com' -PartitionKey 'task-1' 3>$null } | + Should -Throw -ExpectedMessage '*could not be rebuilt*' + } + } +} diff --git a/Tests/Private/New-CIPPTaskDeltaQuery.Tests.ps1 b/Tests/Private/New-CIPPTaskDeltaQuery.Tests.ps1 new file mode 100644 index 0000000000000..de19ca21a41bf --- /dev/null +++ b/Tests/Private/New-CIPPTaskDeltaQuery.Tests.ps1 @@ -0,0 +1,112 @@ +# Pester tests for New-CIPPTaskDeltaQuery +# Pins the mapping from a scheduled task's trigger to delta query parameters, since task creation, +# the rebuild in Get-DeltaQueryUrl and the offline repair script all depend on it producing the same +# key and filter. Includes the $select regression: 'ForEach-Object { } -join' binds -join as a +# ForEach-Object parameter and yields null, which silently dropped WatchedAttributes. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'New-CIPPTaskDeltaQuery.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate New-CIPPTaskDeltaQuery.ps1 under Modules/' } + + function New-GraphDeltaQuery { param($TenantFilter, $Resource, $Parameters, $PartitionKey, $DeltaUrl) } + + . $FunctionPath +} + +Describe 'New-CIPPTaskDeltaQuery' { + BeforeEach { + Mock -CommandName New-GraphDeltaQuery -MockWith { + @{ '@odata.deltaLink' = 'https://graph.microsoft.com/beta/users/delta?$deltatoken=abc' } + } + } + + Context 'Clear Immutable ID trigger' { + It 'keys the delta query by the task RowKey and filters to the watched user' { + $Trigger = [pscustomobject]@{ + Type = 'DeltaQuery' + DeltaResource = 'users' + ResourceFilter = @('11111111-2222-3333-4444-555555555555') + EventType = 'deleted' + ExecutePerResource = $true + ExecutionMode = 'once' + } + + $null = New-CIPPTaskDeltaQuery -Trigger $Trigger -TenantFilter 'contoso.com' -PartitionKey 'task-row-key' + + Should -Invoke New-GraphDeltaQuery -Times 1 -Exactly -ParameterFilter { + $Resource -eq 'users' -and + $TenantFilter -eq 'contoso.com' -and + $PartitionKey -eq 'task-row-key' -and + $Parameters.'$filter' -eq "id eq '11111111-2222-3333-4444-555555555555'" + } + } + + It 'returns the delta link from New-GraphDeltaQuery' { + $Trigger = [pscustomobject]@{ DeltaResource = 'users'; ResourceFilter = @('user-1') } + $Result = New-CIPPTaskDeltaQuery -Trigger $Trigger -TenantFilter 'contoso.com' -PartitionKey 'pk' + $Result.'@odata.deltaLink' | Should -Be 'https://graph.microsoft.com/beta/users/delta?$deltatoken=abc' + } + } + + Context 'Trigger stored as JSON on the task row' { + It 'parses the JSON and unwraps .value-shaped fields' { + $Trigger = '{"Type":{"value":"DeltaQuery"},"DeltaResource":{"value":"groups"},"ResourceFilter":[{"value":"a"},{"value":"b"}]}' + + $null = New-CIPPTaskDeltaQuery -Trigger $Trigger -TenantFilter 'contoso.com' -PartitionKey 'pk' + + Should -Invoke New-GraphDeltaQuery -Times 1 -Exactly -ParameterFilter { + $Resource -eq 'groups' -and $Parameters.'$filter' -eq "id eq 'a' or id eq 'b'" + } + } + } + + Context 'Watched attributes' { + It 'sends WatchedAttributes as a comma joined $select' { + $Trigger = [pscustomobject]@{ + DeltaResource = 'users' + WatchedAttributes = @( + [pscustomobject]@{ value = 'displayName' } + [pscustomobject]@{ value = 'mail' } + ) + } + + $null = New-CIPPTaskDeltaQuery -Trigger $Trigger -TenantFilter 'contoso.com' -PartitionKey 'pk' + + Should -Invoke New-GraphDeltaQuery -Times 1 -Exactly -ParameterFilter { + $Parameters.'$select' -eq 'displayName,mail' + } + } + + It 'accepts plain strings as well as .value objects' { + $Trigger = [pscustomobject]@{ DeltaResource = 'users'; WatchedAttributes = @('displayName', 'mail') } + + $null = New-CIPPTaskDeltaQuery -Trigger $Trigger -TenantFilter 'contoso.com' -PartitionKey 'pk' + + Should -Invoke New-GraphDeltaQuery -Times 1 -Exactly -ParameterFilter { + $Parameters.'$select' -eq 'displayName,mail' + } + } + + It 'omits $select when no attributes are watched' { + $Trigger = [pscustomobject]@{ DeltaResource = 'users'; ResourceFilter = @('user-1') } + + $null = New-CIPPTaskDeltaQuery -Trigger $Trigger -TenantFilter 'contoso.com' -PartitionKey 'pk' + + Should -Invoke New-GraphDeltaQuery -Times 1 -Exactly -ParameterFilter { + -not $Parameters.ContainsKey('$select') + } + } + } + + Context 'Unusable trigger' { + It 'throws when the trigger has no DeltaResource' { + $Trigger = [pscustomobject]@{ Type = 'DeltaQuery'; EventType = 'deleted' } + + { New-CIPPTaskDeltaQuery -Trigger $Trigger -TenantFilter 'contoso.com' -PartitionKey 'pk' } | + Should -Throw -ExpectedMessage '*has no DeltaResource*' + Should -Invoke New-GraphDeltaQuery -Times 0 -Exactly + } + } +} diff --git a/Tests/Private/Push-ExecScheduledCommand.DeltaTrigger.Tests.ps1 b/Tests/Private/Push-ExecScheduledCommand.DeltaTrigger.Tests.ps1 new file mode 100644 index 0000000000000..b6a0fd9950a45 --- /dev/null +++ b/Tests/Private/Push-ExecScheduledCommand.DeltaTrigger.Tests.ps1 @@ -0,0 +1,203 @@ +# Pester tests for the delta trigger path in Push-ExecScheduledCommand +# The delta lookup runs above every try in this function, so a throw there used to escape the +# entrypoint with no result written, leaving the task on the orchestrator's 'Pending' claim to be +# re-picked as a stale claim every hour forever. Pins that a delta failure is recorded on the task, +# that a recurring task stays recurring - including the bare-number recurrences the UI offers - and +# that only ExecutionMode 'once' or a genuinely non-recurring task reaches a terminal state. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-ExecScheduledCommand.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Push-ExecScheduledCommand.ps1 under Modules/' } + + function Get-CippTable { param($tablename) } + function Get-AzDataTableEntity { param($Context, $Filter) } + function Update-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-DeltaQueryUrl { param($TenantFilter, $PartitionKey) } + function New-GraphDeltaQuery { param($DeltaUrl, $TenantFilter, $PartitionKey) } + function Test-DeltaQueryConditions { param($Query, $Trigger, $TenantFilter, $LastTrigger) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($API, $tenant, $tenantid, $message, $sev, $LogData, $headers) } + function Set-CippScheduledTaskContext { param($TaskId) } + function Set-CippUserAgentContext { param($Headers, $Source, $TaskId) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CIPPSchedulerBlockedCommands { } + function Send-CIPPScheduledTaskAlert { param($Results, $TaskInfo, $TenantFilter, $TaskType, $Attachments) } + function Clear-CIPPImmutableID { param($UserID, $TenantFilter, $APIName) } + + . $FunctionPath + + $script:TaskEpoch = 1700000000 + function Get-UnixNow { [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds } + + function New-DeltaTask { + param([string]$Recurrence = '15m', [string]$ExecutionMode = 'once', [int64]$ScheduledTime = (Get-UnixNow)) + [pscustomobject]@{ + PartitionKey = 'ScheduledTask' + RowKey = 'task-1' + Name = 'Clear Immutable ID: bob' + Tenant = 'contoso.com' + TaskState = 'Pending' + Recurrence = $Recurrence + ScheduledTime = "$ScheduledTime" + Command = 'Clear-CIPPImmutableID' + Parameters = '{}' + Trigger = "{`"Type`":`"DeltaQuery`",`"DeltaResource`":`"users`",`"ResourceFilter`":[`"user-1`"],`"EventType`":`"deleted`",`"ExecutePerResource`":true,`"ExecutionMode`":`"$ExecutionMode`"}" + } + } + + function Invoke-Task { + param($Task) + $script:Writes = [System.Collections.Generic.List[object]]::new() + $script:Escaped = $null + try { + $null = Push-ExecScheduledCommand -Item ([pscustomobject]@{ + Command = $Task.Command + Parameters = [pscustomobject]@{ TenantFilter = 'contoso.com'; UserID = 'user-1' } + TaskInfo = $Task + FunctionName = 'ExecScheduledCommand' + }) + } catch { + $script:Escaped = $_.Exception.Message + } + # The task row write is the last one; earlier writes are the 'Running' transition. + $script:Writes | Select-Object -Last 1 + } +} + +Describe 'Push-ExecScheduledCommand delta trigger' { + BeforeEach { + $script:Writes = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Get-CippTable -MockWith { @{ Context = $tablename } } + Mock -CommandName Update-AzDataTableEntity -MockWith { $script:Writes.Add($Entity) } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Set-CippScheduledTaskContext -MockWith { } + Mock -CommandName Set-CippUserAgentContext -MockWith { } + Mock -CommandName Send-CIPPScheduledTaskAlert -MockWith { } + Mock -CommandName Get-CIPPSchedulerBlockedCommands -MockWith { @() } + Mock -CommandName Get-Tenants -MockWith { [pscustomobject]@{ customerId = 'customer-guid' } } + Mock -CommandName Get-CippException -MockWith { @{ NormalizedError = $Exception.Exception.Message } } + Mock -CommandName Get-AzDataTableEntity -MockWith { [pscustomobject]@{ TaskState = 'Pending' } } + Mock -CommandName Get-DeltaQueryUrl -MockWith { 'https://graph/deltalink' } + Mock -CommandName New-GraphDeltaQuery -MockWith { @{ value = @() } } + Mock -CommandName Test-DeltaQueryConditions -MockWith { @{ ConditionsMet = $false; MatchedData = @() } } + Mock -CommandName Clear-CIPPImmutableID -MockWith { 'cleared' } + # The module allow-list check reads Get-Command; only intercept the scheduled command itself + # so Pester's own use of Get-Command is untouched. + Mock -CommandName Get-Command -ParameterFilter { $Name -eq 'Clear-CIPPImmutableID' } -MockWith { + [pscustomobject]@{ Name = 'Clear-CIPPImmutableID'; Module = 'CIPPCore'; Parameters = @{ TenantFilter = 1; UserID = 1 } } + } + } + + Context 'The delta lookup fails' { + It 'does not let the exception escape the entrypoint' { + Mock -CommandName Get-DeltaQueryUrl -MockWith { throw 'Delta Query not found.' } + + $null = Invoke-Task -Task (New-DeltaTask) + + $script:Escaped | Should -BeNullOrEmpty + } + + It 'records the failure on the task instead of leaving it on the Pending claim' { + Mock -CommandName Get-DeltaQueryUrl -MockWith { throw 'Delta Query not found.' } + + $Final = Invoke-Task -Task (New-DeltaTask) + + $Final.TaskState | Should -Be 'Failed - Planned' + $Final.Results | Should -BeLike '*Delta Query not found*' + } + + It 'reschedules one interval out rather than immediately' { + Mock -CommandName Get-DeltaQueryUrl -MockWith { throw 'Delta Query not found.' } + + $Base = Get-UnixNow + $Final = Invoke-Task -Task (New-DeltaTask -Recurrence '15m' -ScheduledTime $Base) + + [int64]$Final.ScheduledTime | Should -Be ($Base + 900) + } + + It 'pulls a long stale schedule forward instead of setting a run time in the past' { + Mock -CommandName Get-DeltaQueryUrl -MockWith { throw 'Delta Query not found.' } + + $Base = Get-UnixNow + $Final = Invoke-Task -Task (New-DeltaTask -Recurrence '15m' -ScheduledTime $script:TaskEpoch) + + [int64]$Final.ScheduledTime | Should -BeGreaterOrEqual ($Base + 900) + [int64]$Final.ScheduledTime | Should -BeLessOrEqual ($Base + 960) + } + + It 'catches a failure from the delta refresh as well as the lookup' { + Mock -CommandName New-GraphDeltaQuery -MockWith { throw 'Failed to create Delta Query: Graph outage.' } + + $Final = Invoke-Task -Task (New-DeltaTask) + + $script:Escaped | Should -BeNullOrEmpty + $Final.TaskState | Should -Be 'Failed - Planned' + } + } + + Context 'Recurrence is preserved across a delta failure' { + BeforeEach { + Mock -CommandName Get-DeltaQueryUrl -MockWith { throw 'Delta Query not found.' } + } + + # Bare numbers mean days and are offered by the scheduler UI. The delta block parses + # Recurrence with its own switch, which did not normalise them, so these went terminal. + It 'keeps a task recurring, next run +s' -ForEach @( + @{ Recurrence = '15m'; Seconds = 900 } + @{ Recurrence = '4h'; Seconds = 14400 } + @{ Recurrence = '1d'; Seconds = 86400 } + @{ Recurrence = '30d'; Seconds = 2592000 } + @{ Recurrence = '1'; Seconds = 86400 } + @{ Recurrence = '7'; Seconds = 604800 } + @{ Recurrence = '30'; Seconds = 2592000 } + ) { + $Base = Get-UnixNow + $Final = Invoke-Task -Task (New-DeltaTask -Recurrence $Recurrence -ScheduledTime $Base) + + $Final.TaskState | Should -Be 'Failed - Planned' + [int64]$Final.ScheduledTime | Should -Be ($Base + $Seconds) + } + + It 'retires a task, which is not recurring' -ForEach @( + @{ Recurrence = '0' } + @{ Recurrence = '' } + ) { + $Final = Invoke-Task -Task (New-DeltaTask -Recurrence $Recurrence) + + $Final.TaskState | Should -Be 'Failed' + } + } + + Context 'The trigger does not fire' { + It 'reschedules the task and leaves it runnable' { + $Base = Get-UnixNow + $Final = Invoke-Task -Task (New-DeltaTask -ScheduledTime $Base) + + $Final.TaskState | Should -Be 'Planned' + [int64]$Final.ScheduledTime | Should -Be ($Base + 900) + Should -Invoke Clear-CIPPImmutableID -Times 0 -Exactly + } + } + + Context 'The trigger fires' { + It 'completes an ExecutionMode once task so it never runs again' { + Mock -CommandName Test-DeltaQueryConditions -MockWith { @{ ConditionsMet = $true; MatchedData = @() } } + + $Final = Invoke-Task -Task (New-DeltaTask -ExecutionMode 'once') + + $Final.TaskState | Should -Be 'Completed' + } + + It 'reschedules a task that is not ExecutionMode once' { + Mock -CommandName Test-DeltaQueryConditions -MockWith { @{ ConditionsMet = $true; MatchedData = @() } } + + $Final = Invoke-Task -Task (New-DeltaTask -ExecutionMode 'always') + + $Final.TaskState | Should -Be 'Planned' + } + } +} diff --git a/Tests/Private/Repair-CIPPTable.Tests.ps1 b/Tests/Private/Repair-CIPPTable.Tests.ps1 new file mode 100644 index 0000000000000..6399168da6fa3 --- /dev/null +++ b/Tests/Private/Repair-CIPPTable.Tests.ps1 @@ -0,0 +1,279 @@ +# Pester tests for Repair-CIPPTable and TableNotFound self-heal in the entity wrappers. +# +# A stale CIPPEnsuredTables entry (migration / external drop without Unregister) causes +# entity ops to 404. These tests protect: repair recreates + re-caches, wrappers retry +# once, non-404 errors are not repaired, and a failed create is not remembered as done. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $GraphHelper = Join-Path $BackendRoot 'Modules/CIPPCore/Public/GraphHelper' + + $script:CreateCalls = [System.Collections.Generic.List[string]]::new() + $script:GetCalls = 0 + $script:AddCalls = 0 + $script:RemoveCalls = 0 + $script:FailGetWithNotFound = $false + $script:FailGetWithOther = $false + $script:FailAddWithNotFound = $false + $script:FailAddWithOther = $false + $script:FailRemoveWithNotFound = $false + $script:FailRemoveWithOther = $false + $script:CreateShouldConflict = $false + $script:CreateShouldFail = $false + + function New-AzDataTableContext { + param($ConnectionString, $TableName, $MaxConnectionsPerServer) + [pscustomobject]@{ TableName = $TableName; ConnectionString = $ConnectionString } + } + + function New-AzDataTable { + param($Context) + if ($script:CreateShouldFail) { + throw 'storage unavailable' + } + if ($script:CreateShouldConflict) { + $script:CreateCalls.Add($Context.TableName) + $Ex = [System.Exception]::new('The table already exists. ErrorCode: TableAlreadyExists') + throw $Ex + } + $script:CreateCalls.Add($Context.TableName) + } + + function Get-AzDataTableLargeEntity { + [CmdletBinding()] + param( + $Context, + $Filter, + $Property, + $First, + $Skip, + $Sort, + [switch]$Count, + [int]$MaxRetries + ) + $script:GetCalls++ + if ($script:FailGetWithNotFound) { + $script:FailGetWithNotFound = $false + $Ex = [System.Exception]::new("The table specified does not exist.`nErrorCode: TableNotFound") + $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new( + $Ex, 'TableNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $Context)) + return + } + if ($script:FailGetWithOther) { + $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new( + [System.Exception]::new('throttled'), 'TooManyRequests', [System.Management.Automation.ErrorCategory]::OperationTimeout, $Context)) + return + } + [pscustomobject]@{ PartitionKey = 'Search'; RowKey = '1' } + } + + function Add-AzDataTableLargeEntity { + param( + $Context, + $Entity, + [switch]$CreateTableIfNotExists, + [switch]$Force, + [string]$OperationType + ) + $script:AddCalls++ + if ($script:FailAddWithNotFound) { + $script:FailAddWithNotFound = $false + throw [System.Exception]::new("The table specified does not exist.`nErrorCode: TableNotFound") + } + if ($script:FailAddWithOther) { + throw [System.Exception]::new('EntityAlreadyExists') + } + } + + function Remove-AzDataTableLargeEntity { + param( + $Context, + $Entity, + [switch]$Force, + [int]$MaxRetries + ) + $script:RemoveCalls++ + if ($script:FailRemoveWithNotFound) { + $script:FailRemoveWithNotFound = $false + throw [System.Exception]::new("The table specified does not exist.`nErrorCode: TableNotFound") + } + if ($script:FailRemoveWithOther) { + throw [System.Exception]::new('precondition failed') + } + } + + . (Join-Path $GraphHelper 'Unregister-CIPPTable.ps1') + . (Join-Path $GraphHelper 'Test-CIPPTableNotFound.ps1') + . (Join-Path $GraphHelper 'Repair-CIPPTable.ps1') + . (Join-Path $BackendRoot 'Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1') + . (Join-Path $BackendRoot 'Modules/CIPPCore/Public/Add-CIPPAzDataTableEntity.ps1') + . (Join-Path $BackendRoot 'Modules/CIPPCore/Public/Remove-CIPPAzDataTableEntity.ps1') + + function Set-StorageAccount { + param([string]$Name) + $env:AzureWebJobsStorage = "DefaultEndpointsProtocol=https;AccountName=$Name;AccountKey=Zm9v;EndpointSuffix=core.windows.net" + } + + function Reset-TableState { + $script:CIPPEnsuredTables = [HashTable]::Synchronized(@{}) + $script:CIPPRepairingTable = $false + $script:CreateCalls.Clear() + $script:GetCalls = 0 + $script:AddCalls = 0 + $script:RemoveCalls = 0 + $script:FailGetWithNotFound = $false + $script:FailGetWithOther = $false + $script:FailAddWithNotFound = $false + $script:FailAddWithOther = $false + $script:FailRemoveWithNotFound = $false + $script:FailRemoveWithOther = $false + $script:CreateShouldConflict = $false + $script:CreateShouldFail = $false + Set-StorageAccount 'acctone' + } + + function Get-CacheKey { + param([string]$TableName) + 'acctone/{0}' -f $TableName + } +} + +Describe 'Test-CIPPTableNotFound' { + It 'matches ErrorCode TableNotFound in the message' { + $Ex = [System.Exception]::new('ErrorCode: TableNotFound') + Test-CIPPTableNotFound $Ex | Should -BeTrue + } + + It 'matches the table service not-found text' { + $Ex = [System.Exception]::new('The table specified does not exist.') + Test-CIPPTableNotFound $Ex | Should -BeTrue + } + + It 'does not match unrelated errors' { + $Ex = [System.Exception]::new('Entity already exists') + Test-CIPPTableNotFound $Ex | Should -BeFalse + } +} + +Describe 'Repair-CIPPTable' { + BeforeEach { + Reset-TableState + } + + It 'unregisters, creates, and marks the table ensured' { + $script:CIPPEnsuredTables[(Get-CacheKey 'AuditLogSearches')] = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + + Repair-CIPPTable -Context $Context + + $script:CreateCalls | Should -Be @('AuditLogSearches') + $script:CIPPEnsuredTables.ContainsKey((Get-CacheKey 'AuditLogSearches')) | Should -BeTrue + } + + It 'treats a concurrent create 409 as success and still caches the table' { + $script:CreateShouldConflict = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + + { Repair-CIPPTable -Context $Context } | Should -Not -Throw + $script:CreateCalls | Should -Be @('AuditLogSearches') + $script:CIPPEnsuredTables.ContainsKey((Get-CacheKey 'AuditLogSearches')) | Should -BeTrue + } + + It 'does not cache a failed creation' { + $script:CreateShouldFail = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + + { Repair-CIPPTable -Context $Context } | Should -Throw + $script:CIPPEnsuredTables.ContainsKey((Get-CacheKey 'AuditLogSearches')) | Should -BeFalse + } + + It 'accepts -TableName and builds a context' { + Repair-CIPPTable -TableName 'CippQueue' + $script:CreateCalls | Should -Be @('CippQueue') + $script:CIPPEnsuredTables.ContainsKey((Get-CacheKey 'CippQueue')) | Should -BeTrue + } +} + +Describe 'Get-CIPPAzDataTableEntity TableNotFound self-heal' { + BeforeEach { + Reset-TableState + # Stale cache: table is "ensured" but storage no longer has it. + $script:CIPPEnsuredTables[(Get-CacheKey 'AuditLogSearches')] = $true + } + + It 'repairs and retries once on TableNotFound' { + $script:FailGetWithNotFound = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + + $Result = Get-CIPPAzDataTableEntity -Context $Context + + $script:GetCalls | Should -Be 2 + $script:CreateCalls | Should -Be @('AuditLogSearches') + $Result.RowKey | Should -Be '1' + } + + It 'does not repair non-TableNotFound errors' { + $script:FailGetWithOther = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + $null = Get-CIPPAzDataTableEntity -Context $Context -ErrorAction SilentlyContinue + + $script:GetCalls | Should -Be 1 + $script:CreateCalls | Should -HaveCount 0 + } +} + +Describe 'Add-CIPPAzDataTableEntity TableNotFound self-heal' { + BeforeEach { + Reset-TableState + $script:CIPPEnsuredTables[(Get-CacheKey 'AuditLogSearches')] = $true + } + + It 'repairs and retries once on TableNotFound' { + $script:FailAddWithNotFound = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + $Entity = @{ PartitionKey = 'Search'; RowKey = '1'; Tenant = 'contoso.com' } + + { Add-CIPPAzDataTableEntity -Context $Context -Entity $Entity -Force } | Should -Not -Throw + + $script:AddCalls | Should -Be 2 + $script:CreateCalls | Should -Be @('AuditLogSearches') + } + + It 'does not repair non-TableNotFound errors' { + $script:FailAddWithOther = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + $Entity = @{ PartitionKey = 'Search'; RowKey = '1'; Tenant = 'contoso.com' } + + { Add-CIPPAzDataTableEntity -Context $Context -Entity $Entity -Force } | Should -Throw + $script:AddCalls | Should -Be 1 + $script:CreateCalls | Should -HaveCount 0 + } +} + +Describe 'Remove-CIPPAzDataTableEntity TableNotFound self-heal' { + BeforeEach { + Reset-TableState + $script:CIPPEnsuredTables[(Get-CacheKey 'AuditLogSearches')] = $true + } + + It 'repairs and retries once on TableNotFound' { + $script:FailRemoveWithNotFound = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + $Entity = @{ PartitionKey = 'Search'; RowKey = '1'; ETag = '*' } + + { Remove-CIPPAzDataTableEntity -Context $Context -Entity $Entity -Force } | Should -Not -Throw + + $script:RemoveCalls | Should -Be 2 + $script:CreateCalls | Should -Be @('AuditLogSearches') + } + + It 'does not repair non-TableNotFound errors' { + $script:FailRemoveWithOther = $true + $Context = New-AzDataTableContext -ConnectionString $env:AzureWebJobsStorage -TableName 'AuditLogSearches' + $Entity = @{ PartitionKey = 'Search'; RowKey = '1'; ETag = '*' } + + { Remove-CIPPAzDataTableEntity -Context $Context -Entity $Entity -Force } | Should -Throw + $script:RemoveCalls | Should -Be 1 + $script:CreateCalls | Should -HaveCount 0 + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardDevicePrepProfile.Assignments.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardDevicePrepProfile.Assignments.Tests.ps1 new file mode 100644 index 0000000000000..8810feccc17b1 --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardDevicePrepProfile.Assignments.Tests.ps1 @@ -0,0 +1,209 @@ +# Pester tests for the assignment half of Invoke-CIPPStandardDevicePrepProfile. +# +# The failure this guards is a half-deployed profile that can never heal: the compliance check +# compared settings only, and the /assign call existed only immediately after policy creation. A +# profile whose settings matched but whose assignment was missing short-circuited as "already +# correctly configured" on every run - the assignment was unreachable and drift could not even see +# it. The check has to read the assignment state, and remediation has to be able to repair the +# assignment without recreating the profile (which would sever the enrollment-time device group). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardDevicePrepProfile.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardDevicePrepProfile.ps1 under Modules/' } + + # Stubs mirror the real signatures and are advanced functions on purpose: strict parameter + # binding makes signature drift in the standard fail loudly here. + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset) } + function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $AsApp, $ComplexFilter) } + function New-GraphPOSTRequest { [CmdletBinding()] param($uri, $tenantid, $body, $type) } + function Get-CIPPIntunePolicyAssignments { [CmdletBinding()] param($PolicyId, $TemplateType, $TenantFilter, $ExistingPolicy) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter, [bool]$LicenseAvailable = $true, [array]$BulkFields) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Write-LogMessage { [CmdletBinding()] param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Get-CippException { [CmdletBinding()] param($Exception) [PSCustomObject]@{ NormalizedError = [string]$Exception } } + + # The assignment helpers are pure apart from the group lookup, so use the real ones - the + # Device Preparation target shape they produce is exactly what these tests exist to pin down. + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneAssignTarget.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1') + . $StandardPath + + $script:Tenant = 'contoso.onmicrosoft.com' + $script:AllUsersGroupId = 'acacacac-9df4-4c7d-9d50-4ef0226f57a9' + + function New-ProfileSettings { + param($AssignTo = 'AllDevicesAndUsers', [int]$Timeout = 60) + [PSCustomObject]@{ + ProfileName = 'TEST_PREP_PROFILE' + ProfileDescription = 'Test profile' + Timeout = $Timeout + CustomErrorMessage = 'Contact IT.' + AllowSkip = $false + AllowDiagnostics = $false + AssignTo = $AssignTo + remediate = $true + report = $true + alert = $false + } + } + + function New-ChoiceSetting { + param($DefinitionId, $Value) + [PSCustomObject]@{ + settingInstance = [PSCustomObject]@{ + settingDefinitionId = $DefinitionId + choiceSettingValue = [PSCustomObject]@{ value = "${DefinitionId}_$Value" } + } + } + } + + function New-SimpleSetting { + param($DefinitionId, $Value) + [PSCustomObject]@{ + settingInstance = [PSCustomObject]@{ + settingDefinitionId = $DefinitionId + simpleSettingValue = [PSCustomObject]@{ value = $Value } + } + } + } + + # The deployed policy, parsed back the way the standard reads it: settings identical to what + # New-ProfileSettings requests, so only the assignment dimension varies per test. + function New-PolicyDetail { + [PSCustomObject]@{ + id = 'policy-1' + name = 'TEST_PREP_PROFILE' + settings = @( + New-ChoiceSetting 'enrollment_autopilot_dpp_deploymentmode' '0' + New-ChoiceSetting 'enrollment_autopilot_dpp_deploymenttype' '0' + New-ChoiceSetting 'enrollment_autopilot_dpp_jointype' '0' + New-ChoiceSetting 'enrollment_autopilot_dpp_accountype' '0' + New-ChoiceSetting 'enrollment_autopilot_dpp_allowskip' '0' + New-ChoiceSetting 'enrollment_autopilot_dpp_allowdiagnostics' '0' + New-SimpleSetting 'enrollment_autopilot_dpp_timeout' 60 + New-SimpleSetting 'enrollment_autopilot_dpp_customerrormessage' 'Contact IT.' + New-SimpleSetting 'enrollment_autopilot_dpp_devicesecuritygroupids' '' + ) + } + } + + function New-AllUsersAssignment { + [PSCustomObject]@{ + target = [PSCustomObject]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = $script:AllUsersGroupId + } + } + } +} + +Describe 'Invoke-CIPPStandardDevicePrepProfile assignment handling' { + BeforeEach { + $script:CompareFields = @() + $script:PostCalls = @() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName New-GraphGetRequest -ParameterFilter { $uri -like '*configurationPolicies' } -MockWith { + @([PSCustomObject]@{ name = 'TEST_PREP_PROFILE'; id = 'policy-1' }) + } + Mock -CommandName New-GraphGetRequest -ParameterFilter { $uri -like '*expand=settings*' } -MockWith { New-PolicyDetail } + Mock -CommandName New-GraphGetRequest -ParameterFilter { $uri -like '*/groups?*' } -MockWith { @() } + Mock -CommandName New-GraphPOSTRequest -MockWith { + $script:PostCalls += @{ uri = $uri; type = $type; body = $body } + [PSCustomObject]@{ id = 'new-policy-1' } + } + Mock -CommandName Get-CIPPIntunePolicyAssignments -MockWith { @() } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + $script:CompareFields += @{ Current = $CurrentValue; Expected = $ExpectedValue } + } + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-StandardsAlert -MockWith { } + } + + Context 'settings correct, assignment missing' { + It 'repairs the assignment in place instead of recreating the profile' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings) + + # One call: the /assign repair on the existing policy. No delete, no recreation. + @($script:PostCalls).Count | Should -Be 1 + $script:PostCalls[0].uri | Should -BeLike "*configurationPolicies('policy-1')/assign" + $script:PostCalls[0].type | Should -Be 'POST' + } + + It 'assigns the All Users virtual group rather than the broad virtual targets' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings) + + $script:PostCalls[0].body | Should -BeLike "*$($script:AllUsersGroupId)*" + $script:PostCalls[0].body | Should -Not -BeLike '*allDevicesAssignmentTarget*' + $script:PostCalls[0].body | Should -Not -BeLike '*allLicensedUsersAssignmentTarget*' + } + + It 'reports the missing assignment so drift can surface it' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings) + + $script:CompareFields[0].Current.isAssigned | Should -BeFalse + $script:CompareFields[0].Expected.isAssigned | Should -BeTrue + $script:CompareFields[0].Current.assignmentDifferences | Should -BeLike '*All Users*' + } + } + + Context 'settings correct, assignment correct' { + BeforeEach { + Mock -CommandName Get-CIPPIntunePolicyAssignments -MockWith { @(New-AllUsersAssignment) } + } + + It 'makes no write calls at all' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings) + + @($script:PostCalls).Count | Should -Be 0 + } + + It 'reports the profile as assigned' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings) + + $script:CompareFields[0].Current.isAssigned | Should -BeTrue + $script:CompareFields[0].Expected.isAssigned | Should -BeTrue + } + } + + Context 'assignment state cannot be read' { + BeforeEach { + Mock -CommandName Get-CIPPIntunePolicyAssignments -MockWith { throw 'Graph timeout' } + } + + It 'treats unknown as not-a-deviation: no remediation, no isAssigned dimension' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings) + + @($script:PostCalls).Count | Should -Be 0 + $script:CompareFields[0].Current.PSObject.Properties.Name | Should -Not -Contain 'isAssigned' + $script:CompareFields[0].Expected.PSObject.Properties.Name | Should -Not -Contain 'isAssigned' + } + } + + Context 'settings drifted' { + It 'recreates the profile and assigns it with the group target' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings -Timeout 20) + + # Delete, recreate, assign - in that order. + @($script:PostCalls).Count | Should -Be 3 + $script:PostCalls[0].type | Should -Be 'DELETE' + $script:PostCalls[1].uri | Should -BeLike '*configurationPolicies' + $script:PostCalls[2].uri | Should -BeLike "*configurationPolicies('new-policy-1')/assign" + $script:PostCalls[2].body | Should -BeLike "*$($script:AllUsersGroupId)*" + } + } + + Context "legacy 'AllDevices' selection" { + It 'does not write a target Device Preparation cannot honour, and says why' { + Invoke-CIPPStandardDevicePrepProfile -Tenant $script:Tenant -Settings (New-ProfileSettings -AssignTo 'AllDevices') + + @($script:PostCalls | Where-Object { $_.uri -like '*assign' }).Count | Should -Be 0 + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Warning' -and $message -like '*cannot be assigned to All Devices*' } + } + } +} diff --git a/version_latest.txt b/version_latest.txt index 0b04f50e23194..8cfd6c02cfaae 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.8.4 \ No newline at end of file +10.8.5 \ No newline at end of file