1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Server: Moved file API tests to route

This commit is contained in:
Laurent Cozic 2021-01-13 18:11:35 +00:00
parent ecb6134828
commit 66a09e5068
15 changed files with 651 additions and 619 deletions

View File

@ -1,535 +0,0 @@
import { testAssetDir, createUserAndSession, createUser, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb, models, controllers } from '../../utils/testing/testUtils';
import * as fs from 'fs-extra';
import { ChangeType, File } from '../../db';
import { ErrorConflict, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors';
import { filePathInfo } from '../../utils/routeUtils';
import { defaultPagination, Pagination, PaginationOrderDir } from '../../models/utils/pagination';
import { msleep } from '../../utils/time';
async function makeTestFile(id: number = 1, ext: string = 'jpg', parentId: string = ''): Promise<File> {
const basename = ext === 'jpg' ? 'photo' : 'poster';
const file: File = {
name: id > 1 ? `${basename}-${id}.${ext}` : `${basename}.${ext}`,
content: await fs.readFile(`${testAssetDir}/${basename}.${ext}`),
// mime_type: `image/${ext}`,
parent_id: parentId,
};
return file;
}
async function makeTestContent(ext: string = 'jpg') {
const basename = ext === 'jpg' ? 'photo' : 'poster';
return await fs.readFile(`${testAssetDir}/${basename}.${ext}`);
}
async function makeTestDirectory(name: string = 'Docs'): Promise<File> {
const file: File = {
name: name,
parent_id: '',
is_directory: 1,
};
return file;
}
async function saveTestFile(sessionId: string, path: string): Promise<File> {
const fileController = controllers().apiFile();
return fileController.putFileContent(
sessionId,
path,
null
);
}
async function saveTestDir(sessionId: string, path: string): Promise<File> {
const fileController = controllers().apiFile();
const parsed = filePathInfo(path);
return fileController.postChild(
sessionId,
parsed.dirname,
{
name: parsed.basename,
is_directory: 1,
}
);
}
describe('FileController', function() {
beforeAll(async () => {
await beforeAllDb('FileController');
});
afterAll(async () => {
await afterAllDb();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileContent = await makeTestContent();
const newFile = await fileController.putFileContent(
session.id,
'root:/photo.jpg:',
fileContent
);
expect(!!newFile.id).toBe(true);
expect(newFile.name).toBe('photo.jpg');
expect(newFile.mime_type).toBe('image/jpeg');
expect(!!newFile.parent_id).toBe(true);
expect(!newFile.content).toBe(true);
expect(newFile.size > 0).toBe(true);
const fileModel = models().file({ userId: user.id });
const newFileReload = await fileModel.loadWithContent(newFile.id);
expect(!!newFileReload).toBe(true);
const newFileHex = fileContent.toString('hex');
const newFileReloadHex = (newFileReload.content as Buffer).toString('hex');
expect(newFileReloadHex.length > 0).toBe(true);
expect(newFileReloadHex).toBe(newFileHex);
});
test('should create sub-directories', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const newDir = await fileController.postFile_(session.id, {
is_directory: 1,
name: 'subdir',
});
expect(!!newDir.id).toBe(true);
expect(newDir.is_directory).toBe(1);
const newDir2 = await fileController.postFile_(session.id, {
is_directory: 1,
name: 'subdir2',
parent_id: newDir.id,
});
const newDirReload2 = await fileController.getFile(session.id, 'root:/subdir/subdir2');
expect(newDirReload2.id).toBe(newDir2.id);
expect(newDirReload2.name).toBe(newDir2.name);
});
test('should create files in sub-directory', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
await fileController.postFile_(session.id, {
is_directory: 1,
name: 'subdir',
});
const newFile = await fileController.putFileContent(
session.id,
'root:/subdir/photo.jpg:',
await makeTestContent()
);
const newFileReload = await fileController.getFile(session.id, 'root:/subdir/photo.jpg');
expect(newFileReload.id).toBe(newFile.id);
expect(newFileReload.name).toBe('photo.jpg');
});
test('should not create a file with an invalid path', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileContent = await makeTestContent();
const error = await checkThrowAsync(async () => fileController.putFileContent(
session.id,
'root:/does/not/exist/photo.jpg:',
fileContent
));
expect(error instanceof ErrorNotFound).toBe(true);
});
test('should get files', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
let file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
let file3: File = await makeTestFile(3);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session1.id, file1);
file2 = await fileController.postFile_(session1.id, file2);
file3 = await fileController.postFile_(session2.id, file3);
const fileId1 = file1.id;
const fileId2 = file2.id;
// Can't get someone else file
const error = await checkThrowAsync(async () => fileController.getFile(session1.id, file3.id));
expect(error instanceof ErrorForbidden).toBe(true);
file1 = await fileController.getFile(session1.id, file1.id);
expect(file1.id).toBe(fileId1);
const fileModel = models().file({ userId: user1.id });
const paginatedResults = await fileController.getChildren(session1.id, await fileModel.userRootFileId(), defaultPagination());
const allFiles = paginatedResults.items;
expect(allFiles.length).toBe(2);
expect(JSON.stringify(allFiles.map(f => f.id).sort())).toBe(JSON.stringify([fileId1, fileId2].sort()));
});
test('should not let create a file in a directory not owned by user', async function() {
const { session } = await createUserAndSession(1);
const user2 = await createUser(2);
const fileModel2 = models().file({ userId: user2.id });
const rootFile2 = await fileModel2.userRootFile();
const file: File = await makeTestFile();
file.parent_id = rootFile2.id;
const fileController = controllers().apiFile();
const hasThrown = await checkThrowAsync(async () => fileController.postFile_(session.id, file));
expect(!!hasThrown).toBe(true);
});
test('should update file properties', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
let file: File = await makeTestFile();
const fileController = controllers().apiFile();
file = await fileController.postFile_(session.id, file);
// Can't have file with empty name
const error = await checkThrowAsync(async () => fileController.patchFile(session.id, file.id, { name: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
await fileController.patchFile(session.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await fileController.patchFile(session.id, file.id, { mime_type: 'image/png' });
file = await fileModel.load(file.id);
expect(file.mime_type).toBe('image/png');
});
test('should not allow duplicate filenames', async function() {
const { session } = await createUserAndSession(1, true);
let file1: File = await makeTestFile(1);
const file2: File = await makeTestFile(1);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session.id, file1);
expect(!!file1.id).toBe(true);
expect(file1.name).toBe(file2.name);
const hasThrown = await checkThrowAsync(async () => await fileController.postFile_(session.id, file2));
expect(!!hasThrown).toBe(true);
});
test('should change the file parent', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
let hasThrown: any = null;
const fileModel = models().file({ userId: user1.id });
let file: File = await makeTestFile();
let file2: File = await makeTestFile(2);
let dir: File = await makeTestDirectory();
const fileController = controllers().apiFile();
file = await fileController.postFile_(session1.id, file);
file2 = await fileController.postFile_(session1.id, file2);
dir = await fileController.postFile_(session1.id, dir);
// Can't set parent to another non-directory file
hasThrown = await checkThrowAsync(async () => await fileController.patchFile(session1.id, file.id, { parent_id: file2.id }));
expect(!!hasThrown).toBe(true);
const fileModel2 = models().file({ userId: user2.id });
const userRoot2 = await fileModel2.userRootFile();
// Can't set parent to someone else directory
hasThrown = await checkThrowAsync(async () => await fileController.patchFile(session1.id, file.id, { parent_id: userRoot2.id }));
expect(!!hasThrown).toBe(true);
await fileController.patchFile(session1.id, file.id, { parent_id: dir.id });
file = await fileModel.load(file.id);
expect(!!file.parent_id).toBe(true);
expect(file.parent_id).toBe(dir.id);
});
test('should delete a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileModel = models().file({ userId: user.id });
const file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
await fileController.postFile_(session.id, file1);
file2 = await fileController.postFile_(session.id, file2);
let allFiles: File[] = await fileModel.all();
const beforeCount: number = allFiles.length;
await fileController.deleteFile(session.id, file2.id);
allFiles = await fileModel.all();
expect(allFiles.length).toBe(beforeCount - 1);
});
test('should create and delete directories', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const dir1: File = await fileController.postChild(session.id, 'root', { name: 'dir1', is_directory: 1 });
const dir2: File = await fileController.postChild(session.id, 'root:/dir1', { name: 'dir2', is_directory: 1 });
const dirReload2: File = await fileController.getFile(session.id, 'root:/dir1/dir2');
expect(dirReload2.id).toBe(dir2.id);
// Delete one directory
await fileController.deleteFile(session.id, 'root:/dir1/dir2');
const error = await checkThrowAsync(async () => fileController.getFile(session.id, 'root:/dir1/dir2'));
expect(error instanceof ErrorNotFound).toBe(true);
// Delete a directory and its sub-directories and files
const dir3: File = await fileController.postChild(session.id, 'root:/dir1', { name: 'dir3', is_directory: 1 });
const file1: File = await fileController.postFile_(session.id, { name: 'file1', parent_id: dir1.id });
const file2: File = await fileController.postFile_(session.id, { name: 'file2', parent_id: dir3.id });
await fileController.deleteFile(session.id, 'root:/dir1');
const fileModel = models().file({ userId: user.id });
expect(!(await fileModel.load(dir1.id))).toBe(true);
expect(!(await fileModel.load(dir3.id))).toBe(true);
expect(!(await fileModel.load(file1.id))).toBe(true);
expect(!(await fileModel.load(file2.id))).toBe(true);
});
test('should not change the parent when updating a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileModel = models().file({ userId: user.id });
const dir1: File = await fileController.postChild(session.id, 'root', { name: 'dir1', is_directory: 1 });
const file1: File = await fileController.putFileContent(session.id, 'root:/dir1/myfile.md', Buffer.from('testing'));
await fileController.putFileContent(session.id, 'root:/dir1/myfile.md', Buffer.from('new content'));
const fileReloaded1 = await fileModel.load(file1.id);
expect(fileReloaded1.parent_id).toBe(dir1.id);
});
test('should not delete someone else file', async function() {
const { session: session1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
const fileController = controllers().apiFile();
const file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
await fileController.postFile_(session1.id, file1);
file2 = await fileController.postFile_(session2.id, file2);
const error = await checkThrowAsync(async () => await fileController.deleteFile(session1.id, file2.id));
expect(error instanceof ErrorForbidden).toBe(true);
});
test('should let admin change or delete files', async function() {
const { session: adminSession } = await createUserAndSession(1, true);
const { session, user } = await createUserAndSession(2);
let file: File = await makeTestFile();
const fileModel = models().file({ userId: user.id });
const fileController = controllers().apiFile();
file = await fileController.postFile_(session.id, file);
await fileController.patchFile(adminSession.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await fileController.deleteFile(adminSession.id, file.id);
expect(!(await fileModel.load(file.id))).toBe(true);
});
test('should update a file content', async function() {
const { session } = await createUserAndSession(1, true);
const file: File = await makeTestFile(1);
const file2: File = await makeTestFile(2, 'png');
const fileController = controllers().apiFile();
const newFile = await fileController.postFile_(session.id, file);
await fileController.putFileContent(session.id, newFile.id, file2.content);
const modFile = await fileController.getFileContent(session.id, newFile.id);
const originalFileHex = (file.content as Buffer).toString('hex');
const modFileHex = (modFile.content as Buffer).toString('hex');
expect(modFileHex.length > 0).toBe(true);
expect(modFileHex === originalFileHex).toBe(false);
expect(modFile.size).toBe(modFile.content.byteLength);
expect(newFile.size).toBe(file.content.byteLength);
});
test('should delete a file content', async function() {
const { session } = await createUserAndSession(1, true);
const file: File = await makeTestFile(1);
const fileController = controllers().apiFile();
const newFile = await fileController.postFile_(session.id, file);
await fileController.putFileContent(session.id, newFile.id, file.content);
await fileController.deleteFileContent(session.id, newFile.id);
const modFile = await fileController.getFile(session.id, newFile.id);
expect(modFile.size).toBe(0);
});
test('should not allow reserved characters', async function() {
const { session } = await createUserAndSession(1, true);
const filenames = [
'invalid*invalid',
'invalid#invalid',
'invalid\\invalid',
];
const fileController = controllers().apiFile();
for (const filename of filenames) {
const error = await checkThrowAsync(async () => fileController.putFileContent(session.id, `root:/${filename}`, null));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
}
});
test('should not allow a directory with the same name', async function() {
const { session } = await createUserAndSession(1, true);
await saveTestDir(session.id, 'root:/somedir:');
let error = await checkThrowAsync(async () => saveTestFile(session.id, 'root:/somedir:'));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
await saveTestFile(session.id, 'root:/somefile.md:');
error = await checkThrowAsync(async () => saveTestDir(session.id, 'root:/somefile.md:'));
expect(error instanceof ErrorConflict).toBe(true);
});
test('should not be possible to delete the root directory', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const error = await checkThrowAsync(async () => fileController.deleteFile(session.id, 'root'));
expect(error instanceof ErrorForbidden).toBe(true);
});
test('should support root:/: format, which means root', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileModel = models().file({ userId: user.id });
const root = await fileController.getFile(session.id, 'root:/:');
expect(root.id).toBe(await fileModel.userRootFileId());
});
test('should paginate results', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
let file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
let file3: File = await makeTestFile(3);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session1.id, file1);
await msleep(1);
file2 = await fileController.postFile_(session1.id, file2);
await msleep(1);
file3 = await fileController.postFile_(session1.id, file3);
const fileModel = models().file({ userId: user1.id });
const rootId = await fileModel.userRootFileId();
const pagination: Pagination = {
limit: 2,
order: [
{
by: 'updated_time',
dir: PaginationOrderDir.ASC,
},
],
page: 1,
};
for (const method of ['page', 'cursor']) {
const page1 = await fileController.getChildren(session1.id, rootId, pagination);
expect(page1.items.length).toBe(2);
expect(page1.has_more).toBe(true);
expect(page1.items[0].id).toBe(file1.id);
expect(page1.items[1].id).toBe(file2.id);
const p = method === 'page' ? { ...pagination, page: 2 } : { cursor: page1.cursor };
const page2 = await fileController.getChildren(session1.id, rootId, p);
expect(page2.items.length).toBe(1);
expect(page2.has_more).toBe(false);
expect(page2.items[0].id).toBe(file3.id);
}
});
test('should track file changes', async function() {
// We only do a basic check because most of the tests for this are in
// ChangeModel.test.ts
const { session: session1 } = await createUserAndSession(1);
let file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session1.id, file1);
await msleep(1); file2 = await fileController.postFile_(session1.id, file2);
const page1 = await fileController.getDelta(session1.id, file1.parent_id, { limit: 1 });
expect(page1.has_more).toBe(true);
expect(page1.items.length).toBe(1);
expect(page1.items[0].type).toBe(ChangeType.Create);
expect(page1.items[0].item.id).toBe(file1.id);
const page2 = await fileController.getDelta(session1.id, file1.parent_id, { cursor: page1.cursor, limit: 1 });
expect(page2.has_more).toBe(true);
expect(page2.items.length).toBe(1);
expect(page2.items[0].type).toBe(ChangeType.Create);
expect(page2.items[0].item.id).toBe(file2.id);
const page3 = await fileController.getDelta(session1.id, file1.parent_id, { cursor: page2.cursor, limit: 1 });
expect(page3.has_more).toBe(false);
expect(page3.items.length).toBe(0);
});
});

