b9f3a2ca0b
Replace the single `user.refreshToken` column with a proper Session
table so users can have multiple concurrent sessions (phone, laptop,
etc.), each with their own refresh token, expiry, label, and
remember-me flag.
- Add Session model (id, userId, tokenHash, label, userAgent,
ipAddress, rememberMe, lastUsedAt, expiresAt).
- Drop `User.refreshToken` and `User.refreshTokenExpiresAt`.
- authService: new createSession/validateSession/rotateSession/
revokeSession/listUserSessions helpers; remove refresh-token-on-user
functions.
- sessionCookies helper now issues a session_id cookie alongside
access_token and refresh_token; rotateSessionCookies keeps the same
session id on refresh.
- Login form adds a "Keep me signed in for 30 days" checkbox;
TTL is 7d by default, 30d with remember-me.
- User-Agent parsed into a friendly label ("Chrome on Windows") for
the upcoming sessions page.
- hooks.server.ts, refresh endpoint, logout, register, oauth callback,
and onboarding all switched to the new session API.
176 lines
4.9 KiB
TypeScript
176 lines
4.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock prisma before importing authService
|
|
vi.mock('../../prisma.js', () => ({
|
|
prisma: {
|
|
session: {
|
|
create: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
findMany: vi.fn()
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Set JWT_SECRET for tests
|
|
process.env.JWT_SECRET = 'test-secret-key-for-unit-tests';
|
|
|
|
import {
|
|
hashPassword,
|
|
verifyPassword,
|
|
signAccessToken,
|
|
verifyAccessToken,
|
|
generateRefreshToken,
|
|
createSession,
|
|
rotateSession,
|
|
validateSession
|
|
} from '../authService.js';
|
|
import { prisma } from '../../prisma.js';
|
|
|
|
describe('authService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('hashPassword / verifyPassword', () => {
|
|
it('hashes a password and verifies it correctly', async () => {
|
|
const password = 'mySecurePassword123';
|
|
const hash = await hashPassword(password);
|
|
|
|
expect(hash).not.toBe(password);
|
|
expect(hash.length).toBeGreaterThan(0);
|
|
|
|
const isValid = await verifyPassword(password, hash);
|
|
expect(isValid).toBe(true);
|
|
});
|
|
|
|
it('rejects wrong password', async () => {
|
|
const hash = await hashPassword('correct-password');
|
|
const isValid = await verifyPassword('wrong-password', hash);
|
|
expect(isValid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('signAccessToken / verifyAccessToken', () => {
|
|
it('signs and verifies a token', () => {
|
|
const payload = { userId: 'usr-1', email: 'test@test.com', role: 'user' };
|
|
const token = signAccessToken(payload);
|
|
|
|
expect(typeof token).toBe('string');
|
|
expect(token.split('.')).toHaveLength(3);
|
|
|
|
const decoded = verifyAccessToken(token);
|
|
expect(decoded.userId).toBe('usr-1');
|
|
expect(decoded.email).toBe('test@test.com');
|
|
expect(decoded.role).toBe('user');
|
|
});
|
|
|
|
it('throws for invalid token', () => {
|
|
expect(() => verifyAccessToken('invalid.token.value')).toThrow(
|
|
'Invalid or expired access token'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('generateRefreshToken', () => {
|
|
it('generates a hex string', () => {
|
|
const token = generateRefreshToken();
|
|
expect(typeof token).toBe('string');
|
|
expect(token.length).toBe(96); // 48 bytes * 2 hex chars
|
|
expect(/^[0-9a-f]+$/.test(token)).toBe(true);
|
|
});
|
|
|
|
it('generates unique tokens', () => {
|
|
const token1 = generateRefreshToken();
|
|
const token2 = generateRefreshToken();
|
|
expect(token1).not.toBe(token2);
|
|
});
|
|
});
|
|
|
|
describe('createSession', () => {
|
|
it('creates a session row and returns the raw refresh token', async () => {
|
|
vi.mocked(prisma.session.create).mockResolvedValue({
|
|
id: 'ses-1',
|
|
userId: 'usr-1',
|
|
tokenHash: 'hash',
|
|
label: 'Chrome on Windows',
|
|
userAgent: 'ua',
|
|
ipAddress: '127.0.0.1',
|
|
rememberMe: false,
|
|
lastUsedAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
createdAt: new Date()
|
|
} as never);
|
|
|
|
const result = await createSession('usr-1', { userAgent: 'ua', ipAddress: '127.0.0.1' });
|
|
|
|
expect(result.sessionId).toBe('ses-1');
|
|
expect(result.refreshToken.length).toBe(96);
|
|
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
|
expect(prisma.session.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('extends expiry for remember-me sessions', async () => {
|
|
vi.mocked(prisma.session.create).mockImplementation(
|
|
(({ data }: { data: Record<string, unknown> }) =>
|
|
Promise.resolve({
|
|
id: 'ses-2',
|
|
...data,
|
|
lastUsedAt: new Date(),
|
|
createdAt: new Date()
|
|
})) as never
|
|
);
|
|
|
|
const result = await createSession('usr-1', { rememberMe: true });
|
|
|
|
const diffDays = (result.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
|
|
expect(diffDays).toBeGreaterThan(29);
|
|
expect(diffDays).toBeLessThan(31);
|
|
});
|
|
});
|
|
|
|
describe('validateSession', () => {
|
|
it('returns null for missing session', async () => {
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue(null);
|
|
const result = await validateSession('ses-x', 'token');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null for expired session', async () => {
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
id: 'ses-1',
|
|
userId: 'usr-1',
|
|
tokenHash: 'hash',
|
|
rememberMe: false,
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
lastUsedAt: new Date(),
|
|
createdAt: new Date(),
|
|
label: null,
|
|
userAgent: null,
|
|
ipAddress: null
|
|
} as never);
|
|
const result = await validateSession('ses-1', 'token');
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('rotateSession', () => {
|
|
it('updates token hash and keeps the same session id', async () => {
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
id: 'ses-1',
|
|
userId: 'usr-1',
|
|
rememberMe: false,
|
|
expiresAt: new Date(Date.now() + 1000)
|
|
} as never);
|
|
vi.mocked(prisma.session.update).mockResolvedValue({} as never);
|
|
|
|
const result = await rotateSession('ses-1');
|
|
|
|
expect(result.sessionId).toBe('ses-1');
|
|
expect(result.refreshToken.length).toBe(96);
|
|
expect(prisma.session.update).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|