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 !scripts/copy-assets.mjs
docs docs
files.zip 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 PORT=8080
BIND_HOST=0.0.0.0 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: # 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) # 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 `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 `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/`, read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,

View File

@@ -12,9 +12,12 @@ services:
environment: environment:
# The bundled postgres service, reachable by name on the compose network. # The bundled postgres service, reachable by name on the compose network.
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
# Crawl on demand while developing rather than every 6 hours. # On demand by default while developing. Set CRAWL_INTERVAL_MS in .env to
CRAWL_INTERVAL_MS: 0 # 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: ports:
# Bound to loopback: this exposes the account's data, and the bearer token # Bound to loopback: this exposes the account's data, and the bearer token
# is the only thing in front of it. # 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 `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 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. reaches the container over the Docker network.
Loopback binding is deliberate. The bearer token is the only thing in front of 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 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, It also builds a **room** ("Raum"): rooms are a separate space from courses,
and the naming misleads — the sidebar's *Kurse* entry points at and the naming misleads — the sidebar's *Kurse* entry points at
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the `/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()))')")" \ "$(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"])') | 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 cat <<ENV
export TSC_URL=$URL export TSC_URL=$URL
export TSC_JWT_COOKIE=$token export TSC_JWT_COOKIE=$token
export MCP_AUTH_TOKEN=local-instance-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 ENV

View File

@@ -124,6 +124,84 @@ if (me) {
const lesson = await client.getLesson(lessonId); const lesson = await client.getLesson(lessonId);
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`); 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) { function summarize(result) {