Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist/
lib/
node_modules/
node_modules/
src/__tests__/
12 changes: 6 additions & 6 deletions dist/main/index.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opsless/ms-teams-github-actions",
"version": "2.0.0",
"version": "2.0.1",
"private": true,
"description": "MS Teams Github Actions integration",
"main": "lib/main.js",
Expand Down
197 changes: 197 additions & 0 deletions src/__tests__/card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {buildWebhookBody, CardData} from '../card'

const baseData: CardData = {
repository: {
name: 'opsless/ms-teams-github-actions',
html_url: 'https://github.com/org/repo'
},
commit: {
message: 'fix: baseline commit message',
html_url: 'https://github.com/org/repo/commit/deadbeef'
},
workflow: {
name: 'CI',
conclusion: 'SUCCEEDED',
conclusion_color: 'good',
run_number: 42,
run_html_url: 'https://github.com/org/repo/actions/runs/42'
},
event: {
type: 'Branch',
html_url: 'https://github.com/org/repo/tree/main'
},
author: {
username: 'tester',
html_url: 'https://github.com/tester',
avatar_url: 'https://github.com/tester.png'
}
}

function commitFactValue(body: unknown): string {
const attachment = (
body as {
attachments: Array<{content: {body: Array<{facts?: Array<{value: string}>}>}}>
}
).attachments[0]
const factSet = attachment.content.body.find(b => b.facts !== undefined)
if (!factSet || !factSet.facts) {
throw new Error('FactSet not found in card')
}
return factSet.facts[0].value
}

function workflowTitleText(body: unknown): string {
const text = (
body as {
attachments: Array<{content: {body: Array<{text: string}>}}>
}
).attachments[0].content.body[0].text
return text
}

describe('buildWebhookBody', () => {
describe('payload envelope', () => {
it('wraps the card in the Teams message envelope', () => {
const body = buildWebhookBody(baseData)
expect(body.type).toBe('message')
expect(body.attachments).toHaveLength(1)
expect(body.attachments[0].contentType).toBe(
'application/vnd.microsoft.card.adaptive'
)
})

it('returns the card content as a plain object, not a JSON string', () => {
const body = buildWebhookBody(baseData)
expect(typeof body.attachments[0].content).toBe('object')
expect(body.attachments[0].content).not.toBeInstanceOf(String)
})

it('builds an Adaptive Card v1.4', () => {
const content = bodyContent(buildWebhookBody(baseData))
expect(content.type).toBe('AdaptiveCard')
expect(content.version).toBe('1.4')
})
})

// Regression coverage for the JSON injection bug fixed in PR #121.
// The original code did `JSON.stringify(template) -> expand -> JSON.parse`,
// which corrupted the JSON whenever a value contained a `"` or `\`. These
// tests pin the contract that the webhook body is always serialisable.
describe('JSON safety with commit messages containing special characters', () => {
const cases: Array<{name: string; message: string}> = [
{name: 'double quotes (original bug)', message: 'build: fix "quoted" task'},
{name: 'backslashes', message: 'fix: path\\with\\backslashes'},
{name: 'trailing backslash', message: 'fix: trailing backslash \\'},
{
name: 'forward and backslashes mixed',
message: 'fix: forward/slash and back\\slash'
},
{name: 'tab character', message: 'chore: tabbed\tmessage'},
{name: 'carriage return', message: 'chore: carriage\rreturn'},
{name: 'newline (first line kept by caller)', message: 'feat: subject\nbody'},
{name: 'single quotes', message: "feat: 'single' quotes"},
{name: 'square and angle brackets', message: 'fix: <script> [BRACKET]'},
{name: 'emoji', message: 'feat: ship it 🚀'},
{name: 'non-ASCII (CJK)', message: 'feat: 日本語 中文 한국어'},
{name: 'JSON-like braces', message: 'fix: {"json": "looking"} thing'},
{name: 'literal template expression', message: 'fix: ${1+1} not evaluated'},
{
name: 'JSONata-style binding',
message: 'fix: ${$root.commit.message} not evaluated'
},
{name: 'empty string', message: ''},
{name: 'plain ASCII baseline', message: 'fix: nothing fancy here'}
]

cases.forEach(({name, message}) => {
it(`produces JSON-roundtrippable body when commit message has ${name}`, () => {
const body = buildWebhookBody({
...baseData,
commit: {...baseData.commit, message}
})

const serialised = JSON.stringify(body)
expect(() => JSON.parse(serialised)).not.toThrow()

const parsed = JSON.parse(serialised)
const firstLine = message.split('\n')[0]
if (firstLine.length > 0) {
expect(commitFactValue(parsed)).toContain(firstLine)
}
})
})

it('does NOT evaluate ${...} expressions in the commit message', () => {
const body = buildWebhookBody({
...baseData,
commit: {
...baseData.commit,
message: 'test: ${1+1} should not become 2'
}
})
const serialised = JSON.stringify(body)
expect(serialised).toContain('${1+1}')
expect(serialised).toContain('${1+1} should not become 2')
expect(serialised).not.toContain('"test: 2 should not become 2"')
})

it('embeds the commit message inside the Commit fact markdown link', () => {
const body = buildWebhookBody({
...baseData,
commit: {
...baseData.commit,
message: 'build: fix "quoted" task'
}
})
expect(commitFactValue(body)).toBe(
'[build: fix "quoted" task](https://github.com/org/repo/commit/deadbeef)'
)
})
})

describe('JSON safety with workflow.name containing special characters', () => {
const names = [
'My "quoted" workflow',
'workflow\\with\\backslash',
'CI/CD <production>',
'日本語 CI'
]

names.forEach(name => {
it(`produces JSON-roundtrippable body for workflow name: ${JSON.stringify(name)}`, () => {
const body = buildWebhookBody({
...baseData,
workflow: {...baseData.workflow, name}
})
const serialised = JSON.stringify(body)
expect(() => JSON.parse(serialised)).not.toThrow()
expect(workflowTitleText(JSON.parse(serialised))).toContain(name)
})
})
})

describe('JSON safety with repository.name containing special characters', () => {
const names = [
'org/"quoted"/repo',
'fork\\slash',
'組織/リポジトリ'
]

names.forEach(name => {
it(`produces JSON-roundtrippable body for repository name: ${JSON.stringify(name)}`, () => {
const body = buildWebhookBody({
...baseData,
repository: {...baseData.repository, name}
})
const serialised = JSON.stringify(body)
expect(() => JSON.parse(serialised)).not.toThrow()
})
})
})
})

