mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
Fix private Linear images in task descriptions (#25849)
* Fix private Linear images in task descriptions * Strengthen Linear image signing validation and typed access
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
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: '',
|
||||
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 () => {
|
||||
|
||||
@@ -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\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<typeof LinearClientModule>('./client')
|
||||
getPublicFileUrlClient.mockImplementation(clientModule.getPublicFileUrlClient)
|
||||
const fetch = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(
|
||||
Response.json({ data: { issue: { description: `` } } })
|
||||
)
|
||||
|
||||
await expect(
|
||||
getDescriptionImageUrls(entry, 'issue-1', ``)
|
||||
).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', ''])(
|
||||
'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: `\n`
|
||||
}
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
getDescriptionImageUrls(entry, 'issue-1', ``)
|
||||
).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: `` })
|
||||
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', ``)
|
||||
).rejects.toBe(error)
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, string> | 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<string, unknown>
|
||||
>(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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string>
|
||||
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])
|
||||
|
||||
@@ -226,6 +226,7 @@ export function LinearIssueTextEditor({
|
||||
<div className="relative">
|
||||
<LinearIssueMarkdownDescriptionEditor
|
||||
value={descriptionDraft}
|
||||
imageUrls={issue.descriptionImageUrls}
|
||||
onChange={updateDescriptionDraft}
|
||||
onSave={saveDescriptionValue}
|
||||
density={density}
|
||||
|
||||
@@ -44,6 +44,7 @@ import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
|
||||
import { RichMarkdownTaskList } from './rich-markdown-task-list'
|
||||
import { createCachedLowlight } from './rich-markdown-lowlight-cache'
|
||||
import { documentResourceAccess } from '@/lib/local-file-access'
|
||||
import { resolveRichMarkdownImageUrl } from './rich-markdown-image-context'
|
||||
|
||||
const lowlight = createCachedLowlight(createLowlight(common))
|
||||
|
||||
@@ -171,7 +172,7 @@ export function createRichMarkdownExtensions({
|
||||
}
|
||||
)
|
||||
} else if (src) {
|
||||
img.src = src
|
||||
img.src = resolveRichMarkdownImageUrl(this.storage, src)
|
||||
} else {
|
||||
img.removeAttribute('src')
|
||||
}
|
||||
|
||||
@@ -9,15 +9,36 @@ export type RichMarkdownImageRuntimeContext = Omit<RuntimeFileOperationArgs, 'co
|
||||
|
||||
export type RichMarkdownImageResolverContext = {
|
||||
filePath: string
|
||||
imageUrls?: Record<string, string>
|
||||
runtimeContext?: RichMarkdownImageRuntimeContext
|
||||
}
|
||||
|
||||
export type RichMarkdownImageResolverSettings = Parameters<typeof settingsForRuntimeOwner>[0]
|
||||
|
||||
type RichMarkdownImageUrls = Record<string, string>
|
||||
|
||||
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<string, unknown>, 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<string, string>
|
||||
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 ?? '',
|
||||
|
||||
@@ -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\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(``)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ export type LinearIssue = {
|
||||
title: string
|
||||
branchName?: string
|
||||
description?: string
|
||||
descriptionImageUrls?: Record<string, string>
|
||||
url: string
|
||||
state: {
|
||||
name: string
|
||||
|
||||
@@ -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\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<void> {
|
||||
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: ``,
|
||||
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<void> {
|
||||
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(``) }])
|
||||
const saved = await electronApp.evaluate(() => globalThis.__linearImageSaved)
|
||||
expect(JSON.stringify(saved)).toContain('Verified edit')
|
||||
expect(JSON.stringify(saved)).not.toContain('signature=')
|
||||
})
|
||||
Reference in New Issue
Block a user