diff --git a/package.json b/package.json index 70dece3396fc..ff1da83ceb4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.4", + "version": "10.8.5", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/version.json b/public/version.json index bfb273510e9e..734751ab5a2f 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.4" + "version": "10.8.5" } \ No newline at end of file diff --git a/src/components/CippAllTenants/AllTenantsDashboard.jsx b/src/components/CippAllTenants/AllTenantsDashboard.jsx index 5ca488bc2ce3..2aeaa1302634 100644 --- a/src/components/CippAllTenants/AllTenantsDashboard.jsx +++ b/src/components/CippAllTenants/AllTenantsDashboard.jsx @@ -14,6 +14,7 @@ import { useAllTenantsDashboard } from './useAllTenantsDashboard' import { AllTenantsBandHeading, AllTenantsBarList, + AllTenantsCacheList, AllTenantsMeterList, AllTenantsRowList, AllTenantsTrendChart, @@ -578,7 +579,7 @@ export const AllTenantsDashboard = () => { - { + const [expanded, setExpanded] = useState(null) + + if (isFetching) { + return ( + + {[0, 1, 2].map((key) => ( + + ))} + + ) + } + + if (!rows.length) { + return ( + + {emptyText} + + ) + } + + return ( + + {rows.map((row, index) => { + const key = row.domain ?? `${row.name}-${index}` + const isOpen = expanded === key + const collections = row.collections ?? [] + const canExpand = collections.length > 0 + + const header = ( + + + + {row.name} + + {row.detail && ( + + {row.detail} + + )} + + {canExpand && ( + <> + + + + )} + + ) + + return ( + + {canExpand ? ( + setExpanded(isOpen ? null : key)} + aria-expanded={isOpen} + sx={{ width: '100%', display: 'block', borderRadius: 1 }} + > + {header} + + ) : ( + header + )} + + + {collections.map((collection) => ( + + + {collection.type} + + + {parseCippDate(collection.lastRefresh).toLocaleString()} ·{' '} + {formatAge(collection.ageHours)} + + + ))} + + + + ) + })} + + ) +} + /** Labelled percentage meters, for pass-rate style measures. */ export const AllTenantsMeterList = ({ meters = [], isFetching }) => { if (isFetching) { diff --git a/src/components/CippAllTenants/useAllTenantsDashboard.js b/src/components/CippAllTenants/useAllTenantsDashboard.js index 09426e63bb0f..39bbbddbe068 100644 --- a/src/components/CippAllTenants/useAllTenantsDashboard.js +++ b/src/components/CippAllTenants/useAllTenantsDashboard.js @@ -103,6 +103,135 @@ const SCALE_TYPES = [ { type: 'ManagedDevices', label: 'Managed devices' }, ] +// Collections the nightly orchestrator deliberately never runs. Start-CIPPDBCacheOrchestrator only +// executes the license groups in Invoke-CIPPDBCacheCollection plus the standalone Mailboxes and +// MFAState tasks; these enumerate every site, library and drive in the estate, so they are populated +// on demand instead (their own report pages, or Settings > Tenants > Refresh CIPPDB Cache). +// +// Their count rows therefore age forever by design. Freshness is the OLDEST collection a tenant has, +// so leaving them in meant one ad-hoc run months ago pinned an otherwise healthy tenant to "stale" +// permanently — on estates that had ever run them, it was all this card reported. +const ADHOC_CACHE_TYPES = new Set([ + 'SharePointSharingLinks', + 'SharePointPermissions', + 'OneDriveRootPermissions', +]) + +// The nightly orchestrator runs at 03:00, so a tenant that collected yesterday is still healthy; +// past 30 hours it has missed a run, and past 72 it has missed three. +const STALE_HOURS = 30 +const CRITICAL_HOURS = 72 + +/** + * Estate-wide scale totals and per-tenant cache freshness from ListDBCache countsOnly rows. Pure so + * the freshness rules can be exercised on their own — the alternative is standing up all seven of + * the dashboard's queries to assert on three integers. + * + * Each stale tenant carries the collections that made it stale, oldest first, so the card can answer + * "which one, and when did it last run" without a second request — the rows are already here. + */ +export const deriveCacheSummary = (rows, tenants) => { + const tenantCount = tenants.length + const totals = new Map() + const collectionsByTenant = new Map() + // Domains whose only rows are ad-hoc collections. They have nothing scheduled to judge, so they + // read as never cached — but saying that flatly would be wrong when a manual sync plainly ran. + const adhocOnly = new Set() + + rows.forEach((row) => { + const type = row?.Type + const count = Number(row?.Count ?? 0) + // Scale totals still count every collection — only the age judgement is scoped. + if (type) totals.set(type, (totals.get(type) ?? 0) + count) + if (!row?.Tenant) return + if (ADHOC_CACHE_TYPES.has(type)) { + adhocOnly.add(row.Tenant) + return + } + + const ageHours = hoursSince(row?.LastRefresh) + if (ageHours === null) return + const collections = collectionsByTenant.get(row.Tenant) ?? [] + collections.push({ type, lastRefresh: row.LastRefresh, ageHours }) + collectionsByTenant.set(row.Tenant, collections) + }) + + collectionsByTenant.forEach((collections, domain) => { + collections.sort((a, b) => b.ageHours - a.ageHours) + adhocOnly.delete(domain) + }) + + const scale = SCALE_TYPES.map(({ type, label }) => ({ + label, + value: totals.get(type) ?? 0, + average: tenantCount ? Math.round((totals.get(type) ?? 0) / tenantCount) : 0, + })) + + let fresh = 0 + let stale = 0 + let missing = 0 + const staleTenants = [] + + // A tenant the cache has never written has no rows at all, so absence is the signal here — + // iterate the tenant list rather than the returned rows. + tenants.forEach((tenant) => { + const domain = tenant?.defaultDomainName + const collections = (domain && collectionsByTenant.get(domain)) || [] + const name = tenant?.displayName || domain + const oldest = collections[0] + + if (!oldest) { + missing += 1 + staleTenants.push({ + name, + domain, + detail: adhocOnly.has(domain) + ? 'Only on-demand collections cached' + : 'No cached collections found', + severity: 'critical', + ageHours: null, + collections: [], + }) + return + } + + const age = oldest.ageHours + if (age <= STALE_HOURS) { + fresh += 1 + return + } + + stale += 1 + staleTenants.push({ + name, + domain, + detail: + age > CRITICAL_HOURS + ? `Oldest collection ${Math.round(age / 24)} days old` + : `Oldest collection ${Math.round(age)} hours old`, + severity: age > CRITICAL_HOURS ? 'critical' : 'warning', + ageHours: age, + // Only what is actually behind — a tenant that missed one run has three stale collections, + // not the eighty that refreshed fine. + collections: collections.filter((entry) => entry.ageHours > STALE_HOURS), + }) + }) + + // Worst first: never cached, then oldest. The list scrolls rather than truncating at five, so this + // order is the triage order. + staleTenants.sort((a, b) => { + if ((a.ageHours === null) !== (b.ageHours === null)) return a.ageHours === null ? -1 : 1 + return (b.ageHours ?? 0) - (a.ageHours ?? 0) + }) + + return { + scale, + hasData: rows.length > 0, + freshness: { fresh, stale, missing }, + staleTenants, + } +} + /** * Every read the All Tenants dashboard performs, plus the derivations each card needs. * @@ -308,74 +437,10 @@ export const useAllTenantsDashboard = () => { /* ------------------------------------------------------ scale and freshness */ - const cache = useMemo(() => { - const rows = asArray(countsApi.data) - - const totals = new Map() - const tenantOldest = new Map() - - rows.forEach((row) => { - const type = row?.Type - const count = Number(row?.Count ?? 0) - if (type) totals.set(type, (totals.get(type) ?? 0) + count) - - const age = hoursSince(row?.LastRefresh) - if (row?.Tenant && age !== null) { - const current = tenantOldest.get(row.Tenant) - if (current === undefined || age > current) tenantOldest.set(row.Tenant, age) - } - }) - - const scale = SCALE_TYPES.map(({ type, label }) => ({ - label, - value: totals.get(type) ?? 0, - average: tenantCount ? Math.round((totals.get(type) ?? 0) / tenantCount) : 0, - })) - - let fresh = 0 - let stale = 0 - let missing = 0 - const staleTenants = [] - - // A tenant the cache has never written has no rows at all, so absence is the signal here — - // iterate the tenant list rather than the returned rows. - tenants.forEach((tenant) => { - const domain = tenant?.defaultDomainName - const age = domain ? tenantOldest.get(domain) : undefined - const name = tenant?.displayName || domain - if (age === undefined) { - missing += 1 - staleTenants.push({ - name, - detail: 'No cached collections found', - severity: 'critical', - }) - } else if (age > 72) { - stale += 1 - staleTenants.push({ - name, - detail: `Oldest collection ${Math.round(age / 24)} days old`, - severity: 'critical', - }) - } else if (age > 30) { - stale += 1 - staleTenants.push({ - name, - detail: `Oldest collection ${Math.round(age)} hours old`, - severity: 'warning', - }) - } else { - fresh += 1 - } - }) - - return { - scale, - hasData: rows.length > 0, - freshness: { fresh, stale, missing }, - staleTenants: staleTenants.slice(0, 5), - } - }, [countsApi.data, tenants, tenantCount]) + const cache = useMemo( + () => deriveCacheSummary(asArray(countsApi.data), tenants), + [countsApi.data, tenants] + ) /* ------------------------------------------------------------ secure score */ diff --git a/src/components/CippCards/CippSharePointQuotaCard.jsx b/src/components/CippCards/CippSharePointQuotaCard.jsx new file mode 100644 index 000000000000..12df3dc44b0c --- /dev/null +++ b/src/components/CippCards/CippSharePointQuotaCard.jsx @@ -0,0 +1,122 @@ +import { Card, Chip, Skeleton, Stack, SvgIcon, Tooltip, Typography } from '@mui/material' +import { Box } from '@mui/system' +import { Storage } from '@mui/icons-material' +import { ApiGetCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' +import { usePermissions } from '../../hooks/use-permissions' +import { LinearProgressWithLabel } from '../linearProgressWithLabel' + +// SharePoint reports the tenant quota in MB, and a tenant pool is routinely multiple TB. +// Roll the unit up so the figures stay readable instead of printing seven-digit megabytes. +const formatStorage = (sizeInMB) => { + const size = Number(sizeInMB) + if (!Number.isFinite(size)) return 'N/A' + if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} TB` + if (size >= 1024) return `${(size / 1024).toFixed(2)} GB` + return `${Math.round(size)} MB` +} + +/** + * Slim tenant-wide SharePoint storage bar, ported from the "SharePoint Quota" donut on the + * classic (v1) dashboard. Reads the SPO admin StorageQuotas API, so it is unaffected by the + * usage-report anonymization that can blank out the per-site columns in the table below. + * + * The totals are tenant-wide: on a Multi-Geo tenant the endpoint sums used storage across + * every geo location against the shared tenant pool. A per-geo chip row is added when there + * is more than one location, so the aggregate never hides where the storage actually sits. + * + * Renders nothing when the data can't apply: AllTenants (the endpoint answers "Not Supported") + * or a user without the Sharepoint.Admin read permission the endpoint requires. + */ +export const CippSharePointQuotaCard = () => { + const currentTenant = useSettings().currentTenant + const { checkPermissions } = usePermissions() + const canReadQuota = checkPermissions(['Sharepoint.Admin.Read', 'Sharepoint.Admin.ReadWrite']) + const isAllTenants = currentTenant === 'AllTenants' + const enabled = !!currentTenant && !isAllTenants && canReadQuota + + const quota = ApiGetCall({ + url: '/api/ListSharepointQuota', + data: { tenantFilter: currentTenant }, + // Same key the v1 dashboard uses, so the two share a single fetch. + queryKey: `${currentTenant}-ListSharepointQuota`, + waiting: enabled, + }) + + if (!enabled) return null + + const usedMB = Number(quota.data?.GeoUsedStorageMB) + const totalMB = Number(quota.data?.TenantStorageMB) + // The endpoint returns a 'Not available' percentage and no figures when the SharePoint admin + // link or the quota call fails, so gate on having real numbers rather than on the status. + const hasQuota = Number.isFinite(usedMB) && Number.isFinite(totalMB) && totalMB > 0 + const percentage = hasQuota ? Math.min(100, Math.round((usedMB / totalMB) * 1000) / 10) : 0 + // Only worth showing when the tenant actually spans geos - a single-geo tenant's one entry + // repeats the Used chip above it. + const geoLocations = Array.isArray(quota.data?.GeoLocations) ? quota.data.GeoLocations : [] + const isMultiGeo = geoLocations.length > 1 + + return ( + + + + + + + + Tenant Storage + + + {quota.isFetching ? ( + + ) : hasQuota ? ( + <> + + + + + + + + + + + + + + {isMultiGeo && + geoLocations.map((geo, index) => ( + + + + ))} + + + ) : ( + + Tenant storage usage is unavailable for this tenant. + + )} + + + ) +} + +export default CippSharePointQuotaCard diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 5a1953382052..b8dbd37bb109 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -216,10 +216,11 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { return result } - // Flatten the results from all pages + // Flatten the results from all pages. A dataKey can be present but null (e.g. an API + // returning {"Accounts":null}), which must read as "no options", not as a null option. const combinedResults = allPages.flatMap((page) => { const nestedData = getNestedValue(page, currentApi?.dataKey) - return nestedData !== undefined ? nestedData : [] + return nestedData ?? [] }) if (!Array.isArray(combinedResults)) { @@ -230,8 +231,11 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { }, ]) } else { - // Convert each item into your { label, value, addedFields, rawData } shape - const convertedOptions = combinedResults.map((option) => { + // Convert each item into your { label, value, addedFields, rawData } shape. + // Null items would throw on the label/value field lookups below. + const convertedOptions = combinedResults + .filter((option) => option !== null && option !== undefined) + .map((option) => { const addedFields = {} if (currentApi?.addedField) { Object.keys(currentApi.addedField).forEach((key) => { diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index d98a00797f01..a9f120aa7c00 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -244,8 +244,11 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { clearTimeout(routerUpdateTimeoutRef.current); } - // Cancel all in-flight queries before changing tenant - queryClient.cancelQueries(); + // Only cancel on a real tenant change; cancelling the initial-load URL backfill + // aborts mount fetches that react-query never retries. + if (query.tenantFilter && query.tenantFilter !== currentTenant.value) { + queryClient.cancelQueries(); + } // Update router only - let the URL watcher handle settings query.tenantFilter = currentTenant.value; diff --git a/src/components/CippTable/CIPPTableToptoolbar.js b/src/components/CippTable/CIPPTableToptoolbar.js index 595da5b7f2b5..6850bf66288a 100644 --- a/src/components/CippTable/CIPPTableToptoolbar.js +++ b/src/components/CippTable/CIPPTableToptoolbar.js @@ -207,7 +207,9 @@ export const CIPPTableToptoolbar = React.memo( const getBulkActions = (actions, selectedRows) => { return ( actions - ?.filter((action) => !action.link && !action?.hideBulk) + // customComponent actions are single-row dialogs; the bulk path renders CippApiDialog + // unconditionally, so admitting one here produces an empty dialog with no API behind it. + ?.filter((action) => !action.link && !action?.hideBulk && !action?.customComponent) ?.map((action) => ({ ...action, // bulkFilterEligible actions run against the eligible subset of the selection: diff --git a/src/components/CippWizard/CippAddTenantTypeSelection.jsx b/src/components/CippWizard/CippAddTenantTypeSelection.jsx index f42c0d6d632f..27b3bf210c3a 100644 --- a/src/components/CippWizard/CippAddTenantTypeSelection.jsx +++ b/src/components/CippWizard/CippAddTenantTypeSelection.jsx @@ -9,19 +9,16 @@ export const CippAddTenantTypeSelection = (props) => { const [selectedOption, setSelectedOption] = useState(null) - // Fetch host tenant organization to check partnerTenantType. - // No tenantFilter means the backend defaults to $env:TenantID (the CIPP host tenant). + // Ask the backend whether this CIPP instance runs on a partner tenant. Deliberately not a + // direct Graph call: the tenant-scoped route is denied for custom roles that block the + // partner tenant, which greys out the partner-only options below for roles that are + // otherwise fully permitted. ListPartnerTenantInfo pins the lookup to the host tenant. const organization = ApiGetCall({ - url: '/api/ListGraphRequest', - queryKey: 'ListGraphRequest-organization-partnerTenantType', - data: { - Endpoint: 'organization', - $select: 'partnerTenantType,displayName', - }, + url: '/api/ListPartnerTenantInfo', + queryKey: 'ListPartnerTenantInfo', }) - const partnerTenantType = organization.data?.Results?.[0]?.partnerTenantType - const isPartner = organization.isSuccess && Boolean(partnerTenantType) + const isPartner = organization.isSuccess && Boolean(organization.data?.isPartnerTenant) const partnerCheckComplete = organization.isSuccess || organization.isError // Register the tenantType field in react-hook-form diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 1e7f3e166e1c..0ad27744fbea 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -83,8 +83,8 @@ const deleteCookie = (name) => { // running build's exact tag is both what we show and what we remember as dismissed. Collapsing // patch releases back to vX.Y.0 here left the dismissal cookie - which stores the tag that was // actually released - permanently unmatchable, so the dialog reopened on every page load. -// baseTag survives only as a display fallback for builds whose exact tag has no release -// (nightly, local, or a version bumped ahead of the tag being published). +// baseTag (vX.Y.0) is what the dialog selects by default so the feature-release notes lead; +// hotfix notes stay reachable via the dropdown. const buildReleaseMetadata = (version) => { const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '')) const [major, minor, patch] = match ? match.slice(1) : ['0', '0', '0'] @@ -140,7 +140,7 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const [open, setOpen] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const [manualOpenRequested, setManualOpenRequested] = useState(false) - const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.releaseTag) + const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.baseTag) const hasOpenedRef = useRef(false) useEffect(() => { @@ -148,8 +148,8 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { }, [releaseMeta.releaseTag]) useEffect(() => { - setSelectedReleaseTag(releaseMeta.releaseTag) - }, [releaseMeta.releaseTag]) + setSelectedReleaseTag(releaseMeta.baseTag) + }, [releaseMeta.baseTag]) useEffect(() => { if (typeof window === 'undefined') { diff --git a/src/data/standards.json b/src/data/standards.json index 62a8932f623d..ac06341b8b8c 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -6449,8 +6449,10 @@ "label": "Policy Assignment", "options": [ { "label": "Do not assign", "value": "none" }, - { "label": "All devices", "value": "AllDevices" }, - { "label": "All users and devices", "value": "AllDevicesAndUsers" } + { + "label": "All users (Device Preparation profiles deploy to the enrolling user, so device targets do not apply)", + "value": "AllDevicesAndUsers" + } ] } ], diff --git a/src/pages/cipp/settings/permissions.js b/src/pages/cipp/settings/permissions.js index 4393a3e602e7..4b3f60f91633 100644 --- a/src/pages/cipp/settings/permissions.js +++ b/src/pages/cipp/settings/permissions.js @@ -11,20 +11,15 @@ import { ApiGetCall } from "../../../api/ApiCall"; const Page = () => { const [importReport, setImportReport] = useState(false); - // Same signal the Add Tenant wizard uses to decide whether partner-only flows apply. - // No tenantFilter means the backend defaults to the CIPP host tenant. + // Same signal the Add Tenant wizard uses to decide whether partner-only flows apply, and the + // same shared query key so the two pages hit one cache entry. const organization = ApiGetCall({ - url: "/api/ListGraphRequest", - queryKey: "ListGraphRequest-organization-partnerTenantType", - data: { - Endpoint: "organization", - $select: "partnerTenantType,displayName", - }, + url: "/api/ListPartnerTenantInfo", + queryKey: "ListPartnerTenantInfo", }); - const partnerTenantType = organization.data?.Results?.[0]?.partnerTenantType; const partnerCheckComplete = organization.isSuccess || organization.isError; - const isPartner = organization.isSuccess && Boolean(partnerTenantType); + const isPartner = organization.isSuccess && Boolean(organization.data?.isPartnerTenant); // Keep the GDAP check visible until we know it does not apply, and always show it when an // imported report contains GDAP data. diff --git a/src/pages/email/resources/management/equipment/index.js b/src/pages/email/resources/management/equipment/index.js index 4a1b45a30413..5f5e4c40ab58 100644 --- a/src/pages/email/resources/management/equipment/index.js +++ b/src/pages/email/resources/management/equipment/index.js @@ -14,7 +14,9 @@ const Page = () => { link: `/email/resources/management/equipment/edit?equipmentId=[ExternalDirectoryObjectId]`, icon: , color: "info", - condition: (row) => !row.isDirSynced, + // ListEquipment returns the raw Get-Mailbox object, so these are PascalCase like the + // columns below - reading row.isDirSynced here is always undefined and never gates. + condition: (row) => !row.IsDirSynced, }, { label: "Edit permissions", @@ -30,7 +32,7 @@ const Page = () => { data: { ID: "ExternalDirectoryObjectId" }, confirmText: "Are you sure you want to block the sign-in for this equipment mailbox?", multiPost: false, - condition: (row) => !row.isDirSynced, + condition: (row) => !row.AccountDisabled && !row.IsDirSynced, }, { label: "Unblock Sign In", @@ -40,7 +42,7 @@ const Page = () => { data: { ID: "ExternalDirectoryObjectId", Enable: true }, confirmText: "Are you sure you want to unblock sign-in for this equipment mailbox?", multiPost: false, - condition: (row) => !row.isDirSynced, + condition: (row) => row.AccountDisabled && !row.IsDirSynced, }, { label: "Delete Equipment", @@ -50,7 +52,7 @@ const Page = () => { data: { ID: "ExternalDirectoryObjectId" }, confirmText: "Are you sure you want to delete this equipment mailbox?", multiPost: false, - condition: (row) => !row.isDirSynced, + condition: (row) => !row.IsDirSynced, }, ]; diff --git a/src/pages/security/reports/cve-report/index.js b/src/pages/security/reports/cve-report/index.js index b0d8317374d8..1c721955990e 100644 --- a/src/pages/security/reports/cve-report/index.js +++ b/src/pages/security/reports/cve-report/index.js @@ -18,7 +18,7 @@ const Page = () => { "exceptionType", "exceptionComment", "exceptionCreatedBy", - "exceptionReadableDate", + "exceptionDate", "exceptionExpiry", ]} /> diff --git a/src/pages/security/safelinks/safelinks/index.jsx b/src/pages/security/safelinks/safelinks/index.jsx index 02ccc9f872ea..bbc08e2a0b75 100644 --- a/src/pages/security/safelinks/safelinks/index.jsx +++ b/src/pages/security/safelinks/safelinks/index.jsx @@ -21,6 +21,19 @@ const Page = () => { } ]; + // Rows for orphaned built-in EOP rules carry PolicyName = null, so every condition has to + // tolerate a missing name rather than dereferencing it. A row with no policy behind it is + // Microsoft managed for these purposes, which is what the string comparisons already encode. + const isMicrosoftManaged = (row) => { + const name = row?.PolicyName ?? ""; + return ( + row?.IsBuiltIn === true || + name.startsWith("Standard Preset Security Policy") || + name.startsWith("Strict Preset Security Policy") || + name === "Built-In Protection Policy" + ); + }; + const actions = [ { label: "Edit Safe Links Policy", @@ -28,7 +41,7 @@ const Page = () => { icon: , color: "success", target: "_self", - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy") && row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), }, { label: "Enable Rule", @@ -42,7 +55,7 @@ const Page = () => { }, confirmText: "Are you sure you want to enable this rule?", color: "info", - condition: (row) => row.State === "Disabled" && !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => row.State === "Disabled" && !isMicrosoftManaged(row), }, { label: "Disable Rule", @@ -56,14 +69,14 @@ const Page = () => { }, confirmText: "Are you sure you want to disable this rule?", color: "info", - condition: (row) => row.State === "Enabled" && !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => row.State === "Enabled" && !isMicrosoftManaged(row), }, { label: "Set Priority", type: "POST", icon: , url: "/api/EditSafeLinksPolicy", - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), data: { PolicyName: "PolicyName", Name: "PolicyName" @@ -95,7 +108,7 @@ const Page = () => { confirmText: "Are you sure you want to create a template based on this policy?", icon: , hideBulk: true, - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), }, { label: "Delete Rule", @@ -108,7 +121,7 @@ const Page = () => { }, confirmText: "Are you sure you want to delete this policy and rule?", color: "danger", - condition: (row) => !row.IsBuiltInProtection && !row.PolicyName.startsWith("Standard Preset Security Policy") && !row.PolicyName.startsWith("Strict Preset Security Policy")&& row.PolicyName !== "Built-In Protection Policy", + condition: (row) => !isMicrosoftManaged(row), } ]; diff --git a/src/pages/teams-share/external-users.js b/src/pages/teams-share/external-users.js index 85fd7a8fa31c..b14abaae836e 100644 --- a/src/pages/teams-share/external-users.js +++ b/src/pages/teams-share/external-users.js @@ -22,14 +22,16 @@ const Page = () => { icon: , url: '/api/ExecRemoveSPOExternalUser', customDataformatter: (row) => { - const r = Array.isArray(row) ? row[0] : row - return { + const formatRow = (r) => ({ tenantFilter: r.Tenant ?? tenantFilter, EntraUserId: r.EntraUserId, LoginName: r.LoginName, SiteUrls: Array.isArray(r.Sites) ? r.Sites : [], DisplayName: r.DisplayName, - } + }) + // When multiple rows are selected, row is an array. Returning an array + // makes CippApiDialog send one request per row (bulk request mode). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) }, confirmText: 'Fully remove guest access for [DisplayName]? This deletes their Entra guest account (if one exists) AND removes them from every site listed in the Sites column, so nothing is left orphaned. Sharing links they hold can be revoked from the Sharing Report; the inert SharePoint store entry ages out on its own.', diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index f0c85a6ccffa..d5cd31bed46f 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -31,6 +31,12 @@ import { CippEditSitePropertiesForm } from '../../../components/CippComponents/C import { CippSiteRecycleBinDialog } from '../../../components/CippComponents/CippSiteRecycleBinDialog' import { CippLibraryPermissionsDialog } from '../../../components/CippComponents/CippLibraryPermissionsDialog' import { CippCheckUserAccessDialog } from '../../../components/CippComponents/CippCheckUserAccessDialog' +import { CippSharePointQuotaCard } from '../../../components/CippCards/CippSharePointQuotaCard' +import { + CippAnonymizedReportAlert, + isReportAnonymized, + useReportAnonymized, +} from '../../../components/CippComponents/CippAnonymizedReportAlert' // Friendly labels for the SharePoint version cleanup (trim) job progress fields. const VERSION_CLEANUP_LABELS = { @@ -150,6 +156,30 @@ const Page = () => { allowAllTenantSync: true, }) + // Two different faults produce empty usage columns here, and they need different advice. + // + // Anonymization: Microsoft 365 hashes the owner names in the SharePoint site usage report. + // Only hashed values prove this - absent usage data does not, because anonymization still + // returns rows, it just hashes them. Both the live and cached paths merge the same report, + // so this is not gated on cache mode. + const anonymizedReport = useReportAnonymized({ + url: reportDB.resolvedApiUrl, + data: reportDB.resolvedApiData, + queryKey: reportDB.resolvedQueryKey, + check: (rows) => isReportAnonymized(rows, ['ownerPrincipalName', 'ownerDisplayName']), + }) + + // Empty usage report: getSharePointSiteUsageDetail returns no rows at all for tenants + // Microsoft has not generated a report for yet. The site listing still populates the table, + // so every usage-derived column is blank. reportRefreshDate comes only from that report, so + // an empty one across every row means the merge contributed nothing. + const noUsageData = useReportAnonymized({ + url: reportDB.resolvedApiUrl, + data: reportDB.resolvedApiData, + queryKey: reportDB.resolvedQueryKey, + check: (rows) => rows.every((site) => !site?.reportRefreshDate), + }) + const actions = [ { label: 'Add Member', @@ -357,48 +387,54 @@ const Page = () => { ), customDataformatter: (row, action, formData) => { - const siteRow = Array.isArray(row) ? row[0] : row - const isGroupSite = siteRow?.rootWebTemplate === 'Group' const v = (x) => (x && typeof x === 'object' && 'value' in x ? x.value : x) - const payload = { - tenantFilter: siteRow.Tenant ?? tenantFilter, - SiteUrl: siteRow.webUrl, - SharingCapability: v(formData.SharingCapability), - DefaultSharingLinkType: v(formData.DefaultSharingLinkType), - DefaultLinkPermission: v(formData.DefaultLinkPermission), - LockState: v(formData.LockState), - } - if (!isGroupSite) { - payload.Title = formData.Title - payload.SharingDomainRestrictionMode = v(formData.SharingDomainRestrictionMode) - payload.OverrideTenantAnonymousLinkExpirationPolicy = - !!formData.OverrideTenantAnonymousLinkExpirationPolicy - payload.InheritVersionPolicyFromTenant = !!formData.InheritVersionPolicyFromTenant - } - if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'AllowList') { - payload.SharingAllowedDomainList = formData.SharingAllowedDomainList - } - if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'BlockList') { - payload.SharingBlockedDomainList = formData.SharingBlockedDomainList - } - if (!isGroupSite && formData.OverrideTenantAnonymousLinkExpirationPolicy) { - payload.AnonymousLinkExpirationInDays = parseInt( - formData.AnonymousLinkExpirationInDays ?? 0, - 10 - ) - } - const storageMax = parseInt(formData.StorageMaximumLevel, 10) - const storageWarn = parseInt(formData.StorageWarningLevel, 10) - if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax - if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn - if (!isGroupSite && !formData.InheritVersionPolicyFromTenant) { - payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim - if (!formData.EnableAutoExpirationVersionTrim) { - payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10) - payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10) + // isGroupSite is evaluated per site: a selection can mix group-backed and classic + // sites, and the group-backed ones reject the properties guarded below. + const formatRow = (siteRow) => { + const isGroupSite = siteRow?.rootWebTemplate === 'Group' + const payload = { + tenantFilter: siteRow.Tenant ?? tenantFilter, + SiteUrl: siteRow.webUrl, + SharingCapability: v(formData.SharingCapability), + DefaultSharingLinkType: v(formData.DefaultSharingLinkType), + DefaultLinkPermission: v(formData.DefaultLinkPermission), + LockState: v(formData.LockState), + } + if (!isGroupSite) { + payload.Title = formData.Title + payload.SharingDomainRestrictionMode = v(formData.SharingDomainRestrictionMode) + payload.OverrideTenantAnonymousLinkExpirationPolicy = + !!formData.OverrideTenantAnonymousLinkExpirationPolicy + payload.InheritVersionPolicyFromTenant = !!formData.InheritVersionPolicyFromTenant } + if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'AllowList') { + payload.SharingAllowedDomainList = formData.SharingAllowedDomainList + } + if (!isGroupSite && v(formData.SharingDomainRestrictionMode) === 'BlockList') { + payload.SharingBlockedDomainList = formData.SharingBlockedDomainList + } + if (!isGroupSite && formData.OverrideTenantAnonymousLinkExpirationPolicy) { + payload.AnonymousLinkExpirationInDays = parseInt( + formData.AnonymousLinkExpirationInDays ?? 0, + 10 + ) + } + const storageMax = parseInt(formData.StorageMaximumLevel, 10) + const storageWarn = parseInt(formData.StorageWarningLevel, 10) + if (!isNaN(storageMax) && storageMax > 0) payload.StorageMaximumLevel = storageMax + if (!isNaN(storageWarn) && storageWarn > 0) payload.StorageWarningLevel = storageWarn + if (!isGroupSite && !formData.InheritVersionPolicyFromTenant) { + payload.EnableAutoExpirationVersionTrim = !!formData.EnableAutoExpirationVersionTrim + if (!formData.EnableAutoExpirationVersionTrim) { + payload.MajorVersionLimit = parseInt(formData.MajorVersionLimit ?? 0, 10) + payload.ExpireVersionsAfterDays = parseInt(formData.ExpireVersionsAfterDays ?? 0, 10) + } + } + return payload } - return payload + // When multiple rows are selected, row is an array. Returning an array + // makes CippApiDialog send one request per row (bulk request mode). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) }, multiPost: false, allowResubmit: true, @@ -514,6 +550,7 @@ const Page = () => { /> ), multiPost: false, + hideBulk: true, }, { label: 'Delete Site', @@ -647,6 +684,7 @@ const Page = () => { /> ), multiPost: false, + hideBulk: true, }, { label: 'Check Cleanup Job Status', @@ -661,6 +699,7 @@ const Page = () => { /> ), multiPost: false, + hideBulk: true, }, ] @@ -727,6 +766,22 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={pageActions} + tableFilter={ + <> + + + Site owner names in this report are pseudo-anonymised because Microsoft 365 report + anonymization is enabled for this tenant. + + {!anonymizedReport && noUsageData && ( + + Microsoft returned no SharePoint usage report for this tenant, so activity, + storage and file count are blank. The site list itself is complete. Usage reports + can take up to 48 hours to appear on a new tenant. + + )} + + } /> {reportDB.syncDialog} diff --git a/tests/components/CippAllTenants/AllTenantsCacheList.test.jsx b/tests/components/CippAllTenants/AllTenantsCacheList.test.jsx new file mode 100644 index 000000000000..49f8e2b077c0 --- /dev/null +++ b/tests/components/CippAllTenants/AllTenantsCacheList.test.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithTheme } from '../../test-utils' +import { AllTenantsCacheList } from '../../../src/components/CippAllTenants/AllTenantsPrimitives' + +const staleRow = { + name: 'Contoso', + domain: 'contoso.onmicrosoft.com', + detail: 'Oldest collection 9 days old', + severity: 'critical', + ageHours: 216, + collections: [ + { + type: 'SPOTenant', + lastRefresh: '2026-08-04T19:07:27.869Z', + ageHours: 216, + }, + { + type: 'SiteActivity', + lastRefresh: '2026-08-10T11:15:01.329Z', + ageHours: 60, + }, + ], +} + +const neverCachedRow = { + name: 'Fabrikam', + domain: 'fabrikam.onmicrosoft.com', + detail: 'No cached collections found', + severity: 'critical', + ageHours: null, + collections: [], +} + +describe('AllTenantsCacheList', () => { + it('renders the empty text when nothing is behind', () => { + renderWithTheme() + expect(screen.getByText('All fresh')).toBeInTheDocument() + }) + + it('keeps the collection detail hidden until the row is expanded', async () => { + renderWithTheme() + + expect(screen.getByText('Contoso')).toBeInTheDocument() + expect(screen.getByText('2 stale')).toBeInTheDocument() + expect(screen.queryByText('SPOTenant')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { expanded: false })) + + expect(screen.getByText('SPOTenant')).toBeInTheDocument() + expect(screen.getByText('SiteActivity')).toBeInTheDocument() + }) + + it('shows each collection with its own last refresh time', async () => { + renderWithTheme() + await userEvent.click(screen.getByRole('button', { expanded: false })) + + // The absolute stamp is locale-formatted, so assert on the age suffix the row appends to it. + expect(screen.getByText(/9 days ago/)).toBeInTheDocument() + expect(screen.getByText(/60 hours ago/)).toBeInTheDocument() + }) + + it('does not offer an expander for a tenant with nothing cached', () => { + renderWithTheme() + + expect(screen.getByText('Fabrikam')).toBeInTheDocument() + expect(screen.getByText('No cached collections found')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders every stale tenant rather than the first few', () => { + const rows = Array.from({ length: 9 }, (_, index) => ({ + ...staleRow, + name: `Tenant ${index}`, + domain: `tenant${index}.onmicrosoft.com`, + })) + renderWithTheme() + + expect(screen.getByText('Tenant 0')).toBeInTheDocument() + expect(screen.getByText('Tenant 8')).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippAllTenants/useAllTenantsDashboard.test.js b/tests/components/CippAllTenants/useAllTenantsDashboard.test.js new file mode 100644 index 000000000000..6655f0222c14 --- /dev/null +++ b/tests/components/CippAllTenants/useAllTenantsDashboard.test.js @@ -0,0 +1,159 @@ +import { deriveCacheSummary } from '../../../src/components/CippAllTenants/useAllTenantsDashboard' + +const HOUR = 3600000 + +const hoursAgo = (hours) => new Date(Date.now() - hours * HOUR).toISOString() + +const tenant = (domain, displayName = domain) => ({ + defaultDomainName: domain, + displayName, +}) + +const row = (Tenant, Type, hours, Count = 1) => ({ + Tenant, + Type, + Count, + LastRefresh: hoursAgo(hours), +}) + +describe('deriveCacheSummary', () => { + it('ages a tenant by its oldest scheduled collection', () => { + const summary = deriveCacheSummary( + [row('a.com', 'Users', 2), row('a.com', 'Mailboxes', 100)], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 0, stale: 1, missing: 0 }) + expect(summary.staleTenants[0]).toMatchObject({ + name: 'Alpha', + domain: 'a.com', + detail: 'Oldest collection 4 days old', + severity: 'critical', + }) + }) + + it('ignores collections the nightly orchestrator never refreshes', () => { + // SharePointSharingLinks is populated on demand only, so a months-old row says nothing about + // whether this tenant is still syncing. + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 2), + row('a.com', 'SharePointSharingLinks', 24 * 90), + row('a.com', 'SharePointPermissions', 24 * 60), + row('a.com', 'OneDriveRootPermissions', 24 * 45), + ], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 1, stale: 0, missing: 0 }) + expect(summary.staleTenants).toEqual([]) + }) + + it('says so when a tenant has only ad-hoc collections rather than none', () => { + const summary = deriveCacheSummary( + [row('a.com', 'SharePointSharingLinks', 24 * 90)], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 0, stale: 0, missing: 1 }) + expect(summary.staleTenants[0]).toMatchObject({ + name: 'Alpha', + detail: 'Only on-demand collections cached', + severity: 'critical', + ageHours: null, + collections: [], + }) + }) + + it('warns between 30 and 72 hours and reports the age in hours', () => { + const summary = deriveCacheSummary( + [row('a.com', 'Users', 48)], + [tenant('a.com', 'Alpha')] + ) + + expect(summary.freshness).toEqual({ fresh: 0, stale: 1, missing: 0 }) + expect(summary.staleTenants[0]).toMatchObject({ + detail: 'Oldest collection 48 hours old', + severity: 'warning', + }) + }) + + it('attaches only the collections that are behind, oldest first', () => { + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 2), + row('a.com', 'SPOTenant', 216), + row('a.com', 'Mailboxes', 72.5), + row('a.com', 'Groups', 29), + ], + [tenant('a.com', 'Alpha')] + ) + + const [alpha] = summary.staleTenants + expect(alpha.collections.map((entry) => entry.type)).toEqual([ + 'SPOTenant', + 'Mailboxes', + ]) + expect(alpha.collections[0].ageHours).toBeCloseTo(216, 1) + expect(alpha.collections[0].lastRefresh).toBeTruthy() + }) + + it('sorts never cached first, then oldest, without truncating the list', () => { + const tenants = ['a', 'b', 'c', 'd', 'e', 'f'].map((letter) => + tenant(`${letter}.com`) + ) + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 100), + row('b.com', 'Users', 400), + row('c.com', 'Users', 200), + row('d.com', 'Users', 50), + row('e.com', 'Users', 300), + ], + tenants + ) + + // f.com has no rows at all, so it leads; the rest follow oldest first. + expect(summary.staleTenants.map((entry) => entry.domain)).toEqual([ + 'f.com', + 'b.com', + 'e.com', + 'c.com', + 'a.com', + 'd.com', + ]) + }) + + it('still totals ad-hoc collections into the scale figures', () => { + // Excluding a type from the age judgement must not remove its records from the estate inventory. + const summary = deriveCacheSummary( + [ + row('a.com', 'Users', 2, 40), + row('b.com', 'Users', 2, 60), + row('a.com', 'SharePointSharingLinks', 24 * 90, 500), + ], + [tenant('a.com'), tenant('b.com')] + ) + + expect(summary.scale).toEqual([ + { label: 'Users', value: 100, average: 50 }, + { label: 'Mailboxes', value: 0, average: 0 }, + { label: 'Managed devices', value: 0, average: 0 }, + ]) + expect(summary.hasData).toBe(true) + }) + + it('reports tenants with no rows at all as never cached', () => { + const summary = deriveCacheSummary( + [row('a.com', 'Users', 2)], + [tenant('a.com'), tenant('b.com')] + ) + + expect(summary.freshness).toEqual({ fresh: 1, stale: 0, missing: 1 }) + expect(summary.staleTenants[0]).toMatchObject({ + name: 'b.com', + detail: 'No cached collections found', + severity: 'critical', + }) + }) +}) diff --git a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx new file mode 100644 index 000000000000..06c181b55374 --- /dev/null +++ b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx @@ -0,0 +1,27 @@ +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' +import { CippBreadcrumbNav } from '../../../src/components/CippComponents/CippBreadcrumbNav' + +// second require.context consumer, this one globs every pages/**/tabOptions.json. covers the +// subdirectory + regex arms of the polyfill that the tutorial glob (flat, no subdirs) doesn't. +// 'Groups' only reaches the trail through src/pages/tenant/administration/tenants/tabOptions.json +vi.mock('next/router', () => ({ + useRouter: () => ({ + pathname: '/tenant/administration/tenants/groups', + asPath: '/tenant/administration/tenants/groups', + query: {}, + isReady: true, + push: () => Promise.resolve(), + replace: () => Promise.resolve(), + events: { on: () => {}, off: () => {}, emit: () => {} }, + }), +})) + +describe('CippBreadcrumbNav', () => { + it('labels the tab crumb from the tabOptions require.context', () => { + renderWithProviders() + + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + expect(screen.getByText('Groups')).toBeInTheDocument() + }) +}) diff --git a/tests/components/ReleaseNotesDialog.test.jsx b/tests/components/ReleaseNotesDialog.test.jsx index ba53c5cb6c13..e74a1c767f51 100644 --- a/tests/components/ReleaseNotesDialog.test.jsx +++ b/tests/components/ReleaseNotesDialog.test.jsx @@ -55,18 +55,18 @@ beforeEach(() => { }) describe('ReleaseNotesDialog', () => { - it('opens on the running hotfix release rather than its .0 base release', async () => { + it('opens on the .0 base release even when running a hotfix build', async () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.8.2 - Hotfix')).toBeInTheDocument() - expect(screen.getByText('Notes for the hotfix that is actually running')).toBeInTheDocument() + expect(await screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz')).toBeInTheDocument() + expect(screen.getByText('Notes for the base release of the 10.8 series')).toBeInTheDocument() }) it('stays dismissed on reload after "Don\'t show until next release"', async () => { const user = userEvent.setup() const { unmount } = renderWithProviders() - await screen.findByText('Release notes for v10.8.2 - Hotfix') + await screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz') await user.click(screen.getByRole('button', { name: "Don't show until next release" })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) diff --git a/tests/contexts/tutorial-context.test.jsx b/tests/contexts/tutorial-context.test.jsx new file mode 100644 index 000000000000..5411488ab20d --- /dev/null +++ b/tests/contexts/tutorial-context.test.jsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { TutorialProvider, useTutorials } from '../../src/contexts/tutorial-context' + +// TutorialProvider loads its tours through webpack's require.context, which vite has no +// equivalent for. tests/mocks/require-context.js maps it onto import.meta.glob, so this +// render is what proves the polyfill actually reaches a src module. +const TutorialProbe = () => { + const { tutorials, getTutorialsForPage } = useTutorials() + return ( + <> +
{tutorials.map((t) => t.id).join(',')}
+
{getTutorialsForPage('/').map((t) => t.id).join(',')}
+ + ) +} + +describe('TutorialProvider', () => { + it('loads the tutorial json off require.context', () => { + render( + + + + ) + + const ids = screen.getByTestId('ids').textContent.split(',') + expect(ids).toEqual( + expect.arrayContaining(['getting-started', 'dashboard-overview', 'tenant-management']) + ) + }) + + it('scopes tutorials to the page they declare', () => { + render( + + + + ) + + // getting-started declares pages: ['/'], the other two declare other routes + expect(screen.getByTestId('home').textContent).toBe('getting-started') + }) +}) diff --git a/vitest.config.mjs b/vitest.config.mjs index 8f2672cbc123..a48c9816f9f4 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -17,6 +17,23 @@ const nextAliases = { 'next/link': path.resolve(dirname, 'tests/mocks/next-link.js'), } +// vitest gives every module its own cjs `require`, which shadows the globalThis polyfill in +// tests/mocks/require-context.js. jsdom only - the browser project has no local require +const requireContextPlugin = { + name: 'cipp-require-context', + enforce: 'pre', + transform(code, id) { + if (id.includes('/node_modules/') || !code.includes('require.context(')) { + return null + } + // lookbehind so an already-prefixed call isn't rewritten to globalThis.globalThis.require + return { + code: code.replace(/(?