diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx
index a87124466131..f2ef68719bcd 100644
--- a/src/pages/identity/administration/users/user/exchange.jsx
+++ b/src/pages/identity/administration/users/user/exchange.jsx
@@ -801,26 +801,16 @@ const Page = () => {
icon: ,
url: '/api/ExecModifyCalPerms',
customDataformatter: (row, action, formData) => {
- var permissions = []
- if (Array.isArray(row)) {
- row.forEach((item) => {
- const originalUser = item._raw ? item._raw.User : item.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: item.AccessRights,
- FolderName: item.FolderName,
- Modification: 'Remove',
- })
- })
- } else {
- const originalUser = row._raw ? row._raw.User : row.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: row.AccessRights,
- FolderName: row.FolderName,
- Modification: 'Remove',
- })
- }
+ const rows = Array.isArray(row) ? row : [row]
+ // UserId is the resolved recipient; User is only a display
+ // name, which Exchange cannot resolve when two share it.
+ const permissions = rows.map((item) => ({
+ UserID: item._raw?.UserId || item._raw?.User || item.User,
+ DisplayName: item._raw?.User || item.User,
+ PermissionLevel: item.AccessRights,
+ FolderName: item.FolderName,
+ Modification: 'Remove',
+ }))
return {
userID: graphUserRequest.data?.[0]?.userPrincipalName,
tenantFilter: userSettingsDefaults.currentTenant,
@@ -870,7 +860,8 @@ const Page = () => {
tenantFilter: userSettingsDefaults.currentTenant,
permissions: [
{
- UserID: originalUser, // Use original identifier for API calls
+ UserID: data._raw?.UserId || originalUser,
+ DisplayName: originalUser,
PermissionLevel: data.AccessRights,
FolderName: data.FolderName,
Modification: 'Remove',
@@ -944,26 +935,16 @@ const Page = () => {
icon: ,
url: '/api/ExecModifyContactPerms',
customDataformatter: (row, action, formData) => {
- var permissions = []
- if (Array.isArray(row)) {
- row.forEach((item) => {
- const originalUser = item._raw ? item._raw.User : item.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: item.AccessRights,
- FolderName: item.FolderName,
- Modification: 'Remove',
- })
- })
- } else {
- const originalUser = row._raw ? row._raw.User : row.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: row.AccessRights,
- FolderName: row.FolderName,
- Modification: 'Remove',
- })
- }
+ const rows = Array.isArray(row) ? row : [row]
+ // UserId is the resolved recipient; User is only a display
+ // name, which Exchange cannot resolve when two share it.
+ const permissions = rows.map((item) => ({
+ UserID: item._raw?.UserId || item._raw?.User || item.User,
+ DisplayName: item._raw?.User || item.User,
+ PermissionLevel: item.AccessRights,
+ FolderName: item.FolderName,
+ Modification: 'Remove',
+ }))
return {
userID: graphUserRequest.data?.[0]?.userPrincipalName,
tenantFilter: userSettingsDefaults.currentTenant,
@@ -1013,7 +994,8 @@ const Page = () => {
tenantFilter: userSettingsDefaults.currentTenant,
permissions: [
{
- UserID: originalUser, // Use original identifier for API calls
+ UserID: data._raw?.UserId || originalUser,
+ DisplayName: originalUser,
PermissionLevel: data.AccessRights,
FolderName: data.FolderName,
Modification: 'Remove',
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(/(?