local-instance: simulate a teacher, and run the files-storage consumer

`scripts/simulate-teacher.mjs` creates, edits and deletes what teachers
create — course, room, topic, task, board, columns, cards, rich text,
link, Etherpad pad, folder, files — so the MCP server can be exercised
against content the real account has never held. It is the only thing
here that writes to a Schulcloud and refuses any non-localhost address.
`scripts/mcp-env.sh` points the server and CLI at the instance.

Two gaps it exposed in the stack itself:

The files-storage AMQP consumer is a separate entrypoint, and we were
running only the HTTP one. Nothing was bound to the `files-storage`
exchange, so `TaskService.delete` — which awaits deleteFilesOfParent
over AMQP before touching the task — hung until the request timeout.
Deleting any task or topic answered 408 with the entity still there.

The demo data is dated 2017-2018 and the v3 endpoints filter on those
dates, so a student saw no tasks at all. seed.sh now brings courses and
homework into the present, which is the difference between a fixture
that exercises the student-facing surface and one that looks empty.

Teams turn out to be uncreatable through the API (the legacy service
registers no `create`, v3 has no route), and a new board is unpublished
and 403s for students, so the simulation adopts a seeded team and leaves
one board a draft on purpose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-13 15:39:51 +02:00
parent 521c21f7ae
commit 3a168e37e5
7 changed files with 490 additions and 0 deletions

View File

@@ -170,6 +170,45 @@ The second row matters: no submission in the real account has ever had a
numeric grade, so `formatGradeState`'s percentage branch had never been seen
against real data. Here it can be.
## Simulating a teacher
`scripts/simulate-teacher.mjs` creates, edits and deletes the things teachers
create, so the MCP server can be run against content the real account has never
held. It is the only thing in this repository that writes to a Schulcloud, and
it refuses to run against anything but a localhost address.
```bash
node scripts/simulate-teacher.mjs create # course, room, topic, task, board,
# columns, cards, rich text, link,
# Etherpad pad, folder, files
node scripts/simulate-teacher.mjs update # rename and rewrite all of it
node scripts/simulate-teacher.mjs delete # remove it again
```
Ids are kept in `.simulate-teacher.json` between phases, so the MCP server can
be pointed at the instance in between:
```bash
eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 39 checks against the local instance
```
Two things it does **not** do, because the API does not allow them:
- **Teams cannot be created.** The legacy service registers
`['find','get','update','patch','remove']` and no `create`, and v3 has no team
route beyond news and create-room; `POST /teams` answers 405. The script
adopts a seeded team and edits that instead.
- **A board is created unpublished.** Students get 403 on it while the course
page still lists its title, so `create` publishes the main board and leaves a
second one as a draft on purpose — both states are worth testing against.
`seed.sh` also drags the demo data into the present. The seed ships courses that
ended in 2018 and homework due in 2017, and the v3 endpoints filter on those
dates: a student sees no tasks at all in an ended course, which makes the whole
student-facing surface look empty for reasons that have nothing to do with the
code under test.
## Profiles
| Profile | Services | Cost |

View File

