Files
Schulcloud-MCP/test/keepalive.test.ts
MechaCat02 d657ece436 Fix session lifetime: 2h sliding idle timeout, not 30 days
The JWT's exp claim says 30 days, and I took that as the session
lifetime. It is only an outer ceiling. The server also keeps a per-token
whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is
JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate
re-sets it on every authenticated request. Two hours idle and the token
is rejected with 29 days still on exp.

Proven, not inferred: the token from yesterday returned 401 at 13.8h old.
The live instance publishes the values unauthenticated at
GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200,
JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the
one-hour UI prompt that prompted this investigation.

refresh-session turns out not to be special: it extends through the same
guard as any other route, and uniquely only in returning the remaining
TTL. So the keepalive uses GET /api/v3/me instead, and the server stays
GET-only; the one POST in the repo is in scripts/probe.mjs, where it
reports the idle budget.

JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but
is vestigial: privateDevice has no references in the current NestJS
source, and generateJwtAndAddToWhitelist never overrides the TTL.

Also fixes a real breakage this surfaced: TypeScript parameter
properties are rejected by Node's type stripping, so `npm run dev` and
`npm test` both failed on any file reaching them. Rewritten as explicit
fields, and noted in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:07:22 +02:00

78 lines
2.8 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { SchulcloudApiError } from '../src/schulcloud/client.ts';
import { SessionKeepalive } from '../src/keepalive.ts';
/** A stand-in for SchulcloudClient that records calls and replays scripted outcomes. */
function fakeClient(outcomes: (Error | 'ok')[]) {
const calls: number[] = [];
return {
calls,
client: {
me: async () => {
const outcome = outcomes[calls.length] ?? 'ok';
calls.push(Date.now());
if (outcome !== 'ok') throw outcome;
return {} as never;
},
} as never,
};
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
describe('SessionKeepalive', () => {
it('pings immediately on start, so a bad token is noticed at boot', async () => {
const { client, calls } = fakeClient(['ok']);
const keepalive = new SessionKeepalive(client, 60_000, 1000, () => {});
keepalive.start();
await settle();
assert.equal(calls.length, 1);
keepalive.stop();
});
it('keeps pinging on the interval', async () => {
const { client, calls } = fakeClient([]);
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
keepalive.start();
await new Promise((resolve) => setTimeout(resolve, 120));
keepalive.stop();
assert.ok(calls.length >= 3, `expected repeated pings, got ${calls.length}`);
});
it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => {
const unauthorized = new SchulcloudApiError(401, '/api/v3/me', '');
const { client, calls } = fakeClient([unauthorized]);
const messages: string[] = [];
const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message));
keepalive.start();
await new Promise((resolve) => setTimeout(resolve, 120));
assert.equal(calls.length, 1, 'must not keep hammering a dead token');
assert.equal(messages.length, 1);
assert.match(messages[0]!, /fresh jwt cookie/);
keepalive.stop();
});
it('retries a transient failure instead of giving up', async () => {
const { client, calls } = fakeClient([new Error('ECONNRESET')]);
const messages: string[] = [];
const keepalive = new SessionKeepalive(client, 10_000, 15, (message) => messages.push(message));
keepalive.start();
await new Promise((resolve) => setTimeout(resolve, 120));
keepalive.stop();
assert.ok(calls.length >= 2, `expected a retry, got ${calls.length} call(s)`);
assert.match(messages[0]!, /retrying in/);
});
it('stop() prevents any further pings', async () => {
const { client, calls } = fakeClient([]);
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
keepalive.start();
await settle();
keepalive.stop();
const seen = calls.length;
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(calls.length, seen, 'no pings after stop()');
});
});