asset-server-plugin.e2e-spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { ConfigService, mergeConfig } from '@vendure/core';
  3. import { AssetFragment } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
  4. import { createTestEnvironment } from '@vendure/testing';
  5. import { exec } from 'child_process';
  6. import fs from 'fs-extra';
  7. import gql from 'graphql-tag';
  8. import fetch from 'node-fetch';
  9. import path from 'path';
  10. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  11. import { initialData } from '../../../e2e-common/e2e-initial-data';
  12. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  13. import {
  14. GetImageTransformParametersArgs,
  15. ImageTransformParameters,
  16. ImageTransformStrategy,
  17. } from '../src/config/image-transform-strategy';
  18. import { AssetServerPlugin } from '../src/plugin';
  19. import {
  20. CreateAssetsMutation,
  21. DeleteAssetMutation,
  22. DeleteAssetMutationVariables,
  23. DeletionResult,
  24. } from './graphql/generated-e2e-asset-server-plugin-types';
  25. const TEST_ASSET_DIR = 'test-assets';
  26. const IMAGE_BASENAME = 'derick-david-409858-unsplash';
  27. class TestImageTransformStrategy implements ImageTransformStrategy {
  28. getImageTransformParameters(args: GetImageTransformParametersArgs): ImageTransformParameters {
  29. if (args.input.preset === 'test') {
  30. throw new Error('Test error');
  31. }
  32. return args.input;
  33. }
  34. }
  35. describe('AssetServerPlugin', () => {
  36. let asset: AssetFragment;
  37. const sourceFilePath = path.join(__dirname, TEST_ASSET_DIR, `source/b6/${IMAGE_BASENAME}.jpg`);
  38. const previewFilePath = path.join(__dirname, TEST_ASSET_DIR, `preview/71/${IMAGE_BASENAME}__preview.jpg`);
  39. const { server, adminClient } = createTestEnvironment(
  40. mergeConfig(testConfig(), {
  41. // logger: new DefaultLogger({ level: LogLevel.Info }),
  42. plugins: [
  43. AssetServerPlugin.init({
  44. assetUploadDir: path.join(__dirname, TEST_ASSET_DIR),
  45. route: 'assets',
  46. imageTransformStrategy: new TestImageTransformStrategy(),
  47. }),
  48. ],
  49. }),
  50. );
  51. beforeAll(async () => {
  52. await fs.emptyDir(path.join(__dirname, TEST_ASSET_DIR, 'source'));
  53. await fs.emptyDir(path.join(__dirname, TEST_ASSET_DIR, 'preview'));
  54. await fs.emptyDir(path.join(__dirname, TEST_ASSET_DIR, 'cache'));
  55. await server.init({
  56. initialData,
  57. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-empty.csv'),
  58. customerCount: 1,
  59. });
  60. await adminClient.asSuperAdmin();
  61. }, TEST_SETUP_TIMEOUT_MS);
  62. afterAll(async () => {
  63. await server.destroy();
  64. });
  65. it('names the Asset correctly', async () => {
  66. const filesToUpload = [path.join(__dirname, `fixtures/assets/${IMAGE_BASENAME}.jpg`)];
  67. const { createAssets }: CreateAssetsMutation = await adminClient.fileUploadMutation({
  68. mutation: CREATE_ASSETS,
  69. filePaths: filesToUpload,
  70. mapVariables: filePaths => ({
  71. input: filePaths.map(p => ({ file: null })),
  72. }),
  73. });
  74. asset = createAssets[0] as AssetFragment;
  75. expect(asset.name).toBe(`${IMAGE_BASENAME}.jpg`);
  76. });
  77. it('creates the expected asset files', async () => {
  78. expect(fs.existsSync(sourceFilePath)).toBe(true);
  79. expect(fs.existsSync(previewFilePath)).toBe(true);
  80. });
  81. it('serves the source file', async () => {
  82. const res = await fetch(`${asset.source}`);
  83. const responseBuffer = await res.buffer();
  84. const sourceFile = await fs.readFile(sourceFilePath);
  85. expect(Buffer.compare(responseBuffer, sourceFile)).toBe(0);
  86. });
  87. it('serves the untransformed preview file', async () => {
  88. const res = await fetch(`${asset.preview}`);
  89. const responseBuffer = await res.buffer();
  90. const previewFile = await fs.readFile(previewFilePath);
  91. expect(Buffer.compare(responseBuffer, previewFile)).toBe(0);
  92. });
  93. it('can handle non-latin filenames', async () => {
  94. const FILE_NAME_ZH = '白飯';
  95. const filesToUpload = [path.join(__dirname, `fixtures/assets/${FILE_NAME_ZH}.jpg`)];
  96. const { createAssets }: { createAssets: AssetFragment[] } = await adminClient.fileUploadMutation({
  97. mutation: CREATE_ASSETS,
  98. filePaths: filesToUpload,
  99. mapVariables: filePaths => ({
  100. input: filePaths.map(p => ({ file: null })),
  101. }),
  102. });
  103. expect(createAssets[0].name).toBe(`${FILE_NAME_ZH}.jpg`);
  104. expect(createAssets[0].source).toContain(`${FILE_NAME_ZH}.jpg`);
  105. const previewUrl = encodeURI(`${createAssets[0].preview}`);
  106. const res = await fetch(previewUrl);
  107. expect(res.status).toBe(200);
  108. const previewFilePathZH = path.join(
  109. __dirname,
  110. TEST_ASSET_DIR,
  111. `preview/3f/${FILE_NAME_ZH}__preview.jpg`,
  112. );
  113. const responseBuffer = await res.buffer();
  114. const previewFile = await fs.readFile(previewFilePathZH);
  115. expect(Buffer.compare(responseBuffer, previewFile)).toBe(0);
  116. });
  117. describe('caching', () => {
  118. const cacheDir = path.join(__dirname, TEST_ASSET_DIR, 'cache');
  119. const cacheFileDir = path.join(__dirname, TEST_ASSET_DIR, 'cache', 'preview', '71');
  120. it('cache initially empty', async () => {
  121. const files = await fs.readdir(cacheDir);
  122. expect(files.length).toBe(0);
  123. });
  124. it('creates cached image on first request', async () => {
  125. const res = await fetch(`${asset.preview}?preset=thumb`);
  126. const responseBuffer = await res.buffer();
  127. expect(fs.existsSync(cacheFileDir)).toBe(true);
  128. const files = await fs.readdir(cacheFileDir);
  129. expect(files.length).toBe(1);
  130. expect(files[0]).toContain(`${IMAGE_BASENAME}__preview`);
  131. const cachedFile = await fs.readFile(path.join(cacheFileDir, files[0]));
  132. // was the file returned the exact same file as is stored in the cache dir?
  133. expect(Buffer.compare(responseBuffer, cachedFile)).toBe(0);
  134. });
  135. it('does not create a new cached image on a second request', async () => {
  136. const res = await fetch(`${asset.preview}?preset=thumb`);
  137. const files = await fs.readdir(cacheFileDir);
  138. expect(files.length).toBe(1);
  139. });
  140. it('does not create a new cached image for an untransformed image', async () => {
  141. const res = await fetch(`${asset.preview}`);
  142. const files = await fs.readdir(cacheFileDir);
  143. expect(files.length).toBe(1);
  144. });
  145. it('does not create a new cached image for an invalid preset', async () => {
  146. const res = await fetch(`${asset.preview}?preset=invalid`);
  147. const files = await fs.readdir(cacheFileDir);
  148. expect(files.length).toBe(1);
  149. const previewFile = await fs.readFile(previewFilePath);
  150. const responseBuffer = await res.buffer();
  151. expect(Buffer.compare(responseBuffer, previewFile)).toBe(0);
  152. });
  153. it('does not create a new cached image if cache=false', async () => {
  154. const res = await fetch(`${asset.preview}?preset=tiny&cache=false`);
  155. const files = await fs.readdir(cacheFileDir);
  156. expect(files.length).toBe(1);
  157. });
  158. it('creates a new cached image if cache=true', async () => {
  159. const res = await fetch(`${asset.preview}?preset=tiny&cache=true`);
  160. const files = await fs.readdir(cacheFileDir);
  161. expect(files.length).toBe(2);
  162. });
  163. });
  164. describe('unexpected input', () => {
  165. it('does not error on non-integer width', async () => {
  166. return fetch(`${asset.preview}?w=10.5`);
  167. });
  168. it('does not error on non-integer height', async () => {
  169. return fetch(`${asset.preview}?h=10.5`);
  170. });
  171. // https://github.com/vendurehq/vendure/security/advisories/GHSA-r9mq-3c9r-fmjq
  172. describe('path traversal', () => {
  173. function curlWithPathAsIs(url: string) {
  174. return new Promise<string>((resolve, reject) => {
  175. // We use curl here rather than node-fetch or any other fetch-type function because
  176. // those will automatically perform path normalization which will mask the path traversal
  177. return exec(`curl --path-as-is ${url}`, (err, stdout, stderr) => {
  178. if (err) {
  179. reject(err);
  180. }
  181. resolve(stdout);
  182. });
  183. });
  184. }
  185. function testPathTraversalOnUrl(urlPath: string) {
  186. return async () => {
  187. const port = server.app.get(ConfigService).apiOptions.port;
  188. const result = await curlWithPathAsIs(`http://localhost:${port}/assets${urlPath}`);
  189. expect(result).not.toContain('@vendure/asset-server-plugin');
  190. expect(result.toLowerCase()).toContain('resource not found');
  191. };
  192. }
  193. it('blocks path traversal 1', testPathTraversalOnUrl(`/../../package.json`));
  194. it('blocks path traversal 2', testPathTraversalOnUrl(`/foo/../../../package.json`));
  195. it('blocks path traversal 3', testPathTraversalOnUrl(`/foo/../../../foo/../package.json`));
  196. it('blocks path traversal 4', testPathTraversalOnUrl(`/%2F..%2F..%2Fpackage.json`));
  197. it('blocks path traversal 5', testPathTraversalOnUrl(`/%2E%2E/%2E%2E/package.json`));
  198. it('blocks path traversal 6', testPathTraversalOnUrl(`/..//..//package.json`));
  199. it('blocks path traversal 7', testPathTraversalOnUrl(`/.%2F.%2F.%2Fpackage.json`));
  200. it('blocks path traversal 8', testPathTraversalOnUrl(`/..\\\\..\\\\package.json`));
  201. it('blocks path traversal 9', testPathTraversalOnUrl(`/\\\\\\..\\\\\\..\\\\\\package.json`));
  202. it('blocks path traversal 10', testPathTraversalOnUrl(`/./../././.././package.json`));
  203. it('blocks path traversal 11', testPathTraversalOnUrl(`/\\.\\..\\.\\.\\..\\.\\package.json`));
  204. });
  205. });
  206. describe('deletion', () => {
  207. it('deleting Asset deletes binary file', async () => {
  208. const { deleteAsset } = await adminClient.query<
  209. DeleteAssetMutation,
  210. DeleteAssetMutationVariables
  211. >(DELETE_ASSET, {
  212. input: {
  213. assetId: asset.id,
  214. force: true,
  215. },
  216. });
  217. expect(deleteAsset.result).toBe(DeletionResult.DELETED);
  218. expect(fs.existsSync(sourceFilePath)).toBe(false);
  219. expect(fs.existsSync(previewFilePath)).toBe(false);
  220. });
  221. });
  222. describe('MIME type detection', () => {
  223. let testImages: AssetFragment[] = [];
  224. async function testMimeTypeOfAssetWithExt(ext: string, expectedMimeType: string) {
  225. const testImage = testImages.find(i => i.source.endsWith(ext))!;
  226. const result = await fetch(testImage.source);
  227. const contentType = result.headers.get('Content-Type');
  228. expect(contentType).toBe(expectedMimeType);
  229. }
  230. beforeAll(async () => {
  231. const formats = ['gif', 'jpg', 'png', 'svg', 'tiff', 'webp'];
  232. const filesToUpload = formats.map(ext => path.join(__dirname, `fixtures/assets/test.${ext}`));
  233. const { createAssets }: CreateAssetsMutation = await adminClient.fileUploadMutation({
  234. mutation: CREATE_ASSETS,
  235. filePaths: filesToUpload,
  236. mapVariables: filePaths => ({
  237. input: filePaths.map(p => ({ file: null })),
  238. }),
  239. });
  240. testImages = createAssets as AssetFragment[];
  241. });
  242. it('gif', async () => {
  243. await testMimeTypeOfAssetWithExt('gif', 'image/gif');
  244. });
  245. it('jpg', async () => {
  246. await testMimeTypeOfAssetWithExt('jpg', 'image/jpeg');
  247. });
  248. it('png', async () => {
  249. await testMimeTypeOfAssetWithExt('png', 'image/png');
  250. });
  251. it('svg', async () => {
  252. await testMimeTypeOfAssetWithExt('svg', 'image/svg+xml');
  253. });
  254. it('tiff', async () => {
  255. await testMimeTypeOfAssetWithExt('tiff', 'image/tiff');
  256. });
  257. it('webp', async () => {
  258. await testMimeTypeOfAssetWithExt('webp', 'image/webp');
  259. });
  260. });
  261. // https://github.com/vendurehq/vendure/issues/1563
  262. it('falls back to binary preview if image file cannot be processed', async () => {
  263. const filesToUpload = [path.join(__dirname, 'fixtures/assets/bad-image.jpg')];
  264. const { createAssets }: CreateAssetsMutation = await adminClient.fileUploadMutation({
  265. mutation: CREATE_ASSETS,
  266. filePaths: filesToUpload,
  267. mapVariables: filePaths => ({
  268. input: filePaths.map(p => ({ file: null })),
  269. }),
  270. });
  271. expect(createAssets.length).toBe(1);
  272. expect(createAssets[0].name).toBe('bad-image.jpg');
  273. });
  274. it('ImageTransformStrategy can throw to prevent transform', async () => {
  275. const res = await fetch(`${asset.preview}?preset=test`);
  276. expect(res.status).toBe(400);
  277. const text = await res.text();
  278. expect(text).toContain('Invalid parameters');
  279. });
  280. });
  281. export const CREATE_ASSETS = gql`
  282. mutation CreateAssets($input: [CreateAssetInput!]!) {
  283. createAssets(input: $input) {
  284. ... on Asset {
  285. id
  286. name
  287. source
  288. preview
  289. focalPoint {
  290. x
  291. y
  292. }
  293. }
  294. }
  295. }
  296. `;
  297. export const DELETE_ASSET = gql`
  298. mutation DeleteAsset($input: DeleteAssetInput!) {
  299. deleteAsset(input: $input) {
  300. result
  301. }
  302. }
  303. `;