From c4da46321fd10529540c78ebe502706410b3a81f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 18:34:19 +0000 Subject: [PATCH 1/2] feat: add identity change handler for improved connection stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick from upstream PR #2264 (commit 5cbad317) with enhancements: ## Changes ### New Files - `src/Utils/identity-change-handler.ts`: Centralized identity change handling - Enterprise-grade JSDoc documentation - Clear result types for all 9 possible outcomes - Debouncing to prevent duplicate session refreshes - Companion device filtering - Offline notification handling - `src/__tests__/Socket/identity-change-handling.test.ts`: Comprehensive tests - Core functionality tests - Debounce behavior tests - Error handling tests - Edge case coverage ### Modified Files - `src/Socket/messages-recv.ts`: Updated error handling - MISSING_KEYS_ERROR now returns NACK with ParsingError - Maintains CTWA recovery logic intact - Preserves all Prometheus metrics integration - `src/Utils/generics.ts`: Added `isStringNullOrEmpty()` helper - `src/Utils/index.ts`: Added identity-change-handler export ## Benefits - Fixes "Online" status shown when actually disconnected (#2132) - Better session management after contact identity changes - Clear separation of concerns with dedicated handler module Co-authored-by: João Lucas de Oliveira Lopes <55464917+jlucaso1@users.noreply.github.com> --- src/Socket/messages-recv.ts | 29 ++- src/Utils/generics.ts | 4 + src/Utils/identity-change-handler.ts | 181 +++++++++++++++ src/Utils/index.ts | 3 + .../Socket/identity-change-handling.test.ts | 207 ++++++++++++++++++ 5 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 src/Utils/identity-change-handler.ts create mode 100644 src/__tests__/Socket/identity-change-handling.test.ts diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index f7385565..fb9e64d2 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -36,6 +36,7 @@ import { getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, + handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, @@ -564,21 +565,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await uploadPreKeys() } } else { - const identityNode = getBinaryNodeChild(node, 'identity') - if (identityNode) { - logger.info({ jid: from }, 'identity changed') - if (identityAssertDebounce.get(from!)) { - logger.debug({ jid: from }, 'skipping identity assert (debounced)') - return - } + const result = await handleIdentityChange(node, { + meId: authState.creds.me?.id, + meLid: authState.creds.me?.lid, + validateSession: signalRepository.validateSession, + assertSessions, + debounceCache: identityAssertDebounce, + logger + }) - identityAssertDebounce.set(from!, true) - try { - await assertSessions([from!], true) - } catch (error) { - logger.warn({ error, jid: from }, 'failed to assert sessions after identity change') - } - } else { + if (result.action === 'no_identity_node') { logger.info({ node }, 'unknown encrypt notification') } } @@ -1239,9 +1235,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await decrypt() // message failed to decrypt if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') { - // Handle "Missing keys" - standard decryption failure, just ACK + // Handle "Missing keys" - standard decryption failure + // Return NACK with parsing error to signal the issue if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) { - return sendMessageAck(node) + return sendMessageAck(node, NACK_REASONS.ParsingError) } // Handle "Message absent from node" - likely a CTWA (Click-to-WhatsApp) ads message diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index ce0a69c8..9f74429f 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -50,6 +50,10 @@ export const BufferJSON = { export const getKeyAuthor = (key: WAMessageKey | undefined | null, meId = 'me') => (key?.fromMe ? meId : key?.participantAlt || key?.remoteJidAlt || key?.participant || key?.remoteJid) || '' +export const isStringNullOrEmpty = (value: string | null | undefined): value is null | undefined | '' => + // eslint-disable-next-line eqeqeq + value == null || value === '' + export const writeRandomPadMax16 = (msg: Uint8Array) => { const pad = randomBytes(1) const padLength = (pad[0]! & 0x0f) + 1 diff --git a/src/Utils/identity-change-handler.ts b/src/Utils/identity-change-handler.ts new file mode 100644 index 00000000..d185b068 --- /dev/null +++ b/src/Utils/identity-change-handler.ts @@ -0,0 +1,181 @@ +/** + * Identity Change Handler + * + * Handles WhatsApp identity change notifications which occur when a contact's + * encryption keys change. This typically happens when: + * - User reinstalls WhatsApp + * - User changes devices + * - User's phone number verification expires and is re-verified + * + * This module centralizes the identity change logic to: + * - Prevent duplicate session refreshes via debouncing + * - Skip unnecessary refreshes for companion devices + * - Handle offline notification scenarios correctly + * - Provide clear result types for monitoring and debugging + * + * @module identity-change-handler + * @see https://github.com/WhiskeySockets/Baileys/issues/2132 + */ + +import NodeCache from '@cacheable/node-cache' +import { areJidsSameUser, type BinaryNode, getBinaryNodeChild, jidDecode } from '../WABinary' +import { isStringNullOrEmpty } from './generics' +import type { ILogger } from './logger' + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Result type for identity change handling operations. + * Each action variant indicates a specific outcome with relevant context. + */ +export type IdentityChangeResult = + | { action: 'no_identity_node' } + | { action: 'invalid_notification' } + | { action: 'skipped_companion_device'; device: number } + | { action: 'skipped_self_primary' } + | { action: 'debounced' } + | { action: 'skipped_offline' } + | { action: 'skipped_no_session' } + | { action: 'session_refreshed' } + | { action: 'session_refresh_failed'; error: unknown } + +/** + * Context required for identity change handling. + * Provides access to session management and logging capabilities. + */ +export type IdentityChangeContext = { + /** Current user's phone number JID (e.g., "5511999999999@s.whatsapp.net") */ + meId: string | undefined + /** Current user's LID (Logical ID) if available */ + meLid: string | undefined + /** Function to validate if a session exists for a JID */ + validateSession: (jid: string) => Promise<{ exists: boolean; reason?: string }> + /** Function to assert/refresh sessions for given JIDs */ + assertSessions: (jids: string[], force?: boolean) => Promise + /** Cache for debouncing identity change processing */ + debounceCache: NodeCache + /** Logger instance for debugging and monitoring */ + logger: ILogger +} + +// ============================================================================ +// MAIN HANDLER +// ============================================================================ + +/** + * Handles an identity change notification from WhatsApp. + * + * This function processes identity change events with the following logic: + * + * 1. **Validation**: Ensures the notification has required fields (from, identity node) + * 2. **Companion Device Filter**: Skips notifications from companion devices (device > 0) + * as they don't require session refresh + * 3. **Self-Primary Skip**: Skips notifications for the current user's own identity + * 4. **Debouncing**: Prevents duplicate processing for the same JID within TTL window + * 5. **Session Check**: Verifies an existing session exists before attempting refresh + * 6. **Offline Handling**: Skips refresh during offline notification processing + * 7. **Session Refresh**: Attempts to refresh the session with force flag + * + * @param node - The binary node containing the identity change notification + * @param ctx - Context object with required dependencies + * @returns Promise resolving to the result of the identity change handling + * + * @example + * ```typescript + * const result = await handleIdentityChange(notificationNode, { + * meId: '5511999999999@s.whatsapp.net', + * meLid: undefined, + * validateSession: async (jid) => authState.keys.get('session', [jid]), + * assertSessions: async (jids, force) => assertSession(jids, force), + * debounceCache: new NodeCache({ stdTTL: 5 }), + * logger: pino() + * }) + * + * switch (result.action) { + * case 'session_refreshed': + * console.log('Session successfully refreshed') + * break + * case 'session_refresh_failed': + * console.error('Failed to refresh session:', result.error) + * break + * // ... handle other cases + * } + * ``` + */ +export async function handleIdentityChange( + node: BinaryNode, + ctx: IdentityChangeContext +): Promise { + const from = node.attrs.from + if (!from) { + return { action: 'invalid_notification' } + } + + // Check for identity node presence + const identityNode = getBinaryNodeChild(node, 'identity') + if (!identityNode) { + return { action: 'no_identity_node' } + } + + ctx.logger.info({ jid: from }, 'identity changed') + + // Skip companion devices - they don't need session refresh + const decoded = jidDecode(from) + if (decoded?.device && decoded.device !== 0) { + ctx.logger.debug( + { jid: from, device: decoded.device }, + 'ignoring identity change from companion device' + ) + return { action: 'skipped_companion_device', device: decoded.device } + } + + // Skip self-primary identity changes + const isSelfPrimary = ctx.meId && ( + areJidsSameUser(from, ctx.meId) || + (ctx.meLid && areJidsSameUser(from, ctx.meLid)) + ) + if (isSelfPrimary) { + ctx.logger.info({ jid: from }, 'self primary identity changed') + return { action: 'skipped_self_primary' } + } + + // Debounce to prevent duplicate processing + if (ctx.debounceCache.get(from)) { + ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)') + return { action: 'debounced' } + } + + // Mark as processing in debounce cache + ctx.debounceCache.set(from, true) + + // Check if we have an existing session to refresh + const hasExistingSession = await ctx.validateSession(from) + if (!hasExistingSession.exists) { + ctx.logger.debug({ jid: from }, 'no old session, skipping session refresh') + return { action: 'skipped_no_session' } + } + + ctx.logger.debug({ jid: from }, 'old session exists, will refresh session') + + // Skip refresh during offline notification processing + // Offline notifications are processed in batch and shouldn't trigger immediate refreshes + const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline) + if (isOfflineNotification) { + ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing') + return { action: 'skipped_offline' } + } + + // Attempt session refresh + try { + await ctx.assertSessions([from], true) + return { action: 'session_refreshed' } + } catch (error) { + ctx.logger.warn( + { error, jid: from }, + 'failed to assert sessions after identity change' + ) + return { action: 'session_refresh_failed', error } + } +} diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 1826cf29..70bd687f 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -17,6 +17,9 @@ export * from './process-message' export * from './message-retry-manager' export * from './browser-utils' +// === Identity and Session Management === +export * from './identity-change-handler' + // === Observability and Resilience Utilities === // Structured logging diff --git a/src/__tests__/Socket/identity-change-handling.test.ts b/src/__tests__/Socket/identity-change-handling.test.ts new file mode 100644 index 00000000..3f12ae14 --- /dev/null +++ b/src/__tests__/Socket/identity-change-handling.test.ts @@ -0,0 +1,207 @@ +import NodeCache from '@cacheable/node-cache' +import { jest } from '@jest/globals' +import P from 'pino' +import { handleIdentityChange, type IdentityChangeContext } from '../../Utils/identity-change-handler' +import { type BinaryNode } from '../../WABinary' + +const logger = P({ level: 'silent' }) + +type ValidateSessionFn = (jid: string) => Promise<{ exists: boolean; reason?: string }> +type AssertSessionsFn = (jids: string[], force?: boolean) => Promise + +describe('Identity Change Handling', () => { + let mockValidateSession: jest.Mock + let mockAssertSessions: jest.Mock + let identityAssertDebounce: NodeCache + let mockMeId: string + let mockMeLid: string | undefined + + function createIdentityChangeNode(from: string, offline?: string): BinaryNode { + return { + tag: 'notification', + attrs: { + from, + type: 'encrypt', + ...(offline !== undefined ? { offline } : {}) + }, + content: [ + { + tag: 'identity', + attrs: {}, + content: Buffer.from('test-identity-key') + } + ] + } + } + + function createContext(): IdentityChangeContext { + return { + meId: mockMeId, + meLid: mockMeLid, + validateSession: mockValidateSession, + assertSessions: mockAssertSessions, + debounceCache: identityAssertDebounce, + logger + } + } + + beforeEach(() => { + jest.clearAllMocks() + mockValidateSession = jest.fn() + mockAssertSessions = jest.fn() + identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false }) + mockMeId = 'myuser@s.whatsapp.net' + mockMeLid = 'mylid@lid' + }) + + describe('Core Checks', () => { + it('should skip companion devices (device > 0)', async () => { + const node = createIdentityChangeNode('user:5@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_companion_device') + }) + + it('should process primary device (device 0 or undefined)', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(result.action).toBe('session_refreshed') + }) + + it('should skip self-primary identity (PN match)', async () => { + const node = createIdentityChangeNode('myuser@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_self_primary') + }) + + it('should skip self-primary identity (LID match)', async () => { + const node = createIdentityChangeNode('mylid@lid') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_self_primary') + }) + + it('should skip when no existing session', async () => { + mockValidateSession.mockResolvedValue({ exists: false }) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockAssertSessions).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_no_session') + }) + + it('should skip session refresh during offline processing', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + + const node = createIdentityChangeNode('user@s.whatsapp.net', '0') + const result = await handleIdentityChange(node, createContext()) + + expect(mockAssertSessions).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_offline') + }) + + it('should refresh session when online with existing session', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true) + expect(result.action).toBe('session_refreshed') + }) + }) + + describe('Debounce', () => { + it('should debounce multiple identity changes for the same JID', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + + const result1 = await handleIdentityChange(node, createContext()) + expect(result1.action).toBe('session_refreshed') + + const result2 = await handleIdentityChange(node, createContext()) + expect(result2.action).toBe('debounced') + expect(mockAssertSessions).toHaveBeenCalledTimes(1) + }) + + it('should allow different JIDs to process independently', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const result1 = await handleIdentityChange(createIdentityChangeNode('user1@s.whatsapp.net'), createContext()) + const result2 = await handleIdentityChange(createIdentityChangeNode('user2@s.whatsapp.net'), createContext()) + + expect(result1.action).toBe('session_refreshed') + expect(result2.action).toBe('session_refreshed') + expect(mockAssertSessions).toHaveBeenCalledTimes(2) + }) + }) + + describe('Error Handling', () => { + it('should handle assertSessions failure gracefully', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + const testError = new Error('Session assertion failed') + mockAssertSessions.mockRejectedValue(testError) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(result.action).toBe('session_refresh_failed') + expect((result as { error: unknown }).error).toBe(testError) + }) + + it('should propagate validateSession errors', async () => { + mockValidateSession.mockRejectedValue(new Error('Database error')) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + await expect(handleIdentityChange(node, createContext())).rejects.toThrow('Database error') + }) + }) + + describe('Edge Cases', () => { + it('should return invalid_notification when from is missing', async () => { + const node: BinaryNode = { + tag: 'notification', + attrs: { type: 'encrypt' }, + content: [{ tag: 'identity', attrs: {}, content: Buffer.from('key') }] + } + + const result = await handleIdentityChange(node, createContext()) + expect(result.action).toBe('invalid_notification') + }) + + it('should return no_identity_node when identity child is missing', async () => { + const node: BinaryNode = { + tag: 'notification', + attrs: { from: 'user@s.whatsapp.net', type: 'encrypt' }, + content: [] + } + + const result = await handleIdentityChange(node, createContext()) + expect(result.action).toBe('no_identity_node') + }) + + it('should handle LID JIDs correctly', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('12345@lid') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).toHaveBeenCalledWith('12345@lid') + expect(result.action).toBe('session_refreshed') + }) + }) +}) From 4a3d8e9e46fe0a875f7cc5122341ad39717f0d44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 18:55:01 +0000 Subject: [PATCH 2/2] fix: address PR review comments for identity change handler Fixes 4 issues identified in PR #35 code review: ## 1. Self-Primary Identity Check (Copilot #1) - BEFORE: `ctx.meId && (areJidsSameUser(...) || (ctx.meLid && ...))` - AFTER: Check meId and meLid independently with OR - FIX: Now correctly detects when only meLid exists ## 2. Debounce Cache Placement (Copilot #3, #4) - BEFORE: Cache set immediately after debounce check - AFTER: Cache set only before actual assertSessions call - FIX: Prevents incorrect debouncing when exiting early (offline, etc.) ## 3. Session Regression (ChatGPT Codex P2) - BEFORE: Returned 'skipped_no_session' when no session exists - AFTER: Always call assertSessions - identity change IS the signal to rebuild - FIX: Critical for key reset and device restore scenarios ## 4. Result Type Enhancement - Added `hadExistingSession: boolean` to 'session_refreshed' result - Removed 'skipped_no_session' action (no longer applicable) - Enables better monitoring of session creation vs refresh ## Test Updates - Added test for meLid-only self-primary detection - Updated session creation test (no longer skips) - Added test verifying debounce not set on offline skip - Added Result Types test suite for type safety --- src/Utils/identity-change-handler.ts | 56 ++++++----- .../Socket/identity-change-handling.test.ts | 94 +++++++++++++++++-- 2 files changed, 118 insertions(+), 32 deletions(-) diff --git a/src/Utils/identity-change-handler.ts b/src/Utils/identity-change-handler.ts index d185b068..08511212 100644 --- a/src/Utils/identity-change-handler.ts +++ b/src/Utils/identity-change-handler.ts @@ -37,8 +37,7 @@ export type IdentityChangeResult = | { action: 'skipped_self_primary' } | { action: 'debounced' } | { action: 'skipped_offline' } - | { action: 'skipped_no_session' } - | { action: 'session_refreshed' } + | { action: 'session_refreshed'; hadExistingSession: boolean } | { action: 'session_refresh_failed'; error: unknown } /** @@ -74,9 +73,12 @@ export type IdentityChangeContext = { * as they don't require session refresh * 3. **Self-Primary Skip**: Skips notifications for the current user's own identity * 4. **Debouncing**: Prevents duplicate processing for the same JID within TTL window - * 5. **Session Check**: Verifies an existing session exists before attempting refresh - * 6. **Offline Handling**: Skips refresh during offline notification processing - * 7. **Session Refresh**: Attempts to refresh the session with force flag + * 5. **Offline Handling**: Skips refresh during offline notification processing + * 6. **Session Refresh**: Attempts to refresh/create the session with force flag + * + * **Important**: Identity change notifications signal that we need to rebuild the session, + * regardless of whether an existing session exists. This is critical for cases where + * the local session was cleared (e.g., after key reset or device restore). * * @param node - The binary node containing the identity change notification * @param ctx - Context object with required dependencies @@ -95,7 +97,7 @@ export type IdentityChangeContext = { * * switch (result.action) { * case 'session_refreshed': - * console.log('Session successfully refreshed') + * console.log('Session refreshed, had existing:', result.hadExistingSession) * break * case 'session_refresh_failed': * console.error('Failed to refresh session:', result.error) @@ -132,45 +134,49 @@ export async function handleIdentityChange( } // Skip self-primary identity changes - const isSelfPrimary = ctx.meId && ( - areJidsSameUser(from, ctx.meId) || - (ctx.meLid && areJidsSameUser(from, ctx.meLid)) - ) - if (isSelfPrimary) { + // FIX: Check meId and meLid independently to handle cases where only meLid exists + const matchesMeId = ctx.meId && areJidsSameUser(from, ctx.meId) + const matchesMeLid = ctx.meLid && areJidsSameUser(from, ctx.meLid) + if (matchesMeId || matchesMeLid) { ctx.logger.info({ jid: from }, 'self primary identity changed') return { action: 'skipped_self_primary' } } // Debounce to prevent duplicate processing + // Check early but don't set yet - we only want to debounce actual refresh attempts if (ctx.debounceCache.get(from)) { ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)') return { action: 'debounced' } } - // Mark as processing in debounce cache - ctx.debounceCache.set(from, true) - - // Check if we have an existing session to refresh - const hasExistingSession = await ctx.validateSession(from) - if (!hasExistingSession.exists) { - ctx.logger.debug({ jid: from }, 'no old session, skipping session refresh') - return { action: 'skipped_no_session' } - } - - ctx.logger.debug({ jid: from }, 'old session exists, will refresh session') - // Skip refresh during offline notification processing // Offline notifications are processed in batch and shouldn't trigger immediate refreshes + // FIX: Check this BEFORE setting debounce cache to avoid incorrect debouncing const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline) if (isOfflineNotification) { ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing') return { action: 'skipped_offline' } } - // Attempt session refresh + // Check if we have an existing session (for logging purposes only) + // FIX: We no longer skip refresh when no session exists - identity change is the + // signal to rebuild the session, which is critical after key reset or device restore + const hasExistingSession = await ctx.validateSession(from) + + if (hasExistingSession.exists) { + ctx.logger.debug({ jid: from }, 'existing session found, will refresh') + } else { + ctx.logger.debug({ jid: from }, 'no existing session, will create new session') + } + + // FIX: Set debounce cache only immediately before the actual refresh attempt + // This ensures we don't incorrectly debounce when we exit early (offline, etc.) + ctx.debounceCache.set(from, true) + + // Attempt session refresh/creation try { await ctx.assertSessions([from], true) - return { action: 'session_refreshed' } + return { action: 'session_refreshed', hadExistingSession: hasExistingSession.exists } } catch (error) { ctx.logger.warn( { error, jid: from }, diff --git a/src/__tests__/Socket/identity-change-handling.test.ts b/src/__tests__/Socket/identity-change-handling.test.ts index 3f12ae14..74f9751b 100644 --- a/src/__tests__/Socket/identity-change-handling.test.ts +++ b/src/__tests__/Socket/identity-change-handling.test.ts @@ -1,7 +1,7 @@ import NodeCache from '@cacheable/node-cache' import { jest } from '@jest/globals' import P from 'pino' -import { handleIdentityChange, type IdentityChangeContext } from '../../Utils/identity-change-handler' +import { handleIdentityChange, type IdentityChangeContext, type IdentityChangeResult } from '../../Utils/identity-change-handler' import { type BinaryNode } from '../../WABinary' const logger = P({ level: 'silent' }) @@ -13,7 +13,7 @@ describe('Identity Change Handling', () => { let mockValidateSession: jest.Mock let mockAssertSessions: jest.Mock let identityAssertDebounce: NodeCache - let mockMeId: string + let mockMeId: string | undefined let mockMeLid: string | undefined function createIdentityChangeNode(from: string, offline?: string): BinaryNode { @@ -71,6 +71,7 @@ describe('Identity Change Handling', () => { const result = await handleIdentityChange(node, createContext()) expect(result.action).toBe('session_refreshed') + expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true) }) it('should skip self-primary identity (PN match)', async () => { @@ -89,22 +90,38 @@ describe('Identity Change Handling', () => { expect(result.action).toBe('skipped_self_primary') }) - it('should skip when no existing session', async () => { + it('should skip self-primary identity when only meLid exists', async () => { + // FIX: Test for the case where meId is undefined but meLid matches + mockMeId = undefined + mockMeLid = 'mylid@lid' + + const node = createIdentityChangeNode('mylid@lid') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_self_primary') + }) + + it('should create session when no existing session exists', async () => { + // FIX: Identity change is the signal to rebuild session, even if none exists + // This is critical for key reset or device restore scenarios mockValidateSession.mockResolvedValue({ exists: false }) + mockAssertSessions.mockResolvedValue(true) const node = createIdentityChangeNode('user@s.whatsapp.net') const result = await handleIdentityChange(node, createContext()) - expect(mockAssertSessions).not.toHaveBeenCalled() - expect(result.action).toBe('skipped_no_session') + expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true) + expect(result.action).toBe('session_refreshed') + expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(false) }) it('should skip session refresh during offline processing', async () => { - mockValidateSession.mockResolvedValue({ exists: true }) - const node = createIdentityChangeNode('user@s.whatsapp.net', '0') const result = await handleIdentityChange(node, createContext()) + // FIX: validateSession should not be called for offline notifications + // because we skip before reaching that point expect(mockAssertSessions).not.toHaveBeenCalled() expect(result.action).toBe('skipped_offline') }) @@ -118,6 +135,7 @@ describe('Identity Change Handling', () => { expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true) expect(result.action).toBe('session_refreshed') + expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true) }) }) @@ -147,6 +165,24 @@ describe('Identity Change Handling', () => { expect(result2.action).toBe('session_refreshed') expect(mockAssertSessions).toHaveBeenCalledTimes(2) }) + + it('should NOT set debounce cache when skipping due to offline', async () => { + // FIX: Debounce should only be set when we actually attempt refresh + const node = createIdentityChangeNode('user@s.whatsapp.net', '0') + + const result1 = await handleIdentityChange(node, createContext()) + expect(result1.action).toBe('skipped_offline') + + // Now process the same JID online - it should NOT be debounced + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const onlineNode = createIdentityChangeNode('user@s.whatsapp.net') + const result2 = await handleIdentityChange(onlineNode, createContext()) + + expect(result2.action).toBe('session_refreshed') + expect(mockAssertSessions).toHaveBeenCalledTimes(1) + }) }) describe('Error Handling', () => { @@ -203,5 +239,49 @@ describe('Identity Change Handling', () => { expect(mockValidateSession).toHaveBeenCalledWith('12345@lid') expect(result.action).toBe('session_refreshed') }) + + it('should process when both meId and meLid are undefined', async () => { + mockMeId = undefined + mockMeLid = undefined + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('anyuser@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(result.action).toBe('session_refreshed') + }) + }) + + describe('Result Types', () => { + it('should include hadExistingSession in session_refreshed result', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) as Extract + + expect(result.action).toBe('session_refreshed') + expect(result.hadExistingSession).toBe(true) + }) + + it('should return hadExistingSession=false when creating new session', async () => { + mockValidateSession.mockResolvedValue({ exists: false }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) as Extract + + expect(result.action).toBe('session_refreshed') + expect(result.hadExistingSession).toBe(false) + }) + + it('should include device number in skipped_companion_device result', async () => { + const node = createIdentityChangeNode('user:5@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) as Extract + + expect(result.action).toBe('skipped_companion_device') + expect(result.device).toBe(5) + }) }) })