Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"userAgent" TEXT,
"ipAddress" TEXT,
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"revokedAt" DATETIME,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE INDEX "Session_userId_idx" ON "Session"("userId");
15 changes: 15 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ model User {
applications JobApplication[]
apiTokens ApiToken[]
emailVerificationTokens EmailVerificationToken[]
sessions Session[]
}

model Session {
id String @id
userId String
userAgent String?
ipAddress String?
lastUsedAt DateTime @default(now())
createdAt DateTime @default(now())
expiresAt DateTime
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
}

model EmailVerificationToken {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CreateSessionUseCase } from '@/use-cases/sessions/CreateSessionUseCase.js';
import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js';
import { SESSION } from '@/constants.js';

describe('CreateSessionUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('creates a session with a generated id and device info', async () => {
const sessionRepository = makeSessionRepository({
create: vi.fn().mockResolvedValue(makeSession({ id: 'generated-id' })),
});
const generateId = vi.fn().mockReturnValue('generated-id');

await new CreateSessionUseCase({ sessionRepository, generateId }).execute({
userId: 'user-1',
userAgent: 'Mozilla/5.0',
ipAddress: '10.0.0.1',
});

expect(sessionRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'generated-id',
userId: 'user-1',
userAgent: 'Mozilla/5.0',
ipAddress: '10.0.0.1',
}),
);
});

it('sets expiresAt roughly SESSION.TTL_MS in the future', async () => {
const sessionRepository = makeSessionRepository({
create: vi.fn().mockResolvedValue(makeSession()),
});
const generateId = vi.fn().mockReturnValue('generated-id');

const before = Date.now();
await new CreateSessionUseCase({ sessionRepository, generateId }).execute({
userId: 'user-1',
userAgent: null,
ipAddress: null,
});
const after = Date.now();

const createCall = vi.mocked(sessionRepository.create).mock.calls[0][0];
const expiresAtMs = createCall.expiresAt.getTime();
expect(expiresAtMs).toBeGreaterThanOrEqual(before + SESSION.TTL_MS - 1000);
expect(expiresAtMs).toBeLessThanOrEqual(after + SESSION.TTL_MS + 1000);
});

