VAPT issue fixed
This commit is contained in:
parent
42e6c2356b
commit
c3e08ebfea
6
package-lock.json
generated
6
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -108,4 +108,4 @@
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
src/__tests__/form16-permission.middleware.test.ts
Normal file
80
src/__tests__/form16-permission.middleware.test.ts
Normal file
@ -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<Response> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
96
src/__tests__/form16-permission.service.test.ts
Normal file
96
src/__tests__/form16-permission.service.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
10
src/config/sessionPolicy.ts
Normal file
10
src/config/sessionPolicy.ts
Normal file
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<void> {
|
||||
try {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const body = ((req.body ?? {}) as Record<string, unknown>);
|
||||
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<string, unknown>;
|
||||
const body = ((req.body ?? {}) as Record<string, unknown>);
|
||||
const updateData: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@ -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))
|
||||
);
|
||||
|
||||
|
||||
@ -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<string> {
|
||||
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',
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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<void> {
|
||||
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<Form1626asUploadLogRow[]> {
|
||||
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<Form162
|
||||
errorsCount: u.errorsCount ?? 0,
|
||||
};
|
||||
});
|
||||
return { rows: mapped, total: count };
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ function normalizeEmail(email: string): string {
|
||||
|
||||
/**
|
||||
* Load Form 16 viewer config from admin_configurations (API-driven).
|
||||
* Returns empty arrays if no config or parse error (empty = allow all).
|
||||
* Returns empty arrays if no config or parse error.
|
||||
*/
|
||||
export async function getForm16ViewerConfig(): Promise<Form16ViewerConfig> {
|
||||
try {
|
||||
@ -55,8 +55,8 @@ export async function getForm16ViewerConfig(): Promise<Form16ViewerConfig> {
|
||||
* 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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user