Skip to content
Open
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
46 changes: 46 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Docker Publish

on:
push:
branches: [ "main", "feature/xmpp-trigger", "infra/docker-publish" ]
tags: [ 'v*.*.*' ]

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'

- name: Build UI
run: |
cd ui
npm install
npm run build

- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ RUN apk add --no-cache tzdata openssl curl git jq bash
# Dependencies stage (Build)
FROM base AS build

# Copy app package.json
# Copy app package.json and patches (needed by postinstall patch-package)
COPY app/package* ./
COPY app/patches ./patches

# Install dependencies (including dev)
RUN npm ci --include=dev --omit=optional --no-audit --no-fund --no-update-notifier
Expand Down
9,740 changes: 5,267 additions & 4,473 deletions app/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "What'up Docker? the app",
"main": "dist/index.js",
"scripts": {
"postinstall": "patch-package",
"build": "tsc",
"start": "nodemon --exec ts-node index.ts | bunyan -L -o short",
"doc": ".docsify serve ./docs",
Expand All @@ -17,6 +18,7 @@
"license": "MIT",
"dependencies": {
"@slack/web-api": "7.12.0",
"@xmpp/client": "^0.13.1",
"aws-sdk": "2.1692.0",
"axios": "1.13.2",
"body-parser": "1.20.3",
Expand Down Expand Up @@ -79,6 +81,7 @@
"eslint-plugin-prettier": "5.5.4",
"jest": "29.7.0",
"nodemon": "3.1.10",
"patch-package": "^8.0.1",
"prettier": "3.6.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
Expand Down
55 changes: 55 additions & 0 deletions app/patches/@xmpp+sasl+0.13.6.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
diff --git a/node_modules/@xmpp/sasl/index.js b/node_modules/@xmpp/sasl/index.js
index 3407742..552d645 100644
--- a/node_modules/@xmpp/sasl/index.js
+++ b/node_modules/@xmpp/sasl/index.js
@@ -42,14 +42,17 @@ async function authenticate(SASL, entity, mechname, credentials) {

if (element.name === "challenge") {
mech.challenge(decode(element.text()));
- const resp = mech.response(creds);
- entity.send(
- xml(
- "response",
- { xmlns: NS, mechanism: mech.name },
- typeof resp === "string" ? encode(resp) : "",
- ),
- );
+ // FIX: mech.response() may return a Promise (e.g., SCRAM-SHA-1).
+ // Use Promise.resolve() to handle both sync and async responses.
+ Promise.resolve(mech.response(creds)).then((resp) => {
+ entity.send(
+ xml(
+ "response",
+ { xmlns: NS, mechanism: mech.name },
+ typeof resp === "string" ? encode(resp) : "",
+ ),
+ );
+ }, reject);
return;
}

@@ -65,13 +68,17 @@ async function authenticate(SASL, entity, mechname, credentials) {
entity.on("nonza", handler);

if (mech.clientFirst) {
- entity.send(
- xml(
- "auth",
- { xmlns: NS, mechanism: mech.name },
- encode(mech.response(creds)),
- ),
- );
+ // FIX: mech.response() may return a Promise (e.g., SCRAM-SHA-1).
+ // Use Promise.resolve() to handle both sync and async responses.
+ Promise.resolve(mech.response(creds)).then((resp) => {
+ entity.send(
+ xml(
+ "auth",
+ { xmlns: NS, mechanism: mech.name },
+ encode(resp),
+ ),
+ );
+ }, reject);
}
});
}
165 changes: 165 additions & 0 deletions app/triggers/providers/xmpp/Xmpp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @ts-nocheck
import { ValidationError } from 'joi';
import Xmpp from './Xmpp';
import bunyan from 'bunyan';
import { client, xml } from '@xmpp/client';

jest.mock('@xmpp/client', () => {
const mockSend = jest.fn().mockResolvedValue(undefined);
const mockStop = jest.fn().mockResolvedValue(undefined);
const mockStart = jest.fn().mockResolvedValue(undefined);
const mockOn = jest.fn((event, cb) => {
if (event === 'online') {
cb({ toString: () => 'user@domain.com' });
}
});

return {
client: jest.fn(() => ({
on: mockOn,
start: mockStart,
stop: mockStop,
send: mockSend
})),
xml: jest.fn((name, attrs, ...children) => ({ name, attrs, children }))
};
});

const loggerBuffer = new bunyan.RingBuffer({ limit: 5 });
const log = bunyan.createLogger({
name: 'Xmpp.Tests',
streams: [{ stream: loggerBuffer }],
});

const xmpp = new Xmpp();
xmpp.log = log;

beforeEach(() => {
loggerBuffer.records = [];
jest.clearAllMocks();
});

const configurationValid = {
service: 'xmpps://chat.example.com:5223',
domain: 'example.com',
user: 'user',
password: 'password123',
to: 'friend@example.com',
threshold: 'all',
mode: 'simple',
once: true,
auto: true,
simpletitle:
'New ${container.updateKind.kind} found for container ${container.name}',
simplebody:
'Container ${container.name} running with ${container.updateKind.kind} ${container.updateKind.localValue} can be updated to ${container.updateKind.kind} ${container.updateKind.remoteValue}${container.result && container.result.link ? "\\n" + container.result.link : ""}',
batchtitle: '${containers.length} updates available',
};

test('validateConfiguration should return validated configuration when valid', () => {
const validatedConfiguration = xmpp.validateConfiguration(configurationValid);
expect(validatedConfiguration).toStrictEqual(configurationValid);
});

test('validateConfiguration should throw error when invalid service', () => {
const configuration = {
...configurationValid,
service: 'http://chat.example.com',
};
expect(() => {
xmpp.validateConfiguration(configuration);
}).toThrow(ValidationError);
});

test('maskConfiguration should mask sensitive data', () => {
xmpp.configuration = {
...configurationValid,
};
expect(xmpp.maskConfiguration()).toEqual({
...configurationValid,
password: 'p*********3',
});
});

test('trigger should send xmpp message as expected', async () => {
xmpp.configuration = configurationValid;
await xmpp.trigger({
id: '31a61a8305ef1fc9a71fa4f20a68d7ec88b28e32303bbc4a5f192e851165b816',
name: 'homeassistant',
watcher: 'local',
includeTags: '^\\d+\\.\\d+.\\d+$',
image: {
id: 'sha256:d4a6fafb7d4da37495e5c9be3242590be24a87d7edcc4f79761098889c54fca6',
registry: {
url: '123456789.dkr.ecr.eu-west-1.amazonaws.com',
},
name: 'test',
tag: {
value: '2021.6.4',
semver: true,
},
},
result: {
link: 'https://test-2.0.0/changelog',
},
updateKind: {
kind: 'tag',
localValue: '1.0.0',
remoteValue: '2.0.0',
},
});

expect(client).toHaveBeenCalledWith({
service: configurationValid.service,
domain: configurationValid.domain,
username: configurationValid.user,
password: configurationValid.password,
});

// Verify the body xml element was constructed
expect(xml).toHaveBeenCalledWith('body', {}, expect.stringContaining('homeassistant'));

// Verify the message xml element was constructed with correct type and recipient
expect(xml).toHaveBeenCalledWith(
'message',
{ type: 'chat', to: configurationValid.to },
expect.anything(),
);

// Verify the message was actually sent
const mockClientInstance = (client as jest.Mock).mock.results[0].value;
expect(mockClientInstance.send).toHaveBeenCalled();
});

test('triggerBatch should send xmpp message as expected', async () => {
xmpp.configuration = configurationValid;
await xmpp.triggerBatch([
{
id: '31a61a8305ef1fc9a71fa4f20a68d7ec88b28e32303bbc4a5f192e851165b816',
name: 'homeassistant',
updateKind: {
kind: 'tag',
localValue: '1.0.0',
remoteValue: '2.0.0',
},
},
]);

expect(client).toHaveBeenCalledWith({
service: configurationValid.service,
domain: configurationValid.domain,
username: configurationValid.user,
password: configurationValid.password,
});

// Verify the message xml element was constructed with correct type and recipient
expect(xml).toHaveBeenCalledWith(
'message',
{ type: 'chat', to: configurationValid.to },
expect.anything(),
);

// Verify the message was actually sent
const mockClientInstance = (client as jest.Mock).mock.results[0].value;
expect(mockClientInstance.send).toHaveBeenCalled();
});
Loading