diff --git a/app/assets/javascripts/observability/components/app.vue b/app/assets/javascripts/observability/components/app.vue index fab8d5b9523ee5581f89571d284db0628665fdae..018da6e7f7662f5925198a8da96b40a377c3c882 100644 --- a/app/assets/javascripts/observability/components/app.vue +++ b/app/assets/javascripts/observability/components/app.vue @@ -1,4 +1,9 @@ @@ -24,13 +218,17 @@ export default {
+
{{ $options.i18n.messages.loadingMetrics }}
+
+ {{ $options.i18n.messages.authenticationFailed }} +
diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js index a5210991ee6b236ae496f34ab2a5d285d0699fa0..5fcd49152037e8506725a79e8090d20e69420874 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -57,3 +57,27 @@ export const FULL_DATE_TIME_FORMAT = `mmm dd yyyy HH:MM:ss.l Z`; export const SHORT_DATE_TIME_FORMAT = `mmm dd yyyy HH:MM:ss Z`; export const ISSUE_PATH_ID_SEPARATOR = '#'; + +export const MESSAGE_TYPES = { + // eslint-disable-next-line @gitlab/require-i18n-strings + AUTH_STATUS: 'O11Y_AUTH_STATUS', + // eslint-disable-next-line @gitlab/require-i18n-strings + JWT_LOGIN: 'O11Y_JWT_LOGIN', + // eslint-disable-next-line @gitlab/require-i18n-strings + JWT_LOGIN_ENCRYPTED: 'O11Y_JWT_LOGIN_ENCRYPTED', +}; +export const TIMEOUTS = { + MAX_MESSAGE_AGE: 1 * 60 * 1000, + MAX_CLOCK_SKEW: 30 * 1000, + BASE_RETRY_DELAY: 2000, + CLEANUP_INTERVAL: 60 * 1000, +}; + +export const RETRY_CONFIG = { + MAX_RETRIES: 3, + EXPONENTIAL_BACKOFF_BASE: 2, +}; + +export const AUTH_CONFIG = { + ENCRYPTION_ENABLED: true, +}; diff --git a/app/assets/javascripts/observability/index.js b/app/assets/javascripts/observability/index.js index 500091cff2ef0027cb53cfb6d635a5505ff68079..c4a2b8aaacf80aaa14ddac8d6b1ca1508fe7b419 100644 --- a/app/assets/javascripts/observability/index.js +++ b/app/assets/javascripts/observability/index.js @@ -6,6 +6,15 @@ export default () => { if (!el) return null; const { dataset } = el; + const authTokens = {}; + for (const key in dataset) { + if (key.startsWith('authTokens')) { + const newKey = key.replace(/^authTokens/, ''); + const formattedKey = newKey.charAt(0).toLowerCase() + newKey.slice(1); + authTokens[formattedKey] = dataset[key]; + } + } + return new Vue({ el, render(h) { @@ -13,6 +22,9 @@ export default () => { props: { o11yUrl: dataset.o11yUrl, path: dataset.path, + authTokens, + title: dataset.title, + encryptionKey: dataset.encryptionKey, }, }); }, diff --git a/app/assets/javascripts/observability/utils/crypto.js b/app/assets/javascripts/observability/utils/crypto.js new file mode 100644 index 0000000000000000000000000000000000000000..9e033a9456b97b2336aa5f8c5dc4158edca19940 --- /dev/null +++ b/app/assets/javascripts/observability/utils/crypto.js @@ -0,0 +1,165 @@ +import { s__ } from '~/locale'; + +/** + * Secure Cross-Frame Messaging Encryption Utilities + * + * This module provides encryption/decryption for sensitive data passed between + * Vue parent and React iframe using Web Crypto API with AES-GCM encryption. + */ + +const CRYPTO_ERROR_MESSAGE = s__('Observability|Crypto operation failed'); +const MAX_MESSAGE_AGE_MS = 5 * 60 * 1000; +const MIN_ENCRYPTION_KEY_LENGTH = 32; + +const ENCRYPTION_CONFIG = { + algorithm: 'AES-GCM', + keyLength: 256, + ivLength: 12, + tagLength: 128, + keyDerivationIterations: 100000, + saltLength: 16, + // eslint-disable-next-line @gitlab/require-i18n-strings + keyDerivationAlgorithm: 'PBKDF2', + hashAlgorithm: 'SHA-256', +}; + +const getSharedSecret = (encryptionKey) => { + if (!encryptionKey || encryptionKey.length < MIN_ENCRYPTION_KEY_LENGTH) { + throw new Error(s__('Observability|Valid encryption key is required for secure operation')); + } + + return new TextEncoder().encode(encryptionKey); +}; + +const deriveKey = async (secret, salt) => { + try { + const baseKey = await crypto.subtle.importKey( + 'raw', + secret, + ENCRYPTION_CONFIG.keyDerivationAlgorithm, + false, + ['deriveKey'], + ); + + return await crypto.subtle.deriveKey( + { + name: ENCRYPTION_CONFIG.keyDerivationAlgorithm, + salt, + iterations: ENCRYPTION_CONFIG.keyDerivationIterations, + hash: ENCRYPTION_CONFIG.hashAlgorithm, + }, + baseKey, + { + name: ENCRYPTION_CONFIG.algorithm, + length: ENCRYPTION_CONFIG.keyLength, + }, + false, + ['encrypt', 'decrypt'], + ); + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const encryptPayload = async (data, origin, encryptionKey) => { + try { + const salt = window.crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.saltLength)); + const iv = window.crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.ivLength)); + + const sharedSecret = getSharedSecret(encryptionKey); + const key = await deriveKey(sharedSecret, salt); + + const plaintext = new TextEncoder().encode(JSON.stringify(data)); + + const additionalData = new TextEncoder().encode(origin); + const encrypted = await crypto.subtle.encrypt( + { + name: ENCRYPTION_CONFIG.algorithm, + iv, + additionalData, + tagLength: ENCRYPTION_CONFIG.tagLength, + }, + key, + plaintext, + ); + + return { + encrypted: Array.from(new Uint8Array(encrypted)), + salt: Array.from(salt), + iv: Array.from(iv), + algorithm: ENCRYPTION_CONFIG.algorithm, + timestamp: Date.now(), + }; + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const decryptPayload = async (encryptedPayload, origin, encryptionKey) => { + try { + const { encrypted, salt, iv, algorithm, timestamp } = encryptedPayload; + + if (algorithm !== ENCRYPTION_CONFIG.algorithm) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } + + if (Date.now() - timestamp > MAX_MESSAGE_AGE_MS) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } + + const encryptedData = new Uint8Array(encrypted); + const saltBytes = new Uint8Array(salt); + const ivBytes = new Uint8Array(iv); + + const sharedSecret = getSharedSecret(encryptionKey); + const key = await deriveKey(sharedSecret, saltBytes); + + const additionalData = new TextEncoder().encode(origin); + + const decrypted = await crypto.subtle.decrypt( + { + name: ENCRYPTION_CONFIG.algorithm, + iv: ivBytes, + additionalData, + tagLength: ENCRYPTION_CONFIG.tagLength, + }, + key, + encryptedData, + ); + + const plaintext = new TextDecoder().decode(decrypted); + return JSON.parse(plaintext); + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const isCryptoSupported = () => { + try { + return Boolean( + window.crypto && + window.crypto.subtle && + window.crypto.getRandomValues && + typeof window.crypto.subtle.encrypt === 'function' && + typeof window.crypto.subtle.decrypt === 'function' && + typeof window.crypto.subtle.deriveKey === 'function', + ); + } catch { + return false; + } +}; + +export const generateSecureRandom = (length = 32) => { + if (length <= 0 || !Number.isInteger(length)) { + throw new Error(s__('Observability|Length must be a positive integer')); + } + if (!isCryptoSupported()) { + throw new Error(s__('Observability|Crypto API not supported')); + } + + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); +}; + +export const generateNonce = () => generateSecureRandom(16); diff --git a/app/assets/javascripts/observability/utils/message_validator.js b/app/assets/javascripts/observability/utils/message_validator.js new file mode 100644 index 0000000000000000000000000000000000000000..dc6212153ce6736fc26d00e7aa9ea3a1a30e2377 --- /dev/null +++ b/app/assets/javascripts/observability/utils/message_validator.js @@ -0,0 +1,192 @@ +import { s__ } from '~/locale'; + +import { MESSAGE_TYPES, TIMEOUTS } from '../constants'; + +export class MessageValidator { + constructor( + allowedOrigin, + allowedMessageType, + { + maxMessageAge = TIMEOUTS.MAX_MESSAGE_AGE, + maxClockSkew = TIMEOUTS.MAX_CLOCK_SKEW, + maxNonceHistory = 1000, + timestampTolerance = 5000, + } = {}, + ) { + if (!allowedOrigin || typeof allowedOrigin !== 'string') { + throw new Error('allowedOrigin must be a non-empty string'); + } + if (!allowedMessageType || typeof allowedMessageType !== 'string') { + throw new Error('allowedMessageType must be a non-empty string'); + } + + this.allowedOrigin = allowedOrigin; + this.allowedMessageType = allowedMessageType; + this.maxMessageAge = maxMessageAge; + this.maxClockSkew = maxClockSkew; + this.maxNonceHistory = maxNonceHistory; + this.timestampTolerance = timestampTolerance; + this.lastMessageTimestamp = 0; + this.processedNonces = new Set(); + this.recentTimestamps = new Set(); + this.lastCleanup = 0; + } + + validateMessage(event, expectedNonce, expectedCounter) { + const { origin, data } = event; + + if ( + !this.validateOrigin(origin) || + !this.validateStructure(data) || + !this.validateType(data.type) || + !this.validateNonce(data.nonce, expectedNonce) || + !this.validateCounter(data.counter, expectedCounter) || + !this.validateTimestamp(data.timestamp) + ) { + return { valid: false, error: s__('Observability|Message validation failed') }; + } + + this.trackNonce(data.nonce); + return { valid: true }; + } + + validateOrigin(origin) { + return origin === this.allowedOrigin; + } + + // eslint-disable-next-line class-methods-use-this + validateStructure(data) { + if (!data || typeof data !== 'object') { + return false; + } + + if ( + typeof data.type !== 'string' || + typeof data.timestamp !== 'number' || + typeof data.nonce !== 'string' || + typeof data.counter !== 'number' + ) { + return false; + } + + if (data.nonce.length === 0) { + return false; + } + + if (data.counter < 0) { + return false; + } + + return true; + } + + validateType(type) { + return type === this.allowedMessageType; + } + + validateNonce(nonce, expectedNonce) { + if (nonce !== expectedNonce) { + return false; + } + + if (this.processedNonces.has(nonce)) { + return false; + } + + return true; + } + + // eslint-disable-next-line class-methods-use-this + validateCounter(counter, expectedCounter) { + return counter === expectedCounter; + } + + validateTimestamp(timestamp) { + const now = Date.now(); + const messageAge = now - timestamp; + + if (messageAge > this.maxMessageAge) { + return false; + } + + if (messageAge < -this.maxClockSkew) { + return false; + } + + if (this.recentTimestamps.has(timestamp)) { + return false; + } + + if (this.lastMessageTimestamp === 0) { + this.lastMessageTimestamp = timestamp; + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + const toleranceWindow = this.timestampTolerance; + const isWithinTolerance = Math.abs(timestamp - this.lastMessageTimestamp) <= toleranceWindow; + + if (isWithinTolerance) { + if (timestamp > this.lastMessageTimestamp) { + this.lastMessageTimestamp = timestamp; + } + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + if (timestamp > this.lastMessageTimestamp) { + this.lastMessageTimestamp = timestamp; + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + return false; + } + + cleanupOldTimestamps(now) { + const cutoffTime = now - this.maxMessageAge; + if (!this.lastCleanup || now - this.lastCleanup > TIMEOUTS.CLEANUP_INTERVAL) { + for (const timestamp of this.recentTimestamps) { + if (timestamp < cutoffTime) { + this.recentTimestamps.delete(timestamp); + } + } + this.lastCleanup = now; + } + } + + trackNonce(nonce) { + this.processedNonces.add(nonce); + + if (this.processedNonces.size > this.maxNonceHistory) { + const noncesArray = Array.from(this.processedNonces); + const noncesToDelete = noncesArray.slice(0, 100); + noncesToDelete.forEach((n) => this.processedNonces.delete(n)); + } + } + + reset() { + this.lastMessageTimestamp = 0; + this.processedNonces.clear(); + this.recentTimestamps.clear(); + this.lastCleanup = 0; + } + + getValidationStats() { + return { + lastMessageTimestamp: this.lastMessageTimestamp, + processedNoncesCount: this.processedNonces.size, + recentTimestampsCount: this.recentTimestamps.size, + allowedOrigin: this.allowedOrigin, + allowedMessageType: this.allowedMessageType, + timestampTolerance: this.timestampTolerance, + }; + } +} + +export const createMessageValidator = (allowedOrigin, options = {}) => { + return new MessageValidator(allowedOrigin, MESSAGE_TYPES.AUTH_STATUS, options); +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bffbc7381cead259538b5631ec945e05a1f6ebbe..c52ad104c41bb562bf69437b1f70121fcc1cf7ab 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43125,6 +43125,12 @@ msgstr "" msgid "Observability|Are you sure you want to delete the observability service settings? This action cannot be undone." msgstr "" +msgid "Observability|Authentication failed. Please refresh the page." +msgstr "" + +msgid "Observability|Authentication timeout" +msgstr "" + msgid "Observability|Configure your observability service connection settings." msgstr "" @@ -43134,6 +43140,12 @@ msgstr "" msgid "Observability|Creates alerts automatically for Observability-related errors." msgstr "" +msgid "Observability|Crypto API not supported" +msgstr "" + +msgid "Observability|Crypto operation failed" +msgstr "" + msgid "Observability|Danger Zone" msgstr "" @@ -43152,9 +43164,15 @@ msgstr "" msgid "Observability|Encryption Key" msgstr "" +msgid "Observability|Encryption not supported in this browser" +msgstr "" + msgid "Observability|Failed to delete observability service settings." msgstr "" +msgid "Observability|Failed to encrypt authentication data" +msgstr "" + msgid "Observability|Failed to load observability usage data." msgstr "" @@ -43197,10 +43215,19 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" -msgid "Observability|O11y Service Settings" +msgid "Observability|Length must be a positive integer" +msgstr "" + +msgid "Observability|Loading metrics..." +msgstr "" + +msgid "Observability|Message validation failed" msgstr "" -msgid "Observability|Observability Dashboard" +msgid "Observability|Missing authentication tokens." +msgstr "" + +msgid "Observability|O11y Service Settings" msgstr "" msgid "Observability|Observability Service Configuration" @@ -43248,6 +43275,9 @@ msgstr "" msgid "Observability|User Email" msgstr "" +msgid "Observability|Valid encryption key is required for secure operation" +msgstr "" + msgid "Observability|View our %{documentation} for further instructions on how to use these features." msgstr "" diff --git a/spec/frontend/observability/components/app_spec.js b/spec/frontend/observability/components/app_spec.js index 48ff99615845d3f0bd6950ba3f66abbb3a35b032..d8b6a4f4e977c8b7589b72eda17ed4acd59b6c12 100644 --- a/spec/frontend/observability/components/app_spec.js +++ b/spec/frontend/observability/components/app_spec.js @@ -1,5 +1,27 @@ import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import App from '~/observability/components/app.vue'; +import * as cryptoModule from '~/observability/utils/crypto'; + +const DEFAULTS = { + O11Y_URL: 'https://o11y.gitlab.com', + PATH: 'traces-explorer', + TOKENS: { accessJwt: 'accessToken', refreshJwt: 'refreshToken', userId: 'userId' }, + TITLE: 'Observability', + ENCRYPTION_KEY: 'test-encryption-key', +}; + +jest.mock('~/observability/utils/crypto', () => ({ + encryptPayload: jest.fn().mockResolvedValue({ + encrypted: [1, 2, 3, 4], + salt: [5, 6, 7, 8], + iv: [9, 10, 11, 12], + algorithm: 'AES-GCM', + timestamp: Date.now(), + }), + isCryptoSupported: jest.fn().mockReturnValue(true), + generateNonce: jest.fn(() => 'test-nonce-12345678901234567890123456'), +})); describe('Observability App Component', () => { let wrapper; @@ -7,52 +29,273 @@ describe('Observability App Component', () => { const createComponent = (props = {}) => { return shallowMount(App, { propsData: { - o11yUrl: 'https://o11y.gitlab.com', - path: 'traces-explorer', + o11yUrl: DEFAULTS.O11Y_URL, + path: DEFAULTS.PATH, + authTokens: DEFAULTS.TOKENS, + title: DEFAULTS.TITLE, + encryptionKey: DEFAULTS.ENCRYPTION_KEY, ...props, }, }); }; - it('renders the iframe with correct attributes', () => { - wrapper = createComponent(); + const setupComponent = async (props = {}) => { + wrapper = createComponent(props); + await nextTick(); + + const iframe = wrapper.find('iframe').element; + const contentWindow = { postMessage: jest.fn() }; + Object.defineProperty(iframe, 'contentWindow', { value: contentWindow }); + Object.defineProperty(iframe, 'style', { value: { display: 'none' }, writable: true }); + + return { iframe, contentWindow }; + }; + + const sendAuthMessage = async (data = {}) => { + Object.assign(wrapper.vm, { + messageNonce: wrapper.vm.messageNonce || 'test-nonce-12345678901234567890123456', + expectedResponseCounter: wrapper.vm.expectedResponseCounter || 1, + lastMessageTimestamp: wrapper.vm.lastMessageTimestamp || Date.now() - 1000, + }); + + const messageData = { + type: 'O11Y_AUTH_STATUS', + timestamp: Date.now() + 1, + nonce: wrapper.vm.messageNonce, + counter: wrapper.vm.expectedResponseCounter, + authenticated: true, + ...data, + }; + + window.dispatchEvent( + new MessageEvent('message', { + data: messageData, + origin: DEFAULTS.O11Y_URL, + }), + ); + + await nextTick(); + }; + + const expectAuthError = (expectedMessage) => { + expect(wrapper.emitted('authentication-error')).toBeDefined(); + expect(wrapper.emitted('authentication-error')[0][0].message).toBe(expectedMessage); + }; + + beforeEach(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + jest.clearAllMocks(); - const iframe = wrapper.find('iframe'); - expect(iframe.exists()).toBe(true); - expect(iframe.attributes('title')).toBe('Observability Dashboard'); - expect(iframe.attributes('frameborder')).toBe('0'); - expect(iframe.classes()).toContain('gl-h-full'); - expect(iframe.classes()).toContain('gl-w-full'); + cryptoModule.isCryptoSupported.mockReturnValue(true); + cryptoModule.encryptPayload.mockResolvedValue({ + encrypted: [1, 2, 3, 4], + salt: [5, 6, 7, 8], + iv: [9, 10, 11, 12], + algorithm: 'AES-GCM', + timestamp: Date.now(), + }); + + global.crypto = { + getRandomValues: jest.fn((array) => array.fill(1)), + subtle: { + encrypt: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])), + decrypt: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])), + deriveKey: jest.fn().mockResolvedValue({}), + importKey: jest.fn().mockResolvedValue({}), + }, + }; }); - it('computes the correct iframe URL', () => { - wrapper = createComponent({ - o11yUrl: 'https://o11y.gitlab.com', - path: 'traces-explorer', + afterEach(() => { + jest.clearAllTimers(); + wrapper?.destroy(); + }); + + describe('Basic Rendering', () => { + it('renders iframe with correct attributes', async () => { + await setupComponent(); + + expect(wrapper.find('iframe').exists()).toBe(true); + expect(wrapper.find('iframe').attributes('src')).toBe( + 'https://o11y.gitlab.com/traces-explorer', + ); + expect(wrapper.find('iframe').attributes('title')).toBe('Observability'); + expect(wrapper.find('iframe').classes()).toContain('gl-h-full'); + }); + + it('shows loading state initially', async () => { + await setupComponent(); + expect(wrapper.find('.o11y-status').text()).toBe('Loading metrics...'); }); + }); - expect(wrapper.find('iframe').attributes('src')).toBe( - 'https://o11y.gitlab.com/traces-explorer', + describe('URL Construction', () => { + const urlCases = [ + { + o11yUrl: 'https://o11y.gitlab.com', + path: 'traces-explorer', + expected: 'https://o11y.gitlab.com/traces-explorer', + }, + { + o11yUrl: 'https://o11y.gitlab.com', + path: '/dashboard', + expected: 'https://o11y.gitlab.com/dashboard', + }, + { + o11yUrl: 'https://o11y.gitlab.com/', + path: 'test', + expected: 'https://o11y.gitlab.com/test', + }, + ]; + + it.each(urlCases)( + 'constructs URL correctly for $o11yUrl + $path', + async ({ o11yUrl, path, expected }) => { + await setupComponent({ o11yUrl, path }); + expect(wrapper.vm.iframeUrl).toBe(expected); + }, ); }); - it('handles paths with leading slashes correctly', () => { - wrapper = createComponent({ - o11yUrl: 'https://o11y.gitlab.com', - path: '/dashboard', + describe('Authentication Flow', () => { + beforeEach(async () => { + await setupComponent(); }); - expect(wrapper.find('iframe').attributes('src')).toBe('https://o11y.gitlab.com/dashboard'); + it('handles successful authentication', async () => { + await sendAuthMessage({ authenticated: true }); + + expect(wrapper.find('.o11y-status').exists()).toBe(false); + expect(wrapper.emitted('authentication-error')).toBeUndefined(); + }); + + it('handles authentication failure', async () => { + await sendAuthMessage({ authenticated: false }); + + expectAuthError('Authentication failed. Please refresh the page.'); + expect(wrapper.find('.o11y-status').text()).toBe( + 'Authentication failed. Please refresh the page.', + ); + }); + + it('ignores messages from wrong origin', async () => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'O11Y_AUTH_STATUS', authenticated: true }, + origin: 'https://wrong-origin.com', + }), + ); + await nextTick(); + + expect(wrapper.find('.o11y-status').text()).toBe('Loading metrics...'); + expect(wrapper.emitted('authentication-error')).toBeUndefined(); + }); }); - it('handles nested paths correctly', () => { - wrapper = createComponent({ - o11yUrl: 'https://o11y.gitlab.com', - path: 'logs/logs-explorer', + describe('Iframe Interactions', () => { + it('shows iframe after load event and successful auth', async () => { + const { iframe } = await setupComponent(); + + iframe.dispatchEvent(new Event('load')); + await nextTick(); + expect(iframe.style.display).toBe('block'); + + await sendAuthMessage({ authenticated: true }); + expect(wrapper.find('.o11y-status').exists()).toBe(false); }); + }); - expect(wrapper.find('iframe').attributes('src')).toBe( - 'https://o11y.gitlab.com/logs/logs-explorer', - ); + describe('Error Handling', () => { + it('handles missing auth tokens', async () => { + const { iframe } = await setupComponent({ + authTokens: { accessJwt: 'token' }, + }); + + iframe.dispatchEvent(new Event('load')); + await nextTick(); + + expectAuthError('Missing authentication tokens.'); + }); + + it('handles crypto not supported', async () => { + cryptoModule.isCryptoSupported.mockReturnValue(false); + + await setupComponent(); + + expectAuthError('Encryption not supported in this browser'); + }); + + it('handles encryption failure', async () => { + const { iframe } = await setupComponent(); + + wrapper.vm.cryptoSupported = true; + cryptoModule.encryptPayload.mockRejectedValue(new Error('Encryption failed')); + iframe.dispatchEvent(new Event('load')); + await nextTick(); + + jest.runAllTimers(); + await nextTick(); + await Promise.resolve(); + await nextTick(); + + expect(cryptoModule.encryptPayload).toHaveBeenCalled(); + + expectAuthError('Failed to encrypt authentication data'); + }); + }); + + describe('Retry Logic', () => { + const mockRetryBehavior = (componentWrapper) => { + return jest.fn(() => { + const { vm } = componentWrapper; + vm.messageCounter += 1; + vm.expectedResponseCounter = vm.messageCounter; + vm.scheduleRetryTimeout(); + }); + }; + + it('retries on timeout with exponential backoff', async () => { + const { iframe } = await setupComponent(); + + const sendAuthSpy = mockRetryBehavior(wrapper); + wrapper.vm.sendAuthMessage = sendAuthSpy; + + iframe.dispatchEvent(new Event('load')); + await nextTick(); + expect(sendAuthSpy).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(2000); + await nextTick(); + expect(sendAuthSpy).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(4000); + await nextTick(); + expect(sendAuthSpy).toHaveBeenCalledTimes(3); + }); + + it('stops retrying after max attempts', async () => { + const { iframe } = await setupComponent(); + + const sendAuthSpy = mockRetryBehavior(wrapper); + wrapper.vm.sendAuthMessage = sendAuthSpy; + + iframe.dispatchEvent(new Event('load')); + await nextTick(); + + jest.advanceTimersByTime(30000); + await nextTick(); + + expectAuthError('Authentication timeout'); + }); + }); + + describe('Cleanup', () => { + it('cleans up without errors', async () => { + await setupComponent(); + + expect(wrapper.vm).toBeDefined(); + expect(wrapper.find('iframe').exists()).toBe(true); + expect(() => wrapper.destroy()).not.toThrow(); + }); }); }); diff --git a/spec/frontend/observability/utils/crypto_spec.js b/spec/frontend/observability/utils/crypto_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..911836e17f7aa8dab47b149ebf70697e129211b2 --- /dev/null +++ b/spec/frontend/observability/utils/crypto_spec.js @@ -0,0 +1,329 @@ +/** + * Test suite for cross-frame authentication encryption + * + * These tests validate the security implementation across both Vue and React + * applications, ensuring encryption/decryption works correctly and securely. + */ + +import { + encryptPayload, + decryptPayload, + isCryptoSupported, + generateSecureRandom, + generateNonce, +} from '../../../../app/assets/javascripts/observability/utils/crypto'; + +describe('Cross-Frame Authentication Encryption', () => { + const fixtures = { + origin: 'https://test-origin.com', + encryptionKey: 'test-encryption-key-for-unit-tests-only', + payload: { + userId: 'test-user-123', + accessJwt: 'fake.jwt.token', + refreshJwt: 'fake.refresh.jwt.refresh', + theme: 'dark', + nonce: 'test-nonce-123456789abcdef', + timestamp: Date.now(), + counter: 1, + }, + }; + + const testHelpers = { + async setupCrypto() { + if (!global.crypto) { + const { webcrypto } = await import('crypto'); + global.crypto = webcrypto; + } + if (!global.crypto.subtle) { + const { webcrypto } = await import('crypto'); + global.crypto.subtle = webcrypto.subtle; + } + }, + + withoutCrypto(testFn) { + const originalCrypto = global.crypto; + const originalSubtle = global.crypto?.subtle; + + return () => { + delete global.crypto; + + try { + return testFn(); + } finally { + global.crypto = originalCrypto; + if (originalCrypto && originalSubtle) { + global.crypto.subtle = originalSubtle; + } + } + }; + }, + + encryptWithFixtures(payload = fixtures.payload) { + return encryptPayload(payload, fixtures.origin, fixtures.encryptionKey); + }, + + decryptWithFixtures(encrypted) { + return decryptPayload(encrypted, fixtures.origin, fixtures.encryptionKey); + }, + }; + + beforeAll(testHelpers.setupCrypto); + + describe('Crypto Feature Detection', () => { + it('detects crypto API support', () => { + expect(isCryptoSupported()).toBe(true); + }); + + it( + 'handles missing crypto API gracefully', + testHelpers.withoutCrypto(() => { + expect(isCryptoSupported()).toBe(false); + }), + ); + + it('generates nonce when crypto is supported', () => { + const nonce = generateNonce(); + expect(nonce).toMatch(/^[a-f0-9]{32}$/); + }); + + it( + 'throws error when crypto is not supported', + testHelpers.withoutCrypto(() => { + expect(() => generateNonce()).toThrow('Crypto API not supported'); + }), + ); + }); + + describe('Encryption/Decryption Workflow', () => { + it('encrypts and decrypts payload successfully', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + expect(encrypted).toEqual( + expect.objectContaining({ + encrypted: expect.any(Array), + salt: expect.any(Array), + iv: expect.any(Array), + algorithm: 'AES-GCM', + timestamp: expect.any(Number), + }), + ); + + const decrypted = await testHelpers.decryptWithFixtures(encrypted); + expect(decrypted).toEqual(fixtures.payload); + }); + + it('fails decryption with wrong origin', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + + await expect( + decryptPayload(encrypted, 'https://wrong-origin.com', fixtures.encryptionKey), + ).rejects.toThrow('Crypto operation failed'); + }); + + it('fails decryption with tampered data', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + encrypted.encrypted[0] = (encrypted.encrypted[0] + 1) % 256; + + await expect(testHelpers.decryptWithFixtures(encrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + + it('fails decryption with old timestamp', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + encrypted.timestamp = Date.now() - 10 * 60 * 1000; + + await expect(testHelpers.decryptWithFixtures(encrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + }); + + describe('Security Validations', () => { + it('generates unique nonces', () => { + const nonce1 = generateSecureRandom(32); + const nonce2 = generateSecureRandom(32); + + expect(nonce1).not.toBe(nonce2); + expect(nonce1).toHaveLength(64); + expect(nonce2).toHaveLength(64); + }); + + describe('generateSecureRandom validation', () => { + const validInputs = [1, 32, 100, undefined]; + const invalidInputs = [ + [0, 'Length must be a positive integer'], + [-1, 'Length must be a positive integer'], + [1.5, 'Length must be a positive integer'], + ['32', 'Length must be a positive integer'], + [NaN, 'Length must be a positive integer'], + ]; + + validInputs.forEach((input) => { + it(`accepts valid input: ${input}`, () => { + expect(() => generateSecureRandom(input)).not.toThrow(); + }); + }); + + invalidInputs.forEach(([input, expectedError]) => { + it(`rejects invalid input: ${input}`, () => { + expect(() => generateSecureRandom(input)).toThrow(expectedError); + }); + }); + }); + + const payloadTestCases = [ + { + name: 'small payload', + payload: { test: 'small' }, + }, + { + name: 'large payload', + payload: { + test: 'large', + data: 'x'.repeat(10000), + nested: { + deep: { + structure: { + with: ['arrays', 'and', 'objects'], + numbers: [1, 2, 3, 4, 5], + }, + }, + }, + }, + }, + { + name: 'unicode payload', + payload: { + emoji: '🔐🛡️🔑', + chinese: '加密数据', + arabic: 'البيانات المشفرة', + special: '!@#$%^&*()_+-=[]{}|;:,.<>?', + quotes: '"\'`', + }, + }, + { + name: 'empty payload', + payload: {}, + }, + ]; + + payloadTestCases.forEach(({ name, payload }) => { + it(`handles ${name}`, async () => { + const encrypted = await testHelpers.encryptWithFixtures(payload); + expect(encrypted).toEqual( + expect.objectContaining({ + encrypted: expect.any(Array), + salt: expect.any(Array), + iv: expect.any(Array), + algorithm: 'AES-GCM', + timestamp: expect.any(Number), + }), + ); + + const decrypted = await testHelpers.decryptWithFixtures(encrypted); + expect(decrypted).toEqual(payload); + }); + }); + }); + + describe('Error Handling', () => { + const errorTestCases = [ + { + name: 'invalid algorithm', + setup: (encrypted) => { + const modified = { ...encrypted, algorithm: 'INVALID-ALGORITHM' }; + return modified; + }, + }, + { + name: 'malformed encrypted data', + setup: (encrypted) => { + const modified = { ...encrypted, encrypted: 'not-an-array' }; + return modified; + }, + }, + ]; + + errorTestCases.forEach(({ name, setup }) => { + it(`handles ${name}`, async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + const modifiedEncrypted = setup(encrypted); + + await expect(testHelpers.decryptWithFixtures(modifiedEncrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + }); + }); + + describe('Performance Tests', () => { + const PERFORMANCE_THRESHOLD_MS = 100; + + async function measureTime(operation) { + const startTime = performance.now(); + await operation(); + return performance.now() - startTime; + } + + it('encrypts and decrypts within reasonable time limits', async () => { + const encryptionTime = await measureTime(() => testHelpers.encryptWithFixtures()); + + const encrypted = await testHelpers.encryptWithFixtures(); + const decryptionTime = await measureTime(() => testHelpers.decryptWithFixtures(encrypted)); + + expect(encryptionTime).toBeLessThan(PERFORMANCE_THRESHOLD_MS); + expect(decryptionTime).toBeLessThan(PERFORMANCE_THRESHOLD_MS); + }); + + it('handles multiple concurrent operations', async () => { + const operations = Array.from({ length: 10 }, async (_, i) => { + const payload = { ...fixtures.payload, counter: i }; + const encrypted = await testHelpers.encryptWithFixtures(payload); + return testHelpers.decryptWithFixtures(encrypted); + }); + + const results = await Promise.all(operations); + + results.forEach((result, index) => { + expect(result.counter).toBe(index); + }); + }); + }); + + describe('Cross-Application Integration', () => { + it('simulates full message flow between Vue and React', async () => { + const vueMessage = { + type: 'O11Y_JWT_LOGIN_ENCRYPTED', + payload: await testHelpers.encryptWithFixtures(), + encrypted: true, + }; + + expect(vueMessage).toEqual( + expect.objectContaining({ + encrypted: true, + type: 'O11Y_JWT_LOGIN_ENCRYPTED', + payload: expect.any(Object), + }), + ); + + const decryptedInReact = await testHelpers.decryptWithFixtures(vueMessage.payload); + expect(decryptedInReact).toEqual(fixtures.payload); + + const reactResponse = { + type: 'O11Y_AUTH_STATUS', + authenticated: true, + timestamp: Date.now(), + nonce: fixtures.payload.nonce, + counter: fixtures.payload.counter, + }; + + expect(reactResponse).toEqual( + expect.objectContaining({ + authenticated: true, + nonce: fixtures.payload.nonce, + counter: fixtures.payload.counter, + }), + ); + }); + }); +}); diff --git a/spec/frontend/observability/utils/message_validator_spec.js b/spec/frontend/observability/utils/message_validator_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..62bd54bee0884e709040c73af1f95b4f0b627039 --- /dev/null +++ b/spec/frontend/observability/utils/message_validator_spec.js @@ -0,0 +1,426 @@ +import { MessageValidator, createMessageValidator } from '~/observability/utils/message_validator'; +import { MESSAGE_TYPES, TIMEOUTS } from '~/observability/constants'; + +jest.mock('~/locale', () => ({ + s__: jest.fn(() => 'Message validation failed'), +})); + +describe('MessageValidator', () => { + let validator; + const mockNow = 1000000; + const config = { + allowedOrigin: 'https://trusted-origin.com', + allowedMessageType: MESSAGE_TYPES.AUTH_STATUS, + maxMessageAge: TIMEOUTS.MAX_MESSAGE_AGE, + }; + + const createEvent = (overrides = {}) => ({ + origin: config.allowedOrigin, + data: { + type: config.allowedMessageType, + timestamp: mockNow - 1000, + nonce: 'valid-nonce', + counter: 1, + ...overrides, + }, + }); + + beforeEach(() => { + validator = new MessageValidator(config.allowedOrigin, config.allowedMessageType, { + maxMessageAge: config.maxMessageAge, + }); + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('initializes with provided configuration', () => { + expect(validator.allowedOrigin).toBe(config.allowedOrigin); + expect(validator.allowedMessageType).toBe(config.allowedMessageType); + expect(validator.maxMessageAge).toBe(config.maxMessageAge); + expect(validator.lastMessageTimestamp).toBe(0); + expect(validator.timestampTolerance).toBe(5000); + }); + + it('applies sensible defaults when options are omitted', () => { + const defaultValidator = new MessageValidator( + config.allowedOrigin, + config.allowedMessageType, + ); + expect(defaultValidator.maxMessageAge).toBe(TIMEOUTS.MAX_MESSAGE_AGE); + }); + + it('accepts custom configuration options', () => { + const customValidator = new MessageValidator( + config.allowedOrigin, + config.allowedMessageType, + { + timestampTolerance: 10000, + }, + ); + expect(customValidator.timestampTolerance).toBe(10000); + }); + }); + + describe('validateMessage', () => { + it('accepts completely valid messages', () => { + const event = createEvent(); + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: true }); + }); + + describe('rejects invalid messages', () => { + const invalidCases = [ + { + name: 'wrong origin', + event: () => createEvent(), + modifier: (event) => ({ ...event, origin: 'https://malicious-origin.com' }), + }, + { + name: 'malformed data structure', + event: () => ({ origin: config.allowedOrigin, data: null }), + modifier: () => {}, + }, + { + name: 'incorrect message type', + event: () => createEvent({ type: 'INVALID_TYPE' }), + modifier: () => {}, + }, + { + name: 'nonce mismatch', + event: () => createEvent(), + modifier: () => {}, + nonce: 'wrong-nonce', + }, + { + name: 'counter mismatch', + event: () => createEvent(), + modifier: () => {}, + counter: 2, + }, + { + name: 'stale timestamp', + event: () => createEvent({ timestamp: 1 }), + modifier: () => {}, + }, + ]; + + invalidCases.forEach(({ name, event, modifier, nonce = 'valid-nonce', counter = 1 }) => { + it(`when ${name}`, () => { + const testEvent = event(); + const modifiedEvent = modifier(testEvent) || testEvent; + const result = validator.validateMessage(modifiedEvent, nonce, counter); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + }); + }); + }); + + describe('origin validation', () => { + const testCases = [ + { input: config.allowedOrigin, expected: true, description: 'matching origin' }, + { input: 'https://malicious-origin.com', expected: false, description: 'different origin' }, + { input: null, expected: false, description: 'null origin' }, + { input: undefined, expected: false, description: 'undefined origin' }, + ]; + + testCases.forEach(({ input, expected, description }) => { + it(`${expected ? 'accepts' : 'rejects'} ${description}`, () => { + expect(validator.validateOrigin(input)).toBe(expected); + }); + }); + }); + + describe('data structure validation', () => { + const validStructure = { + type: 'string-type', + timestamp: 123456789, + nonce: 'string-nonce', + counter: 42, + }; + + it('accepts well-formed data objects', () => { + expect(validator.validateStructure(validStructure)).toBe(true); + }); + + describe('rejects malformed data', () => { + const invalidCases = [ + { input: null, reason: 'null data' }, + { input: undefined, reason: 'undefined data' }, + { input: 'string', reason: 'string instead of object' }, + { input: 123, reason: 'number instead of object' }, + { input: [], reason: 'array instead of object' }, + { input: { ...validStructure, type: 123 }, reason: 'non-string type' }, + { input: { ...validStructure, timestamp: '123456789' }, reason: 'non-numeric timestamp' }, + { input: { ...validStructure, nonce: 123 }, reason: 'non-string nonce' }, + { input: { ...validStructure, counter: '42' }, reason: 'non-numeric counter' }, + ]; + + invalidCases.forEach(({ input, reason }) => { + it(`when data has ${reason}`, () => { + expect(validator.validateStructure(input)).toBe(false); + }); + }); + + it('when required fields are missing', () => { + const requiredFields = ['type', 'timestamp', 'nonce', 'counter']; + requiredFields.forEach((field) => { + const incompleteData = { ...validStructure }; + delete incompleteData[field]; + expect(validator.validateStructure(incompleteData)).toBe(false); + }); + }); + }); + }); + + describe('message type validation', () => { + const testCases = [ + { input: config.allowedMessageType, expected: true }, + { input: 'WRONG_TYPE', expected: false }, + { input: null, expected: false }, + { input: undefined, expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + it(`${expected ? 'accepts' : 'rejects'} ${input || 'falsy values'}`, () => { + expect(validator.validateType(input)).toBe(expected); + }); + }); + }); + + describe('nonce validation', () => { + it('accepts matching nonces', () => { + expect(validator.validateNonce('test-nonce', 'test-nonce')).toBe(true); + }); + + const invalidCases = [ + { actual: 'nonce1', expected: 'nonce2', reason: 'mismatched nonces' }, + { actual: null, expected: 'test', reason: 'null actual nonce' }, + { actual: 'test', expected: null, reason: 'null expected nonce' }, + { actual: undefined, expected: 'test', reason: 'undefined actual nonce' }, + { actual: 'test', expected: undefined, reason: 'undefined expected nonce' }, + ]; + + invalidCases.forEach(({ actual, expected, reason }) => { + it(`rejects ${reason}`, () => { + expect(validator.validateNonce(actual, expected)).toBe(false); + }); + }); + }); + + describe('counter validation', () => { + it('accepts matching counters', () => { + expect(validator.validateCounter(42, 42)).toBe(true); + }); + + const invalidCases = [ + { actual: 1, expected: 2, reason: 'mismatched counters' }, + { actual: null, expected: 1, reason: 'null actual counter' }, + { actual: 1, expected: null, reason: 'null expected counter' }, + { actual: undefined, expected: 1, reason: 'undefined actual counter' }, + { actual: 1, expected: undefined, reason: 'undefined expected counter' }, + ]; + + invalidCases.forEach(({ actual, expected, reason }) => { + it(`rejects ${reason}`, () => { + expect(validator.validateCounter(actual, expected)).toBe(false); + }); + }); + }); + + describe('timestamp validation', () => { + beforeEach(() => { + validator.reset(); + }); + + describe('message age validation', () => { + it('accepts recent timestamps', () => { + const recentTimestamp = mockNow - 1000; + expect(validator.validateTimestamp(recentTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(recentTimestamp); + }); + + it('rejects timestamps older than maximum age', () => { + const staleTimestamp = mockNow - config.maxMessageAge - 1000; + expect(validator.validateTimestamp(staleTimestamp)).toBe(false); + }); + + it('accepts timestamps at the exact age boundary', () => { + const boundaryTimestamp = mockNow - config.maxMessageAge; + expect(validator.validateTimestamp(boundaryTimestamp)).toBe(true); + }); + + it('rejects future timestamps beyond clock skew tolerance', () => { + const futureTimestamp = mockNow + config.maxMessageAge + 1000; + expect(validator.validateTimestamp(futureTimestamp)).toBe(false); + }); + }); + + describe('duplicate timestamp detection', () => { + it('rejects exact duplicate timestamps', () => { + const timestamp = mockNow - 1000; + expect(validator.validateTimestamp(timestamp)).toBe(true); + expect(validator.validateTimestamp(timestamp)).toBe(false); + }); + }); + + describe('out-of-order message handling', () => { + const baseTimestamp = mockNow - 1000; + const tolerance = 5000; + + beforeEach(() => { + validator.validateTimestamp(baseTimestamp); + }); + + it('allows messages within tolerance window', () => { + const withinTolerance = [ + baseTimestamp - 2000, + baseTimestamp - 4000, + baseTimestamp + 2000, + baseTimestamp + 4000, + ]; + + withinTolerance.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + }); + + it('rejects messages outside tolerance window', () => { + const outsideTolerance = [ + baseTimestamp - (tolerance + 1000), + baseTimestamp - (tolerance + 2000), + ]; + + outsideTolerance.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(false); + }); + }); + + it('updates last timestamp when receiving newer messages within tolerance', () => { + const newerTimestamp = baseTimestamp + 3000; + expect(validator.validateTimestamp(newerTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(newerTimestamp); + }); + + it('preserves last timestamp when receiving older messages within tolerance', () => { + const olderTimestamp = baseTimestamp - 3000; + expect(validator.validateTimestamp(olderTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(baseTimestamp); + }); + }); + + it('handles progressive timestamp advancement correctly', () => { + const timestamps = [995000, 1005000, 1015000]; + timestamps.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + }); + + it('manages cleanup of old timestamps from tracking set', () => { + const recentTimestamp = mockNow - 1000; + validator.validateTimestamp(recentTimestamp); + expect(validator.recentTimestamps.has(recentTimestamp)).toBe(true); + + validator.cleanupOldTimestamps(mockNow + config.maxMessageAge + 2000); + expect(validator.recentTimestamps.has(recentTimestamp)).toBe(false); + }); + }); + + describe('validator state management', () => { + it('properly resets all state when reset() is called', () => { + validator.validateTimestamp(mockNow - 1000); + validator.trackNonce('test-nonce'); + + expect(validator.lastMessageTimestamp).not.toBe(0); + expect(validator.recentTimestamps.size).toBeGreaterThan(0); + + validator.reset(); + expect(validator.lastMessageTimestamp).toBe(0); + expect(validator.recentTimestamps.size).toBe(0); + }); + + it('allows reprocessing previously seen timestamps after reset', () => { + const timestamp = mockNow - 1000; + + expect(validator.validateTimestamp(timestamp)).toBe(true); + expect(validator.validateTimestamp(timestamp)).toBe(false); + + validator.reset(); + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + + it('provides comprehensive validation statistics', () => { + validator.validateTimestamp(mockNow - 1000); + validator.trackNonce('test-nonce'); + + const stats = validator.getValidationStats(); + expect(stats).toEqual({ + lastMessageTimestamp: mockNow - 1000, + processedNoncesCount: 1, + recentTimestampsCount: 1, + allowedOrigin: config.allowedOrigin, + allowedMessageType: config.allowedMessageType, + timestampTolerance: 5000, + }); + }); + }); + + describe('factory function', () => { + it('creates properly configured MessageValidator instances', () => { + const createdValidator = createMessageValidator('https://example.com'); + + expect(createdValidator).toBeInstanceOf(MessageValidator); + expect(createdValidator.allowedOrigin).toBe('https://example.com'); + expect(createdValidator.allowedMessageType).toBe(MESSAGE_TYPES.AUTH_STATUS); + expect(createdValidator.maxMessageAge).toBe(TIMEOUTS.MAX_MESSAGE_AGE); + }); + }); + + describe('integration scenarios', () => { + it('processes sequential valid messages correctly', () => { + const messages = [ + { timestamp: 995000, nonce: 'nonce-1', counter: 1 }, + { timestamp: 996000, nonce: 'nonce-2', counter: 2 }, + { timestamp: 997000, nonce: 'nonce-3', counter: 3 }, + ]; + + messages.forEach(({ timestamp, nonce, counter }) => { + const event = createEvent({ timestamp, nonce, counter }); + expect(validator.validateMessage(event, nonce, counter)).toEqual({ valid: true }); + }); + + expect(validator.lastMessageTimestamp).toBe(997000); + }); + + it('maintains state consistency during mixed valid/invalid message processing', () => { + const validEvent = createEvent({ timestamp: 995000, nonce: 'valid-nonce', counter: 1 }); + const invalidEvent = createEvent({ + timestamp: 996000, + nonce: 'invalid-nonce', + counter: 2, + }); + + expect(validator.validateMessage(validEvent, 'valid-nonce', 1)).toEqual({ valid: true }); + expect(validator.validateMessage(invalidEvent, 'expected-nonce', 2)).toEqual({ + valid: false, + error: 'Message validation failed', + }); + + const nextValidEvent = createEvent({ timestamp: 997000, nonce: 'next-nonce', counter: 3 }); + expect(validator.validateMessage(nextValidEvent, 'next-nonce', 3)).toEqual({ valid: true }); + }); + + it('handles attempt to replay previously processed messages', () => { + const event = createEvent({ timestamp: 995000, nonce: 'replay-nonce', counter: 1 }); + + expect(validator.validateMessage(event, 'replay-nonce', 1)).toEqual({ valid: true }); + expect(validator.validateMessage(event, 'replay-nonce', 1)).toEqual({ + valid: false, + error: 'Message validation failed', + }); + }); + }); +});