Repository: slackapi/slack-github-action (js)

Issue:
Templated payload variables are replaced before the payload is parsed
```
runs:
  using: "composite"
  steps:
    - name: Post a message in a channel
      uses: slackapi/slack-github-action@v2.0.0
      with:
        webhook: "${{ env.SLACK_WEBHOOK_URL }}"
        webhook-type: incoming-webhook
        # See: https://api.slack.com/reference/messaging/attachments
        payload: |
          attachments:
            - color: "${{ inputs.status == 'success' && 'good' || inputs.status == 'failure' && 'danger' || '#808080' }}"
              pretext: "${{ inputs.text }}"
              title: "Build Status: ${{ inputs.status }}"
              title_link: "${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
              fields:
                - title: "Author"
                  value: "${{ github.event.head_commit.author.name }}"
                  short: true
                - title: "Repository"
                  value: "<${{ github.event.repository.html_url }}|${{ github.event.repository.full_name }}>"
                  short: true
                - title: "Head commit"
                  value: "<${{ github.event.head_commit.url }}|${{ github.event.head_commit.message }}>"
                  short: false
```

With the above call to `slack-github-action` I ran into a parsing issue. For certain commit messages, the followed error occured.

```
file:///home/runner/_work/_actions/slackapi/slack-github-action/v2.0.0/src/content.js:93
      throw new SlackError(
^
SlackError: Invalid input! Failed to parse contents of the provided payload
    at Content.getContentPayload (file:///home/runner/_work/_actions/slackapi/slack-github-action/v2.0.0/src/content.js:93:1)
    at Content.get (file:///home/runner/_work/_actions/slackapi/slack-github-action/v2.0.0/src/content.js:28:1)
    at new Config (file:///home/runner/_work/_actions/slackapi/slack-github-action/v2.0.0/src/config.js:115:1)
    at send (file:///home/runner/_work/_actions/slackapi/slack-github-action/v2.0.0/src/send.js:13:1)
    at file:///home/runner/_work/_actions/slackapi/slack-github-action/v2.0.0/src/index.js:9:1
    at ModuleJob.run (node:internal/modules/esm/module_job:263:25)
    at ModuleLoader.import (node:internal/modules/esm/loader:540:24)
    at asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
```

After trying a bunch of commit messages, I could simplify the string that would cause the above error to the following

`\n---\n`

This string would be inserted into `${{ github.event.head_commit.message }}>`

This is a YAML-directive. Which is weird, because the variables do not exist at YAML-level.

**Code**

I then further tracked it down to the following code.

First, it translates the variables to their values. Here

https://github.com/slackapi/slack-github-action/blob/d07fadf6b91dd644931ee3b68600be76f0c6917c/src/content.js#L121

And

https://github.com/slackapi/slack-github-action/blob/d07fadf6b91dd644931ee3b68600be76f0c6917c/src/content.js#L155-L166

It tries to parse the payload parameter as YAML

https://github.com/slackapi/slack-github-action/blob/d07fadf6b91dd644931ee3b68600be76f0c6917c/src/content.js#L64-L77

If that fails, it tries to parse the payload parameter as JSON

https://github.com/slackapi/slack-github-action/blob/d07fadf6b91dd644931ee3b68600be76f0c6917c/src/content.js#L78-L100

If that fails, it exits.

**What I think is the problem**

The problem I see with this however, is that the order is incorrect. What is being done is:
- Change variables to their output value.
- Parse Yaml

What should be done is:
- Parse Yaml
- Change variables to their output value

This might also have security implications, since the inserted variables could modify the yaml document. I know to little about YAML and use cases for `slack-github-action` to be sure.

Pull request description:
fix: parse provided payloads before replacing templated variables
### Summary

This PR parses the provided payload **before** replacing templated variables to avoid erroneous parsings from perhaps variables with linebreaks - fixes #427.

### Requirements

* [x] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/slack-github-action/blob/main/.github/contributing.md) and have done my best effort to follow them.
* [x] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).

Patch:
diff --git a/src/content.js b/src/content.js
index e9506a0..2dba1ad 100644
--- a/src/content.js
+++ b/src/content.js
@@ -37,6 +37,9 @@ export default class Content {
         this.values = github.context;
         break;
     }
