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 9bcca4d40c687..34d9a7f3281b2 100644
Binary files a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll and b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll differ
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 61d7298961052..51d22dfd64600 100644
Binary files a/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll and b/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll differ
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
GetAzDataTableLargeEntity
- 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
RemoveAzDataTableLargeEntity
- 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.*?\1>', ' '
+ $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 ba68cf80f0eb1..f66102fb695bf 100644
Binary files a/Shared/CIPPSharp/bin/CIPPSharp.dll and b/Shared/CIPPSharp/bin/CIPPSharp.dll differ
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