Files
nanodrop/tests/integration/session-persistence.test.ts
Brendan Chen 0f0c2f0e96 feat(auth): sliding session renewal middleware
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.
2026-05-09 10:15:31 -07:00

101 lines
3.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createTestApp, type TestContext, loginAs } from '../helpers/setup.ts';
import { createUser } from '../../src/db/users.ts';
import { hashPassword } from '../../src/services/auth.ts';
describe('session persistence (sliding renewal)', () => {
let ctx: TestContext;
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
ctx = createTestApp();
const hash = await hashPassword('secret');
createUser(ctx.db, { username: 'alice', passwordHash: hash });
});
afterEach(async () => {
vi.useRealTimers();
await ctx.app.close();
ctx.cleanup();
});
it('login response includes Set-Cookie with Max-Age=2592000', async () => {
const res = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'alice', password: 'secret' }),
});
expect(res.statusCode).toBe(200);
expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/);
expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/);
});
it('request 25 days after login triggers a fresh Set-Cookie', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-01-26T00:00:00Z')); // +25 days
const res = await ctx.app.inject({
method: 'GET',
url: '/api/v1/files',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(200);
expect(res.headers['set-cookie']).toBeTruthy();
expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/);
expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/);
});
it('request within renewal threshold does NOT include a fresh Set-Cookie', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-01-01T00:30:00Z')); // +30 minutes (under 1 hour)
const res = await ctx.app.inject({
method: 'GET',
url: '/api/v1/files',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(200);
expect(res.headers['set-cookie']).toBeFalsy();
});
it('logout still clears cookie even after renewal threshold has passed', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-01-01T02:00:00Z')); // +2 hours (past threshold)
const res = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(200);
const setCookie = res.headers['set-cookie'] as string | string[];
const setCookieStr = Array.isArray(setCookie) ? setCookie.join('\n') : setCookie;
// Cookie cleared (Max-Age=0 or empty value)
expect(setCookieStr).toMatch(/nanodrop_session=;/);
// No fresh session cookie issued by the renewer
const freshIssue = /nanodrop_session=eyJ/.test(setCookieStr);
expect(freshIssue).toBe(false);
});
it('request 31 days after login is bounced (idle lapse)', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-02-01T01:00:00Z')); // +31 days, past 30-day TTL
const res = await ctx.app.inject({
method: 'GET',
url: '/api/v1/files',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(401);
});
it('anonymous request to /api/v1/files returns 401 with no Set-Cookie', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/files' });
expect(res.statusCode).toBe(401);
expect(res.headers['set-cookie']).toBeFalsy();
});
});