Skip to content

Commit

Permalink
Validate project config srcDir within project root
Browse files Browse the repository at this point in the history
Adds validation within `validateProjectConfig` to ensure the srcDir of a project points to either the project root, or a subdirectory.
This will help ensure a user doesn't attempt to upload a parent or root directory by mistake.

Adds a suite of jest tests to confirm our current validations, as well as the new validation logic.

To support the testing with mocks around `process.exit`, added `return` statements to all the spots in `validateProjectConfig` where the whole node process would be existing anyway.
Open to reverting those changes and using exceptions in the mocking of `process.exit` if that'd be cleaner / clearer.
  • Loading branch information
mendel-at-hubspot committed Nov 3, 2023
1 parent 9530d08 commit 0ecf9f5
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 4 deletions.
2 changes: 2 additions & 0 deletions packages/cli/lang/en.lyaml
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,8 @@ en:
startError: "Failed to start local dev server: {{ message }}"
fileChangeError: "Failed to notify local dev server of file change: {{ message }}"
projects:
config:
srcOutsideProjectDir: "Project source directory '{{ srcDir }}' should be contained within '{{ projectDir }}'"
uploadProjectFiles:
add: "Uploading {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}"
fail: "Failed to upload {{#bold}}{{ projectName }}{{/bold}} project files to {{ accountIdentifier }}"
Expand Down
150 changes: 150 additions & 0 deletions packages/cli/lib/__tests__/projects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { EXIT_CODES } = require('../enums/exitCodes');
const projects = require('../projects');

describe('@hubspot/cli/lib/projects', () => {
describe('validateProjectConfig()', () => {
let realProcess;
let projectDir;
let exitMock;
let errorSpy;

beforeAll(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'projects-'));
fs.mkdirSync(path.join(projectDir, 'src'));

realProcess = process;
errorSpy = jest.spyOn(console, 'error');
});

beforeEach(() => {
exitMock = jest.fn();
global.process = { ...realProcess, exit: exitMock };
});

afterEach(() => {
errorSpy.mockClear();
});

afterAll(() => {
global.process = realProcess;
errorSpy.mockRestore();
});

it('rejects undefined configuration', () => {
projects.validateProjectConfig(null, projectDir);

expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringMatching(/.*config not found.*/)
);
});

it('rejects configuration with missing name', () => {
projects.validateProjectConfig({ srcDir: '.' }, projectDir);

expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringMatching(/.*missing required fields*/)
);
});

it('rejects configuration with missing srcDir', () => {
projects.validateProjectConfig({ name: 'hello' }, projectDir);

expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringMatching(/.*missing required fields.*/)
);
});

describe('rejects configuration with srcDir outside project directory', () => {
it('for parent directory', () => {
projects.validateProjectConfig(
{ name: 'hello', srcDir: '..' },
projectDir
);

expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(`.*'..'.*contained within.*'${projectDir}'.*`)
)
);
});

it('for root directory', () => {
projects.validateProjectConfig(
{ name: 'hello', srcDir: '/' },
projectDir
);

expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(`.*'/'.*contained within.*'${projectDir}'.*`)
)
);
});

it('for complicated directory', () => {
const srcDir = './src/././../src/../../src';

projects.validateProjectConfig({ name: 'hello', srcDir }, projectDir);

expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(`.*'${srcDir}'.*contained within.*'${projectDir}'.*`)
)
);
});
});

it('rejects configuration with srcDir that does not exist', () => {
projects.validateProjectConfig(
{ name: 'hello', srcDir: 'foo' },
projectDir
);

expect(exitMock).toHaveBeenCalledWith(EXIT_CODES.ERROR);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringMatching(/.*could not be found in.*/)
);
});

describe('accepts configuration with valid srcDir', () => {
it('for current directory', () => {
projects.validateProjectConfig(
{ name: 'hello', srcDir: '.' },
projectDir
);

expect(exitMock).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});

it('for relative directory', () => {
projects.validateProjectConfig(
{ name: 'hello', srcDir: './src' },
projectDir
);

expect(exitMock).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});

it('for implied relative directory', () => {
projects.validateProjectConfig(
{ name: 'hello', srcDir: 'src' },
projectDir
);

expect(exitMock).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
});
});
});
19 changes: 15 additions & 4 deletions packages/cli/lib/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,21 +159,32 @@ const validateProjectConfig = (projectConfig, projectDir) => {
logger.error(
`Project config not found. Try running 'hs project create' first.`
);
process.exit(EXIT_CODES.ERROR);
return process.exit(EXIT_CODES.ERROR);
}

if (!projectConfig.name || !projectConfig.srcDir) {
logger.error(
'Project config is missing required fields. Try running `hs project create`.'
);
process.exit(EXIT_CODES.ERROR);
return process.exit(EXIT_CODES.ERROR);
}

if (!fs.existsSync(path.resolve(projectDir, projectConfig.srcDir))) {
const resolvedPath = path.resolve(projectDir, projectConfig.srcDir);
if (!resolvedPath.startsWith(projectDir)) {
logger.error(
i18n(`${i18nKey}.config.srcOutsideProjectDir`, {
srcDir: projectConfig.srcDir,
projectDir,
})
);
return process.exit(EXIT_CODES.ERROR);
}

if (!fs.existsSync(resolvedPath)) {
logger.error(
`Project source directory '${projectConfig.srcDir}' could not be found in ${projectDir}.`
);
process.exit(EXIT_CODES.ERROR);
return process.exit(EXIT_CODES.ERROR);
}
};

Expand Down

0 comments on commit 0ecf9f5

Please sign in to comment.