mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: dev and staging bypass
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
|
||||
# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted),
|
||||
# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda
|
||||
# (canned verified profile, no eSignet call). Leave unset in production.
|
||||
ENV=
|
||||
|
||||
PORT=3001
|
||||
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
||||
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
||||
|
||||
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
13
apps/edr-freight-api/src/common/dev-bypass.util.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Dev/staging bypass gate for OTP, payment and Fayda verification.
|
||||
*
|
||||
* Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be
|
||||
* mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in
|
||||
* production, so this is always false there.
|
||||
*/
|
||||
export function isBypassEnv(): boolean {
|
||||
return ["dev", "staging"].includes(process.env.ENV ?? "");
|
||||
}
|
||||
|
||||
/** Fixed code accepted in addition to the real one when isBypassEnv(). */
|
||||
export const DEV_BYPASS_OTP = "000000";
|
||||
@@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository";
|
||||
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EmailClientService } from "../notifications/email-client.service";
|
||||
import { isBypassEnv, DEV_BYPASS_OTP } from "../../common/dev-bypass.util";
|
||||
|
||||
/**
|
||||
* Where a code goes. At least one of phone/email must be set — enforced by the
|
||||
@@ -146,6 +147,21 @@ export class OtpService {
|
||||
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
|
||||
);
|
||||
|
||||
// Dev/staging only: the row above still exists (so a real code would
|
||||
// still verify), but skip the real SMS/email send — no carrier cost, no
|
||||
// dependency on RabbitMQ/the mail relay being up. Verify with the fixed
|
||||
// DEV_BYPASS_OTP code instead of whatever landed in the row.
|
||||
if (isBypassEnv()) {
|
||||
this.logger.warn(
|
||||
`otp.dispatch.bypassed target=${label} — dev/staging, no real SMS/email sent (verify with ${DEV_BYPASS_OTP})`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
delivered: true,
|
||||
message: "OTP sent successfully",
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||
// here let an attacker wipe the per-target guess budget just by calling
|
||||
// /otp/send between guesses. The counter is cleared only when the code is
|
||||
@@ -394,7 +410,10 @@ export class OtpService {
|
||||
|
||||
// invalid otp — per-target attempt cap so a 6-digit code can't be
|
||||
// brute-forced within its TTL; the code is burned once the budget is spent.
|
||||
if (otpData.otp !== otp) {
|
||||
// Dev/staging only: a fixed code verifies any pending OTP row without
|
||||
// knowing the real one — the row still has to exist (sendOtp still runs).
|
||||
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
|
||||
if (otpData.otp !== otp && !bypassed) {
|
||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
@@ -482,7 +501,10 @@ export class OtpService {
|
||||
);
|
||||
}
|
||||
|
||||
if (otpData.otp !== otp) {
|
||||
// Dev/staging only: a fixed code verifies any pending OTP row without
|
||||
// knowing the real one — the row still has to exist (sendOtp still runs).
|
||||
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
|
||||
if (otpData.otp !== otp && !bypassed) {
|
||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
IntentStatusDto,
|
||||
PaymentPlatformDto,
|
||||
} from "./payments.dto";
|
||||
import { isBypassEnv } from "../../common/dev-bypass.util";
|
||||
|
||||
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||
* the caller (billing) — this service never derives them from a domain record. */
|
||||
@@ -256,34 +257,52 @@ export class PaymentService {
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||
// debited against the intent amount, so the dev shortcut would break it.
|
||||
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
||||
// shortcut floor is 10, not 1.
|
||||
// amountMinor: isCbeBill
|
||||
// ? input.amountMinor
|
||||
// : input.method === ProviderMethod.CAC_BANK
|
||||
// ? 10
|
||||
// : 1,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
payerName: input.payerName,
|
||||
expiresAt: input.expiresAt,
|
||||
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
|
||||
returnUrl:
|
||||
input.returnUrl ??
|
||||
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
// Dev/staging only: skip the real gateway call entirely and report an
|
||||
// immediate SUCCEEDED snapshot — everything below (upsert, settle,
|
||||
// billing notify) runs exactly as it would for a real synchronous
|
||||
// provider success.
|
||||
const snapshot: PaymentIntentSnapshot = isBypassEnv()
|
||||
? {
|
||||
intentId: `bypass-${input.referenceId}`,
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
merchantOrderId: input.orderRef,
|
||||
provider: input.method as ProviderMethod,
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
providerTxnId: `bypass-${input.referenceId}`,
|
||||
paidAt: new Date().toISOString(),
|
||||
}
|
||||
: await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||
// debited against the intent amount, so the dev shortcut would break it.
|
||||
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
||||
// shortcut floor is 10, not 1.
|
||||
// amountMinor: isCbeBill
|
||||
// ? input.amountMinor
|
||||
// : input.method === ProviderMethod.CAC_BANK
|
||||
// ? 10
|
||||
// : 1,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
payerName: input.payerName,
|
||||
expiresAt: input.expiresAt,
|
||||
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
|
||||
returnUrl:
|
||||
input.returnUrl ??
|
||||
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
NormalizedFaydaUserInfo,
|
||||
VerifaydaPurpose,
|
||||
} from './verifayda.types';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isBypassEnv } from '../../common/dev-bypass.util';
|
||||
|
||||
/** Sentinel `code` that skips the real eSignet exchange in dev/staging. */
|
||||
export const DEV_BYPASS_FAYDA_CODE = 'DEV_BYPASS';
|
||||
|
||||
export interface StartVerificationInput {
|
||||
purpose: VerifaydaPurpose;
|
||||
@@ -143,6 +148,26 @@ export class VerifaydaService {
|
||||
async completeVerification(
|
||||
query: VerifaydaCallbackDto,
|
||||
): Promise<CompleteVerificationResult> {
|
||||
// Dev/staging only: caller sends the sentinel code instead of a real
|
||||
// eSignet redirect — skip the token exchange/session entirely and hand
|
||||
// back a canned VERIFY result. `sub` is unique per call so binding both
|
||||
// owner and PoA in the same bypass session doesn't collide.
|
||||
if (isBypassEnv() && query.code === DEV_BYPASS_FAYDA_CODE) {
|
||||
this.logger.warn('Fayda verification BYPASSED (dev/staging)');
|
||||
return {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
sub: `dev-bypass-${randomUUID()}`,
|
||||
fullName: 'Dev Bypass User',
|
||||
email: 'dev-bypass@example.com',
|
||||
phoneNumber: '+251900000000',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'M',
|
||||
address: 'Dev Bypass Address',
|
||||
userDataSaved: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
||||
if (query.state) {
|
||||
|
||||
Reference in New Issue
Block a user