Files
Schulcloud-MCP/scripts/smoke.mjs
MechaCat02 48c6cf39d5 Lay the notes screen out like a notes app
The list of notes and the note being written are one screen now, two
panes: the notes on the left, the open one on the right, side by side
where there is room and one at a time on a phone, where the back button
returns to the list.

The search box moved into the top of that list, and its results *are*
the list — searching is a way of finding a note, not a separate place to
be, and a tab for it was a tab too many. Emptying the box brings the
whole list back. Opening a hit opens that day at the lesson that
matched, rather than at the top of a day with six of them.

A row has to say what the note holds, so the listing carries it: the
subjects a day covers, how many lessons, and the first line actually
written in it. One request for the whole list rather than one per note.

`plainText` is now one rule in one place for wherever a note is shown
rather than edited — the search snippet and the list row both went
through their own half-copy of it, and the row's copy rendered a table
as `| | |` and left `_Fazit_` wearing its markers. It strips one leading
marker, not each in turn, because `## 1. Deutsch` keeps its lesson
number and the list rule was eating it.

Driven in Firefox at both widths: the list, the search, opening a hit,
the jump to the lesson, and the phone's list-then-note. 390 unit tests,
116/117 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:22:47 +02:00

1006 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* End-to-end smoke test: starts the HTTP server, connects a real MCP client
* over Streamable HTTP, and exercises every tool against the live instance.
*
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
* Read-only — it never writes to Schulcloud.
*/
import { randomBytes } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { loadConfig } from '../dist/config.js';
import { createHttpApp } from '../dist/http/server.js';
import { closeServices, createServices } from '../dist/services.js';
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
process.env.MCP_AUTH_TOKEN = TOKEN;
const PATH_SECRET = randomBytes(32).toString('hex');
process.env.MCP_PATH_SECRET = PATH_SECRET;
const CONNECTOR_TOKEN = randomBytes(32).toString('hex');
process.env.MCP_CONNECTOR_TOKEN = CONNECTOR_TOKEN;
// A state directory of its own, so the run can neither read nor leave a saved token.
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
process.env.STATE_DIR = STATE_DIR;
// And a notes directory of its own. The note tools are the only ones here that
// write, so the run must not be able to touch real notes — and pointing them at
// an empty directory is also the only way to assert the empty case.
const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-'));
process.env.NOTES_DIR = NOTES_DIR;
// A password of its own, so the web app is exercised and the run can never be
// opened with one from the environment.
const WEB_PASSWORD = `smoke-${randomBytes(16).toString('hex')}`;
process.env.WEB_PASSWORD = WEB_PASSWORD;
// The app is bound by this script on an ephemeral port, so config.port is unused.
const config = loadConfig();
// Wire the real services so the index-backed tools are exercised when
// DATABASE_URL is set, exactly as the deployed server does.
const services = await createServices(config);
const app = createHttpApp(config, services);
const httpServer = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
});
const { port } = httpServer.address();
const base = `http://127.0.0.1:${port}/mcp`;
let failures = 0;
const results = [];
function check(name, ok, detail) {
results.push({ name, ok, detail });
if (!ok) failures++;
console.log(`${ok ? ' PASS' : ' FAIL'} ${name}${detail ? `${detail}` : ''}`);
}
// --- auth gate ---------------------------------------------------------
console.log('\n== auth ==');
{
const res = await fetch(base, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
});
check('rejects request with no token', res.status === 401, `got ${res.status}`);
}
{
const res = await fetch(base, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer wrong-token' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
});
check('rejects wrong token', res.status === 401, `got ${res.status}`);
}
{
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
check('healthz is open and ok', res.status === 200);
}
// --- connect -----------------------------------------------------------
console.log('\n== protocol ==');
const client = new Client({ name: 'smoke', version: '0' }, { capabilities: {} });
await client.connect(
new StreamableHTTPClientTransport(new URL(base), {
requestInit: { headers: { authorization: `Bearer ${TOKEN}` } },
}),
);
check('client connected with valid token', true);
const { tools } = await client.listTools();
const names = tools.map((t) => t.name).sort();
check('tools listed', tools.length > 0, names.join(', '));
check(
'every tool has a description and schema',
tools.every((t) => t.description && t.inputSchema),
);
const call = async (name, args = {}) => {
const res = await client.callTool({ name, arguments: args });
const text = res.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
return { res, text, isError: res.isError === true };
};
// --- tools against the live instance -----------------------------------
console.log('\n== live tools ==');
const who = await call('whoami');
check('whoami', !who.isError && /School:/.test(who.text), who.text.split('\n')[0]);
const courses = await call('list_courses', { limit: 100 });
check('list_courses', !courses.isError && /Courses \(/.test(courses.text));
const courseIds = [...courses.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]);
check('list_courses returned usable ids', courseIds.length > 0, `${courseIds.length} courses`);
const active = await call('list_courses', { activeOnly: true });
check('list_courses activeOnly', !active.isError);
const dash = await call('get_dashboard');
check('get_dashboard', !dash.isError);
const tasks = await call('list_tasks', { scope: 'open' });
check('list_tasks open', !tasks.isError);
const taskId = tasks.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
check('list_tasks finished', !(await call('list_tasks', { scope: 'finished' })).isError);
check('list_news', !(await call('list_news')).isError);
// Walk courses collecting boards and lessons, to exercise the whole chain.
// Every board id is collected rather than the first one taken: an unpublished
// board is listed on the course page with its title but 403s when opened, so
// "the first board in the course" is not reliably one that can be read.
let boardId, fileId, lessonId, courseWithBoard;
const boardIds = [];
const topicsWithTasks = [];
for (const id of courseIds) {
const course = await call('get_course', { courseId: id });
if (course.isError) continue;
courseWithBoard ??= id;
const boardsSection = course.text.match(/### Boards[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
for (const m of boardsSection.matchAll(/\(`([0-9a-f]{24})`\)/g)) boardIds.push(m[1]);
lessonId ??= course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
// A topic that reports tasks is the interesting one: those tasks are not task
// elements on the course page and carry no id in the API.
const topics = course.text.match(/### Topics[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
for (const m of topics.matchAll(/\(`([0-9a-f]{24})`\) — (\d+) task/g)) topicsWithTasks.push(m[1]);
}
check('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
if (courseWithBoard) {
// The v3 course projection carries none of this; it comes from
// /api/v1/courses, one of the three legacy routes the deployment still
// publishes. Absent is acceptable — the route may be refused — but a course
// that reports none of description, teachers or schedule means the legacy
// lookup stopped working, which is worth knowing.
const course = await call('get_course', { courseId: courseWithBoard });
const enriched = /\*\*Taught by:\*\*|\*\*Members:\*\*|\*\*Weekly schedule:\*\*/.test(course.text);
check('get_course reports course metadata beyond the v3 projection', enriched || true,
enriched ? 'description/teachers/schedule present' : 'legacy course lookup returned nothing');
}
check('found a column board', boardIds.length > 0, `${boardIds.length} board(s)`);
let board, drafts = 0;
for (const id of boardIds) {
const attempt = await call('get_board', { boardId: id });
if (!attempt.isError) {
board = attempt;
boardId = id;
break;
}
if (/draft/i.test(attempt.text)) drafts++;
}
if (boardIds.length > 0) {
check(
'get_board',
Boolean(board) && /Board id:/.test(board.text),
boardId ? `opened ${boardId}${drafts ? `, skipped ${drafts} unpublished` : ''}` : 'no board could be opened',
);
}
if (board) {
// Pads carry real content and the board API returns them empty, so the text
// comes from Etherpad itself. Either it was read, or the tool says plainly
// that it was not — it must never claim the contents cannot be had.
const padLine = board.text.match(/- Collaborative text document `[0-9a-f]{24}`[^\n]*/)?.[0];
check(
'collaborative text documents report contents or say they are empty',
padLine === undefined || /:$|\(empty, or its contents could not be read\)/.test(padLine),
padLine ?? 'no pad on this board',
);
fileId = board.text.match(/File: \*\*[^*]+\*\* \(`([0-9a-f]{24})`/)?.[1];
check('get_board resolved attachments', Boolean(fileId), fileId ?? 'no files on this board');
check('get_board includeFiles=false', !(await call('get_board', { boardId, includeFiles: false })).isError);
}
if (lessonId) check('get_lesson', !(await call('get_lesson', { lessonId })).isError, lessonId);
// A task attached to a topic is reachable only if its id was recovered from the
// topic page: the API's topic-task projection has no id field, and such a task
// is on no course page and drops out of both task lists once it is past due.
if (topicsWithTasks.length > 0) {
const lesson = await call('get_lesson', { lessonId: topicsWithTasks[0] });
const topicTaskId = lesson.text.match(/### Tasks in this lesson[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
check('get_lesson lists a topic\'s tasks with ids', Boolean(topicTaskId), topicTaskId ?? lesson.text.slice(0, 90));
if (topicTaskId) {
const viaTopic = await call('get_task', { taskId: topicTaskId });
check('get_task opens a task found only through a topic', !viaTopic.isError && /Task id:/.test(viaTopic.text));
}
} else {
check('get_lesson lists a topic\'s tasks with ids', true, 'no topic on this account reports tasks — nothing to check');
}
if (taskId) {
const task = await call('get_task', { taskId });
check('get_task', !task.isError && /Task id:/.test(task.text), taskId);
}
// If the board had no file, fall back to hunting one on a task.
if (!fileId && taskId) {
const listed = await call('list_files', { parentType: 'tasks', parentId: taskId });
fileId = listed.text.match(/\(`([0-9a-f]{24})`/)?.[1];
}
if (fileId) {
const dl = await call('download_file', { fileId });
check('download_file extracts content', !dl.isError && /## /.test(dl.text), dl.text.split('\n').slice(0, 1).join(''));
const extracted = /extracted \d+ characters|returned inline|no text extractor/.test(dl.text);
check('download_file reported an extraction outcome', extracted);
const raw = await call('download_file', { fileId, raw: true });
check('download_file raw=true', !raw.isError && /Base64/.test(raw.text));
} else {
check('download_file', false, 'no file id found to test with');
}
console.log('\n== classes and groups ==');
{
// Classes are the only place membership is visible: courses report neither
// their teachers nor their students, and a student may not resolve either
// by user id. An account in no class is a legitimate answer.
const classes = await call('list_classes', { includeGroups: true });
check('list_classes responds', !classes.isError, classes.text.split('\n')[0]);
check(
'list_classes names teachers or says there are none',
!classes.isError && (/taught by/.test(classes.text) || /not in any class/.test(classes.text) || /Groups \(/.test(classes.text)),
);
}
console.log('\n== file manager (Dateien) ==');
// A separate store from files-storage, with a real folder tree. The account may
// hold nothing there, so the checks find something rather than assume it —
// but on an account whose courses do keep files, "nothing found" is a failure.
{
const root = await call('fs_list', { path: '/' });
check('fs_list / names the four areas', !root.isError && /\/courses\//.test(root.text) && /\/shared\//.test(root.text));
const owners = await call('fs_list', { path: '/courses' });
check('fs_list /courses lists course folders', !owners.isError, owners.text.split('\n').find((line) => /course\(s\)/.test(line)));
const ownerIds = [...owners.text.matchAll(/\*\*.*?\/\*\* \(`([0-9a-f]{24})`\)/g)].map((m) => m[1]);
// The first course whose file area holds a file, at most ten listings in.
let coursePath;
let filePath;
let fileName;
let courseWithFiles;
for (const id of ownerIds.slice(0, 10)) {
const tree = await call('fs_tree', { path: `/courses/${id}`, depth: 3, maxFolders: 15 });
if (tree.isError) continue;
const heading = tree.text.match(/^## (\/courses\/.+?) — /m)?.[1];
const line = tree.text.match(/^\s*([^\n]+?\.(?:pdf|docx|txt|png|jpg|xlsx|pptx|odt)) \(/im);
if (heading && line) {
coursePath = heading;
courseWithFiles = id;
fileName = line[1].trim();
// The tree gives names; fs_find recovers the full path to read.
const found = await call('fs_find', { name: fileName, path: `/courses/${id}`, type: 'file', maxFolders: 15 });
filePath = found.text.match(/^- (\/courses\/.+?) — /m)?.[1];
break;
}
}
if (coursePath && filePath) {
check('fs_tree shows a course file area', true, coursePath);
check('fs_find finds a file by name', Boolean(filePath), filePath);
const read = await call('fs_read', { path: filePath, maxChars: 800 });
check(
'fs_read fetches a file-manager file and reports an extraction outcome',
!read.isError && /File id:/.test(read.text),
read.text.split('\n').find((l) => /extracted|image|no extractable|image-only|Word|PDF/.test(l)) ?? fileName,
);
// A course whose teachers use only the file manager used to read as an
// empty course page; get_course must now point at the files.
const page = await call('get_course', { courseId: courseWithFiles });
check('get_course points at the course files', !page.isError && /Course files \(Kurs-Dateien\)/.test(page.text));
} else {
check('fs_tree shows a course file area', true, 'no course among the first ten keeps files — nothing to check');
}
const missing = await call('fs_list', { path: '/courses/__no such course__' });
check('fs_list on a bad path is a tool error that says what is there', missing.isError && /No "|not a file area|Did you mean/.test(missing.text));
const shared = await call('fs_list', { path: '/Geteilte Dateien' });
check('fs_list accepts the German area name', !shared.isError, shared.text.split('\n')[0]);
}
console.log('\n== rooms ==');
// Collected here and used by the H5P section below: a room's boards are where
// this account's quiz lives.
const roomBoardIds = [];
// Rooms ("Räume") are a separate space from courses. An account in none is
// normal — and is exactly the state that hid this whole feature — so the check
// is that the tools answer sensibly either way, not that rooms exist.
{
const listed = await call('list_rooms');
check('list_rooms responds', !listed.isError, listed.text.split('\n')[0]);
const roomId = listed.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
if (roomId) {
const room = await call('get_room', { roomId });
check('get_room opens a room', !room.isError && /Room id:/.test(room.text), roomId);
roomBoardIds.push(...[...room.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]).slice(0, 8));
const roomBoardId = room.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
if (roomBoardId) {
const board = await call('get_board', { boardId: roomBoardId });
check('a room board opens with get_board', !board.isError && /in room/.test(board.text), roomBoardId);
} else {
check('a room board opens with get_board', true, 'the room has no boards');
}
} else {
check('get_room opens a room', true, 'this account is in no rooms — nothing to open');
}
}
console.log('\n== H5P exercises ==');
// A quiz is an H5P element on a board, and Schulcloud has nothing else like it.
// Which boards hold one is data, so the id is discovered by scanning the boards
// this account can see — course boards first, then the rooms', which is where
// this account's quiz actually lives.
{
let contentId;
let foundOn;
const boardsToScan = [...boardIds, ...roomBoardIds];
for (const boardId of boardsToScan) {
const board = await call('get_board', { boardId, includeFiles: false });
const match = board.text.match(/content `([0-9a-f]{24})` — all of it with get_h5p/);
if (match) {
contentId = match[1];
foundOn = boardId;
break;
}
}
if (contentId) {
check('get_board names an H5P exercise and its question count', true, `board ${foundOn}, content ${contentId}`);
const full = await call('get_h5p', { contentId });
check(
'get_h5p returns every question at once, with the solutions marked',
!full.isError && /^### 1\. /m.test(full.text) && /✔/.test(full.text),
full.text.split('\n')[0],
);
const withheld = await call('get_h5p', { contentId, solutions: false });
check(
'solutions=false keeps the options and drops the answers',
!withheld.isError && /^### 1\. /m.test(withheld.text) && !/✔/.test(withheld.text) && /Solutions withheld/.test(withheld.text),
withheld.text.split('\n')[0],
);
} else {
check('get_board names an H5P exercise and its question count', true, 'no board here holds one');
}
const missing = await call('get_h5p', { contentId: '000000000000000000000000' });
check('an unknown content id is a tool error naming the id', missing.isError && /404/.test(missing.text), missing.text.split('\n')[0]);
}
console.log('\n== resources and prompts ==');
// Courses and rooms are resources a person attaches; the prompts are German
// requests picked from a menu. Both reuse the tools' reads, so what is checked
// here is the protocol surface, and the argument handling Claude Code forces
// on prompts: it splits on whitespace, so words arrive joined with "_".
{
const { resources } = await client.listResources();
const courseResources = resources.filter((r) => r.uri.startsWith('schulcloud://courses/'));
const roomResources = resources.filter((r) => r.uri.startsWith('schulcloud://rooms/'));
check('resources/list offers every course', courseResources.length === courseIds.length, `${courseResources.length} of ${courseIds.length}`);
const roomsListed = await call('list_rooms');
const roomCount = [...roomsListed.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].length;
check('resources/list offers every room', roomResources.length === roomCount, `${roomResources.length} of ${roomCount}`);
// Claude Code's @ autocomplete shows the description instead of the name.
check(
'every resource description carries its name',
resources.length > 0 && resources.every((r) => r.name && r.mimeType === 'text/markdown' && r.description?.endsWith(r.name)),
);
const { resourceTemplates } = await client.listResourceTemplates();
check(
'resource templates for courses and rooms',
resourceTemplates.map((t) => t.uriTemplate).sort().join(' ') === 'schulcloud://courses/{courseId} schulcloud://rooms/{roomId}',
);
if (courseWithBoard) {
const read = await client.readResource({ uri: `schulcloud://courses/${courseWithBoard}` });
const viaTool = await call('get_course', { courseId: courseWithBoard });
check(
'a course resource reads exactly as get_course',
read.contents[0]?.mimeType === 'text/markdown' && read.contents[0]?.text === viaTool.text,
read.contents[0]?.text?.split('\n')[0],
);
}
if (roomResources[0]) {
const room = await client.readResource({ uri: roomResources[0].uri });
check('a room resource opens', /Room id:/.test(room.contents[0]?.text ?? ''), roomResources[0].uri);
} else {
check('a room resource opens', true, 'this account is in no rooms — nothing to open');
}
const unknownResource = await client
.readResource({ uri: 'schulcloud://courses/000000000000000000000000' })
.then(() => undefined, (error) => error);
check(
'an unknown course resource is a protocol error, not a crash',
unknownResource !== undefined,
unknownResource?.message?.split('\n')[0],
);
const { prompts } = await client.listPrompts();
check(
'prompts listed',
['pruefungsvorbereitung', 'zusammenfassung'].every((name) => prompts.some((p) => p.name === name)),
prompts.map((p) => p.name).join(', '),
);
const courseTitle = courseWithBoard
? courses.text.match(new RegExp(`- \\*\\*(.+?)\\*\\* \\(\`${courseWithBoard}\`\\)`))?.[1]
: undefined;
if (courseWithBoard && courseTitle) {
const summary = await client.getPrompt({
name: 'zusammenfassung',
arguments: { kurs: courseTitle.split(/\s+/).join('_') },
});
const [embedded, instructions] = summary.messages;
check(
'zusammenfassung finds a course by its joined name and embeds its overview',
embedded?.content.type === 'resource' && embedded.content.resource.uri === `schulcloud://courses/${courseWithBoard}`,
courseTitle,
);
check(
'zusammenfassung asks in German for the named course',
instructions?.content.type === 'text' &&
instructions.content.text.includes(`${courseTitle}`) &&
/Antworte auf Deutsch/.test(instructions.content.text),
);
const exam = await client.getPrompt({
name: 'pruefungsvorbereitung',
arguments: { kurs: courseWithBoard, thema: 'Grundlagen_der_Programmierung', datum: '2026-10-02' },
});
const examText = exam.messages[1]?.content.type === 'text' ? exam.messages[1].content.text : '';
check(
'pruefungsvorbereitung takes an id, a joined topic and a date',
/Thema der Prüfung: Grundlagen der Programmierung/.test(examText) && /Prüfungstermin: 2026-10-02/.test(examText),
);
}
const refused = await client
.getPrompt({ name: 'zusammenfassung', arguments: { kurs: 'kein_solcher_kurs_xyz' } })
.then(() => undefined, (error) => error);
check(
'a prompt for an unknown course is refused, naming what exists',
/Kein Kurs und kein Raum passt/.test(refused?.message ?? ''),
refused?.message?.slice(0, 100),
);
}
console.log('\n== search ==');
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] });
check(`search "${searchTerm}" (fresh + scoped, bypassing any index)`, !search.isError, search.text.split('\n')[0]);
const freshAll = await call('search', { query: searchTerm, fresh: true });
check('unscoped fresh search returns without timing out', !freshAll.isError, freshAll.text.split('\n')[0]);
check('search scoped to one course', !(await call('search', { query: 'a b', courseId: courseIds[0], fresh: true })).isError);
console.log('\n== submissions ==');
if (taskId) {
const task = await call('get_task', { taskId });
check('get_task still works with submission lookup', !task.isError);
const subs = await call('list_submissions', { courseId: courseIds[0], scope: 'all' });
check('list_submissions scoped to a course', !subs.isError, subs.text.split('\n')[0]);
const all = await call('list_submissions', { limit: 5 });
// Feedback and submitter names are the two things the status endpoint cannot
// give: both come from the task's rendered page.
const withFeedback = await call('list_submissions', { scope: 'all', includeFeedback: true, limit: 5 });
check('list_submissions includeFeedback responds', !withFeedback.isError, withFeedback.text.split('\n')[0]);
check(
'list_submissions no longer defers feedback to get_task when asked for it',
!withFeedback.isError && !/pass includeFeedback/.test(withFeedback.text),
);
check('list_submissions unscoped', !all.isError, all.text.split('\n')[0]);
}
console.log('\n== own notes ==');
// The user's own lesson notes: the one store here that is neither Schulcloud's
// nor WebUntis', and the one thing this server can write. Every check runs
// against the throwaway NOTES_DIR above.
{
const noteTools = names.filter((name) => ['list_notes', 'get_note', 'add_note'].includes(name));
check('the note tools are offered when NOTES_DIR is set', noteTools.length === 3, noteTools.join(', ') || 'none');
const empty = await call('list_notes');
check(
'an empty notes directory is explained, not reported as a failure',
!empty.isError && /no notes yet/i.test(empty.text),
empty.text.split('\n')[0],
);
const added = await call('add_note', {
title: 'Erörterung',
text: 'Dreischritt: These, Argument, Fazit. Gegenargument nicht vergessen.',
subject: 'Deutsch',
date: '2026-09-15',
tags: ['klausur'],
});
check('add_note saves a note', !added.isError && /Deutsch\/2026-09-15 Erörterung\.md/.test(added.text), added.text.split('\n')[0]);
const listed = await call('list_notes', { subject: 'deut' });
check('list_notes finds it by a fragment of the subject', !listed.isError && /Erörterung/.test(listed.text), listed.text.split('\n')[0]);
const one = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
check('get_note returns the note in full', !one.isError && /Gegenargument/.test(one.text), one.text.split('\n')[0]);
const appended = await call('add_note', {
title: 'Nachtrag',
text: 'Beispiel: Handyverbot an Schulen.',
subject: 'Deutsch',
date: '2026-09-15',
append: true,
});
const afterAppend = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
check(
'append adds to the same note rather than starting a second one',
!appended.isError && /Handyverbot/.test(afterAppend.text) && /Gegenargument/.test(afterAppend.text),
appended.text.split('\n')[0],
);
const missing = await call('get_note', { path: 'Deutsch/gibt-es-nicht.md' });
check('a missing note is a tool error naming the path', missing.isError && /no note at/i.test(missing.text), missing.text.split('\n')[0]);
// The title reaches the filesystem, so it is untrusted input at exactly the
// boundary core/paths.ts exists to guard.
const hostile = await call('add_note', { title: '../../../etc/passwd', text: 'x', subject: '../..', date: '2026-09-15' });
// The title is echoed back verbatim — it is the user's own — so the check is
// on the path the note actually landed at, in backticks.
const hostilePath = hostile.text.match(/`([^`]+)`/)?.[1] ?? '';
check(
'a note cannot be written outside the notes directory',
!hostile.isError && hostilePath.length > 0 && !hostilePath.split('/').includes('..'),
hostilePath,
);
const fresh = await call('search', { query: 'Gegenargument', fresh: true, courseId: courseIds[0] });
check(
'a live search reads the notes too, so it agrees with the index',
!fresh.isError && /Gegenargument/.test(fresh.text),
fresh.text.split('\n')[0],
);
}
console.log('\n== index tools ==');
// These degrade gracefully without DATABASE_URL, so assert on either outcome
// rather than requiring a database for the smoke run to be meaningful.
const hasIndex = Boolean(process.env.DATABASE_URL);
const status = await call('index_status');
check(
`index_status responds (${hasIndex ? 'with index' : 'no index configured'})`,
hasIndex ? !status.isError : status.isError && /not configured/.test(status.text),
status.text.split('\n')[0],
);
if (hasIndex) {
// Populate before asking what changed: a brand-new index holds no generations
// to diff, and what_changed rightly refuses rather than inventing a baseline.
const refreshed = await call('refresh_index', { courseId: courseIds[0], force: true });
check('refresh_index re-crawls one course', !refreshed.isError, refreshed.text.split('\n')[0]);
}
const changed = await call('what_changed', { since: '2026-01-01' });
check('what_changed responds', hasIndex ? !changed.isError : changed.isError, changed.text.split('\n')[0]);
if (hasIndex) {
// Both the hit and the no-hit answer say when the index was last refreshed;
// a live crawl (fresh=true) says nothing of the sort. That is what separates
// "answered from the index" from "answered by crawling", whatever the term
// happens to match in this account's data.
const indexed = await call('search', { query: searchTerm });
check(
'search uses the index and states freshness',
!indexed.isError && /refreshed/i.test(indexed.text),
indexed.text.split('\n')[0],
);
// Notes are only picked up by a *full* crawl, and a full crawl walks every
// course and every file-manager folder — minutes, not seconds. Indexing them
// is covered by test/store.test.ts against a real Postgres instead; what the
// smoke checks here is that the kind filter exists and answers.
const byKind = await call('search', { query: 'Gegenargument', kinds: ['note'] });
check('search accepts the note kind', !byKind.isError, byKind.text.split('\n')[0]);
}
console.log('\n== WebUntis ==');
// The timetable lives in WebUntis, not in Schulcloud, and the tools only exist
// when a key is configured. Both halves are asserted: with a key the live
// answers have to be shaped right, without one the tools must not be offered at
// all — a tool that can only fail is worse than a missing one.
const hasUntis = Boolean(config.untis);
{
const untisTools = names.filter((name) => name.startsWith('untis_'));
check(
`untis_* tools are offered only with a key (${hasUntis ? 'configured' : 'not configured'})`,
hasUntis
? untisTools.join(' ') === 'untis_homework untis_lesson_topics untis_timetable'
: untisTools.length === 0,
untisTools.join(', ') || 'none',
);
}
if (hasUntis) {
const identity = await call('whoami');
check(
'whoami reports the WebUntis identity',
/- WebUntis: /.test(identity.text) && !/not reachable/.test(identity.text),
identity.text.split('\n').find((line) => line.startsWith('- WebUntis')),
);
const today = await call('untis_timetable');
check(
'untis_timetable answers for today',
!today.isError && /^## \p{L}+, \d{2}\.\d{2}\.\d{4}/mu.test(today.text),
today.text.split('\n')[0],
);
// A four-week window: either it holds lessons or the days say why not. The
// school year has gaps — holidays, and the weeks this account spends at work —
// so requiring lessons would make the run fail on a correct answer.
const start = new Date().toISOString().slice(0, 10);
const end = new Date(Date.now() + 28 * 86_400_000).toISOString().slice(0, 10);
const month = await call('untis_timetable', { from: start, to: end });
const lessonLines = [...month.text.matchAll(/^- \d{2}:\d{2}\d{2}:\d{2} /gm)].length;
check(
'untis_timetable answers for a four-week range',
!month.isError && (lessonLines > 0 || /No lessons/.test(month.text)),
`${lessonLines} lesson line(s)`,
);
const changes = await call('untis_timetable', { from: start, to: end, changesOnly: true });
check(
'untis_timetable lists changes only',
!changes.isError && (/\*\*(Entfall|Vertretung)\*\*/.test(changes.text) || /Nothing cancelled or changed/.test(changes.text)),
changes.text.split('\n')[0],
);
const homework = await call('untis_homework', { from: '2026-08-01', to: end });
check('untis_homework answers', !homework.isError, homework.text.split('\n')[0]);
// Every lesson line carries its period id, which is the handle for the class
// register. Without one there is nothing to ask about, so the check follows
// the timetable's own output rather than a hard-coded id.
const periodId = Number(month.text.match(/`(\d{5,})`/)?.[1]);
if (Number.isInteger(periodId)) {
const topics = await call('untis_lesson_topics', { periodId, limit: 3 });
check(
'untis_lesson_topics reads what previous lessons covered',
!topics.isError && (/Unterrichtsinhalte/.test(topics.text) || /No lesson contents/.test(topics.text)),
topics.text.split('\n')[0],
);
} else {
check('untis_lesson_topics reads what previous lessons covered', true, 'skipped: no lesson in the window');
}
// The subject form is the one that reconstructs a term without a period id.
const subject = month.text.match(/\*\*([A-Za-zÄÖÜäöü0-9]{2,10})\*\*/)?.[1];
if (subject) {
const bySubject = await call('untis_lesson_topics', { subject, from: '2026-06-01', to: end, limit: 5 });
check(
`untis_lesson_topics reads a whole term by subject ("${subject}")`,
!bySubject.isError && (/Unterricht „/.test(bySubject.text) || /No lessons of|nothing was recorded/.test(bySubject.text)),
bySubject.text.split('\n')[0],
);
} else {
check('untis_lesson_topics reads a whole term by subject', true, 'skipped: no subject in the window');
}
const neither = await call('untis_lesson_topics', {});
check(
'untis_lesson_topics asks for a subject or a period, not neither',
neither.isError && /subject/.test(neither.text),
neither.text.split('\n')[0],
);
const unreal = await call('untis_timetable', { from: '2026-02-30' });
check(
'a date that does not exist is refused rather than rolled over',
unreal.isError && /Not a date in the calendar/.test(unreal.text),
unreal.text.split('\n')[0],
);
const huge = await call('untis_timetable', { from: start, to: '2027-06-30' });
check(
'an unreasonably long range is refused before it is fetched',
huge.isError && /at most \d+ at a time/.test(huge.text),
huge.text.split('\n')[0],
);
const prep = await client.getPrompt({ name: 'tagesvorbereitung', arguments: { tag: '2026-09-21' } });
check(
'tagesvorbereitung attaches the timetable and asks in German',
prep.messages.length === 2 &&
/## Montag, 21\.09\.2026/.test(prep.messages[0].content.text) &&
/Bereite mich auf den Schultag/.test(prep.messages[1].content.text),
prep.description,
);
}
console.log('\n== web app ==');
// The one surface here meant for a person rather than a program: a login, the
// day's notes, and the settings page that replaces the Schulcloud token.
{
const root = `http://127.0.0.1:${port}`;
const jsonHeaders = { 'content-type': 'application/json' };
const shell = await fetch(`${root}/app/`);
const shellText = await shell.text();
check(
'the app shell is served with a strict content security policy',
shell.ok &&
/text\/html/.test(shell.headers.get('content-type') ?? '') &&
/default-src 'none'/.test(shell.headers.get('content-security-policy') ?? '') &&
/no-store/.test(shell.headers.get('cache-control') ?? ''),
shell.headers.get('content-security-policy')?.slice(0, 40),
);
check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN));
// Every file the shell asks for, including the two modules the editor is
// made of: a missing one leaves a page that loads and cannot type.
const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest'];
const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`)));
check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' '));
check(
'the editor\'s modules are served as JavaScript',
assets
.filter((_, index) => assetNames[index].endsWith('.js'))
.every((response) => /javascript/.test(response.headers.get('content-type') ?? '')),
assets.map((r) => r.headers.get('content-type')).join(' | '),
);
check(
'the shell loads the app as a module, so its imports resolve',
/<script type="module" src="app\.js">/.test(shellText) &&
shellText.includes('data-command="bold"') &&
shellText.includes('id="search-form"') &&
// The note list and the editor are one screen now, not two tabs.
shellText.includes('id="rail-list"'),
);
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
check('session says "not logged in" rather than failing', anonymousSession.authenticated === false);
const closed = await fetch(`${root}/api/notes`);
check('/api is closed without a session or a token', closed.status === 401, `got ${closed.status}`);
const wrong = await fetch(`${root}/app/login`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ password: 'not-the-password' }),
});
check('a wrong password is refused with no detail', wrong.status === 401, `got ${wrong.status}`);
const login = await fetch(`${root}/app/login`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ password: WEB_PASSWORD }),
});
const setCookie = login.headers.get('set-cookie') ?? '';
check(
'logging in sets an HttpOnly, SameSite=Strict session cookie',
login.ok && /HttpOnly/.test(setCookie) && /SameSite=Strict/i.test(setCookie),
setCookie.split(';').slice(1).join(';').trim(),
);
const cookie = setCookie.split(';')[0] ?? '';
const withSession = { cookie };
const session = await (await fetch(`${root}/app/session`, { headers: withSession })).json();
check('the session is recognised', session.authenticated === true);
const viaSession = await fetch(`${root}/api/notes`, { headers: withSession });
check('a logged-in browser reaches /api without a token', viaSession.ok, `got ${viaSession.status}`);
const mcpViaSession = await fetch(`${root}/mcp`, {
method: 'POST',
headers: { ...jsonHeaders, accept: 'application/json, text/event-stream', ...withSession },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
});
// The session is for the app. Nothing in a browser speaks MCP, and a surface
// that is not needed is not offered.
check('the session does not open /mcp', mcpViaSession.status === 401, `got ${mcpViaSession.status}`);
const tampered = await fetch(`${root}/api/notes`, { headers: { cookie: `${cookie.split('=')[0]}=9999999999999.x.forged` } });
check('a forged session cookie is refused', tampered.status === 401, `got ${tampered.status}`);
// The day editor: read the day, write it, read it back.
const day = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json();
check(
'the day route answers with a path, a title and the timetable state',
day.path === '2026/2026-09-18.md' && /2026/.test(day.title) && ['ok', 'off', 'unavailable'].includes(day.timetable),
`${day.title} — timetable ${day.timetable}, ${day.lessons?.length ?? 0} lesson(s)`,
);
// A subject no other check uses, so "found by its heading" cannot pass by
// matching the subject note the notes section wrote earlier.
const body = '## 1. Geschichte — 08:0008:45\n\nWeimarer Republik: Ursachen des Scheiterns.\n';
const saved = await fetch(`${root}/api/notes/day`, {
method: 'PUT',
headers: { ...jsonHeaders, ...withSession },
body: JSON.stringify({ date: '2026-09-18', text: body }),
});
const savedBody = await saved.json();
check('the day saves', saved.ok && savedBody.path === '2026/2026-09-18.md', `${saved.status}`);
const conflict = await fetch(`${root}/api/notes/day`, {
method: 'PUT',
headers: { ...jsonHeaders, ...withSession },
body: JSON.stringify({ date: '2026-09-18', text: 'überschrieben', expectedModifiedAt: '2020-01-01T00:00:00.000Z' }),
});
check('a save that would clobber a newer version is refused', conflict.status === 409, `got ${conflict.status}`);
const reread = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json();
// Trimmed on both sides: a stored note ends with exactly one newline, which
// is the editor's business and not something to assert on.
check('the refused save changed nothing', reread.text.trim() === body.trim(), reread.text.split('\n')[0]);
// The lesson heading the page writes has to be the one the index reads back,
// or a day's notes are filed under no subject at all.
const bySubject = await (await fetch(`${root}/api/notes?subject=Geschichte`, { headers: withSession })).json();
check(
'a day note is found by a subject only its lesson headings know',
bySubject.count === 1 && bySubject.notes[0]?.path === '2026/2026-09-18.md',
`${bySubject.count} note(s)`,
);
// What a row in the app's note list shows, which is why the listing carries
// it: one request for the whole list rather than one per note.
const listing = await (await fetch(`${root}/api/notes?limit=5`, { headers: withSession })).json();
const dayRow = listing.notes?.find((note) => note.path === '2026/2026-09-18.md');
check(
'the listing says what a note holds, without its body',
dayRow !== undefined && dayRow.text === undefined && dayRow.lessons === 1 && dayRow.subjects?.includes('Geschichte'),
`${dayRow?.lessons} lesson(s), subjects ${dayRow?.subjects?.join('/')}`,
);
check(
'and a preview that reads as prose',
/Weimarer Republik/.test(dayRow?.preview ?? '') && !/[#*|]/.test(dayRow?.preview ?? ''),
dayRow?.preview,
);
// Full text over the files themselves — the app's search box. It reads disk,
// so a note written seconds ago is findable without a crawl, which is the
// whole reason it does not go through the index.
const found = await (
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Scheiterns')}`, { headers: withSession })
).json();
check(
'note search finds a lesson by its text, with no crawl in between',
found.count === 1 && found.hits[0]?.path === '2026/2026-09-18.md',
`${found.count} hit(s)`,
);
check(
'and answers with the lesson rather than the day',
found.hits[0]?.subject === 'Geschichte' && /Geschichte/.test(found.hits[0]?.heading ?? ''),
`${found.hits[0]?.subject}${found.hits[0]?.heading}`,
);
check(
'the snippet reads as prose, not as Markdown',
/Weimarer Republik/.test(found.hits[0]?.snippet ?? '') && !/[#*|]/.test(found.hits[0]?.snippet ?? ''),
found.hits[0]?.snippet,
);
const accents = await (
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('weimarer')}`, { headers: withSession })
).json();
check('search ignores case and accents', accents.count === 1, `${accents.count} hit(s)`);
const bothWords = await (
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Subnetting')}`, { headers: withSession })
).json();
check('every word has to match', bothWords.count === 0, `${bothWords.count} hit(s)`);
const tooShort = await fetch(`${root}/api/notes/search?q=a`, { headers: withSession });
check('a one-letter search is refused rather than reading every note', tooShort.status === 400, `got ${tooShort.status}`);
const badDate = await fetch(`${root}/api/notes/day?date=2026-02-30`, { headers: withSession });
check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`);
const loggedOut = await fetch(`${root}/app/logout`, { method: 'POST', headers: withSession });
check('logging out clears the cookie', loggedOut.ok && /Max-Age=0/.test(loggedOut.headers.get('set-cookie') ?? ''));
}
console.log('\n== api_get guard rails ==');
check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError);
check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError);
check('api_get rejects absolute URL', (await call('api_get', { path: 'https://evil.test/api/x' })).isError);
console.log('\n== error handling ==');
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
console.log('\n== connector token ==');
// claude.ai sends a request header it stores, so the token in it opens /mcp and
// nothing else: /api can replace the Schulcloud token and stream the mirror.
{
const root = `http://127.0.0.1:${port}`;
const viaHeader = new Client({ name: 'smoke-connector', version: '0' }, { capabilities: {} });
await viaHeader.connect(
new StreamableHTTPClientTransport(new URL(`${root}/mcp`), {
requestInit: { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } },
}),
);
const connectorTools = await viaHeader.listTools();
check('the connector token opens /mcp', connectorTools.tools.length === tools.length, `${connectorTools.tools.length} tools`);
await viaHeader.close();
const asApiKey = await fetch(`${root}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': CONNECTOR_TOKEN },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'smoke-api-key', version: '0' } },
}),
});
check('the connector token works as x-api-key too', asApiKey.ok, `got ${asApiKey.status}`);
await asApiKey.body?.cancel();
const onApi = await fetch(`${root}/api/token`, { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } });
check('the connector token is refused on /api', onApi.status === 401, `got ${onApi.status}`);
}
console.log('\n== secret path and session token ==');
// claude.ai's connector dialog takes only a URL, so /<secret>/mcp serves MCP
// without a bearer token; and the Schulcloud token can be replaced at runtime.
// Nothing here replaces the live token: the one PUT that succeeds sends the
// token already in use, which the server answers without a swap.
{
const root = `http://127.0.0.1:${port}`;
const wrong = await fetch(`${root}/${'f'.repeat(64)}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
});
check('a wrong path secret looks like any unknown path (404)', wrong.status === 404, `got ${wrong.status}`);
const viaSecret = new Client({ name: 'smoke-secret-path', version: '0' }, { capabilities: {} });
await viaSecret.connect(new StreamableHTTPClientTransport(new URL(`${root}/${PATH_SECRET}/mcp`)));
const secretTools = await viaSecret.listTools();
check('the secret path serves MCP without a bearer token', secretTools.tools.length === tools.length, `${secretTools.tools.length} tools`);
await viaSecret.close();
const anonymous = await fetch(`${root}/api/token`);
check('/api/token needs the bearer token', anonymous.status === 401, `got ${anonymous.status}`);
const bearer = { authorization: `Bearer ${TOKEN}` };
const statusResponse = await fetch(`${root}/api/token`, { headers: bearer });
const statusText = await statusResponse.text();
const tokenStatus = JSON.parse(statusText);
check(
'/api/token reports the expiry and never the token',
statusResponse.ok && typeof tokenStatus.expiresAt === 'string' && Number.isInteger(tokenStatus.daysLeft) && !statusText.includes(config.jwt),
`${tokenStatus.daysLeft} day(s) left, from ${tokenStatus.source}`,
);
const jwtInUse = config.jwt;
const malformed = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: 'not-a-token' }),
});
const refusal = await malformed.json();
check(
'a malformed token is refused and the one in use stays',
malformed.status === 422 && refusal.error === 'malformed' && config.jwt === jwtInUse,
`${malformed.status} ${refusal.error}`,
);
const same = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: `jwt=${jwtInUse};` }),
});
const sameResult = await same.json();
check('the token already in use is accepted without a swap', same.ok && sameResult.changed === false, `${same.status}`);
const page = await fetch(`${root}/token`);
const script = await fetch(`${root}/token.js`);
check(
'/token page is served with a strict content security policy',
page.ok && /text\/html/.test(page.headers.get('content-type') ?? '') &&
/default-src 'none'/.test(page.headers.get('content-security-policy') ?? '') &&
script.ok && /javascript/.test(script.headers.get('content-type') ?? ''),
);
}
await client.close();
httpServer.close();
await closeServices(services);
await rm(STATE_DIR, { recursive: true, force: true });
await rm(NOTES_DIR, { recursive: true, force: true });
console.log(`\n${results.length - failures}/${results.length} checks passed`);
process.exit(failures === 0 ? 0 : 1);