Files
CBS-Mobile-App/src/services/api/apiLogger.test.ts
T
Abin ToppoandClaude Opus 5.5 5fbd13ba12 Add TPIN reset via OTP, with server-clock countdowns and redacted API logs
- 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>
2026-09-30 18:41:03 +05:30

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');
});
});