Browse Source

Merge pull request #16 from meienberger/tests/auth

Tests/auth
Nicolas Meienberger 3 years ago
parent
commit
c4eb712a34

+ 3 - 3
.github/workflows/ci.yml

@@ -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 - 0
.husky/pre-commit

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

+ 41 - 0
package.json

@@ -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"
+}

+ 4 - 2
packages/system-api/package.json

@@ -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",

+ 147 - 0
packages/system-api/src/modules/auth/__tests__/auth.controller.test.ts

@@ -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 });
+  });
+});

+ 71 - 0
packages/system-api/src/modules/auth/__tests__/auth.helpers.test.ts

@@ -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);
+  });
+});

+ 103 - 0
packages/system-api/src/modules/auth/__tests__/auth.service.test.ts

@@ -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);
+  });
+});

+ 21 - 39
packages/system-api/src/modules/auth/auth.controller.ts

@@ -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 };

+ 8 - 5
packages/system-api/src/modules/auth/auth.helpers.ts

@@ -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 };

+ 48 - 0
packages/system-api/src/modules/auth/auth.service.ts

@@ -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;

File diff suppressed because it is too large
+ 262 - 170
pnpm-lock.yaml


Some files were not shown because too many files changed in this diff