import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import {createServer} from "node:http"; import {createApp} from "../src/app.js"; import {createAttachmentService, parseVisionAnalysis} from "../src/services/attachmentService.js"; import {createDatabase} from "../src/db/index.js"; import {createConfig} from "../src/config.js"; import {VISION_PROMPT_VERSION, VISION_RESPONSE_SCHEMA} from "../src/services/visionAnalysis.js"; async function setup(t, {visionResult = null} = {}) { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "elsewhere-images-")); const requests = []; const order = []; const searchServer = createServer((req, res) => { order.push("search"); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({results: [{title: "Reasoning button documentation", url: "https://example.com/reasoning", content: "The Reasoning button is disabled when the selected model does not support reasoning."}]})); }); await new Promise(resolve => searchServer.listen(0, "127.0.0.1", resolve)); let visionFailure = false; let holdVision = false; let visionAborted = false; let startedVision; const visionStarted = new Promise(resolve => { startedVision = resolve; }); const app = createApp({config: {dbPath: ":memory:", uploadDir: directory, uploadMaxSizeMb: 1, uploadMaxPixels: 10000, testMode: true, sessionSecret: "attachment-tests", chatSummaryUpdateEveryMessages: 0, searxngUrl: `http://127.0.0.1:${searchServer.address().port}`, eventLoopLagMonitorEnabled: false, slowRequestLoggingEnabled: false, structuredRequestLoggingEnabled: false}, modelOverrides: {fetch: async (_url, options) => { const body = JSON.parse(options.body); requests.push(body); order.push(body.model === "gemma3:4b" ? "vision" : "answer"); if (holdVision && body.model === "gemma3:4b") return new Promise((_resolve, reject) => { startedVision(); options.signal.addEventListener("abort", () => { visionAborted = true; reject(new DOMException("Aborted", "AbortError")); }, {once: true}); }); if (visionFailure && body.model === "gemma3:4b") return new Response("Unavailable", {status: 503}); const content = body.model === "gemma3:4b" ? visionResult ? visionResult(body) : JSON.stringify({summary: "A blue screenshot", visibleText: ["Reasoning"], observations: ["A disabled button"], uncertainties: []}) : "The button is disabled."; return new Response([ ...(body.think ? [{message: {thinking: "Inspect evidence"}}] : []), {message: {content: content.slice(0, 5)}}, {message: {content: content.slice(5)}, done: true} ].map(v => JSON.stringify(v)).join("\n")); }}}); const server = app.listen(0, "127.0.0.1"); await new Promise(resolve => server.once("listening", resolve)); t.after(async () => { await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }); await new Promise(resolve => { searchServer.closeAllConnections(); searchServer.close(resolve); }); await app.locals.close(); await fs.rm(directory, {recursive: true, force: true}); }); const base = `http://127.0.0.1:${server.address().port}`; const register = async username => { const response = await fetch(base + "/register", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({username, password: "test-password"})}); assert.equal(response.status, 200); return response.headers.get("set-cookie").split(";")[0]; }; const cookie = await register("images"); const api = (url, body, method = body ? "POST" : "GET", auth = cookie) => fetch(base + url, {method, headers: {Cookie: auth, "Content-Type": "application/json"}, body: body ? JSON.stringify(body) : undefined}); const upload = (buffer, filename = "image.png", auth = cookie) => { const form = new FormData(); form.append("image", new Blob([buffer], {type: "image/png"}), filename); return fetch(base + "/uploads", {method: "POST", headers: auth ? {Cookie: auth} : {}, body: form}); }; const image = format => sharp({create: {width: 20, height: 10, channels: 3, background: "blue"}}).toFormat(format).toBuffer(); const chat = async () => (await (await api("/chats", {title: "Image test"})).json()).chat.id; return {app, api, upload, image, register, directory, requests, order, chat, failVision: () => { visionFailure = true; }, holdVision: () => { holdVision = true; return visionStarted; }, get visionAborted() { return visionAborted; }}; } test("validated local uploads, ownership, limits and temporary cleanup", async t => { const s = await setup(t); for (const format of ["png", "jpeg", "webp"]) { const bytes = await s.image(format); const response = await s.upload(bytes, "../../untrusted.png"); assert.equal(response.status, 201); const metadata = await response.json(); assert.equal(metadata.mimeType, `image/${format}`); assert.equal(metadata.width, 20); assert.equal(metadata.height, 10); assert.equal(metadata.path, undefined); assert.equal(metadata.storageKey, undefined); const row = s.app.locals.db.prepare("SELECT * FROM attachments WHERE id=?").get(metadata.id); assert.equal(row.status, "temporary"); assert.match(row.storage_key, /^\d{4}\/\d{2}\/\d{2}\/[a-f0-9-]{36}\.(png|jpeg|webp)$/); assert.deepEqual(await fs.readFile(path.join(s.directory, row.storage_key)), bytes); const download = await s.api(`/attachments/${metadata.id}`); assert.equal(download.status, 200); assert.equal(download.headers.get("x-content-type-options"), "nosniff"); assert.deepEqual(Buffer.from(await download.arrayBuffer()), bytes); } assert.equal((await s.upload(await s.image("png"), "image.png", "")).status, 403); for (const buffer of [Buffer.from("MZ executable"), Buffer.from(""), Buffer.alloc(0), (await s.image("png")).subarray(0, 40)]) { assert.equal((await s.upload(buffer)).status, 400); } assert.equal((await s.upload(Buffer.alloc(1024 * 1024 + 1))).status, 413); assert.equal((await s.upload(await sharp({create: {width: 101, height: 101, channels: 3, background: "red"}}).png().toBuffer())).status, 400); const other = await s.register("other-images"); const row = s.app.locals.db.prepare("SELECT * FROM attachments LIMIT 1").get(); assert.equal((await s.api(`/attachments/${row.id}`, null, "GET", other)).status, 404); assert.equal((await s.api("/attachments/%2Fetc%2Fpasswd")).status, 404); assert.throws(() => s.app.locals.attachmentService.storage.resolve("../../secret")); const chatId = await s.chat(); assert.equal((await s.api("/chat", {chatId, message: "Image", attachments: [row.id]}, "POST", other)).status, 404); const otherChat = (await (await s.api("/chats", {title: "Other"}, "POST", other)).json()).chat.id; assert.equal((await s.api("/chat", {chatId: otherChat, message: "Image", attachments: [row.id]}, "POST", other)).status, 404); assert.equal((await s.api("/chat", {chatId, message: "Image", attachments: ["/etc/passwd"]})).status, 400); assert.equal((await s.api("/chat", {chatId, message: "Image", attachments: Array(6).fill(row.id)})).status, 400); s.app.locals.db.prepare("UPDATE attachments SET created_at=datetime('now','-25 hours')").run(); await s.app.locals.attachmentService.cleanup(); assert.equal(s.app.locals.db.prepare("SELECT COUNT(*) n FROM attachments").get().n, 0); await assert.rejects(fs.access(path.join(s.directory, row.storage_key))); }); test("account rename retains ownership and account deletion removes pending files", async t => { const s = await setup(t); const image = await (await s.upload(await s.image("png"))).json(); const db = s.app.locals.db; const key = db.prepare("SELECT storage_key FROM attachments WHERE id=?").get(image.id).storage_key; // The attachment FK follows the existing username-based account model. db.prepare("UPDATE users SET username=? WHERE username=?").run("renamed-images", "images"); assert.equal(s.app.locals.attachmentService.owned(image.id, "renamed-images").username, "renamed-images"); s.app.locals.repositories.deleteUser("renamed-images"); await s.app.locals.attachmentService.cleanup(); assert.equal(db.prepare("SELECT COUNT(*) n FROM attachments").get().n, 0); await assert.rejects(fs.access(path.join(s.directory, key))); }); test("images feed dedicated vision, cache, streamed reasoning, reload and shared branch lifecycle", async t => { const s = await setup(t); const uploads = []; for (const format of ["png", "jpeg"]) uploads.push(await (await s.upload(await s.image(format))).json()); const chatId = await s.chat(); const response = await s.api("/chat/stream", {chatId, message: "Why is the button disabled?", attachments: uploads.map(v => v.id), model: "qwen3:14b", reasoning: true}); const wire = await response.text(); assert.match(wire, /"status":"analyzing"/); assert.match(wire, /event: thinking/); assert.match(wire, /event: chunk/); assert.match(wire, /event: done/); const vision = s.requests.filter(v => v.model === "gemma3:4b"); assert.equal(vision.length, 2); for (const request of vision) { assert.equal(request.think, false); assert.deepEqual(request.format, VISION_RESPONSE_SCHEMA); assert.match(request.messages.at(-1).content, /Why is the button disabled/); assert.ok(Buffer.from(request.messages.at(-1).images[0], "base64").length); } const final = s.requests.at(-1); assert.equal(final.model, "qwen3:14b"); assert.equal(final.think, true); assert.equal(final.stream, true); assert.ok(final.messages.some(v => v.content.includes("A blue screenshot"))); assert.ok(final.messages.every(v => !v.images)); assert.ok(!JSON.stringify(final).includes(s.directory)); const db = s.app.locals.db; assert.equal(db.prepare("SELECT COUNT(*) n FROM attachments WHERE status='attached' AND vision_analysis IS NOT NULL").get().n, 2); const history = await (await s.api(`/chats/${chatId}/messages`)).json(); assert.deepEqual(history.messages[0].attachments, uploads); const exported = await (await s.api("/exports/workspace")).json(); assert.deepEqual(exported.workspace.chats[0].messages[0].attachments, uploads); const botId = history.messages.at(-1).id; const retryWire = await (await s.api(`/chats/${chatId}/messages/${botId}/retry/stream`, {model: "qwen3:14b", reasoning: true})).text(); assert.match(retryWire, /event: chunk/); assert.match(retryWire, /event: thinking/); assert.match(retryWire, /event: done/); assert.ok(s.requests.at(-1).messages.some(v => v.content.includes("A blue screenshot"))); assert.equal(s.requests.filter(v => v.model === "gemma3:4b").length, 2, "Retry reuses vision cache"); const branchResponse = await s.api(`/chats/${chatId}/messages/${botId}/branch`, {confirmed: true}); assert.equal(branchResponse.status, 200); const branch = await branchResponse.json(); await s.api(`/chats/${chatId}`, null, "DELETE"); await s.app.locals.attachmentService.cleanup(); assert.equal((await s.api(`/attachments/${uploads[0].id}`)).status, 200, "Branch retains shared file"); await s.api(`/chats/${branch.chat.id}`, null, "DELETE"); await s.app.locals.attachmentService.cleanup(); assert.equal(db.prepare("SELECT COUNT(*) n FROM attachments").get().n, 0); assert.equal((await s.api(`/attachments/${uploads[0].id}`)).status, 404); assert.equal(s.app.locals.db.prepare("SELECT COUNT(*) n FROM upload_deletions").get().n, 0); }); test("images compose with all Web Search and Reasoning combinations in order", async t => { const s = await setup(t); const chatId = await s.chat(); for (const webSearch of [false, true]) for (const reasoning of [false, true]) { const attachment = await (await s.upload(await s.image("png"))).json(); s.order.length = 0; const wire = await (await s.api("/chat/stream", {chatId, message: "Search the web for the Reasoning button documentation", attachments: [attachment.id], webSearch, reasoning})).text(); assert.deepEqual(s.order, webSearch ? ["vision", "search", "answer"] : ["vision", "answer"]); const final = s.requests.at(-1); assert.equal(final.think, reasoning); assert.equal(final.stream, true); assert.ok(final.messages.some(v => v.content.includes("IMAGE OBSERVATIONS"))); assert.equal(final.messages.some(v => v.content.startsWith("")), webSearch); assert.equal(wire.includes("event: thinking"), reasoning); assert.equal(wire.includes('"status":"searching"'), webSearch); assert.equal(wire.includes('"sources":[{'), webSearch); } }); test("vision failure prevents an ungrounded answer and malformed JSON falls back", async t => { const s = await setup(t); const attachment = await (await s.upload(await s.image("png"))).json(); s.failVision(); const wire = await (await s.api("/chat/stream", {chatId: await s.chat(), message: "Explain", attachments: [attachment.id]})).text(); assert.match(wire, /event: error/); assert.match(wire, /Image analysis failed/); assert.ok(!s.requests.some(v => v.model === "qwen3:14b")); assert.equal(parseVisionAnalysis("Plain visual description").summary, "Plain visual description"); assert.equal(parseVisionAnalysis('{"summary":"UI","visibleText":[42,"Hello"]}').visibleText.length, 1); assert.throws(() => parseVisionAnalysis("")); }); test("browser disconnect cancels in-progress vision and never starts the final model", async t => { const s = await setup(t); const attachment = await (await s.upload(await s.image("png"))).json(); const started = s.holdVision(); const response = await s.api("/chat/stream", {chatId: await s.chat(), message: "Describe", attachments: [attachment.id]}); await started; await response.body.cancel(); for (let attempt = 0; attempt < 100 && !s.visionAborted; attempt++) await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(s.visionAborted, true); assert.equal(s.requests.length, 1); assert.equal(s.app.locals.db.prepare("SELECT vision_analysis FROM attachments WHERE id=?").get(attachment.id).vision_analysis, null); }); test("additive schema, files and cached vision survive database reopen; message deletion releases them", async t => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "elsewhere-image-persistence-")); const config = createConfig({dbPath: path.join(directory, "app.sqlite"), dataDir: directory, uploadDir: path.join(directory, "uploads"), structuredRequestLoggingEnabled: false}); let calls = 0; const model = {async generateReply() { calls++; return '{"summary":"Persisted evidence"}'; }}; let db = createDatabase(config); let service = createAttachmentService(db, config, model); t.after(async () => { await service.close(); db.close(); await fs.rm(directory, {recursive: true, force: true}); }); db.prepare("INSERT INTO users(username,password) VALUES('persist','test')").run(); const chatId = Number(db.prepare("INSERT INTO chat_sessions(username,title) VALUES('persist','Images')").run().lastInsertRowid); const buffer = await sharp({create: {width: 2, height: 2, channels: 3, background: "blue"}}).png().toBuffer(); const attachment = await service.upload({buffer, size: buffer.length, originalname: "persistent.png"}, "persist"); const messageId = Number(service.createMessage([attachment.id], "persist", chatId, () => db.prepare("INSERT INTO chat_messages(chat_id,role,content) VALUES(?,'user','Describe')").run(chatId)).lastInsertRowid); await service.analyze(messageId, "Describe"); await service.close(); db.close(); db = createDatabase(config); service = createAttachmentService(db, config, model); assert.deepEqual(service.list(messageId), [attachment]); const row = service.owned(attachment.id, "persist"); assert.deepEqual(await service.storage.read(row.storage_key), buffer); await service.analyze(messageId, "Describe"); assert.equal(calls, 1); assert.equal(db.prepare("SELECT prompt_version FROM attachment_vision_analyses WHERE attachment_id=?").get(attachment.id).prompt_version, VISION_PROMPT_VERSION); config.visionModel = "gemma3:updated"; await service.analyze(messageId, "Describe"); assert.equal(calls, 2); db.prepare("DELETE FROM chat_messages WHERE id=?").run(messageId); await service.cleanup(); assert.equal(db.prepare("SELECT COUNT(*) n FROM attachments").get().n, 0); assert.equal(db.prepare("SELECT COUNT(*) n FROM attachment_vision_analyses").get().n, 0); await assert.rejects(service.storage.read(row.storage_key)); }); const imageEvidence = request => { const message = request.messages.find(item => item.content.startsWith("")); assert.equal(message?.role, "user", "Analysis must not be promoted into system instructions"); return JSON.parse(message.content.split("\n")[1]); }; test("question-aware analysis reaches Qwen intact, separates tasks, and reuses the original task for older regeneration", async t => { const penguin = {summary: "Gray down, black head, large white facial patches", answer: "Emperor penguin chick", confidence: 0.94, evidence: ["Dense gray juvenile down", "Black head", "Large white facial patches"], visibleText: [], observations: [], alternatives: [], uncertainties: []}; const ui = {summary: "Elsewhere chat screenshot", answer: "Reasoning appears disabled while Qwen3 14B is selected", confidence: 0.97, evidence: ["qwen3:14b selected", "Disabled styling on Reasoning"], visibleText: ["qwen3:14b", "Reasoning"], observations: [], alternatives: [], uncertainties: ["Backend cause is not visible"]}; const s = await setup(t, {visionResult: body => JSON.stringify(body.messages.at(-1).content.includes("penguin") ? penguin : ui)}); const attachment = await (await s.upload(await s.image("png"))).json(); const chatId = await s.chat(); const question = "What kind of penguin is this?"; const wire = await (await s.api("/chat/stream", {chatId, message: question, attachments: [attachment.id], reasoning: true})).text(); assert.match(wire, /event: thinking/); assert.match(wire, /event: chunk/); assert.match(wire, /event: done/); const gemma = s.requests.find(request => request.model === "gemma3:4b"); assert.ok(gemma.messages.at(-1).content.includes(JSON.stringify(question))); assert.equal(gemma.think, false); assert.equal(gemma.keep_alive, "30m"); assert.deepEqual(gemma.format, VISION_RESPONSE_SCHEMA); assert.equal(s.requests.at(-1).model, "qwen3:14b"); assert.deepEqual(imageEvidence(s.requests.at(-1)).images[0].analysis, penguin); const guidance = s.requests.at(-1).messages.filter(message => message.role === "system").map(message => message.content).join("\n"); assert.match(guidance, /Do not invent competing identifications absent from alternatives/); assert.match(guidance, /Do not mention Gemma/); const firstBot = (await (await s.api(`/chats/${chatId}/messages`)).json()).messages.at(-1); // A follow-up without re-uploading must not reuse the penguin-specific answer. await s.api("/chat", {chatId, message: "Why is the Reasoning button disabled?"}); assert.equal(s.requests.filter(request => request.model === "gemma3:4b").length, 2); assert.deepEqual(imageEvidence(s.requests.at(-1)).images[0].analysis, ui); assert.equal(s.app.locals.db.prepare("SELECT COUNT(*) n FROM attachment_vision_analyses").get().n, 2); const retry = await (await s.api(`/chats/${chatId}/messages/${firstBot.id}/retry/stream`, {})).text(); assert.match(retry, /event: done/); assert.deepEqual(imageEvidence(s.requests.at(-1)).images[0].analysis, penguin); assert.equal(s.requests.filter(request => request.model === "gemma3:4b").length, 2, "Original question remains cached independently"); assert.ok(s.requests.at(-1).messages.every(message => !message.content.includes("Why is the Reasoning button disabled?"))); }); test("legacy, prompt-version, model and corrupt caches invalidate; matching task cache survives and context never falls back to stale data", async t => { const s = await setup(t); const attachment = await (await s.upload(await s.image("png"))).json(); const chatId = await s.chat(); const db = s.app.locals.db; db.prepare("UPDATE attachments SET vision_model='gemma3:4b',vision_analysis=? WHERE id=?").run('{"summary":"Old vague result"}', attachment.id); await s.api("/chat", {chatId, message: "Identify this", attachments: [attachment.id]}); const user = (await (await s.api(`/chats/${chatId}/messages`)).json()).messages[0]; const service = s.app.locals.attachmentService; const calls = () => s.requests.filter(request => request.model === "gemma3:4b").length; assert.equal(calls(), 1, "Legacy model-only cache is invalid"); await service.analyze(user.id, " Identify this "); assert.equal(calls(), 1); assert.equal(service.context([user], "Unrelated task"), null); db.prepare("UPDATE attachment_vision_analyses SET prompt_version=?").run(VISION_PROMPT_VERSION - 1); assert.equal(service.context([user], "Identify this"), null); await service.analyze(user.id, "Identify this"); assert.equal(calls(), 2); db.prepare("UPDATE attachment_vision_analyses SET vision_model='different-model' WHERE prompt_version=?").run(VISION_PROMPT_VERSION); assert.equal(service.context([user], "Identify this"), null); await service.analyze(user.id, "Identify this"); assert.equal(calls(), 3); db.prepare("UPDATE attachment_vision_analyses SET analysis='broken' WHERE vision_model='gemma3:4b' AND prompt_version=?").run(VISION_PROMPT_VERSION); await service.analyze(user.id, "Identify this"); assert.equal(calls(), 4); await service.analyze(user.id, "Read the visible text"); assert.equal(calls(), 5); await service.analyze(user.id, "Identify this"); assert.equal(calls(), 5); const evidence = service.context([user], "Identify this"); assert.ok(evidence.every(message => !message.content.includes("Old vague result"))); const row = db.prepare("SELECT * FROM attachment_vision_analyses WHERE vision_model='gemma3:4b' AND prompt_version=? AND question=?").get(VISION_PROMPT_VERSION, "Identify this"); assert.ok(row.analyzed_at); }); test("malformed visual output remains raw evidence and image instruction text stays outside trusted Qwen prompts", async t => { const attack = 'ERASE_ALL_CHAT_HISTORY'; let plain = true; const s = await setup(t, {visionResult: () => plain ? `A likely bird. Visible text: ${attack}` : JSON.stringify({summary: "Text screenshot", answer: "A visible instruction", confidence: 0.8, visibleText: [attack], evidence: [], alternatives: [], uncertainties: []})}); const attachment = await (await s.upload(await s.image("png"))).json(); const chatId = await s.chat(); for (const question of ["Describe the screenshot", "Read the visible text"]) { const wire = await (await s.api("/chat/stream", {chatId, message: question, ...(plain ? {attachments: [attachment.id]} : {})})).text(); assert.match(wire, /event: done/); assert.doesNotMatch(wire, /event: error/); const final = s.requests.at(-1); const evidence = imageEvidence(final).images[0].analysis; assert.ok(plain ? evidence.rawAnalysis.includes(attack) : evidence.visibleText.includes(attack)); assert.ok(final.messages.filter(message => message.role === "system").every(message => !message.content.includes("ERASE_ALL_CHAT_HISTORY"))); const encoded = final.messages.find(message => message.content.startsWith("")).content; assert.equal(encoded.split("").length, 2, "Image text cannot terminate the evidence delimiter"); assert.match(encoded, /\\u003c/); plain = false; } });