1- import { type Options , compare as dircompare , fileCompareHandlers } from 'dir-compare' ;
21import { eq } from 'drizzle-orm' ;
3- import fs from 'fs' ;
4- import fsp from 'fs/promises' ;
52import { StatusCodes } from 'http-status-codes' ;
63import path from 'path' ;
74import waitForExpect from 'wait-for-expect' ;
@@ -15,44 +12,79 @@ import { db } from '../../../../../drizzle/db';
1512import { isDirectChild } from '../../../../../drizzle/operations' ;
1613import { itemsRawTable } from '../../../../../drizzle/schema' ;
1714import { assertIsDefined } from '../../../../../utils/assertions' ;
18- import { H5P_LOCAL_CONFIG , H5P_PATH_PREFIX , TMP_FOLDER } from '../../../../../utils/config' ;
1915import type { H5PItem } from '../../../discrimination' ;
2016import { HtmlImportError } from '../errors' ;
2117import { H5P_FILE_DOT_EXTENSION } from './constants' ;
2218import { H5PInvalidManifestError } from './errors' ;
2319import { H5PService } from './h5p.service' ;
2420import { H5P_PACKAGES } from './test/fixtures' ;
25- import { expectH5PFiles , injectH5PImport } from './test/helpers' ;
26-
27- const H5P_ACCORDION_FILENAME = path . basename ( H5P_PACKAGES . ACCORDION . path ) ;
28-
29- const H5P_TMP_FOLDER = path . join ( TMP_FOLDER , 'html-packages' , H5P_PATH_PREFIX ?? '' ) ;
21+ import { injectH5PImport } from './test/helpers' ;
22+
23+ const deleteObjectsMock = jest . fn ( async ( ) => console . debug ( 'deleteObjects' ) ) ;
24+ const copyObjectMock = jest . fn ( async ( ) => console . debug ( 'copyObjectMock' ) ) ;
25+ const headObjectMock = jest . fn ( async ( ) => ( { ContentLength : 10 } ) ) ;
26+ const listObjectsV2Mock = jest . fn ( async ( ) => ( {
27+ Contents : [
28+ {
29+ Key : 'mock-key' ,
30+ } ,
31+ ] ,
32+ } ) ) ;
33+ const uploadDoneMock = jest . fn ( async ( ) => console . debug ( 'aws s3 storage upload' ) ) ;
3034
31- async function cleanFiles ( ) {
32- const storage = path . join ( H5P_LOCAL_CONFIG . local . storageRootPath , H5P_PATH_PREFIX ?? '' ) ;
33- await fsp . rm ( storage , { recursive : true , force : true } ) ;
34- await fsp . rm ( H5P_TMP_FOLDER , { recursive : true , force : true } ) ;
35- }
35+ const MOCK_SIGNED_URL = 'signed-url' ;
36+ jest . mock ( '@aws-sdk/client-s3' , ( ) => {
37+ return {
38+ GetObjectCommand : jest . fn ( ) ,
39+ NotFound : jest . fn ( ( ) => ( { name : 'NotFound' } ) ) ,
40+ MetadataDirective : {
41+ COPY : 'COPY' ,
42+ } ,
43+ S3 : function ( ) {
44+ return {
45+ copyObject : copyObjectMock ,
46+ deleteObjects : deleteObjectsMock ,
47+ headObject : headObjectMock ,
48+ listObjectsV2 : listObjectsV2Mock ,
49+ } ;
50+ } ,
51+ } ;
52+ } ) ;
53+ jest . mock ( '@aws-sdk/s3-request-presigner' , ( ) => {
54+ const getSignedUrl = jest . fn ( async ( ) => MOCK_SIGNED_URL ) ;
55+ return {
56+ getSignedUrl,
57+ } ;
58+ } ) ;
59+ jest . mock ( '@aws-sdk/lib-storage' , ( ) => {
60+ return {
61+ Upload : jest . fn ( ) . mockImplementation ( ( ) => {
62+ return {
63+ done : uploadDoneMock ,
64+ } ;
65+ } ) ,
66+ } ;
67+ } ) ;
3668
3769const buildExpectedItem = ( item : H5PItem ) => {
3870 const contentId = item . extra . h5p . contentId ;
3971
4072 const expectedExtra = {
4173 h5p : {
4274 contentId,
43- h5pFilePath : `${ contentId } /${ H5P_ACCORDION_FILENAME } ` ,
75+ h5pFilePath : `${ contentId } /${ path . basename ( H5P_PACKAGES . ACCORDION . path ) } ` ,
4476 contentFilePath : `${ contentId } /content` ,
4577 } ,
4678 } ;
4779
4880 return {
49- name : H5P_ACCORDION_FILENAME ,
81+ name : path . basename ( H5P_PACKAGES . ACCORDION . path , H5P_FILE_DOT_EXTENSION ) ,
5082 type : 'h5p' ,
5183 extra : expectedExtra ,
5284 } ;
5385} ;
5486
55- describe ( 'Service plugin' , ( ) => {
87+ describe ( 'H5P plugin' , ( ) => {
5688 let app : FastifyInstance ;
5789
5890 beforeAll ( async ( ) => {
@@ -61,7 +93,6 @@ describe('Service plugin', () => {
6193
6294 afterAll ( async ( ) => {
6395 await clearDatabase ( db ) ;
64- await cleanFiles ( ) ;
6596 app . close ( ) ;
6697 } ) ;
6798
@@ -86,42 +117,6 @@ describe('Service plugin', () => {
86117 const item = res . json ( ) ;
87118 expect ( item ) . toMatchObject ( buildExpectedItem ( item ) ) ;
88119 } ) ;
89-
90- it ( 'extracts the files correctly' , async ( ) => {
91- const {
92- actor,
93- items : [ parent ] ,
94- } = await seedFromJson ( {
95- items : [ { memberships : [ { account : 'actor' , permission : 'admin' } ] } ] ,
96- } ) ;
97- assertIsDefined ( actor ) ;
98- mockAuthenticate ( actor ) ;
99-
100- const res = await injectH5PImport ( app , { parentId : parent . id } ) ;
101- expect ( res . statusCode ) . toEqual ( StatusCodes . OK ) ;
102-
103- const item = res . json ( ) ;
104- const { contentId } = item . extra . h5p ;
105- const { storageRootPath } = H5P_LOCAL_CONFIG . local ;
106- await expectH5PFiles ( H5P_PACKAGES . ACCORDION , storageRootPath , H5P_PATH_PREFIX , contentId ) ;
107- } ) ;
108-
109- it ( 'removes the temporary extraction folder' , async ( ) => {
110- const {
111- actor,
112- items : [ parent ] ,
113- } = await seedFromJson ( {
114- items : [ { memberships : [ { account : 'actor' , permission : 'admin' } ] } ] ,
115- } ) ;
116- assertIsDefined ( actor ) ;
117- mockAuthenticate ( actor ) ;
118-
119- const res = await injectH5PImport ( app , { parentId : parent . id } ) ;
120- expect ( res . statusCode ) . toEqual ( StatusCodes . OK ) ;
121-
122- const contents = await fsp . readdir ( H5P_TMP_FOLDER ) ;
123- expect ( contents . length ) . toEqual ( 0 ) ;
124- } ) ;
125120 } ) ;
126121
127122 describe ( 'Hooks' , ( ) => {
@@ -142,9 +137,8 @@ describe('Service plugin', () => {
142137 // save h5p so it saves the files correctly
143138 const res = await injectH5PImport ( app , { parentId : parent . id } ) ;
144139 expect ( res . statusCode ) . toEqual ( StatusCodes . OK ) ;
145-
146140 const item = res . json ( ) ;
147- const contentId = ( item as H5PItem ) . extra . h5p . contentId ;
141+
148142 // delete item
149143 await app . inject ( {
150144 method : 'DELETE' ,
@@ -153,14 +147,10 @@ describe('Service plugin', () => {
153147 id : [ item . id ] ,
154148 } ,
155149 } ) ;
156- // H5P folder should now be deleted
157- const h5pFolder = path . join (
158- ...( [ H5P_LOCAL_CONFIG . local . storageRootPath , H5P_PATH_PREFIX , contentId ] . filter (
159- ( e ) => e ,
160- ) as string [ ] ) ,
161- ) ;
162- await waitForExpect ( ( ) => {
163- expect ( fs . existsSync ( h5pFolder ) ) . toBeFalsy ( ) ;
150+
151+ await waitForExpect ( async ( ) => {
152+ // check files are deleted
153+ expect ( deleteObjectsMock ) . toHaveBeenCalled ( ) ;
164154 } , 5000 ) ;
165155 } ) ;
166156 it ( 'copies H5P assets on item copy' , async ( ) => {
@@ -185,7 +175,6 @@ describe('Service plugin', () => {
185175 expect ( res . statusCode ) . toEqual ( StatusCodes . OK ) ;
186176 const item = res . json ( ) ;
187177
188- const contentId = ( item as H5PItem ) . extra . h5p . contentId ;
189178 // copy item
190179 await app . inject ( {
191180 method : 'POST' ,
@@ -197,60 +186,60 @@ describe('Service plugin', () => {
197186 parentId : targetParent . id ,
198187 } ,
199188 } ) ;
200- // H5P folder should now be copied
201- const h5pBucket = path . join (
202- ...( [ H5P_LOCAL_CONFIG . local . storageRootPath , H5P_PATH_PREFIX ] . filter ( ( e ) => e ) as string [ ] ) ,
203- ) ;
204189
205- let copiedH5P : H5PItem ;
190+ let copiedH5P ;
206191 await waitForExpect ( async ( ) => {
207- // expect(false).toBeTruthy();
208192 copiedH5P = ( await db . query . itemsRawTable . findFirst ( {
209193 where : isDirectChild ( itemsRawTable . path , targetParent . path ) ,
210194 } ) ) as H5PItem ;
211195 expect ( copiedH5P ) . toBeDefined ( ) ;
196+
197+ // check copies exist
198+ expect ( copyObjectMock ) . toHaveBeenCalledTimes ( 2 ) ;
212199 } , 5000 ) ; // the above line ensures exists
200+ } ) ;
201+ it ( 'copies H5P with special characters on item copy' , async ( ) => {
202+ const {
203+ actor,
204+ items : [ parent , targetParent ] ,
205+ } = await seedFromJson ( {
206+ items : [
207+ {
208+ memberships : [ { account : 'actor' , permission : 'admin' } ] ,
209+ } ,
210+ {
211+ memberships : [ { account : 'actor' , permission : 'admin' } ] ,
212+ } ,
213+ ] ,
214+ } ) ;
215+ assertIsDefined ( actor ) ;
216+ mockAuthenticate ( actor ) ;
217+
218+ // save h5p so it saves the files correctly
219+ const res = await injectH5PImport ( app , {
220+ filePath : path . resolve ( __dirname , 'test/fixtures/un nom français ééé.h5p' ) ,
221+ parentId : parent . id ,
222+ } ) ;
223+ expect ( res . statusCode ) . toEqual ( StatusCodes . OK ) ;
224+ const item = res . json ( ) ;
225+
226+ // copy item
227+ await app . inject ( {
228+ method : 'POST' ,
229+ url : '/api/items/copy' ,
230+ query : {
231+ id : [ item . id ] ,
232+ } ,
233+ payload : {
234+ parentId : targetParent . id ,
235+ } ,
236+ } ) ;
213237
214238 await waitForExpect ( async ( ) => {
215- // wait for copied folder to exist
216- const h5pFolders = await fsp . readdir ( h5pBucket ) ;
217- const copiedContentId = copiedH5P . extra . h5p . contentId ;
218- expect ( h5pFolders ) . toContain ( copiedContentId ) ;
219- // expected name of the copy
220- const H5P_ACCORDION_COPY_FILENAME = `${ path . basename (
221- H5P_ACCORDION_FILENAME ,
222- H5P_FILE_DOT_EXTENSION ,
223- ) } -1${ H5P_FILE_DOT_EXTENSION } `;
224- const originalPath = path . join ( h5pBucket , contentId , H5P_ACCORDION_FILENAME ) ;
225- const copyPath = path . join ( h5pBucket , copiedContentId , H5P_ACCORDION_COPY_FILENAME ) ;
226- const originalStats = await fsp . stat ( originalPath ) ;
227- const copyStats = await fsp . stat ( copyPath ) ;
228- const defaultFileCompare = fileCompareHandlers . defaultFileCompare . compareAsync ;
229-
230- const customFileCompare = (
231- path1 : string ,
232- stat1 : fs . Stats ,
233- path2 : string ,
234- stat2 : fs . Stats ,
235- options : Options ,
236- ) => {
237- if ( path1 === originalPath ) {
238- return defaultFileCompare ( path1 , stat1 , copyPath , copyStats , options ) ;
239- } else if ( path2 === originalPath ) {
240- return defaultFileCompare ( copyPath , copyStats , path2 , stat2 , options ) ;
241- } else if ( path1 === copyPath ) {
242- return defaultFileCompare ( path1 , stat1 , originalPath , originalStats , options ) ;
243- } else if ( path2 === copyPath ) {
244- return defaultFileCompare ( originalPath , originalStats , path2 , stat2 , options ) ;
245- } else {
246- return defaultFileCompare ( path1 , stat1 , path2 , stat2 , options ) ;
247- }
248- } ;
249- const dirDiff = await dircompare ( originalPath , copyPath , {
250- compareContent : true ,
251- compareFileAsync : customFileCompare ,
239+ const copiedH5P = await db . query . itemsRawTable . findFirst ( {
240+ where : isDirectChild ( itemsRawTable . path , targetParent . path ) ,
252241 } ) ;
253- expect ( dirDiff . same ) . toBeTruthy ( ) ;
242+ expect ( copiedH5P ) . toBeDefined ( ) ;
254243 } , 5000 ) ; // the above line ensures exists
255244 } ) ;
256245 } ) ;
@@ -298,8 +287,7 @@ describe('Service plugin', () => {
298287 expect ( res . statusCode ) . toEqual ( StatusCodes . BAD_REQUEST ) ;
299288 expect ( res . json ( ) ) . toEqual ( new H5PInvalidManifestError ( 'Missing h5p.json manifest file' ) ) ;
300289 } ) ;
301- it ( 'returns error and deletes extracted files on item creation failure' , async ( ) => {
302- const { storageRootPath } = H5P_LOCAL_CONFIG . local ;
290+ it ( 'returns error on item creation failure' , async ( ) => {
303291 const uploadPackage = jest . spyOn ( resolveDependency ( H5PService ) , 'uploadPackage' ) ;
304292 uploadPackage . mockImplementationOnce ( ( ) => {
305293 throw new Error ( 'mock error on HTML package upload' ) ;
@@ -318,54 +306,10 @@ describe('Service plugin', () => {
318306 assertIsDefined ( actor ) ;
319307 mockAuthenticate ( actor ) ;
320308
321- // count initial number of files
322- const initExtractionDirContents = await fsp . readdir ( H5P_TMP_FOLDER ) ;
323- const initStorageDirContents = await fsp . readdir (
324- path . join ( ...( [ storageRootPath , H5P_PATH_PREFIX ] . filter ( ( e ) => e ) as string [ ] ) ) ,
325- ) ;
326- const initExtractionNb = initExtractionDirContents . length ;
327- const initStorageNb = initStorageDirContents . length ;
328-
329309 // import h5p
330310 const res = await injectH5PImport ( app , { parentId : parent . id } ) ;
331311 expect ( res . statusCode ) . toEqual ( StatusCodes . INTERNAL_SERVER_ERROR ) ;
332312 expect ( res . json ( ) ) . toEqual ( new HtmlImportError ( ) ) ;
333-
334- // should not contain the files for this request anymore
335- await waitForExpect ( async ( ) => {
336- const extractionDirContents = await fsp . readdir ( H5P_TMP_FOLDER ) ;
337- const storageDirContents = await fsp . readdir (
338- path . join ( ...( [ storageRootPath , H5P_PATH_PREFIX ] . filter ( ( e ) => e ) as string [ ] ) ) ,
339- ) ;
340- expect ( extractionDirContents . length ) . toEqual ( initExtractionNb ) ;
341- expect ( storageDirContents . length ) . toEqual ( initStorageNb ) ;
342- } , 5000 ) ;
343- } ) ;
344- it ( 'skips invalid file extensions' , async ( ) => {
345- const { actor } = await seedFromJson ( ) ;
346- assertIsDefined ( actor ) ;
347- mockAuthenticate ( actor ) ;
348-
349- const res = await injectH5PImport ( app , {
350- filePath : H5P_PACKAGES . BOGUS_WRONG_EXTENSION . path ,
351- } ) ;
352- const item = res . json ( ) ;
353- const contentId = item . extra . h5p . contentId ;
354- const { storageRootPath } = H5P_LOCAL_CONFIG . local ;
355- await expectH5PFiles (
356- H5P_PACKAGES . BOGUS_WRONG_EXTENSION ,
357- storageRootPath ,
358- H5P_PATH_PREFIX ,
359- contentId ,
360- ) ;
361- const maliciousFolder = path . join (
362- ...[ storageRootPath , H5P_PATH_PREFIX , contentId , 'content' , 'foo' ] . filter ( ( e ) => e ) ,
363- ) ;
364- expect ( fs . existsSync ( maliciousFolder ) ) . toBeTruthy ( ) ;
365- // only .txt should be left inside
366- const contents = await fsp . readdir ( maliciousFolder ) ;
367- expect ( contents . length ) . toEqual ( 1 ) ;
368- expect ( contents . includes ( 'valid.txt' ) ) . toBeTruthy ( ) ;
369313 } ) ;
370314 } ) ;
371315
0 commit comments