Skip to content

Commit 660272d

Browse files
pyphiliakim
andauthored
fix: fix copy for h5p with custom filename on upload (#2071)
* fix: encore uri filename on h5p upload * feat: build safe name for h5p upload, add test * refactor: apply fix * refactor: fix tests * refactor: fix tests * ci: add garage in test ci * ci: add garage in test ci * test: add h5p service test * refactor: fix tests * test: revert h5p test to mock s3 * refactor: fix tests * refactor: fix test * refactor: disable any errors --------- Co-authored-by: kim <kim.phanhoang@epfl.ch>
1 parent 69ab32a commit 660272d

8 files changed

Lines changed: 345 additions & 178 deletions

File tree

.devcontainer/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ services:
131131
ports:
132132
- 8000:3000
133133

134-
# Localstack is used to test aws services locally
134+
# used to test aws services locally
135135
garage:
136136
hostname: garage
137137
image: dxflrs/garage:v2.0.0

.github/workflows/test.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ env:
3030
ETHERPAD_URL: 'http://etherpad:9001'
3131
LIBRARY_CLIENT_HOST: 'http://localhost:3113'
3232
FILE_STORAGE_ROOT_PATH: /
33-
H5P_FILE_STORAGE_TYPE: local
33+
H5P_FILE_STORAGE_TYPE: s3
3434
H5P_PATH_PREFIX: h5p-content/
35-
H5P_STORAGE_ROOT_PATH: /tmp/h5p
36-
H5P_FILE_STORAGE_HOST: http://localhost:1081
35+
H5P_CONTENT_REGION: garage
36+
H5P_CONTENT_BUCKET: h5p-content
37+
H5P_CONTENT_ACCESS_KEY_ID: graasp-pwd
38+
H5P_CONTENT_SECRET_ACCESS_KEY_ID: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
3739
JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
3840
PASSWORD_RESET_JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
3941
EMAIL_CHANGE_JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

src/services/item/plugins/html/h5p/h5p.controller.test.ts

Lines changed: 99 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import { type Options, compare as dircompare, fileCompareHandlers } from 'dir-compare';
21
import { eq } from 'drizzle-orm';
3-
import fs from 'fs';
4-
import fsp from 'fs/promises';
52
import { StatusCodes } from 'http-status-codes';
63
import path from 'path';
74
import waitForExpect from 'wait-for-expect';
@@ -15,44 +12,79 @@ import { db } from '../../../../../drizzle/db';
1512
import { isDirectChild } from '../../../../../drizzle/operations';
1613
import { itemsRawTable } from '../../../../../drizzle/schema';
1714
import { assertIsDefined } from '../../../../../utils/assertions';
18-
import { H5P_LOCAL_CONFIG, H5P_PATH_PREFIX, TMP_FOLDER } from '../../../../../utils/config';
1915
import type { H5PItem } from '../../../discrimination';
2016
import { HtmlImportError } from '../errors';
2117
import { H5P_FILE_DOT_EXTENSION } from './constants';
2218
import { H5PInvalidManifestError } from './errors';
2319
import { H5PService } from './h5p.service';
2420
import { 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

3769
const 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

Comments
 (0)