mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
Competitor pressure (Mandoe 'AI Magic Create'): prompt -> signage. We answer it in a way that's actually BETTER for signage and costs the operator nothing. Key idea: don't generate raw images (AI garbles text - fatal for menus/promos). The LLM returns a STRUCTURED design spec (headline, supporting text, accent shapes, palette) that the existing Designer renders with real fonts - crisp and fully editable. Reuses the whole Designer. BYOK, fully under the customer's control: each workspace configures its own OpenAI-COMPATIBLE endpoint + key - OpenAI cloud OR self-hosted (Ollama / LM Studio / llama.cpp). Operator bears zero AI cost/liability. - server/lib/secretbox.js: AES-256-GCM for the key at rest (never returned). - routes/ai.js: GET/PUT /api/ai/settings (admin; key write-only) + POST /generate-design (editor+). Output is strictly validated/normalized (cap count, clamp ranges, px->%, strip HTML, validate colors) - never trust the model. SSRF guard: hosted instances block private/internal targets; self-hosted (the whole point of local AI) may point at localhost/LAN. - Designer: an 'AI generate' panel (prompt + Generate) + a settings modal. Verified end-to-end against local Ollama (llama3.1:8b): prompt -> editable design on the canvas. Unit tests cover normalization + the SSRF guard. Suite 61/61. Phase 2 (next): AI background images (OpenAI images / AUTOMATIC1111).
77 lines
3.5 KiB
JavaScript
77 lines
3.5 KiB
JavaScript
'use strict';
|
|
|
|
// #41: unit tests for the security-critical bits of the AI design route -
|
|
// normalizing untrusted LLM output, and the SSRF guard on the configurable
|
|
// endpoint. Node v20 built-ins only; db is mocked so requiring the route doesn't
|
|
// touch a real database. SELF_HOSTED=false so the SSRF guard is active.
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const Database = require('better-sqlite3');
|
|
|
|
process.env.JWT_SECRET = 'test-secret-ai';
|
|
process.env.SELF_HOSTED = 'false';
|
|
|
|
const db = new Database(':memory:');
|
|
const dbModulePath = require.resolve('../db/database');
|
|
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() {}, pruneScreenshots() {} } };
|
|
|
|
const ai = require('../routes/ai');
|
|
const { normalizeDesign, endpointAllowed } = ai;
|
|
|
|
test('normalizeDesign: keeps valid text+shape, sets background', () => {
|
|
const d = normalizeDesign({ background: '#102030', elements: [
|
|
{ type: 'text', x: 5, y: 5, text: 'HELLO', fontSize: 90, color: '#ffffff', bold: true },
|
|
{ type: 'shape', x: 0, y: 90, width: 100, height: 8, color: '#ff0000', opacity: 0.5 },
|
|
]});
|
|
assert.equal(d.background, '#102030');
|
|
assert.equal(d.elements.length, 2);
|
|
assert.equal(d.elements[0].text, 'HELLO');
|
|
assert.equal(d.elements[0].fontFamily, 'Arial');
|
|
});
|
|
|
|
test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => {
|
|
const d = normalizeDesign({ elements: [
|
|
{ type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 },
|
|
]});
|
|
const s = d.elements[0];
|
|
assert.equal(s.x, 0, 'x clamped to 0');
|
|
assert.equal(s.y, 100, 'y clamped to 100');
|
|
assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%');
|
|
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
|
|
assert.equal(s.color, '#3b82f6', 'non-hex color -> default');
|
|
assert.equal(s.opacity, 1, 'opacity clamped to 1');
|
|
});
|
|
|
|
test('normalizeDesign: strips HTML from text, drops empty/invalid', () => {
|
|
const d = normalizeDesign({ elements: [
|
|
{ type: 'text', text: '<img src=x onerror=alert(1)>Sale</b>', fontSize: 9999 },
|
|
{ type: 'text', text: ' ' },
|
|
{ type: 'bogus', text: 'x' },
|
|
null,
|
|
]});
|
|
assert.equal(d.elements.length, 1, 'only the one real text survives');
|
|
assert.equal(d.elements[0].text, 'Sale');
|
|
assert.ok(!/[<>]/.test(d.elements[0].text), 'no angle brackets');
|
|
assert.equal(d.elements[0].fontSize, 200, 'fontSize clamped to max');
|
|
});
|
|
|
|
test('normalizeDesign: caps element count + bad input', () => {
|
|
const many = { elements: Array.from({ length: 50 }, () => ({ type: 'text', text: 'x' })) };
|
|
assert.ok(normalizeDesign(many).elements.length <= 20);
|
|
assert.deepEqual(normalizeDesign(null).elements, []);
|
|
assert.equal(normalizeDesign({ background: 'notacolor' }).background, '#111827');
|
|
});
|
|
|
|
test('endpointAllowed: blocks private/internal when hosted, allows public https', () => {
|
|
assert.equal(endpointAllowed('https://api.openai.com/v1'), true);
|
|
assert.equal(endpointAllowed('http://localhost:11434/v1'), false);
|
|
assert.equal(endpointAllowed('http://127.0.0.1:1234'), false);
|
|
assert.equal(endpointAllowed('http://10.0.0.5/v1'), false);
|
|
assert.equal(endpointAllowed('http://192.168.1.9/v1'), false);
|
|
assert.equal(endpointAllowed('http://169.254.169.254/latest/meta-data'), false, 'cloud metadata blocked');
|
|
assert.equal(endpointAllowed('http://172.16.5.5/v1'), false);
|
|
assert.equal(endpointAllowed('ftp://example.com'), false, 'non-http blocked');
|
|
assert.equal(endpointAllowed('not a url'), false);
|
|
});
|