import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import type { Config } from '../src/config.ts'; import { SchulcloudApiError } from '../src/core/client.ts'; import { decodeClaims, normalizeToken, SessionToken, TokenRejected } from '../src/core/session-token.ts'; const DAY = 86_400; const now = () => Math.floor(Date.now() / 1000); /** An unsigned JWT with the given claims — the server only decodes them. */ function jwt(claims: Record): string { const part = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); return `${part({ alg: 'HS256', typ: 'JWT' })}.${part(claims)}.c2lnbmF0dXJl`; } function config(token: string): Config { return { jwt: token } as Config; } /** A client whose /me answers for the user a token names, or refuses it. */ function client(options: { refuse?: boolean; error?: Error } = {}) { const seen: string[] = []; return { seen, client: { meAs: async (token: string) => { seen.push(token); if (options.error) throw options.error; if (options.refuse) throw new SchulcloudApiError(401, '/api/v3/me', ''); return { user: { id: decodeClaims(token)?.userId } } as never; }, }, }; } async function rejection(promise: Promise): Promise { try { await promise; } catch (error) { assert.ok(error instanceof TokenRejected, `expected TokenRejected, got ${error}`); return error; } assert.fail('the replacement should have been refused'); } describe('normalizeToken', () => { it('takes the token out of whatever the copy produced', () => { const token = jwt({ userId: 'u1' }); for (const pasted of [token, ` ${token}\n`, `jwt=${token}`, `jwt=${token}; Path=/; Secure`, `"${token}"`, `'jwt=${token};'`]) { assert.equal(normalizeToken(pasted), token, JSON.stringify(pasted)); } }); }); describe('decodeClaims', () => { it('reads the user and the expiry', () => { assert.deepEqual(decodeClaims(jwt({ userId: 'u1', exp: 123, roles: ['x'] })), { userId: 'u1', exp: 123 }); }); it('gives up on anything that is not three base64url parts of JSON', () => { for (const value of ['', 'abc', 'a.b', 'a.b.c.d', 'a.!!.c', `x.${Buffer.from('not json').toString('base64url')}.y`]) { assert.equal(decodeClaims(value), undefined, value); } }); }); describe('SessionToken.replace', () => { const current = jwt({ userId: 'u1', exp: now() + 2 * DAY }); const fresh = jwt({ userId: 'u1', exp: now() + 30 * DAY }); it('checks a new token with Schulcloud, swaps it in and tells the listeners', async () => { const cfg = config(current); const { client: fake, seen } = client(); const session = new SessionToken(cfg, fake); let notified = 0; session.onReplaced(() => notified++); const result = await session.replace(`jwt=${fresh};`); assert.equal(result.changed, true); assert.equal(result.persisted, false, 'nothing to persist to without a state directory'); assert.equal(cfg.jwt, fresh); assert.deepEqual(seen, [fresh], 'the check uses the new token, not the one in use'); assert.equal(notified, 1); assert.equal(result.status.source, 'replaced at runtime'); assert.equal(result.status.daysLeft, 29); }); it('refuses a paste that is not a token, without asking Schulcloud', async () => { const cfg = config(current); const { client: fake, seen } = client(); const error = await rejection(new SessionToken(cfg, fake).replace('hello')); assert.equal(error.problem, 'malformed'); assert.equal(cfg.jwt, current); assert.equal(seen.length, 0); }); it('refuses an expired token', async () => { const cfg = config(current); const { client: fake } = client(); const error = await rejection(new SessionToken(cfg, fake).replace(jwt({ userId: 'u1', exp: now() - 60 }))); assert.equal(error.problem, 'expired'); assert.equal(cfg.jwt, current); }); it('refuses a token Schulcloud no longer accepts, and keeps the one in use', async () => { const cfg = config(current); const { client: fake } = client({ refuse: true }); const error = await rejection(new SessionToken(cfg, fake).replace(fresh)); assert.equal(error.problem, 'rejected'); assert.match(error.message, /close the window/); assert.equal(cfg.jwt, current); }); it('refuses another account\'s token: switching accounts is a restart', async () => { const cfg = config(current); const { client: fake } = client(); const error = await rejection(new SessionToken(cfg, fake).replace(jwt({ userId: 'someone-else', exp: now() + 30 * DAY }))); assert.equal(error.problem, 'other_account'); assert.equal(cfg.jwt, current); }); it('passes other failures through untouched, leaving the token in use', async () => { const cfg = config(current); const { client: fake } = client({ error: new SchulcloudApiError(503, '/api/v3/me', '') }); await assert.rejects(new SessionToken(cfg, fake).replace(fresh), SchulcloudApiError); assert.equal(cfg.jwt, current); }); it('treats the token already in use as nothing to do', async () => { const cfg = config(current); const { client: fake, seen } = client(); const session = new SessionToken(cfg, fake); let notified = 0; session.onReplaced(() => notified++); const result = await session.replace(current); assert.equal(result.changed, false); assert.equal(seen.length, 0); assert.equal(notified, 0); }); }); describe('SessionToken state file', () => { let dir: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'session-token-')); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); const older = jwt({ userId: 'u1', exp: now() + 2 * DAY }); const newer = jwt({ userId: 'u1', exp: now() + 30 * DAY }); const quiet = () => {}; it('saves a replacement readable by its owner only, and a restart picks it up', async () => { const { client: fake } = client(); await new SessionToken(config(older), fake, join(dir, 'state')).replace(newer); const file = join(dir, 'state', 'schulcloud-jwt'); assert.equal((await readFile(file, 'utf8')).trim(), newer); assert.equal((await stat(file)).mode & 0o777, 0o600); // A restart: the environment still holds the older token. const restarted = config(older); const session = new SessionToken(restarted, fake, join(dir, 'state')); await session.load(quiet); assert.equal(restarted.jwt, newer); assert.equal(session.status().source, 'state file'); }); it('prefers the environment once it holds the newer token', async () => { await writeFile(join(dir, 'schulcloud-jwt'), `${older}\n`); const cfg = config(newer); const session = new SessionToken(cfg, client().client, dir); await session.load(quiet); assert.equal(cfg.jwt, newer); assert.equal(session.status().source, 'environment'); }); it('never lets a saved token for another account override the environment', async () => { await writeFile(join(dir, 'schulcloud-jwt'), jwt({ userId: 'previous-account', exp: now() + 30 * DAY })); const cfg = config(older); const messages: string[] = []; await new SessionToken(cfg, client().client, dir).load((message) => messages.push(message)); assert.equal(cfg.jwt, older); assert.match(messages.join('\n'), /another account/); }); it('starts from the environment when nothing was saved', async () => { const cfg = config(older); const messages: string[] = []; await new SessionToken(cfg, client().client, dir).load((message) => messages.push(message)); assert.equal(cfg.jwt, older); assert.deepEqual(messages, [], 'a missing state file is the normal first start'); }); });