Prepare for a live account: separate the indexes, and probe new routes live

With .env pointing at the real account, testing against the local instance
became dangerous: process env beats --env-file, so a local smoke run would
crawl fixtures straight into the live index, where a per-course refresh
carries them forward indefinitely. mcp-env.sh now pins its own database
(schulcloud_local) and mirror as well as the instance.

The override publishes the server on MCP_HOST_PORT and takes
CRAWL_INTERVAL_MS from .env, since what_changed can only report what
happened between crawls. The build context leaves out tmp/, which holds a
mirror of the account's files, and local-instance/.

probe checks live what the gap fixes established locally: the /api/v1 course
and user routes, classes, room allowedOperations as an object, and the
preview enums on a real file. A failure there means a feature degrades
rather than breaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent 5ae2210459
commit 10c6544579
8 changed files with 135 additions and 5 deletions

View File

@@ -11,3 +11,8 @@ scripts
!scripts/copy-assets.mjs
docs
files.zip
# Mirrored coursework and server logs: never needed to build, and the mirror
# holds the account's files, which have no business in a build context.
tmp
# The local test instance has its own compose project.
local-instance

View File

@@ -26,6 +26,10 @@ MCP_AUTH_TOKEN=
PORT=8080
BIND_HOST=0.0.0.0
# Local Docker only: the loopback port docker-compose.override.yml publishes the
# container on. Default 8080; change it when that port is already taken.
# MCP_HOST_PORT=8080
# ---------------------------------------------------------------------------
# Index and file mirror (optional — without these the server runs live-only:
# search crawls on every call, and the CLI's /api surface is unavailable)

View File

@@ -29,7 +29,11 @@ npm run session-diagnose # ~2.5h: measure what actually ends the session
```
`docker-compose.override.yml` is local-only and publishes the server on
`127.0.0.1:8080` and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`.
`127.0.0.1:8080` (or `MCP_HOST_PORT`) and Postgres on `127.0.0.1:55432`; see
`docs/LOCAL.md`. When `.env` points at the live account, test against the local
instance only through `local-instance/scripts/mcp-env.sh`: it pins its own
database (`schulcloud_local`) and mirror, so fixtures cannot reach the live
index.
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,

View File

@@ -12,9 +12,12 @@ services:
environment:
# The bundled postgres service, reachable by name on the compose network.
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
# Crawl on demand while developing rather than every 6 hours.
CRAWL_INTERVAL_MS: 0
# On demand by default while developing. Set CRAWL_INTERVAL_MS in .env to
# crawl on a timer instead — what_changed can only report what happened
# between crawls, so against a real account on-demand leaves it empty.
CRAWL_INTERVAL_MS: ${CRAWL_INTERVAL_MS:-0}
ports:
# Bound to loopback: this exposes the account's data, and the bearer token
# is the only thing in front of it.
- "127.0.0.1:8080:8080"
# MCP_HOST_PORT moves it when 8080 is already taken on this machine.
- "127.0.0.1:${MCP_HOST_PORT:-8080}:8080"

View File

@@ -14,7 +14,17 @@ curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index":
`docker-compose.override.yml` is merged automatically and is **local-only**: it
publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`, and
switches crawling to on-demand. On the Pi neither port is published — Caddy
switches crawling to on-demand. Two `.env` settings adjust it: `MCP_HOST_PORT`
moves the published port when 8080 is taken, and `CRAWL_INTERVAL_MS` restores a
crawl timer — worth doing against a real account, because `what_changed` can
only report what happened between crawls.
After pasting a new `TSC_JWT_COOKIE` into `.env`, **recreate** the container —
`env_file` is read when the container is created, not on restart:
```bash
docker compose up -d --force-recreate schulcloud-mcp
``` On the Pi neither port is published — Caddy
reaches the container over the Docker network.
Loopback binding is deliberate. The bearer token is the only thing in front of

View File

@@ -193,6 +193,21 @@ eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 39 checks against the local instance
```
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
mirror at `tmp/mirror-local` — not just the instance at this one. The root
`.env` normally targets the live account, and process env beats `--env-file`, so
without that a local smoke run would crawl these fixtures into the live index,
where a per-course refresh then carries them forward indefinitely. Create the
database once:
```bash
docker exec schulcloud-mcp-db psql -U schulcloud -d postgres \
-c "CREATE DATABASE schulcloud_local OWNER schulcloud"
```
Without it the server does not fail; it runs live-only, and smoke reports the
smaller, index-free check count.
It also builds a **room** ("Raum"): rooms are a separate space from courses,
and the naming misleads — the sidebar's *Kurse* entry points at
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the

