- Reset TPIN: empty-body initiate -> OTP -> encrypted new TPIN via /tpin/reset/*, from a Services tile, search, and "Forgot your TPIN?" on the transfer's TPIN sheet (returns to the same transfer review) - Handle every documented code: SESSION_INVALID signs out, TPIN_NOT_SET offers Create TPIN, CBS customer/account/org refusals stop the flow, outages retry from the start, expired or spent OTP/token restarts, policy VALIDATION_ERROR stays on the new-TPIN step - Secrets live in screen state only and are dropped as each step ends - Countdowns use the envelope timestamp, kept aside by the interceptor, so a wrong device clock cannot skew OTP or token expiry - New TPINs refuse any three-digit run (1235, 5987), for create too - Dev API logger redacts the session token, OTPs, PINs, encrypted payloads and one-time tokens - Tests: request bodies, RSA-OAEP SHA-256 payload decrypted with Node, fresh nonce on key retry, server-clock deadlines, log redaction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
|
|
|
import { apiLogger, redactSecrets } from './apiLogger';
|
|
|
|
describe('redactSecrets', () => {
|
|
it('hides OTPs, PINs, encrypted payloads and every kind of token', () => {
|
|
expect(
|
|
redactSecrets({
|
|
otp: '123456',
|
|
debugOtp: '123456',
|
|
mpin: '2580',
|
|
tpin: '5826',
|
|
encryptedMpin: 'base64…',
|
|
encryptedTpin: 'base64…',
|
|
sessionToken: 'live-session',
|
|
setupToken: 'setup',
|
|
resetToken: 'mpin-reset',
|
|
tpinCreateToken: 'create',
|
|
tpinResetToken: 'reset',
|
|
}),
|
|
).toEqual({
|
|
otp: '<redacted>',
|
|
debugOtp: '<redacted>',
|
|
mpin: '<redacted>',
|
|
tpin: '<redacted>',
|
|
encryptedMpin: '<redacted>',
|
|
encryptedTpin: '<redacted>',
|
|
sessionToken: '<redacted>',
|
|
setupToken: '<redacted>',
|
|
resetToken: '<redacted>',
|
|
tpinCreateToken: '<redacted>',
|
|
tpinResetToken: '<redacted>',
|
|
});
|
|
});
|
|
|
|
it('keeps the fields that help debugging — expiries, statuses, ids', () => {
|
|
const data = {
|
|
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
|
status: 'PENDING_OTP',
|
|
otpExpiresAt: '2026-09-30T13:50:31+05:30',
|
|
tokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
|
resetTokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
|
attemptsRemaining: 3,
|
|
mpinStatus: 'ACTIVE',
|
|
};
|
|
|
|
expect(redactSecrets(data)).toEqual(data);
|
|
});
|
|
|
|
it('reaches into the envelope and into arrays', () => {
|
|
expect(
|
|
redactSecrets({
|
|
success: true,
|
|
data: { tpinResetToken: 'reset', items: [{ otp: '1' }, { name: 'x' }] },
|
|
}),
|
|
).toEqual({
|
|
success: true,
|
|
data: { tpinResetToken: '<redacted>', items: [{ otp: '<redacted>' }, { name: 'x' }] },
|
|
});
|
|
});
|
|
|
|
it('leaves an absent secret visibly absent, and non-objects alone', () => {
|
|
expect(redactSecrets({ debugOtp: null })).toEqual({ debugOtp: null });
|
|
expect(redactSecrets('(no body)')).toBe('(no body)');
|
|
expect(redactSecrets(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('apiLogger', () => {
|
|
let log: jest.SpyInstance;
|
|
let warn: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
log.mockRestore();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
const config = (data: unknown) =>
|
|
({
|
|
method: 'post',
|
|
baseURL: 'https://bank.example/api/v1',
|
|
url: '/tpin/reset/confirm',
|
|
headers: { Authorization: 'Bearer live-session-token', 'Content-Type': 'application/json' },
|
|
data,
|
|
}) as unknown as InternalAxiosRequestConfig;
|
|
|
|
it('prints the curl without the session token or the secrets in the body', () => {
|
|
apiLogger.request(config({ tpinResetToken: 'one-time-reset-token', encryptedTpin: 'cipher' }));
|
|
|
|
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
|
expect(printed).toContain('/tpin/reset/confirm');
|
|
expect(printed).toContain('Authorization: Bearer <session token>');
|
|
expect(printed).not.toContain('live-session-token');
|
|
expect(printed).not.toContain('one-time-reset-token');
|
|
expect(printed).not.toContain('cipher');
|
|
});
|
|
|
|
it('redacts a body the caller serialised themselves', () => {
|
|
apiLogger.request(config(JSON.stringify({ tpinResetId: 'id-1', otp: '654321' })));
|
|
|
|
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
|
expect(printed).toContain('id-1');
|
|
expect(printed).not.toContain('654321');
|
|
});
|
|
|
|
it('redacts secrets in what came back, success or failure', () => {
|
|
const request = config({});
|
|
apiLogger.response({
|
|
status: 200,
|
|
config: request,
|
|
data: { success: true, data: { tpinResetToken: 'one-time-reset-token' } },
|
|
} as AxiosResponse);
|
|
apiLogger.failure({
|
|
status: 400,
|
|
config: request,
|
|
data: { success: false, data: { otp: '654321' }, errorCode: 'VALIDATION_ERROR' },
|
|
} as AxiosResponse);
|
|
|
|
const printed = [...log.mock.calls, ...warn.mock.calls]
|
|
.map((call) => String(call[0]))
|
|
.join('\n');
|
|
expect(printed).toContain('VALIDATION_ERROR');
|
|
expect(printed).not.toContain('one-time-reset-token');
|
|
expect(printed).not.toContain('654321');
|
|
});
|
|
});
|