236 lines
9.2 KiB
JavaScript
236 lines
9.2 KiB
JavaScript
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) => ``).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: `\n` }],
|
|
}), { 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: ``,
|
|
});
|
|
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: '\n;
|
|
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\n```\n``',
|
|
});
|
|
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: `` }],
|
|
}), { 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' },
|
|
]);
|
|
});
|
|
});
|