function bodyContent(body: unknown): {type: string; version: string} {
return (
body as {attachments: Array<{content: {type: string; version: string}}>}
).attachments[0].content
}
129 changes: 129 additions & 0 deletions src/card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {Template} from 'adaptivecards-templating'

const templateData = {
type: 'AdaptiveCard',
body: [
{
type: 'TextBlock',
size: 'large',
weight: 'bolder',
text: "Workflow '${$root.workflow.name}' #${$root.workflow.run_number} ${$root.workflow.conclusion}",
color: '${$root.workflow.conclusion_color}',
fontType: 'Default',
separator: true
},
{
type: 'TextBlock',
text: 'on [${$root.repository.name}](${$root.repository.html_url})',
wrap: true,
spacing: 'None'
},
{
type: 'ColumnSet',
columns: [
{
type: 'Column',
items: [
{
type: 'Image',
style: 'Person',
url: '${$root.author.avatar_url}',
size: 'Medium'
}
],
width: 'auto'
},
{
type: 'Column',
items: [
{
type: 'TextBlock',
weight: 'Bolder',
text: '[${$root.author.username}](${$root.author.html_url})',
wrap: true
}
],
width: 'stretch'
}
],
spacing: 'Medium'
},
{
type: 'FactSet',
facts: [
{
title: 'Commit',
value: '[${$root.commit.message}](${$root.commit.html_url})'
},
{
title: '${$root.event.type}',
value: '[${$root.event.html_url}](${$root.event.html_url})'
},
{
title: 'Workflow run details',
value:
'[${$root.workflow.run_html_url}](${$root.workflow.run_html_url})'
}
],
height: 'stretch',
separator: true,
spacing: 'Medium'
}
],
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.4'
}

export interface CardData {
repository: {
name: string | undefined
html_url: string | undefined
}
commit: {
message: string
html_url: string
}
workflow: {
name: string
conclusion: string
conclusion_color: string
run_number: number
run_html_url: string | undefined
}
event: {
type: string
html_url: string | undefined
}
author: {
username: string | undefined
html_url: string | undefined
avatar_url: string | undefined
}
}

export interface WebhookBody {
type: 'message'
attachments: Array<{
contentType: string
// adaptivecards-templating expands to a plain object; we keep it opaque so
// callers can `JSON.stringify` without us making assumptions about the shape.
content: unknown
}>
}

// Pass the template as an object: stringifying it first would cause raw
// textual substitution inside a JSON string and produce invalid JSON when
// a value (e.g. a commit message) contains a `"` or `\`.
export function buildWebhookBody(data: CardData): WebhookBody {
const template = new Template(templateData)
const content = template.expand({$root: data})
return {
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
content
}
]
}
}
Loading