|
| 1 | +import { getAllMockLoggers } from '@sim/testing/mocks/logger.mock' |
1 | 2 | import JSZip from 'jszip' |
2 | 3 | import { PDFDocument, StandardFonts } from 'pdf-lib' |
3 | 4 | import { beforeEach, describe, expect, it, vi } from 'vitest' |
@@ -249,6 +250,126 @@ describe('Confluence attachment listing', () => { |
249 | 250 | } |
250 | 251 | ) |
251 | 252 |
|
| 253 | + describe('server errors', () => { |
| 254 | + const parents = (ids: string[]) => |
| 255 | + vi.fn( |
| 256 | + async (): Promise<ExternalDocumentList> => ({ |
| 257 | + documents: ids.map((id) => parent(id)), |
| 258 | + hasMore: false, |
| 259 | + }) |
| 260 | + ) |
| 261 | + |
| 262 | + /** Answers a parent's attachment listing with `status` while `failing` says so. */ |
| 263 | + function listingFails(failing: (parentId: string) => boolean, status = 500) { |
| 264 | + fetchMock.mockImplementation(async (input) => { |
| 265 | + const parentId = new URL(String(input)).pathname.split('/').at(-2) ?? '' |
| 266 | + if (failing(parentId)) return new Response('upstream error', { status }) |
| 267 | + return Response.json({ results: [file({ id: `${parentId}-file`, pageId: parentId })] }) |
| 268 | + }) |
| 269 | + } |
| 270 | + |
| 271 | + /** Runs a listing past the shared backoff (~31 s for five retries) without real waits. */ |
| 272 | + async function listPastBackoff(input: Parameters<typeof listConfluenceAttachments>[0]) { |
| 273 | + vi.useFakeTimers() |
| 274 | + try { |
| 275 | + const listing = listConfluenceAttachments(input) |
| 276 | + const settled = listing.then( |
| 277 | + () => undefined, |
| 278 | + () => undefined |
| 279 | + ) |
| 280 | + for (let i = 0; i < 20; i++) await vi.advanceTimersByTimeAsync(10_000) |
| 281 | + await settled |
| 282 | + return await listing |
| 283 | + } finally { |
| 284 | + vi.useRealTimers() |
| 285 | + } |
| 286 | + } |
| 287 | + |
| 288 | + it('retries a transient 500 instead of failing the listing', async () => { |
| 289 | + let failed = false |
| 290 | + listingFails(() => { |
| 291 | + if (failed) return false |
| 292 | + failed = true |
| 293 | + return true |
| 294 | + }) |
| 295 | + const result = await listPastBackoff({ ...INPUT, listParents: parents(['p1']) }) |
| 296 | + expect(result.documents.map((doc) => doc.externalId)).toEqual([ |
| 297 | + 'p1', |
| 298 | + 'attachment:page:p1:p1-file', |
| 299 | + ]) |
| 300 | + expect(result.listingFailures).toBeUndefined() |
| 301 | + expect(fetchMock).toHaveBeenCalledTimes(2) |
| 302 | + }) |
| 303 | + |
| 304 | + it('skips isolated parents whose listing keeps failing and keeps listing the rest', async () => { |
| 305 | + listingFails((id) => id === 'p1' || id === 'p3' || id === 'p5') |
| 306 | + const context: Record<string, unknown> = {} |
| 307 | + const result = await listPastBackoff({ |
| 308 | + ...INPUT, |
| 309 | + listParents: parents(['p1', 'p2', 'p3', 'p4', 'p5']), |
| 310 | + syncContext: context, |
| 311 | + }) |
| 312 | + expect(result.documents.map((doc) => doc.externalId)).toEqual([ |
| 313 | + 'p1', |
| 314 | + 'p2', |
| 315 | + 'p3', |
| 316 | + 'p4', |
| 317 | + 'p5', |
| 318 | + 'attachment:page:p2:p2-file', |
| 319 | + 'attachment:page:p4:p4-file', |
| 320 | + ]) |
| 321 | + expect(result.listingFailures).toEqual({ |
| 322 | + count: 3, |
| 323 | + samples: ['p1', 'p3', 'p5'].map((scope) => ({ |
| 324 | + scope, |
| 325 | + operation: 'confluence.attachments.list', |
| 326 | + status: 500, |
| 327 | + reasons: ['attachment_listing_unavailable'], |
| 328 | + })), |
| 329 | + }) |
| 330 | + expect(result.reconciliationSafe).toBe(false) |
| 331 | + expect(context.reconciliationUnsafe).toBe(true) |
| 332 | + }) |
| 333 | + |
| 334 | + it('logs Atlassian trace identifiers for a 500 but never its body', async () => { |
| 335 | + let failed = false |
| 336 | + fetchMock.mockImplementation(async () => { |
| 337 | + if (failed) return Response.json({ results: [] }) |
| 338 | + failed = true |
| 339 | + return Response.json( |
| 340 | + { |
| 341 | + errors: [{ code: 'INTERNAL_SERVER_ERROR', title: 'x', detail: 'leaked-secret-value' }], |
| 342 | + }, |
| 343 | + { |
| 344 | + status: 500, |
| 345 | + headers: { 'atl-traceid': '5c1f0e2a9b7d4e1f', 'x-arequestid': 'not an id; <script>' }, |
| 346 | + } |
| 347 | + ) |
| 348 | + }) |
| 349 | + await listPastBackoff({ ...INPUT, listParents: parents(['p1']) }) |
| 350 | + const logged = JSON.stringify( |
| 351 | + getAllMockLoggers().flatMap((logger) => |
| 352 | + [logger.info, logger.warn, logger.error, logger.debug].flatMap((fn) => fn.mock.calls) |
| 353 | + ) |
| 354 | + ) |
| 355 | + expect(logged).toContain('5c1f0e2a9b7d4e1f') |
| 356 | + expect(logged).toContain('INTERNAL_SERVER_ERROR') |
| 357 | + expect(logged).not.toContain('<script>') |
| 358 | + expect(logged).not.toContain('leaked-secret-value') |
| 359 | + }) |
| 360 | + |
| 361 | + it.each([500, 503])( |
| 362 | + 'fails the sync when consecutive parents answer %s, as during an outage', |
| 363 | + async (status) => { |
| 364 | + listingFails(() => true, status) |
| 365 | + await expect( |
| 366 | + listPastBackoff({ ...INPUT, listParents: parents(['p1', 'p2', 'p3', 'p4']) }) |
| 367 | + ).rejects.toMatchObject({ status }) |
| 368 | + expect(fetchMock).toHaveBeenCalledTimes(3 * 6) |
| 369 | + } |
| 370 | + ) |
| 371 | + }) |
| 372 | + |
252 | 373 | it('surfaces known oversized files as skipped without downloading', async () => { |
253 | 374 | fetchMock.mockResolvedValue( |
254 | 375 | Response.json({ results: [file({ fileSize: CONNECTOR_MAX_FILE_BYTES + 1 })] }) |
|
0 commit comments