import { afterEach, describe, expect, it, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { registerMarkdownImageGrantRoutes } from './routes.js'; const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', 'base64', ); const roots = []; afterEach(async () => { vi.unstubAllGlobals(); await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); const createFixture = async ({ sources, markdown } = {}) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-session-assets-')); roots.push(root); const approvedTempRoot = path.join(root, 'opencode'); const directory = path.join(root, 'workspace'); await Promise.all([ fs.mkdir(approvedTempRoot, { recursive: true }), fs.mkdir(directory, { recursive: true }), ]); const defaultPath = path.join(approvedTempRoot, 'image.png'); await fs.writeFile(defaultPath, PNG); const requestedSources = sources ?? [new URL(`file://${defaultPath}`).toString()]; const text = markdown ?? requestedSources.map((source) => `![image](${source})`).join('\n'); const fetchMock = vi.fn(async () => new Response(JSON.stringify({ info: { id: 'msg_1', role: 'assistant' }, parts: [{ type: 'text', text }], }), { status: 200, headers: { 'content-type': 'application/json' } })); vi.stubGlobal('fetch', fetchMock); let fullReadCount = 0; const app = express(); registerMarkdownImageGrantRoutes(app, { fsPromises: { ...fs, readFile: async (...args) => { fullReadCount += 1; return fs.readFile(...args); }, }, path, os, crypto, approvedTempRoot, validateDirectoryPath: async (candidate) => candidate === directory ? { ok: true, directory } : { ok: false, error: 'Invalid directory' }, buildOpenCodeUrl: (route) => `http://opencode.test${route}`, getOpenCodeAuthHeaders: () => ({ authorization: 'Basic test' }), }); return { app, approvedTempRoot, directory, fetchMock, fullReadCount: () => fullReadCount, root, sources: requestedSources, }; }; const prepare = (app, directory, sources) => request(app) .post('/api/openchamber/sessions/ses_1/markdown-image-grants') .send({ directory, messageId: 'msg_1', sources }) .expect(200); describe('session image assets', () => { it('percent-encodes the directory header on the message fetch', async () => { const fixture = await createFixture(); await prepare(fixture.app, fixture.directory, ['image.png']); expect(fixture.fetchMock).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ headers: expect.objectContaining({ 'x-opencode-directory': encodeURIComponent(fixture.directory), }), }), ); }); it('prepares workspace and OpenCode temporary images with one message fetch', async () => { const fixture = await createFixture({ sources: ['workspace.png'] }); await fs.writeFile(path.join(fixture.directory, 'workspace.png'), PNG); const temporaryPath = path.join(fixture.approvedTempRoot, 'temporary.png'); await fs.writeFile(temporaryPath, PNG); const temporarySource = new URL(`file://${temporaryPath}`).toString(); fixture.fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ info: { id: 'msg_1', role: 'assistant' }, parts: [{ type: 'text', text: `![workspace](workspace.png)\n![temporary](${temporarySource})` }], }), { status: 200, headers: { 'content-type': 'application/json' } })); const response = await prepare(fixture.app, fixture.directory, ['workspace.png', temporarySource]); expect(fixture.fetchMock).toHaveBeenCalledTimes(1); expect(fixture.fullReadCount()).toBe(0); expect(response.body.results).toHaveLength(2); const canonicalTemporaryPath = await fs.realpath(temporaryPath); expect(response.body.results[0]).toEqual({ source: 'workspace.png', status: 'ready', path: path.join(fixture.directory, 'workspace.png'), }); expect(response.body.results[1]).toEqual(expect.objectContaining({ source: temporarySource, status: 'ready', path: canonicalTemporaryPath, outsideFileGrant: expect.any(String), expiresAt: expect.any(Number), })); }); it('returns partial results without letting one missing image block valid images', async () => { const fixture = await createFixture({ sources: ['present.png', 'deleted.png'] }); await fs.writeFile(path.join(fixture.directory, 'present.png'), PNG); const response = await prepare(fixture.app, fixture.directory, fixture.sources); expect(response.body.results).toEqual([ expect.objectContaining({ source: 'present.png', status: 'ready' }), { source: 'deleted.png', status: 'missing' }, ]); }); it('resolves encoded workspace paths without treating query or fragment text as a filename', async () => { const source = 'screen%20shot.png?version=1#preview'; const fixture = await createFixture({ sources: [source] }); await fs.writeFile(path.join(fixture.directory, 'screen shot.png'), PNG); const response = await prepare(fixture.app, fixture.directory, fixture.sources); expect(response.body.results).toEqual([ expect.objectContaining({ source, status: 'ready' }), ]); }); it('authorizes reference-style image syntax using its resolved destination', async () => { const source = 'reference.png'; const fixture = await createFixture({ sources: [source], markdown: '![screenshot][result]\n\n[result]: reference.png', }); await fs.writeFile(path.join(fixture.directory, source), PNG); const response = await prepare(fixture.app, fixture.directory, fixture.sources); expect(response.body.results).toEqual([ expect.objectContaining({ source, status: 'ready' }), ]); }); it('authorizes inline image destinations containing balanced parentheses', async () => { const source = 'screen(1).png'; const fixture = await createFixture({ sources: [source], markdown: `![screenshot](${source})`, }); await fs.writeFile(path.join(fixture.directory, source), PNG); const response = await prepare(fixture.app, fixture.directory, fixture.sources); expect(response.body.results).toEqual([ expect.objectContaining({ source, status: 'ready' }), ]); }); it('requires inline image destinations with titles to close', async () => { const sources = ['valid.png', 'malformed.png']; const fixture = await createFixture({ sources, markdown: '![valid](valid.png "preview")\n![malformed](malformed.png "preview"', }); await Promise.all(sources.map((source) => fs.writeFile(path.join(fixture.directory, source), PNG))); const response = await prepare(fixture.app, fixture.directory, sources); expect(response.body.results).toEqual([ expect.objectContaining({ source: 'valid.png', status: 'ready' }), { source: 'malformed.png', status: 'error' }, ]); }); it('rejects a source that the message does not reference', async () => { const fixture = await createFixture({ markdown: 'No image here.' }); const response = await prepare(fixture.app, fixture.directory, fixture.sources); expect(response.body.results).toEqual([{ source: fixture.sources[0], status: 'error' }]); }); it('does not authorize image syntax inside fenced or inline code', async () => { const fixture = await createFixture({ markdown: '```md\n![fenced](FENCED)\n```\n`![inline](INLINE)`', }); const sources = ['FENCED', 'INLINE']; const response = await prepare(fixture.app, fixture.directory, sources); expect(response.body.results).toEqual(sources.map((source) => ({ source, status: 'error' }))); }); it('rejects paths outside the workspace and approved temporary root', async () => { const fixture = await createFixture(); const outsidePath = path.join(fixture.root, 'outside.png'); await fs.writeFile(outsidePath, PNG); const source = new URL(`file://${outsidePath}`).toString(); fixture.fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ info: { id: 'msg_1', role: 'assistant' }, parts: [{ type: 'text', text: `![outside](${source})` }], }), { status: 200, headers: { 'content-type': 'application/json' } })); const response = await prepare(fixture.app, fixture.directory, [source]); expect(response.body.results).toEqual([{ source, status: 'error' }]); }); it('rejects non-image bytes and symlink escapes per source', async () => { const fixture = await createFixture({ sources: ['invalid.png', 'linked.png'] }); await fs.writeFile(path.join(fixture.directory, 'invalid.png'), 'not an image'); await fs.writeFile(path.join(fixture.root, 'outside.png'), PNG); await fs.symlink(path.join(fixture.root, 'outside.png'), path.join(fixture.directory, 'linked.png')); const response = await prepare(fixture.app, fixture.directory, fixture.sources); expect(response.body.results).toEqual([ { source: 'invalid.png', status: 'error' }, { source: 'linked.png', status: 'error' }, ]); }); });