it('returns the created session', async () => {
const session = makeSession({ id: 'generated-id' });
const sessionRepository = makeSessionRepository({ create: vi.fn().mockResolvedValue(session) });

const result = await new CreateSessionUseCase({
sessionRepository,
generateId: vi.fn().mockReturnValue('generated-id'),
}).execute({ userId: 'user-1', userAgent: null, ipAddress: null });

expect(result).toEqual(session);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ListSessionsUseCase } from '@/use-cases/sessions/ListSessionsUseCase.js';
import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js';

describe('ListSessionsUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns active sessions for the given user', async () => {
const sessions = [makeSession({ id: 's1' }), makeSession({ id: 's2' })];
const sessionRepository = makeSessionRepository({
findActiveByUserId: vi.fn().mockResolvedValue(sessions),
});

const result = await new ListSessionsUseCase({ sessionRepository }).execute('user-1');

expect(sessionRepository.findActiveByUserId).toHaveBeenCalledWith('user-1');
expect(result).toEqual(sessions);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RevokeOtherSessionsUseCase } from '@/use-cases/sessions/RevokeOtherSessionsUseCase.js';
import { makeSessionRepository } from '@/__tests__/helpers/mocks.js';

describe('RevokeOtherSessionsUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('revokes all sessions for the user except the current one', async () => {
const sessionRepository = makeSessionRepository();

await new RevokeOtherSessionsUseCase({ sessionRepository }).execute('user-1', 'session-1');

expect(sessionRepository.revokeAllForUserExcept).toHaveBeenCalledWith('user-1', 'session-1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RevokeSessionUseCase } from '@/use-cases/sessions/RevokeSessionUseCase.js';
import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js';

describe('RevokeSessionUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('throws NOT_FOUND when the session does not belong to the user', async () => {
const sessionRepository = makeSessionRepository({
findByIdAndUserId: vi.fn().mockResolvedValue(null),
});

const err = await new RevokeSessionUseCase({ sessionRepository })
.execute('session-1', 'user-1')
.catch((e) => e);

expect((err as { code: string }).code).toBe('NOT_FOUND');
expect(sessionRepository.revoke).not.toHaveBeenCalled();
});

it('revokes the session when owned by the user', async () => {
const session = makeSession({ id: 'session-1', userId: 'user-1' });
const sessionRepository = makeSessionRepository({
findByIdAndUserId: vi.fn().mockResolvedValue(session),
});

await new RevokeSessionUseCase({ sessionRepository }).execute('session-1', 'user-1');

expect(sessionRepository.findByIdAndUserId).toHaveBeenCalledWith('session-1', 'user-1');
expect(sessionRepository.revoke).toHaveBeenCalledWith('session-1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TouchSessionUseCase } from '@/use-cases/sessions/TouchSessionUseCase.js';
import { makeSessionRepository, makeSession } from '@/__tests__/helpers/mocks.js';

describe('TouchSessionUseCase', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('throws UNAUTHORIZED when the session does not exist', async () => {
const sessionRepository = makeSessionRepository({ findById: vi.fn().mockResolvedValue(null) });

const err = await new TouchSessionUseCase({ sessionRepository })
.execute('missing')
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
expect(sessionRepository.touch).not.toHaveBeenCalled();
});

it('throws UNAUTHORIZED when the session has been revoked', async () => {
const session = makeSession({ revokedAt: new Date() });
const sessionRepository = makeSessionRepository({
findById: vi.fn().mockResolvedValue(session),
});

const err = await new TouchSessionUseCase({ sessionRepository })
.execute('session-1')
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
expect(sessionRepository.touch).not.toHaveBeenCalled();
});

it('throws UNAUTHORIZED when the session has expired', async () => {
const session = makeSession({ expiresAt: new Date(Date.now() - 1000) });
const sessionRepository = makeSessionRepository({
findById: vi.fn().mockResolvedValue(session),
});

const err = await new TouchSessionUseCase({ sessionRepository })
.execute('session-1')
.catch((e) => e);

expect((err as { code: string }).code).toBe('UNAUTHORIZED');
});

it('updates lastUsedAt/expiresAt for a valid session', async () => {
const session = makeSession({ id: 'session-1', expiresAt: new Date(Date.now() + 60_000) });
const sessionRepository = makeSessionRepository({
findById: vi.fn().mockResolvedValue(session),
});

await new TouchSessionUseCase({ sessionRepository }).execute('session-1');

expect(sessionRepository.touch).toHaveBeenCalledWith('session-1', expect.any(Date));
});
});
12 changes: 12 additions & 0 deletions apps/api/src/__tests__/helpers/createTestDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ const SCHEMA_STATEMENTS = [
FOREIGN KEY ("applicationId") REFERENCES "JobApplication"("id") ON DELETE CASCADE
)`,
`CREATE INDEX "Contact_applicationId_idx" ON "Contact"("applicationId")`,
`CREATE TABLE "Session" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL,
"userAgent" TEXT,
"ipAddress" TEXT,
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"revokedAt" DATETIME,
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE
)`,
`CREATE INDEX "Session_userId_idx" ON "Session"("userId")`,
`CREATE TABLE "EmailVerificationToken" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL,
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/__tests__/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { IInterviewRoundRepository } from '@/use-cases/ports/IInterviewRoun
import type { IActivityLogRepository } from '@/use-cases/ports/IActivityLogRepository.js';
import type { IContactRepository } from '@/use-cases/ports/IContactRepository.js';
import type { IStorageProvider } from '@/use-cases/ports/IStorageProvider.js';
import type { ISessionRepository } from '@/use-cases/ports/ISessionRepository.js';
import type { Session } from '@/domain/session/Session.js';
import type { IEmailVerificationTokenRepository } from '@/use-cases/ports/IEmailVerificationTokenRepository.js';
import type { EmailVerificationToken } from '@/domain/emailVerificationToken/EmailVerificationToken.js';
import type { User } from '@/domain/user/User.js';
Expand Down Expand Up @@ -110,6 +112,31 @@ export const makeApiToken = (overrides?: Partial<ApiToken>): ApiToken => ({
...overrides,
});

export const makeSessionRepository = (
overrides?: Partial<ISessionRepository>,
): ISessionRepository => ({
create: vi.fn(),
findById: vi.fn().mockResolvedValue(null),
findByIdAndUserId: vi.fn().mockResolvedValue(null),
findActiveByUserId: vi.fn().mockResolvedValue([]),
touch: vi.fn().mockResolvedValue(undefined),
revoke: vi.fn().mockResolvedValue(undefined),
revokeAllForUserExcept: vi.fn().mockResolvedValue(undefined),
...overrides,
});

export const makeSession = (overrides?: Partial<Session>): Session => ({
id: 'session-1',
userId: 'user-1',
userAgent: 'Mozilla/5.0 (test)',
ipAddress: '127.0.0.1',
lastUsedAt: new Date('2024-01-01T00:00:00.000Z'),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
expiresAt: new Date('2024-01-08T00:00:00.000Z'),
revokedAt: null,
...overrides,
});

export const makeEmailVerificationTokenRepository = (
overrides?: Partial<IEmailVerificationTokenRepository>,
): IEmailVerificationTokenRepository => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@ describe('FastifyJwtTokenService', () => {

describe('sign', () => {
it('signs an access token with the user payload and ACCESS expiry, no explicit key', () => {
service.sign('user-1', 'user@example.com');
service.sign('user-1', 'user@example.com', 'session-1');

expect(fastifyJwt.jwt.sign).toHaveBeenNthCalledWith(
1,
{ sub: 'user-1', email: 'user@example.com' },
{ sub: 'user-1', email: 'user@example.com', sid: 'session-1' },
{ expiresIn: JWT_EXPIRY.ACCESS },
);
});

it('signs a refresh token with the refresh secret and REFRESH expiry', () => {
service.sign('user-1', 'user@example.com');
service.sign('user-1', 'user@example.com', 'session-1');

expect(fastifyJwt.jwt.sign).toHaveBeenNthCalledWith(
2,
{ sub: 'user-1', email: 'user@example.com' },
{ sub: 'user-1', email: 'user@example.com', sid: 'session-1' },
{ key: REFRESH_SECRET, expiresIn: JWT_EXPIRY.REFRESH },
);
});
Expand All @@ -43,7 +43,7 @@ describe('FastifyJwtTokenService', () => {
.mockReturnValueOnce('access-token-value')
.mockReturnValueOnce('refresh-token-value');

const result = service.sign('user-1', 'user@example.com');
const result = service.sign('user-1', 'user@example.com', 'session-1');

expect(result).toEqual({
accessToken: 'access-token-value',
Expand All @@ -57,14 +57,15 @@ describe('FastifyJwtTokenService', () => {
vi.mocked(fastifyJwt.jwt.verify).mockReturnValue({
sub: 'user-1',
email: 'user@example.com',
sid: 'session-1',
});

const result = service.verifyRefresh('a-refresh-token');

expect(fastifyJwt.jwt.verify).toHaveBeenCalledWith('a-refresh-token', {
key: REFRESH_SECRET,
});
expect(result).toEqual({ sub: 'user-1', email: 'user@example.com' });
expect(result).toEqual({ sub: 'user-1', email: 'user@example.com', sid: 'session-1' });
});

it('throws an UNAUTHORIZED-coded error when verification fails', () => {
Expand Down
Loading
Loading