View File

@ -1,4 +1,4 @@
import { createUser, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb, controllers } from '../../utils/testing/testUtils';
import { createUser, checkThrowAsync, beforeAllDb, afterAllTests, beforeEachDb, controllers } from '../../utils/testing/testUtils';
import { ErrorForbidden } from '../../utils/errors';
describe('SessionController', function() {
@ -8,7 +8,7 @@ describe('SessionController', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -1,4 +1,4 @@
import { models, controllers, createUserAndSession, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb } from '../../utils/testing/testUtils';
import { models, controllers, createUserAndSession, checkThrowAsync, beforeAllDb, afterAllTests, beforeEachDb } from '../../utils/testing/testUtils';
import { File, User } from '../../db';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
@ -9,7 +9,7 @@ describe('UserController', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { defaultAdminEmail, defaultAdminPassword, Notification } from '../db';
import notificationHandler from './notificationHandler';
@ -9,7 +9,7 @@ describe('notificationHandler', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext } from '../utils/testing/testUtils';
import ownerHandler from './ownerHandler';
describe('ownerHandler', function() {
@ -8,7 +8,7 @@ describe('ownerHandler', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { ChangeType, File } from '../db';
import FileModel from './FileModel';
import { msleep } from '../utils/time';
@ -18,7 +18,7 @@ describe('ChangeModel', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, createFileTree } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createFileTree } from '../utils/testing/testUtils';
import { File } from '../db';
describe('FileModel', function() {
@ -8,7 +8,7 @@ describe('FileModel', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { Notification, NotificationLevel } from '../db';
describe('NotificationModel', function() {
@ -8,7 +8,7 @@ describe('NotificationModel', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -20,6 +20,15 @@ export interface Pagination {
cursor?: string;
}
interface PaginationQueryParams {
limit?: number;
order_by?: string;
order_dir?: string;
page?: number;
cursor?: string;
}
export interface PaginatedResults {
items: any[];
has_more: boolean;
@ -107,6 +116,25 @@ export function requestChangePagination(query: any): ChangePagination {
return output;
}
export function paginationToQueryParams(pagination: Pagination): PaginationQueryParams {
const output: PaginationQueryParams = {};
if (!pagination) return {};
if ('limit' in pagination) output.limit = pagination.limit;
if ('page' in pagination) output.page = pagination.page;
if ('cursor' in pagination) output.cursor = pagination.cursor;
if ('order' in pagination) {
const o = pagination.order;
if (o.length) {
output.order_by = o[0].by;
output.order_dir = o[0].dir;
}
}
return output;
}
export interface PageLink {
page?: number;
isEllipsis?: boolean;

View File

@ -1,12 +1,36 @@
import routeHandler from '../../middleware/routeHandler';
import { testAssetDir, beforeAllDb, afterAllDb, beforeEachDb, koaAppContext, createUserAndSession, models } from '../../utils/testing/testUtils';
import { testAssetDir, beforeAllDb, randomHash, afterAllTests, beforeEachDb, createUserAndSession, models, tempDir } from '../../utils/testing/testUtils';
import { getFileMetadataContext, getFileMetadata, deleteFileContent, deleteFileContext, deleteFile, postDirectoryContext, postDirectory, getDirectoryChildren, putFileContentContext, putFileContent, getFileContent, patchFileContext, patchFile, getDelta } from '../../utils/testing/apiUtils';
import * as fs from 'fs-extra';
import { ChangeType, File } from '../../db';
import { Pagination, PaginationOrderDir } from '../../models/utils/pagination';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorNotFound, ErrorConflict } from '../../utils/errors';
import { msleep } from '../../utils/time';
function testFilePath(ext: string = 'jpg') {
const basename = ext === 'jpg' ? 'photo' : 'poster';
return `${testAssetDir}/${basename}.${ext}`;
}
async function makeTempFileWithContent(content: string): Promise<string> {
const d = await tempDir();
const filePath = `${d}/${randomHash()}`;
await fs.writeFile(filePath, content, 'utf8');
return filePath;
}
async function makeTestFile(ownerId: string, id: number = 1, ext: string = 'jpg', parentId: string = ''): Promise<File> {
const basename = ext === 'jpg' ? 'photo' : 'poster';
const file: File = {
name: id > 1 ? `${basename}-${id}.${ext}` : `${basename}.${ext}`,
content: await fs.readFile(`${testAssetDir}/${basename}.${ext}`),
// mime_type: `image/${ext}`,
parent_id: parentId,
};
return models().file({ userId: ownerId }).save(file);
}
describe('api_files', function() {
beforeAll(async () => {
@ -14,7 +38,7 @@ describe('api_files', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {
@ -25,18 +49,7 @@ describe('api_files', function() {
const { user, session } = await createUserAndSession(1, true);
const filePath = testFilePath();
const context = await koaAppContext({
sessionId: session.id,
request: {
method: 'PUT',
url: '/api/files/root:/photo.jpg:/content',
files: { file: { path: filePath } },
},
});
await routeHandler(context);
const newFile = context.response.body;
const newFile = await putFileContent(session.id, 'root:/photo.jpg:', filePath);
expect(!!newFile.id).toBe(true);
expect(newFile.name).toBe('photo.jpg');
@ -60,53 +73,347 @@ describe('api_files', function() {
test('should create sub-directories', async function() {
const { session } = await createUserAndSession(1, true);
const context1 = await koaAppContext({
sessionId: session.id,
request: {
method: 'POST',
url: '/api/files/root/children',
body: {
is_directory: 1,
name: 'subdir',
},
},
});
await routeHandler(context1);
const newDir = context1.response.body;
const newDir = await postDirectory(session.id, 'root:/:', 'subdir');
expect(!!newDir.id).toBe(true);
expect(newDir.is_directory).toBe(1);
const context2 = await koaAppContext({
sessionId: session.id,
request: {
method: 'POST',
url: '/api/files/root:/subdir:/children',
body: {
is_directory: 1,
name: 'subdir2',
},
},
});
const newDir2 = await postDirectory(session.id, 'root:/subdir:', 'subdir2');
await routeHandler(context2);
const newDir2 = context2.response.body;
const context3 = await koaAppContext({
sessionId: session.id,
request: {
method: 'GET',
url: '/api/files/root:/subdir/subdir2:',
},
});
await routeHandler(context3);
const newDirReload2 = context3.response.body;
const newDirReload2 = await getFileMetadata(session.id, 'root:/subdir/subdir2:');
expect(newDirReload2.id).toBe(newDir2.id);
expect(newDirReload2.name).toBe(newDir2.name);
});
test('should create files in sub-directory', async function() {
const { session } = await createUserAndSession(1, true);
await postDirectory(session.id, 'root:/:', 'subdir');
const newFile = await putFileContent(session.id, 'root:/subdir/photo.jpg:', testFilePath());
const newFileReload = await getFileMetadata(session.id, 'root:/subdir/photo.jpg:');
expect(newFileReload.id).toBe(newFile.id);
expect(newFileReload.name).toBe('photo.jpg');
});
test('should not create a file with an invalid path', async function() {
const { session } = await createUserAndSession(1, true);
const context = await putFileContentContext(session.id, 'root:/does/not/exist/photo.jpg:', testFilePath());
expect(context.response.status).toBe(ErrorNotFound.httpCode);
});
test('should get files', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
let file1: File = await makeTestFile(user1.id, 1);
const file2: File = await makeTestFile(user1.id, 2);
const file3: File = await makeTestFile(user2.id, 3);
const fileId1 = file1.id;
const fileId2 = file2.id;
// Can't get someone else file
const context = await getFileMetadataContext(session1.id, file3.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
file1 = await getFileMetadata(session1.id, file1.id);
expect(file1.id).toBe(fileId1);
const fileModel = models().file({ userId: user1.id });
const paginatedResults = await getDirectoryChildren(session1.id, await fileModel.userRootFileId());
const allFiles: File[] = paginatedResults.items;
expect(allFiles.length).toBe(2);
expect(JSON.stringify(allFiles.map(f => f.id).sort())).toBe(JSON.stringify([fileId1, fileId2].sort()));
});
test('should not let create a file in a directory not owned by user', async function() {
const { session: session1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
const file = await putFileContent(session2.id, 'root:/test.jpg:', testFilePath());
const context = await getFileMetadataContext(session1.id, file.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should update file properties', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
let file = await putFileContent(session.id, 'root:/test.jpg:', testFilePath());
// Can't have file with empty name
const context = await patchFileContext(session.id, file.id, { name: '' });
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
await patchFile(session.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await patchFile(session.id, file.id, { mime_type: 'image/png' });
file = await fileModel.load(file.id);
expect(file.mime_type).toBe('image/png');
});
test('should not allow duplicate filenames', async function() {
const { session } = await createUserAndSession(1, true);
const c1 = await postDirectoryContext(session.id, 'root:/:', 'mydir');
expect(c1.response.status).toBe(200);
const c2 = await postDirectoryContext(session.id, 'root:/:', 'mydir');
expect(c2.response.status).toBe(ErrorConflict.httpCode);
});
test('should change the file parent', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const fileModel = models().file({ userId: user1.id });
let file: File = await makeTestFile(user1.id);
const file2: File = await makeTestFile(user1.id, 2);
const dir: File = await postDirectory(session1.id, 'root', 'mydir');
// Can't set parent to another non-directory file
const context1 = await patchFileContext(session1.id, file.id, { parent_id: file2.id });
expect(context1.response.status).toBe(ErrorForbidden.httpCode);
// Can't set parent to someone else directory
const fileModel2 = models().file({ userId: user2.id });
const userRoot2 = await fileModel2.userRootFile();
const context2 = await patchFileContext(session1.id, file.id, { parent_id: userRoot2.id });
expect(context2.response.status).toBe(ErrorForbidden.httpCode);
// Finally, change the parent
await patchFile(session1.id, file.id, { parent_id: dir.id });
file = await fileModel.load(file.id);
expect(!!file.parent_id).toBe(true);
expect(file.parent_id).toBe(dir.id);
});
test('should delete a file', async function() {
const { user, session } = await createUserAndSession(1, true);
await makeTestFile(user.id, 1);
const file2: File = await makeTestFile(user.id, 2);
const fileModel = models().file({ userId: user.id });
let allFiles: File[] = await fileModel.all();
const beforeCount: number = allFiles.length;
await deleteFile(session.id, file2.id);
allFiles = await fileModel.all();
expect(allFiles.length).toBe(beforeCount - 1);
});
test('should create and delete directories', async function() {
const { user, session } = await createUserAndSession(1, true);
const dir1 = await postDirectory(session.id, 'root', 'dir1');
const dir2 = await postDirectory(session.id, dir1.id, 'dir2');
const dirReload2: File = await getFileMetadata(session.id, 'root:/dir1/dir2:');
expect(dirReload2.id).toBe(dir2.id);
// Delete one directory
await deleteFile(session.id, 'root:/dir1/dir2:');
const dirNotFoundContext = await getFileMetadataContext(session.id, 'root:/dir1/dir2:');
expect(dirNotFoundContext.response.status).toBe(ErrorNotFound.httpCode);
// Delete a directory and its sub-directories and files
const dir3 = await postDirectory(session.id, 'root:/dir1:', 'dir3');
const file1 = await putFileContent(session.id, 'root:/dir1/file1:', testFilePath());
const file2 = await putFileContent(session.id, 'root:/dir1/dir3/file2:', testFilePath());
await deleteFile(session.id, 'root:/dir1:');
const fileModel = models().file({ userId: user.id });
expect(!(await fileModel.load(dir1.id))).toBe(true);
expect(!(await fileModel.load(dir3.id))).toBe(true);
expect(!(await fileModel.load(file1.id))).toBe(true);
expect(!(await fileModel.load(file2.id))).toBe(true);
});
test('should not change the parent when updating a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const dir1: File = await postDirectory(session.id, 'root', 'dir1');
const file1: File = await putFileContent(session.id, 'root:/dir1/myfile.md:', await makeTempFileWithContent('testing'));
await putFileContent(session.id, 'root:/dir1/myfile.md:', await makeTempFileWithContent('new content'));
const fileReloaded1 = await fileModel.load(file1.id);
expect(fileReloaded1.parent_id).toBe(dir1.id);
});
test('should not delete someone else file', async function() {
const { session: session1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const file2: File = await makeTestFile(user2.id, 2);
const context = await deleteFileContext(session1.id, file2.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should let admin change or delete files', async function() {
const { session: adminSession } = await createUserAndSession(1, true);
const { user } = await createUserAndSession(2);
let file: File = await makeTestFile(user.id);
const fileModel = models().file({ userId: user.id });
await patchFile(adminSession.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await deleteFile(adminSession.id, file.id);
expect(!(await fileModel.load(file.id))).toBe(true);
});
test('should update a file content', async function() {
const { session } = await createUserAndSession(1, true);
const contentPath1 = await makeTempFileWithContent('test1');
const contentPath2 = await makeTempFileWithContent('test2');
await putFileContent(session.id, 'root:/file.txt:', contentPath1);
const originalContent = (await getFileContent(session.id, 'root:/file.txt:')).toString();
expect(originalContent).toBe('test1');
await putFileContent(session.id, 'root:/file.txt:', contentPath2);
const modContent = (await getFileContent(session.id, 'root:/file.txt:')).toString();
expect(modContent).toBe('test2');
});
test('should delete a file content', async function() {
const { user, session } = await createUserAndSession(1, true);
const file: File = await makeTestFile(user.id, 1);
await putFileContent(session.id, file.id, await makeTempFileWithContent('test1'));
await deleteFileContent(session.id, file.id);
const modFile = await getFileMetadata(session.id, file.id);
expect(modFile.size).toBe(0);
});
test('should not allow reserved characters', async function() {
const { session } = await createUserAndSession(1, true);
const filenames = [
'invalid*invalid',
'invalid#invalid',
'invalid\\invalid',
];
for (const filename of filenames) {
const context = await putFileContentContext(session.id, `root:/${filename}:`, testFilePath());
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
}
});
test('should not allow a directory with the same name', async function() {
const { session } = await createUserAndSession(1, true);
{
await postDirectory(session.id, 'root', 'somedir');
const context = await putFileContentContext(session.id, 'root:/somedir:', testFilePath());
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
}
{
await putFileContent(session.id, 'root:/somefile.md:', testFilePath());
const context = await postDirectoryContext(session.id, 'root', 'somefile.md');
expect(context.response.status).toBe(ErrorConflict.httpCode);
}
});
test('should not be possible to delete the root directory', async function() {
const { session } = await createUserAndSession(1, true);
const context = await deleteFileContext(session.id, 'root');
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should support root:/: format, which means root', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const root = await getFileMetadata(session.id, 'root:/:');
expect(root.id).toBe(await fileModel.userRootFileId());
});
test('should paginate results', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const file1: File = await makeTestFile(user1.id, 1);
await msleep(1);
const file2: File = await makeTestFile(user1.id, 2);
await msleep(1);
const file3: File = await makeTestFile(user1.id, 3);
const fileModel = models().file({ userId: user1.id });
const rootId = await fileModel.userRootFileId();
const pagination: Pagination = {
limit: 2,
order: [
{
by: 'updated_time',
dir: PaginationOrderDir.ASC,
},
],
page: 1,
};
for (const method of ['page', 'cursor']) {
const page1 = await getDirectoryChildren(session1.id, rootId, pagination);
expect(page1.items.length).toBe(2);
expect(page1.has_more).toBe(true);
expect(page1.items[0].id).toBe(file1.id);
expect(page1.items[1].id).toBe(file2.id);
const p = method === 'page' ? { ...pagination, page: 2 } : { cursor: page1.cursor };
const page2 = await getDirectoryChildren(session1.id, rootId, p);
expect(page2.items.length).toBe(1);
expect(page2.has_more).toBe(false);
expect(page2.items[0].id).toBe(file3.id);
}
});
test('should track file changes', async function() {
// We only do a basic check because most of the tests for this are in
// ChangeModel.test.ts
const { user, session: session1 } = await createUserAndSession(1);
const file1: File = await makeTestFile(user.id, 1);
await msleep(1);
const file2: File = await makeTestFile(user.id, 2);
const page1 = await getDelta(session1.id, file1.parent_id, { limit: 1 });
expect(page1.has_more).toBe(true);
expect(page1.items.length).toBe(1);
expect(page1.items[0].type).toBe(ChangeType.Create);
expect(page1.items[0].item.id).toBe(file1.id);
const page2 = await getDelta(session1.id, file1.parent_id, { cursor: page1.cursor, limit: 1 });
expect(page2.has_more).toBe(true);
expect(page2.items.length).toBe(1);
expect(page2.items[0].type).toBe(ChangeType.Create);
expect(page2.items[0].item.id).toBe(file2.id);
const page3 = await getDelta(session1.id, file1.parent_id, { cursor: page2.cursor, limit: 1 });
expect(page3.has_more).toBe(false);
expect(page3.items.length).toBe(0);
});
});

View File

@ -1,5 +1,5 @@
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllDb, beforeEachDb, koaAppContext } from '../../utils/testing/testUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext } from '../../utils/testing/testUtils';
describe('api_ping', function() {
@ -8,7 +8,7 @@ describe('api_ping', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -1,6 +1,6 @@
import { Session } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllDb, beforeEachDb, koaAppContext, createUserAndSession, models } from '../../utils/testing/testUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models } from '../../utils/testing/testUtils';
describe('api_sessions', function() {
@ -9,7 +9,7 @@ describe('api_sessions', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@ -2,6 +2,8 @@
// https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
class ApiError extends Error {
public static httpCode: number = 400;
public httpCode: number;
public code: string;
public constructor(message: string, httpCode: number = 400, code: string = undefined) {
@ -13,51 +15,65 @@ class ApiError extends Error {
}
export class ErrorMethodNotAllowed extends ApiError {
public static httpCode: number = 400;
public constructor(message: string = 'Method Not Allowed') {
super(message, 405);
super(message, ErrorMethodNotAllowed.httpCode);
Object.setPrototypeOf(this, ErrorMethodNotAllowed.prototype);
}
}
export class ErrorNotFound extends ApiError {
public static httpCode: number = 404;
public constructor(message: string = 'Not Found') {
super(message, 404);
super(message, ErrorNotFound.httpCode);
Object.setPrototypeOf(this, ErrorNotFound.prototype);
}
}
export class ErrorForbidden extends ApiError {
public static httpCode: number = 403;
public constructor(message: string = 'Forbidden') {
super(message, 403);
super(message, ErrorForbidden.httpCode);
Object.setPrototypeOf(this, ErrorForbidden.prototype);
}
}
export class ErrorBadRequest extends ApiError {
public static httpCode: number = 400;
public constructor(message: string = 'Bad Request') {
super(message, 400);
super(message, ErrorBadRequest.httpCode);
Object.setPrototypeOf(this, ErrorBadRequest.prototype);
}
}
export class ErrorUnprocessableEntity extends ApiError {
public static httpCode: number = 422;
public constructor(message: string = 'Unprocessable Entity') {
super(message, 422);
super(message, ErrorUnprocessableEntity.httpCode);
Object.setPrototypeOf(this, ErrorUnprocessableEntity.prototype);
}
}
export class ErrorConflict extends ApiError {
public static httpCode: number = 409;
public constructor(message: string = 'Conflict') {
super(message, 409);
super(message, ErrorConflict.httpCode);
Object.setPrototypeOf(this, ErrorConflict.prototype);
}
}
export class ErrorResyncRequired extends ApiError {
public static httpCode: number = 400;
public constructor(message: string = 'Delta cursor is invalid and the complete data should be resynced') {
super(message, 400, 'resyncRequired');
super(message, ErrorResyncRequired.httpCode, 'resyncRequired');
Object.setPrototypeOf(this, ErrorResyncRequired.prototype);
}
}

View File

@ -0,0 +1,194 @@
// These utility functions allow making API calls easily from test units.
// There's two versions of each function:
//
// - A regular one, eg. "postDirectory", which returns whatever would have
// normally return the API call. It also checks for error.
//
// - The other function is suffixed with "Context", eg "postDirectoryContext".
// In that case, it returns the complete Koa context, which can be used in
// particular to access the response object and test for errors.
import { File } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { PaginatedResults, Pagination, paginationToQueryParams } from '../../models/utils/pagination';
import { AppContext } from '../types';
import { koaAppContext } from './testUtils';
function checkContextError(context: AppContext) {
if (context.response.status >= 400) throw new Error(`Cannot create directory: ${JSON.stringify(context.response)}`);
}
export async function getFileMetadataContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}`,
},
});
await routeHandler(context);
return context;
}
export async function getFileMetadata(sessionId: string, path: string): Promise<File> {
const context = await getFileMetadataContext(sessionId, path);
checkContextError(context);
return context.response.body;
}
export async function deleteFileContentContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'DELETE',
url: `/api/files/${path}/content`,
},
});
await routeHandler(context);
return context;
}
export async function deleteFileContent(sessionId: string, path: string): Promise<void> {
const context = await deleteFileContentContext(sessionId, path);
checkContextError(context);
}
export async function deleteFileContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'DELETE',
url: `/api/files/${path}`,
},
});
await routeHandler(context);
return context;
}
export async function deleteFile(sessionId: string, path: string): Promise<void> {
const context = await deleteFileContext(sessionId, path);
checkContextError(context);
}
export async function postDirectoryContext(sessionId: string, parentPath: string, name: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: `/api/files/${parentPath}/children`,
body: {
is_directory: 1,
name: name,
},
},
});
await routeHandler(context);
return context;
}
export async function postDirectory(sessionId: string, parentPath: string, name: string): Promise<File> {
const context = await postDirectoryContext(sessionId, parentPath, name);
checkContextError(context);
return context.response.body;
}
export async function getDirectoryChildrenContext(sessionId: string, path: string, pagination: Pagination = null): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}/children`,
query: paginationToQueryParams(pagination),
},
});
await routeHandler(context);
return context;
}
export async function getDirectoryChildren(sessionId: string, path: string, pagination: Pagination = null): Promise<PaginatedResults> {
const context = await getDirectoryChildrenContext(sessionId, path, pagination);
checkContextError(context);
return context.response.body;
}
export async function putFileContentContext(sessionId: string, path: string, filePath: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'PUT',
url: `/api/files/${path}/content`,
files: { file: { path: filePath } },
},
});
await routeHandler(context);
return context;
}
export async function putFileContent(sessionId: string, path: string, filePath: string): Promise<File> {
const context = await putFileContentContext(sessionId, path, filePath);
checkContextError(context);
return context.response.body;
}
export async function getFileContentContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}/content`,
},
});
await routeHandler(context);
return context;
}
export async function getFileContent(sessionId: string, path: string): Promise<Buffer> {
const context = await getFileContentContext(sessionId, path);
checkContextError(context);
return context.response.body;
}
export async function patchFileContext(sessionId: string, path: string, file: File): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'PATCH',
url: `/api/files/${path}`,
body: file,
},
});
await routeHandler(context);
return context;
}
export async function patchFile(sessionId: string, path: string, file: File): Promise<File> {
const context = await patchFileContext(sessionId, path, file);
checkContextError(context);
return context.response.body;
}
export async function getDeltaContext(sessionId: string, path: string, pagination: Pagination): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}/delta`,
query: paginationToQueryParams(pagination),
},
});
await routeHandler(context);
return context;
}
export async function getDelta(sessionId: string, path: string, pagination: Pagination): Promise<PaginatedResults> {
const context = await getDeltaContext(sessionId, path, pagination);
checkContextError(context);
return context.response.body;
}

View File

@ -11,6 +11,8 @@ import FakeCookies from './koa/FakeCookies';
import FakeRequest from './koa/FakeRequest';
import FakeResponse from './koa/FakeResponse';
import * as httpMocks from 'node-mocks-http';
import * as crypto from 'crypto';
import * as fs from 'fs-extra';
// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
@ -20,6 +22,18 @@ let db_: DbConnection = null;
// require('source-map-support').install();
export function randomHash(): string {
return crypto.createHash('md5').update(`${Date.now()}-${Math.random()}`).digest('hex');
}
let tempDir_: string = null;
export async function tempDir(): Promise<string> {
if (tempDir_) return tempDir_;
tempDir_ = `${packageRootDir}/temp/${randomHash()}`;
await fs.mkdirp(tempDir_);
return tempDir_;
}
export async function beforeAllDb(unitName: string) {
const config: Config = {
...baseConfig,
@ -34,9 +48,16 @@ export async function beforeAllDb(unitName: string) {
db_ = await connectDb(config.database);
}
export async function afterAllDb() {
await disconnectDb(db_);
db_ = null;
export async function afterAllTests() {
if (db_) {
await disconnectDb(db_);
db_ = null;
}
if (tempDir_) {
await fs.remove(tempDir_);
tempDir_ = null;
}
}
export async function beforeEachDb() {
@ -98,6 +119,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
appContext.response = new FakeResponse();
appContext.headers = { ...reqOptions.headers };
appContext.req = req;
appContext.query = req.query;
appContext.method = req.method;
if (options.sessionId) {