diff --git a/.gitignore b/.gitignore index e924dab..8c73d73 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ vendor/ # Local scratch tmp/ files.zip + +# Ids of the fixture the teacher simulation currently has in place. +local-instance/.simulate-teacher.json diff --git a/local-instance/README.md b/local-instance/README.md index 58f8ec8..96a3bdb 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -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 | diff --git a/local-instance/docker-compose.yml b/local-instance/docker-compose.yml index c6faa14..0fb9c6e 100644 --- a/local-instance/docker-compose.yml +++ b/local-instance/docker-compose.yml @@ -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. diff --git a/local-instance/env/api.env b/local-instance/env/api.env index fd9b055..fa455f0 100644 --- a/local-instance/env/api.env +++ b/local-instance/env/api.env @@ -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 diff --git a/local-instance/scripts/mcp-env.sh b/local-instance/scripts/mcp-env.sh new file mode 100755 index 0000000..24ed797 --- /dev/null +++ b/local-instance/scripts/mcp-env.sh @@ -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 < 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 diff --git a/local-instance/scripts/simulate-teacher.mjs b/local-instance/scripts/simulate-teacher.mjs new file mode 100755 index 0000000..116d0eb --- /dev/null +++ b/local-instance/scripts/simulate-teacher.mjs @@ -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: '

Dieses Thema prüft, was der MCP-Server aus einem Thema liest.

' }, + }, + ], + }); + 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: '

Beschreibe in drei Sätzen, was ein Neuron tut.

', + 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: '

Ein Neuron leitet Reize weiter. Suchbegriff: Synapsenspalt.

', + 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: '

Ein Neuron leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.

', + 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); +}