Adds src/middleware/session-renewal.ts with slideSessionIfNeeded — a pure function that re-mints the session cookie iff the JWT is older than SESSION_RENEW_THRESHOLD_SECONDS (1 hour). Wired into makeRequireAuth so every authenticated request bumps the cookie's expiration window forward, giving the 'stay signed in unless you clear cookies' UX. Logout paths are explicitly guarded via LOGOUT_PATHS so the renewer never resurrects a session the user is actively terminating. Query-string strip prevents /logout?next=foo bypass. Opportunistic-auth blocks (GET /, GET /f/:id) verify JWT directly without going through makeRequireAuth, so they don't slide — by design, a public file view shouldn't extend the owner's session. Tests cover threshold semantics, both logout paths, query-string handling, missing iat (legacy token forces refresh), and a full integration suite simulating 25-day and 31-day jumps via vi.setSystemTime.
123 lines
4.4 KiB
TypeScript
123 lines
4.4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import fastifyJwt from '@fastify/jwt';
|
|
import { slideSessionIfNeeded } from '../../src/middleware/session-renewal.ts';
|
|
import {
|
|
SESSION_COOKIE_NAME,
|
|
SESSION_RENEW_THRESHOLD_SECONDS,
|
|
SESSION_TTL_SECONDS,
|
|
} from '../../src/constants.ts';
|
|
|
|
interface TestPayload {
|
|
sub: number;
|
|
username: string;
|
|
iat?: number;
|
|
}
|
|
|
|
async function buildTestServer(): Promise<FastifyInstance> {
|
|
const app = Fastify();
|
|
await app.register(fastifyCookie);
|
|
await app.register(fastifyJwt, {
|
|
secret: 'test-secret',
|
|
cookie: { cookieName: SESSION_COOKIE_NAME, signed: false },
|
|
});
|
|
await app.ready();
|
|
return app;
|
|
}
|
|
|
|
const NOW_ISO = '2026-01-01T12:00:00Z';
|
|
const NOW_SEC = Math.floor(new Date(NOW_ISO).getTime() / 1000);
|
|
|
|
describe('slideSessionIfNeeded', () => {
|
|
let app: FastifyInstance;
|
|
|
|
beforeEach(async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(new Date(NOW_ISO));
|
|
app = await buildTestServer();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
await app.close();
|
|
});
|
|
|
|
function fakeReply(): { setCookieCalls: Array<{ name: string; value: string; opts: Record<string, unknown> }> } & Record<string, unknown> {
|
|
const setCookieCalls: Array<{ name: string; value: string; opts: Record<string, unknown> }> = [];
|
|
return {
|
|
setCookieCalls,
|
|
setCookie(name: string, value: string, opts: Record<string, unknown>) {
|
|
setCookieCalls.push({ name, value, opts });
|
|
return this;
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeRequest(url: string): { url: string } {
|
|
return { url };
|
|
}
|
|
|
|
it('re-issues cookie when now - iat >= SESSION_RENEW_THRESHOLD_SECONDS', () => {
|
|
const reply = fakeReply();
|
|
const payload: TestPayload = {
|
|
sub: 1,
|
|
username: 'alice',
|
|
iat: NOW_SEC - SESSION_RENEW_THRESHOLD_SECONDS,
|
|
};
|
|
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false);
|
|
expect(reply.setCookieCalls.length).toBe(1);
|
|
expect(reply.setCookieCalls[0].name).toBe(SESSION_COOKIE_NAME);
|
|
expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS);
|
|
});
|
|
|
|
it('skips re-issue when now - iat < SESSION_RENEW_THRESHOLD_SECONDS', () => {
|
|
const reply = fakeReply();
|
|
const payload: TestPayload = {
|
|
sub: 1,
|
|
username: 'alice',
|
|
iat: NOW_SEC - (SESSION_RENEW_THRESHOLD_SECONDS - 1),
|
|
};
|
|
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false);
|
|
expect(reply.setCookieCalls.length).toBe(0);
|
|
});
|
|
|
|
it('skips re-issue on /logout path', () => {
|
|
const reply = fakeReply();
|
|
const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 }; // ancient
|
|
slideSessionIfNeeded(fakeRequest('/logout') as never, reply as never, payload, app, false);
|
|
expect(reply.setCookieCalls.length).toBe(0);
|
|
});
|
|
|
|
it('skips re-issue on /api/v1/auth/logout path', () => {
|
|
const reply = fakeReply();
|
|
const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 };
|
|
slideSessionIfNeeded(fakeRequest('/api/v1/auth/logout') as never, reply as never, payload, app, false);
|
|
expect(reply.setCookieCalls.length).toBe(0);
|
|
});
|
|
|
|
it('skips re-issue when /logout has a query string', () => {
|
|
const reply = fakeReply();
|
|
const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 };
|
|
slideSessionIfNeeded(fakeRequest('/logout?redirect=foo') as never, reply as never, payload, app, false);
|
|
expect(reply.setCookieCalls.length).toBe(0);
|
|
});
|
|
|
|
it('treats missing iat as 0 (forces refresh)', () => {
|
|
const reply = fakeReply();
|
|
const payload: TestPayload = { sub: 1, username: 'alice' };
|
|
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false);
|
|
expect(reply.setCookieCalls.length).toBe(1);
|
|
});
|
|
|
|
it('produces a cookie carrying Max-Age=SESSION_TTL_SECONDS', () => {
|
|
const reply = fakeReply();
|
|
const payload: TestPayload = { sub: 1, username: 'alice', iat: 0 };
|
|
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, true);
|
|
expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS);
|
|
expect(reply.setCookieCalls[0].opts.secure).toBe(true);
|
|
expect(reply.setCookieCalls[0].opts.httpOnly).toBe(true);
|
|
expect(reply.setCookieCalls[0].opts.sameSite).toBe('strict');
|
|
});
|
|
});
|