+ )
+ }
+})
+
+const mockGetCspNonce = jest.fn()
+const mockCreateEmotionCache = jest.fn(() => ({ key: 'css' }))
+jest.mock('../utils/cspNonce.ts', () => ({
+ getCspNonce: () => mockGetCspNonce()
+}))
+jest.mock('../utils/emotionCache.ts', () => ({
+ createEmotionCache: (...args: unknown[]) => mockCreateEmotionCache(...args)
+}))
+
// Mock all CSS imports
jest.mock('../index.scss', () => {})
jest.mock('react-toastify/dist/ReactToastify.css', () => {})
@@ -92,6 +117,8 @@ describe('Main.tsx', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.resetModules()
+ mockGetCspNonce.mockReturnValue(undefined)
+ mockCreateEmotionCache.mockReturnValue({ key: 'css' })
mockStore = createMockStore()
// Mock document.getElementById to return root element
@@ -115,6 +142,37 @@ describe('Main.tsx', () => {
jest.resetModules()
})
+ describe('CSP Nonce Wiring', () => {
+ it('should read csp nonce and create emotion cache without nonce when unavailable', async () => {
+ mockGetCspNonce.mockReturnValue(undefined)
+
+ await import('../Main.tsx')
+
+ expect(mockGetCspNonce).toHaveBeenCalled()
+ expect(mockCreateEmotionCache).toHaveBeenCalledWith(undefined)
+ })
+
+ it('should pass csp nonce to createEmotionCache when available', async () => {
+ mockGetCspNonce.mockReturnValue('server-generated-nonce')
+
+ await import('../Main.tsx')
+
+ expect(mockCreateEmotionCache).toHaveBeenCalledWith('server-generated-nonce')
+ })
+
+ it('should invoke createRoot.render after configuring emotion cache', async () => {
+ mockGetCspNonce.mockReturnValue('server-generated-nonce')
+
+ await import('../Main.tsx')
+
+ const ReactDOM = require('react-dom/client')
+ const mockRoot = ReactDOM.createRoot.mock.results[0].value
+
+ expect(mockCreateEmotionCache).toHaveBeenCalled()
+ expect(mockRoot.render).toHaveBeenCalled()
+ })
+ })
+
describe('Module Execution', () => {
it('should execute Main.tsx and call createRoot with root element', async () => {
// Import Main.tsx to execute it
diff --git a/dashboard/src/utils/__tests__/cspNonce.test.ts b/dashboard/src/utils/__tests__/cspNonce.test.ts
new file mode 100644
index 00000000000..59a3b445a5e
--- /dev/null
+++ b/dashboard/src/utils/__tests__/cspNonce.test.ts
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { CSP_NONCE_META_NAME, getCspNonce } from "../cspNonce";
+
+describe("getCspNonce", () => {
+ const originalWindowNonce = window.__CSP_NONCE__;
+
+ beforeEach(() => {
+ document.head.innerHTML = "";
+ document.body.innerHTML = "";
+ delete window.__CSP_NONCE__;
+ });
+
+ afterEach(() => {
+ if (originalWindowNonce === undefined) {
+ delete window.__CSP_NONCE__;
+ } else {
+ window.__CSP_NONCE__ = originalWindowNonce;
+ }
+ });
+
+ it("returns nonce from meta tag when present", () => {
+ const meta = document.createElement("meta");
+ meta.setAttribute("name", CSP_NONCE_META_NAME);
+ meta.setAttribute("content", "meta-nonce-value");
+ document.head.appendChild(meta);
+
+ expect(getCspNonce()).toBe("meta-nonce-value");
+ });
+
+ it("trims whitespace from meta tag nonce", () => {
+ const meta = document.createElement("meta");
+ meta.setAttribute("name", CSP_NONCE_META_NAME);
+ meta.setAttribute("content", " trimmed-meta-nonce ");
+ document.head.appendChild(meta);
+
+ expect(getCspNonce()).toBe("trimmed-meta-nonce");
+ });
+
+ it("prefers meta tag nonce over script nonce", () => {
+ const meta = document.createElement("meta");
+ meta.setAttribute("name", CSP_NONCE_META_NAME);
+ meta.setAttribute("content", "meta-nonce-value");
+ document.head.appendChild(meta);
+
+ const script = document.createElement("script");
+ script.setAttribute("nonce", "script-nonce-value");
+ document.body.appendChild(script);
+
+ expect(getCspNonce()).toBe("meta-nonce-value");
+ });
+
+ it("returns nonce from script tag when meta tag is missing", () => {
+ const script = document.createElement("script");
+ script.setAttribute("nonce", "script-nonce-value");
+ document.body.appendChild(script);
+
+ expect(getCspNonce()).toBe("script-nonce-value");
+ });
+
+ it("returns nonce from window global when dom sources are missing", () => {
+ window.__CSP_NONCE__ = "window-nonce-value";
+
+ expect(getCspNonce()).toBe("window-nonce-value");
+ });
+
+ it("returns undefined when no nonce source is available", () => {
+ expect(getCspNonce()).toBeUndefined();
+ });
+
+ it("returns undefined when meta content is blank", () => {
+ const meta = document.createElement("meta");
+ meta.setAttribute("name", CSP_NONCE_META_NAME);
+ meta.setAttribute("content", " ");
+ document.head.appendChild(meta);
+
+ expect(getCspNonce()).toBeUndefined();
+ });
+
+ it("returns undefined when script nonce is blank and no other source exists", () => {
+ const script = document.createElement("script");
+ script.setAttribute("nonce", " ");
+ document.body.appendChild(script);
+
+ expect(getCspNonce()).toBeUndefined();
+ });
+
+ it("falls back to script nonce when meta content is blank", () => {
+ const meta = document.createElement("meta");
+ meta.setAttribute("name", CSP_NONCE_META_NAME);
+ meta.setAttribute("content", " ");
+ document.head.appendChild(meta);
+
+ const script = document.createElement("script");
+ script.setAttribute("nonce", "script-nonce-value");
+ document.body.appendChild(script);
+
+ expect(getCspNonce()).toBe("script-nonce-value");
+ });
+});
diff --git a/dashboard/src/utils/__tests__/emotionCache.test.ts b/dashboard/src/utils/__tests__/emotionCache.test.ts
new file mode 100644
index 00000000000..06e1df7660f
--- /dev/null
+++ b/dashboard/src/utils/__tests__/emotionCache.test.ts
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+jest.mock("@emotion/cache", () => ({
+ __esModule: true,
+ default: jest.fn(() => ({ key: "css" }))
+}));
+
+import createCache from "@emotion/cache";
+import { createEmotionCache } from "../emotionCache";
+
+describe("createEmotionCache", () => {
+ const mockCreateCache = createCache as jest.MockedFunction;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCreateCache.mockReturnValue({ key: "css" });
+ });
+
+ it("creates emotion cache without nonce when nonce is undefined", () => {
+ createEmotionCache(undefined);
+
+ expect(mockCreateCache).toHaveBeenCalledWith({ key: "css" });
+ });
+
+ it("creates emotion cache without nonce when nonce is blank", () => {
+ createEmotionCache(" ");
+
+ expect(mockCreateCache).toHaveBeenCalledWith({ key: "css" });
+ });
+
+ it("creates emotion cache with nonce when nonce is provided", () => {
+ createEmotionCache("server-generated-nonce");
+
+ expect(mockCreateCache).toHaveBeenCalledWith({
+ key: "css",
+ nonce: "server-generated-nonce"
+ });
+ });
+
+ it("returns the cache instance from @emotion/cache", () => {
+ const cacheInstance = { key: "css", nonce: "server-generated-nonce" };
+ mockCreateCache.mockReturnValue(cacheInstance);
+
+ expect(createEmotionCache("server-generated-nonce")).toBe(cacheInstance);
+ });
+});
diff --git a/dashboard/src/utils/cspNonce.ts b/dashboard/src/utils/cspNonce.ts
new file mode 100644
index 00000000000..5a6e390c5ec
--- /dev/null
+++ b/dashboard/src/utils/cspNonce.ts
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const CSP_NONCE_META_NAME = "csp-nonce";
+
+declare global {
+ interface Window {
+ __CSP_NONCE__?: string;
+ }
+}
+
+const getTrimmedAttribute = (value: string | null | undefined): string | undefined => {
+ const trimmedValue = value?.trim();
+
+ return trimmedValue ? trimmedValue : undefined;
+};
+
+export const getCspNonce = (): string | undefined => {
+ const metaNonce = getTrimmedAttribute(
+ document.querySelector(`meta[name="${CSP_NONCE_META_NAME}"]`)?.getAttribute("content")
+ );
+
+ if (metaNonce) {
+ return metaNonce;
+ }
+
+ const scriptNonce = getTrimmedAttribute(
+ document.querySelector("script[nonce]")?.getAttribute("nonce")
+ );
+
+ if (scriptNonce) {
+ return scriptNonce;
+ }
+
+ return getTrimmedAttribute(window.__CSP_NONCE__);
+};
diff --git a/dashboard/src/utils/emotionCache.ts b/dashboard/src/utils/emotionCache.ts
new file mode 100644
index 00000000000..27d94b953bf
--- /dev/null
+++ b/dashboard/src/utils/emotionCache.ts
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import createCache, { type EmotionCache } from "@emotion/cache";
+
+export const createEmotionCache = (cspNonce?: string): EmotionCache => {
+ const trimmedNonce = cspNonce?.trim();
+
+ return createCache({
+ key: "css",
+ ...(trimmedNonce ? { nonce: trimmedNonce } : {})
+ });
+};