Files
Schulcloud-MCP/test/keepalive.test.ts
MechaCat02 973b82ebf5 Replace the Schulcloud token without a restart
A token lasts 30 days and only a browser login yields one — the account is
federated, so the server cannot mint it. Replacing it meant editing .env and
recreating the container, every month.

`schulcloud token set` (a hidden prompt, or piped input) and a /token page
both send it to PUT /api/token. The server checks it with Schulcloud first —
well-formed, unexpired, still logged in, the same account — then swaps it
into the config every request reads, restarts the keepalive and saves it in
STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved
token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A
refused paste changes nothing, and the token is never logged.

The keepalive's pings carry a generation, so a 401 for the old token that
arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami
and the log report the expiry and warn a week ahead.

Found on the way: a host that is off for more than two hours loses the
session however long the token has left — this machine lost it overnight —
which is what the always-on Pi is for.

174 tests. Smoke 72/72 on the local instance, and a real swap verified end to
end there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

140 lines
5.1 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { SchulcloudApiError } from '../src/core/client.ts';
import { SessionKeepalive } from '../src/core/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\)/);
});
});
describe('SessionKeepalive after a token replacement', () => {
it('resumes when restarted after a 401', async () => {
const unauthorized = new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
const { client, calls } = fakeClient([unauthorized]);
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
keepalive.start();
await settle();
assert.equal(keepalive.state().running, false);
assert.ok(keepalive.state().rejectedAt);
keepalive.restart();
await new Promise((resolve) => setTimeout(resolve, 100));
keepalive.stop();
assert.ok(calls.length >= 3, `expected pings to resume, got ${calls.length}`);
assert.equal(keepalive.state().rejectedAt, undefined);
assert.equal(keepalive.state().budgetSeconds, 7200);
});
it('ignores a 401 for the old token that arrives after the restart', async () => {
// The first ping is still in flight, with the old token, when the token is
// replaced; its 401 must not stop the keepalive now holding the new one.
let release: (() => void) | undefined;
let first = true;
const client = {
extendSession: async () => {
if (first) {
first = false;
await new Promise<void>((resolve) => {
release = resolve;
});
throw new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
}
return { expiresInSeconds: 7200 };
},
} as never;
const messages: string[] = [];
const keepalive = new SessionKeepalive(client, 60_000, 60_000, (message) => messages.push(message));
keepalive.start();
await settle();
keepalive.restart();
await settle();
release?.();
await settle();
assert.equal(keepalive.state().running, true);
assert.equal(messages.filter((message) => /401/.test(message)).length, 0);
keepalive.stop();
});
});