Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/store/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
88 changes: 86 additions & 2 deletions packages/store/src/storehooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 <component>-pr-<number> 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 }) => <Provider store={store}>{children}</Provider>;
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 }) => <Provider store={store}>{children}</Provider>;
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 }) => <Provider store={store}>{children}</Provider>;
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;
});
60 changes: 42 additions & 18 deletions packages/store/src/storehooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <component>-pr-<number> (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',
Expand Down Expand Up @@ -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<FeatureFlagsState>((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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -170,19 +194,19 @@ export const useAppInit = (userId: string | undefined): { coreInitDone: boolean
const coreInitRunning = useRef(false);
const fullInitRunning = useRef(false);

const retrieveCoreData = useCallback((): Promise<unknown[]> => {
const tasks: Promise<unknown>[] = [
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<unknown[]> =>
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<unknown[] | unknown> => {
if (isServiceProvider) {
Expand Down