Merge pull request #16 from meienberger/tests/auth

Tests/auth
This commit is contained in:
Nicolas Meienberger 2022-05-12 19:25:38 +00:00 committed by GitHub
commit c4eb712a34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 737 additions and 328 deletions

View file

@ -3,12 +3,12 @@ on:
push:
env:
ROOT_FOLDER: /test
ROOT_FOLDER: /test
JWT_SECRET: "secret"
jobs:
cache-and-install:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3

5
.husky/pre-commit Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm -r test
pnpm -r lint

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "runtipi",
"version": "0.0.1",
"description": "A homeserver for everyone",
"scripts": {
"prepare": "husky install"
},
"dependencies": {
"eslint": "^8.15.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-next": "^12.1.4",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-node": "^0.3.4",
"eslint-import-resolver-typescript": "^2.4.0",
"eslint-module-utils": "^2.7.3",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.1",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-scope": "^7.1.1",
"eslint-utils": "^3.0.0",
"eslint-visitor-keys": "^3.3.0",
"prettier": "^2.6.2",
"prettier-linter-helpers": "^1.0.0"
},
"devDependencies": {
"husky": "^8.0.1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/meienberger/runtipi.git"
},
"author": "",
"license": "GNU General Public License v3.0",
"bugs": {
"url": "https://github.com/meienberger/runtipi/issues"
},
"homepage": "https://github.com/meienberger/runtipi#readme"
}

View file

