@controleonline/ui-common 1.2.101 → 1.2.103

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.
@@ -1,4 +1,5 @@
1
1
  import {env as APP_ENV} from '@env';
2
+ import {filterApiHeaders} from '../../utils/trustedApiUrl';
2
3
  import {buildAssetUrl} from '@controleonline/../../src/styles/branding';
3
4
  import {
4
5
  resolveAppDomain,
@@ -88,6 +89,60 @@ const resolveDownloadHost = ({company = null, appDomain = ''} = {}) => {
88
89
  * Keep app-domain header only; do not path-inject environment hosts.
89
90
  * app-community#432
90
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
+
91
146
  const isEnvironmentLikeHost = host => {
92
147
  const normalized = normalizeText(host).toLowerCase();
93
148
  if (!normalized) return false;
@@ -119,6 +174,29 @@ const isEnvironmentLikeHost = host => {
119
174
  return false;
120
175
  };
121
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
+
122
200
  const buildBackendDownloadUrl = (fileId, options = {}) => {
123
201
  const normalizedId = String(fileId || '').trim();
124
202
  if (!normalizedId) {
@@ -138,13 +216,11 @@ const buildTenantDownloadUrl = (url, host) => {
138
216
  return absoluteUrl;
139
217
  }
140
218
 
141
- // File download ops are registered as /{appDomain}/files/{id}/download.
142
- // Skipping path inject for environment hosts left SPA on /files/{id}/download
143
- // which 404s until/unless a non-prefixed route is deployed (app-community#796).
144
- const isFileDownloadPath = /\/files\/[^/?#]+\/download/i.test(absoluteUrl);
145
-
146
- // Do not inject environment hosts into non-download paths (app-community#432).
147
- 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)) {
148
224
  return absoluteUrl;
149
225
  }
150
226
 
@@ -227,7 +303,7 @@ export const extractFileId = file => {
227
303
  return null;
228
304
  };
229
305
 
230
- export const resolveDefaultFileSource = (
306
+ const resolveFileSource = (
231
307
  file,
232
308
  {company = null, appDomain = '', headers = {}} = {},
233
309
  ) => {
@@ -309,9 +385,9 @@ export const resolveDefaultFileSource = (
309
385
  return null;
310
386
  }
311
387
 
312
- // Prefer FileService domain (companies/my icon/logo payload) for path inject.
313
- const fileDomain = normalizeText(normalizedFile?.domain);
314
- 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);
315
391
 
316
392
  const uri = isBackendDownload
317
393
  ? buildTenantDownloadUrl(uriBase, downloadHost)
@@ -329,6 +405,11 @@ export const resolveDefaultFileSource = (
329
405
  return null;
330
406
  };
331
407
 
408
+ export const resolveDefaultFileSource = (file, options = {}) => {
409
+ const source = resolveFileSource(file, options);
410
+ return source ? {...source, headers: filterApiHeaders(source.uri, source.headers, APP_ENV?.API_ENTRYPOINT)} : null;
411
+ };
412
+
332
413
  export const resolveDefaultFileUrl = (file, options = {}) =>
333
414
  resolveDefaultFileSource(file, options)?.uri || '';
334
415
 
@@ -0,0 +1,13 @@
1
+ import eventBus from '../components/EventBus';
2
+
3
+ const key = 'product-catalog-cache-revision';
4
+ let revision = '0';
5
+ let sequence = 0;
6
+ export const getProductCatalogCacheRevision = () => {
7
+ try { return localStorage.getItem(key) || revision; } catch { return revision; }
8
+ };
9
+ export const invalidateProductCatalogCache = () => {
10
+ revision = `${Date.now()}:${++sequence}`;
11
+ try { localStorage.setItem(key, revision); } catch { /* In-memory invalidation still applies. */ }
12
+ eventBus.emit('product-catalog-cache:cleared');
13
+ };
@@ -12,6 +12,8 @@ jest.mock('react-native', () => ({
12
12
  View: props => React.createElement('View', props, props.children),
13
13
  }));
14
14
 
15
+ jest.mock('@env', () => ({env: {API_ENTRYPOINT: 'https://api.controleonline.com', DOMAIN: 'manager.controleonline.com'}}));
16
+
15
17
  const UserAvatar = require('../../../react/components/UserAvatar').default;
16
18
 
17
19
  const {describe, expect, it} = global;
@@ -54,3 +56,31 @@ describe('UserAvatar', () => {
54
56
  expect(image.props.source.uri).toContain('d=404');
55
57
  });
56
58
  });
59
+
60
+
61
+ describe('avatar credential boundary', () => {
62
+ it('does not authenticate an external download-shaped image on web', async () => {
63
+ const {Platform} = require('react-native');
64
+ const previousStorage = global.localStorage;
65
+ const previousFetch = global.fetch;
66
+ Platform.OS = 'web';
67
+ global.localStorage = {getItem: () => JSON.stringify({token: 'session-secret'})};
68
+ global.fetch = jest.fn();
69
+ let tree;
70
+ try {
71
+ await renderer.act(async () => { tree = renderer.create(React.createElement(UserAvatar, {imageUrl: 'https://images.example.test/files/1/download', useGravatar: false})); });
72
+ expect(global.fetch).not.toHaveBeenCalled();
73
+ expect(tree.root.findByType('Image').props.source.uri).toBe('https://images.example.test/files/1/download');
74
+ } finally {
75
+ if (tree) await renderer.act(async () => tree.unmount());
76
+ Platform.OS = 'ios'; global.localStorage = previousStorage; global.fetch = previousFetch;
77
+ }
78
+ });
79
+ it('removes session headers from external native avatars', async () => {
80
+ let tree;
81
+ try {
82
+ await renderer.act(async () => { tree = renderer.create(React.createElement(UserAvatar, {imageUrl: 'https://images.example.test/avatar.png', imageHeaders: {'API-TOKEN': 'session-secret', Accept: 'image/*'}, useGravatar: false})); });
83
+ expect(tree.root.findByType('Image').props.source.headers).toEqual({Accept: 'image/*'});
84
+ } finally { if (tree) await renderer.act(async () => tree.unmount()); }
85
+ });
86
+ });
@@ -60,7 +60,7 @@ describe('fileUrl helpers', () => {
60
60
  });
61
61
  });
62
62
 
63
- it('does not path-inject environment hosts like staging (app-community#432)', () => {
63
+ it('keeps the explicit environment domain on the registered download route', () => {
64
64
  const source = resolveDefaultFileSource(
65
65
  {
66
66
  id: 8444,
@@ -77,24 +77,100 @@ describe('fileUrl helpers', () => {
77
77
  );
78
78
 
79
79
  expect(source).toEqual({
80
- uri: 'https://api.controleonline.com/files/8444/download',
80
+ uri: 'https://api.controleonline.com/staging.controleonline.com/files/8444/download',
81
81
  headers: {
82
82
  Authorization: 'Bearer token',
83
83
  'app-domain': 'staging.controleonline.com',
84
84
  },
85
85
  });
86
- expect(source.uri).not.toContain('staging.controleonline.com/files');
86
+ expect(source.uri).toContain('staging.controleonline.com/files');
87
87
  });
88
88
 
89
- it('does not path-inject when fallback DOMAIN is manager platform host', () => {
89
+ it('uses the configured platform domain on the registered download route', () => {
90
90
  // DOMAIN mock is manager.controleonline.com; no company → environment-like
91
91
  const source = resolveDefaultFileSource(
92
92
  {id: 9, url: '/files/9/download'},
93
93
  {headers: {Authorization: 'Bearer t'}},
94
94
  );
95
95
 
96
- expect(source.uri).toBe('https://api.controleonline.com/files/9/download');
96
+ expect(source.uri).toBe('https://api.controleonline.com/manager.controleonline.com/files/9/download');
97
97
  expect(source.headers['app-domain']).toBe('manager.controleonline.com');
98
- expect(source.uri).not.toMatch(/manager\.controleonline\.com\/files/);
98
+ expect(source.uri).toMatch(/manager\.controleonline\.com\/files/);
99
+ });
100
+ });
101
+
102
+
103
+ describe('file authentication origin boundary', () => {
104
+ it('removes ERP credentials from external images and download-shaped URLs', () => {
105
+ for (const uri of ['https://images.example.test/image.png', 'https://images.example.test/files/1/download', '//images.example.test/image.png']) {
106
+ const source = resolveDefaultFileSource(uri, {headers: {'API-TOKEN': 'session-secret', Authorization: 'Bearer session-secret', 'App-Domain': 'tenant.test', Accept: 'image/*'}});
107
+ expect(source.headers).not.toHaveProperty('API-TOKEN');
108
+ expect(source.headers).not.toHaveProperty('Authorization');
109
+ expect(source.headers).not.toHaveProperty('App-Domain');
110
+ expect(source.headers.Accept).toBe('image/*');
111
+ }
112
+ });
113
+ it('preserves authentication on the configured API download', () => {
114
+ const source = resolveDefaultFileSource(1, {headers: {'API-TOKEN': 'session-secret'}});
115
+ expect(source.headers['API-TOKEN']).toBe('session-secret');
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');
99
175
  });
100
176
  });