204 lines
11 KiB
JavaScript
204 lines
11 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { createServer } from 'node:http';
|
||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, dirname, basename } from 'node:path';
|
||
|
||
// Match DSH credentials credentialKey/parseCredentialKey admission rules.
|
||
// Validate at every service boundary AND on reload, unlike the original permissive mock.
|
||
function validateCredentialName(key) {
|
||
assert.match(key, /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/);
|
||
}
|
||
|
||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jVv8AAAAASUVORK5CYII=', 'base64');
|
||
|
||
test('native host integration using only a local image API', async t => {
|
||
const home = await mkdtemp(join(tmpdir(), 'dsh-imagegen-host-test-'));
|
||
const previousHome = process.env.DSH_HOME;
|
||
process.env.DSH_HOME = home;
|
||
t.after(async () => {
|
||
if (previousHome === undefined) delete process.env.DSH_HOME;
|
||
else process.env.DSH_HOME = previousHome;
|
||
assert.equal(dirname(home), tmpdir());
|
||
assert.match(basename(home), /^dsh-imagegen-host-test-/);
|
||
await rm(home, { recursive: true, force: true });
|
||
});
|
||
const requests = [];
|
||
const server = createServer(async (request, response) => {
|
||
try {
|
||
let raw = '';
|
||
for await (const chunk of request) raw += chunk;
|
||
requests.push({ path: request.url, authorization: request.headers.authorization, body: raw ? JSON.parse(raw) : undefined });
|
||
response.setHeader('Content-Type', 'application/json');
|
||
if (request.url === '/v1/models') response.end(JSON.stringify({ data: [{ id: 'test-image-model' }] }));
|
||
else if (request.url === '/v1/images/generations') response.end(JSON.stringify({ data: [{ b64_json: PNG.toString('base64'), revised_prompt: 'Local test image' }] }));
|
||
else { response.statusCode = 404; response.end('{}'); }
|
||
} catch { response.statusCode = 500; response.end('{}'); }
|
||
});
|
||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
||
t.after(() => new Promise((resolve, reject) => {
|
||
server.close(error => error ? reject(error) : resolve());
|
||
server.closeAllConnections();
|
||
}));
|
||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`;
|
||
const records = new Map(), routes = new Map(), tools = new Map(), skills = [], attachments = [], listeners = new Map();
|
||
const ctx = {
|
||
on(event, listener) { listeners.set(event, listener); },
|
||
connection: { fetch: { register(route) {
|
||
assert.equal(route.requestBody, 'buffered');
|
||
assert.ok(!routes.has(route.path));
|
||
routes.set(route.path, route);
|
||
return async () => routes.delete(route.path);
|
||
} } },
|
||
tools: { register(tool) { tools.set(tool.name, tool); return () => tools.delete(tool.name); } },
|
||
credentials: {
|
||
async readRecord(key) { validateCredentialName(key); return records.get(key); },
|
||
async modifyRecord(key, change) { validateCredentialName(key); const value = await change(records.get(key)); records.set(key, value); return value; },
|
||
async deleteRecord(key) { validateCredentialName(key); records.delete(key); },
|
||
},
|
||
attachments: { async saveImage(image) {
|
||
assert.deepEqual(Buffer.from(image.data), PNG);
|
||
assert.equal(image.mediaType, 'image/png');
|
||
const ref = { attachmentId: `test-attachment-${attachments.length}`, mediaType: image.mediaType, bytes: image.data.length, width: 1, height: 1, name: image.name };
|
||
attachments.push(ref);
|
||
return ref;
|
||
} },
|
||
skills: { register(skill) { skills.push(skill); return () => {}; } },
|
||
};
|
||
const host = await import(`../lib/index.mjs?host-test=${Date.now()}`);
|
||
await host.apply(ctx);
|
||
t.after(() => listeners.get('dispose')?.());
|
||
const call = async (path, method = 'GET', body) => {
|
||
const url = new URL(path, 'http://localhost'), route = routes.get(url.pathname);
|
||
assert.ok(route, `route registered: ${url.pathname}`);
|
||
assert.ok(route.methods.includes(method));
|
||
return route.fetch(new Request(url, { method, ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }) }));
|
||
};
|
||
const settings = { baseUrl, models: ['test-image-model'], defaultModel: 'test-image-model' };
|
||
const key = 'test-only-key-never-a-real-secret';
|
||
|
||
await t.test('registers dependencies, routes, tools and skill', () => {
|
||
assert.deepEqual(host.inject, ['connection', 'tools', 'credentials', 'attachments', 'skills']);
|
||
assert.deepEqual([...routes.keys()].sort(), ['/api/imagegen/file', '/api/imagegen/generate', '/api/imagegen/models', '/api/imagegen/settings']);
|
||
assert.deepEqual([...tools.keys()].sort(), ['imagegen_generate', 'imagegen_status']);
|
||
assert.equal(skills.length, 1);
|
||
assert.equal(skills[0].name, 'dsh-imagegen');
|
||
assert.ok(skills[0].content.length > 0);
|
||
assert.equal(typeof listeners.get('dispose'), 'function');
|
||
});
|
||
await t.test('stores keys in credentials, not settings JSON or returned values', async () => {
|
||
const response = await call('/api/imagegen/settings', 'POST', { ...settings, apiKey: key });
|
||
assert.equal(response.status, 200);
|
||
const value = await response.json();
|
||
assert.equal(value.configured, true);
|
||
assert.equal(value.apiKey, undefined);
|
||
assert.ok(!JSON.stringify(value).includes(key));
|
||
assert.equal(records.size, 1);
|
||
const [credentialName, credential] = [...records][0];
|
||
assert.match(credentialName, /^imagegen-local\/endpoint-[0-9a-f]{64}$/);
|
||
assert.deepEqual(credential, { kind: 'api-key', key });
|
||
const disk = await readFile(join(home, 'imagegen', 'settings.json'), 'utf8');
|
||
assert.deepEqual(JSON.parse(disk), settings);
|
||
assert.ok(!disk.includes(key));
|
||
const status = await tools.get('imagegen_status').execute();
|
||
assert.equal(status.configured, true);
|
||
assert.ok(!JSON.stringify(status).includes(key));
|
||
assert.ok(!(await (await call('/api/imagegen/settings')).text()).includes(key));
|
||
});
|
||
await t.test('saved credentials survive strict reload and host reinitialization', async () => {
|
||
const persisted = JSON.stringify({ version: 1, records: Object.fromEntries(records) });
|
||
records.clear();
|
||
for (const [address, value] of Object.entries(JSON.parse(persisted).records)) {
|
||
validateCredentialName(address);
|
||
records.set(address, value);
|
||
}
|
||
listeners.get('dispose')();
|
||
routes.clear(); tools.clear();
|
||
await host.apply(ctx);
|
||
assert.equal((await tools.get('imagegen_status').execute()).configured, true);
|
||
});
|
||
await t.test('lists models using the local authenticated API', async () => {
|
||
const response = await call('/api/imagegen/models', 'POST');
|
||
assert.equal(response.status, 200);
|
||
assert.deepEqual(await response.json(), { models: ['test-image-model'] });
|
||
assert.equal(requests.at(-1).authorization, `Bearer ${key}`);
|
||
});
|
||
await t.test('a different Base URL cannot reuse the original credential', async () => {
|
||
const response = await call('/api/imagegen/settings', 'POST', { ...settings, baseUrl: `${baseUrl}/other-provider` });
|
||
assert.equal(response.status, 200);
|
||
assert.equal((await response.json()).configured, false);
|
||
const before = requests.length;
|
||
const rejected = await call('/api/imagegen/generate', 'POST', { model: 'test-image-model', prompt: 'A local test' });
|
||
assert.equal(rejected.status, 400);
|
||
assert.equal(requests.length, before);
|
||
assert.equal(records.size, 1);
|
||
const restored = await call('/api/imagegen/settings', 'POST', settings);
|
||
assert.equal((await restored.json()).configured, true);
|
||
});
|
||
await t.test('requires explicit model despite a configured default', async () => {
|
||
const before = requests.length;
|
||
const response = await call('/api/imagegen/generate', 'POST', { prompt: 'No model' });
|
||
assert.equal(response.status, 400);
|
||
assert.match((await response.json()).error, /model/i);
|
||
await assert.rejects(tools.get('imagegen_generate').execute({ prompt: 'No model' }, { signal: new AbortController().signal }), /model/i);
|
||
assert.equal(requests.length, before);
|
||
});
|
||
await t.test('tool generation saves original bytes and renders an image attachment', async () => {
|
||
const tool = tools.get('imagegen_generate');
|
||
const args = { model: 'test-image-model', prompt: 'A local test', n: 1 };
|
||
const value = await tool.execute(args, { signal: new AbortController().signal });
|
||
assert.equal(value.model, args.model);
|
||
assert.equal(value.images.length, 1);
|
||
const image = value.images[0];
|
||
assert.equal(dirname(image.path), join(home, 'imagegen', 'outputs'));
|
||
assert.deepEqual(await readFile(image.path), PNG);
|
||
assert.equal(image.revisedPrompt, 'Local test image');
|
||
assert.deepEqual(image.attachment, attachments[0]);
|
||
const content = tool.output.render(args, value);
|
||
assert.equal(content[0].type, 'text');
|
||
assert.deepEqual(content[1], { type: 'image', attachment: attachments[0] });
|
||
assert.ok(!JSON.stringify(content).includes(key));
|
||
assert.deepEqual(requests.at(-1).body, args);
|
||
assert.equal(requests.at(-1).authorization, `Bearer ${key}`);
|
||
const file = await call(image.url);
|
||
assert.equal(file.status, 200);
|
||
assert.equal(file.headers.get('Content-Type'), 'image/png');
|
||
assert.equal(file.headers.get('X-Content-Type-Options'), 'nosniff');
|
||
assert.deepEqual(Buffer.from(await file.arrayBuffer()), PNG);
|
||
});
|
||
await t.test('HTTP generation returns a saved image without credentials', async () => {
|
||
const response = await call('/api/imagegen/generate', 'POST', { model: 'test-image-model', prompt: 'HTTP local test' });
|
||
assert.equal(response.status, 200);
|
||
const value = await response.json();
|
||
assert.equal(value.images.length, 1);
|
||
assert.deepEqual(await readFile(value.images[0].path), PNG);
|
||
assert.ok(!JSON.stringify(value).includes(key));
|
||
});
|
||
await t.test('all digest prefixes are valid and clearing only removes the selected endpoint', async () => {
|
||
const originalRecords = new Map(records);
|
||
const prefixes = new Set();
|
||
for (let i = 0; prefixes.size < 16 && i < 512; i++) {
|
||
const endpoint = { ...settings, baseUrl: `https://example.com/v1/provider-${i}` };
|
||
const response = await call('/api/imagegen/settings', 'POST', { ...endpoint, apiKey: 'test-only-temporary-key' });
|
||
assert.equal(response.status, 200);
|
||
assert.equal((await response.json()).configured, true);
|
||
const address = [...records.keys()].find(item => !originalRecords.has(item));
|
||
validateCredentialName(address);
|
||
prefixes.add(address.split('endpoint-')[1][0]);
|
||
assert.equal((await (await call('/api/imagegen/settings', 'POST', endpoint)).json()).configured, true);
|
||
const cleared = await call('/api/imagegen/settings', 'POST', { ...endpoint, clearKey: true });
|
||
assert.equal(cleared.status, 200);
|
||
assert.equal((await cleared.json()).configured, false);
|
||
assert.deepEqual(records, originalRecords);
|
||
}
|
||
assert.equal(prefixes.size, 16, 'exercise every hexadecimal leading character, including 0–9');
|
||
await call('/api/imagegen/settings', 'POST', settings);
|
||
});
|
||
await t.test('file traversal, absolute paths and missing files return 404', async () => {
|
||
for (const filename of ['../settings.json', '..\\settings.json', join(home, 'imagegen', 'settings.json'), '/etc/passwd', '', '123-00000000-0000-0000-0000-000000000000.png']) {
|
||
assert.equal((await call(`/api/imagegen/file?name=${encodeURIComponent(filename)}`)).status, 404);
|
||
}
|
||
});
|
||
});
|