Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('file download acceptance', () => {
let client: Client;
let app: FileUploadApplication;

before(givenSandbox);
before(givenAClient);

beforeEach(resetSandbox);
Expand Down Expand Up @@ -59,12 +60,15 @@ describe('file download acceptance', () => {
await client.get('/files/test.json').expect(200, {test: 'JSON'});
});

function givenSandbox() {
sandbox = getSandbox();
}

async function givenAClient() {
({app, client} = await setupApplication());
({app, client} = await setupApplication(sandbox.path));
}

async function resetSandbox() {
sandbox = getSandbox();
await sandbox.reset();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface AppWithClient {
}

export function getSandbox() {
const sandbox = new TestSandbox(path.resolve(__dirname, '../../../.sandbox'));
// dist/.sandbox/<a unique temporary subdir>
const sandbox = new TestSandbox(path.resolve(__dirname, '../../.sandbox'));
return sandbox;
}
8 changes: 5 additions & 3 deletions examples/file-transfer/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@loopback/rest-explorer';
import multer from 'multer';
import path from 'path';
import {FILE_UPLOAD_SERVICE} from './keys';
import {FILE_UPLOAD_SERVICE, STORAGE_DIRECTORY} from './keys';
import {MySequence} from './sequence';

export class FileUploadApplication extends BootMixin(RestApplication) {
Expand Down Expand Up @@ -50,10 +50,12 @@ export class FileUploadApplication extends BootMixin(RestApplication) {
* Configure `multer` options for file upload
*/
protected configureFileUpload(destination?: string) {
// Upload files to `dist/.sandbox` by default
destination = destination ?? path.join(__dirname, '../.sandbox');
this.bind(STORAGE_DIRECTORY).to(destination);
const multerOptions: multer.Options = {
storage: multer.diskStorage({
// Upload files to `.sandbox`
destination: destination ?? path.join(__dirname, '../.sandbox'),
destination,
// Use the original file name as is
filename: (req, file, cb) => {
cb(null, file.originalname);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ import {
import fs from 'fs';
import path from 'path';
import {promisify} from 'util';
import {STORAGE_DIRECTORY} from '../keys';

const readdir = promisify(fs.readdir);

const SANDBOX = path.resolve(__dirname, '../../.sandbox');

/**
* A controller to handle file downloads using multipart/form-data media type
*/
export class FileDownloadController {
constructor(@inject(STORAGE_DIRECTORY) private storageDirectory: string) {}
@get('/files', {
responses: {
200: {
Expand All @@ -43,7 +43,7 @@ export class FileDownloadController {
},
})
async listFiles() {
const files = await readdir(SANDBOX);
const files = await readdir(this.storageDirectory);
return files;
}

Expand All @@ -53,19 +53,19 @@ export class FileDownloadController {
@param.path.string('filename') fileName: string,
@inject(RestBindings.Http.RESPONSE) response: Response,
) {
const file = validateFileName(fileName);
const file = this.validateFileName(fileName);
response.download(file, fileName);
return response;
}
}

/**
* Validate file names to prevent them goes beyond the designated directory
* @param fileName - File name
*/
function validateFileName(fileName: string) {
const resolved = path.resolve(SANDBOX, fileName);
if (resolved.startsWith(SANDBOX)) return resolved;
// The resolved file is outside sandbox
throw new HttpErrors.BadRequest(`Invalid file name: ${fileName}`);
/**
* Validate file names to prevent them goes beyond the designated directory
* @param fileName - File name
*/
private validateFileName(fileName: string) {
const resolved = path.resolve(this.storageDirectory, fileName);
if (resolved.startsWith(this.storageDirectory)) return resolved;
// The resolved file is outside sandbox
throw new HttpErrors.BadRequest(`Invalid file name: ${fileName}`);
}
}
5 changes: 5 additions & 0 deletions examples/file-transfer/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ import {FileUploadHandler} from './types';
export const FILE_UPLOAD_SERVICE = BindingKey.create<FileUploadHandler>(
'services.FileUpload',
);

/**
* Binding key for the storage directory
*/
export const STORAGE_DIRECTORY = BindingKey.create<string>('storage.directory');
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {BooterApp} from '../fixtures/application';

describe('application metadata booter acceptance tests', () => {
let app: BooterApp;
const sandbox = new TestSandbox(resolve(__dirname, '../../.sandbox'));
const sandbox = new TestSandbox(resolve(__dirname, '../../.sandbox'), {
// We intentionally use this flag so that `dist/application.js` can keep
// its relative path to satisfy import statements
subdir: false,
});

beforeEach('reset sandbox', () => sandbox.reset());
beforeEach(getApp);
Expand Down
80 changes: 79 additions & 1 deletion packages/testlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Test utilities to help writing LoopBack 4 tests:
- Helpers for creating `supertest` clients for LoopBack applications
- HTTP request/response stubs for writing tests without a listening HTTP server
- Swagger/OpenAPI spec validation
- Test sandbox

## Installation

Expand Down Expand Up @@ -55,8 +56,9 @@ Table of contents:
- [httpGetAsync](#httpgetasync) - Async wrapper for HTTP GET requests.
- [httpsGetAsync](#httpsgetasync) - Async wrapper for HTTPS GET requests.
- [toJSON](#toJSON) - A helper to obtain JSON data representing a given object.
- [createUnexpectedHttpErrorLogger](#createUnexpectedHttpErrorLogger) - An error
- [createUnexpectedHttpErrorLogger](#createunexpectedhttprrrorlogger) - An error
logger that only logs errors for unexpected HTTP statuses.
- [TestSandbox](#testsandbox) - A sandbox directory for tests

### `expect`

Expand Down Expand Up @@ -372,6 +374,82 @@ describe('MyApp', () => {
});
```

### TestSandbox

Many tests need use a temporary directory as the sandbox to mimic a tree of
files. The `TestSandbox` class provides such facilities to create and manage a
sandbox on the file system.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for adding docs for TestSandbox 👏


#### Create a sandbox

```ts
// Create a sandbox as a unique temporary subdirectory under the rootPath
const sandbox = new TestSandbox(rootPath);
const sandbox = new TestSandbox(rootPath, {subdir: true});

// Create a sandbox in the root path directly
// This is same as the old behavior
const sandbox = new TestSandbox(rootPath, {subdir: false});

// Create a sandbox in the `test1` subdirectory of the root path
const sandbox = new TestSandbox(rootPath, {subdir: 'test1'});

// To access the target directory of a sandbox
console.log(sandbox.path);
```

#### Reset a sandbox

All files inside a sandbox will be removed when the sandbox is reset. We also
try to remove cache from `require`.

```ts
await sandbox.reset();
```

#### Delete a sandbox

Removes all files and mark the sandbox unusable.

```ts
await sandbox.delete();
```

#### Create a directory

Recursively creates a directory within the sandbox.

```ts
await sandbox.mkdir(dir);
```

#### Copy a file

Copies a file from src to the TestSandbox. If copying a `.js` file which has an
accompanying `.js.map` file in the src file location, the dest file will have
its sourceMappingURL updated to point to the original file as an absolute path
so you don't need to copy the map file.

```ts
await sandbox.copyFile(src, dest);
```

#### Write a json file

Creates a new file and writes the given data serialized as JSON.

```ts
await sandbox.writeJsonFile(dest, data);
```

#### Write a file

Creates a new file and writes the given data as a UTF-8-encoded text.

```ts
await sandbox.writeFile(dest, data);
```

## Related resources

For more info about `supertest`, please refer to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('TestSandbox integration tests', () => {
beforeEach(callSandboxDelete);

it('throws an error when trying to call getPath()', () => {
expect(() => sandbox.getPath()).to.throw(ERR);
expect(() => sandbox.path).to.throw(ERR);
});

it('throws an error when trying to call mkdir()', async () => {
Expand Down Expand Up @@ -146,11 +146,11 @@ describe('TestSandbox integration tests', () => {
}

function givenPath() {
path = sandbox.getPath();
path = sandbox.path;
}

async function deleteSandbox() {
if (!(await pathExists(path))) return;
await remove(sandbox.getPath());
await remove(sandbox.path);
}
});
51 changes: 51 additions & 0 deletions packages/testlab/src/__tests__/unit/test-sandbox.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright IBM Corp. 2020. All Rights Reserved.
// Node module: @loopback/testlab
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {dirname, join, resolve} from 'path';
import {expect, TestSandbox} from '../..';

const SANDBOX_PATH = resolve(__dirname, '../../.sandbox');

describe('TestSandbox', () => {
it('creates a subdir by default', () => {
const sandbox = new TestSandbox(SANDBOX_PATH);
expect(sandbox.path).to.not.eql(SANDBOX_PATH);
expect(dirname(sandbox.path)).to.eql(SANDBOX_PATH);
});

it('creates a unique subdir by default', () => {
const sandbox1 = new TestSandbox(SANDBOX_PATH);
const sandbox2 = new TestSandbox(SANDBOX_PATH);
expect(sandbox1.path).to.not.eql(sandbox2.path);
});

it('creates a unique subdir if it is true', () => {
const sandbox1 = new TestSandbox(SANDBOX_PATH, {subdir: true});
const sandbox2 = new TestSandbox(SANDBOX_PATH, {subdir: true});
expect(sandbox1.path).to.not.eql(sandbox2.path);
});

it('creates a named subdir', () => {
const sandbox = new TestSandbox(SANDBOX_PATH, {subdir: 'd1'});
expect(sandbox.path).to.not.eql(SANDBOX_PATH);
expect(dirname(sandbox.path)).to.eql(SANDBOX_PATH);
expect(sandbox.path).to.eql(join(SANDBOX_PATH, 'd1'));
});

it('does not creates a subdir if it is false', () => {
const sandbox = new TestSandbox(SANDBOX_PATH, {subdir: false});
expect(sandbox.path).to.eql(SANDBOX_PATH);
});

it("does not creates a subdir if it is '.'", () => {
const sandbox = new TestSandbox(SANDBOX_PATH, {subdir: '.'});
expect(sandbox.path).to.eql(SANDBOX_PATH);
});

it("does not creates a subdir if it is ''", () => {
const sandbox = new TestSandbox(SANDBOX_PATH, {subdir: ''});
expect(sandbox.path).to.eql(SANDBOX_PATH);
});
});
63 changes: 50 additions & 13 deletions packages/testlab/src/test-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,29 @@ import {
emptyDir,
ensureDir,
ensureDirSync,
mkdtempSync,
pathExists,
remove,
writeFile,
writeJson,
} from 'fs-extra';
import {parse, resolve} from 'path';
import {join, parse, resolve} from 'path';

/**
* Options for a test sandbox
*/
export interface TestSandboxOptions {
/**
* The `subdir` controls if/how the sandbox creates a subdirectory under the
* root path. It has one of the following values:
*
* - `true`: Creates a unique subdirectory. This will be the default behavior.
* - `false`: Uses the root path as the target directory without creating a
* subdirectory.
* - a string such as `sub-dir-1`: creates a subdirectory with the given value.
*/
subdir: boolean | string;
}

/**
* TestSandbox class provides a convenient way to get a reference to a
Expand All @@ -37,19 +54,39 @@ export class TestSandbox {
* Will create a directory if it doesn't already exist. If it exists, you
* still get an instance of the TestSandbox.
*
* @param path - Path of the TestSandbox. If relative (it will be resolved relative to cwd()).
*/
constructor(path: string) {
// resolve ensures path is absolute / makes it absolute (relative to cwd())
this._path = resolve(path);
ensureDirSync(this.path);
}

/**
* Returns the path of the TestSandbox
* @example
* ```ts
* // Create a sandbox as a unique temporary subdirectory under the rootPath
* const sandbox = new TestSandbox(rootPath);
* const sandbox = new TestSandbox(rootPath, {subdir: true});
*
* // Create a sandbox in the root path directly
* // This is same as the old behavior
* const sandbox = new TestSandbox(rootPath, {subdir: false});
*
* // Create a sandbox in the `test1` subdirectory of the root path
* const sandbox = new TestSandbox(rootPath, {subdir: 'test1'});
* ```
*
* @param rootPath - Root path of the TestSandbox. If relative it will be
* resolved against the current directory.
* @param options - Options to control if/how the sandbox creates a
* subdirectory for the sandbox. If not provided, the sandbox
* will automatically creates a unique temporary subdirectory. This allows
* sandboxes with the same root path can be used in parallel during testing.
*/
getPath(): string {
return this.path;
constructor(rootPath: string, options?: TestSandboxOptions) {
rootPath = resolve(rootPath);
ensureDirSync(rootPath);
options = {subdir: true, ...options};
const subdir = typeof options.subdir === 'string' ? options.subdir : '.';
if (options.subdir !== true) {
this._path = resolve(rootPath, subdir);
} else {
// Create a unique temporary directory under the root path
// See https://nodejs.org/api/fs.html#fs_fs_mkdtempsync_prefix_options
this._path = mkdtempSync(join(rootPath, `/${process.pid}`));
}
}

/**
Expand Down