@controleonline/ui-common 1.2.102 → 1.2.104

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-common",
3
- "version": "1.2.102",
3
+ "version": "1.2.104",
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
- .fetch('menus-people', {
264
- params: {
265
- myCompany: currentCompany.id,
266
- appType,
267
- menuType: 'home',
268
- },
269
- })
270
- .then(result => {
271
- if (!cancelled) {
272
- actions.setMenus(normalizeRuntimeMenuResponse(result, {appType}));
273
- }
274
- })
275
- .catch(() => {
276
- if (!cancelled) {
277
- actions.setMenus(normalizeRuntimeMenuResponse(null, {appType}));
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 = menuState.scope === menuScope ? menuState.menus : [];
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 [displayUri, setDisplayUri] = useState('');
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 next = normalizeUrl(uri);
66
-
67
- setResolved(false);
68
- setDisplayUri('');
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
- // Authenticated backend download on web
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' && token) {
84
- try {
85
- const headers = {
86
- Accept: '*/*',
87
- 'API-TOKEN': token,
88
- };
89
- const host =
90
- normalizeUrl(APP_ENV?.DOMAIN) ||
91
- (typeof location !== 'undefined' ? location.host : '');
92
- if (host) {
93
- headers['App-Domain'] = host;
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
- if (!cancelled) {
99
- setDisplayUri('');
100
- setResolved(true);
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
- const blob = await response.blob();
106
- objectUrl = URL.createObjectURL(blob);
107
- if (!cancelled) {
108
- setDisplayUri(objectUrl);
109
- setResolved(true);
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
- if (!cancelled) {
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
- return {displayUri, resolved};
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(() => {
@@ -89,6 +89,60 @@ const resolveDownloadHost = ({company = null, appDomain = ''} = {}) => {
89
89
  * Keep app-domain header only; do not path-inject environment hosts.
90
90
  * app-community#432
91
91
  */
92
+ const resolveApiEntrypointHost = () => {
93
+ const entry = normalizeText(APP_ENV?.API_ENTRYPOINT);
94
+ if (!entry) {
95
+ return '';
96
+ }
97
+
98
+ try {
99
+ const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(entry)
100
+ ? entry
101
+ : `https://${entry}`;
102
+ return new URL(withProtocol).host.toLowerCase();
103
+ } catch (error) {
104
+ return entry
105
+ .replace(/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//, '')
106
+ .replace(/\/.*$/, '')
107
+ .toLowerCase();
108
+ }
109
+ };
110
+
111
+ /**
112
+ * API host must never be used as /{host}/files/{id}/download segment.
113
+ * Backend File.domain often stores the API host (api.controleonline.com);
114
+ * using it produces broken URLs like:
115
+ * https://api.controleonline.com/api.controleonline.com/files/8408/download
116
+ * Tenant white-labels need:
117
+ * https://api.controleonline.com/erp.jaguncos.com.br/files/8408/download
118
+ * app-community#1160
119
+ */
120
+ const isApiEntrypointHost = host => {
121
+ const normalized = normalizeText(host).toLowerCase();
122
+ if (!normalized) {
123
+ return false;
124
+ }
125
+
126
+ const apiHost = resolveApiEntrypointHost();
127
+ if (apiHost && normalized === apiHost) {
128
+ return true;
129
+ }
130
+
131
+ if (
132
+ normalized === 'api.controleonline.com' ||
133
+ normalized === 'api.controleonline.com.br'
134
+ ) {
135
+ return true;
136
+ }
137
+
138
+ // Generic api.* hosts are infrastructure, not tenant app-domains.
139
+ if (/^api\./i.test(normalized)) {
140
+ return true;
141
+ }
142
+
143
+ return false;
144
+ };
145
+
92
146
  const isEnvironmentLikeHost = host => {
93
147
  const normalized = normalizeText(host).toLowerCase();
94
148
  if (!normalized) return false;
@@ -120,6 +174,29 @@ const isEnvironmentLikeHost = host => {
120
174
  return false;
121
175
  };
122
176
 
177
+ /**
178
+ * Prefer File.domain only when it is a real tenant host.
179
+ * Ignore API entrypoint and environment/platform hosts (app-community#1160).
180
+ */
181
+ const pickDownloadHost = (fileDomain, fallbackHost) => {
182
+ const candidate = normalizeText(fileDomain);
183
+ if (
184
+ candidate &&
185
+ !isApiEntrypointHost(candidate) &&
186
+ !isEnvironmentLikeHost(candidate)
187
+ ) {
188
+ return candidate;
189
+ }
190
+
191
+ const fallback = normalizeText(fallbackHost);
192
+ if (fallback && !isApiEntrypointHost(fallback)) {
193
+ return fallback;
194
+ }
195
+
196
+ // Last resort: runtime host (web multi-tenant) or configured DOMAIN.
197
+ return resolveAppDomain(APP_ENV?.DOMAIN) || fallback;
198
+ };
199
+
123
200
  const buildBackendDownloadUrl = (fileId, options = {}) => {
124
201
  const normalizedId = String(fileId || '').trim();
125
202
  if (!normalizedId) {
@@ -139,13 +216,11 @@ const buildTenantDownloadUrl = (url, host) => {
139
216
  return absoluteUrl;
140
217
  }
141
218
 
142
- // File download ops are registered as /{appDomain}/files/{id}/download.
143
- // Skipping path inject for environment hosts left SPA on /files/{id}/download
144
- // which 404s until/unless a non-prefixed route is deployed (app-community#796).
145
- const isFileDownloadPath = /\/files\/[^/?#]+\/download/i.test(absoluteUrl);
146
-
147
- // Do not inject environment hosts into non-download paths (app-community#432).
148
- if (isEnvironmentLikeHost(normalizedHost) && !isFileDownloadPath) {
219
+ // Do not path-inject environment/platform hosts (app-community#432).
220
+ // Bare /files/{id}/download remains for staging/manager/dev; tenant hosts
221
+ // (erp.jaguncos.com.br, loja..., etc.) still get /{appDomain}/files/... .
222
+ // Never path-inject the API entrypoint host (app-community#1160).
223
+ if (isEnvironmentLikeHost(normalizedHost) || isApiEntrypointHost(normalizedHost)) {
149
224
  return absoluteUrl;
150
225
  }
151
226
 
@@ -310,9 +385,9 @@ const resolveFileSource = (
310
385
  return null;
311
386
  }
312
387
 
313
- // Prefer FileService domain (companies/my icon/logo payload) for path inject.
314
- const fileDomain = normalizeText(normalizedFile?.domain);
315
- const downloadHost = fileDomain || host;
388
+ // Prefer FileService domain only when it is a tenant host, never the API host.
389
+ // file.domain often comes as api.controleonline.com (app-community#1160).
390
+ const downloadHost = pickDownloadHost(normalizedFile?.domain, host);
316
391
 
317
392
  const uri = isBackendDownload
318
393
  ? buildTenantDownloadUrl(uriBase, downloadHost)
@@ -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
+ });
@@ -114,4 +114,63 @@ describe('file authentication origin boundary', () => {
114
114
  const source = resolveDefaultFileSource(1, {headers: {'API-TOKEN': 'session-secret'}});
115
115
  expect(source.headers['API-TOKEN']).toBe('session-secret');
116
116
  });
117
+
118
+ it('ignores file.domain when it is the API host and uses tenant app-domain (app-community#1160)', () => {
119
+ const company = {domain: 'loja.jaguncos.com.br'};
120
+
121
+ const source = resolveDefaultFileSource(
122
+ {
123
+ id: 8408,
124
+ domain: 'api.controleonline.com',
125
+ url: '/api.controleonline.com/files/8408/download',
126
+ },
127
+ {
128
+ company,
129
+ headers: {Authorization: 'Bearer token'},
130
+ },
131
+ );
132
+
133
+ expect(source.uri).toBe(
134
+ 'https://api.controleonline.com/loja.jaguncos.com.br/files/8408/download',
135
+ );
136
+ expect(source.uri).not.toMatch(/api\.controleonline\.com\/files/);
137
+ expect(source.headers['app-domain']).toBe('loja.jaguncos.com.br');
138
+ });
139
+
140
+ it('ignores API host as company domain and falls back to explicit appDomain', () => {
141
+ const source = resolveDefaultFileSource(
142
+ {
143
+ id: 8408,
144
+ domain: 'api.controleonline.com',
145
+ },
146
+ {
147
+ company: {domain: 'api.controleonline.com'},
148
+ appDomain: 'erp.jaguncos.com.br',
149
+ headers: {},
150
+ },
151
+ );
152
+
153
+ expect(source.uri).toBe(
154
+ 'https://api.controleonline.com/erp.jaguncos.com.br/files/8408/download',
155
+ );
156
+ expect(source.headers['app-domain']).toBe('erp.jaguncos.com.br');
157
+ });
158
+
159
+ it('still prefers a real tenant file.domain over company when not API host', () => {
160
+ const source = resolveDefaultFileSource(
161
+ {
162
+ id: 12,
163
+ domain: 'cardapio.jaguncos.com.br',
164
+ url: '/files/12/download',
165
+ },
166
+ {
167
+ company: {domain: 'erp.jaguncos.com.br'},
168
+ },
169
+ );
170
+
171
+ expect(source.uri).toBe(
172
+ 'https://api.controleonline.com/cardapio.jaguncos.com.br/files/12/download',
173
+ );
174
+ expect(source.headers['app-domain']).toBe('cardapio.jaguncos.com.br');
175
+ });
117
176
  });
@@ -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
- module.exports = {isTrustedApiUrl, filterApiHeaders};
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};