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
9 changes: 7 additions & 2 deletions .github/workflows/ci-test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,14 @@ jobs:
# the deprecation logger log without throwing. Other suites use the
# docker-compose default of TEST_MODE='true'.
TEST_MODE: ${{ startsWith(inputs.type, 'api') && 'api' || 'true' }}
TEST_TYPE: ${{ inputs.type }}
run: |
# when we are testing CE, we only need to start the rocketchat container
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d rocketchat --wait
# when we are testing CE, we only need to start the rocketchat container (plus mock-server for the api suite)
services=(rocketchat)
if [ "$TEST_TYPE" == "api" ]; then
services+=(mock-server)
fi
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d "${services[@]}" --wait

- name: Start containers for EE
if: inputs.release == 'ee'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ providers.registerProvider({
endPoint: 'https://www.loom.com/v1/oembed?format=json',
});

if (process.env.TEST_MODE) {
// resolves the oembed payload from the CI mock-server instead of a real provider — see the oembed suite in tests/end-to-end/api/chat.ts
providers.registerProvider({
urls: [new RegExp('https?://mock-server\\.dev(:\\d+)?/video/\\S+')],
endPoint: 'http://mock-server.dev:8080/oembed',
});
}

export const beforeGetUrlContent = (data: {
urlObj: URL;
}): {
Expand Down
191 changes: 176 additions & 15 deletions apps/meteor/tests/end-to-end/api/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { sleep } from '../../../lib/utils/sleep';
import { getCredentials, api, request, credentials, apiUrl } from '../../data/api-data';
import { followMessage, sendSimpleMessage, deleteMessage } from '../../data/chat.helper';
import { imgURL } from '../../data/interactions';
import { mockServerHealthy, mockServerReset, mockServerSet } from '../../data/mock-server.helper';
import { updatePermission, updateSetting } from '../../data/permissions.helper';
import { addUserToRoom, createRoom, deleteRoom, getSubscriptionByRoomId } from '../../data/rooms.helper';
import { password } from '../../data/user';
Expand Down Expand Up @@ -1262,14 +1263,61 @@ describe('[Chat]', () => {
});

describe('oembed', () => {
// the TEST_MODE oembed provider (server/services/messages/lib/oembed/providers.ts) resolves /video/ URLs through GET /oembed on the mock-server
const mockVideoUrl = (id: string) => `http://mock-server.dev:8080/video/${id}`;
const mockPageUrl = (id: string) => `http://mock-server.dev:8080/page/${id}`;
const mockOembedEndpoint = { method: 'GET', path: '/oembed' };
// the whole oembed pipeline runs against the local mock-server, so a fixed wait suffices — exceeding it means a CI perf problem worth surfacing
const oembedProcessingMs = 500;

const youtubeOembedResponse = {
title: 'Rocket.Chat Demo',
author_name: 'Rocket.Chat',
author_url: 'https://www.youtube.com/@RocketChatApp',
type: 'video',
height: 150,
width: 200,
version: '1.0',
provider_name: 'YouTube',
provider_url: 'https://www.youtube.com/',
thumbnail_height: 360,
thumbnail_width: 480,
thumbnail_url: 'https://i.ytimg.com/vi/T2v29gK8fP4/hqdefault.jpg',
html: '<iframe width="200" height="150" src="https://www.youtube.com/embed/T2v29gK8fP4?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen title="Rocket.Chat Demo"></iframe>',
};

const spotifyOembedResponse = {
html: '<iframe style="border-radius: 12px" width="100%" height="152" title="Spotify Embed: Never Gonna Give You Up" frameborder="0" allowfullscreen allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy" src="https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT?utm_source=oembed"></iframe>',
iframe_url: 'https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT?utm_source=oembed',
width: 456,
height: 152,
version: '1.0',
provider_name: 'Spotify',
provider_url: 'https://spotify.com',
type: 'rich',
title: 'Never Gonna Give You Up',
thumbnail_url: 'https://image-cdn-ak.spotifycdn.com/image/ab67616d00001e0215ebbedaacef61af244262a8',
thumbnail_width: 300,
thumbnail_height: 300,
};

let ytEmbedMsgId: IMessage['_id'];
let imgUrlMsgId: IMessage['_id'];

before(async () => {
const healthy = await mockServerHealthy();
expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true;
await mockServerReset();
});

// programmed responses queue per key and an infinite (times: 0) entry never expires, so each test starts from a clean mock
afterEach(() => mockServerReset());

before(() =>
Promise.all([
updateSetting('API_EmbedIgnoredHosts', ''),
updateSetting('API_EmbedSafePorts', '80, 443, 3000'),
updateSetting('SSRF_Allowlist', '127.0.0.1:3000'),
updateSetting('API_EmbedSafePorts', '80, 443, 3000, 8080'),
updateSetting('SSRF_Allowlist', '127.0.0.1:3000, mock-server.dev'),
]),
);

Expand All @@ -1282,10 +1330,11 @@ describe('[Chat]', () => {
);

before(async () => {
await mockServerSet(mockOembedEndpoint.method, mockOembedEndpoint.path, youtubeOembedResponse);
const ytEmbedMsgPayload = {
_id: `id-${Date.now()}`,
rid: testChannel._id,
msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4',
msg: mockVideoUrl('embed-max-width'),
emoji: ':smirk:',
};
const ytPostResponse = await request.post(api('chat.sendMessage')).set(credentials).send({ message: ytEmbedMsgPayload });
Expand Down Expand Up @@ -1360,7 +1409,7 @@ describe('[Chat]', () => {
.send({
message: {
rid: testChannel._id,
msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4',
msg: mockVideoUrl('empty-preview-urls'),
},
previewUrls: [],
})
Expand Down Expand Up @@ -1389,16 +1438,19 @@ describe('[Chat]', () => {
});

it('should generate previews of chosen URL when the previewUrls array is provided', async () => {
const chosenUrl = mockPageUrl('chosen-preview');
await mockServerSet('GET', '/page/chosen-preview', '<html><head><title>Chosen Preview Page</title></head><body></body></html>');

let msgId;
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: testChannel._id,
msg: 'https://www.youtube.com/watch?v=T2v29gK8fP4 https://www.rocket.chat/',
msg: `${mockVideoUrl('not-chosen-preview')} ${chosenUrl}`,
},
previewUrls: ['https://www.rocket.chat/'],
previewUrls: [chosenUrl],
})
.expect('Content-Type', 'application/json')
.expect(200)
Expand All @@ -1410,8 +1462,7 @@ describe('[Chat]', () => {
msgId = res.body.message._id;
});

// process is async now so wait for a sec
await sleep(1000);
await sleep(oembedProcessingMs);

await request
.get(api('chat.getMessage'))
Expand All @@ -1425,19 +1476,19 @@ describe('[Chat]', () => {
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(2);

expect(res.body.message.urls[0]).to.have.property('meta').that.is.an('object').that.is.empty;
expect(res.body.message.urls[1]).to.have.property('meta').that.is.an('object').that.is.not.empty;
expect(res.body.message.urls[1]).to.have.property('meta').to.have.property('pageTitle', 'Chosen Preview Page');
});
});

it('should not generate previews if the message contains more than five external URL', async () => {
let msgId;
const urls = [
'https://www.youtube.com/watch?v=no050HN4ojo',
'https://www.youtube.com/watch?v=9iaSd13mqXA',
'https://www.youtube.com/watch?v=MW_qsbgt1KQ',
'https://www.youtube.com/watch?v=hLF1XwH5rd4',
'https://www.youtube.com/watch?v=Eo-F9hRBbTk',
'https://www.youtube.com/watch?v=08ms3W7adFI',
mockVideoUrl('over-limit-1'),
mockVideoUrl('over-limit-2'),
mockVideoUrl('over-limit-3'),
mockVideoUrl('over-limit-4'),
mockVideoUrl('over-limit-5'),
mockVideoUrl('over-limit-6'),
];
await request
.post(api('chat.sendMessage'))
Expand Down Expand Up @@ -1474,6 +1525,116 @@ describe('[Chat]', () => {
});
});
});

it('should embed the metadata of a rich oembed provider response', async () => {
await mockServerSet(mockOembedEndpoint.method, mockOembedEndpoint.path, spotifyOembedResponse);
const msg = mockVideoUrl('rich-provider');

let msgId;
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({ message: { rid: testChannel._id, msg } })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
msgId = res.body.message._id;
});

await sleep(oembedProcessingMs);

await request
.get(api('chat.getMessage'))
.set(credentials)
.query({
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(1);

const { meta } = res.body.message.urls[0];
expect(meta).to.have.property('oembedUrl', msg);
expect(meta).to.have.property('oembedTitle', 'Never Gonna Give You Up');
expect(meta).to.have.property('oembedType', 'rich');
expect(meta).to.have.property('oembedProviderName', 'Spotify');
expect(meta).to.have.property('oembedThumbnailUrl', spotifyOembedResponse.thumbnail_url);
expect(meta).to.have.property('oembedHtml').to.have.string('<iframe style="max-width: 100%;width:400px;height:225px"');
expect(meta, 'non-string oembed values must be dropped').to.not.have.any.keys(
'oembedWidth',
'oembedHeight',
'oembedThumbnailWidth',
'oembedThumbnailHeight',
);
});
});

it('should embed only the oembed source url when the provider response has no string values', async () => {
await mockServerSet(mockOembedEndpoint.method, mockOembedEndpoint.path, { status: 404, ok: false });
const msg = mockVideoUrl('no-string-values');

let msgId;
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({ message: { rid: testChannel._id, msg } })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
msgId = res.body.message._id;
});

await sleep(oembedProcessingMs);

await request
.get(api('chat.getMessage'))
.set(credentials)
.query({
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(1);

expect(res.body.message.urls[0]).to.have.property('meta').that.deep.equals({ oembedUrl: msg });
});
});

it('should not embed metadata when the oembed provider responds with an error', async () => {
await mockServerSet(mockOembedEndpoint.method, mockOembedEndpoint.path, {}, 500);

let msgId;
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({ message: { rid: testChannel._id, msg: mockVideoUrl('error-response') } })
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
msgId = res.body.message._id;
});

await sleep(oembedProcessingMs);

await request
.get(api('chat.getMessage'))
.set(credentials)
.query({
msgId,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('message').to.have.property('urls').to.be.an('array').that.has.lengthOf(1);

expect(res.body.message.urls[0]).to.have.property('meta').that.is.an('object').that.is.empty;
});
});
});

describe('Read only channel', () => {
Expand Down
6 changes: 6 additions & 0 deletions docker-compose-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ services:
- api
ports:
- 4000:8080
networks:
default:
# single-label hostnames fail server-fetch SSRF domain validation and message-parser only links TLDs
# from its whitelist (.local/.test/.internal are not in it), so oembed tests reach it through this alias
aliases:
- mock-server.dev
healthcheck:
test: ['CMD', '/mock-server', '-healthcheck']
interval: 2s
Expand Down
Loading