+    if (config.inputs.payloadTemplated) {
+      this.values = this.templatize(this.values);
+    }
     if (config.inputs.payloadDelimiter) {
       this.values = flatten(this.values, {
         delimiter: config.inputs.payloadDelimiter,
@@ -63,9 +66,8 @@ export default class Content {
       );
     }
     try {
-      const input = this.templatize(config, config.inputs.payload);
       const content = /** @type {Content} */ (
-        yaml.load(input, {
+        yaml.load(config.inputs.payload, {
           schema: yaml.JSON_SCHEMA,
         })
       );
@@ -119,18 +121,17 @@ export default class Content {
         path.resolve(config.inputs.payloadFilePath),
         "utf-8",
       );
-      const content = this.templatize(config, input);
       if (
         config.inputs.payloadFilePath.endsWith("yaml") ||
         config.inputs.payloadFilePath.endsWith("yml")
       ) {
-        const load = yaml.load(content, {
+        const load = yaml.load(input, {
           schema: yaml.JSON_SCHEMA,
         });
         return /** @type {Content} */ (load);
       }
       if (config.inputs.payloadFilePath.endsWith("json")) {
-        return JSON.parse(content);
+        return JSON.parse(input);
       }
       throw new SlackError(
         config.core,
@@ -148,20 +149,32 @@ export default class Content {
   }
 
   /**
-   * Replace templated variables in the provided content if requested.
-   * @param {Config} config
-   * @param {string} input - The initial value of the content.
-   * @returns {string} Content with templatized variables replaced.
+   * Replace templated variables in the provided content as requested.
+   * @param {unknown} input - The initial value of the content.
+   * @returns {unknown} Content with templatized variables replaced.
    */
-  templatize(config, input) {
-    if (!config.inputs.payloadTemplated) {
-      return input;
+  templatize(input) {
+    if (Array.isArray(input)) {
+      return input.map((v) => this.templatize(v));
+    }
+    if (input && typeof input === "object") {
+      /**
+       * @type {Record<string, unknown>}
+       */
+      const out = {};
+      for (const [k, v] of Object.entries(input)) {
+        out[k] = this.templatize(v);
+      }
+      return out;
+    }
+    if (typeof input === "string") {
+      const template = input.replace(/\$\{\{/g, "{{"); // swap ${{ for {{
+      const context = {
+        env: process.env,
+        github: github.context,
+      };
+      return markup.up(template, context);
     }
-    const template = input.replace(/\$\{\{/g, "{{"); // swap ${{ for {{
-    const context = {
-      env: process.env,
-      github: github.context,
-    };
-    return markup.up(template, context);
+    return input;
   }
 }


Test patch:
diff --git a/test/content.spec.js b/test/content.spec.js
index 30a3728..737f604 100644
--- a/test/content.spec.js
+++ b/test/content.spec.js
@@ -84,16 +84,120 @@ describe("content", () => {
       assert.deepEqual(config.content.values, expected);
     });
 
+    it("templatizes variables requires configuration", async () => {
+      mocks.core.getInput.withArgs("payload").returns(`{
+          "message": "this matches an existing variable: \${{ github.apiUrl }}",
+          "channel": "C0123456789"
+        }
+      `);
+      const config = new Config(mocks.core);
+      const expected = {
+        message: "this matches an existing variable: ${{ github.apiUrl }}",
+        channel: "C0123456789",
+      };
+      assert.deepEqual(config.content.values, expected);
+    });
+
     it("templatizes variables with matching variables", async () => {
-      mocks.core.getInput
-        .withArgs("payload")
-        .returns("message: Served ${{ env.NUMBER }} from ${{ github.apiUrl }}");
+      mocks.core.getInput.withArgs("payload").returns(`
+          channel: C0123456789
+          reply_broadcast: false
+          message: Served \${{ env.NUMBER }} items
+          blocks:
+            - type: section
+              text:
+                type: mrkdwn
+                text: "Served \${{ env.NUMBER }} items on: \${{ env.DETAILS }}"
+            - type: divider
+            - type: section
+              block_id: selector
+              text:
+                type: mrkdwn
+                text: Send feedback
+              accessory:
+                action_id: response
+                type: multi_static_select
+                placeholder:
+                  type: plain_text
+                  text: Select URL
+                options:
+                - text:
+                    type: plain_text
+                    text: "\${{ github.apiUrl }}"
+                  value: api
+                - text:
+                    type: plain_text
+                    text: "\${{ github.serverUrl }}"
+                  value: server
+                - text:
+                    type: plain_text
+                    text: "\${{ github.graphqlUrl }}"
+                  value: graphql
+        `);
       mocks.core.getBooleanInput.withArgs("payload-templated").returns(true);
+      process.env.DETAILS = `
+-fri
+-sat
+-sun`;
       process.env.NUMBER = 12;
       const config = new Config(mocks.core);
+      process.env.DETAILS = undefined;
       process.env.NUMBER = undefined;
       const expected = {
-        message: "Served 12 from https://api.github.com",
+        channel: "C0123456789",
+        reply_broadcast: false,
+        message: "Served 12 items",
+        blocks: [
+          {
+            type: "section",
+            text: {
+              type: "mrkdwn",
+              text: "Served 12 items on: \n-fri\n-sat\n-sun",
+            },
+          },
+          {
+            type: "divider",
+          },
+          {
+            type: "section",
+            block_id: "selector",
+            text: {
+              type: "mrkdwn",
+              text: "Send feedback",
+            },
+            accessory: {
+              action_id: "response",
+              type: "multi_static_select",
+              placeholder: {
+                type: "plain_text",
+                text: "Select URL",
+              },
+              options: [
+                {
+                  text: {
+                    type: "plain_text",
+                    text: "https://api.github.com",
+                  },
+                  value: "api",
+                },
+                {
+                  text: {
+                    type: "plain_text",
+                    text: "https://github.com",
+                  },
+                  value: "server",
+                },
+                {
+                  text: {
+                    type: "plain_text",
+                    text: "https://api.github.com/graphql",
+                  },
+                  value: "graphql",
+                },
+              ],
+            },
+          },
+        ],
       };
       assert.deepEqual(config.content.values, expected);
     });
@@ -252,19 +356,147 @@ describe("content", () => {
       assert.deepEqual(config.content.values, expected);
     });
 
+    it("templatizes variables requires configuration", async () => {
+      mocks.core.getInput.withArgs("payload-file-path").returns("example.json");
+      mocks.fs.readFileSync
+        .withArgs(path.resolve("example.json"), "utf-8")
+        .returns(`{
+          "message": "this matches an existing variable: \${{ github.apiUrl }}",
+          "channel": "C0123456789"
+        }
+      `);
+      const config = new Config(mocks.core);
+      const expected = {
+        message: "this matches an existing variable: ${{ github.apiUrl }}",
+        channel: "C0123456789",
+      };
+      assert.deepEqual(config.content.values, expected);
+    });
+
     it("templatizes variables with matching variables", async () => {
       mocks.core.getInput.withArgs("payload-file-path").returns("example.json");
       mocks.fs.readFileSync
         .withArgs(path.resolve("example.json"), "utf-8")
         .returns(`{
-            "message": "Served $\{\{ env.NUMBER }} from $\{\{ github.apiUrl }}"
+            "channel": "C0123456789",
+            "reply_broadcast": false,
+            "message": "Served \${{ env.NUMBER }} items",
+            "blocks": [
+              {
+                "type": "section",
+                "text": {
+                  "type": "mrkdwn",
+                  "text": "Served \${{ env.NUMBER }} items on: \${{ env.DETAILS }}"
+                }
+              },
+              {
+                "type": "divider"
+              },
+              {
+                "type": "section",
+                "block_id": "selector",
+                "text": {
+                  "type": "mrkdwn",
+                  "text": "Send feedback"
+                },
+                "accessory": {
+                  "action_id": "response",
+                  "type": "multi_static_select",
+                  "placeholder": {
+                    "type": "plain_text",
+                    "text": "Select URL"
+                  },
+                  "options": [
+                    {
+                      "text": {
+                        "type": "plain_text",
+                        "text": "\${{ github.apiUrl }}"
+                      },
+                      "value": "api"
+                    },
+                    {
+                      "text": {
+                        "type": "plain_text",
+                        "text": "\${{ github.serverUrl }}"
+                      },
+                      "value": "server"
+                    },
+                    {
+                      "text": {
+                        "type": "plain_text",
+                        "text": "\${{ github.graphqlUrl }}"
+                      },
+                      "value": "graphql"
+                    }
+                  ]
+                }
+              }
+            ]
           }`);
       mocks.core.getBooleanInput.withArgs("payload-templated").returns(true);
+      process.env.DETAILS = `
+-fri
+-sat
+-sun`;
       process.env.NUMBER = 12;
       const config = new Config(mocks.core);
+      process.env.DETAILS = undefined;
       process.env.NUMBER = undefined;
       const expected = {
-        message: "Served 12 from https://api.github.com",
+        channel: "C0123456789",
+        reply_broadcast: false,
+        message: "Served 12 items",
+        blocks: [
+          {
+            type: "section",
+            text: {
+              type: "mrkdwn",
+              text: "Served 12 items on: \n-fri\n-sat\n-sun",
+            },
+          },
+          {
+            type: "divider",
+          },
+          {
+            type: "section",
+            block_id: "selector",
+            text: {
+              type: "mrkdwn",
+              text: "Send feedback",
+            },
+            accessory: {
+              action_id: "response",
+              type: "multi_static_select",
+              placeholder: {
+                type: "plain_text",
+                text: "Select URL",
+              },
+              options: [
+                {
+                  text: {
+                    type: "plain_text",
+                    text: "https://api.github.com",
+                  },
+                  value: "api",
+                },
+                {
+                  text: {
+                    type: "plain_text",
+                    text: "https://github.com",
+                  },
+                  value: "server",
+                },
+                {
+                  text: {
+                    type: "plain_text",
+                    text: "https://api.github.com/graphql",
+                  },
+                  value: "graphql",
+                },
+              ],
+            },
+          },
+        ],
       };
       assert.deepEqual(config.content.values, expected);
     });