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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f1def81eb7
commit
5fbd13ba12
@@ -0,0 +1,131 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user