Schulcloud holds the material for a lesson but not the lesson: this school's course `times` are empty and it publishes the schedule in WebUntis. So "what do I have today, and has anything been cancelled" was unanswerable, and the timetable cannot be typed into a prompt either — it changes daily. core/untis.ts talks to the API the Untis Mobile app uses, and three tools sit on it: untis_timetable (a day or a range, Entfall, Vertretung, room changes, the notes on each period, inline homework, the period id), untis_homework (the class register's list, which is not Schulcloud's tasks) and untis_lesson_topics (what earlier lessons of a series actually covered, which is what says where a subject got to). Read-only, but not by the Schulcloud client's rule: this API is JSON-RPC, so every call is a POST, reads included. READ_METHODS is the guarantee instead, enforced at the single choke point and asserted by a test. It matters because the key can do what the app can — the live account holds W_OWN_ABSENCE, so the same key could report the user absent. What the live instance taught us, all recorded in docs/API.md: - `startDateTime` ends in Z and is local time. The 08:00 lesson reports 08:00Z, so new Date() would move every lesson by an hour or two. - A substitution is two periods, the original CANCELLED and the replacement IRREGULAR beside it, not one period with a changed teacher. - Announced tests live in the period's info text. The exam module is unused here, so getExams2017 is always empty and that field carries the tests. - A day with no lessons is not a holiday: the weeks this account spends in the company simply have no periods. - `?v=i3.2` is required, or the call fails with a Java NPE reported as -8998. Errors arrive with HTTP 200 and an error member. -8504 is a rejected key and -8524 a drifting clock; the tools name both, because no retry fixes either. Configuration is all four UNTIS_* values or none — three are identifiers and the fourth is a credential, so a half-filled block is a paste that went wrong. Without them the tools are not registered at all, since a tool that can only fail is worse than a missing one. whoami reports the WebUntis identity and survives a dead Schulcloud session, so "is the server reachable" no longer gets a misleadingly total no. mcp-env.sh switches WebUntis off for a fixture run: that key belongs to the real school. 224 tests. All 10 WebUntis smoke checks pass, with a key and without one; the Schulcloud checks in those runs answer 401 because this machine's session is logged out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
5.3 KiB
TypeScript
154 lines
5.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { afterEach, describe, it } from 'node:test';
|
|
import { loadConfig } from '../src/config.ts';
|
|
|
|
const SAVED = { ...process.env };
|
|
afterEach(() => {
|
|
process.env = { ...SAVED };
|
|
});
|
|
|
|
describe('loadConfig', () => {
|
|
it('requires the instance URL and token', () => {
|
|
delete process.env.TSC_URL;
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
assert.throws(() => loadConfig(), /TSC_URL/);
|
|
});
|
|
|
|
it('strips trailing slashes so paths concatenate cleanly', () => {
|
|
process.env.TSC_URL = 'https://example.org///';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
assert.equal(loadConfig().baseUrl, 'https://example.org');
|
|
});
|
|
|
|
it('rejects a non-numeric port rather than silently defaulting', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.PORT = 'not-a-number';
|
|
assert.throws(() => loadConfig(), /PORT/);
|
|
});
|
|
|
|
it('treats a blank auth token as absent', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_AUTH_TOKEN = ' ';
|
|
assert.equal(loadConfig().authToken, undefined);
|
|
});
|
|
});
|
|
|
|
describe('loadConfig: secret MCP path and state directory', () => {
|
|
it('accepts a long URL-safe secret and leaves it off by default', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
assert.equal(loadConfig().mcpPathSecret, undefined);
|
|
process.env.MCP_PATH_SECRET = '0123456789abcdef0123456789abcdef';
|
|
assert.equal(loadConfig().mcpPathSecret, '0123456789abcdef0123456789abcdef');
|
|
});
|
|
|
|
it('refuses a short or unsafe secret without echoing it', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
for (const secret of ['short-secret', 'has spaces in it but is long enough 1234', 'slash/in/the/middle/0123456789abcdefgh']) {
|
|
process.env.MCP_PATH_SECRET = secret;
|
|
assert.throws(
|
|
() => loadConfig(),
|
|
(error: Error) => /MCP_PATH_SECRET/.test(error.message) && !error.message.includes(secret),
|
|
);
|
|
}
|
|
});
|
|
|
|
it('resolves the state directory to an absolute path', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.STATE_DIR = 'tmp/state';
|
|
assert.match(loadConfig().stateDir ?? '', /^\/.*\/tmp\/state$/);
|
|
});
|
|
});
|
|
|
|
describe('loadConfig: connector token', () => {
|
|
const connector = 'connector-0123456789abcdef0123456789';
|
|
|
|
it('is off by default, and accepted alongside the main token', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_AUTH_TOKEN = 'main-token';
|
|
assert.equal(loadConfig().connectorToken, undefined);
|
|
process.env.MCP_CONNECTOR_TOKEN = connector;
|
|
assert.equal(loadConfig().connectorToken, connector);
|
|
});
|
|
|
|
it('refuses a short or spaced token without echoing it', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_AUTH_TOKEN = 'main-token';
|
|
for (const token of ['too-short', 'long enough but with spaces in it 0123']) {
|
|
process.env.MCP_CONNECTOR_TOKEN = token;
|
|
assert.throws(
|
|
() => loadConfig(),
|
|
(error: Error) => /MCP_CONNECTOR_TOKEN/.test(error.message) && !error.message.includes(token),
|
|
);
|
|
}
|
|
});
|
|
|
|
it('refuses to leave /api open, or to be the main token under another name', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_CONNECTOR_TOKEN = connector;
|
|
delete process.env.MCP_AUTH_TOKEN;
|
|
assert.throws(() => loadConfig(), /needs MCP_AUTH_TOKEN/);
|
|
process.env.MCP_AUTH_TOKEN = connector;
|
|
assert.throws(() => loadConfig(), /must differ from MCP_AUTH_TOKEN/);
|
|
});
|
|
});
|
|
|
|
describe('loadConfig: WebUntis', () => {
|
|
const UNTIS = {
|
|
UNTIS_SERVER: 'ags-erfurt.webuntis.com',
|
|
UNTIS_SCHOOL: 'ags-erfurt',
|
|
UNTIS_USER: 'fia24b-test',
|
|
UNTIS_SECRET: 'JBSWY3DPEHPK3PXP',
|
|
};
|
|
|
|
/** The four are read from the environment, so a stray real value must not leak in. */
|
|
function only(overrides: Partial<typeof UNTIS> = {}): void {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
for (const name of Object.keys(UNTIS)) delete process.env[name];
|
|
for (const [name, value] of Object.entries(overrides)) process.env[name] = value;
|
|
}
|
|
|
|
it('is off when nothing is set', () => {
|
|
only();
|
|
assert.equal(loadConfig().untis, undefined);
|
|
});
|
|
|
|
it('reads all four values', () => {
|
|
only(UNTIS);
|
|
assert.deepEqual(loadConfig().untis, {
|
|
server: 'ags-erfurt.webuntis.com',
|
|
school: 'ags-erfurt',
|
|
user: 'fia24b-test',
|
|
secret: 'JBSWY3DPEHPK3PXP',
|
|
});
|
|
});
|
|
|
|
it('names what is missing instead of half-configuring itself', () => {
|
|
only({ UNTIS_SERVER: UNTIS.UNTIS_SERVER, UNTIS_SCHOOL: UNTIS.UNTIS_SCHOOL });
|
|
assert.throws(() => loadConfig(), /missing: UNTIS_USER, UNTIS_SECRET/);
|
|
assert.throws(() => loadConfig(), /Profil/);
|
|
});
|
|
|
|
it('refuses a URL where the bare host belongs', () => {
|
|
only({ ...UNTIS, UNTIS_SERVER: 'https://ags-erfurt.webuntis.com/WebUntis/' });
|
|
assert.throws(() => loadConfig(), /UNTIS_SERVER must be the bare host/);
|
|
});
|
|
|
|
it('refuses a key that is not base32, without echoing it', () => {
|
|
const secret = 'not-a-base32-key!';
|
|
only({ ...UNTIS, UNTIS_SECRET: secret });
|
|
assert.throws(
|
|
() => loadConfig(),
|
|
(error: Error) => /UNTIS_SECRET/.test(error.message) && !error.message.includes(secret),
|
|
);
|
|
});
|
|
});
|