diff --git a/front/assets/js/utils.js b/front/assets/js/utils.js index 245589d82..5812d858f 100644 --- a/front/assets/js/utils.js +++ b/front/assets/js/utils.js @@ -60,4 +60,19 @@ export class Utils { static endOfWeek(date) { return moment(date).endOf('isoWeek'); } + + // Escapes CSS attribute values + // Uses CSS.escape() when available, with fallback for older browsers + // Reference: https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape + static escapeCSSAttributeValue(value) { + if (!value) return value; + + // Use native CSS.escape() if available + if (typeof CSS !== 'undefined' && CSS.escape) { + return CSS.escape(value); + } + + // Fallback for older browsers: escape quotes and backslashes + return value.replace(/(["'\\])/g, '\\$1'); + } } diff --git a/front/assets/js/utils.spec.js b/front/assets/js/utils.spec.js index 4036953f4..eb0a98518 100644 --- a/front/assets/js/utils.spec.js +++ b/front/assets/js/utils.spec.js @@ -21,4 +21,97 @@ describe("Utils", () => { expect(Utils.toSeconds(36000000000)).to.equal(36) }); }); + describe("escapeCSSAttributeValue", () => { + it("escapes single quotes", () => { + const input = "Publish 'my-package' to Production"; + const escaped = Utils.escapeCSSAttributeValue(input); + const expected = "Publish \\'my-package\\' to Production"; + expect(escaped).to.equal(expected); + }); + + it("escapes double quotes", () => { + const input = 'Deploy "production" build'; + const escaped = Utils.escapeCSSAttributeValue(input); + const expected = 'Deploy \\"production\\" build'; + expect(escaped).to.equal(expected); + }); + + it("escapes backslashes", () => { + const input = "Path\\to\\file"; + const escaped = Utils.escapeCSSAttributeValue(input); + const expected = "Path\\\\to\\\\file"; + expect(escaped).to.equal(expected); + }); + + it("escapes special characters", () => { + const input = "test[data]:value.class#id"; + const escaped = Utils.escapeCSSAttributeValue(input); + // When CSS.escape is available, it escapes brackets, colons, etc. + // Fallback only escapes quotes and backslashes + if (typeof CSS !== 'undefined' && CSS.escape) { + expect(escaped).to.not.equal(input); + } else { + expect(escaped).to.equal(input); + } + }); + + it("handles simple strings", () => { + const input = "Simple-promotion_name123"; + const escaped = Utils.escapeCSSAttributeValue(input); + // Simple alphanumeric strings with hyphens/underscores shouldn't need escaping + expect(escaped).to.equal(input); + }); + + it("handles empty string", () => { + expect(Utils.escapeCSSAttributeValue("")).to.equal(""); + }); + + it("handles null/undefined", () => { + expect(Utils.escapeCSSAttributeValue(null)).to.equal(null); + expect(Utils.escapeCSSAttributeValue(undefined)).to.equal(undefined); + }); + + it("escapes complex strings with brackets and quotes", () => { + const input = "test'][arbitrary-selector][data-x='some-value"; + const escaped = Utils.escapeCSSAttributeValue(input); + const expected = "test\\'][arbitrary-selector][data-x=\\'some-value"; + expect(escaped).to.equal(expected); + }); + + it("can be used in DOM selectors", () => { + const input = "Publish 'my-package' to Production"; + const escaped = Utils.escapeCSSAttributeValue(input); + const expected = "Publish \\'my-package\\' to Production"; + expect(escaped).to.equal(expected); + + expect(() => { + document.querySelector(`[data-promotion-target="${escaped}"]`); + }).to.not.throw(); + }); + + it("handles unicode characters (emoji)", () => { + const input = "Deploy 🚀 to production"; + const escaped = Utils.escapeCSSAttributeValue(input); + expect(escaped).to.equal(input); + }); + + it("handles unicode characters (accented letters)", () => { + const input = "Déploiement en français"; + const escaped = Utils.escapeCSSAttributeValue(input); + expect(escaped).to.equal(input); + }); + + it("handles unicode characters (Chinese)", () => { + const input = "部署到生产环境"; + const escaped = Utils.escapeCSSAttributeValue(input); + expect(escaped).to.equal(input); + }); + + it("handles mixed unicode and special characters", () => { + const input = "Deploy 'app' 🚀 to Prod"; + const escaped = Utils.escapeCSSAttributeValue(input); + const expected = "Deploy \\'app\\' 🚀 to Prod"; + expect(escaped).to.equal(expected); + }); + }); }) diff --git a/front/assets/js/workflow_view/switch.js b/front/assets/js/workflow_view/switch.js index 9ce9d1d37..1a9a05df6 100644 --- a/front/assets/js/workflow_view/switch.js +++ b/front/assets/js/workflow_view/switch.js @@ -3,6 +3,7 @@ import $ from "jquery" import { TriggerEvent } from "./trigger_event" import { Pollman } from "../pollman" import { TargetParams } from "./target_params" +import { Utils } from "../utils" export var Switch = { init: function() { @@ -40,7 +41,8 @@ export var Switch = { Switch.askToConfirmPromotion(switchId, promotionTarget); // Focus on first input or select in the promotion box - const promotionForm = $(`[data-promotion-target="${promotionTarget}"]`); + const escapedPromotionTarget = Utils.escapeCSSAttributeValue(promotionTarget); + const promotionForm = $(`[data-promotion-target="${escapedPromotionTarget}"]`); const firstInput = promotionForm.find('input, select').first(); if (firstInput.length) { if (firstInput[0].tomselect) { @@ -82,8 +84,9 @@ export var Switch = { Pollman.start(); alert("Something went wrong. Please try again."); - $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${parentPromotionTarget}'][promote-button]`).removeAttr("disabled"); - $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${parentPromotionTarget}'][promote-button]`).removeClass("btn-working"); + let escapedTarget = Utils.escapeCSSAttributeValue(parentPromotionTarget); + $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).removeAttr("disabled"); + $(`[switch='${parentPromotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).removeClass("btn-working"); }); }) }, @@ -96,9 +99,10 @@ export var Switch = { let promotionTarget = Switch.parentPromotionTarget(target) let promotionSwitch = Switch.parentSwitch(target) + let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget); - $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-confirmation]`).hide(); - $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).show(); + $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-confirmation]`).hide(); + $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).show(); Pollman.pollNow(); Pollman.start(); @@ -138,18 +142,21 @@ export var Switch = { askToConfirmPromotion: function(promotionSwitch, promotionTarget) { Switch.hidePromotionBoxElements(promotionSwitch, promotionTarget); - $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-confirmation]`).show(); + let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget); + $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-confirmation]`).show(); }, hidePromotionBoxElements: function(promotionSwitch, promotionTarget) { - $(`[switch='${promotionSwitch}'] [promotion-box][data-promotion-target='${promotionTarget}']`).children().hide(); + let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget); + $(`[switch='${promotionSwitch}'] [promotion-box][data-promotion-target='${escapedTarget}']`).children().hide(); }, showPromotingInProgress(promotionSwitch, promotionTarget) { - $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-confirmation]`).hide(); - $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).show(); - $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).attr("disabled", ""); - $(`[switch='${promotionSwitch}'] [data-promotion-target='${promotionTarget}'][promote-button]`).addClass("btn-working"); + let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget); + $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-confirmation]`).hide(); + $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).show(); + $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).attr("disabled", ""); + $(`[switch='${promotionSwitch}'] [data-promotion-target='${escapedTarget}'][promote-button]`).addClass("btn-working"); Switch.afterResize(promotionSwitch); }, @@ -167,7 +174,8 @@ export var Switch = { }, latestTriggerEvent: function(promotionSwitch, promotionTarget) { - return $(`[switch='${promotionSwitch}'] [trigger-event][data-promotion-target='${promotionTarget}']`).first(); + let escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget); + return $(`[switch='${promotionSwitch}'] [trigger-event][data-promotion-target='${escapedTarget}']`).first(); }, isProcessed: function(triggerEvent) { diff --git a/front/assets/js/workflow_view/switch.spec.js b/front/assets/js/workflow_view/switch.spec.js new file mode 100644 index 000000000..02d50a731 --- /dev/null +++ b/front/assets/js/workflow_view/switch.spec.js @@ -0,0 +1,257 @@ +/** + * @prettier + */ + +import { expect } from "chai"; +import { Switch } from "./switch"; +import { Utils } from "../utils"; +import $ from "jquery"; +import sinon from "sinon"; + +describe("Switch", () => { + beforeEach(() => { + // Setup DOM for tests + document.body.innerHTML = ` +
+ +
+ + +
+
+
+ `; + }); + + afterEach(() => { + document.body.innerHTML = ""; + $("body").off(); // Clean up event handlers + }); + + describe("promotion target name escaping", () => { + it("handles promotion targets with single quotes", () => { + const promotionName = "Publish 'my-package' to Production"; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + const expected = "Publish \\'my-package\\' to Production"; + + expect(escaped).to.equal(expected); + expect(() => { + $(`[data-promotion-target="${escaped}"]`); + }).to.not.throw(); + }); + + it("handles promotion targets with double quotes", () => { + const promotionName = 'Deploy "production" build'; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + const expected = 'Deploy \\"production\\" build'; + + expect(escaped).to.equal(expected); + expect(() => { + $(`[data-promotion-target="${escaped}"]`); + }).to.not.throw(); + }); + + it("handles promotion targets with complex special characters", () => { + const promotionName = "test'][arbitrary-selector][data-x='some-value"; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + const expected = "test\\'][arbitrary-selector][data-x=\\'some-value"; + + expect(escaped).to.equal(expected); + expect(() => { + $(`[switch='123'] [data-promotion-target='${escaped}'][promote-button]`); + }).to.not.throw(); + }); + + it("handles promotion targets with brackets and colons", () => { + const promotionName = "test[data]:value.class#id"; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + + // CSS.escape handles these when available, fallback doesn't need to + if (typeof CSS !== 'undefined' && CSS.escape) { + expect(escaped).to.not.equal(promotionName); + } else { + expect(escaped).to.equal(promotionName); + } + }); + + it("handles promotion targets with emoji", () => { + const promotionName = "Deploy 🚀 to Production"; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + + expect(escaped).to.equal(promotionName); + }); + + it("handles promotion targets with accented characters", () => { + const promotionName = "Déploiement Français"; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + + expect(escaped).to.equal(promotionName); + }); + + it("handles promotion targets with CJK characters", () => { + const promotionName = "部署到生产环境"; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + + expect(escaped).to.equal(promotionName); + }); + + it("handles mixed unicode and quotes", () => { + const promotionName = "Deploy 'app' 🎉 to Staging"; + const escaped = Utils.escapeCSSAttributeValue(promotionName); + const expected = "Deploy \\'app\\' 🎉 to Staging"; + + expect(escaped).to.equal(expected); + }); + }); + + describe("hidePromotionBoxElements", () => { + it("escapes promotion target before using in selector", () => { + const promotionTarget = "Deploy 'prod' build"; + document.body.innerHTML = ` +
+
+
Child element
+
+
+ `; + + Switch.hidePromotionBoxElements("switch-1", promotionTarget); + + // The child should be hidden (display: none) + const child = $("[switch='switch-1'] [promotion-box] .child"); + expect(child.css("display")).to.equal("none"); + }); + }); + + describe("askToConfirmPromotion", () => { + it("escapes promotion target and shows confirmation", () => { + const promotionTarget = "Test 'target' name"; + const escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget); + document.body.innerHTML = ` +
+
+
Element
+
+ +
+ `; + + sinon.stub(Switch, "hidePromotionBoxElements"); + + Switch.askToConfirmPromotion("switch-1", promotionTarget); + + // Verify hidePromotionBoxElements was called + expect(Switch.hidePromotionBoxElements.calledOnce).to.be.true; + + // The confirmation element should have show() called (display not 'none') + const confirmation = $(`[switch='switch-1'] [promote-confirmation][data-promotion-target='${escapedTarget}']`); + expect(confirmation.css("display")).to.not.equal("none"); + + Switch.hidePromotionBoxElements.restore(); + }); + }); + + describe("showPromotingInProgress", () => { + it("safely handles escaped promotion targets", () => { + const promotionTarget = "Deploy \"staging\""; + const escapedTarget = Utils.escapeCSSAttributeValue(promotionTarget); + document.body.innerHTML = ` +
+
Confirm
+ +
+ `; + + Switch.showPromotingInProgress("switch-1", promotionTarget); + + const confirmation = $(`[switch='switch-1'] [promote-confirmation][data-promotion-target='${escapedTarget}']`); + const button = $(`[switch='switch-1'] [promote-button][data-promotion-target='${escapedTarget}']`); + + expect(confirmation.css("display")).to.equal("none"); + expect(button.css("display")).to.not.equal("none"); + expect(button.prop("disabled")).to.be.true; + expect(button.hasClass("btn-working")).to.be.true; + }); + }); + + describe("latestTriggerEvent", () => { + it("finds trigger event with escaped promotion target", () => { + const promotionTarget = "Deploy 'production'"; + document.body.innerHTML = ` +
+
Event 1
+
Event 2
+
+ `; + + const result = Switch.latestTriggerEvent("switch-1", promotionTarget); + + expect(result.length).to.equal(1); + expect(result.attr("data-id")).to.equal("event-1"); + }); + }); + + describe("parentSwitch", () => { + it("returns the switch ID from target element", () => { + const target = $(""); + expect(Switch.parentSwitch(target)).to.equal("switch-456"); + }); + }); + + describe("parentPromotionTarget", () => { + it("returns the promotion target from target element", () => { + const target = $(""); + expect(Switch.parentPromotionTarget(target)).to.equal("My Target"); + }); + }); + + describe("hasEmptyRequiredParameter", () => { + it("returns true when required input is empty", () => { + const form = $(` +
+ +
+ `); + + expect(Switch.hasEmptyRequiredParameter(form)).to.be.true; + }); + + it("returns false when all required inputs are filled", () => { + const form = $(` +
+ +
+ `); + + expect(Switch.hasEmptyRequiredParameter(form)).to.be.false; + }); + + it("returns true when required select has no value", () => { + // Create a select with no options - .val() will return null + const form = $(` +
+ +
+ `); + + expect(Switch.hasEmptyRequiredParameter(form)).to.be.true; + }); + }); + + describe("isProcessed", () => { + it("returns true when trigger event is processed", () => { + const triggerEvent = $("
"); + expect(Switch.isProcessed(triggerEvent)).to.be.true; + }); + + it("returns false when trigger event is not processed", () => { + const triggerEvent = $("
"); + expect(Switch.isProcessed(triggerEvent)).to.be.false; + }); + + it("returns false when trigger event is null", () => { + expect(Switch.isProcessed(null)).to.not.be.true; + }); + }); +}); diff --git a/front/docker-compose.yml b/front/docker-compose.yml index 809077c90..d4ce8ef37 100644 --- a/front/docker-compose.yml +++ b/front/docker-compose.yml @@ -45,6 +45,11 @@ services: tty: true volumes: - .:/app + - /app/_build + - /app/deps + - /app/assets/node_modules + - /app/assets/lib + - /app/assets/bin redis-cache: image: "redis:5-buster" diff --git a/front/test/browser/workflow_page/promotions_test.exs b/front/test/browser/workflow_page/promotions_test.exs index 087109d5f..c90630b23 100644 --- a/front/test/browser/workflow_page/promotions_test.exs +++ b/front/test/browser/workflow_page/promotions_test.exs @@ -82,6 +82,113 @@ defmodule Front.Browser.WorkflowPage.PromotionsTest do assert has_text?(page, "Nevermind") end + test "promotion targets with single quotes render and open correctly", ctx do + quoted_name = "Publish 'my-package' to Production" + Support.Stubs.Switch.add_target(ctx.switch, name: quoted_name) + + page = open(ctx) + + assert_has(page, Query.button(quoted_name)) + click(page, Query.button(quoted_name)) + + assert_has( + page, + Query.css("[promote-confirmation][data-promotion-target=\"#{quoted_name}\"]") + ) + + assert_has(page, Query.text("Promote to #{quoted_name}?")) + end + + test "promotion targets with double quotes render and open correctly", ctx do + quoted_name = ~s(Deploy "production" build) + Support.Stubs.Switch.add_target(ctx.switch, name: quoted_name) + + page = open(ctx) + + # Use CSS selector with data attribute since XPath queries don't handle embedded quotes + click(page, Query.css("[promote-button][data-promotion-target='#{quoted_name}']")) + + # Verify confirmation dialog appeared + assert_has(page, Query.css("[promote-confirmation][data-promotion-target='#{quoted_name}']")) + end + + test "promotion targets with backslashes render and open correctly", ctx do + name_with_backslash = "Deploy\\Staging\\App" + Support.Stubs.Switch.add_target(ctx.switch, name: name_with_backslash) + + page = open(ctx) + + assert_has(page, Query.button(name_with_backslash)) + click(page, Query.button(name_with_backslash)) + assert_has(page, Query.text("Promote to #{name_with_backslash}?")) + end + + test "promotion targets with brackets and special characters work correctly", ctx do + special_name = "Deploy[test]:value.config" + Support.Stubs.Switch.add_target(ctx.switch, name: special_name) + + page = open(ctx) + + assert_has(page, Query.button(special_name)) + click(page, Query.button(special_name)) + assert_has(page, Query.text("Promote to #{special_name}?")) + end + + test "promotion targets with emoji render and open correctly", ctx do + emoji_name = "Deploy 🚀 to Production" + Support.Stubs.Switch.add_target(ctx.switch, name: emoji_name) + + page = open(ctx) + + assert_has(page, Query.button(emoji_name)) + click(page, Query.button(emoji_name)) + assert_has(page, Query.text("Promote to #{emoji_name}?")) + end + + test "promotion targets with accented characters render and open correctly", ctx do + accented_name = "Déploiement Français" + Support.Stubs.Switch.add_target(ctx.switch, name: accented_name) + + page = open(ctx) + + assert_has(page, Query.button(accented_name)) + click(page, Query.button(accented_name)) + assert_has(page, Query.text("Promote to #{accented_name}?")) + end + + test "promotion targets with CJK characters render and open correctly", ctx do + cjk_name = "部署到生产环境" + Support.Stubs.Switch.add_target(ctx.switch, name: cjk_name) + + page = open(ctx) + + assert_has(page, Query.button(cjk_name)) + click(page, Query.button(cjk_name)) + assert_has(page, Query.text("Promote to #{cjk_name}?")) + end + + test "promotion targets with mixed special characters and unicode work correctly", ctx do + mixed_name = "Deploy 'app' 🎉 to Production" + Support.Stubs.Switch.add_target(ctx.switch, name: mixed_name) + + page = open(ctx) + + assert_has(page, Query.button(mixed_name)) + click(page, Query.button(mixed_name)) + assert_has(page, Query.text("Promote to #{mixed_name}?")) + end + + test "promotion targets with complex bracket and quote combinations work correctly", ctx do + complex_name = "test[data] 'value' config" + Support.Stubs.Switch.add_target(ctx.switch, name: complex_name) + + page = open(ctx) + + assert_has(page, Query.button(complex_name)) + click(page, Query.button(complex_name)) + assert_has(page, Query.text("Promote to #{complex_name}?")) + end + describe "when deployment targets are enabled" do setup ctx do Support.Stubs.Feature.enable_feature(ctx.org.id, :deployment_targets) diff --git a/front/test/browser/workflow_page_test.exs b/front/test/browser/workflow_page_test.exs index 51a79f972..59b2312a7 100644 --- a/front/test/browser/workflow_page_test.exs +++ b/front/test/browser/workflow_page_test.exs @@ -42,6 +42,53 @@ defmodule Front.Browser.WorkflowPage do assert_text(page, "Staging") end + test "promotions with unicode characters (emoji) are displayed correctly", params do + switch = params.switch + Support.Stubs.Switch.add_target(switch, name: "Deploy 🚀 Production") + + page = open(params) + + assert_text(page, "Deploy 🚀 Production") + end + + test "promotions with accented characters are displayed correctly", params do + switch = params.switch + Support.Stubs.Switch.add_target(switch, name: "Déploiement Français") + + page = open(params) + + assert_text(page, "Déploiement Français") + end + + test "promotions with CJK characters are displayed correctly", params do + switch = params.switch + Support.Stubs.Switch.add_target(switch, name: "部署到生产环境") + + page = open(params) + + assert_text(page, "部署到生产环境") + end + + test "promotions with single quotes are displayed and clickable", params do + switch = params.switch + Support.Stubs.Switch.add_target(switch, name: "Publish 'my-package' to Production") + + page = open(params) + + assert_text(page, "Publish 'my-package' to Production") + assert find(page, Query.button("Publish 'my-package' to Production")) + end + + test "promotions with mixed unicode and special characters work correctly", params do + switch = params.switch + Support.Stubs.Switch.add_target(switch, name: "Deploy 'app' 🎉 to Staging") + + page = open(params) + + assert_text(page, "Deploy 'app' 🎉 to Staging") + assert find(page, Query.button("Deploy 'app' 🎉 to Staging")) + end + test "If project is public, show blocks, but promotions should be disabled", params do Support.Stubs.PermissionPatrol.remove_all_permissions() diff --git a/front/test/front_web/views/pipeline_view_test.exs b/front/test/front_web/views/pipeline_view_test.exs index 0901476c4..dd429849b 100644 --- a/front/test/front_web/views/pipeline_view_test.exs +++ b/front/test/front_web/views/pipeline_view_test.exs @@ -1,5 +1,6 @@ defmodule FrontWeb.PipelineViewTest do use FrontWeb.ConnCase + import Phoenix.View, only: [render_to_string: 3] alias Front.Models alias FrontWeb.PipelineView alias Support.Factories @@ -187,6 +188,121 @@ defmodule FrontWeb.PipelineViewTest do %{pipeline | triggerer: triggerer} end + describe "switch/_target_form.html" do + test "renders promotion attributes correctly when the target name includes single quotes", %{ + conn: conn + } do + html = + render_to_string(PipelineView, "switch/_target_form.html", %{ + conn: conn, + workflow: %{id: "wf-1"}, + pipeline: %{id: "pl-1"}, + switch: %{id: "sw-1"}, + target: %{name: "Publish 'my-package' to Production", parameters: []} + }) + + assert html =~ ~s(data-promotion-target="Publish 'my-package' to Production") + assert html =~ ~s(data-switch="sw-1") + assert html =~ ~s(promote-confirmation) + end + + test "escapes double quotes inside promotion target names", %{conn: conn} do + target_name = ~s(Publish "critical" to Production) + + html = + render_to_string(PipelineView, "switch/_target_form.html", %{ + conn: conn, + workflow: %{id: "wf-1"}, + pipeline: %{id: "pl-1"}, + switch: %{id: "sw-1"}, + target: %{name: target_name, parameters: []} + }) + + assert html =~ ~s(data-promotion-target="Publish "critical" to Production") + assert html =~ ~s(Start promotion) + end + + test "handles promotion target names with unicode emoji", %{conn: conn} do + target_name = "Deploy 🚀 to Production" + + html = + render_to_string(PipelineView, "switch/_target_form.html", %{ + conn: conn, + workflow: %{id: "wf-1"}, + pipeline: %{id: "pl-1"}, + switch: %{id: "sw-1"}, + target: %{name: target_name, parameters: []} + }) + + assert html =~ ~s(data-promotion-target="Deploy 🚀 to Production") + assert html =~ ~s(promote-confirmation) + end + + test "handles promotion target names with accented characters", %{conn: conn} do + target_name = "Déploiement en Français" + + html = + render_to_string(PipelineView, "switch/_target_form.html", %{ + conn: conn, + workflow: %{id: "wf-1"}, + pipeline: %{id: "pl-1"}, + switch: %{id: "sw-1"}, + target: %{name: target_name, parameters: []} + }) + + assert html =~ ~s(data-promotion-target="Déploiement en Français") + assert html =~ ~s(promote-confirmation) + end + + test "handles promotion target names with CJK characters", %{conn: conn} do + target_name = "部署到生产环境" + + html = + render_to_string(PipelineView, "switch/_target_form.html", %{ + conn: conn, + workflow: %{id: "wf-1"}, + pipeline: %{id: "pl-1"}, + switch: %{id: "sw-1"}, + target: %{name: target_name, parameters: []} + }) + + assert html =~ ~s(data-promotion-target="部署到生产环境") + assert html =~ ~s(promote-confirmation) + end + + test "handles promotion target names with mixed unicode and special characters", %{conn: conn} do + target_name = "Deploy 'app' 🎉 to Staging" + + html = + render_to_string(PipelineView, "switch/_target_form.html", %{ + conn: conn, + workflow: %{id: "wf-1"}, + pipeline: %{id: "pl-1"}, + switch: %{id: "sw-1"}, + target: %{name: target_name, parameters: []} + }) + + assert html =~ ~s(data-promotion-target="Deploy 'app' 🎉 to Staging") + assert html =~ ~s(promote-confirmation) + end + + test "handles CSS selector metacharacters in target names", %{conn: conn} do + target_name = "Deploy[test]:value.class#id" + + html = + render_to_string(PipelineView, "switch/_target_form.html", %{ + conn: conn, + workflow: %{id: "wf-1"}, + pipeline: %{id: "pl-1"}, + switch: %{id: "sw-1"}, + target: %{name: target_name, parameters: []} + }) + + assert html =~ ~s(data-promotion-target=) + assert html =~ ~s(promote-confirmation) + end + end + describe ".action_string" do test "when the pipeline is terminated by a user => shows correct message" do terminator = %Models.User{