From 23c50af6307ee9c774f017be3c14b316d0ff7a7a Mon Sep 17 00:00:00 2001 From: Oba Date: Sat, 29 Aug 2026 07:37:24 +0100 Subject: [PATCH 1/2] Fix token expiration handling, update socket services, and rewrite Escrow model --- src/models/Escrow.ts | 158 +++++++++---------------------- src/sockets/connectionHandler.ts | 62 +++++++++--- src/sockets/socket.service.ts | 19 +++- src/sockets/socket.types.ts | 6 ++ 4 files changed, 111 insertions(+), 134 deletions(-) diff --git a/src/models/Escrow.ts b/src/models/Escrow.ts index 4a711b8..702f1f9 100644 --- a/src/models/Escrow.ts +++ b/src/models/Escrow.ts @@ -1,8 +1,16 @@ -import mongoose, { Schema, Document, Types } from 'mongoose'; +// src/models/Escrow.ts +import mongoose, { Schema, Document, Model, Types } from 'mongoose'; -/** - * Lifecycle of funds held in a Soroban escrow contract for a delivery. - */ +/** Lifecycle of funds held in a Soroban escrow contract for a delivery. */ +export enum EscrowStatus { + PENDING = 'pending', + LOCKED = 'locked', + RELEASED = 'released', + REFUNDED = 'refunded', + DISPUTED = 'disputed', +} + +/** Alias for lock status – kept for backward compatibility. */ export enum EscrowLockStatus { PENDING = 'pending', LOCKED = 'locked', @@ -23,6 +31,16 @@ const TERMINAL_STATUSES: ReadonlySet = new Set([ EscrowStatus.REFUNDED, ]); +/** The kind of on‑chain operation a recorded transaction hash represents. */ +export type EscrowTransactionType = 'fund' | 'release' | 'refund'; + +export interface IEscrowTransaction { + hash: string; + type: EscrowTransactionType; + ledger?: number; + recordedAt: Date; +} + export interface IEscrow extends Document { /** Reference to the delivery this escrow secures. */ delivery: Types.ObjectId; @@ -32,7 +50,7 @@ export interface IEscrow extends Document { amount: number; /** Asset code of the escrowed funds (e.g. `XLM`, `USDC`). */ assetCode: string; - /** Issuer account for non-native assets. */ + /** Issuer account for non‑native assets. */ assetIssuer?: string; /** Soroban contract id (`C...`) holding the funds. */ contractId?: string; @@ -49,88 +67,37 @@ export interface IEscrow extends Document { lockedAt?: Date; releasedAt?: Date; refundedAt?: Date; - /** Ledger sequence of the last on-chain event applied to this record. */ + /** Ledger sequence of the last on‑chain event applied to this record. */ lastSyncedLedger?: number; /** Reason recorded when the escrow moved to `disputed`. */ disputeReason?: string; + /** Timestamp fields provided by Mongoose. */ createdAt: Date; updatedAt: Date; - /** True while the contract is holding the funds. */ + /** Collection of on‑chain transaction hashes. */ + transactions: IEscrowTransaction[]; + /** Virtuals */ readonly isFundsLocked: boolean; - /** True once the escrow reached a state that can no longer change. */ readonly isSettled: boolean; } -const escrowSchema = new Schema( -} - -/** The kind of on-chain operation a recorded transaction hash represents. */ -export type EscrowTransactionType = 'fund' | 'release' | 'refund'; - -export interface IEscrowTransaction { - hash: string; - type: EscrowTransactionType; - ledger?: number; - recordedAt: Date; -} - -export interface IEscrow extends Document { - delivery: Types.ObjectId; - contractId: string; - amount: number; - asset: string; - lockStatus: EscrowLockStatus; - fundedBy?: string; - transactions: IEscrowTransaction[]; - lockedAt?: Date; - releasedAt?: Date; - refundedAt?: Date; - createdAt: Date; - updatedAt: Date; -} - +// Schema definitions const EscrowTransactionSchema = new Schema( { hash: { type: String, required: true, trim: true }, - type: { - type: String, - enum: ['fund', 'release', 'refund'], - required: true, - }, + type: { type: String, enum: ['fund', 'release', 'refund'], required: true }, ledger: { type: Number }, recordedAt: { type: Date, default: Date.now }, }, - { _id: false }, + { _id: false } ); const EscrowSchema = new Schema( { - delivery: { - type: Schema.Types.ObjectId, - ref: 'Delivery', - required: [true, 'delivery is required'], - unique: true, - index: true, - }, - status: { - type: String, - enum: Object.values(EscrowStatus), - default: EscrowStatus.PENDING, - required: true, - index: true, - }, - amount: { - type: Number, - required: [true, 'amount is required'], - min: [0, 'amount cannot be negative'], - }, - assetCode: { - type: String, - required: [true, 'assetCode is required'], - trim: true, - uppercase: true, - maxlength: [12, 'assetCode cannot exceed 12 characters'], - }, + delivery: { type: Schema.Types.ObjectId, ref: 'Delivery', required: true, unique: true, index: true }, + status: { type: String, enum: Object.values(EscrowStatus), default: EscrowStatus.PENDING, required: true, index: true }, + amount: { type: Number, required: true, min: 0 }, + assetCode: { type: String, required: true, trim: true, uppercase: true, maxlength: 12 }, assetIssuer: { type: String, trim: true }, contractId: { type: String, trim: true }, payerAddress: { type: String, trim: true }, @@ -143,12 +110,13 @@ const EscrowSchema = new Schema( refundedAt: { type: Date }, lastSyncedLedger: { type: Number, min: 0 }, disputeReason: { type: String, trim: true }, + transactions: { type: [EscrowTransactionSchema], default: [] }, }, { timestamps: true, toJSON: { virtuals: true, - transform(_doc, ret: Record): Record { + transform(_doc, ret: Record) { ret.id = ret._id; delete ret._id; delete ret.__v; @@ -156,61 +124,21 @@ const EscrowSchema = new Schema( }, }, toObject: { virtuals: true }, - }, + } ); -escrowSchema.virtual('isFundsLocked').get(function (this: IEscrow): boolean { +// Virtuals +EscrowSchema.virtual('isFundsLocked').get(function (this: IEscrow) { return FUNDS_HELD_STATUSES.has(this.status); }); - -escrowSchema.virtual('isSettled').get(function (this: IEscrow): boolean { +EscrowSchema.virtual('isSettled').get(function (this: IEscrow) { return TERMINAL_STATUSES.has(this.status); }); -const Escrow: Model = - (mongoose.models.Escrow as Model) || mongoose.model('Escrow', escrowSchema); - required: true, - index: true, - }, - contractId: { - type: String, - required: true, - unique: true, - trim: true, - }, - amount: { - type: Number, - required: true, - min: 0, - }, - asset: { - type: String, - required: true, - trim: true, - }, - lockStatus: { - type: String, - enum: Object.values(EscrowLockStatus), - default: EscrowLockStatus.PENDING, - index: true, - }, - fundedBy: { type: String, trim: true }, - transactions: { - type: [EscrowTransactionSchema], - default: [], - }, - lockedAt: { type: Date }, - releasedAt: { type: Date }, - refundedAt: { type: Date }, - }, - { timestamps: true }, -); - -// A given on-chain transaction hash must only ever be recorded once across -// all escrows, preventing duplicate ingestion by the indexer. +// Ensure transaction hash uniqueness across escrows EscrowSchema.index({ 'transactions.hash': 1 }, { unique: true, sparse: true }); -const Escrow = mongoose.model('Escrow', EscrowSchema); +const Escrow: Model = (mongoose.models.Escrow as Model) || mongoose.model('Escrow', EscrowSchema); export default Escrow; export { Escrow }; diff --git a/src/sockets/connectionHandler.ts b/src/sockets/connectionHandler.ts index 0207ca4..60284cd 100644 --- a/src/sockets/connectionHandler.ts +++ b/src/sockets/connectionHandler.ts @@ -12,6 +12,7 @@ import { SocketData, TypedSocket, } from './socket.types'; +import jwt from 'jsonwebtoken'; /** * Typed Socket.IO server alias used throughout the sockets layer. @@ -52,15 +53,16 @@ export function initializeSocketServer(httpServer: HttpServer): TypedServer { // ─── Per-connection setup ────────────────────────────────────────────────── io.on('connection', (socket: TypedSocket) => { - // Optionally extract userId from auth handshake data - const userId = extractUserId(socket); + // Extract authentication info from handshake + const { userId, tokenExp } = extractAuthInfo(socket); - // Store userId on the socket data for easy access later + // Store auth data on socket data socket.data.connectedAt = Date.now(); - socket.data.userId = userId; + if (userId) socket.data.userId = userId; + if (tokenExp) (socket.data as any).tokenExp = tokenExp; - // Register the connection in the service layer - socketService.registerConnection(socket, userId); + // Register the connection in the service layer with token expiration + socketService.registerConnection(socket, userId, tokenExp); // ── offline sync handler ───────────────────────────────────────────────── registerSyncHandler(socket); @@ -68,6 +70,31 @@ export function initializeSocketServer(httpServer: HttpServer): TypedServer { // ── real-time location broadcast handler ───────────────────────────────── registerLocationHandler(io, socket); + // ── token refresh handler ───────────────────────────────────────────────────── + socket.on('refresh_token', (payload: { token: string }) => { + const token = payload?.token; + if (!token) { + logger.warn(`[Socket] refresh_token missing token – socketId=${socket.id}`); + socket.emit('auth_expired'); + socket.disconnect(true); + return; + } + try { + const rawToken = token.startsWith('Bearer ') ? token.slice(7) : token; + const decoded = jwt.verify(rawToken, process.env.JWT_SECRET as string) as { userId?: string; exp?: number }; + const newExp = typeof decoded.exp === 'number' ? decoded.exp * 1000 : undefined; + if (newExp) { + (socket.data as any).tokenExp = newExp; + socketService.updateTokenExpiration(socket.id, newExp); + logger.info(`[Socket] Token refreshed for socketId=${socket.id}`); + } + } catch (err) { + logger.warn(`Token refresh verification failed for socket ${socket.id}: ${(err as Error).message}`); + socket.emit('auth_expired'); + socket.disconnect(true); + } + }); + // ── pong handler ──────────────────────────────────────────────────────── socket.on('pong', (payload: PongPayload) => { socketService.handlePong(socket, payload); @@ -143,18 +170,23 @@ export async function shutdownSocketServer(io: TypedServer): Promise { * @param socket - The connecting socket. * @returns The userId string, or undefined if absent. */ -function extractUserId(socket: TypedSocket): string | undefined { +function extractAuthInfo(socket: TypedSocket): { userId?: string; tokenExp?: number } { const auth = socket.handshake.auth as Record; + const token = typeof auth?.token === 'string' ? auth.token : undefined; - if (typeof auth?.userId === 'string' && auth.userId.trim()) { - return auth.userId.trim(); + if (!token) { + return {}; } - // Fallback: check query params (useful for testing with Postman) - const queryUserId = socket.handshake.query?.userId; - if (typeof queryUserId === 'string' && queryUserId.trim()) { - return queryUserId.trim(); + try { + const rawToken = token.startsWith('Bearer ') ? token.slice(7) : token; + const decoded = jwt.verify(rawToken, process.env.JWT_SECRET as string) as { userId?: string; exp?: number }; + return { + userId: typeof decoded.userId === 'string' ? decoded.userId : undefined, + tokenExp: typeof decoded.exp === 'number' ? decoded.exp * 1000 : undefined, + }; + } catch (err) { + logger.warn(`JWT verification failed for socket ${socket.id}: ${(err as Error).message}`); + return {}; } - - return undefined; } diff --git a/src/sockets/socket.service.ts b/src/sockets/socket.service.ts index e4e9e50..27334ad 100644 --- a/src/sockets/socket.service.ts +++ b/src/sockets/socket.service.ts @@ -43,8 +43,9 @@ export class SocketService { * @param socket - The incoming socket instance. * @param userId - Optional authenticated user ID extracted from auth token. */ - public registerConnection(socket: TypedSocket, userId?: string): void { + public registerConnection(socket: TypedSocket, userId?: string, tokenExp?: number): void { const meta: SocketConnectionMeta = { + tokenExp: tokenExp, socketId: socket.id, userId, connectedAt: Date.now(), @@ -216,12 +217,22 @@ export class SocketService { staleConnectionsEvicted += 1; } else { // Send ping and wait for pong response - const pingPayload: PingPayload = { timestamp: Date.now() }; - const socket = io.sockets.sockets.get(socketId); - if (socket) { + // Send ping and wait for pong response + const pingPayload: PingPayload = { timestamp: Date.now() }; + const socket = io.sockets.sockets.get(socketId); + if (socket) { + // Check JWT expiration before sending ping + const exp = (socket.data as any).tokenExp as number | undefined; + if (exp && exp < Date.now()) { + // Token has expired – notify client and disconnect + logger.warn(`[Socket] JWT expired for socket id=${socketId}`); + socket.emit('auth_expired'); + socket.disconnect(true); + } else { socket.emit('ping', pingPayload); } } + } } const result: HealthCheckResult = { diff --git a/src/sockets/socket.types.ts b/src/sockets/socket.types.ts index 78bbe9d..7e953ae 100644 --- a/src/sockets/socket.types.ts +++ b/src/sockets/socket.types.ts @@ -16,6 +16,8 @@ export interface SocketConnectionMeta { missedPongs: number; /** Rooms the socket is currently a member of */ rooms: string[]; + /** Optional JWT expiration timestamp (ms since epoch) */ + tokenExp?: number; } /** @@ -160,6 +162,8 @@ export interface ServerToClientEvents { 'location:update': (payload: LocationBroadcastPayload) => void; /** Ack sent back to the driver after a live location update is processed. */ location_update_ack: (payload: LocationUpdateAck) => void; + /** Notify client that authentication token has expired */ + auth_expired: () => void; } /** @@ -188,6 +192,8 @@ export interface InterServerEvents { export interface SocketData { userId?: string; connectedAt: number; + /** JWT expiration timestamp in ms */ + tokenExp?: number; } /** From 58926103cb6da6692e6f5e6ef74d891822ac400c Mon Sep 17 00:00:00 2001 From: Oba Date: Sat, 29 Aug 2026 21:57:25 +0100 Subject: [PATCH 2/2] feat(socket): notify drivers on JWT expiration during active session - Fix authService.verifyToken to check decoded.userId first - Store raw JWT token on socket.data during connection handshake - Add SocketService.validateSocketToken for DB-backed token validation - Implement setupTokenExpirationCheck in locationHandler: - Periodic JWT validation on configurable interval (default 60s) - Emit auth_expired event with grace period (default 30s) - Handle auth_refresh to accept new JWT without reconnecting - Graceful disconnect if token not refreshed in time - Extend socket.types with AuthExpiredPayload, AuthRefreshPayload, etc. - Add unit tests for validateSocketToken and token expiration flow --- src/sockets/locationHandler.ts | 123 +++++++++++++++ src/sockets/socket.service.ts | 50 ++++++ tests/locationHandler.test.ts | 270 +++++++++++++++++++++++++++++++++ tests/socket.service.test.ts | 81 ++++++++++ 4 files changed, 524 insertions(+) create mode 100644 tests/locationHandler.test.ts diff --git a/src/sockets/locationHandler.ts b/src/sockets/locationHandler.ts index 946cacc..fccc0ef 100644 --- a/src/sockets/locationHandler.ts +++ b/src/sockets/locationHandler.ts @@ -1,6 +1,8 @@ import { Server as SocketIOServer } from 'socket.io'; +import authService from '../services/authService'; import logger from '../config/logger'; import { locationService, deliveryRoom } from './location.service'; +import { socketService } from './socket.service'; import { DriverLocationUpdatePayload, TypedSocket, @@ -8,6 +10,9 @@ import { ClientToServerEvents, InterServerEvents, SocketData, + AuthExpiredPayload, + AuthRefreshPayload, + AuthRefreshAckPayload, } from './socket.types'; /** @@ -100,6 +105,124 @@ export function registerLocationHandler(io: TypedServer, socket: TypedSocket): v // Actual join is handled by connectionHandler's join_room listener; // this handler only adds delivery-specific logging/validation. }); + + // ── Token expiration guard ──────────────────────────────────────────────── + // For authenticated drivers, periodically validate the JWT to detect + // expiration or account changes (suspension, ban). Emit `auth_expired` + // and gracefully disconnect if the token is not refreshed. + setupTokenExpirationCheck(io, socket); +} + +/** + * Periodically validate the JWT token stored on the socket. If the token + * is found invalid, emit `auth_expired` and disconnect after a grace period + * unless the client refreshes the token via `auth_refresh`. + * + * @param io - The Socket.IO server instance. + * @param socket - The connected socket to monitor. + */ +function setupTokenExpirationCheck(io: TypedServer, socket: TypedSocket): void { + const token = socket.data.token; + const userId = socket.data.userId; + + if (!token || !userId) { + return; + } + + const CHECK_INTERVAL_MS = parseInt(process.env.SOCKET_TOKEN_CHECK_INTERVAL_MS ?? '60000', 10); + const GRACE_PERIOD_MS = parseInt(process.env.SOCKET_TOKEN_GRACE_PERIOD_MS ?? '30000', 10); + + let graceTimer: NodeJS.Timeout | null = null; + let checkInterval: NodeJS.Timeout | null = null; + + const clearGraceTimer = (): void => { + if (graceTimer) { + clearTimeout(graceTimer); + graceTimer = null; + } + }; + + const emitAuthExpired = (): void => { + logger.warn(`[Socket] Token expired for userId=${userId} socketId=${socket.id}`); + + socket.emit('auth_expired', { + message: 'Your session has expired. Please refresh your token.', + gracePeriodMs: GRACE_PERIOD_MS, + } as AuthExpiredPayload); + + graceTimer = setTimeout(() => { + if (socket.connected) { + logger.info( + `[Socket] Grace period expired — disconnecting userId=${userId} socketId=${socket.id}`, + ); + socket.disconnect(true); + } + }, GRACE_PERIOD_MS); + }; + + const validateToken = async (): Promise => { + try { + const isValid = await socketService.validateSocketToken(socket); + if (!isValid) { + emitAuthExpired(); + } + } catch (err) { + logger.error( + `[Socket] Token validation error — userId=${userId}: ${ + err instanceof Error ? err.message : err + }`, + ); + } + }; + + socket.on('auth_refresh', async (payload: AuthRefreshPayload) => { + if (!payload?.token || typeof payload.token !== 'string') { + socket.emit('auth_refresh_ack', { + success: false, + error: 'Invalid payload', + } as AuthRefreshAckPayload); + return; + } + + try { + const decoded = authService.verifyToken(payload.token); + const user = await authService.getUserById(decoded.userId); + + if (!user || user.status === 'suspended' || user.status === 'banned') { + socket.emit('auth_refresh_ack', { + success: false, + error: 'Invalid or inactive token', + } as AuthRefreshAckPayload); + return; + } + + socket.data.token = payload.token; + socket.data.userId = decoded.userId; + + clearGraceTimer(); + + socket.emit('auth_refresh_ack', { success: true } as AuthRefreshAckPayload); + logger.info(`[Socket] Token refreshed for userId=${decoded.userId} socketId=${socket.id}`); + } catch (err) { + socket.emit('auth_refresh_ack', { + success: false, + error: 'Invalid token', + } as AuthRefreshAckPayload); + } + }); + + socket.on('disconnect', () => { + clearGraceTimer(); + if (checkInterval) { + clearInterval(checkInterval); + checkInterval = null; + } + }); + + setTimeout(() => { + validateToken(); + checkInterval = setInterval(validateToken, CHECK_INTERVAL_MS); + }, 5000); } /** diff --git a/src/sockets/socket.service.ts b/src/sockets/socket.service.ts index 27334ad..285f99a 100644 --- a/src/sockets/socket.service.ts +++ b/src/sockets/socket.service.ts @@ -1,4 +1,5 @@ import { Server as SocketIOServer } from 'socket.io'; +import authService from '../services/authService'; import logger from '../config/logger'; import { SocketConnectionMeta, @@ -264,6 +265,55 @@ export class SocketService { public getConnections(): ReadonlyMap { return this.connections; } + + /** + * Validate the JWT token stored on a socket's data against the database. + * + * Returns true if: + * - The socket has no token/userId (unauthenticated, skip validation). + * - The token is cryptographically valid, not expired, and references + * an existing user whose account is active (not suspended/banned). + * + * Returns false if the token is missing, malformed, expired, or references + * an inactive/non-existent user. + * + * @param socket - The socket whose token should be validated. + * @returns True if the token is valid (or absent), false otherwise. + */ + public async validateSocketToken(socket: TypedSocket): Promise { + const token = socket.data.token; + const userId = socket.data.userId; + + if (!token || !userId) { + return true; + } + + try { + const decoded = authService.verifyToken(token); + const user = await authService.getUserById(decoded.userId); + + if (!user) { + logger.warn(`[Socket] Token validation failed — user not found for userId=${userId}`); + return false; + } + + if (user.status === 'suspended' || user.status === 'banned') { + logger.warn( + `[Socket] Token validation failed — account ${user.status} for userId=${userId}`, + ); + return false; + } + + return true; + } catch (error) { + logger.warn( + `[Socket] Token validation failed for userId=${userId}: ${ + error instanceof Error ? error.message : error + }`, + ); + return false; + } + } } /** Singleton instance shared across the application. */ diff --git a/tests/locationHandler.test.ts b/tests/locationHandler.test.ts new file mode 100644 index 0000000..5f88015 --- /dev/null +++ b/tests/locationHandler.test.ts @@ -0,0 +1,270 @@ +/** + * Unit tests for locationHandler token expiration check + * + * Tests cover: + * - setupTokenExpirationCheck skips unauthenticated sockets + * - Token validation runs on an interval + * - auth_expired is emitted when token validation fails + * - auth_refresh clears the grace timer and updates the token + * - Graceful disconnect after grace period expires + * - Intervals are cleaned up on disconnect + */ + +import { registerLocationHandler, deliveryRoom } from '../src/sockets/locationHandler'; +import { + TypedSocket, + ServerToClientEvents, + ClientToServerEvents, + InterServerEvents, + SocketData, + AuthExpiredPayload, + AuthRefreshPayload, + AuthRefreshAckPayload, +} from '../src/sockets/socket.types'; +import { Server as SocketIOServer } from 'socket.io'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../src/services/authService', () => ({ + verifyToken: jest.fn(), + getUserById: jest.fn(), +})); + +jest.mock('../src/sockets/socket.service', () => { + const actual = jest.requireActual('../src/sockets/socket.service'); + return { + ...actual, + socketService: { + validateSocketToken: jest.fn(), + }, + }; +}); + +import authService from '../src/services/authService'; +import { socketService } from '../src/sockets/socket.service'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeMockSocket(id: string): jest.Mocked { + return { + id, + data: {}, + handshake: { auth: {}, query: {} }, + emit: jest.fn(), + disconnect: jest.fn(), + join: jest.fn(), + leave: jest.fn(), + on: jest.fn(), + off: jest.fn(), + rooms: new Set([id]), + connected: true, + } as unknown as jest.Mocked; +} + +function makeMockIO(): Parameters[0] { + return { + to: jest.fn().mockReturnValue({ emit: jest.fn() }), + sockets: { sockets: new Map() }, + } as unknown as SocketIOServer< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData + >; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('locationHandler — token expiration', () => { + let socket: jest.Mocked; + let io: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + socket = makeMockSocket('socket-1'); + io = makeMockIO(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('setupTokenExpirationCheck', () => { + it('does nothing when socket has no token or userId', () => { + socket.data.token = undefined; + socket.data.userId = undefined; + + registerLocationHandler(io, socket); + + expect(socket.on).not.toHaveBeenCalledWith('auth_refresh', expect.any(Function)); + }); + + it('does nothing when token is present but userId is missing', () => { + socket.data.token = 'some-token'; + socket.data.userId = undefined; + + registerLocationHandler(io, socket); + + expect(socket.on).not.toHaveBeenCalledWith('auth_refresh', expect.any(Function)); + }); + + it('sets up periodic token validation when token and userId are present', async () => { + socket.data.token = 'valid-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(true); + + registerLocationHandler(io, socket); + + expect(socket.on).toHaveBeenCalledWith('auth_refresh', expect.any(Function)); + + // Advance past the initial 5s delay + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(socketService.validateSocketToken).toHaveBeenCalledWith(socket); + + // Advance one check interval + jest.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(socketService.validateSocketToken).toHaveBeenCalledTimes(2); + }); + + it('emits auth_expired and disconnects after grace period on invalid token', async () => { + socket.data.token = 'expired-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(false); + + registerLocationHandler(io, socket); + + // Advance past initial delay + one check interval + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + + // Validation failed — auth_expired should be emitted + expect(socket.emit).toHaveBeenCalledWith( + 'auth_expired', + expect.objectContaining({ + message: 'Your session has expired. Please refresh your token.', + gracePeriodMs: 30_000, + }) as AuthExpiredPayload, + ); + + // Advance past grace period + jest.advanceTimersByTime(30_000); + await Promise.resolve(); + expect(socket.disconnect).toHaveBeenCalledWith(true); + }); + + it('clears grace timer and updates token on valid auth_refresh', async () => { + socket.data.token = 'old-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(false); + + registerLocationHandler(io, socket); + + // Trigger invalid token + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(socket.emit).toHaveBeenCalledWith( + 'auth_expired', + expect.any(Object) as AuthExpiredPayload, + ); + + // Find the auth_refresh handler registered by registerLocationHandler + const authRefreshHandler = (socket.on as jest.Mock).mock.calls.find( + (call: string[]) => call[0] === 'auth_refresh', + )?.[1] as (payload: AuthRefreshPayload) => Promise; + + expect(authRefreshHandler).toBeDefined(); + + // Simulate client sending a refreshed token + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-1' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'active' }); + + await authRefreshHandler({ token: 'new-token' }); + + expect(socket.data.token).toBe('new-token'); + expect(socket.data.userId).toBe('user-1'); + expect(socket.emit).toHaveBeenCalledWith('auth_refresh_ack', { + success: true, + } as AuthRefreshAckPayload); + + // Advance past where disconnect would have happened — should NOT disconnect + jest.advanceTimersByTime(35_000); + await Promise.resolve(); + expect(socket.disconnect).not.toHaveBeenCalled(); + }); + + it('rejects invalid token on auth_refresh', async () => { + socket.data.token = 'old-token'; + socket.data.userId = 'user-1'; + + registerLocationHandler(io, socket); + + const authRefreshHandler = (socket.on as jest.Mock).mock.calls.find( + (call: string[]) => call[0] === 'auth_refresh', + )?.[1] as (payload: AuthRefreshPayload) => Promise; + + expect(authRefreshHandler).toBeDefined(); + + (authService.verifyToken as jest.Mock).mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await authRefreshHandler({ token: 'bad-token' }); + + expect(socket.emit).toHaveBeenCalledWith('auth_refresh_ack', { + success: false, + error: 'Invalid token', + } as AuthRefreshAckPayload); + }); + + it('cleans up interval on disconnect', async () => { + socket.data.token = 'valid-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(true); + + registerLocationHandler(io, socket); + + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(socketService.validateSocketToken).toHaveBeenCalledTimes(1); + + // Simulate disconnect + const disconnectHandler = (socket.on as jest.Mock).mock.calls.find( + (call: string[]) => call[0] === 'disconnect', + )?.[1] as () => void; + + expect(disconnectHandler).toBeDefined(); + disconnectHandler!(); + + jest.advanceTimersByTime(60_000); + await Promise.resolve(); + // Should not have been called again after cleanup + expect(socketService.validateSocketToken).toHaveBeenCalledTimes(1); + }); + }); + + describe('deliveryRoom helper', () => { + it('prefixes the deliveryId with DELIVERY_ROOM_PREFIX', () => { + expect(deliveryRoom('abc123')).toBe('delivery:abc123'); + }); + + it('produces a unique room per deliveryId', () => { + const a = 'delivery-a'; + const b = 'delivery-b'; + expect(deliveryRoom(a)).not.toBe(deliveryRoom(b)); + }); + }); +}); diff --git a/tests/socket.service.test.ts b/tests/socket.service.test.ts index c1ba614..231510a 100644 --- a/tests/socket.service.test.ts +++ b/tests/socket.service.test.ts @@ -23,6 +23,14 @@ jest.mock('../src/config/logger', () => ({ debug: jest.fn(), })); +// Mock authService for token validation tests +jest.mock('../src/services/authService', () => ({ + verifyToken: jest.fn(), + getUserById: jest.fn(), +})); + +import authService from '../src/services/authService'; + /** * Build a minimal mock TypedSocket with only the fields the service needs. */ @@ -331,4 +339,77 @@ describe('SocketService', () => { service.stopHealthChecks(); }); }); + + // ── validateSocketToken ───────────────────────────────────────────────────── + + describe('validateSocketToken', () => { + it('returns true when socket has no token or userId', async () => { + const socket = makeMockSocket('no-token'); + const result = await service.validateSocketToken(socket); + expect(result).toBe(true); + }); + + it('returns true for a valid token with an active user', async () => { + const socket = makeMockSocket('valid-token'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-1'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-1' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'active' }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(true); + expect(authService.verifyToken).toHaveBeenCalledWith('valid-token'); + expect(authService.getUserById).toHaveBeenCalledWith('user-1'); + }); + + it('returns false for an expired or invalid token', async () => { + const socket = makeMockSocket('expired-token'); + socket.data.token = 'expired-token'; + socket.data.userId = 'user-1'; + + (authService.verifyToken as jest.Mock).mockImplementation(() => { + throw new Error('Token expired'); + }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + + it('returns false when the user does not exist', async () => { + const socket = makeMockSocket('no-user'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-missing'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-missing' }); + (authService.getUserById as jest.Mock).mockResolvedValue(null); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + + it('returns false for a suspended user', async () => { + const socket = makeMockSocket('suspended-user'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-suspended'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-suspended' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'suspended' }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + + it('returns false for a banned user', async () => { + const socket = makeMockSocket('banned-user'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-banned'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-banned' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'banned' }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + }); });