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
+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.");
});