Files
esg/src/providers/implementations/amitis.provider.ts
s.hajizadeh 45ef396466 feat: add Persian error translations and comprehensive test suite
Implement normalized error handling with Persian translations across all
exception types, replace legacy NestJS exceptions with AppException,
and add unit, integration, and smoke tests.

- Add error catalog with gateway-owned codes and Persian messages
- Introduce AppException wrapping normalized error envelopes
- Add translateError helper for automatic messageFa population
- Remove claims module and update provider error normalization
- Add unit tests for error contracts and helper functions
- Add integration tests for admin and inquiry endpoints
- Add smoke tests for real provider connectivity
- Add test support utilities (auth mocks, assertions, app factory)
2026-06-29 17:25:19 +03:30

327 lines
10 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosError, AxiosInstance } from 'axios';
import {
createOutboundAxiosInstance,
isOutboundHttpDebugEnabled,
} from '../../common/helpers/http-client.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
import {
AppErrorCode,
buildNormalizedError,
} from '../../common/constants/error-messages';
import { AmitisAuthServiceConfig } from '../../config/configuration';
import { GeneralTokenDocument } from '../schemas/general-token.schema';
import { GeneralTokenService } from '../services/general-token.service';
interface CentInsurTokenResponse {
Token?: string;
token?: string;
AccessToken?: string;
accessToken?: string;
access_token?: string;
RefreshToken?: string;
refreshToken?: string;
refresh_token?: string;
TokenType?: string;
tokenType?: string;
token_type?: string;
ExpiresIn?: number | string;
expiresIn?: number | string;
expires_in?: number | string;
IsSucceed?: boolean;
LoginStatus?: number;
data?: CentInsurTokenResponse;
}
interface AmitisAuthToken {
accessToken: string;
refreshToken?: string;
tokenType: string;
expiresIn: number;
}
/**
* AMITIS Authentication Service
* Provides token-based authentication for Hamta and Moallem providers
* This is NOT a provider itself, but an authentication helper service
*/
@Injectable()
export class AmitisProvider {
readonly name = ProviderName.AMITIS;
private readonly logger = new Logger(AmitisProvider.name);
private readonly httpClient: AxiosInstance;
private readonly config: AmitisAuthServiceConfig;
constructor(
configService: ConfigService,
private readonly generalTokenService: GeneralTokenService,
) {
this.config = configService.get<AmitisAuthServiceConfig>('amitis')!;
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
});
}
isEnabled(): boolean {
return this.config.enabled && Boolean(this.config.baseUrl);
}
/**
* Get access token for a specific provider and inquiry type
* Handles token caching, refresh, and 24-hour expiration
* - Access token expires in 20 minutes
* - Refresh token expires in 24 hours from creation
*/
async getAccessToken(
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
password: string,
): Promise<string> {
const tokenKey = this.getTokenKey(providerName, inquiryType);
const latestToken = await this.generalTokenService.getLatestToken(tokenKey);
// Use cached token if valid and within 24-hour window
if (latestToken && !this.isExpired(latestToken) && this.isWithin24Hours(latestToken)) {
return latestToken.accessToken;
}
// Try to refresh if we have a refresh token and it's within 24 hours
if (latestToken?.refreshToken && this.isWithin24Hours(latestToken)) {
try {
return await this.refreshAccessToken(latestToken, providerName, inquiryType);
} catch (error) {
this.logger.warn(
`AMITIS token refresh failed for ${providerName}/${inquiryType}, requesting a new token: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// Login to get new token
return this.login(providerName, inquiryType, username, password);
}
private async login(
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
password: string,
): Promise<string> {
const loginBody = new URLSearchParams({
username: username.toLowerCase(),
password,
});
try {
const loginUrl = `${this.config.baseUrl}${this.config.loginPath}`;
this.logger.log(
`AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`,
);
const response = await this.httpClient.post<CentInsurTokenResponse>(
this.config.loginPath,
loginBody.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if (isOutboundHttpDebugEnabled()) {
this.logger.log(
`AMITIS login ← ${response.status} | provider=${providerName} | inquiry=${inquiryType} | bodyKeys=${Object.keys(response.data ?? {}).join(',') || 'none'}`,
);
}
const token = this.extractToken(response.data);
await this.saveToken(token, providerName, inquiryType, username, password, 'login');
return token.accessToken;
} catch (error) {
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
}
}
private async refreshAccessToken(
latestToken: GeneralTokenDocument,
providerName: ProviderName,
inquiryType: InquiryType,
): Promise<string> {
try {
const response = await this.httpClient.post<CentInsurTokenResponse>(this.config.refreshPath, {
Token: latestToken.accessToken,
RefreshToken: latestToken.refreshToken,
});
const token = this.extractToken(response.data, latestToken.refreshToken);
await this.saveToken(
token,
providerName,
inquiryType,
latestToken.username,
latestToken.clientSecret,
'refresh',
);
return token.accessToken;
} catch (error) {
throw this.toAuthError(
`AMITIS refresh failed for ${providerName}/${inquiryType}`,
error,
);
}
}
private async saveToken(
token: AmitisAuthToken,
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
password: string,
scope: string,
): Promise<void> {
await this.generalTokenService.create({
serviceProvider: this.getTokenKey(providerName, inquiryType),
tokenType: token.tokenType,
url: this.config.baseUrl,
clientId: username.toLowerCase(),
clientSecret: password,
username: username.toLowerCase(),
scope,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresIn: token.expiresIn,
expiresAt: new Date(Date.now() + token.expiresIn * 1000),
});
}
private extractToken(
responseBody: CentInsurTokenResponse,
fallbackRefreshToken?: string,
): AmitisAuthToken {
const body = responseBody.data ?? responseBody;
const loginStatus = body.LoginStatus;
if (loginStatus === 2 || body.IsSucceed === false) {
throw this.createAuthError(
loginStatus === 2
? 'AMITIS username or password is invalid for this service account'
: `AMITIS login rejected (LoginStatus=${loginStatus ?? 'unknown'})`,
loginStatus === 2 ? 'PROVIDER_AUTH_FAILED' : 'PROVIDER_REJECTED',
);
}
if (loginStatus !== undefined && loginStatus !== 1 && body.IsSucceed !== true) {
throw this.createAuthError(
`AMITIS login rejected (LoginStatus=${loginStatus})`,
'PROVIDER_REJECTED',
);
}
const accessToken =
body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token;
if (!accessToken || !String(accessToken).trim()) {
const bodyPreview = JSON.stringify(responseBody).slice(0, 500);
throw new Error(
`AMITIS auth response did not include an access token | body=${bodyPreview}`,
);
}
const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60);
return {
accessToken,
refreshToken:
body.RefreshToken ?? body.refreshToken ?? body.refresh_token ?? fallbackRefreshToken,
tokenType: body.TokenType ?? body.tokenType ?? body.token_type ?? 'Bearer',
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 20 * 60,
};
}
private isExpired(token: GeneralTokenDocument): boolean {
// Access token expires in 20 minutes, add 60 second buffer
const refreshBufferMs = 60 * 1000;
return !token.expiresAt || Date.now() + refreshBufferMs >= token.expiresAt.getTime();
}
/**
* Check if token is within 24-hour window from creation
* Refresh token is valid for 24 hours after initial login
*/
private isWithin24Hours(token: GeneralTokenDocument): boolean {
if (!token.createdAt) {
return false;
}
const tokenCreationTime = token.createdAt.getTime();
const currentTime = Date.now();
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
return currentTime - tokenCreationTime < twentyFourHoursMs;
}
private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string {
return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`;
}
private createAuthError(message: string, code: AppErrorCode): Error {
const normalized = buildNormalizedError(code, {
message,
providerMessage: message,
providerCode: code,
});
const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err;
}
private createProviderAuthError(message: string, error: unknown): Error {
const providerCode =
error instanceof AxiosError
? String(error.response?.status ?? error.code ?? 'NETWORK_ERROR')
: undefined;
const providerMessage = error instanceof Error ? error.message : String(error);
const normalized = buildNormalizedError('PROVIDER_AUTH_FAILED', {
message,
providerMessage,
providerCode,
});
const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err;
}
private toAuthError(message: string, error: unknown): Error {
if (error instanceof Error && 'normalizedError' in error) {
return error;
}
if (error instanceof AxiosError) {
const status = error.response?.status ?? 'NETWORK_ERROR';
const body =
error.response?.data !== undefined
? JSON.stringify(error.response.data).slice(0, 500)
: undefined;
return this.createProviderAuthError(
body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`,
error,
);
}
if (error instanceof Error) {
return this.createProviderAuthError(`${message}: ${error.message}`, error);
}
return this.createProviderAuthError(`${message}: ${String(error)}`, error);
}
}
// Made with Bob