From 3b71c5175031c8c9fbacf83a04a5a53c62484ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:25:41 +0000 Subject: [PATCH 1/2] fix(store): only treat non-preview hosted domains as hosted Previously isHosted was derived from a substring check against 'hosted.mender.io', which also matched per-PR preview deployments such as os-pr-2012.staging.hosted.mender.io, wrongly flagging them as a hosted production environment. Derive the hosted domains from the locations constant and treat a host as hosted when it equals or is a subdomain of one of them (so regular staging hosts like staging.hosted.mender.io stay hosted), while excluding per-PR preview deployments identified by a leading -pr- label. The explicit isHosted feature flag still forces hosted mode. Signed-off-by: Claude --- packages/store/src/storehooks.test.tsx | 27 ++++++++++++++++++++++++-- packages/store/src/storehooks.ts | 11 +++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/store/src/storehooks.test.tsx b/packages/store/src/storehooks.test.tsx index 066292184..90a3f7501 100644 --- a/packages/store/src/storehooks.test.tsx +++ b/packages/store/src/storehooks.test.tsx @@ -26,13 +26,15 @@ import { expect, it, vi } from 'vitest'; 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 +162,24 @@ 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; +}); diff --git a/packages/store/src/storehooks.ts b/packages/store/src/storehooks.ts index 31fc416af..34f315721 100644 --- a/packages/store/src/storehooks.ts +++ b/packages/store/src/storehooks.ts @@ -20,7 +20,7 @@ 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, @@ -63,6 +63,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 +118,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 = { From 6f4956f37140fd3cf967d27508693e458c3b5a44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:26:05 +0000 Subject: [PATCH 2/2] fix(store): detect on-prem enterprise by probing the organization endpoint Core init previously only fetched the organization when hasMultitenancy, isHosted or isEnterprise was already true, so a non-hosted deployment that never pre-set those flags could not be recognised as enterprise even when it exposed the organization endpoint. Always probe getUserOrganization after parseEnvironmentInfo (so the final isHosted value is available) and classify the deployment from the outcome: a reachable endpoint on a non-hosted deployment marks it multitenant enterprise (setFeatures hasMultitenancy/isEnterprise true), an unreachable one marks it a single-tenant OS install (both false). Hosted deployments are left untouched so their enterprise status stays plan-derived via getIsEnterprise. The rejection is caught so core init still resolves. The store setupTests server is exported so tests can override the organization handler to exercise the failure path. Signed-off-by: Claude --- packages/store/setupTests.ts | 2 +- packages/store/src/storehooks.test.tsx | 61 ++++++++++++++++++++++++++ packages/store/src/storehooks.ts | 49 ++++++++++++++------- 3 files changed, 95 insertions(+), 17 deletions(-) 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 90a3f7501..7fd7e80a9 100644 --- a/packages/store/src/storehooks.test.tsx +++ b/packages/store/src/storehooks.test.tsx @@ -17,12 +17,15 @@ 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'; @@ -183,3 +186,61 @@ it('should treat hosted domains and their subdomains as hosted, except per-PR pr } 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 34f315721..0aaf35a08 100644 --- a/packages/store/src/storehooks.ts +++ b/packages/store/src/storehooks.ts @@ -26,7 +26,6 @@ import { getDevicesByStatus as getDevicesByStatusSelector, getFeatures, getGlobalSettings as getGlobalSettingsSelector, - getIsEnterprise, getIsServiceProvider, getOfflineThresholdSettings, getOnboardingState as getOnboardingStateSelector, @@ -136,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; @@ -164,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); @@ -177,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) {