/** * Tests! */ 'use strict'; /* dependencies below */ import fs, { readdirSync } from 'node:fs'; import process from 'node:process'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Agent as httpAgent } from 'node:http'; import { Agent as httpsAgent } from 'node:https'; import { expect, test, describe, beforeEach, afterEach } from '@jest/globals'; import fastify from 'fastify'; import { summaly } from '../src/index.js'; import { StatusError } from '../src/utils/status-error.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); /* settings below */ Error.stackTraceLimit = Infinity; // During the test the env variable is set to test process.env.NODE_ENV = 'test'; process.env.SUMMALY_ALLOW_PRIVATE_IP = 'true'; const port = 3060; const host = `http://localhost:${port}`; // Display detail of unhandled promise rejection process.on('unhandledRejection', console.dir); let app: ReturnType | null = null; afterEach(async () => { if (app) { await app.close(); app = null; } }); /* tests below */ test('basic', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); expect(await summaly(host)).toEqual({ title: 'KISS principle', icon: null, description: null, thumbnail: null, player: { url: null, width: null, height: null, 'allow': [ 'autoplay', 'encrypted-media', 'fullscreen', ], }, sitename: 'localhost:3060', sensitive: false, url: host, activityPub: null, }); }); test('Stage Bye Stage', async () => { // If this test fails, you must rewrite the result data and the example in README.md. const summary = await summaly('https://www.youtube.com/watch?v=NMIEAhH_fTU'); expect(summary).toEqual( { 'title': '【アイドルマスター】「Stage Bye Stage」(歌:島村卯月、渋谷凛、本田未央)', 'icon': 'https://www.youtube.com/s/desktop/4feff1e2/img/favicon.ico', 'description': 'Website▶https://columbia.jp/idolmaster/Playlist▶https://www.youtube.com/playlist?list=PL83A2998CF3BBC86D2018年7月18日発売予定THE IDOLM@STER CINDERELLA GIRLS CG STAR...', 'thumbnail': 'https://i.ytimg.com/vi/NMIEAhH_fTU/maxresdefault.jpg', 'player': { 'url': 'https://www.youtube.com/embed/NMIEAhH_fTU?feature=oembed', 'width': 200, 'height': 113, 'allow': [ 'autoplay', 'clipboard-write', 'encrypted-media', 'picture-in-picture', 'web-share', 'fullscreen', ], }, 'sitename': 'YouTube', 'sensitive': false, 'activityPub': null, 'url': 'https://www.youtube.com/watch?v=NMIEAhH_fTU', }, ); }); test('If a favicon is not specified in the HTML but is present in the root, it will be set correctly.', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/no-favicon.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); app.get('/favicon.ico', (_, reply) => reply.status(200).send()); await app.listen({ port }); const summary = await summaly(host); expect(summary.icon).toBe(`${host}/favicon.ico`); }); test('If the favicon is not specified in the HTML and does not exist in the root, it will be null.', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/no-favicon.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); app.get('*', (_, reply) => reply.status(404).send()); await app.listen({ port }); const summary = await summaly(host); expect(summary.icon).toBe(null); }); test('The title is cleaned up.', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/og-title.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.title).toBe('Strawberry Pasta'); }); describe('Private IP blocking', () => { beforeEach(() => { process.env.SUMMALY_ALLOW_PRIVATE_IP = 'false'; app = fastify(); app.get('*', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/og-title.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); return app.listen({ port }); }); test('I can not get private IP server information', async () => { const summary = await summaly(host).catch((e: StatusError) => e); if (summary instanceof StatusError) { expect(summary.name).toBe('StatusError'); } else { expect(summary).toBeInstanceOf(StatusError); } }); test('Allow private IP if agent is specified', async () => { const summary = await summaly(host, { agent: { http: new httpAgent({ keepAlive: true }), https: new httpsAgent({ keepAlive: true }), }, }); expect(summary.title).toBe('Strawberry Pasta'); }); test('Do not allow private ip if agent is an empty object', async () => { const summary = await summaly(host, { agent: {} }).catch((e: StatusError) => e); if (summary instanceof StatusError) { expect(summary.name).toBe('StatusError'); } else { expect(summary).toBeInstanceOf(StatusError); } }); afterEach(() => { process.env.SUMMALY_ALLOW_PRIVATE_IP = 'true'; }); }); describe('OGP', () => { test('title', async () => { app = fastify(); app.get('*', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/og-title.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.title).toBe('Strawberry Pasta'); }); test('description', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/og-description.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.description).toBe('Strawberry Pasta'); }); test('site_name', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/og-site_name.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.sitename).toBe('Strawberry Pasta'); }); test('thumbnail', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/og-image.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.thumbnail).toBe('https://himasaku.net/himasaku.png'); }); }); describe('TwitterCard', () => { test('title', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/twitter-title.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.title).toBe('Strawberry Pasta'); }); test('description', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/twitter-description.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.description).toBe('Strawberry Pasta'); }); test('thumbnail', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/twitter-image.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.thumbnail).toBe('https://himasaku.net/himasaku.png'); }); test('Player detection - PeerTube:video => video', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/player-peertube-video.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/embedurl'); expect(summary.player.allow).toStrictEqual(['autoplay', 'encrypted-media', 'fullscreen']); }); test('Player detection - Pleroma:video => video', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/player-pleroma-video.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/embedurl'); expect(summary.player.allow).toStrictEqual(['autoplay', 'encrypted-media', 'fullscreen']); }); test('Player detection - Pleroma:image => image', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/player-pleroma-image.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.thumbnail).toBe('https://example.com/imageurl'); }); }); describe('oEmbed', () => { const setUpFastify = async (oEmbedPath: string, htmlPath = 'htmls/oembed.html') => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(new URL(htmlPath, import.meta.url)); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); app.get('/oembed.json', (request, reply) => { const content = fs.readFileSync(new URL(oEmbedPath, new URL('oembed/', import.meta.url))); reply.header('content-length', content.length); reply.header('content-type', 'application/json'); return reply.send(content); }); await app.listen({ port }); }; for (const filename of readdirSync(new URL('oembed/invalid', import.meta.url))) { test(`Invalidity test: ${filename}`, async () => { await setUpFastify(`invalid/${filename}`); const summary = await summaly(host); expect(summary.player.url).toBe(null); }); } test('basic properties', async () => { await setUpFastify('oembed.json'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.player.width).toBe(500); expect(summary.player.height).toBe(300); }); test('type: video', async () => { await setUpFastify('oembed-video.json'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.player.width).toBe(500); expect(summary.player.height).toBe(300); }); test('max height', async () => { await setUpFastify('oembed-too-tall.json'); const summary = await summaly(host); expect(summary.player.height).toBe(1024); }); test('children are ignored', async () => { await setUpFastify('oembed-iframe-child.json'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); }); test('allows fullscreen', async () => { await setUpFastify('oembed-allow-fullscreen.json'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.player.allow).toStrictEqual(['fullscreen']); }); test('allows legacy allowfullscreen', async () => { await setUpFastify('oembed-allow-fullscreen-legacy.json'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.player.allow).toStrictEqual(['fullscreen']); }); test('allows safelisted permissions', async () => { await setUpFastify('oembed-allow-safelisted-permissions.json'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.player.allow).toStrictEqual([ 'autoplay', 'clipboard-write', 'fullscreen', 'encrypted-media', 'picture-in-picture', 'web-share', ]); }); test('ignores rare permissions', async () => { await setUpFastify('oembed-ignore-rare-permissions.json'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.player.allow).toStrictEqual(['autoplay']); }); test('oEmbed with relative path', async () => { await setUpFastify('oembed.json', 'htmls/oembed-relative.html'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); }); test('oEmbed with nonexistent path', async () => { await setUpFastify('oembed.json', 'htmls/oembed-nonexistent-path.html'); const summary = await summaly(host); expect(summary.player.url).toBe(null); expect(summary.description).toBe('nonexistent'); }); test('oEmbed with wrong path', async () => { await setUpFastify('oembed.json', 'htmls/oembed-wrong-path.html'); const summary = await summaly(host); expect(summary.player.url).toBe(null); expect(summary.description).toBe('wrong url'); }); test('oEmbed with OpenGraph', async () => { await setUpFastify('oembed.json', 'htmls/oembed-and-og.html'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.description).toBe('blobcats rule the world'); }); test('Invalid oEmbed with valid OpenGraph', async () => { await setUpFastify('invalid/oembed-insecure.json', 'htmls/oembed-and-og.html'); const summary = await summaly(host); expect(summary.player.url).toBe(null); expect(summary.description).toBe('blobcats rule the world'); }); test('oEmbed with og:video', async () => { await setUpFastify('oembed.json', 'htmls/oembed-and-og-video.html'); const summary = await summaly(host); expect(summary.player.url).toBe('https://example.com/'); expect(summary.player.allow).toStrictEqual([]); }); test('width: 100%', async () => { await setUpFastify('oembed-percentage-width.json'); const summary = await summaly(host); expect(summary.player.width).toBe(null); expect(summary.player.height).toBe(300); }); }); describe('ActivityPub', () => { test('Basic', async () => { app = fastify(); app.get('*', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/activitypub.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.activityPub).toBe('https://misskey.test/notes/abcdefg'); }); test('Null', async () => { app = fastify(); app.get('*', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); const summary = await summaly(host); expect(summary.activityPub).toBe(null); }); }); describe('sensitive', () => { test('default', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); expect((await summaly(host)).sensitive).toBe(false); }); test('mixi:content-rating 1', async () => { app = fastify(); app.get('/', (request, reply) => { const content = fs.readFileSync(_dirname + '/htmls/mixi-sensitive.html'); reply.header('content-length', content.length); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); expect((await summaly(host)).sensitive).toBe(true); }); }); describe('UserAgent', () => { test('The UA settings are reflected.', async () => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); let ua: string | undefined = undefined; app = fastify(); app.get('/', (request, reply) => { reply.header('content-length', content.byteLength); reply.header('content-type', 'text/html'); ua = request.headers['user-agent']; return reply.send(content); }); await app.listen({ port }); await summaly(host, { userAgent: 'test-ua' }); expect(ua).toBe('test-ua'); }); }); describe('content-length limit', () => { test('No errors will occur if the content is within the content-length limit.', async () => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); app = fastify(); app.get('/', (request, reply) => { reply.header('content-length', content.byteLength); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); expect(await summaly(host, { contentLengthLimit: content.byteLength })).toBeDefined(); }); test('If the content-length limit is exceeded, an error will occur.', async () => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); app = fastify(); app.get('/', (request, reply) => { reply.header('content-length', content.byteLength); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); await expect(summaly(host, { contentLengthLimit: content.byteLength - 1 })).rejects.toThrow(); }); }); describe('content-length required', () => { test('[When option is enabled] No error occurs if content-length is returned', async () => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); app = fastify(); app.get('/', (request, reply) => { reply.header('content-length', content.byteLength); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); expect(await summaly(host, { contentLengthRequired: true, contentLengthLimit: content.byteLength })).toBeDefined(); }); test('[When option is enabled] An error occurs if content-length is not returned.', async () => { app = fastify(); app.get('/', (request, reply) => { reply.header('content-type', 'text/html'); // If you don't pass it as stream, the content-length will be set automatically. return reply.send(fs.createReadStream(_dirname + '/htmls/basic.html')); }); await app.listen({ port }); await expect(summaly(host, { contentLengthRequired: true })).rejects.toThrow(); }); test('[When option is disabled] No error occurs if content-length is returned', async () => { const content = fs.readFileSync(_dirname + '/htmls/basic.html'); app = fastify(); app.get('/', (request, reply) => { reply.header('content-length', content.byteLength); reply.header('content-type', 'text/html'); return reply.send(content); }); await app.listen({ port }); expect(await summaly(host, { contentLengthRequired: false, contentLengthLimit: content.byteLength })).toBeDefined(); }); test('[When option is disabled] No error occurs even if content-length is not returned', async () => { app = fastify(); app.get('/', (request, reply) => { reply.header('content-type', 'text/html'); // If you don't pass it as stream, the content-length will be set automatically. return reply.send(fs.createReadStream(_dirname + '/htmls/basic.html')); }); await app.listen({ port }); expect(await summaly(host, { contentLengthRequired: false })).toBeDefined(); }); });