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": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
|
|||||||
@ -108,4 +108,4 @@
|
|||||||
"node": ">=22.0.0",
|
"node": ">=22.0.0",
|
||||||
"npm": ">=10.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 { SSOConfig, SSOUserData } from '../types/auth.types';
|
||||||
|
import { ACCESS_TOKEN_TTL, REFRESH_TOKEN_TTL } from './sessionPolicy';
|
||||||
|
|
||||||
// Use getter functions to read from process.env dynamically
|
// Use getter functions to read from process.env dynamically
|
||||||
// This ensures values are read after secrets are loaded from Google Secret Manager
|
// This ensures values are read after secrets are loaded from Google Secret Manager
|
||||||
const ssoConfig: SSOConfig = {
|
const ssoConfig: SSOConfig = {
|
||||||
get jwtSecret() { return process.env.JWT_SECRET || ''; },
|
get jwtSecret() { return process.env.JWT_SECRET || ''; },
|
||||||
// VAPT: reduce access token lifetime to 30 minutes by default
|
// VAPT hard policy: no env-based override for token lifetimes.
|
||||||
get jwtExpiry() { return process.env.JWT_EXPIRY || '30m'; },
|
get jwtExpiry() { return ACCESS_TOKEN_TTL; },
|
||||||
get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; },
|
get refreshTokenExpiry() { return REFRESH_TOKEN_TTL; },
|
||||||
get sessionSecret() { return process.env.SESSION_SECRET || ''; },
|
get sessionSecret() { return process.env.SESSION_SECRET || ''; },
|
||||||
// Use only FRONTEND_URL from environment - no fallbacks
|
// Use only FRONTEND_URL from environment - no fallbacks
|
||||||
get allowedOrigins() {
|
get allowedOrigins() {
|
||||||
|
|||||||
@ -67,8 +67,8 @@ export const SYSTEM_CONFIG = {
|
|||||||
|
|
||||||
// Session & Security
|
// Session & Security
|
||||||
SECURITY: {
|
SECURITY: {
|
||||||
SESSION_TIMEOUT_MINUTES: parseInt(process.env.SESSION_TIMEOUT_MINUTES || '480', 10), // 8 hours
|
SESSION_TIMEOUT_MINUTES: parseInt(process.env.SESSION_TIMEOUT_MINUTES || '30', 10),
|
||||||
JWT_EXPIRY: process.env.JWT_EXPIRY || '8h',
|
JWT_EXPIRY: process.env.JWT_EXPIRY || '30m',
|
||||||
ENABLE_2FA: process.env.ENABLE_2FA === 'true',
|
ENABLE_2FA: process.env.ENABLE_2FA === 'true',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { AuthenticatedRequest } from '../types/express';
|
|||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { activityService, SYSTEM_EVENT_REQUEST_ID } from '../services/activity.service';
|
import { activityService, SYSTEM_EVENT_REQUEST_ID } from '../services/activity.service';
|
||||||
import { getRequestMetadata } from '../utils/requestUtils';
|
import { getRequestMetadata } from '../utils/requestUtils';
|
||||||
|
import { ACCESS_TOKEN_TTL_MS, REFRESH_TOKEN_TTL_MS } from '../config/sessionPolicy';
|
||||||
|
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
private authService: AuthService;
|
private authService: AuthService;
|
||||||
@ -129,7 +130,7 @@ export class AuthController {
|
|||||||
return;
|
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
|
// Set new access token in cookie if using cookie-based auth
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
@ -140,10 +141,10 @@ export class AuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isSecureEnv,
|
||||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain
|
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
|
// SECURITY: In production, don't return token in response body
|
||||||
// Token is securely stored in httpOnly cookie
|
// Token is securely stored in httpOnly cookie
|
||||||
@ -154,7 +155,7 @@ export class AuthController {
|
|||||||
} else {
|
} else {
|
||||||
// Dev: Include token for debugging
|
// Dev: Include token for debugging
|
||||||
ResponseHandler.success(res, {
|
ResponseHandler.success(res, {
|
||||||
accessToken: newAccessToken
|
accessToken: refreshResult.accessToken
|
||||||
}, 'Token refreshed successfully');
|
}, 'Token refreshed successfully');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -218,7 +219,7 @@ export class AuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isSecureEnv,
|
||||||
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
maxAge: ACCESS_TOKEN_TTL_MS,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -271,7 +272,7 @@ export class AuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isSecureEnv,
|
||||||
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
||||||
maxAge: 24 * 60 * 60 * 1000,
|
maxAge: ACCESS_TOKEN_TTL_MS,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -498,14 +499,14 @@ export class AuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isSecureEnv,
|
||||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const,
|
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);
|
res.cookie('accessToken', result.accessToken, cookieOptions);
|
||||||
|
|
||||||
const refreshCookieOptions = {
|
const refreshCookieOptions = {
|
||||||
...cookieOptions,
|
...cookieOptions,
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.cookie('refreshToken', result.refreshToken, refreshCookieOptions);
|
res.cookie('refreshToken', result.refreshToken, refreshCookieOptions);
|
||||||
@ -582,14 +583,14 @@ export class AuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isSecureEnv,
|
||||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' for same-domain
|
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);
|
res.cookie('accessToken', result.accessToken, cookieOptions);
|
||||||
|
|
||||||
const refreshCookieOptions = {
|
const refreshCookieOptions = {
|
||||||
...cookieOptions,
|
...cookieOptions,
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days for refresh token
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.cookie('refreshToken', result.refreshToken, refreshCookieOptions);
|
res.cookie('refreshToken', result.refreshToken, refreshCookieOptions);
|
||||||
|
|||||||
@ -62,6 +62,61 @@ export class Form16Controller {
|
|||||||
.join('|');
|
.join('|');
|
||||||
return `${header}\n${row}\n`;
|
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
|
* GET /api/v1/form16/permissions
|
||||||
* Returns Form 16 permissions for the current user (API-driven from admin config).
|
* 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> {
|
async create26as(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
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 tanNumber = (body.tanNumber as string)?.trim();
|
||||||
const financialYear = (body.financialYear as string)?.trim();
|
const financialYear = (body.financialYear as string)?.trim();
|
||||||
const quarter = (body.quarter as string)?.trim();
|
const quarter = (body.quarter as string)?.trim();
|
||||||
@ -336,7 +391,7 @@ export class Form16Controller {
|
|||||||
if (Number.isNaN(id)) {
|
if (Number.isNaN(id)) {
|
||||||
return ResponseHandler.error(res, 'Invalid entry id', 400);
|
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> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber;
|
if (body.tanNumber !== undefined) updateData.tanNumber = body.tanNumber;
|
||||||
if (body.panNumber !== undefined) updateData.panNumber = body.panNumber;
|
if (body.panNumber !== undefined) updateData.panNumber = body.panNumber;
|
||||||
@ -707,6 +762,14 @@ export class Form16Controller {
|
|||||||
if (!file || !file.buffer) {
|
if (!file || !file.buffer) {
|
||||||
return ResponseHandler.error(res, 'No file uploaded. Please upload a .txt file.', 400);
|
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) {
|
if (!userId) {
|
||||||
return ResponseHandler.error(res, 'Authentication required', 401);
|
return ResponseHandler.error(res, 'Authentication required', 401);
|
||||||
}
|
}
|
||||||
@ -751,8 +814,9 @@ export class Form16Controller {
|
|||||||
async get26asUploadHistory(req: Request, res: Response): Promise<void> {
|
async get26asUploadHistory(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10), 1), 200);
|
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10), 1), 200);
|
||||||
const history = await form16Service.list26asUploadHistory(limit);
|
const offset = Math.max(0, parseInt(String(req.query.offset ?? '0'), 10) || 0);
|
||||||
return ResponseHandler.success(res, { history }, '26AS upload history fetched');
|
const { rows: history, total } = await form16Service.list26asUploadHistory(limit, offset);
|
||||||
|
return ResponseHandler.success(res, { history, total }, '26AS upload history fetched');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('[Form16Controller] get26asUploadHistory error:', 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 multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@ -8,9 +8,15 @@ import { form16Controller } from '../controllers/form16.controller';
|
|||||||
import { form16SapController } from '../controllers/form16Sap.controller';
|
import { form16SapController } from '../controllers/form16Sap.controller';
|
||||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||||
import { UPLOAD_DIR } from '../config/storage';
|
import { UPLOAD_DIR } from '../config/storage';
|
||||||
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
|
|
||||||
const router = Router();
|
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)
|
// 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');
|
const form16ExtractDir = path.join(UPLOAD_DIR, 'form16-extract');
|
||||||
if (!fs.existsSync(form16ExtractDir)) {
|
if (!fs.existsSync(form16ExtractDir)) {
|
||||||
@ -50,8 +56,17 @@ const upload26asTxt = multer({
|
|||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 40 * 1024 * 1024 },
|
limits: { fileSize: 40 * 1024 * 1024 },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
const originalName = (file.originalname || '').trim();
|
||||||
const isTxt = ext === '.txt' || (file.mimetype && (file.mimetype === 'text/plain' || file.mimetype === 'application/octet-stream'));
|
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) {
|
if (isTxt) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} 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);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// Permissions (API-driven from admin config; used by frontend to show/hide Form 16 and 26AS)
|
// 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)
|
// 26AS (who can see: twentySixAsViewerEmails from admin config)
|
||||||
router.get(
|
router.get(
|
||||||
'/26as',
|
'/26as',
|
||||||
|
requireForm16ReOnly,
|
||||||
requireForm1626AsAccess,
|
requireForm1626AsAccess,
|
||||||
asyncHandler(form16Controller.list26as.bind(form16Controller))
|
asyncHandler(form16Controller.list26as.bind(form16Controller))
|
||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/26as',
|
'/26as',
|
||||||
|
requireForm16ReOnly,
|
||||||
requireForm1626AsAccess,
|
requireForm1626AsAccess,
|
||||||
asyncHandler(form16Controller.create26as.bind(form16Controller))
|
asyncHandler(form16Controller.create26as.bind(form16Controller))
|
||||||
);
|
);
|
||||||
router.put(
|
router.put(
|
||||||
'/26as/:id',
|
'/26as/:id',
|
||||||
|
requireForm16ReOnly,
|
||||||
requireForm1626AsAccess,
|
requireForm1626AsAccess,
|
||||||
asyncHandler(form16Controller.update26as.bind(form16Controller))
|
asyncHandler(form16Controller.update26as.bind(form16Controller))
|
||||||
);
|
);
|
||||||
router.delete(
|
router.delete(
|
||||||
'/26as/:id',
|
'/26as/:id',
|
||||||
|
requireForm16ReOnly,
|
||||||
requireForm1626AsAccess,
|
requireForm1626AsAccess,
|
||||||
asyncHandler(form16Controller.delete26as.bind(form16Controller))
|
asyncHandler(form16Controller.delete26as.bind(form16Controller))
|
||||||
);
|
);
|
||||||
router.get(
|
router.get(
|
||||||
'/26as/upload-history',
|
'/26as/upload-history',
|
||||||
|
requireForm16ReOnly,
|
||||||
requireForm1626AsAccess,
|
requireForm1626AsAccess,
|
||||||
asyncHandler(form16Controller.get26asUploadHistory.bind(form16Controller))
|
asyncHandler(form16Controller.get26asUploadHistory.bind(form16Controller))
|
||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/26as/upload',
|
'/26as/upload',
|
||||||
|
requireForm16ReOnly,
|
||||||
requireForm1626AsAccess,
|
requireForm1626AsAccess,
|
||||||
upload26asTxt.single('file'),
|
upload26asTxtSingle,
|
||||||
asyncHandler(form16Controller.upload26as.bind(form16Controller))
|
asyncHandler(form16Controller.upload26as.bind(form16Controller))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import axios from 'axios';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { emitToUser } from '../realtime/socket';
|
import { emitToUser } from '../realtime/socket';
|
||||||
|
import { ACCESS_TOKEN_TTL_MS } from '../config/sessionPolicy';
|
||||||
|
|
||||||
function parseDeviceFromUserAgent(ua?: string): string {
|
function parseDeviceFromUserAgent(ua?: string): string {
|
||||||
if (!ua) return 'Unknown Device';
|
if (!ua) return 'Unknown Device';
|
||||||
@ -417,7 +418,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Generate JWT access token
|
* Generate JWT access token
|
||||||
*/
|
*/
|
||||||
private generateAccessToken(user: User): string {
|
private generateAccessToken(user: User, expiresIn?: StringValue | number): string {
|
||||||
if (!ssoConfig.jwtSecret) {
|
if (!ssoConfig.jwtSecret) {
|
||||||
throw new Error('JWT secret is not configured');
|
throw new Error('JWT secret is not configured');
|
||||||
}
|
}
|
||||||
@ -431,7 +432,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const options: SignOptions = {
|
const options: SignOptions = {
|
||||||
expiresIn: ssoConfig.jwtExpiry as StringValue | number
|
expiresIn: expiresIn ?? (ssoConfig.jwtExpiry as StringValue | number)
|
||||||
};
|
};
|
||||||
|
|
||||||
return jwt.sign(payload, ssoConfig.jwtSecret, options);
|
return jwt.sign(payload, ssoConfig.jwtSecret, options);
|
||||||
@ -472,7 +473,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* Refresh access token using refresh token
|
||||||
*/
|
*/
|
||||||
async refreshAccessToken(refreshToken: string): Promise<string> {
|
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; accessTokenTtlMs: number }> {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(refreshToken, ssoConfig.jwtSecret) as any;
|
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');
|
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) {
|
} catch (error) {
|
||||||
logAuthEvent('auth_failure', undefined, {
|
logAuthEvent('auth_failure', undefined, {
|
||||||
action: 'token_refresh_failed',
|
action: 'token_refresh_failed',
|
||||||
|
|||||||
@ -678,6 +678,115 @@ function normalizeQuarter(raw: string): string {
|
|||||||
return (raw || '').trim() || '';
|
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" */
|
/** Compact FY for Form 16 note numbers: "2024-25" -> "24-25" */
|
||||||
function form16FyCompact(financialYear: string): string {
|
function form16FyCompact(financialYear: string): string {
|
||||||
const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim();
|
const fy = normalizeFinancialYear(financialYear) || (financialYear || '').trim();
|
||||||
@ -1229,6 +1338,11 @@ export async function createSubmission(
|
|||||||
await workflow.update({ status: WorkflowStatus.CLOSED });
|
await workflow.update({ status: WorkflowStatus.CLOSED });
|
||||||
logger.info(`[Form16] Request ${requestId} set to CLOSED (credit note issued).`);
|
logger.info(`[Form16] Request ${requestId} set to CLOSED (credit note issued).`);
|
||||||
}
|
}
|
||||||
|
await renameForm16SubmissionPdfAfterCreditNote({
|
||||||
|
submissionId: submission.id,
|
||||||
|
requestId,
|
||||||
|
oldRelativePath: uploadFilePath.replace(/\\/g, '/'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@ -2899,13 +3013,18 @@ export interface Form1626asUploadLogRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** List 26AS upload history (most recent first) for management section. */
|
/** List 26AS upload history (most recent first) for management section. */
|
||||||
export async function list26asUploadHistory(limit: number = 50): Promise<Form1626asUploadLogRow[]> {
|
export async function list26asUploadHistory(
|
||||||
const rows = await Form1626asUploadLog.findAll({
|
limit: number = 50,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<{ rows: Form1626asUploadLogRow[]; total: number }> {
|
||||||
|
const { rows, count } = await Form1626asUploadLog.findAndCountAll({
|
||||||
limit,
|
limit,
|
||||||
|
offset,
|
||||||
order: [['uploadedAt', 'DESC']],
|
order: [['uploadedAt', 'DESC']],
|
||||||
include: [{ model: User, as: 'uploadedByUser', attributes: ['email', 'displayName'], required: false }],
|
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;
|
const u = r as any;
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@ -2918,4 +3037,5 @@ export async function list26asUploadHistory(limit: number = 50): Promise<Form162
|
|||||||
errorsCount: u.errorsCount ?? 0,
|
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).
|
* 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> {
|
export async function getForm16ViewerConfig(): Promise<Form16ViewerConfig> {
|
||||||
try {
|
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.).
|
* Check if user can view Form 16 submission data (Credit Notes, Non-submitted Dealers, etc.).
|
||||||
* - Admin: always allowed (full access to everything).
|
* - Admin: always allowed (full access to everything).
|
||||||
* - Dealers: always allowed (they see their own submissions).
|
* - Dealers: always allowed (they see their own submissions).
|
||||||
* - RE users: allowed if submissionViewerEmails is empty, or user email is in submissionViewerEmails,
|
* - RE users: allowed only if user email is in submissionViewerEmails
|
||||||
* or user email is in twentySixAsViewerEmails (26AS access implies submission access so sidebar shows both).
|
* OR in twentySixAsViewerEmails (26AS access implies submission access).
|
||||||
*/
|
*/
|
||||||
export async function canViewForm16Submission(
|
export async function canViewForm16Submission(
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
@ -69,7 +69,6 @@ export async function canViewForm16Submission(
|
|||||||
const config = await getForm16ViewerConfig();
|
const config = await getForm16ViewerConfig();
|
||||||
const email = normalizeEmail(userEmail);
|
const email = normalizeEmail(userEmail);
|
||||||
if (!email) return false;
|
if (!email) return false;
|
||||||
if (config.submissionViewerEmails.length === 0 && config.twentySixAsViewerEmails.length === 0) return true;
|
|
||||||
if (config.submissionViewerEmails.includes(email)) return true;
|
if (config.submissionViewerEmails.includes(email)) return true;
|
||||||
if (config.twentySixAsViewerEmails.includes(email)) return true;
|
if (config.twentySixAsViewerEmails.includes(email)) return true;
|
||||||
return false;
|
return false;
|
||||||
@ -78,12 +77,12 @@ export async function canViewForm16Submission(
|
|||||||
/**
|
/**
|
||||||
* Check if user can view 26AS page and 26AS data.
|
* Check if user can view 26AS page and 26AS data.
|
||||||
* - Admin: always allowed (full access to everything).
|
* - 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> {
|
export async function canView26As(userEmail: string, role?: string): Promise<boolean> {
|
||||||
if (role === 'ADMIN') return true;
|
if (role === 'ADMIN') return true;
|
||||||
const config = await getForm16ViewerConfig();
|
const config = await getForm16ViewerConfig();
|
||||||
const email = normalizeEmail(userEmail);
|
const email = normalizeEmail(userEmail);
|
||||||
if (config.twentySixAsViewerEmails.length === 0) return true;
|
if (!email) return false;
|
||||||
return config.twentySixAsViewerEmails.includes(email);
|
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
|
* Check if GCS is properly configured
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user