import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { fetchMock, handlers } = vi.hoisted(() => ({ fetchMock: vi.fn(), handlers: new Map unknown>() })) vi.mock('electron', () => ({ app: { getVersion: () => '1.2.3-test' }, ipcMain: { handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { handlers.set(channel, handler) }), removeHandler: vi.fn((channel: string) => handlers.delete(channel)) }, net: { fetch: (...args: unknown[]) => fetchMock(...args) } })) import { MAX_FEEDBACK_IMAGE_RESPONSE_BYTES } from './feedback-image-attachments' import { registerFeedbackHandlers, submitFeedback } from './feedback' function okResponse(): Response { return { ok: true, status: 200 } as unknown as Response } function errorResponse(status: number): Response { return { ok: false, status } as unknown as Response } function requestInit(callIndex = 0): RequestInit { return fetchMock.mock.calls[callIndex]?.[1] as RequestInit } function postedBody(callIndex = 0): Record { return JSON.parse(String(requestInit(callIndex).body)) as Record } function diagnosticSubmitArgs(): Parameters[0] { return { feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: attached', feedbackWithoutDiagnosticBundle: '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed', submissionType: 'crash', submitAnonymously: true, githubLogin: null, githubEmail: null, diagnosticBundle: { bundleSubmissionId: 'bundleabcdefghijklmnop', content: '{"type":"bundle-header"}\n', bytes: 25, spanCount: 1 } } } describe('submitFeedback', () => { beforeEach(() => { vi.useRealTimers() handlers.clear() fetchMock.mockReset() fetchMock.mockResolvedValue(okResponse()) }) afterEach(() => { vi.useRealTimers() }) it('strips GitHub identity and anonymous contact fields when submitted anonymously', async () => { const anonymousArgs = { feedback: 'private bug report', submitAnonymously: true, githubLogin: 'trusted-user', githubEmail: 'trusted@example.com', anonymousGithubLogin: 'trusted-user', anonymousEmail: 'trusted@example.com', anonymousX: 'trusted' } await submitFeedback(anonymousArgs) const body = postedBody() expect(body).toMatchObject({ feedback: 'private bug report', submissionType: 'feedback', githubLogin: null, githubEmail: null, appVersion: '1.2.3-test' }) expect(body).not.toHaveProperty('anonymousGithubLogin') expect(body).not.toHaveProperty('anonymousEmail') expect(body).not.toHaveProperty('anonymousX') }) it('preserves verified GitHub identity when not submitted anonymously', async () => { await submitFeedback({ feedback: 'public bug report', submitAnonymously: false, githubLogin: 'trusted-user', githubEmail: 'trusted@example.com' }) const body = postedBody() expect(body).toMatchObject({ feedback: 'public bug report', submissionType: 'feedback', githubLogin: 'trusted-user', githubEmail: 'trusted@example.com', appVersion: '1.2.3-test' }) }) it('preserves crash submissions for the crash report lane', async () => { await submitFeedback({ feedback: '[Crash Report]', submissionType: 'crash', submitAnonymously: false, githubLogin: 'trusted-user', githubEmail: null } as Parameters[0]) expect(postedBody()).toMatchObject({ feedback: '[Crash Report]', submissionType: 'crash', githubLogin: 'trusted-user', githubEmail: null }) }) it('attaches diagnostic bundles only to crash submissions', async () => { const diagnosticBundle = { bundleSubmissionId: 'bundleabcdefghijklmnop', content: '{"type":"bundle-header"}\n', bytes: 25, spanCount: 1 } await submitFeedback({ feedback: '[Crash Report]', submissionType: 'crash', submitAnonymously: true, githubLogin: null, githubEmail: null, diagnosticBundle } as Parameters[0]) await submitFeedback({ feedback: 'normal feedback', submitAnonymously: true, githubLogin: null, githubEmail: null, diagnosticBundle } as Parameters[0]) const crashInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined const feedbackInit = fetchMock.mock.calls[1]?.[1] as RequestInit | undefined const crashFormData = crashInit?.body as FormData expect(crashFormData).toBeInstanceOf(FormData) expect(crashInit?.headers).toBeUndefined() expect(crashFormData.get('submissionType')).toBe('crash') expect(crashFormData.get('diagnosticBundleSubmissionId')).toBe( diagnosticBundle.bundleSubmissionId ) expect(crashFormData.get('diagnosticBundleBytes')).toBe(String(diagnosticBundle.bytes)) expect(crashFormData.get('diagnosticBundleSpanCount')).toBe(String(diagnosticBundle.spanCount)) const file = crashFormData.get('diagnosticBundleFile') expect(file).toBeInstanceOf(Blob) await expect((file as Blob).text()).resolves.toBe(diagnosticBundle.content) expect(JSON.parse(String(feedbackInit?.body))).not.toHaveProperty('diagnosticBundle') }) it('retries a rejected diagnostic attachment as report-only JSON on the website API', async () => { fetchMock.mockResolvedValueOnce(errorResponse(413)).mockResolvedValueOnce(okResponse()) await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ ok: true, diagnosticBundleFailure: { status: 413, error: 'status 413' } }) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback') expect(requestInit(0).body).toBeInstanceOf(FormData) expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' }) expect(postedBody(1)).toMatchObject({ feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed', submissionType: 'crash' }) expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') }) it('retries a diagnostic attachment server error as report-only JSON on the website API', async () => { fetchMock.mockResolvedValueOnce(errorResponse(502)).mockResolvedValueOnce(okResponse()) await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ ok: true, diagnosticBundleFailure: { status: 502, error: 'status 502' } }) expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' }) expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') }) it('retries a diagnostic attachment network error as report-only JSON on the website API', async () => { fetchMock.mockRejectedValueOnce(new Error('attachment network failed')) fetchMock.mockResolvedValueOnce(okResponse()) await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ ok: true, diagnosticBundleFailure: { status: null, error: 'attachment network failed' } }) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') expect(requestInit(1).body).not.toBeInstanceOf(FormData) expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') }) it('allows 60 seconds for a diagnostic attachment before retrying report-only JSON', async () => { vi.useFakeTimers() fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => { return new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(new Error('runtime abort text'))) }) }) fetchMock.mockResolvedValueOnce(okResponse()) const result = submitFeedback(diagnosticSubmitArgs()) await vi.advanceTimersByTimeAsync(59_999) expect(fetchMock).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) await expect(result).resolves.toEqual({ ok: true, diagnosticBundleFailure: { status: null, error: 'request timed out after 60 seconds' } }) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') expect(postedBody(1)).not.toHaveProperty('diagnosticBundle') }) it('retries a proxy-rejected diagnostic attachment as website JSON', async () => { fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse()) await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ ok: true, diagnosticBundleFailure: { status: 403, error: 'status 403' } }) expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback') expect(requestInit(1).body).not.toBeInstanceOf(FormData) }) it.each([401, 409, 429])( 'does not retry a diagnostic attachment rejected with status %s', async (status) => { fetchMock.mockResolvedValueOnce(errorResponse(status)) await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ ok: false, status, error: `status ${status}` }) expect(fetchMock).toHaveBeenCalledTimes(1) } ) it('preserves attachment and report-only failures when the degraded retry fails', async () => { fetchMock.mockRejectedValueOnce(new Error('attachment network failed')) fetchMock.mockRejectedValueOnce(new Error('report-only network failed')) await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({ ok: false, status: null, error: 'report-only network failed', diagnosticBundleFailure: { status: null, error: 'attachment network failed' } }) expect(fetchMock).toHaveBeenCalledTimes(2) }) it('retries the website API when the primary feedback request stalls', async () => { vi.useFakeTimers() fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => { return new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(new Error('request aborted'))) }) }) fetchMock.mockResolvedValueOnce(okResponse()) const result = submitFeedback({ feedback: 'stalled primary', submitAnonymously: false, githubLogin: 'trusted-user', githubEmail: 'trusted@example.com' }) await vi.advanceTimersByTimeAsync(10_000) await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({ ok: true }) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ 'https://www.onorca.dev/v1/feedback', 'https://www.onorca.dev/v1/feedback' ]) }) it('does not retry a non-diagnostic 404', async () => { fetchMock.mockResolvedValueOnce(errorResponse(404)) await expect( submitFeedback({ feedback: 'missing feedback route', submitAnonymously: true, githubLogin: null, githubEmail: null }) ).resolves.toEqual({ ok: false, status: 404, error: 'status 404' }) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback') }) it('does not retry again when the website retry stalls after a primary server error', async () => { vi.useFakeTimers() fetchMock.mockResolvedValueOnce(errorResponse(500)) fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => { return new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(new Error('retry aborted'))) }) }) const result = submitFeedback({ feedback: 'primary 500 and retry stalled', submitAnonymously: false, githubLogin: 'trusted-user', githubEmail: 'trusted@example.com' }) await vi.advanceTimersByTimeAsync(10_000) await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({ ok: false, status: null, error: 'status 500; retry: request timed out after 10 seconds' }) expect(fetchMock).toHaveBeenCalledTimes(2) expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ 'https://www.onorca.dev/v1/feedback', 'https://www.onorca.dev/v1/feedback' ]) }) it('preserves the primary status when a same-host retry also returns a server error', async () => { fetchMock.mockResolvedValueOnce(errorResponse(502)).mockResolvedValueOnce(errorResponse(503)) await expect( submitFeedback({ feedback: 'primary and retry both server errors', submitAnonymously: true, githubLogin: null, githubEmail: null }) ).resolves.toEqual({ ok: false, status: 503, error: 'status 502; retry: status 503' }) expect(fetchMock).toHaveBeenCalledTimes(2) }) it('posts to the website API first so crash reports use the snippet-capable route', async () => { await submitFeedback({ feedback: '[Crash Report]', submissionType: 'crash', submitAnonymously: true, githubLogin: null, githubEmail: null } as Parameters[0]) expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback') }) it('forces renderer IPC submissions onto the feedback lane', async () => { registerFeedbackHandlers() await handlers.get('feedback:submit')?.(null, { feedback: 'not a crash report', submissionType: 'crash', submitAnonymously: false, githubLogin: 'trusted-user', githubEmail: null }) expect(postedBody()).toMatchObject({ feedback: 'not a crash report', submissionType: 'feedback', githubLogin: 'trusted-user', githubEmail: null }) }) describe('image attachments', () => { function pngImage(bytes = 8): { contentType: string; data: Uint8Array } { return { contentType: 'image/png', data: new Uint8Array(bytes).fill(1) } } function imageSubmitArgs( images: { contentType: string; data: Uint8Array }[] ): Parameters[0] { return { feedback: 'images attached', submissionType: 'feedback', githubLogin: 'someone', githubEmail: null, images } } function jsonResponse(body: unknown): Response { return Response.json(body, { status: 202 }) } it('sends attached images as multipart form parts', async () => { await submitFeedback(imageSubmitArgs([pngImage(), pngImage()])) const body = requestInit().body as FormData expect(body).toBeInstanceOf(FormData) expect(body.getAll('feedbackImage')).toHaveLength(2) expect(body.get('feedback')).toBe('images attached') // Why: multipart must not lose the enrichment fields the JSON lane sends. expect(body.get('submissionType')).toBe('feedback') expect(body.get('appVersion')).toBe('1.2.3-test') }) it('keeps the JSON lane when nothing is attached', async () => { await submitFeedback(imageSubmitArgs([])) expect(requestInit().body).not.toBeInstanceOf(FormData) expect(postedBody().feedback).toBe('images attached') }) it('reports partial delivery when the server could not attach the images', async () => { fetchMock.mockResolvedValue(jsonResponse({ ok: true, imagesDelivered: false })) await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({ ok: true, imagesDelivered: false }) }) it('accepts the production atomic-success response when it omits the image result', async () => { fetchMock.mockResolvedValue(jsonResponse({ ok: true })) await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({ ok: true, imagesDelivered: true }) }) it('reports unconfirmed delivery for a settled non-JSON 2xx', async () => { fetchMock.mockResolvedValue({ ok: true, status: 202, json: async () => { throw new SyntaxError('Unexpected token') } } as unknown as Response) await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({ ok: true, imagesDelivered: false }) }) it('reports unconfirmed delivery when the response body aborts before the deadline', async () => { fetchMock.mockResolvedValue({ ok: true, status: 202, json: async () => { throw new TypeError('terminated') } } as unknown as Response) await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({ ok: true, imagesDelivered: false }) expect(requestInit().signal).toMatchObject({ aborted: false }) }) it('bounds the image-delivery response body', async () => { fetchMock.mockResolvedValue( new Response('x'.repeat(MAX_FEEDBACK_IMAGE_RESPONSE_BYTES + 1), { status: 202 }) ) await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({ ok: true, imagesDelivered: false }) }) it('fails a stalled delivery response body at the attachment timeout', async () => { vi.useFakeTimers() fetchMock.mockImplementation((_url: string, init?: RequestInit) => Promise.resolve({ ok: true, status: 202, json: () => new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(new Error('body aborted'))) }) } as unknown as Response) ) const result = submitFeedback(imageSubmitArgs([pngImage()])) await vi.advanceTimersByTimeAsync(60_000) await expect(result).resolves.toEqual({ ok: false, status: null, error: 'request timed out after 60 seconds' }) expect(requestInit().signal).toMatchObject({ aborted: true }) }) it('rejects unsupported image types before any request is made', async () => { const result = await submitFeedback( imageSubmitArgs([{ contentType: 'application/pdf', data: new Uint8Array(4) }]) ) expect(result.ok).toBe(false) expect(fetchMock).not.toHaveBeenCalled() }) // Why: the renderer screens types first, so this lane only matters for a // renderer invoking the channel directly — the case the handler guards. it('rejects a prototype member posing as a content type over IPC', async () => { registerFeedbackHandlers() const result = (await handlers.get('feedback:submit')?.(null, { feedback: 'images attached', githubLogin: null, githubEmail: null, images: [{ contentType: 'constructor', data: new Uint8Array(4).fill(1) }] })) as { ok: boolean; error?: string } expect(result).toMatchObject({ ok: false, error: 'Unsupported image type.' }) expect(fetchMock).not.toHaveBeenCalled() }) it('rejects malformed IPC bytes before typed-array normalization', async () => { registerFeedbackHandlers() const result = (await handlers.get('feedback:submit')?.(null, { feedback: 'images attached', githubLogin: null, githubEmail: null, images: [{ contentType: 'image/png', data: '8388608' }] })) as { ok: boolean; error?: string } expect(result).toMatchObject({ ok: false, error: 'Invalid image attachment bytes.' }) expect(fetchMock).not.toHaveBeenCalled() }) it('rejects oversized IPC batches before normalizing their entries', async () => { registerFeedbackHandlers() const result = (await handlers.get('feedback:submit')?.(null, { feedback: 'images attached', githubLogin: null, githubEmail: null, images: Array.from({ length: 5 }, () => ({ contentType: 'image/png', data: '8388608' })) })) as { ok: boolean; error?: string } expect(result).toMatchObject({ ok: false, error: 'Attach 4 images or fewer.' }) expect(fetchMock).not.toHaveBeenCalled() }) it('rejects more images than the supported count', async () => { const result = await submitFeedback( imageSubmitArgs(Array.from({ length: 5 }, () => pngImage())) ) expect(result.ok).toBe(false) expect(fetchMock).not.toHaveBeenCalled() }) it('does not fail a crash report over images it was never going to send', async () => { // Why: the crash lane discards images, so validating them there would // abort a crash report the user needs delivered. await submitFeedback({ ...diagnosticSubmitArgs(), images: Array.from({ length: 9 }, () => ({ contentType: 'application/pdf', data: new Uint8Array(0) })) } as Parameters[0]) expect(fetchMock).toHaveBeenCalledTimes(1) const body = requestInit().body as FormData expect(body.getAll('feedbackImage')).toHaveLength(0) expect(body.get('submissionType')).toBe('crash') }) it('drops images from crash submissions', async () => { await submitFeedback({ ...diagnosticSubmitArgs(), images: [pngImage()] } as Parameters[0]) const body = requestInit().body as FormData expect(body.getAll('feedbackImage')).toHaveLength(0) expect(body.get('diagnosticBundleSubmissionId')).toBe('bundleabcdefghijklmnop') }) }) })