From 0de96b4a5cd7ad5e1d34392743463f103f2cee3e Mon Sep 17 00:00:00 2001 From: dakotadux Date: Tue, 29 Jul 2025 14:43:08 -0600 Subject: [PATCH] Add observability message validator component This component is used to validate messages sent from the iframed react app to the main vue app. The component is used to validate the message's origin, type, nonce, and counter. The component is also used to validate the message's timestamp. --- .../javascripts/observability/constants.js | 15 + .../observability/utils/message_validator.js | 181 ++++++ locale/gitlab.pot | 9 + .../utils/message_validator_spec.js | 526 ++++++++++++++++++ 4 files changed, 731 insertions(+) create mode 100644 app/assets/javascripts/observability/utils/message_validator.js create mode 100644 spec/frontend/observability/utils/message_validator_spec.js diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js index a5210991ee6b23..d1795dff51676c 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -57,3 +57,18 @@ 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, +}; 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 00000000000000..86cde82b440f13 --- /dev/null +++ b/app/assets/javascripts/observability/utils/message_validator.js @@ -0,0 +1,181 @@ +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(s__('Observability|allowedOrigin must be a non-empty string')); + } + if (!allowedMessageType || typeof allowedMessageType !== 'string') { + throw new Error(s__('Observability|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 Map(); + this.recentTimestamps = new Map(); + 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; + + // Reject messages that are too old + if (messageAge > this.maxMessageAge) return false; + + // Reject messages that are too far in the future (clock skew protection) + if (messageAge < -this.maxClockSkew) return false; + + // Reject exact duplicate timestamps + if (this.recentTimestamps.has(timestamp)) return false; + + const isNewer = timestamp > this.lastMessageTimestamp; + const isWithinTolerance = + Math.abs(timestamp - this.lastMessageTimestamp) <= this.timestampTolerance; + + // Accept if it's the first message, a newer message, or an older message within the tolerance window + const isValidSequence = this.lastMessageTimestamp === 0 || isNewer || isWithinTolerance; + + if (isValidSequence) { + // Update last timestamp only for newer messages or first message + if (isNewer || this.lastMessageTimestamp === 0) { + this.lastMessageTimestamp = timestamp; + } + this.recentTimestamps.set(timestamp, now); + 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); + } else { + break; + } + } + this.lastCleanup = now; + } + } + + trackNonce(nonce) { + this.processedNonces.set(nonce, Date.now()); + + while (this.processedNonces.size > this.maxNonceHistory) { + const oldestNonce = this.processedNonces.keys().next().value; + this.processedNonces.delete(oldestNonce); + } + } + + 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 a639b870ffaec7..6d4108adfdc586 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43376,6 +43376,9 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" +msgid "Observability|Message validation failed" +msgstr "" + msgid "Observability|O11y Service Settings" msgstr "" @@ -43430,6 +43433,12 @@ msgstr "" msgid "Observability|View our %{documentation} for further instructions on how to use these features." msgstr "" +msgid "Observability|allowedMessageType must be a non-empty string" +msgstr "" + +msgid "Observability|allowedOrigin must be a non-empty string" +msgstr "" + msgid "Oct" msgstr "" 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 00000000000000..4bdde6c37b72f5 --- /dev/null +++ b/spec/frontend/observability/utils/message_validator_spec.js @@ -0,0 +1,526 @@ +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('trackNonce functionality', () => { + beforeEach(() => { + validator = new MessageValidator(config.allowedOrigin, config.allowedMessageType, { + maxMessageAge: config.maxMessageAge, + maxNonceHistory: 5, + }); + }); + + it('tracks nonces and maintains history within limit', () => { + const nonces = ['nonce-1', 'nonce-2', 'nonce-3', 'nonce-4', 'nonce-5']; + + nonces.forEach((nonce) => { + validator.trackNonce(nonce); + }); + + expect(validator.processedNonces.size).toBe(5); + nonces.forEach((nonce) => { + expect(validator.processedNonces.has(nonce)).toBe(true); + }); + }); + + it('removes oldest nonces when exceeding maxNonceHistory', () => { + const nonces = ['nonce-1', 'nonce-2', 'nonce-3', 'nonce-4', 'nonce-5', 'nonce-6', 'nonce-7']; + + nonces.forEach((nonce) => { + validator.trackNonce(nonce); + }); + + expect(validator.processedNonces.size).toBe(5); + + expect(validator.processedNonces.has('nonce-1')).toBe(false); + expect(validator.processedNonces.has('nonce-2')).toBe(false); + + expect(validator.processedNonces.has('nonce-3')).toBe(true); + expect(validator.processedNonces.has('nonce-4')).toBe(true); + expect(validator.processedNonces.has('nonce-5')).toBe(true); + expect(validator.processedNonces.has('nonce-6')).toBe(true); + expect(validator.processedNonces.has('nonce-7')).toBe(true); + }); + + it('maintains exact maxNonceHistory size when adding many nonces', () => { + const manyNonces = Array.from({ length: 20 }, (_, i) => `nonce-${i + 1}`); + + manyNonces.forEach((nonce) => { + validator.trackNonce(nonce); + }); + + expect(validator.processedNonces.size).toBe(5); // maxNonceHistory + + const expectedNonces = ['nonce-16', 'nonce-17', 'nonce-18', 'nonce-19', 'nonce-20']; + expectedNonces.forEach((nonce) => { + expect(validator.processedNonces.has(nonce)).toBe(true); + }); + }); + + it('stores timestamps with nonces', () => { + const testNonce = 'test-nonce'; + const beforeTime = Date.now(); + + validator.trackNonce(testNonce); + + const afterTime = Date.now(); + const storedTime = validator.processedNonces.get(testNonce); + + expect(storedTime).toBeGreaterThanOrEqual(beforeTime); + expect(storedTime).toBeLessThanOrEqual(afterTime); + }); + + it('handles duplicate nonce tracking gracefully', () => { + const nonce = 'duplicate-nonce'; + + validator.trackNonce(nonce); + const firstTime = validator.processedNonces.get(nonce); + + jest.spyOn(Date, 'now').mockReturnValue(mockNow + 100); + + validator.trackNonce(nonce); + const secondTime = validator.processedNonces.get(nonce); + + expect(secondTime).toBeGreaterThan(firstTime); + expect(validator.processedNonces.size).toBe(1); + }); + + it('works with default maxNonceHistory when not specified', () => { + const defaultValidator = new MessageValidator( + config.allowedOrigin, + config.allowedMessageType, + { maxMessageAge: config.maxMessageAge }, + ); + + const manyNonces = Array.from({ length: 1005 }, (_, i) => `nonce-${i + 1}`); + + manyNonces.forEach((nonce) => { + defaultValidator.trackNonce(nonce); + }); + + expect(defaultValidator.processedNonces.size).toBe(1000); // Default maxNonceHistory + }); + }); + + 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', + }); + }); + }); +}); -- GitLab