Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
github-metrics / Generate repository metrics SVG (push) Failing after 2m8s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
github-metrics / Generate repository metrics SVG (push) Failing after 2m8s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
875
apps/daemon/tests/live-artifacts-routes.test.ts
Normal file
875
apps/daemon/tests/live-artifacts-routes.test.ts
Normal file
@@ -0,0 +1,875 @@
|
||||
// @ts-nocheck
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
import { connectorService, ConnectorServiceError } from '../src/connectors/service.js';
|
||||
import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from '../src/tool-tokens.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(here, '../../..');
|
||||
const serverRuntimeDataRoot = process.env.OD_DATA_DIR
|
||||
? path.resolve(projectRoot, process.env.OD_DATA_DIR)
|
||||
: path.join(projectRoot, '.od');
|
||||
|
||||
let server;
|
||||
let baseUrl;
|
||||
const projectIds = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
const started = await startServer({ port: 0, returnServer: true });
|
||||
server = started.server;
|
||||
baseUrl = started.url;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await new Promise((resolve, reject) => {
|
||||
if (!server) return resolve(undefined);
|
||||
server.close((error) => (error ? reject(error) : resolve(undefined)));
|
||||
});
|
||||
server = undefined;
|
||||
toolTokenRegistry.clear();
|
||||
const cleanupProjectIds = projectIds.splice(0);
|
||||
await Promise.all(
|
||||
cleanupProjectIds.map((projectId) =>
|
||||
rm(path.join(serverRuntimeDataRoot, 'projects', projectId), { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function uniqueProjectId() {
|
||||
const id = `route-live-artifact-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
projectIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function validCreateInput(title = 'Tool Route Live Artifact') {
|
||||
return {
|
||||
title,
|
||||
preview: { type: 'html', entry: 'index.html' },
|
||||
document: {
|
||||
format: 'html_template_v1',
|
||||
templatePath: 'template.html',
|
||||
generatedPreviewPath: 'index.html',
|
||||
dataPath: 'data.json',
|
||||
dataJson: { title, owner: 'Agent' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function jsonFetch(url, init) {
|
||||
const response = await fetch(url, init);
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
async function textFetch(url, init) {
|
||||
const response = await fetch(url, init);
|
||||
return { status: response.status, headers: response.headers, body: await response.text() };
|
||||
}
|
||||
|
||||
async function createProject(projectId) {
|
||||
const response = await fetch(`${baseUrl}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: projectId, name: projectId }),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
async function rawHttpJsonFetch(url, { headers = {}, method = 'GET' } = {}) {
|
||||
const parsed = new URL(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body) });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function writeProjectJson(projectId, name, value) {
|
||||
const candidates = [path.join(serverRuntimeDataRoot, 'projects', projectId)];
|
||||
let lastError;
|
||||
let wrote = false;
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, name), `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
wrote = true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (wrote) return;
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function openProjectEvents(projectId) {
|
||||
const response = await fetch(`${baseUrl}/api/projects/${encodeURIComponent(projectId)}/events`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`failed to open project events stream: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const events = [];
|
||||
|
||||
const pump = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let boundary = buffer.indexOf('\n\n');
|
||||
while (boundary >= 0) {
|
||||
const raw = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
boundary = buffer.indexOf('\n\n');
|
||||
if (!raw.trim() || raw.startsWith(':')) continue;
|
||||
const evt = { event: 'message', data: '' };
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('event: ')) evt.event = line.slice(7);
|
||||
if (line.startsWith('data: ')) evt.data += line.slice(6);
|
||||
}
|
||||
try {
|
||||
evt.data = JSON.parse(evt.data);
|
||||
} catch {}
|
||||
events.push(evt);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
async waitFor(predicate, timeoutMs = 5_000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const match = events.find(predicate);
|
||||
if (match) return match;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(`timed out waiting for project event; seen=${JSON.stringify(events)}`);
|
||||
},
|
||||
async close() {
|
||||
await reader.cancel().catch(() => {});
|
||||
await pump.catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mintToolToken(projectId, runId, overrides = {}) {
|
||||
return toolTokenRegistry.mint({
|
||||
projectId,
|
||||
runId,
|
||||
allowedEndpoints: CHAT_TOOL_ENDPOINTS,
|
||||
allowedOperations: CHAT_TOOL_OPERATIONS,
|
||||
...overrides,
|
||||
}).token;
|
||||
}
|
||||
|
||||
describe('live artifact tool routes', () => {
|
||||
it('creates and lists live artifacts for agent registration', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const runId = 'run-route-test';
|
||||
const token = mintToolToken(projectId, runId);
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: validCreateInput(),
|
||||
templateHtml: '<!doctype html><h1>{{data.title}}</h1><p>{{data.owner}}</p>',
|
||||
provenanceJson: {
|
||||
generatedAt: '2026-04-30T00:00:00.000Z',
|
||||
generatedBy: 'agent',
|
||||
sources: [{ label: 'Route test', type: 'user_input' }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(200);
|
||||
expect(create.body.artifact).toMatchObject({
|
||||
projectId,
|
||||
title: 'Tool Route Live Artifact',
|
||||
createdByRunId: runId,
|
||||
refreshStatus: 'idle',
|
||||
});
|
||||
|
||||
const list = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/list`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.artifacts).toHaveLength(1);
|
||||
expect(list.body.artifacts[0]).toMatchObject({
|
||||
id: create.body.artifact.id,
|
||||
projectId,
|
||||
title: 'Tool Route Live Artifact',
|
||||
hasDocument: true,
|
||||
});
|
||||
expect(list.body.artifacts[0].document).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refreshes live artifacts through tool and UI routes', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-refresh');
|
||||
const executeConnector = vi.spyOn(connectorService, 'execute')
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
safety: { sideEffect: 'read', approval: 'auto' },
|
||||
output: { title: 'Open bugs', owner: '7' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
safety: { sideEffect: 'read', approval: 'auto' },
|
||||
output: { title: 'Open bugs', owner: '8' },
|
||||
});
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: {
|
||||
...validCreateInput('Refresh Route Artifact'),
|
||||
document: {
|
||||
...validCreateInput('Refresh Route Artifact').document,
|
||||
sourceJson: {
|
||||
type: 'connector_tool',
|
||||
toolName: 'monet.metrics',
|
||||
input: { report: 'bugs' },
|
||||
connector: {
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
approvalPolicy: 'read_only_auto',
|
||||
},
|
||||
refreshPermission: 'manual_refresh_granted_for_read_only',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
expect(create.body.artifact.document.sourceJson.refreshPermission).toBe('manual_refresh_granted_for_read_only');
|
||||
|
||||
const toolRefresh = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ artifactId: create.body.artifact.id }),
|
||||
});
|
||||
expect(toolRefresh.status).toBe(200);
|
||||
expect(toolRefresh.body.refresh).toMatchObject({ id: 'refresh-000001', status: 'succeeded', refreshedSourceCount: 1 });
|
||||
expect(toolRefresh.body.artifact).toMatchObject({ refreshStatus: 'succeeded', lastRefreshedAt: expect.any(String) });
|
||||
expect(toolRefresh.body.artifact.document.dataJson).toMatchObject({ title: 'Open bugs', owner: '7' });
|
||||
expect(executeConnector).toHaveBeenCalledTimes(1);
|
||||
expect(executeConnector).toHaveBeenLastCalledWith(
|
||||
expect.not.objectContaining({ expectedApprovalPolicy: expect.anything() }),
|
||||
expect.objectContaining({ purpose: 'artifact_refresh' }),
|
||||
);
|
||||
|
||||
const uiRefresh = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(uiRefresh.status).toBe(200);
|
||||
expect(uiRefresh.body.refresh).toMatchObject({ id: 'refresh-000002', status: 'succeeded', refreshedSourceCount: 1 });
|
||||
expect(uiRefresh.body.artifact.document.dataJson).toMatchObject({ title: 'Open bugs', owner: '8' });
|
||||
expect(executeConnector).toHaveBeenCalledTimes(2);
|
||||
expect(executeConnector).toHaveBeenLastCalledWith(
|
||||
expect.not.objectContaining({ expectedApprovalPolicy: expect.anything() }),
|
||||
expect.objectContaining({ purpose: 'artifact_refresh' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects local refresh sources when refreshPermission is none', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-refresh-disabled');
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ input: validCreateInput('Disabled Refresh Artifact') }),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
|
||||
const update = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
artifactId: create.body.artifact.id,
|
||||
input: {
|
||||
document: {
|
||||
...validCreateInput('Disabled Refresh Artifact').document,
|
||||
sourceJson: {
|
||||
type: 'daemon_tool',
|
||||
toolName: 'project_files.search',
|
||||
input: { query: 'should-not-run' },
|
||||
refreshPermission: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(update.status).toBe(200);
|
||||
expect(update.body.artifact.document.sourceJson.refreshPermission).toBe('none');
|
||||
|
||||
const refresh = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(refresh.status).toBe(400);
|
||||
expect(refresh.body.error).toMatchObject({
|
||||
code: 'LIVE_ARTIFACT_REFRESH_UNAVAILABLE',
|
||||
message: 'Refresh is disabled for this artifact source.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns persisted refresh history after a local_file refresh', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-refresh-history');
|
||||
await writeProjectJson(projectId, 'artifact-metrics.json', {
|
||||
summary: { owner: 'Disk source', status: 'ready' },
|
||||
stats: { openBugs: 7 },
|
||||
});
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: {
|
||||
...validCreateInput('Refresh History Artifact'),
|
||||
document: {
|
||||
...validCreateInput('Refresh History Artifact').document,
|
||||
dataJson: { title: 'Refresh History Artifact', summary: { owner: 'Agent' } },
|
||||
sourceJson: {
|
||||
type: 'local_file',
|
||||
input: { path: 'artifact-metrics.json' },
|
||||
outputMapping: {
|
||||
dataPaths: [
|
||||
{ from: 'json.summary', to: 'summary' },
|
||||
{ from: 'json.stats', to: 'stats' },
|
||||
],
|
||||
transform: 'identity',
|
||||
},
|
||||
refreshPermission: 'manual_refresh_granted_for_read_only',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
|
||||
const refresh = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(refresh.status).toBe(200);
|
||||
expect(refresh.body.artifact.document.dataJson).toMatchObject({
|
||||
title: 'Refresh History Artifact',
|
||||
summary: { owner: 'Disk source', status: 'ready' },
|
||||
stats: { openBugs: 7 },
|
||||
});
|
||||
|
||||
const refreshes = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refreshes?projectId=${encodeURIComponent(projectId)}`);
|
||||
expect(refreshes.status).toBe(200);
|
||||
expect(refreshes.body.refreshes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
projectId,
|
||||
artifactId: create.body.artifact.id,
|
||||
refreshId: refresh.body.refresh.id,
|
||||
step: 'document',
|
||||
status: 'succeeded',
|
||||
source: expect.objectContaining({ sourceType: 'document' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits project SSE live artifact events for patch delete and refresh', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-project-sse');
|
||||
await createProject(projectId);
|
||||
await writeProjectJson(projectId, 'artifact-metrics.json', {
|
||||
summary: { owner: 'Disk source', status: 'ready' },
|
||||
});
|
||||
const stream = await openProjectEvents(projectId);
|
||||
|
||||
try {
|
||||
await stream.waitFor((evt) => evt.event === 'ready' && evt.data.projectId === projectId);
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: {
|
||||
...validCreateInput('SSE Artifact'),
|
||||
document: {
|
||||
...validCreateInput('SSE Artifact').document,
|
||||
sourceJson: {
|
||||
type: 'local_file',
|
||||
input: { path: 'artifact-metrics.json' },
|
||||
outputMapping: { dataPaths: [{ from: 'json.summary', to: 'summary' }], transform: 'identity' },
|
||||
refreshPermission: 'manual_refresh_granted_for_read_only',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
|
||||
const patch = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'SSE Artifact Updated' }),
|
||||
});
|
||||
expect(patch.status).toBe(200);
|
||||
await stream.waitFor((evt) => evt.event === 'live_artifact'
|
||||
&& evt.data.action === 'updated'
|
||||
&& evt.data.artifactId === create.body.artifact.id
|
||||
&& evt.data.title === 'SSE Artifact Updated');
|
||||
|
||||
const refresh = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(refresh.status).toBe(200);
|
||||
await stream.waitFor((evt) => evt.event === 'live_artifact_refresh'
|
||||
&& evt.data.phase === 'started'
|
||||
&& evt.data.artifactId === create.body.artifact.id);
|
||||
await stream.waitFor((evt) => evt.event === 'live_artifact_refresh'
|
||||
&& evt.data.phase === 'succeeded'
|
||||
&& evt.data.artifactId === create.body.artifact.id
|
||||
&& evt.data.refreshId === refresh.body.refresh.id);
|
||||
|
||||
const deleted = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(deleted.status).toBe(200);
|
||||
await stream.waitFor((evt) => evt.event === 'live_artifact'
|
||||
&& evt.data.action === 'deleted'
|
||||
&& evt.data.artifactId === create.body.artifact.id);
|
||||
} finally {
|
||||
await stream.close();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('rejects manual refresh requests with non-loopback host before refresh side effects', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-refresh-local-security');
|
||||
const executeConnector = vi.spyOn(connectorService, 'execute').mockResolvedValue({
|
||||
ok: true,
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
safety: { sideEffect: 'read', approval: 'auto' },
|
||||
output: { title: 'Should not refresh', owner: '0' },
|
||||
});
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: {
|
||||
...validCreateInput('Refresh Local Security'),
|
||||
document: {
|
||||
...validCreateInput('Refresh Local Security').document,
|
||||
sourceJson: {
|
||||
type: 'connector_tool',
|
||||
toolName: 'monet.metrics',
|
||||
input: { report: 'bugs' },
|
||||
connector: {
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
approvalPolicy: 'read_only_auto',
|
||||
},
|
||||
refreshPermission: 'manual_refresh_granted_for_read_only',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
|
||||
const refresh = await rawHttpJsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
headers: { Host: 'attacker.example' },
|
||||
});
|
||||
|
||||
expect(refresh.status).toBe(403);
|
||||
expect(refresh.body.error).toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
details: { header: 'host' },
|
||||
});
|
||||
expect(executeConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects connector refresh sources when refreshPermission is none', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-refresh-default');
|
||||
const executeConnector = vi.spyOn(connectorService, 'execute').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
safety: { sideEffect: 'read', approval: 'auto' },
|
||||
output: { title: 'Default refresh', owner: '9' },
|
||||
});
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ input: validCreateInput('Default Refresh Artifact') }),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
|
||||
const update = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
artifactId: create.body.artifact.id,
|
||||
input: {
|
||||
document: {
|
||||
...validCreateInput('Default Refresh Artifact').document,
|
||||
sourceJson: {
|
||||
type: 'connector_tool',
|
||||
toolName: 'monet.metrics',
|
||||
input: { report: 'defaults' },
|
||||
connector: {
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
approvalPolicy: 'read_only_auto',
|
||||
},
|
||||
refreshPermission: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(update.status).toBe(200);
|
||||
expect(update.body.artifact.document.sourceJson.refreshPermission).toBe('none');
|
||||
|
||||
const refresh = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(refresh.status).toBe(400);
|
||||
expect(refresh.body.error).toMatchObject({
|
||||
code: 'LIVE_ARTIFACT_REFRESH_UNAVAILABLE',
|
||||
message: 'Refresh is disabled for this artifact source.',
|
||||
});
|
||||
expect(executeConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects refresh requests when no refresh source exists', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-refresh-unavailable');
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ input: validCreateInput('No Source Artifact') }),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
|
||||
const uiRefresh = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(uiRefresh.status).toBe(400);
|
||||
expect(uiRefresh.body.error).toMatchObject({
|
||||
code: 'LIVE_ARTIFACT_REFRESH_UNAVAILABLE',
|
||||
message: 'No refresh source is available yet.',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks artifacts failed and returns connector refresh error codes', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-refresh-failure');
|
||||
vi.spyOn(connectorService, 'execute').mockRejectedValueOnce(
|
||||
new ConnectorServiceError('CONNECTOR_NOT_CONNECTED', 'connector is not connected', 403, { connectorId: 'monet' }),
|
||||
);
|
||||
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: {
|
||||
...validCreateInput('Failed Refresh Artifact'),
|
||||
document: {
|
||||
...validCreateInput('Failed Refresh Artifact').document,
|
||||
sourceJson: {
|
||||
type: 'connector_tool',
|
||||
toolName: 'monet.metrics',
|
||||
input: { report: 'fail' },
|
||||
connector: {
|
||||
connectorId: 'monet',
|
||||
toolName: 'monet.metrics',
|
||||
approvalPolicy: 'read_only_auto',
|
||||
},
|
||||
refreshPermission: 'manual_refresh_granted_for_read_only',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
|
||||
const refresh = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/refresh?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(refresh.status).toBe(403);
|
||||
expect(refresh.body.error).toMatchObject({ code: 'CONNECTOR_NOT_CONNECTED', message: 'connector is not connected' });
|
||||
|
||||
const detail = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}?projectId=${encodeURIComponent(projectId)}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.artifact).toMatchObject({ refreshStatus: 'failed' });
|
||||
});
|
||||
|
||||
it('serves live artifact previews with restrictive iframe headers', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-preview');
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: validCreateInput('Preview Route Artifact'),
|
||||
templateHtml: '<!doctype html><html><body><h1>{{data.title}}</h1><p>{{data.owner}}</p></body></html>',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(200);
|
||||
const preview = await textFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/preview?projectId=${encodeURIComponent(projectId)}`);
|
||||
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.headers.get('content-type')).toContain('text/html');
|
||||
expect(preview.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(preview.headers.get('referrer-policy')).toBe('no-referrer');
|
||||
expect(preview.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect(preview.headers.get('vary')).toContain('Origin');
|
||||
const csp = preview.headers.get('content-security-policy') || '';
|
||||
expect(csp).toContain("default-src 'none'");
|
||||
expect(csp).toContain("script-src 'none'");
|
||||
expect(csp).toContain("frame-ancestors 'self'");
|
||||
expect(csp).toContain('sandbox allow-same-origin');
|
||||
expect(preview.body).toContain('<h1>Preview Route Artifact</h1>');
|
||||
expect(preview.body).toContain('<p>Agent</p>');
|
||||
|
||||
const templateSource = await textFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/preview?projectId=${encodeURIComponent(projectId)}&variant=template`);
|
||||
expect(templateSource.status).toBe(200);
|
||||
expect(templateSource.headers.get('content-type')).toContain('text/plain');
|
||||
expect(templateSource.body).toContain('{{data.title}}');
|
||||
|
||||
const renderedSource = await textFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/preview?projectId=${encodeURIComponent(projectId)}&variant=rendered-source`);
|
||||
expect(renderedSource.status).toBe(200);
|
||||
expect(renderedSource.headers.get('content-type')).toContain('text/plain');
|
||||
expect(renderedSource.body).toContain('<h1>Preview Route Artifact</h1>');
|
||||
expect(renderedSource.body).not.toContain('{{data.title}}');
|
||||
});
|
||||
|
||||
it('returns API dataJson from data.json when the artifact cache diverges', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-data-json-source');
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: validCreateInput('API Cache Artifact'),
|
||||
templateHtml: '<!doctype html><h1>{{data.title}}</h1><p>{{data.owner}}</p>',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(200);
|
||||
const diskDataJson = { title: 'Disk API Title', owner: 'data.json owner' };
|
||||
await writeFile(
|
||||
path.join(serverRuntimeDataRoot, 'projects', projectId, '.live-artifacts', create.body.artifact.id, 'data.json'),
|
||||
`${JSON.stringify(diskDataJson, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const detail = await jsonFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}?projectId=${encodeURIComponent(projectId)}`);
|
||||
const preview = await textFetch(`${baseUrl}/api/live-artifacts/${create.body.artifact.id}/preview?projectId=${encodeURIComponent(projectId)}`);
|
||||
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.artifact.document.dataJson).toEqual(diskDataJson);
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.body).toContain('<h1>Disk API Title</h1>');
|
||||
expect(preview.body).toContain('<p>data.json owner</p>');
|
||||
});
|
||||
|
||||
it('rejects preview requests with non-loopback host or origin headers', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-preview-local-security');
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: validCreateInput('Preview Local Security'),
|
||||
templateHtml: '<!doctype html><h1>{{data.title}}</h1>',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(200);
|
||||
const previewUrl = `${baseUrl}/api/live-artifacts/${create.body.artifact.id}/preview?projectId=${encodeURIComponent(projectId)}`;
|
||||
|
||||
const rejectedHost = await rawHttpJsonFetch(previewUrl, { headers: { Host: 'attacker.example' } });
|
||||
expect(rejectedHost.status).toBe(403);
|
||||
expect(rejectedHost.body.error).toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
details: { header: 'host' },
|
||||
});
|
||||
|
||||
const rejectedOrigin = await jsonFetch(previewUrl, { headers: { Origin: 'https://attacker.example' } });
|
||||
expect(rejectedOrigin.status).toBe(403);
|
||||
expect(rejectedOrigin.body.error).toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
details: { header: 'origin' },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows loopback-origin preview preflight without opening broad CORS', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const response = await fetch(`${baseUrl}/api/live-artifacts/unused/preview?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'http://localhost:17573' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:17573');
|
||||
expect(response.headers.get('access-control-allow-methods')).toBe('GET, POST, OPTIONS');
|
||||
expect(response.headers.get('access-control-allow-origin')).not.toBe('*');
|
||||
});
|
||||
|
||||
it('rejects executable script in template previews', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-template-script');
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
input: validCreateInput('Unsafe Template'),
|
||||
templateHtml: '<!doctype html><h1>{{data.title}}</h1><script src="/evil.js"></script>',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(400);
|
||||
expect(create.body.error).toMatchObject({
|
||||
code: 'LIVE_ARTIFACT_INVALID',
|
||||
details: { kind: 'validation' },
|
||||
});
|
||||
expect(JSON.stringify(create.body.error.details.issues)).toContain('script elements are not supported');
|
||||
});
|
||||
|
||||
it('returns shared API validation errors from tool create', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-validation');
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ input: { title: '' } }),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(400);
|
||||
expect(create.body.error).toMatchObject({
|
||||
code: 'LIVE_ARTIFACT_INVALID',
|
||||
details: { kind: 'validation' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects missing bearer token', async () => {
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: validCreateInput() }),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(401);
|
||||
expect(create.body.error).toMatchObject({
|
||||
code: 'TOOL_TOKEN_MISSING',
|
||||
details: {
|
||||
endpoint: '/api/tools/live-artifacts/create',
|
||||
operation: 'live-artifacts:create',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects projectId overrides from the request body', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-project-override');
|
||||
const create = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
projectId: 'different-project-id',
|
||||
input: validCreateInput(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(create.status).toBe(403);
|
||||
expect(create.body.error).toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
details: { suppliedProjectId: 'different-project-id' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects tokens that are not allowed to access the endpoint', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-endpoint-denied', {
|
||||
allowedEndpoints: ['/api/tools/live-artifacts/create'],
|
||||
});
|
||||
|
||||
const list = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/list`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(list.status).toBe(403);
|
||||
expect(list.body.error).toMatchObject({
|
||||
code: 'TOOL_ENDPOINT_DENIED',
|
||||
details: {
|
||||
endpoint: '/api/tools/live-artifacts/list',
|
||||
operation: 'live-artifacts:list',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects tokens that are not allowed to perform the operation', async () => {
|
||||
const projectId = uniqueProjectId();
|
||||
const token = mintToolToken(projectId, 'run-route-test-operation-denied', {
|
||||
allowedEndpoints: ['/api/tools/live-artifacts/list'],
|
||||
allowedOperations: ['live-artifacts:create'],
|
||||
});
|
||||
|
||||
const list = await jsonFetch(`${baseUrl}/api/tools/live-artifacts/list`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(list.status).toBe(403);
|
||||
expect(list.body.error).toMatchObject({
|
||||
code: 'TOOL_OPERATION_DENIED',
|
||||
details: {
|
||||
endpoint: '/api/tools/live-artifacts/list',
|
||||
operation: 'live-artifacts:list',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user