Files
Schulcloud-MCP/test/keepalive.test.ts
MechaCat02 60ca4d3eba Keepalive via refresh-session; GET pings measured insufficient
The endurance test refuted the sliding-window model I committed earlier.
A keepalive doing only GET /api/v3/me succeeded at t+0/30/60/90 and was
still rejected by t+120 — consistent with the session ending ~2h after
LOGIN (t+107), and inconsistent with 2h after the last request, which
would have been t+210.

This is a live-vs-source divergence, not a misreading: both the current
JwtWhitelistAdapter and the legacy Feathers ensureTokenIsWhitelisted
re-set the Valkey TTL on every authenticated request, so the source
reads as a sliding window. The instance does not behave that way.

So the keepalive now calls POST /authentication/refresh-session, the
endpoint behind the UI's "Sitzung verlängern" button, which a separate
100s test showed does hold the reported budget at 7200s. It is the only
non-GET request in the server: no body, touches only our own session,
cannot read or modify user data, and is not exposed as a tool, so no
model-driven call can ever be a POST. It logs the returned budget, which
makes a failing extension visible before the session is lost.

Whether this is sufficient is NOT established. Two mechanisms still fit:
an idle TTL that reads fail to refresh (keepalive works), or an absolute
cap/revocation anchored at login — e.g. the IDP's back-channel logout,
which clears every token for the account rather than one. Added
scripts/session-diagnose.mjs to settle it: it logs the budget every 10
min, so a decaying series indicates the former and an abrupt 401 at
7200s the latter. Docs state the open question rather than asserting a
mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:46:54 +02:00

91 lines
3.4 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: {
extendSession: async () => {
const outcome = outcomes[calls.length] ?? 'ok';
calls.push(Date.now());
if (outcome !== 'ok') throw outcome;
return { expiresInSeconds: 7200 };
},
} 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/authentication/refresh-session', '');
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()');
});
});
describe('SessionKeepalive logging', () => {
it('reports the remaining session budget on a successful extension', async () => {
const messages: string[] = [];
const client = { extendSession: async () => ({ expiresInSeconds: 7200 }) } as never;
const keepalive = new SessionKeepalive(client, 60_000, 1000, (m) => messages.push(m));
keepalive.start();
await new Promise((resolve) => setTimeout(resolve, 30));
keepalive.stop();
assert.equal(messages.length, 1);
assert.match(messages[0]!, /session extended, 7200s \(120 min\)/);
});
});