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(); }); });