View File

@@ -23,8 +23,19 @@ token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \
"$(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"])')
# The index and mirror are pinned too, not just the instance. The repo's .env
# normally points at the live account, and process env beats --env-file, so
# without these a local smoke run would crawl this throwaway instance straight
# into the live index — and a per-course refresh carries everything outside its
# scope forward, so the fixtures would outlive the run. Fixtures in real data
# is exactly the accident the store tests' "test" guard exists to prevent.
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
cat <<ENV
export TSC_URL=$URL
export TSC_JWT_COOKIE=$token
export MCP_AUTH_TOKEN=local-instance-token
export DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_local
export MIRROR_DIR=$ROOT/tmp/mirror-local
export INDEX_PERSONAL_FILES=true
ENV

View File

@@ -124,6 +124,84 @@ if (me) {
const lesson = await client.getLesson(lessonId);
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
}
// --- assumptions first established against the local 33.40 instance ----
// Each of these was verified locally and against the deployment's ingress
// table, not against this instance. A FAIL here means the matching feature
// degrades (to bare ids, to no timetable, to no preview) rather than breaks.
console.log('\nlegacy routes, classes, rooms:');
const firstCourse = courses[0];
if (firstCourse) {
await expect('GET /api/v1/courses/{id} (description, teachers, timetable)', async () => {
const legacy = await client.getLegacyCourse(firstCourse.id);
const parts = [
legacy.description ? 'description' : null,
legacy.teacherIds?.length ? `${legacy.teacherIds.length} teacher id(s)` : null,
legacy.times?.length ? `${legacy.times.length} timetable slot(s)` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : 'responds, but carries none of the fields';
});
}
await expect('GET /api/v1/users/{me} (the only id -> name route)', async () => {
const user = await client.getLegacyUser(me.user.id);
return user.firstName || user.fullName ? 'resolves your own name' : 'responds without a name';
});
const teacherId = firstCourse ? (await client.getLegacyCourse(firstCourse.id).catch(() => undefined))?.teacherIds?.[0] : undefined;
if (teacherId) {
// Expected to be refused for a student: names then degrade to "not
// visible to this account". Reported, not failed, either way.
const seen = await client.getLegacyUser(teacherId).then(() => 'readable', (error) => `refused (${error.status ?? error.message})`);
console.log(` — GET /api/v1/users/{teacher}: ${seen} — a student is expected to be refused`);
}
await expect('GET /api/v3/groups/class', async () => {
const classes = await client.listClasses();
const named = classes.filter((entry) => entry.teacherNames?.length).length;
return `${classes.length} class(es), ${named} with teacher names`;
});
const rooms = await client.listRooms().catch(() => []);
if (rooms[0]) {
await expect('room allowedOperations is an object, not a list', async () => {
const room = await client.getRoom(rooms[0].id);
const ops = room.allowedOperations;
if (Array.isArray(ops)) throw new Error('it is an array here — fix RoomItem.allowedOperations and rooms.ts');
return `${Object.values(ops ?? {}).filter(Boolean).length} operation(s) granted`;
});
} else {
console.log(' — no room to check allowedOperations against (in none is normal)');
}
// The preview route is the answer for image-only PDFs, and it has two
// undocumented enums. Only testable with a file whose preview is possible.
if (boardId) {
const skeleton = await client.getBoardSkeleton(boardId);
const cards = await client.getCards(skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId)).slice(0, 20));
const pdfElement = cards.flatMap((c) => c.elements).filter((e) => e.type === 'file' || e.type === 'fileFolder');
let previewed = false;
for (const element of pdfElement) {
const page = await client
.listFiles({ storageLocationId: me.school.id, parentType: 'boardnodes', parentId: element.id })
.catch(() => undefined);
const record = page?.data.find((file) => file.previewStatus === 'preview_possible');
if (!record) continue;
await expect(`GET /api/v3/file/preview (width=500, outputFormat=image/webp) on ${record.name}`, async () => {
const preview = await client.getFilePreview(record, 500);
return `${preview.mimeType}, ${preview.bytes.length} bytes`;
});
previewed = true;
break;
}
if (!previewed) console.log(' — no previewable file among the sampled cards');
}
}
async function expect(label, run) {
try {
const detail = await run();
console.log(` ok ${label}${detail ? ` (${detail})` : ''}`);
} catch (error) {
const status = error instanceof SchulcloudApiError ? error.status : '—';
console.log(` FAIL ${label}${status} ${String(error.message).slice(0, 120)}`);
}
}
function summarize(result) {