@controleonline/ui-common 1.2.103 → 1.2.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/react/components/DefaultProvider.native.lifecycle.js +4 -1
- package/src/react/components/DefaultProvider.web.configuration.js +30 -21
- package/src/react/components/RuntimeBottomNavigationBar.js +10 -2
- package/src/react/components/UserAvatar.js +54 -63
- package/src/react/utils/fileUrl.js +12 -15
- package/src/tests/react/components/avatarSessionRecovery.test.js +102 -0
- package/src/tests/react/components/singleItemCompanyDevice.test.js +18 -0
- package/src/tests/react/utils/fileUrl.test.js +23 -0
- package/src/utils/trustedApiUrl.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@controleonline/ui-common",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.105",
|
|
4
4
|
"author": "ControleOnline",
|
|
5
5
|
"description": "ControleOnline Common Quasar UI Component",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"last 1 version, not dead, ie >= 11"
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@controleonline/ui-default": "
|
|
41
|
+
"@controleonline/ui-default": "1.0.281",
|
|
42
42
|
"md5": "^2.3.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -294,6 +294,7 @@ export function useNativeDeviceConfigFetch({device, isLogged, currentCompany, se
|
|
|
294
294
|
currentCompany &&
|
|
295
295
|
Object.entries(currentCompany).length > 0
|
|
296
296
|
) {
|
|
297
|
+
let active = true;
|
|
297
298
|
setDeviceConfigFetched(false);
|
|
298
299
|
setDeviceRuntimeConfigSynced(false);
|
|
299
300
|
deviceConfigsActions
|
|
@@ -303,6 +304,7 @@ export function useNativeDeviceConfigFetch({device, isLogged, currentCompany, se
|
|
|
303
304
|
type: runtimeDeviceType,
|
|
304
305
|
})
|
|
305
306
|
.then(data => {
|
|
307
|
+
if (!active) return;
|
|
306
308
|
if (Array.isArray(data) && data.length > 0) {
|
|
307
309
|
const nextItem = {
|
|
308
310
|
...data[0],
|
|
@@ -316,8 +318,9 @@ export function useNativeDeviceConfigFetch({device, isLogged, currentCompany, se
|
|
|
316
318
|
})
|
|
317
319
|
.catch(() => {})
|
|
318
320
|
.finally(() => {
|
|
319
|
-
setDeviceConfigFetched(true);
|
|
321
|
+
if (active) setDeviceConfigFetched(true);
|
|
320
322
|
});
|
|
323
|
+
return () => { active = false; };
|
|
321
324
|
}
|
|
322
325
|
}, [currentCompany?.id, isLogged, device?.id, runtimeDeviceType]);
|
|
323
326
|
}
|
|
@@ -8,6 +8,7 @@ export function useWebDeviceConfigFetch({device, isLogged, deviceConfigPeopleIri
|
|
|
8
8
|
return;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
let active = true;
|
|
11
12
|
setDeviceConfigFetched(false);
|
|
12
13
|
setDeviceRuntimeConfigSynced(false);
|
|
13
14
|
deviceConfigsActions.setItem({});
|
|
@@ -19,6 +20,7 @@ export function useWebDeviceConfigFetch({device, isLogged, deviceConfigPeopleIri
|
|
|
19
20
|
type: runtimeDeviceType,
|
|
20
21
|
})
|
|
21
22
|
.then(data => {
|
|
23
|
+
if (!active) return;
|
|
22
24
|
const selectedConfig = selectRuntimeDeviceConfig({
|
|
23
25
|
items: data,
|
|
24
26
|
deviceId: device.id,
|
|
@@ -39,8 +41,9 @@ export function useWebDeviceConfigFetch({device, isLogged, deviceConfigPeopleIri
|
|
|
39
41
|
})
|
|
40
42
|
.catch(() => {})
|
|
41
43
|
.finally(() => {
|
|
42
|
-
setDeviceConfigFetched(true);
|
|
44
|
+
if (active) setDeviceConfigFetched(true);
|
|
43
45
|
});
|
|
46
|
+
return () => { active = false; };
|
|
44
47
|
}, [
|
|
45
48
|
device?.id,
|
|
46
49
|
deviceConfigPeopleIri,
|
|
@@ -250,38 +253,44 @@ export function useWebCompanies({device, isLogged, peopleActions}) {
|
|
|
250
253
|
}, [isLogged, device?.id]);
|
|
251
254
|
}
|
|
252
255
|
|
|
253
|
-
export function useWebMenus({isLogged, currentCompany, actions, api, appType, normalizeRuntimeMenuResponse}) {
|
|
256
|
+
export function useWebMenus({isLogged, currentCompany, actions, api, appType, normalizeRuntimeMenuResponse, menuContextKey, setMenuLoad, includeToolbar = false}) {
|
|
254
257
|
useEffect(() => {
|
|
255
258
|
if (!isLogged || !currentCompany?.id) {
|
|
256
259
|
actions.setMenus([]);
|
|
260
|
+
setMenuLoad?.({key: menuContextKey, status: 'idle'});
|
|
257
261
|
return undefined;
|
|
258
262
|
}
|
|
259
263
|
|
|
260
264
|
let cancelled = false;
|
|
265
|
+
setMenuLoad?.({key: menuContextKey, status: 'loading'});
|
|
261
266
|
|
|
262
|
-
api
|
|
263
|
-
.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
267
|
+
const fetchMenus = menuType => api.fetch('menus-people', {
|
|
268
|
+
params: {myCompany: currentCompany.id, appType, menuType},
|
|
269
|
+
});
|
|
270
|
+
const request = includeToolbar
|
|
271
|
+
? Promise.all([fetchMenus('home'), fetchMenus('toolbar')])
|
|
272
|
+
: fetchMenus('home').then(result => [result]);
|
|
273
|
+
request.then(results => {
|
|
274
|
+
if (cancelled) return;
|
|
275
|
+
if (results.some(result => result?.response?.success === false)) {
|
|
276
|
+
actions.setMenus([]);
|
|
277
|
+
setMenuLoad?.({key: menuContextKey, status: 'error'});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
actions.setMenus(results.flatMap(result => normalizeRuntimeMenuResponse(result, {
|
|
281
|
+
appType, ...(includeToolbar ? {allowFallback: false} : {}),
|
|
282
|
+
}) || []));
|
|
283
|
+
setMenuLoad?.({key: menuContextKey, status: 'ready'});
|
|
284
|
+
}).catch(() => {
|
|
285
|
+
if (cancelled) return;
|
|
286
|
+
actions.setMenus(includeToolbar ? [] : normalizeRuntimeMenuResponse(null, {appType}));
|
|
287
|
+
setMenuLoad?.({key: menuContextKey, status: 'error'});
|
|
288
|
+
});
|
|
280
289
|
|
|
281
290
|
return () => {
|
|
282
291
|
cancelled = true;
|
|
283
292
|
};
|
|
284
|
-
}, [actions, appType, currentCompany?.id, isLogged]);
|
|
293
|
+
}, [actions, appType, currentCompany?.id, isLogged, menuContextKey, setMenuLoad, includeToolbar]);
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
export function useWebThemeFetch({api, parseThemeCss, setBaseThemeColors, actions, currentCompany, mainCompany, device}) {
|
|
@@ -3,6 +3,7 @@ import {useNavigationState} from '@react-navigation/native';
|
|
|
3
3
|
import {useStore} from '@store';
|
|
4
4
|
import {api} from '@controleonline/ui-common/src/api';
|
|
5
5
|
import {app_type} from '@appType';
|
|
6
|
+
import {useTheme} from './DefaultProvider';
|
|
6
7
|
import BottomNavigationBar from '@controleonline/ui-common/src/react/components/BottomNavigationBar';
|
|
7
8
|
import {
|
|
8
9
|
getBottomNavigationPreset,
|
|
@@ -33,6 +34,8 @@ const RuntimeBottomNavigationBar = ({
|
|
|
33
34
|
testID = 'bottom-navigation',
|
|
34
35
|
useModernWebChromeProps = false,
|
|
35
36
|
}) => {
|
|
37
|
+
const {runtimeMenus: providerMenus} = useTheme?.() || {};
|
|
38
|
+
const managedMenus = providerMenus?.managed === true;
|
|
36
39
|
const state = useNavigationState(current => current);
|
|
37
40
|
const activeRouteName =
|
|
38
41
|
activeRouteNameProp || state?.routes?.[state.index]?.name || 'HomePage';
|
|
@@ -48,10 +51,15 @@ const RuntimeBottomNavigationBar = ({
|
|
|
48
51
|
const routeAliases = routeAliasesProp || preset?.routeAliases || {};
|
|
49
52
|
const appType = normalizeAppType(app_type);
|
|
50
53
|
const menuScope = JSON.stringify([appType, currentCompany?.id, menuType, presetKey]);
|
|
51
|
-
const runtimeMenus =
|
|
54
|
+
const runtimeMenus = managedMenus
|
|
55
|
+
? (providerMenus.ready ? resolveToolbarRuntimeMenus({
|
|
56
|
+
apiMenus: providerMenus.modules, preset, presetKey, menuType, translate: global.t?.t,
|
|
57
|
+
}) : [])
|
|
58
|
+
: menuState.scope === menuScope ? menuState.menus : [];
|
|
52
59
|
|
|
53
60
|
// Always consult menus-people for role-aware toolbar; preset is fallback only.
|
|
54
61
|
useEffect(() => {
|
|
62
|
+
if (managedMenus) return undefined;
|
|
55
63
|
const setRuntimeMenus = menus => setMenuState({scope: menuScope, menus});
|
|
56
64
|
if (!currentCompany?.id || !menuType) {
|
|
57
65
|
setRuntimeMenus(
|
|
@@ -109,7 +117,7 @@ const RuntimeBottomNavigationBar = ({
|
|
|
109
117
|
return () => {
|
|
110
118
|
cancelled = true;
|
|
111
119
|
};
|
|
112
|
-
}, [appType, currentCompany?.id, menuType, preset, presetKey, menuScope]);
|
|
120
|
+
}, [appType, currentCompany?.id, menuType, preset, presetKey, menuScope, managedMenus]);
|
|
113
121
|
|
|
114
122
|
const colors = useMemo(() => {
|
|
115
123
|
if (colorsOverride) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {readStoredSessionToken} from '../../utils/storedSessionToken';
|
|
2
|
-
import {isTrustedApiUrl, filterApiHeaders} from '../../utils/trustedApiUrl';
|
|
2
|
+
import {isTrustedApiUrl, filterApiHeaders, resolveApiFileProtocol} from '../../utils/trustedApiUrl';
|
|
3
3
|
import React, {useEffect, useMemo, useState} from 'react';
|
|
4
4
|
import {Image, Platform, StyleSheet, Text, View} from 'react-native';
|
|
5
5
|
import {env as APP_ENV} from '@env';
|
|
@@ -55,99 +55,90 @@ const probeGravatar = async url => {
|
|
|
55
55
|
* - Gravatar (d=404) on web: probe first; never set <img src> on expected 404.
|
|
56
56
|
* - Other URLs / native: pass through (native Image onError handles 404).
|
|
57
57
|
*/
|
|
58
|
-
const useDisplayUri = uri => {
|
|
59
|
-
const [
|
|
60
|
-
const [resolved, setResolved] = useState(false);
|
|
58
|
+
const useDisplayUri = (uri, imageAuthContext) => {
|
|
59
|
+
const [result, setResult] = useState(null);
|
|
61
60
|
|
|
62
61
|
useEffect(() => {
|
|
63
62
|
let cancelled = false;
|
|
64
63
|
let objectUrl = '';
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
65
|
+
const next = Platform.OS === 'web'
|
|
66
|
+
? resolveApiFileProtocol(normalizeUrl(uri), APP_ENV?.API_ENTRYPOINT)
|
|
67
|
+
: normalizeUrl(uri);
|
|
68
|
+
const finish = displayUri => {
|
|
69
|
+
if (!cancelled) setResult({uri, imageAuthContext, displayUri, resolved: true});
|
|
70
|
+
};
|
|
69
71
|
|
|
70
72
|
const run = async () => {
|
|
71
|
-
if (!next) {
|
|
72
|
-
if (!cancelled) {
|
|
73
|
-
setDisplayUri('');
|
|
74
|
-
setResolved(true);
|
|
75
|
-
}
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const token = readStoredSessionToken();
|
|
73
|
+
if (!next) { finish(''); return; }
|
|
80
74
|
|
|
81
|
-
//
|
|
75
|
+
// An explicitly unresolved store session must not reuse an older stored token.
|
|
76
|
+
const token = imageAuthContext === undefined
|
|
77
|
+
? readStoredSessionToken() : normalizeUrl(imageAuthContext);
|
|
82
78
|
if (isBackendDownloadUrl(next) && isTrustedApiUrl(next, APP_ENV?.API_ENTRYPOINT)
|
|
83
|
-
&& Platform.OS === 'web'
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
79
|
+
&& Platform.OS === 'web') {
|
|
80
|
+
// Never expose a private download directly to an unauthenticated <Image>.
|
|
81
|
+
if (!token) { finish(''); return; }
|
|
82
|
+
const headers = {Accept: '*/*', 'API-TOKEN': token};
|
|
83
|
+
const host = normalizeUrl(APP_ENV?.DOMAIN) ||
|
|
84
|
+
(globalThis.location?.host || '');
|
|
85
|
+
if (host) headers['App-Domain'] = host;
|
|
86
|
+
|
|
87
|
+
for (let attempt = 0; attempt < 2 && !cancelled; attempt += 1) {
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await fetch(next, {
|
|
91
|
+
method: 'GET', headers, redirect: 'error', signal: controller?.signal,
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (cancelled || error?.name === 'AbortError') return;
|
|
95
|
+
if (attempt === 0) continue;
|
|
96
|
+
finish(''); return;
|
|
94
97
|
}
|
|
95
|
-
|
|
96
|
-
const response = await fetch(next, {method: 'GET', headers, redirect: 'error'});
|
|
98
|
+
if (cancelled) return;
|
|
97
99
|
if (!response.ok) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
return;
|
|
100
|
+
// One recovery attempt for transient server failures, never auth failures.
|
|
101
|
+
if (attempt === 0 && response.status >= 500 && response.status < 600) continue;
|
|
102
|
+
finish(''); return;
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
} catch {
|
|
112
|
-
if (!cancelled) {
|
|
113
|
-
setDisplayUri('');
|
|
114
|
-
setResolved(true);
|
|
104
|
+
try {
|
|
105
|
+
const blob = await response.blob();
|
|
106
|
+
if (cancelled) return;
|
|
107
|
+
objectUrl = URL.createObjectURL(blob);
|
|
108
|
+
finish(objectUrl);
|
|
109
|
+
} catch {
|
|
110
|
+
finish('');
|
|
115
111
|
}
|
|
112
|
+
return;
|
|
116
113
|
}
|
|
117
114
|
return;
|
|
118
115
|
}
|
|
119
116
|
|
|
120
|
-
// Gravatar d=404: probe on web so <img> never receives a known-missing URL
|
|
121
117
|
if (isGravatarUrl(next) && Platform.OS === 'web') {
|
|
122
118
|
const ok = await probeGravatar(next);
|
|
123
|
-
|
|
124
|
-
setDisplayUri(ok ? next : '');
|
|
125
|
-
setResolved(true);
|
|
126
|
-
}
|
|
119
|
+
finish(ok ? next : '');
|
|
127
120
|
return;
|
|
128
121
|
}
|
|
129
|
-
|
|
130
|
-
// Default: use as-is (native, or non-special web URLs)
|
|
131
|
-
if (!cancelled) {
|
|
132
|
-
setDisplayUri(next);
|
|
133
|
-
setResolved(true);
|
|
134
|
-
}
|
|
122
|
+
finish(next);
|
|
135
123
|
};
|
|
136
|
-
|
|
137
124
|
run();
|
|
138
125
|
|
|
139
126
|
return () => {
|
|
140
127
|
cancelled = true;
|
|
128
|
+
controller?.abort();
|
|
141
129
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
142
130
|
};
|
|
143
|
-
}, [uri]);
|
|
131
|
+
}, [uri, imageAuthContext]);
|
|
144
132
|
|
|
145
|
-
|
|
133
|
+
// Do not show a previous session's blob while the next effect is starting.
|
|
134
|
+
return result && result.uri === uri && result.imageAuthContext === imageAuthContext
|
|
135
|
+
? result : {displayUri: '', resolved: false};
|
|
146
136
|
};
|
|
147
137
|
|
|
148
138
|
const UserAvatar = ({
|
|
149
139
|
imageUrl,
|
|
150
140
|
imageHeaders,
|
|
141
|
+
imageAuthContext,
|
|
151
142
|
email,
|
|
152
143
|
name,
|
|
153
144
|
size = 40,
|
|
@@ -170,10 +161,10 @@ const UserAvatar = ({
|
|
|
170
161
|
|
|
171
162
|
useEffect(() => {
|
|
172
163
|
setSourceIndex(0);
|
|
173
|
-
}, [sources]);
|
|
164
|
+
}, [sources, imageAuthContext]);
|
|
174
165
|
|
|
175
166
|
const currentSource = sources[sourceIndex];
|
|
176
|
-
const {displayUri, resolved} = useDisplayUri(currentSource);
|
|
167
|
+
const {displayUri, resolved} = useDisplayUri(currentSource, imageAuthContext);
|
|
177
168
|
|
|
178
169
|
// After probe resolves empty for gravatar/backend → try next source or initials
|
|
179
170
|
useEffect(() => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {env as APP_ENV} from '@env';
|
|
2
|
-
import {filterApiHeaders} from '../../utils/trustedApiUrl';
|
|
2
|
+
import {filterApiHeaders, isTrustedApiUrl} from '../../utils/trustedApiUrl';
|
|
3
3
|
import {buildAssetUrl} from '@controleonline/../../src/styles/branding';
|
|
4
4
|
import {
|
|
5
5
|
resolveAppDomain,
|
|
@@ -79,16 +79,11 @@ const ensureAbsoluteUrl = url => {
|
|
|
79
79
|
const resolveDownloadHost = ({company = null, appDomain = ''} = {}) => {
|
|
80
80
|
const fallbackDomain =
|
|
81
81
|
normalizeText(appDomain) || resolveAppDomain(APP_ENV?.DOMAIN);
|
|
82
|
-
|
|
82
|
+
const companyDomain = resolveCompanyDomain(company, fallbackDomain);
|
|
83
|
+
// FileService may return the API authority instead of an application domain.
|
|
84
|
+
return isApiEntrypointHost(companyDomain) ? fallbackDomain : companyDomain;
|
|
83
85
|
};
|
|
84
86
|
|
|
85
|
-
/**
|
|
86
|
-
* Environment / platform hosts (staging, dev, localhost) are not tenant domains.
|
|
87
|
-
* Injecting them into /{host}/files/{id}/download produces URLs like
|
|
88
|
-
* https://s.controleonline.com/staging.controleonline.com/files/... → 403.
|
|
89
|
-
* Keep app-domain header only; do not path-inject environment hosts.
|
|
90
|
-
* app-community#432
|
|
91
|
-
*/
|
|
92
87
|
const resolveApiEntrypointHost = () => {
|
|
93
88
|
const entry = normalizeText(APP_ENV?.API_ENTRYPOINT);
|
|
94
89
|
if (!entry) {
|
|
@@ -216,11 +211,10 @@ const buildTenantDownloadUrl = (url, host) => {
|
|
|
216
211
|
return absoluteUrl;
|
|
217
212
|
}
|
|
218
213
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (isEnvironmentLikeHost(normalizedHost) || isApiEntrypointHost(normalizedHost)) {
|
|
214
|
+
// Public file routes require the application domain, including registered
|
|
215
|
+
// staging/dev/platform domains. Never use the API authority as that domain.
|
|
216
|
+
if (isApiEntrypointHost(normalizedHost) ||
|
|
217
|
+
!isTrustedApiUrl(absoluteUrl, APP_ENV?.API_ENTRYPOINT)) {
|
|
224
218
|
return absoluteUrl;
|
|
225
219
|
}
|
|
226
220
|
|
|
@@ -377,8 +371,11 @@ const resolveFileSource = (
|
|
|
377
371
|
isDownloadLikeUrl(normalizedFile?.uri) ||
|
|
378
372
|
isDownloadLikeUrl(normalizedFile?.url) ||
|
|
379
373
|
isDownloadLikeUrl(normalizedFile?.path);
|
|
374
|
+
const rawUrl = normalizedFile.url || normalizedFile.path || normalizedFile.uri;
|
|
375
|
+
// Relative backend paths belong to API_ENTRYPOINT. File.domain selects the
|
|
376
|
+
// route's tenant segment, not a new server origin.
|
|
380
377
|
const uriBase =
|
|
381
|
-
directUrl ||
|
|
378
|
+
(isDownloadLikeUrl(rawUrl) ? rawUrl : directUrl) ||
|
|
382
379
|
(fileId ? buildBackendDownloadUrl(fileId, {company, appDomain}) : '');
|
|
383
380
|
|
|
384
381
|
if (!uriBase) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import renderer, {act} from 'react-test-renderer';
|
|
3
|
+
import UserAvatar from '@controleonline/ui-common/src/react/components/UserAvatar';
|
|
4
|
+
jest.mock('@env', () => ({env: {API_ENTRYPOINT: 'https://api.synthetic.test', DOMAIN: 'synthetic.test'}}));
|
|
5
|
+
jest.mock('@store', () => ({useStore: jest.fn()}));
|
|
6
|
+
jest.mock('react-native', () => ({Platform: {OS: 'web'}, Image: 'Image', Text: 'Text', View: 'View', StyleSheet: {create: value => value}}));
|
|
7
|
+
const {useStore} = require('@store');
|
|
8
|
+
const imageUrl = 'https://api.synthetic.test/files/42/download';
|
|
9
|
+
const ok = () => ({ok: true, status: 200, blob: async () => ({})});
|
|
10
|
+
let tree;
|
|
11
|
+
let original;
|
|
12
|
+
const render = async props => {await act(async () => {tree = renderer.create(<UserAvatar imageUrl={imageUrl} useGravatar={false} {...props}/>);});};
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
global.IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
original = {fetch: global.fetch, storage: global.localStorage, create: URL.createObjectURL, revoke: URL.revokeObjectURL};
|
|
16
|
+
global.localStorage = {getItem: () => null};
|
|
17
|
+
global.fetch = jest.fn().mockResolvedValue(ok());
|
|
18
|
+
URL.createObjectURL = jest.fn(() => 'blob:synthetic');
|
|
19
|
+
URL.revokeObjectURL = jest.fn();
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (tree) act(() => tree.unmount());
|
|
23
|
+
tree = undefined;
|
|
24
|
+
global.fetch = original.fetch; global.localStorage = original.storage;
|
|
25
|
+
URL.createObjectURL = original.create; URL.revokeObjectURL = original.revoke;
|
|
26
|
+
});
|
|
27
|
+
it('renders initials before an image and authentication context exist', async () => {
|
|
28
|
+
await render({imageUrl: undefined, imageAuthContext: undefined, name: 'Leandro'});
|
|
29
|
+
expect(tree.root.findAllByType('Image')).toHaveLength(0);
|
|
30
|
+
expect(tree.root.findByType('Text').props.children).toBe('L');
|
|
31
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
it('waits for session then fetches the same private URL', async () => {
|
|
34
|
+
await render({imageAuthContext: ''});
|
|
35
|
+
expect(tree.root.findAllByType('Image')).toHaveLength(0);
|
|
36
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
37
|
+
await act(async () => {tree.update(<UserAvatar imageUrl={imageUrl} useGravatar={false} imageAuthContext="session-ready"/>);});
|
|
38
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(fetch.mock.calls[0][1].headers['API-TOKEN']).toBe('session-ready');
|
|
40
|
+
});
|
|
41
|
+
it('uses the configured protocol for private images from the same API authority', async () => {
|
|
42
|
+
await render({imageUrl: imageUrl.replace('https:', 'http:'), imageAuthContext: 'ready'});
|
|
43
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(fetch.mock.calls[0][0]).toBe(imageUrl);
|
|
45
|
+
expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
|
|
46
|
+
});
|
|
47
|
+
it.each([401, 403, 404])('does not retry status %s', async status => {
|
|
48
|
+
fetch.mockResolvedValue({ok: false, status});
|
|
49
|
+
await render({imageAuthContext: 'ready'});
|
|
50
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(tree.root.findAllByType('Image')).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
it.each(['network', 503])('recovers once from %s', async failure => {
|
|
54
|
+
if (failure === 'network') fetch.mockRejectedValueOnce(new TypeError('network'));
|
|
55
|
+
else fetch.mockResolvedValueOnce({ok: false, status: failure});
|
|
56
|
+
await render({imageAuthContext: 'ready'});
|
|
57
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
58
|
+
expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
|
|
59
|
+
});
|
|
60
|
+
it('bounds repeated failures to two attempts', async () => {
|
|
61
|
+
fetch.mockRejectedValue(new TypeError('network'));
|
|
62
|
+
await render({imageAuthContext: 'ready'});
|
|
63
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
it('resets Image.onError after session changes', async () => {
|
|
66
|
+
await render({imageAuthContext: 'first'});
|
|
67
|
+
await act(async () => {tree.root.findByType('Image').props.onError();});
|
|
68
|
+
expect(tree.root.findAllByType('Image')).toHaveLength(0);
|
|
69
|
+
await act(async () => {tree.update(<UserAvatar imageUrl={imageUrl} useGravatar={false} imageAuthContext="second"/>);});
|
|
70
|
+
expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
|
|
71
|
+
expect(fetch.mock.calls.at(-1)[1].headers['API-TOKEN']).toBe('second');
|
|
72
|
+
});
|
|
73
|
+
it('never fetches credentials for an untrusted origin', async () => {
|
|
74
|
+
await render({imageUrl: 'https://external.synthetic.test/files/42/download', imageAuthContext: 'private'});
|
|
75
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it('aborts unmounted request and avoids a late blob URL', async () => {
|
|
78
|
+
let resolveBlob;
|
|
79
|
+
fetch.mockResolvedValue({ok: true, blob: () => new Promise(resolve => {resolveBlob = resolve;})});
|
|
80
|
+
await render({imageAuthContext: 'ready'});
|
|
81
|
+
const signal = fetch.mock.calls[0][1].signal;
|
|
82
|
+
await act(async () => {tree.unmount(); tree = undefined;});
|
|
83
|
+
await act(async () => {resolveBlob({});});
|
|
84
|
+
expect(signal.aborted).toBe(true);
|
|
85
|
+
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('revokes successful blobs when unmounted', async () => {
|
|
89
|
+
await render({imageAuthContext: 'ready'});
|
|
90
|
+
expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
|
|
91
|
+
await act(async () => {tree.unmount(); tree = undefined;});
|
|
92
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic');
|
|
93
|
+
});
|
|
94
|
+
it('ignores the earlier session response after a context change', async () => {
|
|
95
|
+
let resolveOld;
|
|
96
|
+
fetch.mockImplementationOnce(() => new Promise(resolve => {resolveOld = resolve;}));
|
|
97
|
+
await render({imageAuthContext: 'first'});
|
|
98
|
+
await act(async () => {tree.update(<UserAvatar imageUrl={imageUrl} useGravatar={false} imageAuthContext="second"/>);});
|
|
99
|
+
await act(async () => {resolveOld(ok());});
|
|
100
|
+
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
|
|
102
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import renderer, {act} from 'react-test-renderer';
|
|
3
|
+
import {useNativeDeviceConfigFetch} from '@controleonline/ui-common/src/react/components/DefaultProvider.native.lifecycle';
|
|
4
|
+
import {useWebDeviceConfigFetch} from '@controleonline/ui-common/src/react/components/DefaultProvider.web.configuration';
|
|
5
|
+
it.each([['web',useWebDeviceConfigFetch],['native',useNativeDeviceConfigFetch]])('ignores late device configuration after a company switch on %s',async(_platform,useConfig)=>{
|
|
6
|
+
global.IS_REACT_ACT_ENVIRONMENT=true;
|
|
7
|
+
let resolveOld;const old=new Promise(resolve=>{resolveOld=resolve;});
|
|
8
|
+
const actions={setItem:jest.fn(),getItems:jest.fn().mockReturnValueOnce(old).mockResolvedValueOnce([{id:20}])};
|
|
9
|
+
const done=jest.fn(), synced=jest.fn(), select=({items})=>items[0],parse=x=>x||{};
|
|
10
|
+
function Probe({company}){useConfig({currentCompany:{id:company},device:{id:'web-10'},isLogged:true,deviceConfigPeopleIri:`/people/${company}`,setDeviceConfigFetched:done,setDeviceRuntimeConfigSynced:synced,deviceConfigsActions:actions,runtimeDeviceType:'PDV',selectRuntimeDeviceConfig:select,parseConfigsObject:parse});return null;}
|
|
11
|
+
let tree;await act(async()=>{tree=renderer.create(<Probe company={3}/>);});
|
|
12
|
+
await act(async()=>tree.update(<Probe company={2}/>));
|
|
13
|
+
expect(actions.setItem).toHaveBeenLastCalledWith({id:20,configs:{}});
|
|
14
|
+
actions.setItem.mockClear();done.mockClear();
|
|
15
|
+
await act(async()=>resolveOld([{id:10}]));
|
|
16
|
+
expect(actions.setItem).not.toHaveBeenCalled();expect(done).not.toHaveBeenCalled();
|
|
17
|
+
await act(async()=>tree.unmount());
|
|
18
|
+
});
|
|
@@ -14,6 +14,28 @@ const {
|
|
|
14
14
|
} = require('../../../react/utils/fileUrl');
|
|
15
15
|
|
|
16
16
|
describe('fileUrl helpers', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
'staging.controleonline.com', 'dev.controleonline.com',
|
|
19
|
+
'admin.controleonline.com', 'manager.controleonline.com',
|
|
20
|
+
'pos.lave-go.com', 'shop.lave-go.com', 'delivery.controleonline.com',
|
|
21
|
+
'crm.controleonline.com', 'ppc.controleonline.com', 'service.controleonline.com',
|
|
22
|
+
'localhost:8081',
|
|
23
|
+
])('resolves shared file shapes on the application route for %s', appDomain => {
|
|
24
|
+
for (const file of [6042, '/files/6042', '/files/6042/download',
|
|
25
|
+
{id: 6042, domain: 'api.controleonline.com', url: '/api.controleonline.com/files/6042/download'},
|
|
26
|
+
{file: {id: 6042, url: '/files/6042/download'}}]) {
|
|
27
|
+
const source = resolveDefaultFileSource(file, {appDomain, headers: {'API-TOKEN': 'session'}});
|
|
28
|
+
expect(source.uri).toBe(`https://api.controleonline.com/${encodeURIComponent(appDomain)}/files/6042/download`);
|
|
29
|
+
expect(source.headers).toEqual({'API-TOKEN': 'session', 'app-domain': appDomain});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('preserves URL parameters and fragments while replacing an infrastructure prefix', () => {
|
|
34
|
+
expect(resolveFileImageUrl('/api.controleonline.com/files/6042/download?size=48&app-domain=old#image', {
|
|
35
|
+
appDomain: 'staging.controleonline.com',
|
|
36
|
+
})).toBe('https://api.controleonline.com/staging.controleonline.com/files/6042/download?size=48#image');
|
|
37
|
+
});
|
|
38
|
+
|
|
17
39
|
it('uses path app-domain for backend download urls and keeps headers', () => {
|
|
18
40
|
const company = {
|
|
19
41
|
domain: 'maincompany.controleonline.com',
|
|
@@ -108,6 +130,7 @@ describe('file authentication origin boundary', () => {
|
|
|
108
130
|
expect(source.headers).not.toHaveProperty('Authorization');
|
|
109
131
|
expect(source.headers).not.toHaveProperty('App-Domain');
|
|
110
132
|
expect(source.headers.Accept).toBe('image/*');
|
|
133
|
+
expect(source.uri).toBe(uri);
|
|
111
134
|
}
|
|
112
135
|
});
|
|
113
136
|
it('preserves authentication on the configured API download', () => {
|
|
@@ -16,4 +16,14 @@ const filterApiHeaders = (uri, headers = {}, apiEntryPoint) => {
|
|
|
16
16
|
!/^(api[-_]?(token|key)|authorization|proxy-authorization|cookie|app-domain|device)$/i.test(key)));
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// Normalize only file downloads on the configured API authority; trust checks still
|
|
20
|
+
// validate the final origin/path before attaching session headers.
|
|
21
|
+
const resolveApiFileProtocol = (uri, apiEntryPoint) => {
|
|
22
|
+
const image = String(uri || '');
|
|
23
|
+
const imageOrigin = image.match(/^https?:\/\/([^/]+)/i);
|
|
24
|
+
const apiOrigin = String(apiEntryPoint || '').match(/^https?:\/\/([^/]+)/i);
|
|
25
|
+
if (!imageOrigin || !apiOrigin || imageOrigin[1] !== apiOrigin[1]
|
|
26
|
+
|| !/\/files\/[^/?#]+\/download(?:[?#]|$)/i.test(image)) return image;
|
|
27
|
+
return apiOrigin[0] + image.slice(imageOrigin[0].length);
|
|
28
|
+
};
|
|
29
|
+
module.exports = {isTrustedApiUrl, filterApiHeaders, resolveApiFileProtocol};
|