Skip to content

Commit eb20006

Browse files
harikakondurclaude
andcommitted
fix(link-checker): resolve scan reset on tab switch and entry fetch limit [ES-524] (#11255)
* fix(security): send x-tenant-id header from MsTeamsBotService to bot service [AIS-004] All four bot-service calls now include x-tenant-id matching the path tenantId, satisfying the new per-tenant authorization check added to the msteams-bot-service. Updates mockRequestHeaders to accept tenantId so calledWith assertions remain accurate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): strip trailing slash from bot service URL in constructor [AIS-004] Prevents double-slash paths when MSTEAMS_BOT_SERVICE_BASE_URL is configured with a trailing slash (e.g. https://example.com/dev/). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(link-checker): guard visibility reload against missing installation match [ES-524] When getForOrganization returns no match for the current space/environment, match is undefined and deepEqual(undefined, params) always returns false, causing a page reload every time the user switches back to the tab. This wiped in-progress scan results, explaining both the "links disappear on tab switch" and "page freezes/resets at ~14,670 links" symptoms. Only reload when a match is found AND its parameters differ from the current installation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(link-checker): switch entry pagination to cursor-based to bypass 10k CMA limit [ES-524] The Contentful CMA enforces a hard cap of skip+limit<=10000. With ENTRY_FETCH_LIMIT=100 this meant the fetching loop silently stopped after 100 pages (10000 entries), which for the customer space translated to ~14,670 links found. Replace skip-based pagination with sys.id[gt] cursor pagination (ordered by sys.id) which has no upper bound. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(link-checker): add tests for no-match visibility reload guard and cursor pagination [ES-524] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7ba8a98 commit eb20006

8 files changed

Lines changed: 122 additions & 23 deletions

File tree

apps/jira/jira-app/src/components/Auth/OAuth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default class OAuth extends React.Component<Props> {
2828
const oauthWindow = window.open(url, 'Jira Contentful', 'left=150,top=10,width=800,height=900');
2929

3030
window.addEventListener('message', (e) => {
31-
if (e.source !== oauthWindow) {
31+
if (e.source !== oauthWindow || e.origin !== window.location.origin) {
3232
return;
3333
}
3434

apps/jira/jira-app/src/index.spec.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,14 @@ describe('The Jira App Components', () => {
227227
(window.open as Mock).mockReturnValue(source);
228228

229229
fireEvent.click(oauthButton);
230-
fireEvent(window, new MessageEvent('message', { data: { token, expireTime }, source }));
230+
fireEvent(
231+
window,
232+
new MessageEvent('message', {
233+
data: { token, expireTime },
234+
source,
235+
origin: 'http://localhost:3000',
236+
})
237+
);
231238

232239
expect(window.open).toHaveBeenCalledWith(
233240
'https://auth.atlassian.com/authorize?audience=api.atlassian.com&client_id=XD9k9QU9VT4Rt26u6lbO3NM0fOqvvXan&scope=read%3Ajira-user%20read%3Ajira-work%20write%3Ajira-work&redirect_uri=https%3A%2F%2Fapi.jira.ctfapps.net%2Fauth&response_type=code&state=http%3A%2F%2Flocalhost%3A3000%2F&prompt=consent',
@@ -249,7 +256,14 @@ describe('The Jira App Components', () => {
249256
(window.open as Mock).mockReturnValue(source);
250257

251258
fireEvent.click(oauthButton);
252-
fireEvent(window, new MessageEvent('message', { data: { error }, source }));
259+
fireEvent(
260+
window,
261+
new MessageEvent('message', {
262+
data: { error },
263+
source,
264+
origin: 'http://localhost:3000',
265+
})
266+
);
253267

254268
expect(mockSdk.notifier.error).toHaveBeenCalledWith(
255269
'There was an error authenticating. Please refresh and try again.'
@@ -580,7 +594,7 @@ describe('The Jira App Components', () => {
580594
standalone(mockWindow as any);
581595
expect(mockWindow.opener.postMessage).toHaveBeenCalledWith(
582596
{ token: '123', expireTime: 10100 },
583-
'*'
597+
'http://localhost:1234'
584598
);
585599
expect(mockWindow.history.replaceState).toHaveBeenCalledWith({}, 'oauth', '/');
586600
});
@@ -605,7 +619,10 @@ describe('The Jira App Components', () => {
605619
standalone(mockWindow as any);
606620

607621
expect(mockWindow.localStorage.setItem).toHaveBeenCalledTimes(0);
608-
expect(mockWindow.opener.postMessage).toHaveBeenCalledWith({ error: errorMessage }, '*');
622+
expect(mockWindow.opener.postMessage).toHaveBeenCalledWith(
623+
{ error: errorMessage },
624+
'http://localhost:1234'
625+
);
609626
});
610627

611628
it('should handle no query string', () => {
@@ -629,7 +646,7 @@ describe('The Jira App Components', () => {
629646
expect(mockWindow.localStorage.setItem).toHaveBeenCalledTimes(0);
630647
expect(mockWindow.opener.postMessage).toHaveBeenCalledWith(
631648
{ error: 'No query string provided!' },
632-
'*'
649+
'http://localhost:1234'
633650
);
634651
});
635652
});
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
const standalone = (window: Window) => {
2-
const { searchParams, search } = new URL(window.location.href);
2+
const { searchParams, search, origin } = new URL(window.location.href);
33

44
if (search.length) {
55
const error = searchParams.get('error');
66

77
if (error) {
8-
window.opener.postMessage({ error }, '*');
8+
window.opener.postMessage({ error }, origin);
99
return;
1010
}
1111

@@ -14,11 +14,11 @@ const standalone = (window: Window) => {
1414

1515
const expireTime = Date.now() + expiresIn * 1000;
1616

17-
window.opener.postMessage({ token, expireTime }, '*');
17+
window.opener.postMessage({ token, expireTime }, origin);
1818

1919
window.history.replaceState({}, 'oauth', '/');
2020
} else {
21-
window.opener.postMessage({ error: 'No query string provided!' }, '*');
21+
window.opener.postMessage({ error: 'No query string provided!' }, origin);
2222
}
2323
};
2424
export default standalone;

apps/link-checker/__tests__/locations/Page.spec.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,80 @@ describe('Page component', () => {
374374
expect(reload).not.toHaveBeenCalled();
375375
});
376376

377+
it('does not reload when getForOrganization returns no match for the current space/environment', async () => {
378+
const reload = vi.fn();
379+
Object.defineProperty(window, 'location', {
380+
value: { reload },
381+
configurable: true,
382+
writable: true,
383+
});
384+
385+
mockSdk.cma.appInstallation = {
386+
getForOrganization: vi.fn().mockResolvedValue({
387+
items: [
388+
{
389+
sys: {
390+
space: { sys: { id: 'other-space' } },
391+
environment: { sys: { id: 'other-env' } },
392+
},
393+
parameters: { selectedContentTypeIds: ['article'] },
394+
},
395+
],
396+
}),
397+
};
398+
399+
render(<Page />);
400+
401+
triggerVisibilityChange('hidden');
402+
triggerVisibilityChange('visible');
403+
404+
await waitFor(() => {
405+
expect(mockSdk.cma.appInstallation.getForOrganization).toHaveBeenCalled();
406+
});
407+
expect(reload).not.toHaveBeenCalled();
408+
});
409+
410+
it('uses cursor pagination and passes sys.id[gt] on subsequent entry fetches', async () => {
411+
const batch1 = Array.from({ length: 100 }, (_, i) => ({
412+
sys: { id: `entry-batch1-${i}`, contentType: { sys: { id: 'article' } } },
413+
fields: {
414+
title: { 'en-US': `Entry ${i}` },
415+
body: { 'en-US': `https://example.com/link-${i}` },
416+
},
417+
}));
418+
const batch2 = [
419+
{
420+
sys: { id: 'entry-batch2-0', contentType: { sys: { id: 'article' } } },
421+
fields: {
422+
title: { 'en-US': 'Last entry' },
423+
body: { 'en-US': 'https://example.com/last' },
424+
},
425+
},
426+
];
427+
428+
const getMany = vi
429+
.fn()
430+
.mockResolvedValueOnce({ items: batch1 })
431+
.mockResolvedValueOnce({ items: batch2 });
432+
433+
mockSdk.cma.entry = { getMany };
434+
435+
render(<Page />);
436+
fireEvent.click(screen.getByRole('button', { name: 'Find links' }));
437+
438+
await screen.findByText('https://example.com/last');
439+
440+
expect(getMany).toHaveBeenCalledTimes(2);
441+
expect(getMany).toHaveBeenNthCalledWith(
442+
2,
443+
expect.objectContaining({
444+
query: expect.objectContaining({
445+
'sys.id[gt]': 'entry-batch1-99',
446+
}),
447+
})
448+
);
449+
});
450+
377451
it('checks www URLs as absolute https URLs instead of resolving them against the current domain', async () => {
378452
const createWithResponse = vi.fn().mockResolvedValue({
379453
response: { body: JSON.stringify({ status: 200 }) },

apps/link-checker/components/locations/Page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export default function Page() {
195195
item.sys.space.sys.id === sdk.ids.space &&
196196
item.sys.environment.sys.id === sdk.ids.environment
197197
);
198-
if (!deepEqual(match?.parameters, sdk.parameters.installation)) {
198+
if (match && !deepEqual(match.parameters, sdk.parameters.installation)) {
199199
window.location.reload();
200200
}
201201
} catch {
@@ -318,22 +318,23 @@ export default function Page() {
318318
const resultMap = new Map<string, PageLinkResult>();
319319
const checksToQueue: typeof pendingChecks = [];
320320

321-
let skip = 0;
321+
let lastSeenId: string | undefined;
322322
let hasLoadedFirstBatch = false;
323323
let entriesScanned = 0;
324324
let linksFound = 0;
325325
const entryQueryBase = {
326326
'sys.contentType.sys.id[in]': Array.from(contentTypeMap.keys()).join(','),
327+
order: 'sys.id',
327328
};
328329

329330
while (true) {
330331
const response = await sdk.cma.entry.getMany({
331332
spaceId: sdk.ids.space,
332333
environmentId: sdk.ids.environment,
333334
query: {
334-
skip,
335335
limit: ENTRY_FETCH_LIMIT,
336336
...entryQueryBase,
337+
...(lastSeenId ? { 'sys.id[gt]': lastSeenId } : {}),
337338
},
338339
});
339340

@@ -461,7 +462,7 @@ export default function Page() {
461462
break;
462463
}
463464

464-
skip += ENTRY_FETCH_LIMIT;
465+
lastSeenId = batchEntries[batchEntries.length - 1].sys.id;
465466
}
466467

467468
if (!hasLoadedFirstBatch) {

apps/slack/frontend/src/index.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import { CustomSDKProvider } from './CustomSDKProvider';
99
const params = new URLSearchParams(window.location.search);
1010

1111
if (params.has('result')) {
12-
window.opener.postMessage({
13-
result: params.get('result'),
14-
state: params.get('state'),
15-
accessToken: params.get('accessToken'),
16-
refreshToken: params.get('refreshToken'),
17-
errorMessage: params.get('errorMessage'),
18-
});
12+
const { origin } = new URL(window.location.href);
13+
window.opener.postMessage(
14+
{
15+
result: params.get('result'),
16+
state: params.get('state'),
17+
accessToken: params.get('accessToken'),
18+
refreshToken: params.get('refreshToken'),
19+
errorMessage: params.get('errorMessage'),
20+
},
21+
origin
22+
);
1923
window.close();
2024
} else {
2125
const root = document.getElementById('root');

apps/slack/frontend/src/useConnect.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export const useConnect = () => {
6363
const [setTemporaryRefreshToken] = useAuthStore((state) => [state.setTemporaryRefreshToken]);
6464

6565
const onMessage = async (message: MessageEvent) => {
66+
if (message.origin !== window.location.origin) {
67+
return;
68+
}
6669
if (message.data.result === 'error') {
6770
sdk.notifier.error('Something went wrong while authenticating with Slack. Please try again.');
6871
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const standalone = (window: Window) => {
2-
const { searchParams, search } = new URL(window.location.href);
2+
const { searchParams, search, origin } = new URL(window.location.href);
33
window.history.replaceState({}, 'smartling', '/');
44

55
if (search.length) {
@@ -8,7 +8,7 @@ const standalone = (window: Window) => {
88

99
window.localStorage.setItem('token', token);
1010
window.localStorage.setItem('refreshToken', refreshToken);
11-
window.opener.postMessage({ token, refreshToken }, '*');
11+
window.opener.postMessage({ token, refreshToken }, origin);
1212
}
1313
};
1414
export default standalone;

0 commit comments

Comments
 (0)