@@ -134,6 +134,26 @@ services:
ports: ["127.0.0.1:4444:4444"]
restart: unless-stopped
file-storage-consumer:
# The AMQP half of files-storage, and a separate entrypoint from the HTTP
# one: only `files-storage-consumer.app` registers FilesStorageConsumer, so
# running the HTTP app alone leaves the `files-storage` exchange with no
# queue bound to it.
#
# The symptom is not a file problem. TaskService.delete awaits
# deleteFilesOfParent over AMQP before touching the task, so with nothing
# consuming, deleting a task or a topic hangs until the request timeout and
# answers 408 REQUEST_TIMEOUT with the entity still there. Copying a course
# goes the same way.
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
command: ["dist/apps/files-storage-consumer.app.js"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
mongo: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
file-preview:
# Generates thumbnails via ImageMagick, driven off RabbitMQ. Optional: with
# it absent, files still upload and download, they just have no preview.

View File

@@ -93,3 +93,8 @@ FEATURE_VIDIS_MEDIA_ACTIVATIONS_ENABLED=false
# is off, so they get a placeholder rather than a real service. Hydra is the
# OAuth2 provider behind external tool launches; we do not run it.
HYDRA_URI=http://hydra.invalid:4444
# Calendar. The live deployment runs a schulcloud-calendar service and this
# stack does not. Deletions still succeed: the calendar call fails and is
# tolerated. Nothing here reads calendars.
CALENDAR_SERVICE_ENABLED=false

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Mint a session on the local instance and print the environment the MCP server
# and CLI expect, so they can be pointed at it instead of the live Schulcloud.
#
# eval "$(./scripts/mcp-env.sh)" # as the demo student
# eval "$(./scripts/mcp-env.sh klara.fall@schul-cloud.org Schulcloud1\!)"
#
# Nothing is written to the repo: the output contains a live session token, and
# a throwaway instance is still no reason to start committing those.
set -euo pipefail
# `localhost`, not `127.0.0.1`: it must match SC_DOMAIN, because urls the server
# hands back (Etherpad pads, for one) are built from it, and the client refuses
# to follow a url onto a different host rather than leak a session cookie there.
URL=${LOCAL_SC_URL:-http://localhost:4400}
USER=${1:-demo-schueler@schul-cloud.org}
PASS=${2:-schulcloud}
token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \
-H 'Content-Type: application/json' \
-d "$(printf '{"username":%s,"password":%s}' \
"$(printf '%s' "$USER" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
"$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
cat <<ENV
export TSC_URL=$URL
export TSC_JWT_COOKIE=$token
export MCP_AUTH_TOKEN=local-instance-token
ENV

View File

@@ -49,6 +49,43 @@ SECRET=$(curl -fsS -X POST "$MGMT/encrypt-plain-text" \
print(' schools linked to the provider: ' + r.modifiedCount);
"
# The demo data is dated 2017-2018, and the v3 endpoints filter on those dates:
# a course that has ended shows a student no tasks, and a task whose due date
# passed years ago is in neither the open nor the finished list and has dropped
# off the course page. The result is a student account that looks empty for
# reasons that have nothing to do with whatever is being tested against it.
#
# So move the stale data into the present. Courses get a term around today.
# Homework is shifted by one offset per date cluster, which keeps the relative
# order — and so which tasks are past due, the ones carrying the graded
# submissions — while landing the newest of them two weeks ago.
echo "==> dating the demo data to the present"
"${COMPOSE[@]}" exec -T mongo mongosh schulcloud --quiet --eval "
const now = new Date();
const courses = db.courses.updateMany({ untilDate: { \$lt: now } }, { \$set: {
startDate: new Date(now.getTime() - 180 * 86400000),
untilDate: new Date(now.getTime() + 185 * 86400000),
} });
print(' courses given a current term: ' + courses.modifiedCount);
let moved = 0;
for (let pass = 0; pass < 10; pass++) {
const cutoff = new Date(Date.now() - 365 * 86400000);
const newest = db.homeworks.find({ dueDate: { \$lt: cutoff } }).sort({ dueDate: -1 }).limit(1).toArray()[0];
if (!newest) break;
const offset = (Date.now() - 14 * 86400000) - newest.dueDate.getTime();
db.homeworks.find({ dueDate: { \$lt: cutoff } }).forEach((h) => {
const set = {};
for (const field of ['dueDate', 'availableDate', 'createdAt', 'updatedAt']) {
if (h[field] instanceof Date) set[field] = new Date(h[field].getTime() + offset);
}
db.homeworks.updateOne({ _id: h._id }, { \$set: set });
moved++;
});
}
print(' homework brought forward: ' + moved);
"
cat <<'ACCOUNTS'
==> ready — http://localhost:4400

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* Simulate a teacher's work against the LOCAL instance: create, change and
* delete the things teachers actually create, so the MCP server can be exercised
* against content the real account has never contained.
*
* This is the only thing in this repository that writes to a Schulcloud, and the
* guard below is what keeps it that way. The MCP server itself stays read-only;
* nothing here runs through it.
*
* node scripts/simulate-teacher.mjs create # build the fixture, print ids
* node scripts/simulate-teacher.mjs update # rename/edit everything it made
* node scripts/simulate-teacher.mjs delete # remove it again
*
* State lives in .simulate-teacher.json so the phases can be run one at a time
* with MCP checks in between.
*/
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const STATE = join(ROOT, '.simulate-teacher.json');
// The server's own port, not the nginx origin: `/api/v1` is deliberately not
// routed through the ingress (the live deployment does the same), and the
// legacy services are the only way to create tasks and topics.
const API = process.env.LOCAL_SC_API ?? 'http://127.0.0.1:3030';
const FILES = process.env.LOCAL_SC_FILES ?? 'http://127.0.0.1:4444';
for (const [name, url] of [['LOCAL_SC_API', API], ['LOCAL_SC_FILES', FILES]]) {
const { hostname } = new URL(url);
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') {
console.error(`refusing to run: ${name}=${url} is not a localhost address.`);
process.exit(2);
}
}
const TEACHER = process.env.SIM_TEACHER ?? 'klara.fall@schul-cloud.org';
const PASSWORD = process.env.SIM_PASSWORD ?? 'Schulcloud1!';
/** Everything is created here, because the demo student is a member. */
const COURSE = process.env.SIM_COURSE ?? '59a3c657a2049554a93fec3a'; // Biologie 9b
const STUDENT_EMAIL = 'demo-schueler@schul-cloud.org';
let jwt = '';
let me;
async function req(base, method, path, body, { raw = false } = {}) {
const headers = { Authorization: `Bearer ${jwt}` };
let payload;
if (body instanceof FormData) {
payload = body;
} else if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetch(`${base}${path}`, { method, headers, body: payload });
const text = await res.text();
if (!res.ok) {
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 400)}`);
}
if (raw) return text;
return text ? JSON.parse(text) : undefined;
}
const v3 = (method, path, body, opts) => req(API, method, `/api/v3${path}`, body, opts);
const v1 = (method, path, body, opts) => req(API, method, `/api/v1${path}`, body, opts);
const files = (method, path, body, opts) => req(FILES, method, `/api/v3/file${path}`, body, opts);
const log = (...a) => console.log(' ', ...a);
const step = (s) => console.log(`\n== ${s} ==`);
function loadState() {
return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {};
}
function saveState(s) {
writeFileSync(STATE, JSON.stringify(s, null, '\t') + '\n');
}
async function login() {
const res = await fetch(`${API}/api/v3/authentication/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: TEACHER, password: PASSWORD }),
});
if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`);
jwt = (await res.json()).accessToken;
me = await v3('GET', '/me');
log(`signed in as ${me.user.firstName} ${me.user.lastName} (${me.roles.map((r) => r.name).join(', ')})`);
}
/** The demo student, so created content is visible to the account under test. */
async function studentId() {
const found = await v1('GET', `/users?email=${encodeURIComponent(STUDENT_EMAIL)}`);
const user = found.data?.[0] ?? found[0];
if (!user) throw new Error(`could not find ${STUDENT_EMAIL}`);
return user._id;
}
// ---------------------------------------------------------------- create ---
async function create() {
const s = loadState();
// Saved after every step, not at the end: a failure halfway through used to
// leave created objects with no record of their ids, and so no way to delete
// them again.
const keep = (k, v) => { s[k] = v; saveState(s); return v; };
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
const student = await studentId();
step('course');
const course = await v3('POST', '/courses', { name: `MCP-Test Kurs (${stamp})` });
keep('courseId', course.courseId ?? course.id ?? course._id);
// v3 creates the course but takes no members; the legacy service does.
await v1('PATCH', `/courses/${s.courseId}`, { userIds: [student], teacherIds: [me.user.id] });
log(`course ${s.courseId} with the demo student enrolled`);
step('room');
const room = await v3('POST', '/rooms', {
name: `MCP-Test Raum (${stamp})`,
color: 'blue',
features: [],
});
keep('roomId', room.id);
log(`room ${s.roomId}`);
step('team');
// Teams cannot be created through the API: the legacy service registers
// ['find','get','update','patch','remove'] and no 'create'
// (schulcloud-server src/services/teams/index.js), and v3 has no team route
// beyond news and create-room. POST /teams answers 405. So the simulation
// adopts a seeded team and edits that instead — which is all the MCP server
// needs, since it reads teams only through api_get.
const teams = await v1('GET', '/teams');
const existing = (teams.data ?? [])[0];
if (existing) {
keep('teamId', existing._id);
log(`adopted seeded team ${existing._id} (${existing.name}) — creation is not exposed`);
} else {
log('no seeded team to adopt; skipping');
}
step('topic (legacy lesson) in Biologie 9b');
const lesson = await v1('POST', '/lessons', {
name: `MCP-Test Thema (${stamp})`,
courseId: COURSE,
hidden: false,
contents: [
{
title: 'Einführung',
hidden: false,
component: 'text',
content: { text: '<p>Dieses Thema prüft, was der MCP-Server aus einem Thema liest.</p>' },
},
],
});
keep('lessonId', lesson._id);
log(`lesson ${s.lessonId}`);
step('task (homework) in Biologie 9b');
const task = await v1('POST', '/homework', {
name: `MCP-Test Aufgabe (${stamp})`,
description: '<p>Beschreibe in drei Sätzen, was ein Neuron tut.</p>',
courseId: COURSE,
availableDate: new Date(Date.now() - 86400_000).toISOString(),
dueDate: new Date(Date.now() + 7 * 86400_000).toISOString(),
private: false,
teacherId: me.user.id,
schoolId: me.school.id,
});
keep('taskId', task._id);
log(`task ${s.taskId}`);
step('column board in Biologie 9b');
const board = await v3('POST', '/boards', {
title: `MCP-Test Board (${stamp})`,
parentId: COURSE,
parentType: 'course',
layout: 'columns',
});
keep('boardId', board.id);
const column = await v3('POST', `/boards/${s.boardId}/columns`);
keep('columnId', column.id);
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material' });
const card = await v3('POST', `/columns/${s.columnId}/cards`);
keep('cardId', card.id);
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem' });
log(`board ${s.boardId} / column ${s.columnId} / card ${s.cardId}`);
step('card elements');
const rich = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'richText' });
keep('richTextId', rich.id);
await v3('PATCH', `/elements/${s.richTextId}/content`, {
data: {
content: {
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Suchbegriff: Synapsenspalt.</p>',
inputFormat: 'richTextCk5',
},
type: 'richText',
},
});
log(`richText ${s.richTextId}`);
const link = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'link' });
keep('linkId', link.id);
await v3('PATCH', `/elements/${s.linkId}/content`, {
data: {
content: { url: 'https://www.dbildungscloud.de/', title: 'dBildungscloud', description: '', imageUrl: '', originalImageUrl: '' },
type: 'link',
},
});
log(`link ${s.linkId}`);
const pad = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'collaborativeTextEditor' });
keep('padElementId', pad.id);
log(`collaborativeTextEditor (Etherpad) ${s.padElementId}`);
const folder = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'fileFolder' });
keep('folderId', folder.id);
await v3('PATCH', `/elements/${s.folderId}/content`, {
data: { content: { title: 'Arbeitsblätter' }, type: 'fileFolder' },
});
log(`fileFolder (directory) ${s.folderId}`);
step('files');
const fileEl = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'file' });
keep('fileElementId', fileEl.id);
keep('fileId', await upload(s.fileElementId, 'nervensystem-notiz.txt', 'text/plain',
'Das Nervensystem\n\nReizleitung erfolgt ueber Synapsen. Stichwort: Synapsenspalt.\n'));
log(`file element ${s.fileElementId} holding file ${s.fileId}`);
keep('folderFileId', await upload(s.folderId, 'arbeitsblatt-1.txt', 'text/plain',
'Arbeitsblatt 1\n\nAufgabe: Beschrifte die Teile eines Neurons.\n'));
log(`file ${s.folderFileId} inside the directory`);
step('publishing');
// A board is created as a draft. Students get 403 on it while the course page
// still lists its title, so the fixture needs both states to be useful.
await v3('PATCH', `/boards/${s.boardId}/visibility`, { isVisible: true });
log('main board published');
const draft = await v3('POST', '/boards', {
title: `MCP-Test Entwurf, unveröffentlicht (${stamp})`,
parentId: COURSE,
parentType: 'course',
layout: 'columns',
});
keep('draftBoardId', draft.id);
log(`second board left unpublished on purpose: ${draft.id}`);
saveState(s);
console.log(`\nstate written to ${STATE}`);
summary(s);
}
/** files-storage attaches bytes to a board node (element) id, not to the card. */
async function upload(parentId, name, type, body) {
const form = new FormData();
form.append('file', new Blob([body], { type }), name);
const record = await files('POST', `/upload/school/${me.school.id}/boardnodes/${parentId}`, form);
return record.id;
}
// ---------------------------------------------------------------- update ---
async function update() {
const s = loadState();
if (!s.boardId) throw new Error('nothing to update — run `create` first');
const stamp = new Date().toISOString().slice(11, 16);
step('renames');
await v1('PATCH', `/courses/${s.courseId}`, { name: `MCP-Test Kurs [umbenannt ${stamp}]` });
log('course renamed');
await v3('PATCH', `/boards/${s.boardId}/title`, { title: `MCP-Test Board [umbenannt ${stamp}]` });
log('board renamed');
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material (überarbeitet)' });
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem — überarbeitet' });
log('column and card renamed');
await v1('PATCH', `/homework/${s.taskId}`, { name: `MCP-Test Aufgabe [umbenannt ${stamp}]` });
log('task renamed');
await v1('PATCH', `/lessons/${s.lessonId}`, { name: `MCP-Test Thema [umbenannt ${stamp}]` });
log('topic renamed');
if (s.teamId) {
const team = await v1('GET', `/teams/${s.teamId}`);
await v1('PATCH', `/teams/${s.teamId}`, { name: `${team.name.replace(/ \[MCP .*$/, '')} [MCP ${stamp}]` });
log('team renamed (the one write teams do allow)');
}
await v3('PUT', `/rooms/${s.roomId}`, { name: `MCP-Test Raum [umbenannt ${stamp}]`, color: 'green', features: [] });
log('room renamed');
step('content edits');
await v3('PATCH', `/elements/${s.richTextId}/content`, {
data: {
content: {
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.</p>',
inputFormat: 'richTextCk5',
},
type: 'richText',
},
});
log('rich text rewritten (new search term: Ranvierscher Schnürring)');
await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' });
log('file renamed');
saveState({ ...s, updated: true });
}
// ---------------------------------------------------------------- delete ---
async function remove() {
const s = loadState();
if (!s.boardId) throw new Error('nothing to delete — run `create` first');
step('deleting what was created');
const tries = [
['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
['link element', () => v3('DELETE', `/elements/${s.linkId}`)],
['card', () => v3('DELETE', `/cards/${s.cardId}`)],
['column', () => v3('DELETE', `/columns/${s.columnId}`)],
['board', () => v3('DELETE', `/boards/${s.boardId}`)],
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
];
for (const [what, fn] of tries) {
try {
await fn();
log(`deleted ${what}`);
} catch (err) {
log(`could NOT delete ${what}: ${String(err.message).slice(0, 160)}`);
}
}
unlinkSync(STATE);
console.log(`\nstate file removed`);
}
function summary(s) {
console.log('\nids for the MCP side:');
for (const [k, v] of Object.entries(s)) console.log(` ${k.padEnd(16)} ${v}`);
}
const phase = process.argv[2] ?? 'create';
await login();
if (phase === 'create') await create();
else if (phase === 'update') await update();
else if (phase === 'delete') await remove();
else if (phase === 'show') summary(loadState());
else {
console.error(`unknown phase ${phase}; expected create | update | delete | show`);
process.exit(2);
}