Fix admin authorization and harden chat requests
CI / check (22) (push) Successful in 10m3s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-05 22:49:29 +02:00
parent e8cd13a0af
commit c48e1b17c4
38 changed files with 4897 additions and 1257 deletions
+71
View File
@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import {ApiError, get, post, stream} from "../public/js/api.js";
test("JSON requests retain HTTP metadata, handle empty success, and distinguish bad bodies from network errors", async t => {
let response;
t.mock.method(globalThis, "fetch", async () => response);
for (const status of [204, 205, 200]) {
response = new Response(null, {status});
assert.deepEqual(await get("/empty"), {});
}
response = new Response("<html>Proxy unavailable</html>", {status: 503, statusText: "Service Unavailable", headers: {"X-Request-ID": "proxy-123"}});
assert.deepEqual(await get("/proxy"), {error: "HTTP 503 Service Unavailable", status: 503, code: "HTTP_503", requestId: "proxy-123"});
response = Response.json({error: "Already exists", code: "USERNAME_TAKEN", status: 400, requestId: "body-123", retryAfter: 5}, {status: 409});
assert.deepEqual(await post("/register", {}), {error: "Already exists", code: "USERNAME_TAKEN", status: 409, requestId: "body-123", retryAfter: 5});
response = Response.json({error: {message: "Forbidden", code: "DENIED"}}, {status: 403});
assert.equal((await get("/private")).code, "DENIED");
response = new Response("not json", {headers: {"X-Request-ID": "invalid-body"}});
assert.deepEqual(await get("/invalid"), {error: "Invalid JSON response.", status: 200, code: "INVALID_RESPONSE", requestId: "invalid-body"});
t.mock.method(globalThis, "fetch", async () => { throw new TypeError("Failed to fetch"); });
assert.equal((await get("/offline")).code, "NETWORK_ERROR");
});
test("JSON requests support caller abort and deadlines across headers and body reads", async t => {
let signal;
t.mock.method(globalThis, "fetch", async (_url, options) => {
signal = options.signal;
return new Promise(() => {});
});
const controller = new AbortController();
const cancelled = get("/cancel", {signal: controller.signal});
controller.abort();
assert.equal((await cancelled).code, "ABORTED");
assert.equal((await get("/timeout", {timeoutMs: 10})).code, "TIMEOUT");
assert.equal(signal.aborted, true);
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream(), {headers: {"X-Request-ID": "slow-body"}}));
const bodyTimeout = await get("/slow-body", {timeoutMs: 10});
assert.equal(bodyTimeout.code, "TIMEOUT");
assert.equal(bodyTimeout.status, 200);
assert.equal(bodyTimeout.requestId, "slow-body");
});
test("SSE errors reject by default and deliver complete metadata to an explicit handler", async t => {
const payload = {error: "Model unavailable", status: 503, code: "MODEL_UNAVAILABLE", requestId: "model-123"};
t.mock.method(globalThis, "fetch", async () => new Response(`event: error\ndata: ${JSON.stringify(payload)}\n\n`));
await assert.rejects(stream("/stream", {}), error => error instanceof ApiError && error.status === 503 && error.code === payload.code && error.requestId === payload.requestId);
let handled;
await stream("/stream", {}, {onError: error => { handled = error; }});
assert.deepEqual(handled, payload);
t.mock.method(globalThis, "fetch", async () => new Response("upstream unavailable", {status: 503, headers: {"X-Request-ID": "proxy-123"}}));
await assert.rejects(stream("/stream", {}), error => error.status === 503 && error.code === "HTTP_503" && error.requestId === "proxy-123");
});
test("SSE supports fragmented UTF-8, multiline data, replacements, cancellation and stalled reads", async t => {
const received = [];
const wire = 'event: chunk\r\ndata: {"chunk":"Hello 🌳"}\r\n\r\nevent: replace\ndata: {"target":"reply",\ndata: "text":"Corrected"}\n\nevent: done\ndata: {"reply":"Corrected"}\n\n';
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({start(controller) {
for (const byte of new TextEncoder().encode(wire)) controller.enqueue(Uint8Array.of(byte));
controller.close();
}})));
await stream("/stream", {}, {onChunk: data => received.push(data.chunk), onReplace: data => received.push(data.text), onDone: data => received.push(data.reply)});
assert.deepEqual(received, ["Hello 🌳", "Corrected", "Corrected"]);
let cancelled = 0;
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({cancel() { cancelled++; }}), {headers: {"X-Request-ID": "stream-123"}}));
await assert.rejects(stream("/stream", {}, {}, {timeoutMs: 10}), error => error.code === "TIMEOUT" && error.requestId === "stream-123");
assert.equal(cancelled, 1);
const controller = new AbortController();
const pending = stream("/stream", {}, {}, controller.signal);
controller.abort();
await assert.rejects(pending, error => error.code === "ABORTED");
});
+126 -1
View File
@@ -7,6 +7,9 @@ import fs from "fs";
import os from "os";
import path from "path";
import session from "express-session";
import bcrypt from "bcrypt";
import Database from "better-sqlite3";
import {initializeSchema, runMigrations} from "../src/db/schema.js";
import {createApp, createCloseHandler} from "../src/app.js";
class CookieJar {
@@ -271,7 +274,7 @@ test("username and password changes keep account data and sessions consistent",
.run(attachmentId, "accountowner", "picture.png", "rename-test-image", "image/png", 1, 1, 1);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "wrong"}, jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 400);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 409);
const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar});
assert.equal(renamed.status, 200);
assert.equal(renamed.json.username, "newowner");
@@ -312,6 +315,7 @@ test("admin diagnostics require admin and return model status", async () => {
assert.equal(userDiagnostics.json.error, "Admin only");
await request(server.baseUrl, "/register", {method: "POST", body: {username: "admin", password: "password123"}});
server.app.locals.db.prepare("UPDATE users SET role = 'admin' WHERE username = ?").run("admin");
await request(server.baseUrl, "/login", {method: "POST", body: {username: "admin", password: "password123"}, jar: adminJar});
const diagnostics = await request(server.baseUrl, "/admin/diagnostics", {jar: adminJar});
assert.equal(diagnostics.status, 200);
@@ -1193,3 +1197,124 @@ test("every served page references existing local assets", async () => {
}
} finally { await server.close(); }
});
test("roles cannot be obtained by registration or rename and revocation affects existing sessions", async t => {
for (const entry of ["register", "rename"]) {
const server = await startTestServer();
t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar,
body: {username: entry === "register" ? "admin" : "ordinary", password: "password123", role: "admin", isAdmin: true}});
if (entry === "rename") {
assert.equal((await request(server.baseUrl, "/admin/reports", {jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", jar,
body: {username: "admin", password: "password123", role: "admin"}})).status, 200);
}
const assertPermissions = async status => {
for (const [url, method] of [["/admin/reports", "GET"], ["/admin/diagnostics", "GET"],
["/admin/reports/999", "POST"], ["/admin/personas/market/999/moderate", "POST"]]) {
assert.equal((await request(server.baseUrl, url, {method, jar, body: method === "POST" ? {} : undefined})).status, status, url);
}
};
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.role, "user");
await assertPermissions(403);
server.app.locals.db.prepare("UPDATE users SET role = 'admin' WHERE username = 'admin'").run();
await assertPermissions(200);
await request(server.baseUrl, "/settings/username", {method: "PUT", jar, body: {username: "moderator", password: "password123"}});
await assertPermissions(200);
server.app.locals.db.prepare("UPDATE users SET role = 'user' WHERE username = 'moderator'").run();
await assertPermissions(403);
}
});
test("legacy users named admin migrate to an unprivileged role, idempotently", () => {
const db = new Database(":memory:");
try {
db.exec("CREATE TABLE users (username TEXT PRIMARY KEY, password TEXT NOT NULL); INSERT INTO users VALUES ('admin', 'hash')");
initializeSchema(db);
runMigrations(db, {dropLegacyChats: true});
runMigrations(db, {dropLegacyChats: true});
assert.equal(db.prepare("SELECT role FROM users WHERE username = 'admin'").get().role, "user");
assert.throws(() => db.prepare("UPDATE users SET role = 'owner'").run(), /CHECK/);
} finally { db.close(); }
});
test("simultaneous registrations race at insertion and return one 200 and one 409", async t => {
const server = await startTestServer();
t.after(() => server.close());
const hash = bcrypt.hash;
let hashing = 0, release;
const barrier = new Promise(resolve => { release = resolve; });
t.mock.method(bcrypt, "hash", async (...args) => {
if (++hashing === 2) release();
await barrier;
return hash(...args);
});
const results = await Promise.all([new CookieJar(), new CookieJar()].map(jar => request(server.baseUrl, "/register", {
method: "POST", jar, body: {username: "registration-race", password: "password123"}
})));
assert.deepEqual(results.map(result => result.status).sort(), [200, 409]);
assert.equal(results.find(result => result.status === 409).json.code, "USERNAME_TAKEN");
assert.equal(server.app.locals.db.prepare("SELECT COUNT(*) AS count FROM users").get().count, 1);
assert.equal((await request(server.baseUrl, "/register", {method: "POST", body: {username: "registration-race", password: "password123"}})).status, 409);
});
test("unexpected async auth and health failures reach Express error handling", async t => {
const server = await startTestServer();
t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "health-check", password: "password123"}});
const failure = Object.assign(new Error("private database details"), {code: "SQLITE_ERROR"});
t.mock.method(server.app.locals.repositories, "insertUser", () => { throw failure; });
const result = await request(server.baseUrl, "/register", {method: "POST", body: {username: "broken-insert", password: "password123"}});
assert.equal(result.status, 500);
assert.equal(result.json.error, "Internal server error");
t.mock.method(server.app.locals.modelService, "checkHealth", async () => { throw failure; });
assert.equal((await request(server.baseUrl, "/health", {jar})).status, 500);
assert.equal(server.app.locals.observability.getSnapshot().errorsTotal, 2);
assert.equal((await request(server.baseUrl, "/session")).status, 200);
});
test("browser sanitizer is served from the npm dependency without exposing node_modules", async t => {
const server = await startTestServer();
t.after(() => server.close());
const response = await fetch(server.baseUrl + "/vendor/dompurify.js");
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-cache");
assert.equal(await response.text(), fs.readFileSync(new URL("../node_modules/dompurify/dist/purify.min.js", import.meta.url), "utf8"));
assert.ok((await fetch(server.baseUrl + "/node_modules/dompurify/package.json")).status >= 400);
});
test("persona corrections explicitly replace provisional SSE output before persisting the final answer", async t => {
let pass = 0;
const modelService = {
async streamReply(_model, _messages, onChunk, options) {
const reply = ++pass === 1 ? 'You: I agree.' : 'The guide waits quietly.';
options.onThinking?.('considering', 'considering');
onChunk(reply, reply);
return reply;
},
mapError: error => ({status: 500, body: {error: error.message}})
};
const server = await startTestServer({modelService});
t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "correction", password: "password123"}});
const {json: {persona}} = await request(server.baseUrl, "/personas", {method: "POST", jar, body: {personaType: "assistant", name: "Guide"}});
const {json: {chat}} = await request(server.baseUrl, `/personas/${persona.id}/chat`, {method: "POST", jar, body: {}});
const response = await fetch(server.baseUrl + "/chat/stream", {method: "POST", headers: jar.headers({"Content-Type": "application/json"}),
body: JSON.stringify({chatId: chat.id, message: "Hello", model: "qwen3:14b", reasoning: true})});
const events = (await response.text()).trim().split("\n\n").map(event => ({
type: event.split("\n")[0].slice(7), data: JSON.parse(event.split("\n")[1].slice(6))
}));
assert.equal(pass, 2);
const textEvents = events.filter(event => ["chunk", "replace"].includes(event.type));
assert.deepEqual(textEvents, [
{type: "chunk", data: {chunk: "You: I agree."}},
{type: "replace", data: {target: "thinking", text: ""}},
{type: "replace", data: {target: "reply", text: ""}},
{type: "chunk", data: {chunk: "The guide waits quietly."}}
]);
assert.equal(events.at(-1).type, "done");
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages`, {jar})).json.messages.at(-1).content, "The guide waits quietly.");
});
+86
View File
@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createComposerController} from "../public/js/composerController.js";
import {createConversationState} from "../public/js/conversationState.js";
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
const noop = () => {};
const element = () => ({value: "Hello", style: {}, scrollHeight: 0, scrollTop: 0, clientHeight: 0,
classList: {toggle: noop, add: noop}, setAttribute: noop, focus: noop, querySelectorAll: () => []});
function fixture(overrides = {}) {
const state = createConversationState();
const elements = {msgInput: element(), sendBtn: element(), messagesDiv: element(), modelSelect: {value: "original-model"}};
const calls = {create: 0, streams: [], refresh: [], notices: []};
const controller = createComposerController({state, elements,
attachmentComposer: {pending: false, attachments: [], setBusy: noop, clear: noop, clearSentDraft: noop},
webSearchButton: element(), reasoningToggle: {enabled: true, setBusy: noop, reset: noop}, webSearchToggle: {enabled: true, reset: noop},
setChatActivity: noop, updateComposerPlaceholder: noop, setNotice: message => calls.notices.push(message),
createNewChat: async () => {
calls.create++;
const chat = overrides.creation ? await overrides.creation : {id: 1, title: "New chat"};
if (chat?.id) { state.activeChatId = chat.id; state.chatSessions.push(chat); }
return chat;
},
getChatById: id => state.chatSessions.find(chat => chat.id === id),
updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: noop,
addMessage: () => ({isConnected: true, querySelector: () => element(), remove: noop, replaceWith: noop}),
setMessageContent: noop, setActiveChat: async id => calls.refresh.push(id), loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop,
api: {put: async () => overrides.rename ? await overrides.rename : {}, post: async () => assert.fail("Send cancellation must abort its own request"),
stream: async (_url, payload, handlers) => {
calls.streams.push(payload);
if (overrides.failure) throw new Error(overrides.failure);
handlers.onDone({reply: "Done"});
}}
});
return {state, elements, calls, ...controller};
}
test("rapid sends lock before chat creation and keep the captured chat/model after navigation during rename", async () => {
const creation = deferred(), rename = deferred();
const s = fixture({creation: creation.promise, rename: rename.promise});
const first = s.sendMessage();
await s.sendMessage();
assert.equal(s.state.isProcessing, true);
assert.equal(s.calls.create, 1);
creation.resolve({id: 7, title: "New chat"});
await new Promise(resolve => setImmediate(resolve));
await s.sendMessage();
s.state.activeChatId = 8;
s.elements.modelSelect.value = "changed-model";
s.elements.msgInput.value = "Draft in another conversation";
rename.resolve({});
await first;
assert.equal(s.calls.streams.length, 1);
assert.equal(s.calls.streams[0].chatId, 7);
assert.equal(s.calls.streams[0].model, "original-model");
assert.equal(s.calls.streams[0].reasoning, true);
assert.deepEqual(s.calls.refresh, []);
assert.deepEqual(s.state.currentMessages, []);
assert.equal(s.elements.msgInput.value, "Draft in another conversation");
assert.equal(s.state.isProcessing, false);
});
test("creation, rename and stream failures unlock sending and retain the draft", async () => {
for (const options of [{creation: Promise.resolve({error: "Creation failed"})}, {rename: Promise.resolve({error: "Rename failed"})}, {failure: "Stream failed"}]) {
const s = fixture(options);
await s.sendMessage();
assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.disabled, false);
assert.equal(s.elements.msgInput.value, "Hello");
assert.match(s.calls.notices.at(-1), /failed/);
if (options.failure) assert.equal(s.state.currentMessages[0].deliveryStatus, "failed");
else assert.equal(s.calls.streams.length, 0);
}
});
test("Stop during chat creation prevents a later stream and releases the send lock", async () => {
const creation = deferred();
const s = fixture({creation: creation.promise});
const pending = s.sendMessage();
await s.stopGeneration();
creation.resolve({id: 4, title: "New chat"});
await pending;
assert.equal(s.calls.streams.length, 0);
assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.value, "Hello");
});
+4 -3
View File
@@ -76,11 +76,12 @@ test("thinking streams separately and survives reload, retry, branch and import"
globalThis.fetch = async () => new Response(wire);
try {
await stream("/fixture", {}, {
onThinking: data => events.push(["thinking", data.fullThinking]),
onChunk: data => events.push(["answer", data.fullReply])
onThinking: data => events.push(["thinking", data.chunk]),
onChunk: data => events.push(["answer", data.chunk])
});
} finally { globalThis.fetch = originalFetch; }
assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "Consider carefully."], ["answer", "Answer"]]);
assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "carefully."], ["answer", "Answer"]]);
assert.doesNotMatch(wire, /fullReply|fullThinking/);
const bot = (await api(`/chats/${chatId}/messages`)).messages.at(-1);
assert.equal(bot.thinking, "Consider carefully.");
assert.equal(bot.content, "Answer");
+10 -3
View File
@@ -35,6 +35,12 @@ test("inline renderer streams in place, follows only nearby output, commits or r
const oldDocument = globalThis.document, oldFetch = globalThis.fetch;
t.after(() => { globalThis.document = oldDocument; globalThis.fetch = oldFetch; });
globalThis.document = {createElement: tag => new Element(tag)};
const frames = new Map();
let frameId = 0;
const oldRequestFrame = globalThis.requestAnimationFrame, oldCancelFrame = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = callback => { frames.set(++frameId, callback); return frameId; };
globalThis.cancelAnimationFrame = id => frames.delete(id);
t.after(() => { globalThis.requestAnimationFrame = oldRequestFrame; globalThis.cancelAnimationFrame = oldCancelFrame; });
for (const outcome of ["success", "error", "disconnect"]) {
const scroller = new Element(), card = new Element(), later = new Element();
const header = new Element(), oldBody = new Element(), actions = new Element(), oldSources = new Element();
@@ -52,6 +58,7 @@ test("inline renderer streams in place, follows only nearby output, commits or r
const event = async (name, data) => {
controller.enqueue(new TextEncoder().encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`));
await new Promise(resolve => setTimeout(resolve, 0));
for (const callback of [...frames.values()]) callback();
};
assert.equal(card.attributes["aria-busy"], "true");
assert.equal(card.querySelector(".msg-sources"), null);
@@ -60,15 +67,15 @@ test("inline renderer streams in place, follows only nearby output, commits or r
assert.equal(card.querySelector(".loading-shell").textContent, "Searching the web...");
await event("status", {status: "generating", sources: [{title: "New source", url: "https://example.org/new"}]});
assert.notEqual(card.querySelector(".msg-sources"), oldSources);
await event("thinking", {fullThinking: "New reasoning"});
await event("thinking", {chunk: "New reasoning"});
assert.equal(card.querySelector(".msg-reasoning-content").textContent, "New reasoning");
await event("chunk", {fullReply: "Partial"});
await event("chunk", {chunk: "Partial"});
assert.equal(card.querySelector(".msg-content").textContent, "Partial");
assert.equal(scroller.scrollTop, 20);
assert.equal(completed, undefined);
assert.deepEqual(scroller.childNodes, [card, later]);
card.bottom = 1000; // User has scrolled away from this response.
await event("chunk", {fullReply: "Partial answer"});
await event("chunk", {chunk: " answer"});
assert.equal(scroller.scrollTop, 20);
if (outcome === "success") {
await event("done", {reply: "Partial answer", message: {id: 2, content: "Partial answer"}});
+38
View File
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createStreamRenderer} from "../public/js/streamRenderer.js";
import {createTextStreamEvents} from "../src/services/streamEvents.js";
test("streaming sends linear-sized deltas, applies replacements and batches rendering", () => {
const events = [], frames = new Map(), rendered = [];
let nextId = 0;
const renderer = createStreamRenderer({
onReply: text => rendered.push(["reply", text]), onThinking: text => rendered.push(["thinking", text]),
schedule: callback => { frames.set(++nextId, callback); return nextId; }, cancel: id => frames.delete(id)
});
const emit = createTextStreamEvents((type, data) => {
events.push({type, data});
renderer[{chunk: "onChunk", thinking: "onThinking", replace: "onReplace"}[type]](data);
});
for (let i = 0; i < 1000; i++) emit.onChunk("a", "a".repeat(i + 1));
assert.equal(JSON.stringify(events).length < 45000, true);
assert.equal(frames.size, 1);
assert.equal(rendered.length, 0);
renderer.flush();
assert.deepEqual(rendered, [["reply", "a".repeat(1000)]]);
emit.onThinking("provisional", "provisional");
emit.onThinking("", ""); emit.onChunk("", "");
emit.onChunk("Corrected", "Corrected");
renderer.flush();
assert.deepEqual(rendered.slice(1), [["thinking", ""], ["reply", "Corrected"]]);
assert.ok(events.some(event => event.type === "replace" && event.data.target === "reply"));
renderer.onChunk({chunk: " stale"});
renderer.dispose();
assert.equal(frames.size, 0);
renderer.flush();
assert.equal(rendered.at(-1)[1], "Corrected");
renderer.onChunk({chunk: "", fullReply: ""});
renderer.onChunk({chunk: "Legacy", fullReply: "Legacy"});
renderer.flush();
assert.equal(rendered.at(-1)[1], "Legacy");
});