diff --git a/packages/store/setupTests.ts b/packages/store/setupTests.ts index 62ea06bde..f9b751fdc 100644 --- a/packages/store/setupTests.ts +++ b/packages/store/setupTests.ts @@ -22,7 +22,7 @@ process.on('unhandledRejection', err => { }); // Setup requests interception -const server = setupServer(...handlers); +export const server = setupServer(...handlers); beforeAll(async () => { await server.listen({ onUnhandledRequest: 'error' }); diff --git a/packages/store/src/storehooks.test.tsx b/packages/store/src/storehooks.test.tsx index 066292184..7fd7e80a9 100644 --- a/packages/store/src/storehooks.test.tsx +++ b/packages/store/src/storehooks.test.tsx @@ -17,22 +17,27 @@ import { Provider } from 'react-redux'; import { defaultState } from '@/testUtils'; import { userId } from '@northern.tech/testing/mockData'; import { inventoryDevice } from '@northern.tech/testing/requestHandlers/deviceHandlers'; +import { tenantadmApiUrlv1 } from '@northern.tech/utils/constants'; import { deepCompare } from '@northern.tech/utils/helpers'; import { renderHook, waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; import configureMockStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import { expect, it, vi } from 'vitest'; +import { server } from '../setupTests'; import { actions as appActions } from './appSlice'; import { getSessionInfo } from './auth'; import { EXTERNAL_PROVIDER, timeUnits } from './commonConstants'; -import { DEVICE_STATES } from './constants'; +import { DEVICE_STATES, locations } from './constants'; import { expectedOnboardingActions } from './onboardingSlice/thunks.test'; import { actions as organizationActions } from './organizationSlice'; -import { useAppInit } from './storehooks'; +import { parseEnvironmentInfo, useAppInit } from './storehooks'; import { getUserOrganization } from './thunks'; import { actions as userActions } from './usersSlice'; +const oldHostname = window.location.hostname; + const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -160,3 +165,82 @@ it('should trigger the offline threshold migration dialog', async () => { const notificationAction = storeActions.find(action => action.type === userActions.setShowStartupNotification.type); expect(notificationAction.payload).toBeTruthy(); }); +it('should treat hosted domains and their subdomains as hosted, except per-PR preview deployments', async () => { + const expectations = [ + { hostname: locations.us.location, isHosted: true }, + { hostname: locations.eu.location, isHosted: true }, + { hostname: locations.cn.location, isHosted: true }, + // staging and other subdomains of a hosted domain are valid hosted instances + { hostname: `staging.${locations.us.location}`, isHosted: true }, + { hostname: `testing.staging.${locations.us.location}`, isHosted: true }, + // per-PR preview deployments (leading -pr- label) must not be treated as hosted + { hostname: `os-pr-2012.staging.${locations.us.location}`, isHosted: false }, + { hostname: 'localhost', isHosted: false } + ]; + for (const { hostname, isHosted } of expectations) { + window.location.hostname = hostname; + const store = mockStore({ ...defaultState, app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: false } } }); + await store.dispatch(parseEnvironmentInfo()); + const setFeaturesAction = store.getActions().find(action => action.type === appActions.setFeatures.type); + expect(setFeaturesAction.payload.isHosted).toBe(isHosted); + } + window.location.hostname = oldHostname; +}); + +const makeCoreInitStore = overrides => + mockStore({ + ...defaultState, + ...overrides, + app: { ...defaultState.app, ...overrides.app, features: { ...defaultState.app.features, ...overrides.app?.features } }, + organization: { ...defaultState.organization, organization: {}, ...overrides.organization }, + users: { + ...defaultState.users, + currentSession: getSessionInfo(), + globalSettings: { ...defaultState.users.globalSettings, id_attribute: { attribute: 'mac', scope: 'identity' } } + } + }); + +const onPremEnterpriseFeatures = { hasMultitenancy: true, isEnterprise: true }; +const osFeatures = { hasMultitenancy: false, isEnterprise: false }; +const setFeaturesActionsFor = (storeActions, payload) => + storeActions.filter(action => action.type === appActions.setFeatures.type && deepCompare(action.payload, payload)); + +it('should mark a non-hosted deployment with a reachable organization endpoint as multitenant enterprise', async () => { + const store = makeCoreInitStore({ app: { features: { isHosted: false } } }); + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useAppInit(userId), { wrapper }); + await waitFor(() => expect(result.current.coreInitDone).toBeTruthy()); + await vi.runAllTimersAsync(); + const storeActions = store.getActions(); + expect(storeActions.some(action => action.type === getUserOrganization.fulfilled.type)).toBeTruthy(); + // a reachable org endpoint on a non-hosted deployment identifies an on-prem enterprise install + expect(setFeaturesActionsFor(storeActions, onPremEnterpriseFeatures)).toHaveLength(1); +}); +it('should mark a non-hosted deployment as OS when the organization endpoint is unreachable', async () => { + server.use(http.get(`${tenantadmApiUrlv1}/user/tenant`, () => new HttpResponse(null, { status: 500 }))); + const store = makeCoreInitStore({ app: { features: { isHosted: false } } }); + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useAppInit(userId), { wrapper }); + await waitFor(() => expect(result.current.coreInitDone).toBeTruthy()); + await vi.runAllTimersAsync(); + const storeActions = store.getActions(); + // a failed probe must be caught so core init still completes, and the deployment is marked non-enterprise/OS + expect(storeActions.some(action => action.type === getUserOrganization.rejected.type)).toBeTruthy(); + expect(storeActions.some(action => action.type === organizationActions.setOrganization.type)).toBeFalsy(); + expect(setFeaturesActionsFor(storeActions, osFeatures)).toHaveLength(1); +}); +it('should keep hosted deployments enterprise/multitenant without overriding them from the probe', async () => { + window.location.hostname = locations.us.location; + const store = makeCoreInitStore({ app: { features: { isHosted: true } } }); + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useAppInit(userId), { wrapper }); + await waitFor(() => expect(result.current.coreInitDone).toBeTruthy()); + await vi.runAllTimersAsync(); + const storeActions = store.getActions(); + const setFeaturesAction = storeActions.find(action => action.type === appActions.setFeatures.type); + expect(setFeaturesAction.payload.isHosted).toBe(true); + // hosted enterprise status stays plan-derived, so the probe must not push on-prem/OS overrides + expect(setFeaturesActionsFor(storeActions, onPremEnterpriseFeatures)).toHaveLength(0); + expect(setFeaturesActionsFor(storeActions, osFeatures)).toHaveLength(0); + window.location.hostname = oldHostname; +}); diff --git a/packages/store/src/storehooks.ts b/packages/store/src/storehooks.ts index 31fc416af..0aaf35a08 100644 --- a/packages/store/src/storehooks.ts +++ b/packages/store/src/storehooks.ts @@ -20,13 +20,12 @@ import Cookies from 'universal-cookie'; import storeActions from './actions'; import { getSessionInfo } from './auth'; -import { DEPLOYMENT_STATES, DEVICE_STATES, TIMEOUTS, timeUnits } from './constants'; +import { DEPLOYMENT_STATES, DEVICE_STATES, TIMEOUTS, locations, timeUnits } from './constants'; import type { DeviceSliceType } from './devicesSlice'; import { getDevicesByStatus as getDevicesByStatusSelector, getFeatures, getGlobalSettings as getGlobalSettingsSelector, - getIsEnterprise, getIsServiceProvider, getOfflineThresholdSettings, getOnboardingState as getOnboardingStateSelector, @@ -63,6 +62,13 @@ dayjs.extend(durationDayJs); const { setDeviceListState, setFirstLoginAfterSignup, setTooltipsState, setShowStartupNotification } = storeActions; +// a host is hosted when it is one of the known hosted domains or a subdomain of one (e.g. staging.hosted.mender.io), +// except for per-PR preview deployments whose leading label looks like -pr- (e.g. os-pr-2012.staging.hosted.mender.io) +const hostedDomains = Object.values(locations).map(({ location }) => location); +const isPreviewDeployment = (hostname: string) => /^[^.]+-pr-\d+\./.test(hostname); +const isHostedHostname = (hostname: string) => + !isPreviewDeployment(hostname) && hostedDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`)); + const featureFlags = [ 'hasAiEnabled', 'hasAuditlogs', @@ -111,7 +117,7 @@ export const parseEnvironmentInfo = createAppAsyncThunk(`app/parseEnvironmentInf environmentData = environmentDatas.reduce((accu, flag) => ({ ...accu, [flag]: mender_environment[flag] || appState[flag as keyof typeof appState] }), {}); environmentFeatures = { ...featureFlags.reduce((accu, flag) => ({ ...accu, [flag]: stringToBoolean(features[flag]) }), {}), - isHosted: stringToBoolean(features.isHosted) || window.location.hostname.includes('hosted.mender.io') + isHosted: stringToBoolean(features.isHosted) || isHostedHostname(window.location.hostname) }; onboardingComplete = !environmentFeatures.isHosted || stringToBoolean(disableOnboarding) || onboardingComplete; versionInfo = { @@ -129,6 +135,25 @@ export const parseEnvironmentInfo = createAppAsyncThunk(`app/parseEnvironmentInf ]); }); +// probes the organization endpoint to classify the deployment: reaching it means the deployment is multitenant, +// which - when not hosted - identifies an on-prem enterprise installation, while an unreachable endpoint identifies +// a single-tenant OS installation. hosted deployments keep their plan-derived enterprise status (getIsEnterprise). +// must run after parseEnvironmentInfo so the final isHosted value is available in the store. +export const probeOrganizationCapabilities = createAppAsyncThunk(`app/probeOrganizationCapabilities`, (_, { dispatch, getState }) => + dispatch(getUserOrganization()) + .unwrap() + .then(() => { + if (!getFeatures(getState()).isHosted) { + dispatch(storeActions.setFeatures({ hasMultitenancy: true, isEnterprise: true })); + } + }) + .catch(() => { + if (!getFeatures(getState()).isHosted) { + dispatch(storeActions.setFeatures({ hasMultitenancy: false, isEnterprise: false })); + } + }) +); + type OnboardingState = { complete?: boolean; showTips?: boolean | null; @@ -157,8 +182,7 @@ const maybeAddOnboardingTasks = ({ devicesByStatus, dispatch, onboardingState, t export const useAppInit = (userId: string | undefined): { coreInitDone: boolean } => { const dispatch = useAppDispatch(); const [coreInitDone, setCoreInitDone] = useState(false); - const isEnterprise = useAppSelector(getIsEnterprise); - const { hasMultitenancy, isHosted } = useAppSelector(getFeatures); + const { hasMultitenancy } = useAppSelector(getFeatures); const devicesByStatus = useAppSelector(getDevicesByStatusSelector); const onboardingState = useAppSelector(getOnboardingStateSelector); const { columnSelection = [], trackingConsentGiven: hasTrackingEnabled, tooltips = {} } = useAppSelector(getUserSettingsSelector); @@ -170,19 +194,19 @@ export const useAppInit = (userId: string | undefined): { coreInitDone: boolean const coreInitRunning = useRef(false); const fullInitRunning = useRef(false); - const retrieveCoreData = useCallback((): Promise => { - const tasks: Promise[] = [ - dispatch(parseEnvironmentInfo()).unwrap(), - dispatch(getUserSettings()).unwrap(), - dispatch(getGlobalSettings()).unwrap(), - Promise.resolve(dispatch(setFirstLoginAfterSignup(stringToBoolean(cookies.get('firstLoginAfterSignup'))))) - ]; - const multitenancy = hasMultitenancy || isHosted || isEnterprise; - if (multitenancy) { - tasks.push(dispatch(getUserOrganization()).unwrap()); - } - return Promise.all(tasks); - }, [dispatch, hasMultitenancy, isHosted, isEnterprise]); + const retrieveCoreData = useCallback( + (): Promise => + Promise.all([ + // parse the environment first so isHosted is set before the organization probe classifies the deployment type + dispatch(parseEnvironmentInfo()) + .unwrap() + .then(() => dispatch(probeOrganizationCapabilities()).unwrap()), + dispatch(getUserSettings()).unwrap(), + dispatch(getGlobalSettings()).unwrap(), + Promise.resolve(dispatch(setFirstLoginAfterSignup(stringToBoolean(cookies.get('firstLoginAfterSignup'))))) + ]), + [dispatch] + ); const retrieveAppData = useCallback((): Promise => { if (isServiceProvider) {