diff --git a/packages/base/bundle.esm.js b/packages/base/bundle.esm.js index 678e6a1d52c6..a5692aac72ad 100644 --- a/packages/base/bundle.esm.js +++ b/packages/base/bundle.esm.js @@ -31,6 +31,7 @@ import applyDirection from "./dist/locale/applyDirection.js"; import { getCurrentRuntimeIndex } from "./dist/Runtimes.js"; import { startMultipleDrag } from "./dist/DragAndDrop.js"; import LegacyDateFormats from "./dist/features/LegacyDateFormats.js"; +import { boot } from "./dist/Boot.js"; window["sap-ui-webcomponents-bundle"] = { configuration : { @@ -54,6 +55,7 @@ window["sap-ui-webcomponents-bundle"] = { parseProperties, registerI18nLoader, getI18nBundle, + boot, renderFinished, applyDirection, EventProvider, diff --git a/packages/base/cypress/specs/ThemeRoot.cy.tsx b/packages/base/cypress/specs/ThemeRoot.cy.tsx new file mode 100644 index 000000000000..2cf252aecb34 --- /dev/null +++ b/packages/base/cypress/specs/ThemeRoot.cy.tsx @@ -0,0 +1,180 @@ +import { internals } from "../../src/Location.js"; +import TestGeneric from "../../test/test-elements/Generic.js"; +import { resetConfiguration } from "../../src/InitialConfiguration.js"; +import { getTheme } from "../../src/config/Theme.js"; +import { getThemeRoot } from "../../src/config/ThemeRoot.js"; +import applyTheme from "../../src/theming/applyTheme.js"; + +const THEME = "sap_horizon"; + +const addMetaTag = (content: string) => { + cy.window().then($win => { + const metaTag = $win.document.createElement("meta"); + metaTag.name = "sap-allowed-theme-origins"; + metaTag.content = content; + $win.document.head.append(metaTag); + }); +}; + +const removeMetaTag = () => { + cy.window().then($win => { + $win.document.head.querySelector("[name='sap-allowed-theme-origins']")?.remove(); + }); +}; + +const removeThemeLink = () => { + cy.window().then($win => { + $win.document.head.querySelector("link[sap-ui-webcomponents-theme]")?.remove(); + }); +}; + +const applyAndCheckLink = (theme: string) => { + cy.wrap({ applyTheme, getTheme }) + .invoke("getTheme") + .then(() => { + return cy.wrap({ applyTheme }).invoke("applyTheme", theme); + }); +}; + +// ─── Without meta tag ──────────────────────────────────────────────────────── + +describe("ThemeRoot via URL param — without meta tag", () => { + afterEach(() => { + removeThemeLink(); + }); + + [ + { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/", blocked: true }, + { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/", blocked: true }, + { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/", blocked: true }, + { label: "absolute URL (same host, no meta tag)", themeRoot: "http://example.com/themes/", blocked: true }, + { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/", blocked: true }, + { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/", blocked: true }, + { label: "protocol-relative (same host, no meta tag)", themeRoot: "//example.com/themes/", blocked: true }, + { label: "root-relative", themeRoot: "/themes/", blocked: false }, + { label: "relative (current dir)", themeRoot: "./themes/", blocked: false }, + { label: "relative (parent dir)", themeRoot: "../themes/", blocked: false }, + ].forEach(({ label, themeRoot, blocked }) => { + describe(`${label}: ${themeRoot}`, () => { + before(() => { + cy.stub(internals, "search").callsFake(() => { + return `sap-ui-theme=${THEME}@${themeRoot}`; + }); + + cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true); + cy.mount(); + }); + + it(blocked ? "should not create a link element" : "should create a link element (same-origin, no meta tag needed)", () => { + applyAndCheckLink(THEME); + if (blocked) { + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist"); + } else { + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`) + .should("exist") + .and("have.attr", "href") + .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`); + } + }); + }); + }); +}); + +// ─── With meta tag ──────────────────────────────────────────────────────────── + +describe("ThemeRoot via URL param — with meta tag (allowed: http://example.com)", () => { + afterEach(() => { + removeThemeLink(); + }); + + after(() => { + removeMetaTag(); + }); + + before(() => { + addMetaTag("http://example.com"); + }); + + const blocked = [ + { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/" }, + { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/" }, + { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/" }, + { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/" }, + { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/" }, + ]; + + const allowed = [ + { + label: "absolute URL (matches allowed origin)", + themeRoot: "http://example.com/themes/", + expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css", + }, + { + label: "protocol-relative (resolves to allowed origin)", + themeRoot: "//example.com/themes/", + expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css", + }, + ]; + + const sameOriginAllowed = [ + { label: "root-relative", themeRoot: "/themes/" }, + { label: "relative (current dir)", themeRoot: "./themes/" }, + { label: "relative (parent dir)", themeRoot: "../themes/" }, + ]; + + blocked.forEach(({ label, themeRoot }) => { + describe(`blocked — ${label}: ${themeRoot}`, () => { + before(() => { + cy.stub(internals, "search").callsFake(() => { + return `sap-ui-theme=${THEME}@${themeRoot}`; + }); + cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true); + cy.mount(); + }); + + it("should not create a link element", () => { + applyAndCheckLink(THEME); + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist"); + }); + }); + }); + + allowed.forEach(({ label, themeRoot, expectedHref }) => { + describe(`allowed — ${label}: ${themeRoot}`, () => { + before(() => { + cy.stub(internals, "search").callsFake(() => { + return `sap-ui-theme=${THEME}@${themeRoot}`; + }); + cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true); + cy.mount(); + }); + + it("should create a link element with correct href", () => { + applyAndCheckLink(THEME); + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`) + .should("exist") + .and("have.attr", "href", expectedHref); + }); + }); + }); + + sameOriginAllowed.forEach(({ label, themeRoot }) => { + describe(`allowed (same-origin) — ${label}: ${themeRoot}`, () => { + before(() => { + cy.stub(internals, "search").callsFake(() => { + return `sap-ui-theme=${THEME}@${themeRoot}`; + }); + cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true); + cy.mount(); + }); + + it("should create a link element", () => { + applyAndCheckLink(THEME); + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`) + .should("exist") + .and("have.attr", "href") + .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`); + }); + }); + }); +}); diff --git a/packages/base/cypress/specs/ThemeRootAPI.cy.tsx b/packages/base/cypress/specs/ThemeRootAPI.cy.tsx new file mode 100644 index 000000000000..2c9fae10aeab --- /dev/null +++ b/packages/base/cypress/specs/ThemeRootAPI.cy.tsx @@ -0,0 +1,148 @@ +import TestGeneric from "../../test/test-elements/Generic.js"; +import { setThemeRoot } from "../../src/config/ThemeRoot.js"; +import { getTheme } from "../../src/config/Theme.js"; +import applyTheme from "../../src/theming/applyTheme.js"; + +const THEME = "sap_horizon"; + +const addMetaTag = (content: string) => { + cy.window().then($win => { + const metaTag = $win.document.createElement("meta"); + metaTag.name = "sap-allowed-theme-origins"; + metaTag.content = content; + $win.document.head.append(metaTag); + }); +}; + +const removeMetaTag = () => { + cy.window().then($win => { + $win.document.head.querySelector("[name='sap-allowed-theme-origins']")?.remove(); + }); +}; + +const removeThemeLink = () => { + cy.window().then($win => { + $win.document.head.querySelector("link[sap-ui-webcomponents-theme]")?.remove(); + }); +}; + +const applyAndCheckLink = (theme: string) => { + cy.wrap({ applyTheme, getTheme }) + .invoke("getTheme") + .then(() => { + return cy.wrap({ applyTheme }).invoke("applyTheme", theme); + }); +}; + +// ─── Without meta tag ──────────────────────────────────────────────────────── + +describe("ThemeRoot via setThemeRoot API — without meta tag", () => { + before(() => { + cy.mount(); + }); + + afterEach(() => { + removeThemeLink(); + cy.wrap({ setThemeRoot }).invoke("setThemeRoot", undefined); + }); + + [ + { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/", blocked: true }, + { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/", blocked: true }, + { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/", blocked: true }, + { label: "absolute URL (same host, no meta tag)", themeRoot: "http://example.com/themes/", blocked: true }, + { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/", blocked: true }, + { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/", blocked: true }, + { label: "protocol-relative (same host, no meta tag)", themeRoot: "//example.com/themes/", blocked: true }, + { label: "root-relative", themeRoot: "/themes/", blocked: false }, + { label: "relative (current dir)", themeRoot: "./themes/", blocked: false }, + { label: "relative (parent dir)", themeRoot: "../themes/", blocked: false }, + ].forEach(({ label, themeRoot, blocked }) => { + it(`${blocked ? "should not create" : "should create"} a link element — ${label}: ${themeRoot}`, () => { + cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot); + applyAndCheckLink(THEME); + if (blocked) { + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist"); + } else { + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`) + .should("exist") + .and("have.attr", "href") + .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`); + } + }); + }); +}); + +// ─── With meta tag ──────────────────────────────────────────────────────────── + +describe("ThemeRoot via setThemeRoot API — with meta tag (allowed: http://example.com)", () => { + before(() => { + addMetaTag("http://example.com"); + cy.mount(); + }); + + afterEach(() => { + removeThemeLink(); + cy.wrap({ setThemeRoot }).invoke("setThemeRoot", undefined); + }); + + after(() => { + removeMetaTag(); + }); + + const blocked = [ + { label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/" }, + { label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/" }, + { label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/" }, + { label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/" }, + { label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/" }, + ]; + + const allowed = [ + { + label: "absolute URL (matches allowed origin)", + themeRoot: "http://example.com/themes/", + expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css", + }, + { + label: "protocol-relative (resolves to allowed origin)", + themeRoot: "//example.com/themes/", + expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css", + }, + ]; + + const sameOriginAllowed = [ + { label: "root-relative", themeRoot: "/themes/" }, + { label: "relative (current dir)", themeRoot: "./themes/" }, + { label: "relative (parent dir)", themeRoot: "../themes/" }, + ]; + + blocked.forEach(({ label, themeRoot }) => { + it(`should not create a link element — blocked: ${label}: ${themeRoot}`, () => { + cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot); + applyAndCheckLink(THEME); + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist"); + }); + }); + + allowed.forEach(({ label, themeRoot, expectedHref }) => { + it(`should create a link element with correct href — allowed: ${label}: ${themeRoot}`, () => { + cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot); + applyAndCheckLink(THEME); + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`) + .should("exist") + .and("have.attr", "href", expectedHref); + }); + }); + + sameOriginAllowed.forEach(({ label, themeRoot }) => { + it(`should create a link element — same-origin: ${label}: ${themeRoot}`, () => { + cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot); + applyAndCheckLink(THEME); + cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`) + .should("exist") + .and("have.attr", "href") + .and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`); + }); + }); +}); diff --git a/packages/base/src/validateThemeRoot.ts b/packages/base/src/validateThemeRoot.ts index f9d02d673be4..f338996fa4ea 100644 --- a/packages/base/src/validateThemeRoot.ts +++ b/packages/base/src/validateThemeRoot.ts @@ -31,7 +31,7 @@ const validateThemeRoot = (themeRoot: string) => { let isSameOrigin = false; try { - if (themeRoot.startsWith(".") || themeRoot.startsWith("/")) { + if (themeRoot.startsWith(".") || (themeRoot.startsWith("/") && !themeRoot.startsWith("//"))) { // Handle relative url // new URL("/newExmPath", "http://example.com/exmPath") => http://example.com/newExmPath // new URL("./newExmPath", "http://example.com/exmPath") => http://example.com/exmPath/newExmPath @@ -39,7 +39,8 @@ const validateThemeRoot = (themeRoot: string) => { resultUrl = new URL(themeRoot, getLocationHref()).toString(); isSameOrigin = true; } else { - const themeRootURL = new URL(themeRoot); + // Protocol-relative URLs (//host/path) need a base to resolve the protocol + const themeRootURL = themeRoot.startsWith("//") ? new URL(themeRoot, getLocationHref()) : new URL(themeRoot); const origin = themeRootURL.origin; const currentOrigin = new URL(getLocationHref()).origin; diff --git a/packages/base/test/pages/ThemeRoot.html b/packages/base/test/pages/ThemeRoot.html new file mode 100644 index 000000000000..fa7bd73c144d --- /dev/null +++ b/packages/base/test/pages/ThemeRoot.html @@ -0,0 +1,115 @@ + + + + + + Base package default test page + + + + + + + +

Without meta tag

+ Reset URL + + + + + diff --git a/packages/base/test/pages/ThemeRoot2.html b/packages/base/test/pages/ThemeRoot2.html new file mode 100644 index 000000000000..4d37344c5116 --- /dev/null +++ b/packages/base/test/pages/ThemeRoot2.html @@ -0,0 +1,117 @@ + + + + + + + Base package default test page + + + + + + + +

With meta tag

+ Reset URL +

Following page is using the meta tag sap-allowed-theme-origins to specify allowed theme origins with http://example.com as the allowed origin.

+ + + + +