;
+
export const inputSchema = {
basePath: z.string()
.describe("Absolute base path for the creation."),
@@ -20,4 +29,7 @@ export const inputSchema = {
cardType: z.enum(supportedCardTypes)
.describe("Type of the Integration Card to create.")
.default("List"),
+ destinations: z.array(destinationSchema)
+ .describe("List of destinations to be included in the card configuration.")
+ .optional(),
};
diff --git a/src/tools/create_ui5_app/create_ui5_app.ts b/src/tools/create_ui5_app/create_ui5_app.ts
index 10fd8c54..5ce1d85a 100644
--- a/src/tools/create_ui5_app/create_ui5_app.ts
+++ b/src/tools/create_ui5_app/create_ui5_app.ts
@@ -11,7 +11,8 @@ import {CreateUi5AppParams, CreateUi5AppResult} from "./schema.js";
import {getLogger} from "@ui5/logger";
import {isUi5Framework, Ui5Framework} from "../../utils/ui5Framework.js";
import {dirExists, InvalidInputError, PKG_VERSION} from "../../utils.js";
-import isValidUrl from "./isValidUrl.js";
+import getAllowedDomains from "../../utils/getAllowedDomains.js";
+import isValidUrl from "../../utils/isValidUrl.js";
const log = getLogger("tools:create_ui5_app:create_ui5_app");
@@ -299,36 +300,3 @@ The minimum version for ${framework} is ${minFwkVersionToUse}.`
},
};
}
-
-function getAllowedDomains() {
- if ("UI5_MCP_SERVER_ALLOWED_DOMAINS" in process.env || "UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS" in process.env) {
- const inputDomainList = process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS ??
- process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS;
- if (!inputDomainList?.trim()) {
- // Empty list allows all domains
- log.verbose("Empty value for UI5_MCP_SERVER_ALLOWED_DOMAINS, allowing all domains");
- return [];
- }
- // Use the environment variable if set
- const domainList = inputDomainList.split(",").map((d) => d.trim());
- // Validate domains to catch user errors
- for (const domain of domainList) {
- try {
- // Note that the dot prefix (which we use for wildcards) is valid in a domain
- new URL(`https://${domain}`);
- } catch (err) {
- throw new InvalidInputError(
- `Invalid domain '${domain}' in UI5_MCP_SERVER_ALLOWED_DOMAINS: ` +
- (err instanceof Error ? err.message : String(err))
- );
- }
- }
- log.verbose(`${domainList.length} allowed OData V4 domains configured: ${domainList.join(", ")}`);
- return domainList;
- }
- return [
- // Default allowed domains for OData V4 services
- "localhost",
- "services.odata.org",
- ];
-}
diff --git a/src/utils/getAllowedDomains.ts b/src/utils/getAllowedDomains.ts
new file mode 100644
index 00000000..c883f393
--- /dev/null
+++ b/src/utils/getAllowedDomains.ts
@@ -0,0 +1,37 @@
+import {getLogger} from "@ui5/logger";
+import {InvalidInputError} from "../utils.js";
+
+const log = getLogger("utils:getAllowedDomains");
+
+export default function getAllowedDomains() {
+ if ("UI5_MCP_SERVER_ALLOWED_DOMAINS" in process.env || "UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS" in process.env) {
+ const inputDomainList = process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS ??
+ process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS;
+ if (!inputDomainList?.trim()) {
+ // Empty list allows all domains
+ log.verbose("Empty value for UI5_MCP_SERVER_ALLOWED_DOMAINS, allowing all domains");
+ return [];
+ }
+ // Use the environment variable if set
+ const domainList = inputDomainList.split(",").map((d) => d.trim());
+ // Validate domains to catch user errors
+ for (const domain of domainList) {
+ try {
+ // Note that the dot prefix (which we use for wildcards) is valid in a domain
+ new URL(`https://${domain}`);
+ } catch (err) {
+ throw new InvalidInputError(
+ `Invalid domain '${domain}' in UI5_MCP_SERVER_ALLOWED_DOMAINS: ` +
+ (err instanceof Error ? err.message : String(err))
+ );
+ }
+ }
+ log.verbose(`${domainList.length} allowed domains configured: ${domainList.join(", ")}`);
+ return domainList;
+ }
+ return [
+ // Default allowed domains for services
+ "localhost",
+ "services.odata.org",
+ ];
+}
diff --git a/src/tools/create_ui5_app/isValidUrl.ts b/src/utils/isValidUrl.ts
similarity index 98%
rename from src/tools/create_ui5_app/isValidUrl.ts
rename to src/utils/isValidUrl.ts
index 931cde42..cfb3bed4 100644
--- a/src/tools/create_ui5_app/isValidUrl.ts
+++ b/src/utils/isValidUrl.ts
@@ -1,4 +1,4 @@
-import {InvalidInputError} from "../../utils.js";
+import {InvalidInputError} from "../utils.js";
/**
* Validates a URL against a set of specified rules, including protocol and domain allow lists.
diff --git a/test/expected/create_integration_card/common/test/App.js b/test/expected/create_integration_card/common/test/App.js
index 7dee18be..847e2e5a 100644
--- a/test/expected/create_integration_card/common/test/App.js
+++ b/test/expected/create_integration_card/common/test/App.js
@@ -7,13 +7,15 @@ sap.ui.define(["sap/ui/integration/Host"], async (Host) => {
const card = document.getElementById("card");
const applyChangesBtn = document.getElementById("applyChangesBtn");
const resetBtn = document.getElementById("resetBtn");
+ const destinations = {
+ };
const host = new Host({
- resolveDestination: function(sDestinationName) {
- if (sDestinationName === "Northwind") {
- return "https://services.odata.org/V4/Northwind/Northwind.svc/";
+ resolveDestination: function(destinationName) {
+ if (destinations[destinationName]) {
+ return destinations[destinationName];
}
- throw new Error("Destination " + sDestinationName + " not found!");
+ return Promise.reject("Destination " + destinationName + " not found!");
},
actions: [
{
@@ -27,6 +29,15 @@ sap.ui.define(["sap/ui/integration/Host"], async (Host) => {
]
});
+ // Called by the Configuration Editor to show a list of available destinations
+ host.getDestinations = function() {
+ return Promise.resolve(Object.entries(destinations).map(([name, url]) => {
+ return {
+ name,
+ };
+ }));
+ };
+
card.host = host.getId();
card.manifest = "../card/manifest.json";
diff --git a/test/expected/create_integration_card/common/test/index.css b/test/expected/create_integration_card/common/test/index.css
index b0477e4f..4239c912 100644
--- a/test/expected/create_integration_card/common/test/index.css
+++ b/test/expected/create_integration_card/common/test/index.css
@@ -17,6 +17,10 @@ body {
flex-direction: column;
}
+#editorSection {
+ --sapUiIntegrationEditorFormHeight: auto;
+}
+
.button-container {
display: flex;
justify-content: flex-end;
diff --git a/test/expected/create_integration_card/common/test/index.html b/test/expected/create_integration_card/common/test/index.html
index d31f0270..68305f66 100644
--- a/test/expected/create_integration_card/common/test/index.html
+++ b/test/expected/create_integration_card/common/test/index.html
@@ -23,9 +23,7 @@
title}",
+ "subtitle": "{i18n>subtitle}"
+ },
+ "content": {
+ "data": {
+ "path": "/items"
+ },
+ "item": {
+ "title": "{title}",
+ "description": "{description}"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/expected/create_integration_card/destinations/package.json b/test/expected/create_integration_card/destinations/package.json
new file mode 100644
index 00000000..91b07f69
--- /dev/null
+++ b/test/expected/create_integration_card/destinations/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "template-card",
+ "version": "1.0.0",
+ "description": "A basic template card project",
+ "scripts": {
+ "start": "http-server ./ -c-1 -o test/index.html",
+ "test": "echo \"No tests specified\" && exit 0"
+ },
+ "devDependencies": {
+ "http-server": "^14.1.1"
+ }
+}
\ No newline at end of file
diff --git a/test/expected/create_integration_card/destinations/test/App.js b/test/expected/create_integration_card/destinations/test/App.js
new file mode 100644
index 00000000..541c37e9
--- /dev/null
+++ b/test/expected/create_integration_card/destinations/test/App.js
@@ -0,0 +1,63 @@
+sap.ui.define(["sap/ui/integration/Host"], async (Host) => {
+ "use strict";
+ await customElements.whenDefined("ui-integration-card");
+ await customElements.whenDefined("ui-integration-card-editor");
+
+ const editor = document.getElementById("editor");
+ const card = document.getElementById("card");
+ const applyChangesBtn = document.getElementById("applyChangesBtn");
+ const resetBtn = document.getElementById("resetBtn");
+ const destinations = {
+ "northwind": "https://services.odata.org/V4/Northwind/Northwind.svc/",
+ "myapi": "http://localhost:8080/v1/"
+ };
+ const host = new Host({
+ resolveDestination: function(destinationName) {
+ if (destinations[destinationName]) {
+ return destinations[destinationName];
+ }
+
+ return Promise.reject("Destination " + destinationName + " not found!");
+ },
+ actions: [
+ {
+ type: "Custom",
+ text: "Configure",
+ icon: "sap-icon://action-settings",
+ action: () => {
+ document.getElementById("editorSection").classList.remove("hidden");
+ }
+ }
+ ]
+ });
+
+ // Called by the Configuration Editor to show a list of available destinations
+ host.getDestinations = function() {
+ return Promise.resolve(Object.entries(destinations).map(([name, url]) => {
+ return {
+ name,
+ };
+ }));
+ };
+
+ card.host = host.getId();
+ card.manifest = "../card/manifest.json";
+
+ editor.host = host.getId();
+ editor.card = "card";
+
+ let initialSettings;
+
+ // UI5 CustomElementBase doesn't expose the "ready" event, so use timeout
+ setTimeout(() => {
+ initialSettings = editor.getCurrentSettings();
+ }, 3000);
+
+ applyChangesBtn.addEventListener("click", () => {
+ card.manifestChanges = [editor.getCurrentSettings()];
+ });
+
+ resetBtn.addEventListener("click", () => {
+ card.manifestChanges = [initialSettings];
+ });
+});
\ No newline at end of file
diff --git a/test/expected/create_integration_card/destinations/test/index.css b/test/expected/create_integration_card/destinations/test/index.css
new file mode 100644
index 00000000..4239c912
--- /dev/null
+++ b/test/expected/create_integration_card/destinations/test/index.css
@@ -0,0 +1,61 @@
+body {
+ margin: 2rem;
+}
+
+.container {
+ display: flex;
+ gap: 2rem;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.card-section {
+ flex: 1;
+ min-width: 300px;
+ max-width: 600px;
+ display: block;
+ flex-direction: column;
+}
+
+#editorSection {
+ --sapUiIntegrationEditorFormHeight: auto;
+}
+
+.button-container {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
+
+.config-button {
+ padding: 0.25rem 0.5rem;
+ border: none;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s ease;
+}
+
+.config-button.primary {
+ background-color: #0070f3;
+ color: white;
+}
+
+.config-button.primary:hover {
+ background-color: #0051cc;
+}
+
+.config-button.secondary {
+ background-color: #f5f5f5;
+ color: #333;
+ border: 1px solid #ddd;
+}
+
+.config-button.secondary:hover {
+ background-color: #e5e5e5;
+}
+
+.hidden {
+ display: none;
+}
\ No newline at end of file
diff --git a/test/expected/create_integration_card/destinations/test/index.html b/test/expected/create_integration_card/destinations/test/index.html
new file mode 100644
index 00000000..68305f66
--- /dev/null
+++ b/test/expected/create_integration_card/destinations/test/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
Integration Card Preview
+
+
+
+
+
+
+
Preview
+
+
+
Integration Card
+
+
+
+
+
Configuration Editor
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/expected/create_integration_card/single_destination/card/dt/Configuration.js b/test/expected/create_integration_card/single_destination/card/dt/Configuration.js
new file mode 100644
index 00000000..bc386ab0
--- /dev/null
+++ b/test/expected/create_integration_card/single_destination/card/dt/Configuration.js
@@ -0,0 +1,43 @@
+sap.ui.define([
+ "sap/ui/integration/Designtime",
+],
+function (
+ Designtime
+) {
+ "use strict";
+
+ return function () {
+ return new Designtime({
+ form: {
+ items: {
+ groupheader1: {
+ label: "General Settings",
+ type: "group",
+ },
+ separator1: {
+ type: "separator",
+ },
+ title: {
+ manifestpath: "/sap.card/header/title",
+ type: "string",
+ translatable: true,
+ label: "Card Title",
+ cols: 1,
+ allowDynamicValues: true,
+ },
+ subtitle: {
+ manifestpath: "/sap.card/header/subtitle",
+ type: "string",
+ translatable: true,
+ label: "Card Subtitle",
+ cols: 1,
+ allowDynamicValues: true,
+ },
+ },
+ },
+ preview: {
+ modes: "None",
+ },
+ });
+ };
+});
\ No newline at end of file
diff --git a/test/expected/create_integration_card/single_destination/card/i18n/i18n.properties b/test/expected/create_integration_card/single_destination/card/i18n/i18n.properties
new file mode 100644
index 00000000..467b579c
--- /dev/null
+++ b/test/expected/create_integration_card/single_destination/card/i18n/i18n.properties
@@ -0,0 +1,2 @@
+title=Sample Integration Card
+subtitle=Subtitle
\ No newline at end of file
diff --git a/test/expected/create_integration_card/single_destination/card/manifest.json b/test/expected/create_integration_card/single_destination/card/manifest.json
new file mode 100644
index 00000000..adaf6789
--- /dev/null
+++ b/test/expected/create_integration_card/single_destination/card/manifest.json
@@ -0,0 +1,58 @@
+{
+ "_version": "1.78.0",
+ "sap.app": {
+ "id": "sample.integration.card",
+ "type": "card",
+ "title": "Sample Integration Card",
+ "applicationVersion": {
+ "version": "1.0.0"
+ }
+ },
+ "sap.ui": {
+ "technology": "UI5",
+ "deviceTypes": {
+ "desktop": true,
+ "tablet": true,
+ "phone": true
+ }
+ },
+ "sap.card": {
+ "type": "List",
+ "configuration": {
+ "destinations": {
+ "northwind": {
+ "name": "northwind",
+ "defaultUrl": "https://services.odata.org/V4/Northwind/Northwind.svc/"
+ }
+ },
+ "editor": "./dt/Configuration"
+ },
+ "data": {
+ "json": {
+ "items": [
+ {
+ "title": "Item 1",
+ "description": "Description for Item 1"
+ },
+ {
+ "title": "Item 2",
+ "description": "Description for Item 2"
+ }
+ ]
+ }
+ },
+ "header": {
+ "title": "{i18n>title}",
+ "subtitle": "{i18n>subtitle}"
+ },
+ "content": {
+ "data": {
+ "path": "/items"
+ },
+ "item": {
+ "title": "{title}",
+ "description": "{description}"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/expected/create_integration_card/single_destination/package.json b/test/expected/create_integration_card/single_destination/package.json
new file mode 100644
index 00000000..91b07f69
--- /dev/null
+++ b/test/expected/create_integration_card/single_destination/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "template-card",
+ "version": "1.0.0",
+ "description": "A basic template card project",
+ "scripts": {
+ "start": "http-server ./ -c-1 -o test/index.html",
+ "test": "echo \"No tests specified\" && exit 0"
+ },
+ "devDependencies": {
+ "http-server": "^14.1.1"
+ }
+}
\ No newline at end of file
diff --git a/test/expected/create_integration_card/single_destination/test/App.js b/test/expected/create_integration_card/single_destination/test/App.js
new file mode 100644
index 00000000..bffe1c60
--- /dev/null
+++ b/test/expected/create_integration_card/single_destination/test/App.js
@@ -0,0 +1,62 @@
+sap.ui.define(["sap/ui/integration/Host"], async (Host) => {
+ "use strict";
+ await customElements.whenDefined("ui-integration-card");
+ await customElements.whenDefined("ui-integration-card-editor");
+
+ const editor = document.getElementById("editor");
+ const card = document.getElementById("card");
+ const applyChangesBtn = document.getElementById("applyChangesBtn");
+ const resetBtn = document.getElementById("resetBtn");
+ const destinations = {
+ "northwind": "https://services.odata.org/V4/Northwind/Northwind.svc/"
+ };
+ const host = new Host({
+ resolveDestination: function(destinationName) {
+ if (destinations[destinationName]) {
+ return destinations[destinationName];
+ }
+
+ return Promise.reject("Destination " + destinationName + " not found!");
+ },
+ actions: [
+ {
+ type: "Custom",
+ text: "Configure",
+ icon: "sap-icon://action-settings",
+ action: () => {
+ document.getElementById("editorSection").classList.remove("hidden");
+ }
+ }
+ ]
+ });
+
+ // Called by the Configuration Editor to show a list of available destinations
+ host.getDestinations = function() {
+ return Promise.resolve(Object.entries(destinations).map(([name, url]) => {
+ return {
+ name,
+ };
+ }));
+ };
+
+ card.host = host.getId();
+ card.manifest = "../card/manifest.json";
+
+ editor.host = host.getId();
+ editor.card = "card";
+
+ let initialSettings;
+
+ // UI5 CustomElementBase doesn't expose the "ready" event, so use timeout
+ setTimeout(() => {
+ initialSettings = editor.getCurrentSettings();
+ }, 3000);
+
+ applyChangesBtn.addEventListener("click", () => {
+ card.manifestChanges = [editor.getCurrentSettings()];
+ });
+
+ resetBtn.addEventListener("click", () => {
+ card.manifestChanges = [initialSettings];
+ });
+});
\ No newline at end of file
diff --git a/test/expected/create_integration_card/single_destination/test/index.css b/test/expected/create_integration_card/single_destination/test/index.css
new file mode 100644
index 00000000..4239c912
--- /dev/null
+++ b/test/expected/create_integration_card/single_destination/test/index.css
@@ -0,0 +1,61 @@
+body {
+ margin: 2rem;
+}
+
+.container {
+ display: flex;
+ gap: 2rem;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.card-section {
+ flex: 1;
+ min-width: 300px;
+ max-width: 600px;
+ display: block;
+ flex-direction: column;
+}
+
+#editorSection {
+ --sapUiIntegrationEditorFormHeight: auto;
+}
+
+.button-container {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
+
+.config-button {
+ padding: 0.25rem 0.5rem;
+ border: none;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s ease;
+}
+
+.config-button.primary {
+ background-color: #0070f3;
+ color: white;
+}
+
+.config-button.primary:hover {
+ background-color: #0051cc;
+}
+
+.config-button.secondary {
+ background-color: #f5f5f5;
+ color: #333;
+ border: 1px solid #ddd;
+}
+
+.config-button.secondary:hover {
+ background-color: #e5e5e5;
+}
+
+.hidden {
+ display: none;
+}
\ No newline at end of file
diff --git a/test/expected/create_integration_card/single_destination/test/index.html b/test/expected/create_integration_card/single_destination/test/index.html
new file mode 100644
index 00000000..68305f66
--- /dev/null
+++ b/test/expected/create_integration_card/single_destination/test/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
Integration Card Preview
+
+
+
+
+
+
+
Preview
+
+
+
Integration Card
+
+
+
+
+
Configuration Editor
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/lib/tools/create_integration_card/create_integration_card.integration.ts b/test/lib/tools/create_integration_card/create_integration_card.integration.ts
index 95f0f2a7..7b22ff6e 100644
--- a/test/lib/tools/create_integration_card/create_integration_card.integration.ts
+++ b/test/lib/tools/create_integration_card/create_integration_card.integration.ts
@@ -107,3 +107,75 @@ supportedCardTypes.forEach((cardType) => {
await checkFileContentsIgnoreLineFeeds(t, expectedFiles, expectedPath, folderPath);
});
});
+
+test.serial("Generate card template with single destination", async (t) => {
+ const folderPath = path.join(
+ __dirname, "..", "..", "..", "tmp", "create_integration_card_single_destination"
+ );
+ await rm(folderPath, {recursive: true, force: true});
+
+ const destinations = [
+ {
+ name: "northwind",
+ defaultUrl: "https://services.odata.org/V4/Northwind/Northwind.svc/",
+ },
+ ];
+
+ const result = await t.context.createIntegrationCard({
+ folderPath,
+ cardType: "List",
+ manifestVersion: "1.78.0",
+ destinations,
+ });
+
+ // Normalize paths for snapshot consistency across OSes
+ const normalizedResult = result.map((filePath) => filePath.replaceAll(path.sep, "/")).sort();
+ t.snapshot(
+ normalizedResult,
+ "Result of createIntegrationCard with single destination should match expected structure"
+ );
+
+ const expectedPath = path.join(expectedBasePath, "single_destination");
+ const expectedFiles = await findFiles(expectedPath);
+
+ // Check for all directories and files
+ await directoryDeepEqual(t, folderPath, expectedPath);
+
+ // Check for all file contents
+ await checkFileContentsIgnoreLineFeeds(t, expectedFiles, expectedPath, folderPath);
+});
+
+test.serial("Generate card template with multiple destinations", async (t) => {
+ const folderPath = path.join(__dirname, "..", "..", "..", "tmp", "create_integration_card_destinations");
+ await rm(folderPath, {recursive: true, force: true});
+ const destinations = [
+ {
+ name: "northwind",
+ defaultUrl: "https://services.odata.org/V4/Northwind/Northwind.svc/",
+ },
+ {
+ name: "myapi",
+ defaultUrl: "http://localhost:8080/v1/",
+ },
+ ];
+
+ const result = await t.context.createIntegrationCard({
+ folderPath,
+ cardType: "List",
+ manifestVersion: "1.78.0",
+ destinations,
+ });
+
+ // Normalize paths for snapshot consistency across OSes
+ const normalizedResult = result.map((filePath) => filePath.replaceAll(path.sep, "/")).sort();
+ t.snapshot(normalizedResult, "Result of createIntegrationCard with destinations should match expected structure");
+
+ const expectedPath = path.join(expectedBasePath, "destinations");
+ const expectedFiles = await findFiles(expectedPath);
+
+ // Check for all directories and files
+ await directoryDeepEqual(t, folderPath, expectedPath);
+
+ // Check for all file contents
+ await checkFileContentsIgnoreLineFeeds(t, expectedFiles, expectedPath, folderPath);
+});
diff --git a/test/lib/tools/create_integration_card/create_integration_card.ts b/test/lib/tools/create_integration_card/create_integration_card.ts
index cdd2602e..17c36f7f 100644
--- a/test/lib/tools/create_integration_card/create_integration_card.ts
+++ b/test/lib/tools/create_integration_card/create_integration_card.ts
@@ -3,6 +3,8 @@ import esmock from "esmock";
import sinonGlobal from "sinon";
import {InvalidInputError} from "../../../../src/utils.js";
import path from "node:path";
+import getAllowedDomains from "../../../../src/utils/getAllowedDomains.js";
+import realIsValidUrl from "../../../../src/utils/isValidUrl.js";
// Define test context type
const test = anyTest as TestFn<{
@@ -23,6 +25,7 @@ const test = anyTest as TestFn<{
isLevelEnabled: sinonGlobal.SinonStub;
};
globbyStub: sinonGlobal.SinonStub;
+ isValidUrlStub: sinonGlobal.SinonStub;
createIntegrationCard: typeof import(
"../../../../src/tools/create_integration_card/create_integration_card.js"
).createIntegrationCard;
@@ -66,6 +69,9 @@ test.beforeEach(async (t) => {
];
t.context.globbyStub = t.context.sinon.stub().resolves(t.context.staticFiles);
+ // Create a stub that wraps the real isValidUrl function
+ t.context.isValidUrlStub = t.context.sinon.stub().callsFake(realIsValidUrl);
+
const {createIntegrationCard} = await esmock(
"../../../../src/tools/create_integration_card/create_integration_card.js", {
"@ui5/logger": {
@@ -86,6 +92,12 @@ test.beforeEach(async (t) => {
"../../../../src/utils.js": {
dirExists: t.context.dirExistsStub,
},
+ "../../../../src/utils/isValidUrl.js": {
+ default: t.context.isValidUrlStub,
+ },
+ "../../../../src/utils/getAllowedDomains.js": {
+ default: getAllowedDomains,
+ },
}
);
@@ -110,6 +122,7 @@ test("createIntegrationCard executes successfully", async (t) => {
folderPath,
cardType: "List",
manifestVersion: "1.78.0",
+ destinations: undefined,
});
t.is(mkdirStub.callCount, 4);
@@ -134,6 +147,7 @@ test("createIntegrationCard executes successfully", async (t) => {
t.deepEqual(templateVars, {
cardType: "List",
manifestVersion: "1.78.0",
+ destinations: undefined,
});
});
@@ -222,3 +236,66 @@ test("Error processing template file", async (t) => {
instanceOf: Error,
});
});
+
+test.serial("Destinations: Throws error when there is not allowed domain", async (t) => {
+ const {createIntegrationCard, mkdirStub} = t.context;
+ const folderPath = "/some/folder/path/card";
+ const destinations = [
+ {
+ name: "invalidDomain",
+ defaultUrl: "https://invalid-domain.com/api/v1/",
+ },
+ ];
+ const allowedDomains = ["allowed-domain.com"];
+
+ process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "allowed-domain.com";
+
+ await t.throwsAsync(async () => {
+ await createIntegrationCard({
+ folderPath,
+ cardType: "List",
+ manifestVersion: "1.78.0",
+ destinations,
+ });
+ }, {
+ message: `Domain "invalid-domain.com" is not allowed. Allowed domains are: ${allowedDomains.join(", ")}. See ` +
+ `https://github.com/UI5/mcp-server#configuration for information on how to configure the allow list.`,
+ instanceOf: InvalidInputError,
+ });
+
+ t.true(mkdirStub.notCalled);
+ t.true(t.context.isValidUrlStub.calledOnce, "isValidUrl should be called once");
+ t.deepEqual(
+ t.context.isValidUrlStub.firstCall.args,
+ [destinations[0].defaultUrl, allowedDomains],
+ "isValidUrl should be called with the destination URL and allowed domains"
+ );
+});
+
+test.serial("Destinations: Successfully generates card template with allowed destination domain", async (t) => {
+ const {createIntegrationCard} = t.context;
+ const folderPath = "/some/folder/path/card";
+ const destinations = [
+ {
+ name: "validDomain",
+ defaultUrl: "https://allowed-domain.com/api/v1/",
+ },
+ ];
+ process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "allowed-domain.com";
+
+ await t.notThrowsAsync(async () => {
+ await createIntegrationCard({
+ folderPath,
+ cardType: "List",
+ manifestVersion: "1.78.0",
+ destinations,
+ });
+ });
+
+ t.true(t.context.isValidUrlStub.calledOnce, "isValidUrl should be called once");
+ t.deepEqual(
+ t.context.isValidUrlStub.firstCall.args,
+ [destinations[0].defaultUrl, ["allowed-domain.com"]],
+ "isValidUrl should be called with the destination URL and allowed domains"
+ );
+});
diff --git a/test/lib/tools/create_integration_card/index.ts b/test/lib/tools/create_integration_card/index.ts
index 91b85f04..5e3c72fb 100644
--- a/test/lib/tools/create_integration_card/index.ts
+++ b/test/lib/tools/create_integration_card/index.ts
@@ -78,6 +78,7 @@ test("create_integration_card tool returns success message on success", async (t
folderPath: "/projects/mycards/mycard".replace(/\//g, path.sep),
cardType: "List",
manifestVersion: "1.78.0",
+ destinations: undefined,
}]);
const message = `Successfully created Integration Card ${params.cardFolderName} at ${params.basePath}\n` +
diff --git a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md
index 51aeba33..76c9b1fa 100644
--- a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md
+++ b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md
@@ -87,3 +87,31 @@ Generated by [AVA](https://avajs.dev).
'test/index.css',
'test/index.html',
]
+
+## Generate card template with single destination
+
+> Result of createIntegrationCard with single destination should match expected structure
+
+ [
+ 'card/dt/Configuration.js',
+ 'card/i18n/i18n.properties',
+ 'card/manifest.json',
+ 'package.json',
+ 'test/App.js',
+ 'test/index.css',
+ 'test/index.html',
+ ]
+
+## Generate card template with multiple destinations
+
+> Result of createIntegrationCard with destinations should match expected structure
+
+ [
+ 'card/dt/Configuration.js',
+ 'card/i18n/i18n.properties',
+ 'card/manifest.json',
+ 'package.json',
+ 'test/App.js',
+ 'test/index.css',
+ 'test/index.html',
+ ]
diff --git a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap
index 1ad47a46..d6b08914 100644
Binary files a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap and b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap differ
diff --git a/test/lib/utils/getAllowedDomains.ts b/test/lib/utils/getAllowedDomains.ts
new file mode 100644
index 00000000..53bfc795
--- /dev/null
+++ b/test/lib/utils/getAllowedDomains.ts
@@ -0,0 +1,101 @@
+import anyTest, {TestFn} from "ava";
+import sinonGlobal from "sinon";
+import esmock from "esmock";
+import {InvalidInputError} from "../../../src/utils.js";
+
+const test = anyTest as TestFn<{
+ sinon: sinonGlobal.SinonSandbox;
+ getAllowedDomains: typeof import("../../../src/utils/getAllowedDomains.js").default;
+ loggerMock: {
+ verbose: sinonGlobal.SinonStub;
+ };
+ originalEnv: NodeJS.ProcessEnv;
+}>;
+
+test.beforeEach(async (t) => {
+ t.context.sinon = sinonGlobal.createSandbox();
+ t.context.originalEnv = {...process.env};
+
+ const loggerMock = {
+ verbose: t.context.sinon.stub(),
+ };
+ t.context.loggerMock = loggerMock;
+
+ const getAllowedDomainsModule = await esmock("../../../src/utils/getAllowedDomains.js", {
+ "@ui5/logger": {
+ getLogger: t.context.sinon.stub().returns(loggerMock),
+ },
+ });
+
+ t.context.getAllowedDomains = getAllowedDomainsModule.default;
+});
+
+test.afterEach.always((t) => {
+ process.env = t.context.originalEnv;
+ t.context.sinon.restore();
+});
+
+test.serial("returns default domains when no environment is set", (t) => {
+ const {getAllowedDomains, loggerMock} = t.context;
+ delete process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS;
+ delete process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS;
+
+ const domains = getAllowedDomains();
+
+ t.deepEqual(domains, ["localhost", "services.odata.org"]);
+ t.false(loggerMock.verbose.called);
+});
+
+test.serial("returns trimmed domains from UI5_MCP_SERVER_ALLOWED_DOMAINS", (t) => {
+ const {getAllowedDomains, loggerMock} = t.context;
+ process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = " example.com , .api.example.com ,localhost ";
+
+ const domains = getAllowedDomains();
+
+ t.deepEqual(domains, ["example.com", ".api.example.com", "localhost"]);
+ t.true(loggerMock.verbose.calledWith(
+ "3 allowed domains configured: example.com, .api.example.com, localhost"
+ ));
+});
+
+test.serial("prefers UI5_MCP_SERVER_ALLOWED_DOMAINS over UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS", (t) => {
+ const {getAllowedDomains, loggerMock} = t.context;
+ process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "primary.example.com";
+ process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = "secondary.example.com";
+
+ const domains = getAllowedDomains();
+
+ t.deepEqual(domains, ["primary.example.com"]);
+ t.true(loggerMock.verbose.calledWith("1 allowed domains configured: primary.example.com"));
+});
+
+test.serial("returns empty list and logs when env value is blank", (t) => {
+ const {getAllowedDomains, loggerMock} = t.context;
+ process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = " \t ";
+
+ const domains = getAllowedDomains();
+
+ t.deepEqual(domains, []);
+ t.true(loggerMock.verbose.calledWith(
+ "Empty value for UI5_MCP_SERVER_ALLOWED_DOMAINS, allowing all domains"
+ ));
+});
+
+test.serial("throws InvalidInputError for malformed domain entries", (t) => {
+ const {getAllowedDomains} = t.context;
+ process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "invalid domain";
+
+ const error = t.throws(() => getAllowedDomains(), {instanceOf: InvalidInputError});
+ t.regex(error?.message ?? "", /Invalid domain 'invalid domain' in UI5_MCP_SERVER_ALLOWED_DOMAINS/);
+});
+
+test.serial("falls back to UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS when primary is unset", (t) => {
+ const {getAllowedDomains, loggerMock} = t.context;
+ delete process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS;
+ process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = "odata.example.com";
+
+ const domains = getAllowedDomains();
+
+ t.deepEqual(domains, ["odata.example.com"]);
+ t.true(loggerMock.verbose.calledWith("1 allowed domains configured: odata.example.com"));
+});
diff --git a/test/lib/tools/create_ui5_app/isValidUrl.ts b/test/lib/utils/isValidUrl.ts
similarity index 97%
rename from test/lib/tools/create_ui5_app/isValidUrl.ts
rename to test/lib/utils/isValidUrl.ts
index c649f467..ec7c5187 100644
--- a/test/lib/tools/create_ui5_app/isValidUrl.ts
+++ b/test/lib/utils/isValidUrl.ts
@@ -1,6 +1,6 @@
import test from "ava";
-import isValidUrl from "../../../../src/tools/create_ui5_app/isValidUrl.js";
-import {InvalidInputError} from "../../../../src/utils.js";
+import isValidUrl from "../../../src/utils/isValidUrl.js";
+import {InvalidInputError} from "../../../src/utils.js";
test("should return false for non-string or empty input", (t) => {
t.false(isValidUrl(""), "Empty string should be invalid");