From 5b8a982f8f76afea8bacbab437173fb617f0e6aa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:49:22 -0700 Subject: [PATCH] Fix private Linear images in task descriptions (#25849) * Fix private Linear images in task descriptions * Strengthen Linear image signing validation and typed access --- src/main/linear/client.test.ts | 13 ++ src/main/linear/issues.test.ts | 17 +- .../linear/linear-description-images.test.ts | 124 +++++++++++++ src/main/linear/linear-description-images.ts | 43 +++++ src/main/linear/linear-issue-comments.ts | 4 +- src/main/linear/linear-issue-lookups.ts | 9 +- .../LinearIssueMarkdownDescriptionEditor.tsx | 9 + .../src/components/LinearIssueTextEditor.tsx | 1 + .../editor/rich-markdown-extensions.ts | 3 +- .../editor/rich-markdown-image-context.ts | 24 +++ .../rich-markdown-signed-images.test.ts | 63 +++++++ src/shared/linear/issue-types.ts | 1 + tests/e2e/linear-private-images.spec.ts | 170 ++++++++++++++++++ 13 files changed, 470 insertions(+), 11 deletions(-) create mode 100644 src/main/linear/linear-description-images.test.ts create mode 100644 src/main/linear/linear-description-images.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-signed-images.test.ts create mode 100644 tests/e2e/linear-private-images.spec.ts diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 1043a0997ec..a442ebad3de 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -146,6 +146,19 @@ function mkdtempLike(prefix: string): string { } describe('Linear client workspace storage', () => { + it('requests temporary file URLs using only the selected workspace credential', async () => { + const linear = await loadClientModule() + await linear.connect('token-alpha') + await linear.connect('token-beta') + const entry = linear.getClients('org-alpha')[0] + linear.getPublicFileUrlClient(entry) + expect(linearClientMock).toHaveBeenLastCalledWith({ + apiKey: 'token-alpha', + headers: { + 'public-file-urls-expire-in': String(linear.LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS) + } + }) + }) it('stores multiple workspaces and remembers the selected workspace', async () => { const linear = await loadClientModule() diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index ecc2060aabb..73350e7c5c0 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -3,6 +3,7 @@ import type { LinearClientForWorkspace } from './client' import { credentialDecryptionMessage } from '../../shared/integration-credential-errors' const rawRequest = vi.fn() +const signedRawRequest = vi.fn() const getClients = vi.fn() const clearToken = vi.fn() const isAuthError = vi.fn() @@ -18,6 +19,7 @@ vi.mock('./linear-token-store', () => ({ vi.mock('./client', () => ({ getClients: (...args: unknown[]) => getClients(...args), + getPublicFileUrlClient: () => ({ client: { rawRequest: signedRawRequest } }), isAuthError: (...args: unknown[]) => isAuthError(...args) })) @@ -139,14 +141,14 @@ describe('Linear issue queries', () => { }) it('fetches issue comments with one request (no per-comment user N+1)', async () => { - rawRequest.mockResolvedValueOnce({ + signedRawRequest.mockResolvedValueOnce({ data: { issue: { comments: { nodes: [ { id: 'comment-1', - body: 'First', + body: '![First](https://uploads.linear.app/w/image?signature=fresh)', createdAt: '2026-01-02T03:04:05.000Z', user: { displayName: 'Ada', avatarUrl: 'https://example.com/a.png' } }, @@ -172,7 +174,7 @@ describe('Linear issue queries', () => { await expect(getIssueComments('issue-uuid', 'workspace-1')).resolves.toEqual([ { id: 'comment-1', - body: 'First', + body: '![First](https://uploads.linear.app/w/image?signature=fresh)', createdAt: '2026-01-02T03:04:05.000Z', user: { displayName: 'Ada', avatarUrl: 'https://example.com/a.png' } }, @@ -186,10 +188,11 @@ describe('Linear issue queries', () => { ]) // The point of the fix: one request regardless of comment count. - expect(rawRequest).toHaveBeenCalledTimes(1) - expect(rawRequest.mock.calls[0][0]).toContain('query OrcaLinearIssueComments') - expect(rawRequest.mock.calls[0][0]).toContain('user {') - expect(rawRequest.mock.calls[0][1]).toEqual({ id: 'issue-uuid' }) + expect(signedRawRequest).toHaveBeenCalledTimes(1) + expect(signedRawRequest.mock.calls[0][0]).toContain('query OrcaLinearIssueComments') + expect(signedRawRequest.mock.calls[0][0]).toContain('user {') + expect(signedRawRequest.mock.calls[0][1]).toEqual({ id: 'issue-uuid' }) + expect(rawRequest).not.toHaveBeenCalled() }) it('returns an empty comment list when no Linear client is configured', async () => { diff --git a/src/main/linear/linear-description-images.test.ts b/src/main/linear/linear-description-images.test.ts new file mode 100644 index 00000000000..2992bca26f1 --- /dev/null +++ b/src/main/linear/linear-description-images.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as LinearClientModule from './client' +import { loadLinearSdk } from './linear-sdk' +import { getDescriptionImageUrls } from './linear-description-images' +import { getIssue } from './linear-issue-lookups' + +const { rawRequest, issue, getClients, getPublicFileUrlClient, isAuthError } = vi.hoisted(() => ({ + rawRequest: vi.fn(), + issue: vi.fn(), + getClients: vi.fn(), + getPublicFileUrlClient: vi.fn(), + isAuthError: vi.fn() +})) + +vi.mock('./client', () => ({ getClients, getPublicFileUrlClient, isAuthError })) +vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() })) +vi.mock('./linear-request-concurrency', () => ({ acquire: vi.fn(), release: vi.fn() })) +vi.mock('./linear-issue-query-support', () => ({ + mapIssueForWorkspace: async (_entry: unknown, result: unknown) => result +})) + +const entry = { + workspace: { + id: 'workspace-1', + organizationId: 'workspace-1', + organizationName: 'Workspace', + displayName: 'Ada', + email: null + }, + apiKey: 'private-key', + client: new (loadLinearSdk().LinearClient)({ apiKey: 'private-key' }) +} +entry.client.issue = issue +const source = 'https://uploads.linear.app/w/image' +const signed = `${source}?signature=fresh&expires=123` + +describe('Linear description images', () => { + afterEach(() => vi.restoreAllMocks()) + + beforeEach(() => { + vi.resetAllMocks() + getClients.mockReturnValue([entry]) + getPublicFileUrlClient.mockReturnValue({ client: { rawRequest } }) + }) + + it('keeps editable description URLs canonical while supplying signed display URLs', async () => { + const description = `Before\n\n![Screenshot](${source})\n\nAfter` + issue.mockResolvedValue({ id: 'issue-1', description }) + rawRequest.mockResolvedValue({ + data: { issue: { description: description.replace(source, signed) } } + }) + + await expect(getIssue('issue-1', 'workspace-1')).resolves.toEqual({ + id: 'issue-1', + description, + descriptionImageUrls: { [source]: signed } + }) + expect(getPublicFileUrlClient).toHaveBeenCalledWith(entry) + expect(rawRequest.mock.calls[0][1]).toEqual({ id: 'issue-1' }) + }) + + it('sends the signing header through the real SDK transport with the owning workspace token', async () => { + const clientModule = await vi.importActual('./client') + getPublicFileUrlClient.mockImplementation(clientModule.getPublicFileUrlClient) + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + Response.json({ data: { issue: { description: `![Screenshot](${signed})` } } }) + ) + + await expect( + getDescriptionImageUrls(entry, 'issue-1', `![Screenshot](${source})`) + ).resolves.toEqual({ [source]: signed }) + expect(fetch).toHaveBeenCalledExactlyOnceWith( + 'https://api.linear.app/graphql', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: entry.apiKey, + 'public-file-urls-expire-in': String(clientModule.LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS) + }) + }) + ) + }) + + it.each([undefined, 'No images', '![Public](https://example.com/image.png)'])( + 'skips the extra read for descriptions without private uploads: %s', + async (description) => { + issue.mockResolvedValue({ id: 'issue-1', description }) + await expect(getIssue('issue-1')).resolves.toEqual({ id: 'issue-1', description }) + expect(getPublicFileUrlClient).not.toHaveBeenCalled() + } + ) + + it('matches reordered media by path and preserves the original query parameters', async () => { + const original = `${source}?width=640` + rawRequest.mockResolvedValue({ + data: { + issue: { + description: `![Other](https://uploads.linear.app/w/other?signature=other)\n![Screenshot](${signed})` + } + } + }) + await expect( + getDescriptionImageUrls(entry, 'issue-1', `![Screenshot](${original})`) + ).resolves.toEqual({ [original]: signed }) + }) + + it('keeps the issue readable when the signing read fails', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + issue.mockResolvedValue({ id: 'issue-1', description: `![Screenshot](${source})` }) + rawRequest.mockRejectedValue(new Error('Network unavailable')) + await expect(getIssue('issue-1')).resolves.toMatchObject({ id: 'issue-1' }) + }) + + it('propagates authentication failures for credential recovery', async () => { + const error = new Error('Unauthorized') + isAuthError.mockReturnValue(true) + rawRequest.mockRejectedValue(error) + await expect( + getDescriptionImageUrls(entry, 'issue-1', `![Screenshot](${source})`) + ).rejects.toBe(error) + }) +}) diff --git a/src/main/linear/linear-description-images.ts b/src/main/linear/linear-description-images.ts new file mode 100644 index 00000000000..f651c72542f --- /dev/null +++ b/src/main/linear/linear-description-images.ts @@ -0,0 +1,43 @@ +import { extractLinearInlineMedia } from '../../shared/linear/inline-media' +import { getPublicFileUrlClient, isAuthError, type LinearClientForWorkspace } from './client' + +const DESCRIPTION_QUERY = `query OrcaLinearDescriptionImages($id: String!) { + issue(id: $id) { description } +}` + +export async function getDescriptionImageUrls( + entry: LinearClientForWorkspace, + issueId: string, + description: string | undefined +): Promise | undefined> { + const uploads = extractLinearInlineMedia(description, 'description').filter( + (media) => media.linearUpload + ) + if (uploads.length === 0) { + return undefined + } + + try { + const result = await getPublicFileUrlClient(entry).client.rawRequest< + { issue?: { description?: string | null } | null }, + Record + >(DESCRIPTION_QUERY, { id: issueId }) + const signed = extractLinearInlineMedia(result.data?.issue?.description, 'description').filter( + (media) => media.linearUpload + ) + const signedByPath = new Map(signed.map((media) => [new URL(media.url).pathname, media.url])) + // Keep access signatures out of the editable Markdown and its save payload. + return Object.fromEntries( + uploads.flatMap((media) => { + const url = signedByPath.get(new URL(media.url).pathname) + return url ? [[media.url, url]] : [] + }) + ) + } catch (error) { + if (isAuthError(error)) { + throw error + } + console.warn('[linear] description image URLs failed:', error) + return undefined + } +} diff --git a/src/main/linear/linear-issue-comments.ts b/src/main/linear/linear-issue-comments.ts index 98a6d61a04a..fffcf3053a1 100644 --- a/src/main/linear/linear-issue-comments.ts +++ b/src/main/linear/linear-issue-comments.ts @@ -2,7 +2,7 @@ import type { LinearClient } from '@linear/sdk' import type { LinearComment } from '../../shared/linear/issue-types' import { acquire, release } from './linear-request-concurrency' import { clearToken } from './linear-token-store' -import { getClients, isAuthError } from './client' +import { getClients, getPublicFileUrlClient, isAuthError } from './client' import { ATTACHMENT_BY_UUID_QUERY, COMMENT_BY_UUID_QUERY, @@ -184,7 +184,7 @@ export async function getIssueComments( await acquire() try { - const result = await entry.client.client.rawRequest< + const result = await getPublicFileUrlClient(entry).client.rawRequest< LinearIssueCommentsResponse, LinearRawVariables >(ISSUE_COMMENTS_QUERY, { id: issueId }) diff --git a/src/main/linear/linear-issue-lookups.ts b/src/main/linear/linear-issue-lookups.ts index f79cc826162..567aaa4c210 100644 --- a/src/main/linear/linear-issue-lookups.ts +++ b/src/main/linear/linear-issue-lookups.ts @@ -3,6 +3,7 @@ import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-typ import { acquire, release } from './linear-request-concurrency' import { clearToken } from './linear-token-store' import { getClients, isAuthError } from './client' +import { getDescriptionImageUrls } from './linear-description-images' import { ATTACHMENT_BY_UUID_QUERY, COMMENT_BY_UUID_QUERY, @@ -43,10 +44,16 @@ export async function getIssue( await acquire() try { const issue = await entry.client.issue(id) - return await mapIssueForWorkspace(entry, issue, { + const mapped = await mapIssueForWorkspace(entry, issue, { includeChildren: true, includeProject: true }) + const descriptionImageUrls = await getDescriptionImageUrls( + entry, + mapped.id, + mapped.description + ) + return { ...mapped, ...(descriptionImageUrls ? { descriptionImageUrls } : {}) } } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index 3eb17140995..49790bbf285 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -6,6 +6,7 @@ import Placeholder from '@tiptap/extension-placeholder' import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' +import { setRichMarkdownImageResolverContext } from '@/components/editor/rich-markdown-image-context' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' import { createRichMarkdownEditorCodec, @@ -24,6 +25,7 @@ import { type LinearIssueMarkdownDescriptionEditorProps = { value: string + imageUrls?: Record onChange: (value: string) => void onSave: (value: string) => void density: 'page' | 'drawer' @@ -46,6 +48,7 @@ function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) { export function LinearIssueMarkdownDescriptionEditor({ value, + imageUrls, onChange, onSave, density, @@ -115,6 +118,12 @@ export function LinearIssueMarkdownDescriptionEditor({ ) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) + useEffect(() => { + if (editor) { + setRichMarkdownImageResolverContext(editor, { filePath: '', imageUrls }) + } + }, [editor, imageUrls]) + useEffect(() => { editorRef.current = editor }, [editor]) diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 66806bf70f9..3bc96a93cd0 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -226,6 +226,7 @@ export function LinearIssueTextEditor({
runtimeContext?: RichMarkdownImageRuntimeContext } export type RichMarkdownImageResolverSettings = Parameters[0] +type RichMarkdownImageUrls = Record + +function isRichMarkdownImageUrls(value: unknown): value is RichMarkdownImageUrls { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value).every((url) => typeof url === 'string') + ) +} + +export function resolveRichMarkdownImageUrl(storage: Record, src: string): string { + const imageUrls = storage.imageUrls + if (isRichMarkdownImageUrls(imageUrls) && Object.hasOwn(imageUrls, src)) { + return imageUrls[src] ?? src + } + return src +} + type RichMarkdownImageStorage = { image?: { contextVersion?: number filePath: string + imageUrls?: Record reloadListeners?: Set<() => void> runtimeContext?: RichMarkdownImageRuntimeContext } @@ -71,6 +92,7 @@ export function setRichMarkdownImageResolverContext( } const previousSignature = getRichMarkdownImageContextSignature({ filePath: imageStorage.filePath, + imageUrls: imageStorage.imageUrls, runtimeContext: imageStorage.runtimeContext }) const nextSignature = getRichMarkdownImageContextSignature(context) @@ -81,6 +103,7 @@ export function setRichMarkdownImageResolverContext( // Why: nodeViews need a cheap change signal because the markdown src can // remain identical while the file/runtime resolver context changes. imageStorage.filePath = context.filePath + imageStorage.imageUrls = context.imageUrls imageStorage.runtimeContext = context.runtimeContext imageStorage.contextVersion = (imageStorage.contextVersion ?? 0) + 1 storage.image = imageStorage @@ -93,6 +116,7 @@ export function setRichMarkdownImageResolverContext( function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolverContext): string { return [ context.filePath, + JSON.stringify(context.imageUrls ?? {}), context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client', context.runtimeContext?.connectionId ?? 'local', context.runtimeContext?.expectedExternalSshTargetId ?? '', diff --git a/src/renderer/src/components/editor/rich-markdown-signed-images.test.ts b/src/renderer/src/components/editor/rich-markdown-signed-images.test.ts new file mode 100644 index 00000000000..5c86e5dcdd7 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-signed-images.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/react' +import { afterEach, describe, expect, it } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context' + +const editors: Editor[] = [] +const source = 'https://uploads.linear.app/workspace/image' + +function createEditor() { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + extensions: createRichMarkdownExtensions({ codec }), + content: `Before\n\n![Screenshot](${source})\n\nAfter`, + contentType: 'markdown' + }) + document.body.append(editor.view.dom) + editors.push(editor) + return editor +} + +afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()) + document.body.replaceChildren() +}) + +describe('signed images in rich Markdown', () => { + it('displays signed URLs while edits and serialization retain the source URL', () => { + const editor = createEditor() + const signed = `${source}?signature=temporary` + setRichMarkdownImageResolverContext(editor, { filePath: '', imageUrls: { [source]: signed } }) + expect(editor.view.dom.querySelector('img')?.src).toBe(signed) + editor.commands.insertContentAt(1, 'Edited ') + expect(editor.getMarkdown()).toContain(`![Screenshot](${source})`) + expect(editor.getMarkdown()).toContain('Edited Before') + expect(editor.getMarkdown()).not.toContain('signature') + expect(editor.getHTML()).not.toContain('signature') + }) + + it('refreshes signatures without replacing the document or disturbing selection', () => { + const editor = createEditor() + editor.commands.setTextSelection(3) + const doc = editor.state.doc + for (const signature of ['first', 'refreshed']) { + const signed = `${source}?signature=${signature}` + setRichMarkdownImageResolverContext(editor, { filePath: '', imageUrls: { [source]: signed } }) + expect(editor.view.dom.querySelector('img')?.src).toBe(signed) + expect(editor.state.doc).toBe(doc) + expect(editor.state.selection.from).toBe(3) + } + }) + + it('clears the previous issue mapping and leaves ordinary URLs alone', () => { + const editor = createEditor() + setRichMarkdownImageResolverContext(editor, { + filePath: '', + imageUrls: { [source]: `${source}?signature=temporary` } + }) + setRichMarkdownImageResolverContext(editor, { filePath: '' }) + expect(editor.view.dom.querySelector('img')?.src).toBe(source) + }) +}) diff --git a/src/shared/linear/issue-types.ts b/src/shared/linear/issue-types.ts index db1ba1a068d..121d39391ad 100644 --- a/src/shared/linear/issue-types.ts +++ b/src/shared/linear/issue-types.ts @@ -8,6 +8,7 @@ export type LinearIssue = { title: string branchName?: string description?: string + descriptionImageUrls?: Record url: string state: { name: string diff --git a/tests/e2e/linear-private-images.spec.ts b/tests/e2e/linear-private-images.spec.ts new file mode 100644 index 00000000000..a593f6a82c5 --- /dev/null +++ b/tests/e2e/linear-private-images.spec.ts @@ -0,0 +1,170 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import type { LinearIssue } from '../../src/shared/linear/issue-types' +import type { LinearIssueUpdate } from '../../src/shared/issue-mutation-types' + +declare global { + var __linearImageSaved: LinearIssueUpdate[] | undefined +} + +const SOURCE = 'https://uploads.linear.app/test-workspace/screenshot.png' +const SIGNED = `${SOURCE}?signature=temporary` +const DESCRIPTION = `Screenshot from the issue:\n\n![Screenshot](${SOURCE})\n\nDescription text.` +const ISSUE: LinearIssue = { + id: 'private-images', + identifier: 'IMG-1', + workspaceId: 'test-workspace', + title: 'Images in Linear tasks', + description: DESCRIPTION, + url: 'https://linear.app/test/issue/IMG-1', + state: { name: 'Todo', type: 'unstarted', color: '' }, + team: { id: 'team-1', name: 'Test', key: 'IMG' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '2026-10-06T00:00:00.000Z' +} + +async function installFixture(app: ElectronApplication, signed: boolean): Promise { + await app.evaluate( + ({ ipcMain }, { issue, source, signedUrl, signed }) => { + const displayed = { + ...issue, + id: signed ? 'after' : 'before', + ...(signed ? { descriptionImageUrls: { [source]: signedUrl } } : {}) + } + const saved: LinearIssueUpdate[] = [] + for (const channel of [ + 'linear:getIssue', + 'linear:issueComments', + 'linear:updateIssue', + 'linear:listTeams', + 'linear:listIssues', + 'linear:teamStates', + 'linear:teamLabels', + 'linear:teamMembers' + ]) { + ipcMain.removeHandler(channel) + } + ipcMain.handle('linear:getIssue', () => displayed) + ipcMain.handle('linear:issueComments', () => [ + { + id: 'comment-1', + body: `![Comment screenshot](${signed ? signedUrl : source})`, + createdAt: issue.updatedAt, + user: { displayName: 'Test user' } + } + ]) + ipcMain.handle('linear:updateIssue', (_event, args: { updates: LinearIssueUpdate }) => { + saved.push(args.updates) + return { ok: true } + }) + ipcMain.handle('linear:listTeams', () => []) + ipcMain.handle('linear:listIssues', () => ({ items: [] })) + ipcMain.handle('linear:teamStates', () => []) + ipcMain.handle('linear:teamLabels', () => []) + ipcMain.handle('linear:teamMembers', () => []) + globalThis.__linearImageSaved = saved + }, + { issue: ISSUE, source: SOURCE, signedUrl: SIGNED, signed } + ) +} + +async function openIssue(page: Page, signed: boolean): Promise { + await page.evaluate( + ({ issue, signed }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.setState({ + linearStatus: { + connected: true, + viewer: null, + workspaces: [], + activeWorkspaceId: 'test-workspace', + selectedWorkspaceId: 'test-workspace' + }, + linearStatusChecked: true, + checkLinearConnection: async () => {} + }) + store.getState().openTaskPage({ + taskSource: 'linear', + openLinearIssue: { ...issue, id: signed ? 'after' : 'before' } + }) + }, + { issue: ISSUE, signed } + ) + await expect(page.getByLabel('Issue description', { exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: 'Comment screenshot' })).toBeVisible() +} + +test.use({ seedTestRepo: false }) + +test('private Linear images load without putting signatures into description edits', async ({ + electronApp, + orcaPage +}, testInfo) => { + const image = readFileSync(join(process.cwd(), 'resources', 'icon.png')) + let unsignedRequests = 0 + let signedRequests = 0 + await orcaPage.route('https://uploads.linear.app/**', async (route) => { + if (new URL(route.request().url()).searchParams.get('signature') === 'temporary') { + signedRequests++ + await route.fulfill({ status: 200, contentType: 'image/png', body: image }) + } else { + unsignedRequests++ + await route.fulfill({ status: 401, body: 'Unauthorized' }) + } + }) + + await installFixture(electronApp, false) + await openIssue(orcaPage, false) + await expect.poll(() => unsignedRequests).toBeGreaterThanOrEqual(1) + await expect + .poll(() => + orcaPage + .getByAltText('Screenshot', { exact: true }) + .evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth === 0) + ) + .toBe(true) + await testInfo.attach('linear-images-before.png', { + body: await orcaPage.screenshot({ path: testInfo.outputPath('linear-images-before.png') }), + contentType: 'image/png' + }) + + await installFixture(electronApp, true) + await openIssue(orcaPage, true) + await expect + .poll(() => + orcaPage + .getByAltText('Screenshot', { exact: true }) + .evaluate((img: HTMLImageElement) => img.naturalWidth) + ) + .toBeGreaterThan(0) + await expect(orcaPage.getByRole('link', { name: 'Comment screenshot' })).toHaveAttribute( + 'href', + SIGNED + ) + await expect.poll(() => signedRequests).toBeGreaterThanOrEqual(1) + await testInfo.attach('linear-images-after.png', { + body: await orcaPage.screenshot({ path: testInfo.outputPath('linear-images-after.png') }), + contentType: 'image/png' + }) + + const editor = orcaPage.getByLabel('Issue description', { exact: true }) + await editor.click() + await editor.press('End') + await editor.press('ArrowRight') + await editor.press('Enter') + await editor.pressSequentially('Verified edit') + await orcaPage.getByRole('textbox', { name: 'Issue title' }).click() + await expect + .poll(() => electronApp.evaluate(() => globalThis.__linearImageSaved)) + .toEqual([{ description: expect.stringContaining(`![Screenshot](${SOURCE})`) }]) + const saved = await electronApp.evaluate(() => globalThis.__linearImageSaved) + expect(JSON.stringify(saved)).toContain('Verified edit') + expect(JSON.stringify(saved)).not.toContain('signature=') +})