diff --git a/package-lock.json b/package-lock.json index 5c6e497..2d6bf21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7229,9 +7229,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", diff --git a/package.json b/package.json index 6e6728b..9a301a5 100644 --- a/package.json +++ b/package.json @@ -108,4 +108,4 @@ "node": ">=22.0.0", "npm": ">=10.0.0" } -} \ No newline at end of file +} diff --git a/src/__tests__/form16-permission.middleware.test.ts b/src/__tests__/form16-permission.middleware.test.ts new file mode 100644 index 0000000..0f8a9b4 --- /dev/null +++ b/src/__tests__/form16-permission.middleware.test.ts @@ -0,0 +1,80 @@ +import { NextFunction, Request, Response } from 'express'; +import { + requireForm1626AsAccess, + requireForm16ReOnly, + requireForm16SubmissionAccess, +} from '../middlewares/form16Permission.middleware'; +import { canView26As, canViewForm16Submission } from '../services/form16Permission.service'; +import { getDealerCodeForUser } from '../services/form16.service'; + +jest.mock('../services/form16Permission.service', () => ({ + canView26As: jest.fn(), + canViewForm16Submission: jest.fn(), +})); + +jest.mock('../services/form16.service', () => ({ + getDealerCodeForUser: jest.fn(), +})); + +function createRes(): Response { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res as Response; +} + +describe('Form16 Permission Middlewares', () => { + const mockedCanView26As = canView26As as jest.Mock; + const mockedCanViewForm16Submission = canViewForm16Submission as jest.Mock; + const mockedGetDealerCodeForUser = getDealerCodeForUser as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows ADMIN on 26AS middleware without config dependency', async () => { + const req = { user: { userId: 'a1', email: 'admin@royalenfield.com', role: 'ADMIN' } } as unknown as Request; + const res = createRes(); + const next = jest.fn() as NextFunction; + + await requireForm1626AsAccess(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(mockedCanView26As).not.toHaveBeenCalled(); + }); + + it('denies non-authorized user on 26AS middleware', async () => { + mockedCanView26As.mockResolvedValue(false); + const req = { user: { userId: 'u1', email: 'user@royalenfield.com', role: 'USER' } } as unknown as Request; + const res = createRes(); + const next = jest.fn() as NextFunction; + + await requireForm1626AsAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect((res.status as jest.Mock).mock.calls[0][0]).toBe(403); + }); + + it('denies dealer on RE-only middleware', async () => { + mockedGetDealerCodeForUser.mockResolvedValue('DLR001'); + const req = { user: { userId: 'u2', email: 'dealer@royalenfield.com', role: 'USER' } } as unknown as Request; + const res = createRes(); + const next = jest.fn() as NextFunction; + + await requireForm16ReOnly(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect((res.status as jest.Mock).mock.calls[0][0]).toBe(403); + }); + + it('allows submission middleware for authorized non-admin RE user', async () => { + mockedCanViewForm16Submission.mockResolvedValue(true); + const req = { user: { userId: 'u3', email: 'submission@royalenfield.com', role: 'USER' } } as unknown as Request; + const res = createRes(); + const next = jest.fn() as NextFunction; + + await requireForm16SubmissionAccess(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/form16-permission.service.test.ts b/src/__tests__/form16-permission.service.test.ts new file mode 100644 index 0000000..7e1880d --- /dev/null +++ b/src/__tests__/form16-permission.service.test.ts @@ -0,0 +1,96 @@ +import { canView26As, canViewForm16Submission, getForm16ViewerConfig } from '../services/form16Permission.service'; +import { sequelize } from '../config/database'; +import { getDealerCodeForUser } from '../services/form16.service'; + +jest.mock('../config/database', () => ({ + sequelize: { + query: jest.fn(), + }, +})); + +jest.mock('../services/form16.service', () => ({ + getDealerCodeForUser: jest.fn(), +})); + +describe('Form16 Permission Service (strict RBAC)', () => { + const mockedQuery = sequelize.query as jest.Mock; + const mockedGetDealerCodeForUser = getDealerCodeForUser as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns normalized viewer lists from config', async () => { + mockedQuery.mockResolvedValue([ + { + config_value: JSON.stringify({ + submissionViewerEmails: [' User1@royalenfield.com '], + twentySixAsViewerEmails: ['USER2@royalenfield.com'], + }), + }, + ]); + + const config = await getForm16ViewerConfig(); + + expect(config.submissionViewerEmails).toEqual(['user1@royalenfield.com']); + expect(config.twentySixAsViewerEmails).toEqual(['user2@royalenfield.com']); + }); + + it('ADMIN always has submission and 26AS access', async () => { + expect(await canViewForm16Submission('admin@royalenfield.com', 'u-admin', 'ADMIN')).toBe(true); + expect(await canView26As('admin@royalenfield.com', 'ADMIN')).toBe(true); + }); + + it('dealer always has submission access, but not implicit 26AS access', async () => { + mockedGetDealerCodeForUser.mockResolvedValue('DLR001'); + mockedQuery.mockResolvedValue([{ config_value: JSON.stringify({ submissionViewerEmails: [], twentySixAsViewerEmails: [] }) }]); + + expect(await canViewForm16Submission('dealer@royalenfield.com', 'u-dealer', 'USER')).toBe(true); + expect(await canView26As('dealer@royalenfield.com', 'USER')).toBe(false); + }); + + it('non-admin RE user gets submission access only when listed in submission viewers', async () => { + mockedGetDealerCodeForUser.mockResolvedValue(null); + mockedQuery.mockResolvedValue([ + { + config_value: JSON.stringify({ + submissionViewerEmails: ['submissions@royalenfield.com'], + twentySixAsViewerEmails: [], + }), + }, + ]); + + expect(await canViewForm16Submission('submissions@royalenfield.com', 'u1', 'USER')).toBe(true); + expect(await canViewForm16Submission('other@royalenfield.com', 'u2', 'USER')).toBe(false); + }); + + it('26AS viewers implicitly have submission access', async () => { + mockedGetDealerCodeForUser.mockResolvedValue(null); + mockedQuery.mockResolvedValue([ + { + config_value: JSON.stringify({ + submissionViewerEmails: [], + twentySixAsViewerEmails: ['twentysix@royalenfield.com'], + }), + }, + ]); + + expect(await canViewForm16Submission('twentysix@royalenfield.com', 'u3', 'USER')).toBe(true); + expect(await canView26As('twentysix@royalenfield.com', 'USER')).toBe(true); + }); + + it('strict deny when viewer lists are empty for non-admin RE user', async () => { + mockedGetDealerCodeForUser.mockResolvedValue(null); + mockedQuery.mockResolvedValue([ + { + config_value: JSON.stringify({ + submissionViewerEmails: [], + twentySixAsViewerEmails: [], + }), + }, + ]); + + expect(await canViewForm16Submission('re-user@royalenfield.com', 'u4', 'USER')).toBe(false); + expect(await canView26As('re-user@royalenfield.com', 'USER')).toBe(false); + }); +}); diff --git a/src/config/sessionPolicy.ts b/src/config/sessionPolicy.ts new file mode 100644 index 0000000..7ada073 --- /dev/null +++ b/src/config/sessionPolicy.ts @@ -0,0 +1,10 @@ +/** + * Centralized session policy for VAPT compliance. + * Keep strict constants (no environment overrides) to prevent accidental relaxation. + */ + +export const ACCESS_TOKEN_TTL = '30m'; +export const REFRESH_TOKEN_TTL = '30m'; +export const ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000; +export const REFRESH_TOKEN_TTL_MS = 30 * 60 * 1000; + diff --git a/src/config/sso.ts b/src/config/sso.ts index 8874e76..51f14dd 100644 --- a/src/config/sso.ts +++ b/src/config/sso.ts @@ -1,12 +1,13 @@ import { SSOConfig, SSOUserData } from '../types/auth.types'; +import { ACCESS_TOKEN_TTL, REFRESH_TOKEN_TTL } from './sessionPolicy'; // Use getter functions to read from process.env dynamically // This ensures values are read after secrets are loaded from Google Secret Manager const ssoConfig: SSOConfig = { get jwtSecret() { return process.env.JWT_SECRET || ''; }, - // VAPT: reduce access token lifetime to 30 minutes by default - get jwtExpiry() { return process.env.JWT_EXPIRY || '30m'; }, - get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; }, + // VAPT hard policy: no env-based override for token lifetimes. + get jwtExpiry() { return ACCESS_TOKEN_TTL; }, + get refreshTokenExpiry() { return REFRESH_TOKEN_TTL; }, get sessionSecret() { return process.env.SESSION_SECRET || ''; }, // Use only FRONTEND_URL from environment - no fallbacks get allowedOrigins() { diff --git a/src/config/system.config.ts b/src/config/system.config.ts index 3585c73..24daaaa 100644 --- a/src/config/system.config.ts +++ b/src/config/system.config.ts @@ -67,8 +67,8 @@ export const SYSTEM_CONFIG = { // Session & Security SECURITY: { - SESSION_TIMEOUT_MINUTES: parseInt(process.env.SESSION_TIMEOUT_MINUTES || '480', 10), // 8 hours - JWT_EXPIRY: process.env.JWT_EXPIRY || '8h', + SESSION_TIMEOUT_MINUTES: parseInt(process.env.SESSION_TIMEOUT_MINUTES || '30', 10), + JWT_EXPIRY: process.env.JWT_EXPIRY || '30m', ENABLE_2FA: process.env.ENABLE_2FA === 'true', }, diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 7ed7b49..ef7f867 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -6,6 +6,7 @@ import type { AuthenticatedRequest } from '../types/express'; import logger from '../utils/logger'; import { activityService, SYSTEM_EVENT_REQUEST_ID } from '../services/activity.service'; import { getRequestMetadata } from '../utils/requestUtils'; +import { ACCESS_TOKEN_TTL_MS, REFRESH_TOKEN_TTL_MS } from '../config/sessionPolicy'; export class AuthController { private authService: AuthService; @@ -129,7 +130,7 @@ export class AuthController { return; } - const newAccessToken = await this.authService.refreshAccessToken(refreshToken); + const refreshResult = await this.authService.refreshAccessToken(refreshToken); // Set new access token in cookie if using cookie-based auth const isProduction = process.env.NODE_ENV === 'production'; @@ -140,10 +141,10 @@ export class AuthController { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain - maxAge: 24 * 60 * 60 * 1000, // 24 hours + maxAge: Math.max(1000, refreshResult.accessTokenTtlMs), }; - res.cookie('accessToken', newAccessToken, cookieOptions); + res.cookie('accessToken', refreshResult.accessToken, cookieOptions); // SECURITY: In production, don't return token in response body // Token is securely stored in httpOnly cookie @@ -154,7 +155,7 @@ export class AuthController { } else { // Dev: Include token for debugging ResponseHandler.success(res, { - accessToken: newAccessToken + accessToken: refreshResult.accessToken }, 'Token refreshed successfully'); } } catch (error) { @@ -218,7 +219,7 @@ export class AuthController { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const), - maxAge: 24 * 60 * 60 * 1000, // 24 hours + maxAge: ACCESS_TOKEN_TTL_MS, path: '/', }; @@ -271,7 +272,7 @@ export class AuthController { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const), - maxAge: 24 * 60 * 60 * 1000, + maxAge: ACCESS_TOKEN_TTL_MS, path: '/', }; @@ -498,14 +499,14 @@ export class AuthController { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, - maxAge: 24 * 60 * 60 * 1000, // 24 hours + maxAge: ACCESS_TOKEN_TTL_MS, }; res.cookie('accessToken', result.accessToken, cookieOptions); const refreshCookieOptions = { ...cookieOptions, - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + maxAge: REFRESH_TOKEN_TTL_MS, }; res.cookie('refreshToken', result.refreshToken, refreshCookieOptions); @@ -582,14 +583,14 @@ export class AuthController { httpOnly: true, secure: isSecureEnv, sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' for same-domain - maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token + maxAge: ACCESS_TOKEN_TTL_MS, }; res.cookie('accessToken', result.accessToken, cookieOptions); const refreshCookieOptions = { ...cookieOptions, - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days for refresh token + maxAge: REFRESH_TOKEN_TTL_MS, }; res.cookie('refreshToken', result.refreshToken, refreshCookieOptions); diff --git a/src/controllers/form16.controller.ts b/src/controllers/form16.controller.ts index 6426388..d8be378 100644 --- a/src/controllers/form16.controller.ts +++ b/src/controllers/form16.controller.ts @@ -62,6 +62,61 @@ export class Form16Controller { .join('|'); return `${header}\n${row}\n`; } + + private isStrictTxt26asFile(file: { originalname?: string; mimetype?: string; buffer?: Buffer }): { ok: boolean; reason?: string } { + const originalName = (file.originalname || '').trim(); + const ext = path.extname(originalName).toLowerCase(); + if (ext !== '.txt') { + return { ok: false, reason: 'Only .txt files are allowed for 26AS upload.' }; + } + + const mime = String(file.mimetype || '').toLowerCase(); + const allowedMimes = new Set(['text/plain', 'text/csv', 'application/octet-stream']); + if (!allowedMimes.has(mime)) { + return { ok: false, reason: 'Invalid MIME type. Only plain text (.txt) is allowed for 26AS upload.' }; + } + + if (!file.buffer || file.buffer.length === 0) { + return { ok: false, reason: 'Uploaded file is empty. Please upload a valid 26AS .txt file.' }; + } + + const b = file.buffer; + if ( + (b.length >= 4 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) || // PDF + (b.length >= 4 && b[0] === 0x50 && b[1] === 0x4b && b[2] === 0x03 && b[3] === 0x04) || // ZIP/DOCX/XLSX + (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) || // PNG + (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) || // JPEG + (b.length >= 2 && b[0] === 0x4d && b[1] === 0x5a) // EXE + ) { + return { ok: false, reason: 'Binary file signature detected. Only plain text 26AS .txt files are allowed.' }; + } + + let suspiciousControlCount = 0; + for (let i = 0; i < b.length; i++) { + const byte = b[i]; + if (byte === 0x00) { + return { ok: false, reason: 'Invalid text content. Null bytes detected.' }; + } + const isTabOrLfOrCr = byte === 0x09 || byte === 0x0a || byte === 0x0d; + const isPrintableAscii = byte >= 0x20 && byte <= 0x7e; + if (!isTabOrLfOrCr && !isPrintableAscii) suspiciousControlCount++; + } + if (suspiciousControlCount / Math.max(b.length, 1) > 0.01) { + return { ok: false, reason: 'Invalid text content. File appears to contain binary data.' }; + } + + const text = b.toString('utf8'); + if (text.includes('\uFFFD')) { + return { ok: false, reason: 'Invalid UTF-8 text content. Please upload a plain text .txt file.' }; + } + + const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + if (lines.length === 0) { + return { ok: false, reason: 'Uploaded file has no usable text rows.' }; + } + + return { ok: true }; + } /** * GET /api/v1/form16/permissions * Returns Form 16 permissions for the current user (API-driven from admin config). @@ -292,7 +347,7 @@ export class Form16Controller { */ async create26as(req: Request, res: Response): Promise { try { - const body = req.body as Record; + const body = ((req.body ?? {}) as Record); const tanNumber = (body.tanNumber as string)?.trim(); const financialYear = (body.financialYear as string)?.trim(); const quarter = (body.quarter as string)?.trim(); @@ -336,7 +391,7 @@ export class Form16Controller { if (Number.isNaN(id)) { return ResponseHandler.error(res, 'Invalid entry id', 400); } - const body = req.body as Record; + const body = ((req.body ?? {}) as Record); const updateData: Record = {}; if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber; if (body.panNumber !== undefined) updateData.panNumber = body.panNumber; @@ -707,6 +762,14 @@ export class Form16Controller { if (!file || !file.buffer) { return ResponseHandler.error(res, 'No file uploaded. Please upload a .txt file.', 400); } + const ext = path.extname(file.originalname || '').toLowerCase(); + if (ext !== '.txt') { + return ResponseHandler.error(res, 'Only .txt files are allowed for 26AS upload.', 400); + } + const strictTxtValidation = this.isStrictTxt26asFile(file); + if (!strictTxtValidation.ok) { + return ResponseHandler.error(res, strictTxtValidation.reason || 'Invalid 26AS text file.', 400); + } if (!userId) { return ResponseHandler.error(res, 'Authentication required', 401); } @@ -751,8 +814,9 @@ export class Form16Controller { async get26asUploadHistory(req: Request, res: Response): Promise { try { const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10), 1), 200); - const history = await form16Service.list26asUploadHistory(limit); - return ResponseHandler.success(res, { history }, '26AS upload history fetched'); + const offset = Math.max(0, parseInt(String(req.query.offset ?? '0'), 10) || 0); + const { rows: history, total } = await form16Service.list26asUploadHistory(limit, offset); + return ResponseHandler.success(res, { history, total }, '26AS upload history fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('[Form16Controller] get26asUploadHistory error:', error); diff --git a/src/routes/form16.routes.ts b/src/routes/form16.routes.ts index c986f9c..41b23a0 100644 --- a/src/routes/form16.routes.ts +++ b/src/routes/form16.routes.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router, json, urlencoded } from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; @@ -8,9 +8,15 @@ import { form16Controller } from '../controllers/form16.controller'; import { form16SapController } from '../controllers/form16Sap.controller'; import { asyncHandler } from '../middlewares/errorHandler.middleware'; import { UPLOAD_DIR } from '../config/storage'; +import { ResponseHandler } from '../utils/responseHandler'; const router = Router(); +// Form16 routes are mounted before global parsers in app.ts to preserve multipart streams. +// Add route-local parsers so JSON/x-www-form-urlencoded endpoints (e.g., /26as POST/PUT) still receive req.body. +router.use(json({ limit: '10mb' })); +router.use(urlencoded({ extended: true, limit: '10mb' })); + // REform16 pattern: disk storage to uploads dir (path.join(__dirname, '../../uploads') → we use UPLOAD_DIR/form16-extract) const form16ExtractDir = path.join(UPLOAD_DIR, 'form16-extract'); if (!fs.existsSync(form16ExtractDir)) { @@ -50,8 +56,17 @@ const upload26asTxt = multer({ storage: multer.memoryStorage(), limits: { fileSize: 40 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { - const ext = path.extname(file.originalname || '').toLowerCase(); - const isTxt = ext === '.txt' || (file.mimetype && (file.mimetype === 'text/plain' || file.mimetype === 'application/octet-stream')); + const originalName = (file.originalname || '').trim(); + const ext = path.extname(originalName).toLowerCase(); + const mime = String(file.mimetype || '').toLowerCase(); + // Keep route-level filter strict and deterministic: only .txt name + known text mime types. + // Controller still performs deep buffer validation to block renamed binaries. + const allowedMimes = new Set([ + 'text/plain', + 'text/csv', + 'application/octet-stream', + ]); + const isTxt = ext === '.txt' && allowedMimes.has(mime); if (isTxt) { cb(null, true); } else { @@ -60,6 +75,18 @@ const upload26asTxt = multer({ }, }); +const upload26asTxtSingle = (req: any, res: any, next: any) => { + upload26asTxt.single('file')(req, res, (err: any) => { + if (!err) return next(); + const message = + err?.message || + (err?.code === 'LIMIT_FILE_SIZE' + ? 'File too large. Maximum allowed size is 40 MB for 26AS upload.' + : 'Invalid 26AS upload file. Only .txt files are allowed.'); + return ResponseHandler.error(res, message, 400); + }); +}; + router.use(authenticateToken); // Permissions (API-driven from admin config; used by frontend to show/hide Form 16 and 26AS) @@ -188,33 +215,39 @@ router.post( // 26AS (who can see: twentySixAsViewerEmails from admin config) router.get( '/26as', + requireForm16ReOnly, requireForm1626AsAccess, asyncHandler(form16Controller.list26as.bind(form16Controller)) ); router.post( '/26as', + requireForm16ReOnly, requireForm1626AsAccess, asyncHandler(form16Controller.create26as.bind(form16Controller)) ); router.put( '/26as/:id', + requireForm16ReOnly, requireForm1626AsAccess, asyncHandler(form16Controller.update26as.bind(form16Controller)) ); router.delete( '/26as/:id', + requireForm16ReOnly, requireForm1626AsAccess, asyncHandler(form16Controller.delete26as.bind(form16Controller)) ); router.get( '/26as/upload-history', + requireForm16ReOnly, requireForm1626AsAccess, asyncHandler(form16Controller.get26asUploadHistory.bind(form16Controller)) ); router.post( '/26as/upload', + requireForm16ReOnly, requireForm1626AsAccess, - upload26asTxt.single('file'), + upload26asTxtSingle, asyncHandler(form16Controller.upload26as.bind(form16Controller)) ); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index c24df92..3c11cff 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -8,6 +8,7 @@ import axios from 'axios'; import bcrypt from 'bcryptjs'; import { v4 as uuidv4 } from 'uuid'; import { emitToUser } from '../realtime/socket'; +import { ACCESS_TOKEN_TTL_MS } from '../config/sessionPolicy'; function parseDeviceFromUserAgent(ua?: string): string { if (!ua) return 'Unknown Device'; @@ -417,7 +418,7 @@ export class AuthService { /** * Generate JWT access token */ - private generateAccessToken(user: User): string { + private generateAccessToken(user: User, expiresIn?: StringValue | number): string { if (!ssoConfig.jwtSecret) { throw new Error('JWT secret is not configured'); } @@ -431,7 +432,7 @@ export class AuthService { }; const options: SignOptions = { - expiresIn: ssoConfig.jwtExpiry as StringValue | number + expiresIn: expiresIn ?? (ssoConfig.jwtExpiry as StringValue | number) }; return jwt.sign(payload, ssoConfig.jwtSecret, options); @@ -472,7 +473,7 @@ export class AuthService { /** * Refresh access token using refresh token */ - async refreshAccessToken(refreshToken: string): Promise { + async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; accessTokenTtlMs: number }> { try { const decoded = jwt.verify(refreshToken, ssoConfig.jwtSecret) as any; @@ -489,7 +490,23 @@ export class AuthService { throw new Error('Session expired due to login from another device'); } - return this.generateAccessToken(user); + // Strict 30-minute session timeout from login time. + const lastLoginTime = user.lastLogin ? new Date(user.lastLogin).getTime() : 0; + if (!lastLoginTime || Number.isNaN(lastLoginTime)) { + throw new Error('Session expired'); + } + const sessionAgeMs = Date.now() - lastLoginTime; + if (sessionAgeMs > ACCESS_TOKEN_TTL_MS) { + throw new Error('Session expired'); + } + // Absolute session deadline: refreshed token must never outlive login + 30m. + const remainingSessionMs = ACCESS_TOKEN_TTL_MS - sessionAgeMs; + if (remainingSessionMs <= 0) { + throw new Error('Session expired'); + } + const remainingSessionSeconds = Math.max(1, Math.floor(remainingSessionMs / 1000)); + const accessToken = this.generateAccessToken(user, `${remainingSessionSeconds}s` as StringValue); + return { accessToken, accessTokenTtlMs: remainingSessionSeconds * 1000 }; } catch (error) { logAuthEvent('auth_failure', undefined, { action: 'token_refresh_failed', diff --git a/src/services/form16.service.ts b/src/services/form16.service.ts index 215f912..5c4d44d 100644 --- a/src/services/form16.service.ts +++ b/src/services/form16.service.ts @@ -678,6 +678,115 @@ function normalizeQuarter(raw: string): string { return (raw || '').trim() || ''; } +/** + * Assessment Year from Financial Year (Indian income tax): FY 2024-25 → AY 2025-26. + */ +function financialYearToAssessmentYear(financialYear: string): string { + const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim(); + const m = /^(\d{4})-(\d{2})$/.exec(fy); + if (!m) return fy.replace(/[^\w.-]/g, '_').slice(0, 24) || 'AY'; + const y1 = parseInt(m[1], 10); + const ayStart = y1 + 1; + const ayEnd2 = (y1 + 2) % 100; + return `${ayStart}-${String(ayEnd2).padStart(2, '0')}`; +} + +function sanitizeForm16PdfDeductorSegment(text: string, maxLen: number): string { + let s = String(text || '') + .replace(/[\r\n]+/g, ' ') + .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!s) return 'Deductor'; + if (s.length > maxLen) s = s.slice(0, maxLen).trim(); + return s; +} + +function sanitizeForm16PdfCertSegment(text: string): string { + const t = String(text || '').trim().replace(/[^A-Za-z0-9_-]/g, ''); + return t || 'CERT'; +} + +/** + * PDF file name after successful 26AS match + credit note: + * [TAN]_[AY]_[Quarter]_[Name and address of deductor]_[Certificate][_Vn].pdf + * Revised submissions (version > 1) append _V2, _V3, ... + */ +function buildForm16CreditNoteSuccessPdfFileName(sub: Form16aSubmission): string { + const tan = normalizeTanNumber(String(sub.tanNumber || '')) + .replace(/[^A-Z0-9]/gi, '') + .toUpperCase() || 'TAN'; + const fy = normalizeFinancialYear(String(sub.financialYear || '').trim()) || String(sub.financialYear || '').trim(); + const ay = financialYearToAssessmentYear(fy); + const qRaw = String(sub.quarter || '').trim(); + const q = normalizeQuarter(qRaw) || qRaw || 'QX'; + const ocr = (sub.ocrExtractedData || {}) as Record; + let nameAddr = String(ocr.nameAndAddressOfDeductor || '').trim(); + if (!nameAddr) { + const dn = String(ocr.deductorName || sub.deductorName || '').trim(); + const da = String(ocr.deductorAddress || '').trim(); + nameAddr = [dn, da].filter(Boolean).join(', '); + } + if (!nameAddr) nameAddr = String(sub.deductorName || 'Deductor').trim(); + let deductorSan = sanitizeForm16PdfDeductorSegment(nameAddr, 150); + const certSan = sanitizeForm16PdfCertSegment(String(sub.form16aNumber || '')); + const ver = typeof sub.version === 'number' && sub.version > 1 ? `_V${sub.version}` : ''; + let base = `${tan}_${ay}_${q}_${deductorSan}_${certSan}${ver}`; + if (base.length > 220) { + const over = base.length - 220; + const shorter = Math.max(20, deductorSan.length - over - 5); + deductorSan = sanitizeForm16PdfDeductorSegment(nameAddr, shorter); + base = `${tan}_${ay}_${q}_${deductorSan}_${certSan}${ver}`; + } + return `${base}.pdf`; +} + +async function renameForm16SubmissionPdfAfterCreditNote(params: { + submissionId: number; + requestId: string; + oldRelativePath: string; +}): Promise { + const { submissionId, requestId, oldRelativePath } = params; + const oldPathNorm = String(oldRelativePath || '').replace(/\\/g, '/').trim(); + if (!oldPathNorm || oldPathNorm.includes('..') || !oldPathNorm.startsWith('requests/')) { + logger.warn('[Form16] Skip PDF rename: invalid storage path', { oldPathNorm }); + return; + } + + const sub = await Form16aSubmission.findByPk(submissionId); + if (!sub) return; + + const newFileName = buildForm16CreditNoteSuccessPdfFileName(sub); + + try { + const result = await gcsStorageService.renameRequestDocumentFile({ + oldRelativePath: oldPathNorm, + newFileName, + }); + await sub.update({ documentUrl: result.storageUrl }); + + const doc = await Document.findOne({ + where: { requestId, filePath: oldPathNorm }, + }); + if (doc) { + const fp = result.filePath.length <= 500 ? result.filePath : result.filePath.slice(0, 500); + const su = + result.storageUrl.length <= 500 ? result.storageUrl : undefined; + await doc.update({ + fileName: newFileName.slice(0, 255), + originalFileName: newFileName.slice(0, 255), + filePath: fp, + storageUrl: su, + }); + } else { + logger.warn('[Form16] PDF renamed; documents row not found for path', { requestId, oldPathNorm }); + } + logger.info('[Form16] Form 16A PDF renamed after credit note', { submissionId, newFileName }); + } catch (e: any) { + logger.error('[Form16] Failed to rename Form 16 PDF after credit note:', e?.message || e); + } +} + /** Compact FY for Form 16 note numbers: "2024-25" -> "24-25" */ function form16FyCompact(financialYear: string): string { const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim(); @@ -1229,6 +1338,11 @@ export async function createSubmission( await workflow.update({ status: WorkflowStatus.CLOSED }); logger.info(`[Form16] Request ${requestId} set to CLOSED (credit note issued).`); } + await renameForm16SubmissionPdfAfterCreditNote({ + submissionId: submission.id, + requestId, + oldRelativePath: uploadFilePath.replace(/\\/g, '/'), + }); } } catch (err: any) { logger.error( @@ -2899,13 +3013,18 @@ export interface Form1626asUploadLogRow { } /** List 26AS upload history (most recent first) for management section. */ -export async function list26asUploadHistory(limit: number = 50): Promise { - const rows = await Form1626asUploadLog.findAll({ +export async function list26asUploadHistory( + limit: number = 50, + offset: number = 0 +): Promise<{ rows: Form1626asUploadLogRow[]; total: number }> { + const { rows, count } = await Form1626asUploadLog.findAndCountAll({ limit, + offset, order: [['uploadedAt', 'DESC']], include: [{ model: User, as: 'uploadedByUser', attributes: ['email', 'displayName'], required: false }], + distinct: true, }); - return rows.map((r) => { + const mapped = rows.map((r) => { const u = r as any; return { id: u.id, @@ -2918,4 +3037,5 @@ export async function list26asUploadHistory(limit: number = 50): Promise { try { @@ -55,8 +55,8 @@ export async function getForm16ViewerConfig(): Promise { * Check if user can view Form 16 submission data (Credit Notes, Non-submitted Dealers, etc.). * - Admin: always allowed (full access to everything). * - Dealers: always allowed (they see their own submissions). - * - RE users: allowed if submissionViewerEmails is empty, or user email is in submissionViewerEmails, - * or user email is in twentySixAsViewerEmails (26AS access implies submission access so sidebar shows both). + * - RE users: allowed only if user email is in submissionViewerEmails + * OR in twentySixAsViewerEmails (26AS access implies submission access). */ export async function canViewForm16Submission( userEmail: string, @@ -69,7 +69,6 @@ export async function canViewForm16Submission( const config = await getForm16ViewerConfig(); const email = normalizeEmail(userEmail); if (!email) return false; - if (config.submissionViewerEmails.length === 0 && config.twentySixAsViewerEmails.length === 0) return true; if (config.submissionViewerEmails.includes(email)) return true; if (config.twentySixAsViewerEmails.includes(email)) return true; return false; @@ -78,12 +77,12 @@ export async function canViewForm16Submission( /** * Check if user can view 26AS page and 26AS data. * - Admin: always allowed (full access to everything). - * - Otherwise: allowed if twentySixAsViewerEmails is empty, or user email is in the list. + * - Otherwise: allowed only if user email is in twentySixAsViewerEmails. */ export async function canView26As(userEmail: string, role?: string): Promise { if (role === 'ADMIN') return true; const config = await getForm16ViewerConfig(); const email = normalizeEmail(userEmail); - if (config.twentySixAsViewerEmails.length === 0) return true; + if (!email) return false; return config.twentySixAsViewerEmails.includes(email); } diff --git a/src/services/gcsStorage.service.ts b/src/services/gcsStorage.service.ts index ab2734c..bd4e71b 100644 --- a/src/services/gcsStorage.service.ts +++ b/src/services/gcsStorage.service.ts @@ -345,6 +345,80 @@ class GCSStorageService { } } + /** + * Rename a file already stored under uploads/requests/{requestNumber}/... (same shape as upload). + * GCS: copy to new name in same folder, delete old object. Local: rename on disk. + */ + async renameRequestDocumentFile(options: { + oldRelativePath: string; + newFileName: string; + }): Promise<{ storageUrl: string; filePath: string; fileName: string }> { + const { oldRelativePath } = options; + let newFileName = path.basename(String(options.newFileName || '').trim()); + if (!newFileName || newFileName === '.' || newFileName === '..') { + throw new Error('Invalid new file name'); + } + if (!oldRelativePath || oldRelativePath.includes('..')) { + throw new Error('Invalid old path'); + } + + const oldNorm = oldRelativePath.replace(/\\/g, '/'); + const dir = path.posix.dirname(oldNorm); + const newRelativePath = `${dir}/${newFileName}`; + + if (!this.isConfigured()) { + const fullOld = path.join(UPLOAD_DIR, ...oldNorm.split('/').filter(Boolean)); + const fullNew = path.join(UPLOAD_DIR, ...newRelativePath.split('/').filter(Boolean)); + if (!fs.existsSync(fullOld)) { + throw new Error(`Local file not found: ${oldNorm}`); + } + fs.mkdirSync(path.dirname(fullNew), { recursive: true }); + fs.renameSync(fullOld, fullNew); + const storageUrl = `/uploads/${newRelativePath.replace(/\\/g, '/')}`; + logger.info('[GCS] Renamed local Form 16 document', { oldNorm, newRelativePath }); + return { storageUrl, filePath: newRelativePath.replace(/\\/g, '/'), fileName: newFileName }; + } + + if (!this.storage) { + throw new Error('GCS storage not initialized'); + } + + try { + const bucket = this.storage.bucket(this.bucketName); + const oldFile = bucket.file(oldNorm); + const [exists] = await oldFile.exists(); + if (!exists) { + throw new Error(`GCS file not found: ${oldNorm}`); + } + const newFile = bucket.file(newRelativePath); + await oldFile.copy(newFile); + await oldFile.delete(); + + let publicUrl: string; + try { + await newFile.makePublic(); + publicUrl = `https://storage.googleapis.com/${this.bucketName}/${newRelativePath}`; + } catch (makePublicError: any) { + if (makePublicError?.code === 400 || makePublicError?.message?.includes('publicAccessPrevention')) { + logger.warn('[GCS] Renamed file cannot be public; using signed URL.'); + publicUrl = await this.getSignedUrl(newRelativePath, 60 * 24 * 365); + } else { + throw makePublicError; + } + } + + logger.info('[GCS] Renamed document in bucket', { from: oldNorm, to: newRelativePath }); + return { + storageUrl: publicUrl, + filePath: newRelativePath, + fileName: newFileName, + }; + } catch (error) { + logger.error('[GCS] renameRequestDocumentFile failed:', error); + throw new Error(`Failed to rename file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + /** * Check if GCS is properly configured */