1436 lines
68 KiB
JavaScript
1436 lines
68 KiB
JavaScript
/**
|
|
* @file Prüft Anmeldung, Kontoverwaltung, Chats, Personas und den Server-Lebenszyklus der Anwendung.
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
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 {
|
|
constructor() {
|
|
this.cookie = "";
|
|
}
|
|
|
|
update(response) {
|
|
const values = typeof response.headers.getSetCookie === "function"
|
|
? response.headers.getSetCookie()
|
|
: [response.headers.get("set-cookie")].filter(Boolean);
|
|
if (!values.length) return;
|
|
this.cookie = values.map((value) => value.split(";")[0]).join("; ");
|
|
}
|
|
|
|
headers(extra = {}) {
|
|
return this.cookie ? {...extra, Cookie: this.cookie} : extra;
|
|
}
|
|
}
|
|
|
|
async function startTestServer(overrides = {}) {
|
|
let replyCount = 0;
|
|
const modelService = overrides.modelService || {
|
|
async generateReply() {
|
|
replyCount += 1;
|
|
return `stub-reply-${replyCount}`;
|
|
},
|
|
async streamReply(model, messagesPayload, onChunk) {
|
|
replyCount += 1;
|
|
const reply = `stub-reply-${replyCount}`;
|
|
if (onChunk) {
|
|
onChunk(reply.slice(0, 5), reply.slice(0, 5));
|
|
onChunk(reply.slice(5), reply);
|
|
}
|
|
return reply;
|
|
},
|
|
async listModels() {
|
|
return {models: [{name: "mistral:latest"}]};
|
|
},
|
|
async checkHealth() {
|
|
return {ok: true, checkedAt: new Date().toISOString(), error: null};
|
|
},
|
|
async getDiagnostics() {
|
|
return {
|
|
model: {
|
|
ok: true,
|
|
checkedAt: new Date().toISOString(),
|
|
error: null,
|
|
latencyMs: 12,
|
|
backendUrl: "http://model.test",
|
|
availableModels: ["mistral:latest"],
|
|
modelCount: 1,
|
|
listLatencyMs: 12,
|
|
cacheAgeMs: 0,
|
|
lastFailure: null,
|
|
activeRequests: []
|
|
}
|
|
};
|
|
},
|
|
mapError(error) {
|
|
return {status: 500, body: {error: error.message, code: "TEST_MODEL"}};
|
|
}
|
|
};
|
|
|
|
const app = createApp({
|
|
config: {
|
|
port: 0,
|
|
dbPath: ":memory:",
|
|
testMode: true,
|
|
sessionSecret: "test-secret",
|
|
...(overrides.config || {})
|
|
},
|
|
modelService,
|
|
sessionStore: overrides.sessionStore
|
|
});
|
|
|
|
const server = await new Promise((resolve) => {
|
|
const instance = app.listen(0, () => resolve(instance));
|
|
});
|
|
const {port} = server.address();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
|
|
return {
|
|
app,
|
|
baseUrl,
|
|
async close() {
|
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
await app.locals.close();
|
|
}
|
|
};
|
|
}
|
|
|
|
async function request(baseUrl, path, {method = "GET", body, jar} = {}) {
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(jar ? jar.headers() : {})
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined
|
|
});
|
|
if (jar) jar.update(response);
|
|
const json = await response.json();
|
|
return {status: response.status, json};
|
|
}
|
|
|
|
test("public health checks reach the backend without exposing diagnostics or requiring a session", async t => {
|
|
const server = await startTestServer();
|
|
t.after(() => server.close());
|
|
let checks = 0;
|
|
server.app.locals.modelService.checkHealth = async () => { checks++; return {ok: checks === 1, backendUrl: "private-backend", error: "private detail"}; };
|
|
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 200, json: {ok: true}});
|
|
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 503, json: {ok: false}});
|
|
assert.equal(checks, 2);
|
|
assert.equal((await request(server.baseUrl, "/admin/diagnostics")).status, 403);
|
|
});
|
|
|
|
test("personality API keeps prompts private, validates IDs, and enforces chat ownership", async t => {
|
|
const server = await startTestServer();
|
|
t.after(() => server.close());
|
|
const owner = new CookieJar(), other = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method:"POST", jar:owner, body:{username:"personality-owner", password:"password123"}});
|
|
await request(server.baseUrl, "/register", {method:"POST", jar:other, body:{username:"personality-other", password:"password123"}});
|
|
const listed = await request(server.baseUrl, "/personalities", {jar:owner});
|
|
assert.equal(listed.json.personalities.length, 6);
|
|
assert.equal(listed.json.personalities.filter(item => item.isDefault).length, 1);
|
|
assert.equal(listed.json.personalities.find(item => item.id === "programmer").name, "Ada");
|
|
assert.equal(listed.json.personalities.find(item => item.id === "programmer").specialization, "Programming & Development");
|
|
assert.equal(listed.json.personalities.find(item => item.id === "programmer").historicalInspiration, "Ada Lovelace");
|
|
assert.equal(JSON.stringify(listed.json).includes("systemPrompt"), false);
|
|
const catalog = await fetch(`${server.baseUrl}/app/personas`, {headers:owner.headers()});
|
|
assert.match(await catalog.text(), /Personalities/);
|
|
const created = await request(server.baseUrl, "/chats", {method:"POST", jar:owner, body:{personalityId:"programmer"}});
|
|
assert.equal(created.json.chat.personality_id, "programmer");
|
|
const defaultChat = await request(server.baseUrl, "/chats", {method:"POST", jar:owner, body:{systemPrompt:"Injected"}});
|
|
assert.equal(defaultChat.json.chat.personality_id, "general-assistant");
|
|
assert.equal((await request(server.baseUrl, "/chats", {method:"POST", jar:owner, body:{personalityId:"invented"}})).status, 400);
|
|
assert.equal((await request(server.baseUrl, `/chats/${created.json.chat.id}/personality`, {method:"PUT", jar:other, body:{personalityId:"tutor"}})).status, 404);
|
|
assert.equal((await request(server.baseUrl, `/chats/${created.json.chat.id}/personality`, {method:"PUT", jar:owner, body:{personalityId:"tutor"}})).json.chat.personality_id, "tutor");
|
|
assert.equal((await request(server.baseUrl, "/personas", {method:"POST", jar:owner, body:{name:"Custom"}})).status, 404);
|
|
});
|
|
|
|
test("new passwords are limited to 72 UTF-8 bytes while legacy credentials remain usable", async t => {
|
|
const server = await startTestServer({config: {authRateLimitMax: 100}});
|
|
t.after(() => server.close());
|
|
for (const password of ["a".repeat(73), "é".repeat(37), "😀".repeat(19)]) {
|
|
const result = await request(server.baseUrl, "/register", {method: "POST", body: {username: "too-long", password}});
|
|
assert.equal(result.status, 400);
|
|
assert.equal(result.json.code, "PASSWORD_TOO_LONG");
|
|
assert.equal(server.app.locals.repositories.getUser("too-long"), undefined);
|
|
}
|
|
const jar = new CookieJar();
|
|
const boundary = "é".repeat(36);
|
|
assert.equal((await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "boundary", password: boundary}})).status, 200);
|
|
const change = newPassword => request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: boundary, newPassword}});
|
|
assert.equal((await change("a".repeat(73))).json.code, "PASSWORD_TOO_LONG");
|
|
assert.equal((await change("😀".repeat(19))).json.code, "PASSWORD_TOO_LONG");
|
|
assert.equal((await change("a".repeat(72))).status, 200);
|
|
const legacy = "a".repeat(72) + "existing suffix";
|
|
server.app.locals.repositories.insertUser("legacy", await bcrypt.hash(legacy, 4));
|
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", jar, body: {username: "legacy", password: legacy}})).status, 200);
|
|
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: legacy, newPassword: "new-password"}})).status, 200);
|
|
});
|
|
|
|
test("history pages are bounded, preserve branch identity and batch message metadata", async t => {
|
|
const server = await startTestServer(); t.after(() => server.close());
|
|
const jar = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "paged", password: "password123"}});
|
|
const repo = server.app.locals.repositories;
|
|
const chat = repo.createChat("paged", "Long conversation");
|
|
let parent = null;
|
|
const ids = [];
|
|
for (let index = 0; index < 125; index++) {
|
|
parent = Number(repo.insertBranchChatMessage(chat.id, index % 2 ? "bot" : "user", `Message ${index}`, null, "test", "complete", null, parent).lastInsertRowid);
|
|
ids.push(parent);
|
|
}
|
|
repo.saveMessageVersion(chat.id, ids[123], {content: "Alternate answer", modelName: "other-model"});
|
|
const sibling = Number(repo.insertBranchChatMessage(chat.id, "bot", "Other branch", null, "test", "complete", null, ids[122]).lastInsertRowid);
|
|
repo.updateChatActiveLeaf(chat.id, "paged", parent);
|
|
t.mock.method(repo, "listMessageVersions", () => assert.fail("History should batch versions"));
|
|
t.mock.method(repo, "listSiblingChatMessages", () => assert.fail("History should batch siblings"));
|
|
t.mock.method(server.app.locals.attachmentService, "list", () => assert.fail("History should batch attachments"));
|
|
const first = await request(server.baseUrl, `/chats/${chat.id}/messages`, {jar});
|
|
assert.equal(first.status, 200); assert.equal(first.json.messages.length, 50);
|
|
assert.equal(first.json.messages[0].id, ids[75]);
|
|
const answer = first.json.messages.find(message => message.id === ids[123]);
|
|
assert.equal(answer.versionCount, 2); assert.equal(answer.nextSiblingId, sibling);
|
|
const page = first.json.pagination;
|
|
const second = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar});
|
|
assert.deepEqual(second.json.messages.map(message => message.id), ids.slice(25, 75));
|
|
const third = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${second.json.pagination.before}&leaf=${page.leafId}`, {jar});
|
|
assert.deepEqual(third.json.messages.map(message => message.id), ids.slice(0, 25));
|
|
assert.equal(third.json.pagination.hasMore, false);
|
|
const oldMatch = await request(server.baseUrl, `/chats/${chat.id}/messages?around=${ids[4]}`, {jar});
|
|
assert.equal(oldMatch.json.matchOnBranch, true); assert.equal(oldMatch.json.pagination.hasNewer, true);
|
|
assert.equal(oldMatch.json.messages.at(-1).id, ids[4]);
|
|
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${sibling}`, {jar})).status, 400);
|
|
repo.updateChatActiveLeaf(chat.id, "paged", sibling);
|
|
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar})).status, 409);
|
|
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?limit=101`, {jar})).status, 400);
|
|
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/branches`, {jar})).json.nodes.length, 126);
|
|
const stranger = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", jar: stranger, body: {username: "stranger", password: "password123"}});
|
|
for (const suffix of ["messages", "branches", `messages/${ids[123]}/versions`, "memories"]) {
|
|
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/${suffix}`, {jar: stranger})).status, 404);
|
|
}
|
|
});
|
|
|
|
test("conversation memory exposes context estimates and supports validated summary corrections", async t => {
|
|
const server = await startTestServer(); t.after(() => server.close());
|
|
const jar = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "memory-editor", password: "password123"}});
|
|
const repo = server.app.locals.repositories;
|
|
const chat = repo.createChat("memory-editor", "Memory");
|
|
repo.createChatMemory("memory-editor", chat, "Prefers concise answers.");
|
|
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", 0);
|
|
const loaded = (await request(server.baseUrl, `/chats/${chat.id}/memories`, {jar})).json;
|
|
assert.equal(loaded.summary, "Old summary"); assert.ok(loaded.context.estimatedTokens > 0);
|
|
const update = body => request(server.baseUrl, `/chats/${chat.id}/summary`, {method: "PUT", jar, body});
|
|
assert.equal((await update({summary: "Corrected summary", previousSummary: "Old summary"})).status, 200);
|
|
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, "Corrected summary");
|
|
assert.equal((await update({summary: "Stale edit", previousSummary: "Old summary"})).status, 409);
|
|
assert.equal((await update({summary: "x".repeat(loaded.summaryMaxChars + 1)})).status, 400);
|
|
assert.equal((await update({summary: "", previousSummary: "Corrected summary"})).status, 200);
|
|
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, null);
|
|
});
|
|
|
|
test("auth flow validates inputs and persists session", async () => {
|
|
const server = await startTestServer();
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {
|
|
method: "POST",
|
|
body: {username: "ab", password: "123456"}
|
|
}).then(({status, json}) => {
|
|
assert.equal(status, 400);
|
|
assert.match(json.error, /at least 3 characters/i);
|
|
});
|
|
|
|
await request(server.baseUrl, "/register", {
|
|
method: "POST",
|
|
body: {username: "alice", password: "password123"}
|
|
}).then(({status}) => assert.equal(status, 200));
|
|
|
|
await request(server.baseUrl, "/login", {
|
|
method: "POST",
|
|
body: {username: "alice", password: "password123"},
|
|
jar
|
|
}).then(({status}) => assert.equal(status, 200));
|
|
|
|
await request(server.baseUrl, "/session", {jar}).then(({json}) => {
|
|
assert.equal(json.user, "alice");
|
|
});
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("invite-only registration requires invite code", async () => {
|
|
const server = await startTestServer({config: {registrationInviteCode: "let-me-in"}});
|
|
|
|
await request(server.baseUrl, "/register", {
|
|
method: "POST",
|
|
body: {username: "invitee", password: "password123"}
|
|
}).then(({status, json}) => {
|
|
assert.equal(status, 403);
|
|
assert.match(json.error, /invite code/i);
|
|
});
|
|
|
|
await request(server.baseUrl, "/register", {
|
|
method: "POST",
|
|
body: {username: "invitee", password: "password123", inviteCode: "let-me-in"}
|
|
}).then(({status}) => assert.equal(status, 200));
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("createApp can use an injected session store", async () => {
|
|
class CountingStore extends session.Store {
|
|
constructor() {
|
|
super();
|
|
this.sessions = new Map();
|
|
this.setCount = 0;
|
|
}
|
|
|
|
get(sid, callback) {
|
|
callback(null, this.sessions.get(sid));
|
|
}
|
|
|
|
set(sid, value, callback) {
|
|
this.setCount += 1;
|
|
this.sessions.set(sid, value);
|
|
callback();
|
|
}
|
|
|
|
destroy(sid, callback) {
|
|
this.sessions.delete(sid);
|
|
callback();
|
|
}
|
|
}
|
|
|
|
const store = new CountingStore();
|
|
const server = await startTestServer({sessionStore: store});
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {
|
|
method: "POST",
|
|
body: {username: "alice", password: "password123"}
|
|
}).then(({status}) => assert.equal(status, 200));
|
|
|
|
await request(server.baseUrl, "/login", {
|
|
method: "POST",
|
|
body: {username: "alice", password: "password123"},
|
|
jar
|
|
}).then(({status}) => assert.equal(status, 200));
|
|
|
|
assert.equal(store.setCount > 0, true);
|
|
await server.close();
|
|
});
|
|
|
|
test("account export, logout-all, and deletion controls work", async () => {
|
|
const server = await startTestServer();
|
|
const firstJar = new CookieJar();
|
|
const secondJar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "owner", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: firstJar});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: secondJar});
|
|
|
|
const exported = await request(server.baseUrl, "/settings/export", {jar: firstJar});
|
|
assert.equal(exported.status, 200);
|
|
assert.equal(exported.json.account.username, "owner");
|
|
assert.ok(exported.json.account.workspace);
|
|
|
|
const logoutAll = await request(server.baseUrl, "/settings/logout-all-sessions", {
|
|
method: "POST",
|
|
body: {},
|
|
jar: firstJar
|
|
});
|
|
assert.equal(logoutAll.status, 200);
|
|
const staleSession = await request(server.baseUrl, "/settings/profile", {jar: secondJar});
|
|
assert.equal(staleSession.status, 403);
|
|
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: firstJar});
|
|
const wrongDelete = await request(server.baseUrl, "/settings/account", {
|
|
method: "DELETE",
|
|
body: {currentPassword: "wrong-password"},
|
|
jar: firstJar
|
|
});
|
|
assert.equal(wrongDelete.status, 403);
|
|
|
|
const deleted = await request(server.baseUrl, "/settings/account", {
|
|
method: "DELETE",
|
|
body: {currentPassword: "password123"},
|
|
jar: firstJar
|
|
});
|
|
assert.equal(deleted.status, 200);
|
|
|
|
const loginAfterDelete = await request(server.baseUrl, "/login", {
|
|
method: "POST",
|
|
body: {username: "owner", password: "password123"}
|
|
});
|
|
assert.equal(loginAfterDelete.status, 400);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("username and password changes keep account data and sessions consistent", async t => {
|
|
const server = await startTestServer();
|
|
t.after(() => server.close());
|
|
const jar = new CookieJar();
|
|
const otherSession = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "accountowner", password: "old-password"}});
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "alreadytaken", password: "password123"}});
|
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar})).status, 200);
|
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar: otherSession})).status, 200);
|
|
const chat = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Keep this chat"}, jar});
|
|
assert.equal(chat.status, 200);
|
|
assert.equal((await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Keeper", personaType: "user"}, jar})).status, 404);
|
|
const db = server.app.locals.db;
|
|
const attachmentId = "12345678-1234-1234-1234-123456789abc";
|
|
db.prepare("INSERT INTO attachments (id, username, original_name, storage_key, mime_type, size_bytes, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
.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, 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");
|
|
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.username, "newowner");
|
|
assert.equal((await request(server.baseUrl, "/session", {jar})).json.user, "newowner");
|
|
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
|
|
assert.equal((await request(server.baseUrl, "/chats", {jar})).json.chats[0].id, chat.json.chat.id);
|
|
assert.equal(db.prepare("SELECT username FROM attachments WHERE id = ?").get(attachmentId).username, "newowner");
|
|
assert.deepEqual(db.pragma("foreign_key_check"), []);
|
|
|
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}, jar: otherSession})).status, 200);
|
|
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "wrong", newPassword: "new-password"}, jar})).status, 403);
|
|
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "old-password", newPassword: "new-password"}, jar})).status, 200);
|
|
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).status, 200);
|
|
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
|
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}})).status, 403);
|
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "new-password"}})).status, 200);
|
|
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "new-password"}})).status, 400);
|
|
assert.equal((await request(server.baseUrl, "/settings/export", {jar})).json.account.username, "newowner");
|
|
});
|
|
|
|
test("admin diagnostics require admin and return model status", async () => {
|
|
const server = await startTestServer({
|
|
config: {
|
|
sessionSecret: "change-me-session-secret",
|
|
slowRequestLoggingEnabled: false,
|
|
eventLoopLagMonitorEnabled: false
|
|
}
|
|
});
|
|
const userJar = new CookieJar();
|
|
const adminJar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "alice", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "alice", password: "password123"}, jar: userJar});
|
|
const userDiagnostics = await request(server.baseUrl, "/admin/diagnostics", {jar: userJar});
|
|
assert.equal(userDiagnostics.status, 403);
|
|
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);
|
|
assert.equal(diagnostics.json.model.ok, true);
|
|
assert.deepEqual(diagnostics.json.model.availableModels, ["mistral:latest"]);
|
|
assert.ok(diagnostics.json.configWarnings.some((warning) => warning.includes("SESSION_SECRET")));
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("invalid JSON returns a clean 400 response", async () => {
|
|
const server = await startTestServer();
|
|
|
|
const response = await fetch(`${server.baseUrl}/register`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: "{"
|
|
});
|
|
const json = await response.json();
|
|
|
|
assert.equal(response.status, 400);
|
|
assert.deepEqual(json, {error: "Invalid JSON"});
|
|
await server.close();
|
|
});
|
|
|
|
test("cross-origin state-changing requests are blocked", async () => {
|
|
const server = await startTestServer();
|
|
|
|
const response = await fetch(`${server.baseUrl}/register`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Origin: "https://example.test"
|
|
},
|
|
body: JSON.stringify({username: "alice", password: "password123"})
|
|
});
|
|
const json = await response.json();
|
|
|
|
assert.equal(response.status, 403);
|
|
assert.deepEqual(json, {error: "Cross-origin request blocked"});
|
|
await server.close();
|
|
});
|
|
|
|
test("file-backed startup creates data and session directories", async () => {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "krishd-app-"));
|
|
const dataDir = path.join(rootDir, "data");
|
|
const sessionsDir = path.join(rootDir, "sessions");
|
|
const app = createApp({
|
|
config: {
|
|
rootDir,
|
|
dataDir,
|
|
sessionsDir,
|
|
dbPath: path.join(dataDir, "app.db"),
|
|
testMode: true,
|
|
sessionSecret: "test-secret"
|
|
},
|
|
modelService: {
|
|
async generateReply() {},
|
|
async streamReply() {},
|
|
async listModels() {
|
|
return {models: []};
|
|
},
|
|
async checkHealth() {
|
|
return {ok: true, checkedAt: new Date().toISOString(), error: null};
|
|
},
|
|
async getDiagnostics() {
|
|
return {model: {ok: true, availableModels: [], modelCount: 0, activeRequests: []}};
|
|
},
|
|
mapError(error) {
|
|
return {status: 500, body: {error: error.message}};
|
|
}
|
|
}
|
|
});
|
|
|
|
assert.equal(fs.existsSync(dataDir), true);
|
|
assert.equal(fs.existsSync(sessionsDir), true);
|
|
await app.locals.close();
|
|
});
|
|
|
|
test("close handler attempts all resources and reports collected failures", async () => {
|
|
const closed = [];
|
|
const close = createCloseHandler([
|
|
() => {
|
|
closed.push("first");
|
|
throw new Error("first failed");
|
|
},
|
|
() => {
|
|
closed.push("second");
|
|
},
|
|
() => {
|
|
closed.push("third");
|
|
throw new Error("third failed");
|
|
}
|
|
]);
|
|
|
|
await assert.rejects(close, (error) => {
|
|
assert.equal(error instanceof AggregateError, true);
|
|
assert.equal(error.errors.length, 2);
|
|
assert.deepEqual(error.errors.map((item) => item.message), ["first failed", "third failed"]);
|
|
return true;
|
|
});
|
|
assert.deepEqual(closed, ["first", "second", "third"]);
|
|
});
|
|
|
|
test("chat creation, send, and retry endpoints work with stubbed model service", async () => {
|
|
const server = await startTestServer();
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "bob", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "bob", password: "password123"}, jar});
|
|
|
|
const createChat = await request(server.baseUrl, "/chats", {
|
|
method: "POST",
|
|
body: {title: "Test chat"},
|
|
jar
|
|
});
|
|
assert.equal(createChat.status, 200);
|
|
const chatId = createChat.json.chat.id;
|
|
|
|
const sendMessage = await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId, message: "hello", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
assert.equal(sendMessage.status, 200);
|
|
assert.equal(sendMessage.json.reply, "stub-reply-1");
|
|
|
|
const messages = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
|
assert.equal(messages.status, 200);
|
|
assert.equal(messages.json.messages.length, 2);
|
|
const botMessage = messages.json.messages.find((message) => message.role === "bot");
|
|
|
|
const retry = await request(server.baseUrl, `/chats/${chatId}/messages/${botMessage.id}/retry`, {
|
|
method: "POST",
|
|
body: {model: "mistral:latest"},
|
|
jar
|
|
});
|
|
assert.equal(retry.status, 200);
|
|
assert.equal(retry.json.message.content, "stub-reply-2");
|
|
assert.equal(retry.json.message.retryVariants.length, 2);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("conversation branches, edit-resend, and generation settings work", async () => {
|
|
const calls = [];
|
|
const modelService = {
|
|
async generateReply(model, messages, options) {
|
|
calls.push({model, messages, options});
|
|
return `reply-${calls.length}`;
|
|
},
|
|
async streamReply() {},
|
|
async listModels() { return {models: [{name: "preferred:model", model: "preferred:model"}]}; },
|
|
mapError(error) { return {status: 500, body: {error: error.message}}; }
|
|
};
|
|
const server = await startTestServer({modelService});
|
|
const jar = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "brancher", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "brancher", password: "password123"}, jar});
|
|
const created = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Branches"}, jar});
|
|
const chatId = created.json.chat.id;
|
|
|
|
await request(server.baseUrl, `/chats/${chatId}/generation-settings`, {
|
|
method: "PUT",
|
|
body: {preferredModel: "preferred:model", temperature: 0.4, contextLength: 4096, responseLength: 300, systemInstruction: "Be exact."},
|
|
jar
|
|
});
|
|
await request(server.baseUrl, "/chat", {method: "POST", body: {chatId, message: "first", model: "selected:model"}, jar});
|
|
assert.equal(calls[0].model, "selected:model");
|
|
assert.deepEqual(calls[0].options.generation, {temperature: 0.4, contextLength: 4096, responseLength: 300});
|
|
assert.match(calls[0].messages[0].content, /Be exact/);
|
|
|
|
const switchedModel = await request(server.baseUrl, `/chats/${chatId}/model`, {
|
|
method: "PUT", body: {model: "selected:model"}, jar
|
|
});
|
|
assert.equal(switchedModel.status, 200);
|
|
assert.equal(switchedModel.json.chat.preferred_model, "selected:model");
|
|
|
|
await request(server.baseUrl, "/chat", {method: "POST", body: {chatId, message: "second", model: "ignored:model"}, jar});
|
|
const original = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
|
const firstAssistant = original.json.messages[1];
|
|
const originalLeaf = original.json.messages.at(-1);
|
|
|
|
const regenerated = await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/retry`, {
|
|
method: "POST", body: {model: "ignored:model"}, jar
|
|
});
|
|
assert.equal(regenerated.status, 200);
|
|
let active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
|
assert.equal(active.json.messages.length, 4);
|
|
assert.equal(active.json.messages[1].id, firstAssistant.id);
|
|
assert.equal(active.json.messages[1].versionCount, 2);
|
|
assert.equal(active.json.messages[1].siblingCount, 1);
|
|
await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/versions/0`, {method: "POST", body: {}, jar});
|
|
|
|
await request(server.baseUrl, `/chats/${chatId}/branches/${originalLeaf.id}/activate`, {method: "POST", body: {}, jar});
|
|
active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
|
assert.equal(active.json.messages.length, 4);
|
|
|
|
const unconfirmedBranch = await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/branch`, {
|
|
method: "POST", body: {}, jar
|
|
});
|
|
assert.equal(unconfirmedBranch.status, 400);
|
|
|
|
const branched = await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/branch`, {
|
|
method: "POST", body: {confirmed: true}, jar
|
|
});
|
|
assert.equal(branched.status, 200);
|
|
assert.notEqual(branched.json.chat.id, chatId);
|
|
assert.equal(branched.json.chat.source_chat_id, chatId);
|
|
assert.equal(branched.json.chat.branched_from_message_id, firstAssistant.id);
|
|
assert.match(branched.json.chat.title, /\(branch\)$/);
|
|
assert.deepEqual(branched.json.messages.map((message) => message.content), ["first", "reply-1"]);
|
|
assert.equal(branched.json.chat.preferred_model, "selected:model");
|
|
assert.equal(branched.json.chat.temperature, 0.4);
|
|
|
|
const edited = await request(server.baseUrl, `/chats/${chatId}/messages/${active.json.messages[0].id}/edit-resend`, {
|
|
method: "POST", body: {chatId, message: "edited first", model: "ignored:model", confirmed: true}, jar
|
|
});
|
|
assert.equal(edited.status, 200);
|
|
assert.notEqual(edited.json.chat.id, chatId);
|
|
assert.notEqual(edited.json.chat.id, branched.json.chat.id);
|
|
assert.deepEqual(edited.json.messages.map((message) => message.content), ["edited first", "reply-4"]);
|
|
|
|
active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
|
assert.deepEqual(active.json.messages.map((message) => message.content), ["first", "reply-1", "second", "reply-2"]);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("chat memories are durable facts included in model payload", async () => {
|
|
let capturedMessages = [];
|
|
const modelService = {
|
|
async generateReply(model, messagesPayload) {
|
|
capturedMessages = messagesPayload;
|
|
return "memory-aware reply";
|
|
},
|
|
async streamReply() {
|
|
return "unused";
|
|
},
|
|
async listModels() {
|
|
return {models: [{name: "mistral:latest"}]};
|
|
},
|
|
async checkHealth() {
|
|
return {ok: true, checkedAt: new Date().toISOString(), error: null};
|
|
},
|
|
async getDiagnostics() {
|
|
return {model: {ok: true, availableModels: ["mistral:latest"], modelCount: 1, activeRequests: []}};
|
|
},
|
|
mapError(error) {
|
|
return {status: 500, body: {error: error.message, code: "TEST_MODEL"}};
|
|
}
|
|
};
|
|
const server = await startTestServer({modelService});
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "memoryuser", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "memoryuser", password: "password123"}, jar});
|
|
const createChat = await request(server.baseUrl, "/chats", {
|
|
method: "POST",
|
|
body: {title: "Memory chat"},
|
|
jar
|
|
});
|
|
const chatId = createChat.json.chat.id;
|
|
|
|
const memory = await request(server.baseUrl, `/chats/${chatId}/memories`, {
|
|
method: "POST",
|
|
body: {fact: "The user prefers terse answers."},
|
|
jar
|
|
});
|
|
assert.equal(memory.status, 200);
|
|
assert.equal(memory.json.memory.fact, "The user prefers terse answers.");
|
|
|
|
const memories = await request(server.baseUrl, `/chats/${chatId}/memories`, {jar});
|
|
assert.equal(memories.status, 200);
|
|
assert.equal(memories.json.memories.length, 1);
|
|
|
|
const sendMessage = await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId, message: "hello", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
assert.equal(sendMessage.status, 200);
|
|
assert.ok(capturedMessages.some((message) => message.role === "system" && message.content.includes("The user prefers terse answers.")));
|
|
|
|
const deleted = await request(server.baseUrl, `/chats/${chatId}/memories/${memory.json.memory.id}`, {
|
|
method: "DELETE",
|
|
jar
|
|
});
|
|
assert.equal(deleted.status, 200);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("chat context summary rolls up older messages into future payloads", async () => {
|
|
let replyCount = 0;
|
|
let latestPayload = [];
|
|
const modelService = {
|
|
async generateReply(model, messagesPayload) {
|
|
if (messagesPayload.some((message) => message.content.includes("Merge the previous summary"))) {
|
|
return "Summary: user greeted the assistant and asked for continuity.";
|
|
}
|
|
latestPayload = messagesPayload;
|
|
replyCount += 1;
|
|
return `reply-${replyCount}`;
|
|
},
|
|
async streamReply() {
|
|
return "unused";
|
|
},
|
|
async listModels() {
|
|
return {models: [{name: "mistral:latest"}]};
|
|
},
|
|
async checkHealth() {
|
|
return {ok: true, checkedAt: new Date().toISOString(), error: null};
|
|
},
|
|
async getDiagnostics() {
|
|
return {model: {ok: true, availableModels: ["mistral:latest"], modelCount: 1, activeRequests: []}};
|
|
},
|
|
mapError(error) {
|
|
return {status: 500, body: {error: error.message, code: "TEST_MODEL"}};
|
|
}
|
|
};
|
|
const server = await startTestServer({
|
|
modelService,
|
|
config: {chatHistoryLimit: 2, chatSummaryUpdateEveryMessages: 2}
|
|
});
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "summarized", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "summarized", password: "password123"}, jar});
|
|
const createChat = await request(server.baseUrl, "/chats", {
|
|
method: "POST",
|
|
body: {title: "Summary chat"},
|
|
jar
|
|
});
|
|
const chatId = createChat.json.chat.id;
|
|
|
|
await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId, message: "hello", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId, message: "remember this", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId, message: "what is next?", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
|
|
assert.ok(latestPayload.some((message) => message.role === "system" && message.content.includes("CONVERSATION SUMMARY")));
|
|
assert.ok(latestPayload.some((message) => message.content.includes("user greeted the assistant")));
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test.skip("legacy character roleplay endpoints are retired", async () => {
|
|
const server = await startTestServer();
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "roleplayer", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "roleplayer", password: "password123"}, jar});
|
|
|
|
const persona = await request(server.baseUrl, "/personas", {
|
|
method: "POST",
|
|
body: {personaType: "assistant", name: "Guide", details: "Patient and direct"},
|
|
jar
|
|
});
|
|
assert.equal(persona.status, 200);
|
|
|
|
const started = await request(server.baseUrl, "/roleplays/start", {
|
|
method: "POST",
|
|
body: {assistantPersonaId: persona.json.persona.id, scenarioPrompt: "A quiet station.", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
assert.equal(started.status, 200);
|
|
assert.equal(started.json.generatedInitialMessage, true);
|
|
assert.ok(started.json.chat.id);
|
|
|
|
const session = await request(server.baseUrl, "/session", {jar});
|
|
assert.equal(session.status, 200);
|
|
assert.equal(session.json.user, "roleplayer");
|
|
|
|
const sendMessage = await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId: started.json.chat.id, message: "I step closer.", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
assert.equal(sendMessage.status, 200);
|
|
assert.equal(sendMessage.json.reply, "stub-reply-2");
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test.skip("legacy character opener generation is retired", async () => {
|
|
const modelService = {
|
|
async generateReply() {
|
|
throw new Error("Model backend timed out.");
|
|
},
|
|
async streamReply() {
|
|
return "unused";
|
|
},
|
|
async listModels() {
|
|
return {models: [{name: "mistral:latest"}]};
|
|
},
|
|
async checkHealth() {
|
|
return {ok: false, checkedAt: new Date().toISOString(), error: "Model backend timed out."};
|
|
},
|
|
mapError(error) {
|
|
return {status: 504, body: {error: error.message, code: "MODEL_TIMEOUT"}};
|
|
}
|
|
};
|
|
const server = await startTestServer({modelService});
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "degraded", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "degraded", password: "password123"}, jar});
|
|
|
|
const persona = await request(server.baseUrl, "/personas", {
|
|
method: "POST",
|
|
body: {personaType: "assistant", name: "Guide", details: "Patient and direct"},
|
|
jar
|
|
});
|
|
|
|
const started = await request(server.baseUrl, "/roleplays/start", {
|
|
method: "POST",
|
|
body: {assistantPersonaId: persona.json.persona.id, model: "mistral:latest"},
|
|
jar
|
|
});
|
|
assert.equal(started.status, 200);
|
|
assert.equal(started.json.degraded, true);
|
|
assert.equal(started.json.generatedInitialMessage, false);
|
|
assert.ok(started.json.chat.id);
|
|
|
|
const session = await request(server.baseUrl, "/session", {jar});
|
|
assert.equal(session.json.user, "degraded");
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test.skip("legacy persona publishing and collection are retired", async () => {
|
|
const server = await startTestServer();
|
|
const creatorJar = new CookieJar();
|
|
const collectorJar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "creator", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "creator", password: "password123"}, jar: creatorJar});
|
|
|
|
const persona = await request(server.baseUrl, "/personas", {
|
|
method: "POST",
|
|
body: {personaType: "assistant", name: "Agent", details: "Helpful"},
|
|
jar: creatorJar
|
|
});
|
|
assert.equal(persona.status, 200);
|
|
|
|
const publish = await request(server.baseUrl, `/personas/${persona.json.persona.id}/publish`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar: creatorJar
|
|
});
|
|
assert.equal(publish.status, 200);
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "collector", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "collector", password: "password123"}, jar: collectorJar});
|
|
|
|
const market = await request(server.baseUrl, "/personas/market", {jar: collectorJar});
|
|
assert.equal(market.status, 200);
|
|
assert.equal(market.json.personas.length, 1);
|
|
|
|
const collect = await request(server.baseUrl, `/personas/market/${market.json.personas[0].id}/collect`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar: collectorJar
|
|
});
|
|
assert.equal(collect.status, 200);
|
|
assert.equal(collect.json.persona.name, "Agent");
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("chat search, organization, and workspace export work", async () => {
|
|
const server = await startTestServer();
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "searcher", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "searcher", password: "password123"}, jar});
|
|
|
|
const createChat = await request(server.baseUrl, "/chats", {
|
|
method: "POST",
|
|
body: {title: "Notes"},
|
|
jar
|
|
});
|
|
const chatId = createChat.json.chat.id;
|
|
|
|
await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId, message: "findable keyword", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
|
|
await request(server.baseUrl, "/chat", {
|
|
method: "POST",
|
|
body: {chatId, message: "another keyword passage", model: "mistral:latest"},
|
|
jar
|
|
});
|
|
|
|
const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar});
|
|
assert.equal(search.status, 200);
|
|
assert.equal(search.json.chats.length, 1);
|
|
assert.equal(search.json.chats[0].matched_content, "another keyword passage");
|
|
const messages = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
|
|
assert.equal(search.json.chats[0].matched_message_id, messages.json.messages.find(message => message.content === "another keyword passage").id);
|
|
|
|
const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, {
|
|
method: "PUT",
|
|
body: {folderName: "Research", isPinned: true, archived: false},
|
|
jar
|
|
});
|
|
assert.equal(organize.status, 200);
|
|
assert.equal(organize.json.chat.folder_name, "Research");
|
|
assert.equal(organize.json.chat.is_pinned, 1);
|
|
|
|
const exported = await request(server.baseUrl, "/exports/workspace", {jar});
|
|
assert.equal(exported.status, 200);
|
|
assert.equal(exported.json.workspace.chats.length, 1);
|
|
|
|
const invalidImport = await request(server.baseUrl, "/imports/workspace/preview", {
|
|
method: "POST",
|
|
body: {workspace: {personas: [{details: "Missing name"}]}},
|
|
jar
|
|
});
|
|
assert.equal(invalidImport.status, 400);
|
|
assert.match(invalidImport.json.errors[0], /missing a name/i);
|
|
|
|
const duplicateImport = await request(server.baseUrl, "/imports/workspace", {
|
|
method: "POST",
|
|
body: {workspace: exported.json.workspace},
|
|
jar
|
|
});
|
|
assert.equal(duplicateImport.status, 409);
|
|
assert.match(duplicateImport.json.error, /duplicate/i);
|
|
|
|
const workspace = {
|
|
personas: [{name: "Imported Guide", persona_type: "assistant", details: "Imported"}],
|
|
templates: [{name: "Imported Template", prompt_text: "Say hello"}],
|
|
chats: [{
|
|
title: "Imported Notes",
|
|
memories: [{fact: "The imported chat remembers the old station."}],
|
|
messages: [
|
|
{role: "user", content: "Imported question"},
|
|
{role: "assistant", content: "Imported answer"}
|
|
]
|
|
}]
|
|
};
|
|
const preview = await request(server.baseUrl, "/imports/workspace/preview", {
|
|
method: "POST",
|
|
body: {workspace},
|
|
jar
|
|
});
|
|
assert.equal(preview.status, 200);
|
|
assert.deepEqual(preview.json.preview, {personas: 1, chats: 1, messages: 2, memories: 1, templates: 1, duplicates: []});
|
|
|
|
const imported = await request(server.baseUrl, "/imports/workspace", {
|
|
method: "POST",
|
|
body: {workspace},
|
|
jar
|
|
});
|
|
assert.equal(imported.status, 200);
|
|
assert.deepEqual(imported.json.imported, {personas: 1, chats: 1, templates: 1});
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("archiving frees an active chat slot and restoring respects the limit", async () => {
|
|
const server = await startTestServer({config: {chatLimit: 1}});
|
|
try {
|
|
const jar = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "archiver", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "archiver", password: "password123"}, jar});
|
|
const first = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "First"}, jar});
|
|
assert.equal(first.status, 200);
|
|
assert.equal((await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Blocked"}, jar})).status, 400);
|
|
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
|
|
method: "PUT", body: {archived: true}, jar
|
|
})).status, 200);
|
|
const second = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Second"}, jar});
|
|
assert.equal(second.status, 200);
|
|
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
|
|
method: "PUT", body: {archived: false}, jar
|
|
})).status, 400);
|
|
await request(server.baseUrl, `/chats/${second.json.chat.id}/organization`, {method: "PUT", body: {archived: true}, jar});
|
|
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
|
|
method: "PUT", body: {archived: false}, jar
|
|
})).status, 200);
|
|
} finally { await server.close(); }
|
|
});
|
|
|
|
test.skip("legacy persona cloning, versions, and market feedback are retired", async () => {
|
|
const server = await startTestServer();
|
|
const jar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "maker", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "maker", password: "password123"}, jar});
|
|
|
|
const persona = await request(server.baseUrl, "/personas", {
|
|
method: "POST",
|
|
body: {personaType: "assistant", name: "Historian", details: "Original"},
|
|
jar
|
|
});
|
|
const personaId = persona.json.persona.id;
|
|
|
|
const clone = await request(server.baseUrl, `/personas/${personaId}/clone`, {method: "POST", body: {}, jar});
|
|
assert.equal(clone.status, 200);
|
|
assert.match(clone.json.persona.name, /copy/i);
|
|
|
|
await request(server.baseUrl, `/personas/${personaId}`, {
|
|
method: "PUT",
|
|
body: {personaType: "assistant", name: "Historian", details: "Updated"},
|
|
jar
|
|
});
|
|
const versions = await request(server.baseUrl, `/personas/${personaId}/versions`, {jar});
|
|
assert.equal(versions.status, 200);
|
|
assert.ok(versions.json.versions.length >= 1);
|
|
|
|
const restoreTarget = versions.json.versions[0];
|
|
const restored = await request(server.baseUrl, `/personas/${personaId}/versions/${restoreTarget.id}/restore`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar
|
|
});
|
|
assert.equal(restored.status, 200);
|
|
|
|
const publish = await request(server.baseUrl, `/personas/${personaId}/publish`, {
|
|
method: "POST",
|
|
body: {tags: ["mentor", "history"]},
|
|
jar
|
|
});
|
|
assert.equal(publish.status, 200);
|
|
const marketId = publish.json.persona.id;
|
|
|
|
const favorite = await request(server.baseUrl, `/personas/market/${marketId}/favorite`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar
|
|
});
|
|
assert.equal(favorite.status, 200);
|
|
assert.equal(favorite.json.favorite, true);
|
|
|
|
const rating = await request(server.baseUrl, `/personas/market/${marketId}/rate`, {
|
|
method: "POST",
|
|
body: {rating: 5},
|
|
jar
|
|
});
|
|
assert.equal(rating.status, 200);
|
|
assert.equal(rating.json.persona.rating_count, 1);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test.skip("legacy public persona market is retired", async () => {
|
|
const server = await startTestServer();
|
|
const creatorJar = new CookieJar();
|
|
const fanJar = new CookieJar();
|
|
const runnerJar = new CookieJar();
|
|
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "creator2", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "creator2", password: "password123"}, jar: creatorJar});
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "fan", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "fan", password: "password123"}, jar: fanJar});
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "runner", password: "password123"}});
|
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "runner", password: "password123"}, jar: runnerJar});
|
|
|
|
const assistantOne = await request(server.baseUrl, "/personas", {
|
|
method: "POST",
|
|
body: {personaType: "assistant", name: "Archivist", details: "Keeps records"},
|
|
jar: creatorJar
|
|
});
|
|
const assistantTwo = await request(server.baseUrl, "/personas", {
|
|
method: "POST",
|
|
body: {personaType: "assistant", name: "Pilot", details: "Fast paced"},
|
|
jar: creatorJar
|
|
});
|
|
const userPersona = await request(server.baseUrl, "/personas", {
|
|
method: "POST",
|
|
body: {personaType: "user", name: "Scout", details: "Observant"},
|
|
jar: creatorJar
|
|
});
|
|
|
|
const publishAssistantOne = await request(server.baseUrl, `/personas/${assistantOne.json.persona.id}/publish`, {
|
|
method: "POST",
|
|
body: {tags: ["lore"]},
|
|
jar: creatorJar
|
|
});
|
|
const publishAssistantTwo = await request(server.baseUrl, `/personas/${assistantTwo.json.persona.id}/publish`, {
|
|
method: "POST",
|
|
body: {tags: ["action"]},
|
|
jar: creatorJar
|
|
});
|
|
const publishUser = await request(server.baseUrl, `/personas/${userPersona.json.persona.id}/publish`, {
|
|
method: "POST",
|
|
body: {tags: ["identity"]},
|
|
jar: creatorJar
|
|
});
|
|
|
|
const archivistMarketId = publishAssistantOne.json.persona.id;
|
|
const pilotMarketId = publishAssistantTwo.json.persona.id;
|
|
const scoutMarketId = publishUser.json.persona.id;
|
|
|
|
await request(server.baseUrl, `/personas/market/${archivistMarketId}/favorite`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar: fanJar
|
|
});
|
|
await request(server.baseUrl, `/personas/market/${archivistMarketId}/favorite`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar: runnerJar
|
|
});
|
|
await request(server.baseUrl, `/personas/market/${archivistMarketId}/rate`, {
|
|
method: "POST",
|
|
body: {rating: 5},
|
|
jar: fanJar
|
|
});
|
|
await request(server.baseUrl, `/personas/market/${pilotMarketId}/collect`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar: fanJar
|
|
});
|
|
await request(server.baseUrl, `/personas/market/${pilotMarketId}/chat`, {
|
|
method: "POST",
|
|
body: {},
|
|
jar: runnerJar
|
|
});
|
|
|
|
const assistantsNewest = await request(server.baseUrl, "/personas/market?personaType=assistant&sort=newest", {jar: fanJar});
|
|
assert.equal(assistantsNewest.status, 200);
|
|
assert.deepEqual(assistantsNewest.json.personas.map((persona) => persona.name), ["Pilot", "Archivist"]);
|
|
|
|
const assistantsFavorited = await request(server.baseUrl, "/personas/market?personaType=assistant&sort=most_favorited", {jar: fanJar});
|
|
assert.equal(assistantsFavorited.status, 200);
|
|
assert.equal(assistantsFavorited.json.personas[0].name, "Archivist");
|
|
assert.equal(assistantsFavorited.json.personas[0].is_favorite, true);
|
|
assert.equal(assistantsFavorited.json.personas[1].is_favorite, false);
|
|
|
|
const assistantsPopular = await request(server.baseUrl, "/personas/market?personaType=assistant&sort=most_popular", {jar: fanJar});
|
|
assert.equal(assistantsPopular.status, 200);
|
|
assert.equal(assistantsPopular.json.personas[0].name, "Pilot");
|
|
|
|
const userOnly = await request(server.baseUrl, "/personas/market?personaType=user&sort=best", {jar: fanJar});
|
|
assert.equal(userOnly.status, 200);
|
|
assert.deepEqual(userOnly.json.personas.map((persona) => persona.name), ["Scout"]);
|
|
assert.equal(userOnly.json.personas[0].id, scoutMarketId);
|
|
|
|
await server.close();
|
|
});
|
|
|
|
test("public pages, workspace guards, and legacy chat links have distinct destinations", async () => {
|
|
const server = await startTestServer();
|
|
try {
|
|
for (const [url, marker] of [["/", "A space for every conversation."], ["/login", 'id="accountForm"'], ["/register", 'id="accountForm"']]) {
|
|
const response = await fetch(server.baseUrl + url);
|
|
assert.equal(response.status, 200);
|
|
const html = await response.text();
|
|
assert.ok(html.includes(marker));
|
|
assert.ok(!html.includes('id="chat"'));
|
|
}
|
|
const retiredMarket = await fetch(server.baseUrl + "/market", {redirect:"manual"});
|
|
assert.equal(retiredMarket.status, 302);
|
|
assert.equal(retiredMarket.headers.get("location"), "/login");
|
|
for (const url of ["/app", "/app/personas", "/app/settings", "/app/chats/42", "/app/chats/new?prompt=hello"]) {
|
|
const response = await fetch(server.baseUrl + url, {redirect: "manual"});
|
|
assert.equal(response.status, 302);
|
|
assert.equal(response.headers.get("location"), `/login?next=${encodeURIComponent(url)}`);
|
|
}
|
|
const legacy = await fetch(server.baseUrl + "/?chat=42", {redirect: "manual"});
|
|
assert.equal(legacy.headers.get("location"), "/app/chats/42");
|
|
const jar = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "newvisitor", password: "password123"}, jar});
|
|
const session = await request(server.baseUrl, "/session", {jar});
|
|
assert.equal(session.json.user, "newvisitor", "registration signs in immediately");
|
|
const home = await fetch(server.baseUrl + "/app", {headers: jar.headers()});
|
|
assert.match(await home.text(), /What would you like to do/);
|
|
const chat = await fetch(server.baseUrl + "/app/chats/new", {headers: jar.headers()});
|
|
const html = await chat.text();
|
|
assert.ok(html.includes('src="/js/script.js'));
|
|
assert.ok(!html.includes('id="loginForm"'));
|
|
assert.deepEqual((await request(server.baseUrl, "/chats", {jar})).json.chats, [], "home and chat pages do not create chats on GET");
|
|
await request(server.baseUrl, "/logout", {method: "POST", body: {}, jar});
|
|
assert.equal((await fetch(server.baseUrl + "/app", {headers: jar.headers(), redirect: "manual"})).status, 302);
|
|
} finally { await server.close(); }
|
|
});
|
|
|
|
test("legacy persona creation, publishing, and public discovery are unavailable", async () => {
|
|
const server = await startTestServer();
|
|
try {
|
|
const jar = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "publicmaker", password: "password123"}, jar});
|
|
assert.equal((await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Rainkeeper"}, jar})).status, 404);
|
|
assert.equal((await request(server.baseUrl, "/api/discover", {jar})).status, 404);
|
|
} finally { await server.close(); }
|
|
});
|
|
|
|
test("page headers share navigation and keep session markup private", async () => {
|
|
const server = await startTestServer();
|
|
try {
|
|
const jar = new CookieJar();
|
|
const username = '<maker&friend>';
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username, password: "password123"}, jar});
|
|
let sharedNavigation;
|
|
for (const route of ["/", "/app", "/app/chats/new", "/app/personas", "/app/settings", "/privacy.html", "/tos.html"]) {
|
|
const response = await fetch(server.baseUrl + route, {headers: jar.headers()});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("cache-control"), "private, no-store");
|
|
const html = await response.text();
|
|
assert.equal((html.match(/class="shell-header"/g) || []).length, 1);
|
|
assert.ok(html.includes('<maker&friend>'));
|
|
assert.ok(!html.includes(username), "account names must be escaped in header markup");
|
|
const navigation = html.match(/<nav class="shell-nav"[^>]*>(.*?)<\/nav>/s)[1].replace(/ aria-current="page"/g, "");
|
|
if (sharedNavigation) assert.equal(navigation, sharedNavigation);
|
|
sharedNavigation = navigation;
|
|
}
|
|
const guest = await fetch(server.baseUrl + "/");
|
|
const html = await guest.text();
|
|
assert.ok(!html.includes('<maker&friend>'));
|
|
assert.ok(html.includes('href="/login"'));
|
|
} finally { await server.close(); }
|
|
});
|
|
|
|
test("frontend scripts and styles revalidate across deployments", async () => {
|
|
const server = await startTestServer({config: {staticMaxAge: "1h"}});
|
|
try {
|
|
for (const asset of ["/js/script.js?v=20260909-account-controls", "/js/dom.js?v=20260909-account-controls", "/css/pages.css?v=20260909-account-controls"]) {
|
|
const response = await fetch(server.baseUrl + asset);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("cache-control"), "no-cache");
|
|
const etag = response.headers.get("etag");
|
|
assert.ok(etag);
|
|
await response.text();
|
|
}
|
|
|
|
for (const entrypoint of ["/js/script.js", "/js/settings.js", "/js/market.js"]) {
|
|
const response = await fetch(server.baseUrl + entrypoint);
|
|
assert.equal(response.status, 200);
|
|
assert.doesNotMatch(await response.text(), /from\s+["']\.\/js\//, `${entrypoint} must not resolve dependencies below /js/js`);
|
|
}
|
|
} finally { await server.close(); }
|
|
});
|
|
|
|
test("every served page references existing local assets", async () => {
|
|
const server = await startTestServer();
|
|
try {
|
|
const assetPattern = /(?:src|href)="([^"#]+\.(?:css|js|svg)(?:\?[^"#]*)?)"/g;
|
|
const assertPageAssets = async (route, headers = {}) => {
|
|
const response = await fetch(server.baseUrl + route, {headers});
|
|
assert.equal(response.status, 200, `${route} must render`);
|
|
const html = await response.text();
|
|
for (const [, reference] of html.matchAll(assetPattern)) {
|
|
if (/^https?:\/\//.test(reference)) continue;
|
|
const assetUrl = new URL(reference, server.baseUrl + route);
|
|
const assetResponse = await fetch(assetUrl, {headers});
|
|
assert.equal(assetResponse.status, 200, `${route} references missing asset ${assetUrl.pathname}`);
|
|
}
|
|
};
|
|
|
|
for (const route of ["/", "/login", "/register", "/market", "/privacy.html", "/tos.html"]) {
|
|
await assertPageAssets(route);
|
|
}
|
|
|
|
const jar = new CookieJar();
|
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "assetchecker", password: "password123"}, jar});
|
|
for (const route of ["/app", "/app/chats/new", "/app/personas", "/app/settings", "/market"]) {
|
|
await assertPageAssets(route, jar.headers());
|
|
}
|
|
} 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.skip("legacy character reply guards are retired", 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.");
|
|
});
|