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