]>
Commit | Line | Data |
---|---|---|
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | |
2 | ||
3 | import * as chai from 'chai' | |
4 | import { XMLParser, XMLValidator } from 'fast-xml-parser' | |
5 | import { HttpStatusCode, VideoPrivacy } from '@shared/models' | |
6 | import { | |
7 | cleanupTests, | |
8 | createMultipleServers, | |
9 | createSingleServer, | |
10 | doubleFollow, | |
11 | makeGetRequest, | |
12 | PeerTubeServer, | |
13 | setAccessTokensToServers, | |
14 | setDefaultChannelAvatar, | |
15 | stopFfmpeg, | |
16 | waitJobs | |
17 | } from '@shared/server-commands' | |
18 | ||
19 | chai.use(require('chai-xml')) | |
20 | chai.use(require('chai-json-schema')) | |
21 | chai.config.includeStack = true | |
22 | ||
23 | describe('Test syndication feeds', () => { | |
24 | let servers: PeerTubeServer[] = [] | |
25 | let serverHLSOnly: PeerTubeServer | |
26 | let userAccessToken: string | |
27 | let rootAccountId: number | |
28 | let rootChannelId: number | |
29 | let userAccountId: number | |
30 | let userChannelId: number | |
31 | let userFeedToken: string | |
32 | let liveId: string | |
33 | ||
34 | before(async function () { | |
35 | this.timeout(120000) | |
36 | ||
37 | // Run servers | |
38 | servers = await createMultipleServers(2) | |
39 | serverHLSOnly = await createSingleServer(3, { | |
40 | transcoding: { | |
41 | enabled: true, | |
42 | webtorrent: { enabled: false }, | |
43 | hls: { enabled: true } | |
44 | } | |
45 | }) | |
46 | ||
47 | await setAccessTokensToServers([ ...servers, serverHLSOnly ]) | |
48 | await setDefaultChannelAvatar(servers[0]) | |
49 | await doubleFollow(servers[0], servers[1]) | |
50 | ||
51 | await servers[0].config.enableLive({ allowReplay: false, transcoding: false }) | |
52 | ||
53 | { | |
54 | const user = await servers[0].users.getMyInfo() | |
55 | rootAccountId = user.account.id | |
56 | rootChannelId = user.videoChannels[0].id | |
57 | } | |
58 | ||
59 | { | |
60 | userAccessToken = await servers[0].users.generateUserAndToken('john') | |
61 | ||
62 | const user = await servers[0].users.getMyInfo({ token: userAccessToken }) | |
63 | userAccountId = user.account.id | |
64 | userChannelId = user.videoChannels[0].id | |
65 | ||
66 | const token = await servers[0].users.getMyScopedTokens({ token: userAccessToken }) | |
67 | userFeedToken = token.feedToken | |
68 | } | |
69 | ||
70 | { | |
71 | await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'user video' } }) | |
72 | } | |
73 | ||
74 | { | |
75 | const attributes = { | |
76 | name: 'my super name for server 1', | |
77 | description: 'my super description for server 1', | |
78 | fixture: 'video_short.webm' | |
79 | } | |
80 | const { id } = await servers[0].videos.upload({ attributes }) | |
81 | ||
82 | await servers[0].comments.createThread({ videoId: id, text: 'super comment 1' }) | |
83 | await servers[0].comments.createThread({ videoId: id, text: 'super comment 2' }) | |
84 | } | |
85 | ||
86 | { | |
87 | const attributes = { name: 'unlisted video', privacy: VideoPrivacy.UNLISTED } | |
88 | const { id } = await servers[0].videos.upload({ attributes }) | |
89 | ||
90 | await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) | |
91 | } | |
92 | ||
93 | await waitJobs(servers) | |
94 | }) | |
95 | ||
96 | describe('All feed', function () { | |
97 | ||
98 | it('Should be well formed XML (covers RSS 2.0 and ATOM 1.0 endpoints)', async function () { | |
99 | for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) { | |
100 | const rss = await servers[0].feed.getXML({ feed, ignoreCache: true }) | |
101 | expect(rss).xml.to.be.valid() | |
102 | ||
103 | const atom = await servers[0].feed.getXML({ feed, format: 'atom', ignoreCache: true }) | |
104 | expect(atom).xml.to.be.valid() | |
105 | } | |
106 | }) | |
107 | ||
108 | it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () { | |
109 | for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) { | |
110 | const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true }) | |
111 | expect(JSON.parse(jsonText)).to.be.jsonSchema({ type: 'object' }) | |
112 | } | |
113 | }) | |
114 | ||
115 | it('Should serve the endpoint with a classic request', async function () { | |
116 | await makeGetRequest({ | |
117 | url: servers[0].url, | |
118 | path: '/feeds/videos.xml', | |
119 | accept: 'application/xml', | |
120 | expectedStatus: HttpStatusCode.OK_200 | |
121 | }) | |
122 | }) | |
123 | ||
124 | it('Should serve the endpoint as a cached request', async function () { | |
125 | const res = await makeGetRequest({ | |
126 | url: servers[0].url, | |
127 | path: '/feeds/videos.xml', | |
128 | accept: 'application/xml', | |
129 | expectedStatus: HttpStatusCode.OK_200 | |
130 | }) | |
131 | ||
132 | expect(res.headers['x-api-cache-cached']).to.equal('true') | |
133 | }) | |
134 | ||
135 | it('Should not serve the endpoint as a cached request', async function () { | |
136 | const res = await makeGetRequest({ | |
137 | url: servers[0].url, | |
138 | path: '/feeds/videos.xml?v=186', | |
139 | accept: 'application/xml', | |
140 | expectedStatus: HttpStatusCode.OK_200 | |
141 | }) | |
142 | ||
143 | expect(res.headers['x-api-cache-cached']).to.not.exist | |
144 | }) | |
145 | ||
146 | it('Should refuse to serve the endpoint without accept header', async function () { | |
147 | await makeGetRequest({ url: servers[0].url, path: '/feeds/videos.xml', expectedStatus: HttpStatusCode.NOT_ACCEPTABLE_406 }) | |
148 | }) | |
149 | }) | |
150 | ||
151 | describe('Videos feed', function () { | |
152 | ||
153 | it('Should contain a valid enclosure (covers RSS 2.0 endpoint)', async function () { | |
154 | for (const server of servers) { | |
155 | const rss = await server.feed.getXML({ feed: 'videos', ignoreCache: true }) | |
156 | expect(XMLValidator.validate(rss)).to.be.true | |
157 | ||
158 | const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false }) | |
159 | const xmlDoc = parser.parse(rss) | |
160 | ||
161 | const enclosure = xmlDoc.rss.channel.item[0].enclosure | |
162 | expect(enclosure).to.exist | |
163 | ||
164 | expect(enclosure['@_type']).to.equal('video/webm') | |
165 | expect(enclosure['@_length']).to.equal(218910) | |
166 | expect(enclosure['@_url']).to.contain('-720.webm') | |
167 | } | |
168 | }) | |
169 | ||
170 | it('Should contain a valid \'attachments\' object (covers JSON feed 1.0 endpoint)', async function () { | |
171 | for (const server of servers) { | |
172 | const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true }) | |
173 | const jsonObj = JSON.parse(json) | |
174 | expect(jsonObj.items.length).to.be.equal(2) | |
175 | expect(jsonObj.items[0].attachments).to.exist | |
176 | expect(jsonObj.items[0].attachments.length).to.be.eq(1) | |
177 | expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent') | |
178 | expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910) | |
179 | expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent') | |
180 | } | |
181 | }) | |
182 | ||
183 | it('Should filter by account', async function () { | |
184 | { | |
185 | const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true }) | |
186 | const jsonObj = JSON.parse(json) | |
187 | expect(jsonObj.items.length).to.be.equal(1) | |
188 | expect(jsonObj.items[0].title).to.equal('my super name for server 1') | |
189 | expect(jsonObj.items[0].author.name).to.equal('root') | |
190 | } | |
191 | ||
192 | { | |
193 | const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true }) | |
194 | const jsonObj = JSON.parse(json) | |
195 | expect(jsonObj.items.length).to.be.equal(1) | |
196 | expect(jsonObj.items[0].title).to.equal('user video') | |
197 | expect(jsonObj.items[0].author.name).to.equal('john') | |
198 | } | |
199 | ||
200 | for (const server of servers) { | |
201 | { | |
202 | const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true }) | |
203 | const jsonObj = JSON.parse(json) | |
204 | expect(jsonObj.items.length).to.be.equal(1) | |
205 | expect(jsonObj.items[0].title).to.equal('my super name for server 1') | |
206 | } | |
207 | ||
208 | { | |
209 | const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true }) | |
210 | const jsonObj = JSON.parse(json) | |
211 | expect(jsonObj.items.length).to.be.equal(1) | |
212 | expect(jsonObj.items[0].title).to.equal('user video') | |
213 | } | |
214 | } | |
215 | }) | |
216 | ||
217 | it('Should filter by video channel', async function () { | |
218 | { | |
219 | const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true }) | |
220 | const jsonObj = JSON.parse(json) | |
221 | expect(jsonObj.items.length).to.be.equal(1) | |
222 | expect(jsonObj.items[0].title).to.equal('my super name for server 1') | |
223 | expect(jsonObj.items[0].author.name).to.equal('root') | |
224 | } | |
225 | ||
226 | { | |
227 | const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true }) | |
228 | const jsonObj = JSON.parse(json) | |
229 | expect(jsonObj.items.length).to.be.equal(1) | |
230 | expect(jsonObj.items[0].title).to.equal('user video') | |
231 | expect(jsonObj.items[0].author.name).to.equal('john') | |
232 | } | |
233 | ||
234 | for (const server of servers) { | |
235 | { | |
236 | const query = { videoChannelName: 'root_channel@' + servers[0].host } | |
237 | const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true }) | |
238 | const jsonObj = JSON.parse(json) | |
239 | expect(jsonObj.items.length).to.be.equal(1) | |
240 | expect(jsonObj.items[0].title).to.equal('my super name for server 1') | |
241 | } | |
242 | ||
243 | { | |
244 | const query = { videoChannelName: 'john_channel@' + servers[0].host } | |
245 | const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true }) | |
246 | const jsonObj = JSON.parse(json) | |
247 | expect(jsonObj.items.length).to.be.equal(1) | |
248 | expect(jsonObj.items[0].title).to.equal('user video') | |
249 | } | |
250 | } | |
251 | }) | |
252 | ||
253 | it('Should correctly have videos feed with HLS only', async function () { | |
254 | this.timeout(120000) | |
255 | ||
256 | await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) | |
257 | ||
258 | await waitJobs([ serverHLSOnly ]) | |
259 | ||
260 | const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true }) | |
261 | const jsonObj = JSON.parse(json) | |
262 | expect(jsonObj.items.length).to.be.equal(1) | |
263 | expect(jsonObj.items[0].attachments).to.exist | |
264 | expect(jsonObj.items[0].attachments.length).to.be.eq(4) | |
265 | ||
266 | for (let i = 0; i < 4; i++) { | |
267 | expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent') | |
268 | expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0) | |
269 | expect(jsonObj.items[0].attachments[i].url).to.exist | |
270 | } | |
271 | }) | |
272 | ||
273 | it('Should not display waiting live videos', async function () { | |
274 | const { uuid } = await servers[0].live.create({ | |
275 | fields: { | |
276 | name: 'live', | |
277 | privacy: VideoPrivacy.PUBLIC, | |
278 | channelId: rootChannelId | |
279 | } | |
280 | }) | |
281 | liveId = uuid | |
282 | ||
283 | const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true }) | |
284 | ||
285 | const jsonObj = JSON.parse(json) | |
286 | expect(jsonObj.items.length).to.be.equal(2) | |
287 | expect(jsonObj.items[0].title).to.equal('my super name for server 1') | |
288 | expect(jsonObj.items[1].title).to.equal('user video') | |
289 | }) | |
290 | ||
291 | it('Should display published live videos', async function () { | |
292 | this.timeout(120000) | |
293 | ||
294 | const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' }) | |
295 | await servers[0].live.waitUntilPublished({ videoId: liveId }) | |
296 | ||
297 | const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true }) | |
298 | ||
299 | const jsonObj = JSON.parse(json) | |
300 | expect(jsonObj.items.length).to.be.equal(3) | |
301 | expect(jsonObj.items[0].title).to.equal('live') | |
302 | expect(jsonObj.items[1].title).to.equal('my super name for server 1') | |
303 | expect(jsonObj.items[2].title).to.equal('user video') | |
304 | ||
305 | await stopFfmpeg(ffmpeg) | |
306 | }) | |
307 | }) | |
308 | ||
309 | describe('Video comments feed', function () { | |
310 | ||
311 | it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () { | |
312 | for (const server of servers) { | |
313 | const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) | |
314 | ||
315 | const jsonObj = JSON.parse(json) | |
316 | expect(jsonObj.items.length).to.be.equal(2) | |
317 | expect(jsonObj.items[0].content_html).to.contain('<p>super comment 2</p>') | |
318 | expect(jsonObj.items[1].content_html).to.contain('<p>super comment 1</p>') | |
319 | } | |
320 | }) | |
321 | ||
322 | it('Should not list comments from muted accounts or instances', async function () { | |
323 | this.timeout(30000) | |
324 | ||
325 | const remoteHandle = 'root@' + servers[0].host | |
326 | ||
327 | await servers[1].blocklist.addToServerBlocklist({ account: remoteHandle }) | |
328 | ||
329 | { | |
330 | const json = await servers[1].feed.getJSON({ feed: 'video-comments', ignoreCache: true }) | |
331 | const jsonObj = JSON.parse(json) | |
332 | expect(jsonObj.items.length).to.be.equal(0) | |
333 | } | |
334 | ||
335 | await servers[1].blocklist.removeFromServerBlocklist({ account: remoteHandle }) | |
336 | ||
337 | { | |
338 | const videoUUID = (await servers[1].videos.quickUpload({ name: 'server 2' })).uuid | |
339 | await waitJobs(servers) | |
340 | await servers[0].comments.createThread({ videoId: videoUUID, text: 'super comment' }) | |
341 | await waitJobs(servers) | |
342 | ||
343 | const json = await servers[1].feed.getJSON({ feed: 'video-comments', ignoreCache: true }) | |
344 | const jsonObj = JSON.parse(json) | |
345 | expect(jsonObj.items.length).to.be.equal(3) | |
346 | } | |
347 | ||
348 | await servers[1].blocklist.addToMyBlocklist({ account: remoteHandle }) | |
349 | ||
350 | { | |
351 | const json = await servers[1].feed.getJSON({ feed: 'video-comments', ignoreCache: true }) | |
352 | const jsonObj = JSON.parse(json) | |
353 | expect(jsonObj.items.length).to.be.equal(2) | |
354 | } | |
355 | }) | |
356 | }) | |
357 | ||
358 | describe('Video feed from my subscriptions', function () { | |
359 | let feeduserAccountId: number | |
360 | let feeduserFeedToken: string | |
361 | ||
362 | it('Should list no videos for a user with no videos and no subscriptions', async function () { | |
363 | const attr = { username: 'feeduser', password: 'password' } | |
364 | await servers[0].users.create({ username: attr.username, password: attr.password }) | |
365 | const feeduserAccessToken = await servers[0].login.getAccessToken(attr) | |
366 | ||
367 | { | |
368 | const user = await servers[0].users.getMyInfo({ token: feeduserAccessToken }) | |
369 | feeduserAccountId = user.account.id | |
370 | } | |
371 | ||
372 | { | |
373 | const token = await servers[0].users.getMyScopedTokens({ token: feeduserAccessToken }) | |
374 | feeduserFeedToken = token.feedToken | |
375 | } | |
376 | ||
377 | { | |
378 | const body = await servers[0].subscriptions.listVideos({ token: feeduserAccessToken }) | |
379 | expect(body.total).to.equal(0) | |
380 | ||
381 | const query = { accountId: feeduserAccountId, token: feeduserFeedToken } | |
382 | const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) | |
383 | const jsonObj = JSON.parse(json) | |
384 | expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos | |
385 | } | |
386 | }) | |
387 | ||
388 | it('Should fail with an invalid token', async function () { | |
389 | const query = { accountId: feeduserAccountId, token: 'toto' } | |
390 | await servers[0].feed.getJSON({ feed: 'subscriptions', query, expectedStatus: HttpStatusCode.FORBIDDEN_403, ignoreCache: true }) | |
391 | }) | |
392 | ||
393 | it('Should fail with a token of another user', async function () { | |
394 | const query = { accountId: feeduserAccountId, token: userFeedToken } | |
395 | await servers[0].feed.getJSON({ feed: 'subscriptions', query, expectedStatus: HttpStatusCode.FORBIDDEN_403, ignoreCache: true }) | |
396 | }) | |
397 | ||
398 | it('Should list no videos for a user with videos but no subscriptions', async function () { | |
399 | const body = await servers[0].subscriptions.listVideos({ token: userAccessToken }) | |
400 | expect(body.total).to.equal(0) | |
401 | ||
402 | const query = { accountId: userAccountId, token: userFeedToken } | |
403 | const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) | |
404 | const jsonObj = JSON.parse(json) | |
405 | expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos | |
406 | }) | |
407 | ||
408 | it('Should list self videos for a user with a subscription to themselves', async function () { | |
409 | this.timeout(30000) | |
410 | ||
411 | await servers[0].subscriptions.add({ token: userAccessToken, targetUri: 'john_channel@' + servers[0].host }) | |
412 | await waitJobs(servers) | |
413 | ||
414 | { | |
415 | const body = await servers[0].subscriptions.listVideos({ token: userAccessToken }) | |
416 | expect(body.total).to.equal(1) | |
417 | expect(body.data[0].name).to.equal('user video') | |
418 | ||
419 | const query = { accountId: userAccountId, token: userFeedToken } | |
420 | const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) | |
421 | const jsonObj = JSON.parse(json) | |
422 | expect(jsonObj.items.length).to.be.equal(1) // subscribed to self, it should not list the instance's videos but list john's | |
423 | } | |
424 | }) | |
425 | ||
426 | it('Should list videos of a user\'s subscription', async function () { | |
427 | this.timeout(30000) | |
428 | ||
429 | await servers[0].subscriptions.add({ token: userAccessToken, targetUri: 'root_channel@' + servers[0].host }) | |
430 | await waitJobs(servers) | |
431 | ||
432 | { | |
433 | const body = await servers[0].subscriptions.listVideos({ token: userAccessToken }) | |
434 | expect(body.total).to.equal(2, 'there should be 2 videos part of the subscription') | |
435 | ||
436 | const query = { accountId: userAccountId, token: userFeedToken } | |
437 | const json = await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) | |
438 | const jsonObj = JSON.parse(json) | |
439 | expect(jsonObj.items.length).to.be.equal(2) // subscribed to root, it should not list the instance's videos but list root/john's | |
440 | } | |
441 | }) | |
442 | ||
443 | it('Should renew the token, and so have an invalid old token', async function () { | |
444 | await servers[0].users.renewMyScopedTokens({ token: userAccessToken }) | |
445 | ||
446 | const query = { accountId: userAccountId, token: userFeedToken } | |
447 | await servers[0].feed.getJSON({ feed: 'subscriptions', query, expectedStatus: HttpStatusCode.FORBIDDEN_403, ignoreCache: true }) | |
448 | }) | |
449 | ||
450 | it('Should succeed with the new token', async function () { | |
451 | const token = await servers[0].users.getMyScopedTokens({ token: userAccessToken }) | |
452 | userFeedToken = token.feedToken | |
453 | ||
454 | const query = { accountId: userAccountId, token: userFeedToken } | |
455 | await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true }) | |
456 | }) | |
457 | ||
458 | }) | |
459 | ||
460 | after(async function () { | |
461 | await cleanupTests([ ...servers, serverHLSOnly ]) | |
462 | }) | |
463 | }) |