@ -11,6 +11,7 @@
"clean": "rimraf dist",
"lint": "eslint . --ext .ts",
"test": "jest",
"test:watch": "jest --watch",
"build-prod": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --minify --analyze=verbose --external:./node_modules/* --format=esm",
"build:watch": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --external:./node_modules/* --format=esm --watch",
"start:dev": "NODE_ENV=development nodemon --trace-deprecation --trace-warnings --watch dist dist/server.js",
@ -20,6 +21,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"argon2": "^0.28.5",
"bcrypt": "^5.0.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
@ -29,6 +31,7 @@
"helmet": "^5.0.2",
"internal-ip": "^6.0.0",
"jsonwebtoken": "^8.5.1",
"mock-fs": "^5.1.2",
"node-port-scanner": "^3.0.1",
"p-iteration": "^1.1.8",
"passport": "^0.5.2",
@ -36,8 +39,7 @@
"passport-http-bearer": "^1.0.1",
"public-ip": "^5.0.0",
"systeminformation": "^5.11.9",
"tcp-port-used": "^1.0.2",
"mock-fs": "^5.1.2"
"tcp-port-used": "^1.0.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",

View file

@ -0,0 +1,147 @@
import { Request, Response } from 'express';
import fs from 'fs';
import * as argon2 from 'argon2';
import config from '../../../config';
import AuthController from '../auth.controller';
let user: any;
jest.mock('fs');
const next = jest.fn();
const MOCK_USER_REGISTERED = () => ({
[`${config.ROOT_FOLDER}/state/users.json`]: `[${user}]`,
});
const MOCK_NO_USER = {
[`${config.ROOT_FOLDER}/state/users.json`]: '[]',
};
beforeAll(async () => {
const hash = await argon2.hash('password');
user = JSON.stringify({
email: 'username',
password: hash,
});
});
describe('Login', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_USER_REGISTERED());
});
it('Should put cookie in response after login', async () => {
const json = jest.fn();
const res = { cookie: jest.fn(), status: jest.fn(() => ({ json })), json: jest.fn() } as unknown as Response;
const req = { body: { email: 'username', password: 'password' } } as Request;
await AuthController.login(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('tipi_token', expect.any(String), expect.any(Object));
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ token: expect.any(String) });
expect(next).not.toHaveBeenCalled();
});
it('Should throw if username is not provided in request', async () => {
const res = { cookie: jest.fn(), status: jest.fn(), json: jest.fn() } as unknown as Response;
const req = { body: { password: 'password' } } as Request;
await AuthController.login(req, res, next);
expect(res.cookie).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(Error));
});
it('Should throw if password is not provided in request', async () => {
const res = { cookie: jest.fn(), status: jest.fn(), json: jest.fn() } as unknown as Response;
const req = { body: { email: 'username' } } as Request;
await AuthController.login(req, res, next);
expect(res.cookie).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(Error));
});
});
describe('Register', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_NO_USER);
});
it('Should put cookie in response after register', async () => {
const json = jest.fn();
const res = { cookie: jest.fn(), status: jest.fn(() => ({ json })), json: jest.fn() } as unknown as Response;
const req = { body: { email: 'username', password: 'password', name: 'name' } } as Request;
await AuthController.register(req, res, next);
expect(res.cookie).toHaveBeenCalledWith('tipi_token', expect.any(String), expect.any(Object));
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ token: expect.any(String) });
});
});
describe('Me', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_USER_REGISTERED());
});
it('Should return user if present in request', async () => {
const json = jest.fn();
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
const req = { user } as Request;
await AuthController.me(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ user });
});
it('Should return null if user is not present in request', async () => {
const json = jest.fn();
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
const req = {} as Request;
await AuthController.me(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ user: null });
});
});
describe('isConfigured', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_NO_USER);
});
it('Should return false if no user is registered', async () => {
const json = jest.fn();
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
const req = {} as Request;
await AuthController.isConfigured(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ configured: false });
});
it('Should return true if user is registered', async () => {
// @ts-ignore
fs.__createMockFiles(MOCK_USER_REGISTERED());
const json = jest.fn();
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
const req = { user } as Request;
await AuthController.isConfigured(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ configured: true });
});
});

View file

@ -0,0 +1,71 @@
import * as argon2 from 'argon2';
import fs from 'fs';
import config from '../../../config';
import { IUser } from '../../../config/types';
import AuthHelpers from '../auth.helpers';
let user: IUser;
beforeAll(async () => {
const hash = await argon2.hash('password');
user = { email: 'username', password: hash, name: 'name' };
});
jest.mock('fs');
const MOCK_USER_REGISTERED = () => ({
[`${config.ROOT_FOLDER}/state/users.json`]: `[${JSON.stringify(user)}]`,
});
describe('TradeTokenForUser', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_USER_REGISTERED());
});
it('Should return null if token is invalid', () => {
const result = AuthHelpers.tradeTokenForUser('invalid token');
expect(result).toBeNull();
});
it('Should return user if token is valid', async () => {
const token = await AuthHelpers.getJwtToken(user, 'password');
const result = AuthHelpers.tradeTokenForUser(token);
expect(result).toEqual(user);
});
});
describe('GetJwtToken', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_USER_REGISTERED());
});
it('Should return token if user and password are valid', async () => {
const token = await AuthHelpers.getJwtToken(user, 'password');
expect(token).toBeDefined();
});
it('Should throw if password is invalid', async () => {
await expect(AuthHelpers.getJwtToken(user, 'invalid password')).rejects.toThrow('Wrong password');
});
});
describe('getUser', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_USER_REGISTERED());
});
it('Should return null if user is not found', () => {
const result = AuthHelpers.getUser('invalid token');
expect(result).toBeUndefined();
});
it('Should return user if token is valid', async () => {
const result = AuthHelpers.getUser('username');
expect(result).toEqual(user);
});
});

View file

@ -0,0 +1,103 @@
import fs from 'fs';
// import bcrypt from 'bcrypt';
import jsonwebtoken from 'jsonwebtoken';
import * as argon2 from 'argon2';
import config from '../../../config';
import AuthService from '../auth.service';
import { IUser } from '../../../config/types';
jest.mock('fs');
let user: any;
const MOCK_USER_REGISTERED = () => ({
[`${config.ROOT_FOLDER}/state/users.json`]: `[${user}]`,
});
const MOCK_NO_USER = {
[`${config.ROOT_FOLDER}/state/users.json`]: '[]',
};
beforeAll(async () => {
const hash = await argon2.hash('password');
user = JSON.stringify({
email: 'username',
password: hash,
});
});
describe('Login', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_USER_REGISTERED());
});
it('Should return token after login', async () => {
const token = await AuthService.login('username', 'password');
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
expect(token).toBeDefined();
expect(email).toBe('username');
});
it('Should throw if user does not exist', async () => {
await expect(AuthService.login('username1', 'password')).rejects.toThrowError('User not found');
});
it('Should throw if password is incorrect', async () => {
await expect(AuthService.login('username', 'password1')).rejects.toThrowError('Wrong password');
});
});
describe('Register', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_NO_USER);
});
it('Should return token after register', async () => {
const token = await AuthService.register('username', 'password', 'name');
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
expect(token).toBeDefined();
expect(email).toBe('username');
});
it('Should correctly write user to file', async () => {
await AuthService.register('username', 'password', 'name');
const users: IUser[] = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/users.json`, 'utf8'));
expect(users.length).toBe(1);
expect(users[0].email).toBe('username');
expect(users[0].name).toBe('name');
const valid = await argon2.verify(users[0].password, 'password');
expect(valid).toBeTruthy();
});
it('Should throw if user already exists', async () => {
await AuthService.register('username', 'password', 'name');
await expect(AuthService.register('username', 'password', 'name')).rejects.toThrowError('There is already an admin user');
});
it('Should throw if email is not provided', async () => {
await expect(AuthService.register('', 'password', 'name')).rejects.toThrowError('Missing email or password');
});
it('Should throw if password is not provided', async () => {
await expect(AuthService.register('username', '', 'name')).rejects.toThrowError('Missing email or password');
});
it('Does not throw if name is not provided', async () => {
await AuthService.register('username', 'password', '');
const users: IUser[] = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/users.json`, 'utf8'));
expect(users.length).toBe(1);
});
});

View file

@ -1,8 +1,7 @@
import { NextFunction, Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { IUser } from '../../config/types';
import { readJsonFile, writeFile } from '../fs/fs.helpers';
import { getJwtToken, getUser } from './auth.helpers';
import { readJsonFile } from '../fs/fs.helpers';
import AuthService from './auth.service';
const login = async (req: Request, res: Response, next: NextFunction) => {
try {
@ -12,13 +11,7 @@ const login = async (req: Request, res: Response, next: NextFunction) => {
throw new Error('Missing id or password');
}
const user = getUser(email);
if (!user) {
throw new Error('User not found');
}
const token = await getJwtToken(user, password);
const token = await AuthService.login(email, password);
res.cookie('tipi_token', token, {
httpOnly: false,
@ -34,26 +27,9 @@ const login = async (req: Request, res: Response, next: NextFunction) => {
const register = async (req: Request, res: Response, next: NextFunction) => {
try {
const users: IUser[] = readJsonFile('/state/users.json');
if (users.length > 0) {
throw new Error('There is already an admin user');
}
const { email, password, name } = req.body;
if (!email || !password) {
throw new Error('Missing email or password');
}
if (users.find((user) => user.email === email)) {
throw new Error('User already exists');
}
const hash = await bcrypt.hash(password, 10);
const newuser: IUser = { email, name, password: hash };
const token = await getJwtToken(newuser, password);
const token = await AuthService.register(email, password, name);
res.cookie('tipi_token', token, {
httpOnly: false,
@ -61,28 +37,34 @@ const register = async (req: Request, res: Response, next: NextFunction) => {
maxAge: 1000 * 60 * 60 * 24 * 7,
});
writeFile('/state/users.json', JSON.stringify([newuser]));
res.status(200).json({ token });
} catch (e) {
next(e);
}
};
const me = async (req: Request, res: Response) => {
const { user } = req;
const me = async (req: Request, res: Response, next: NextFunction) => {
try {
const { user } = req;
if (user) {
res.status(200).json({ user });
} else {
res.status(200).json({ user: null });
if (user) {
res.status(200).json({ user });
} else {
res.status(200).json({ user: null });
}
} catch (e) {
next(e);
}
};
const isConfigured = async (req: Request, res: Response) => {
const users: IUser[] = readJsonFile('/state/users.json');
const isConfigured = async (req: Request, res: Response, next: NextFunction) => {
try {
const users: IUser[] = readJsonFile('/state/users.json');
res.status(200).json({ configured: users.length > 0 });
res.status(200).json({ configured: users.length > 0 });
} catch (e) {
next(e);
}
};
export default { login, me, register, isConfigured };

View file

@ -1,10 +1,10 @@
import jsonwebtoken from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import * as argon2 from 'argon2';
import { IUser, Maybe } from '../../config/types';
import { readJsonFile } from '../fs/fs.helpers';
import config from '../../config';
export const getUser = (email: string): Maybe<IUser> => {
const getUser = (email: string): Maybe<IUser> => {
const savedUser: IUser[] = readJsonFile('/state/users.json');
const user = savedUser.find((u) => u.email === email);
@ -13,17 +13,19 @@ export const getUser = (email: string): Maybe<IUser> => {
};
const compareHashPassword = (password: string, hash = ''): Promise<boolean> => {
return bcrypt.compare(password, hash || '');
return argon2.verify(hash, password);
};
const getJwtToken = async (user: IUser, password: string) => {
const validPassword = await compareHashPassword(password, user.password || '');
const validPassword = await compareHashPassword(password, user.password);
if (validPassword) {
if (config.JWT_SECRET) {
return jsonwebtoken.sign({ email: user.email }, config.JWT_SECRET, {
expiresIn: '7d',
});
} else {
throw new Error('JWT_SECRET is not set');
}
}
@ -33,6 +35,7 @@ const getJwtToken = async (user: IUser, password: string) => {
const tradeTokenForUser = (token: string): Maybe<IUser> => {
try {
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
const users: IUser[] = readJsonFile('/state/users.json');
return users.find((user) => user.email === email);
@ -41,4 +44,4 @@ const tradeTokenForUser = (token: string): Maybe<IUser> => {
}
};
export { tradeTokenForUser, getJwtToken };
export default { tradeTokenForUser, getJwtToken, getUser };

View file

@ -0,0 +1,48 @@
import * as argon2 from 'argon2';
import { IUser } from '../../config/types';
import { readJsonFile, writeFile } from '../fs/fs.helpers';
import AuthHelpers from './auth.helpers';
const login = async (email: string, password: string) => {
const user = AuthHelpers.getUser(email);
if (!user) {
throw new Error('User not found');
}
const token = await AuthHelpers.getJwtToken(user, password);
return token;
};
const register = async (email: string, password: string, name: string) => {
const users: IUser[] = readJsonFile('/state/users.json');
if (users.length > 0) {
throw new Error('There is already an admin user');
}
if (!email || !password) {
throw new Error('Missing email or password');
}
if (users.find((user) => user.email === email)) {
throw new Error('User already exists');
}
const hash = await argon2.hash(password); // bcrypt.hash(password, 10);
const newuser: IUser = { email, name, password: hash };
const token = await AuthHelpers.getJwtToken(newuser, password);
writeFile('/state/users.json', JSON.stringify([newuser]));
return token;
};
const AuthService = {
login,
register,
};
export default AuthService;

565
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff