refactor: cleanup now un-used graphql resolvers and services

This commit is contained in:
Nicolas Meienberger 2022-12-26 22:09:13 +01:00 committed by Nicolas Meienberger
parent f6a6b85b60
commit 4609078894
10 changed files with 16 additions and 646 deletions

View file

@ -12,7 +12,8 @@
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
"version": "echo $npm_package_version",
"release:rc": "./scripts/deploy/release-rc.sh",
"test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test ."
"test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test .",
"test:build:arm64": "docker buildx build --platform linux/arm64 -t meienberger/runtipi:test ."
},
"devDependencies": {
"@commitlint/cli": "^17.0.3",

View file

@ -26,6 +26,7 @@
"@trpc/next": "^10.7.0",
"@trpc/react-query": "^10.7.0",
"@trpc/server": "^10.7.0",
"argon2": "^0.29.1",
"clsx": "^1.1.1",
"fs-extra": "^10.1.0",
"graphql": "^15.8.0",

View file

@ -1,3 +1,11 @@
ROOT_FOLDER=/test
ROOT_FOLDER_HOST=/tipi
JWT_SECRET=secret
POSTGRES_DBNAME=postgres
POSTGRES_HOST=localhost
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
APPS_REPO_ID=repo-id
APPS_REPO_URL=http://test.com
INTERNAL_IP=localhost
JWT_SECRET=secret
STORAGE_PATH=/tipi/test

View file

@ -1,234 +0,0 @@
import { faker } from '@faker-js/faker';
import jwt from 'jsonwebtoken';
import { DataSource } from 'typeorm';
import TipiCache from '../../../config/TipiCache';
import { getConfig } from '../../../core/config/TipiConfig';
import { setupConnection, teardownConnection } from '../../../test/connection';
import { gcall } from '../../../test/gcall';
import { loginMutation, registerMutation } from '../../../test/mutations';
import { isConfiguredQuery, MeQuery, refreshTokenQuery } from '../../../test/queries';
import { TokenResponse } from '../auth.types';
import User from '../user.entity';
import { createUser } from './user.factory';
jest.mock('redis');
let db: DataSource | null = null;
const TEST_SUITE = 'authresolver';
beforeAll(async () => {
db = await setupConnection(TEST_SUITE);
});
afterAll(async () => {
await db?.destroy();
await teardownConnection(TEST_SUITE);
});
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
await User.clear();
});
describe('Test: me', () => {
const email = faker.internet.email();
let user1: User;
beforeEach(async () => {
user1 = await createUser(email);
});
it('should return null if no user is logged in', async () => {
const { data } = await gcall<{ me: User }>({
source: MeQuery,
});
expect(data?.me).toBeNull();
});
it('should return the user if a user is logged in', async () => {
const { data } = await gcall<{ me: User | null }>({
source: MeQuery,
userId: user1.id,
});
expect(data?.me?.username).toEqual(user1.username);
});
});
describe('Test: register', () => {
const email = faker.internet.email();
const password = faker.internet.password();
it('should register a user', async () => {
const { data } = await gcall<{ register: TokenResponse }>({
source: registerMutation,
variableValues: {
input: { username: email, password },
},
});
expect(data?.register).toBeDefined();
expect(data?.register?.token).toBeDefined();
const decoded = jwt.verify(data?.register?.token || '', getConfig().jwtSecret) as jwt.JwtPayload;
expect(decoded).toBeDefined();
expect(decoded).not.toBeNull();
expect(decoded).toHaveProperty('id');
expect(decoded).toHaveProperty('iat');
expect(decoded).toHaveProperty('exp');
expect(decoded).toHaveProperty('session');
});
it('should not register a user with an existing username', async () => {
await createUser(email);
const { errors } = await gcall<{ register: TokenResponse }>({
source: registerMutation,
variableValues: {
input: { username: email, password },
},
});
expect(errors?.[0].message).toEqual('User already exists');
});
it('should not register a user with a malformed email', async () => {
const { errors } = await gcall<{ register: TokenResponse }>({
source: registerMutation,
variableValues: {
input: { username: 'not an email', password },
},
});
expect(errors?.[0].message).toEqual('Invalid username');
});
});
describe('Test: login', () => {
const email = faker.internet.email();
beforeEach(async () => {
await createUser(email);
});
it('should login a user', async () => {
const { data } = await gcall<{ login: TokenResponse }>({
source: loginMutation,
variableValues: {
input: { username: email, password: 'password' },
},
});
const token = data?.login.token as string;
expect(token).toBeDefined();
const decoded = jwt.verify(token, getConfig().jwtSecret) as { id: string; session: string };
const user = await User.findOne({ where: { username: email.toLowerCase().trim() } });
expect(decoded.id).toBeDefined();
expect(user?.id).toEqual(decoded.id);
});
it('should not login a user with an incorrect password', async () => {
const { errors } = await gcall<{ login: TokenResponse }>({
source: loginMutation,
variableValues: {
input: { username: email, password: 'wrong password' },
},
});
expect(errors?.[0].message).toEqual('Wrong password');
});
it('should not login a user with a malformed email', async () => {
const { errors } = await gcall<{ login: TokenResponse }>({
source: loginMutation,
variableValues: {
input: { username: 'not an email', password: 'password' },
},
});
expect(errors?.[0].message).toEqual('User not found');
});
});
describe('Test: logout', () => {
const email = faker.internet.email();
let user1: User;
beforeEach(async () => {
user1 = await createUser(email);
});
it('should logout a user', async () => {
const { data } = await gcall<{ logout: boolean }>({
source: 'mutation { logout }',
userId: user1.id,
session: 'session',
});
expect(data?.logout).toBeTruthy();
});
});
describe('Test: isConfigured', () => {
it('should return false if no users exist', async () => {
const { data } = await gcall<{ isConfigured: boolean }>({
source: isConfiguredQuery,
});
expect(data?.isConfigured).toBeFalsy();
});
it('should return true if a user exists', async () => {
await createUser(faker.internet.email());
const { data } = await gcall<{ isConfigured: boolean }>({
source: isConfiguredQuery,
});
expect(data?.isConfigured).toBeTruthy();
});
});
describe('Test: refreshToken', () => {
const email = faker.internet.email();
let user1: User;
beforeEach(async () => {
user1 = await createUser(email);
});
it('should return a new token', async () => {
// Arrange
const session = faker.datatype.uuid();
await TipiCache.set(session, user1.id.toString());
// Act
const { data } = await gcall<{ refreshToken: TokenResponse }>({
source: refreshTokenQuery,
userId: user1.id,
session,
});
const decoded = jwt.verify(data?.refreshToken?.token || '', getConfig().jwtSecret) as jwt.JwtPayload;
// Assert
expect(data?.refreshToken).toBeDefined();
expect(data?.refreshToken?.token).toBeDefined();
expect(decoded).toBeDefined();
expect(decoded).not.toBeNull();
expect(decoded).toHaveProperty('id');
expect(decoded).toHaveProperty('iat');
expect(decoded).toHaveProperty('exp');
expect(decoded).toHaveProperty('session');
expect(decoded.id).toEqual(user1.id.toString());
expect(decoded.session).not.toEqual(session);
});
});

View file

@ -1,234 +0,0 @@
import * as argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { faker } from '@faker-js/faker';
import { DataSource } from 'typeorm';
import AuthService from '../auth.service';
import { createUser } from './user.factory';
import User from '../user.entity';
import { setupConnection, teardownConnection } from '../../../test/connection';
import { setConfig } from '../../../core/config/TipiConfig';
import TipiCache from '../../../config/TipiCache';
let db: DataSource | null = null;
const TEST_SUITE = 'authservice';
jest.mock('redis');
beforeAll(async () => {
setConfig('jwtSecret', 'test');
db = await setupConnection(TEST_SUITE);
});
beforeEach(async () => {
await User.clear();
});
afterAll(async () => {
await db?.destroy();
await teardownConnection(TEST_SUITE);
});
describe('Login', () => {
it('Should return a valid jsonwebtoken containing a user id', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser(email);
// Act
const { token } = await AuthService.login({ username: email, password: 'password' });
const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
// Assert
expect(decoded).toBeDefined();
expect(decoded).toBeDefined();
expect(decoded).not.toBeNull();
expect(decoded).toHaveProperty('id');
expect(decoded.id).toBe(user.id);
expect(decoded).toHaveProperty('iat');
expect(decoded).toHaveProperty('exp');
expect(decoded).toHaveProperty('session');
});
it('Should throw if user does not exist', async () => {
await expect(AuthService.login({ username: 'test', password: 'test' })).rejects.toThrowError('User not found');
});
it('Should throw if password is incorrect', async () => {
const email = faker.internet.email();
await createUser(email);
await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
});
});
describe('Register', () => {
it('Should return valid jsonwebtoken after register', async () => {
// Arrange
const email = faker.internet.email();
// Act
const { token } = await AuthService.register({ username: email, password: 'password' });
const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
// Assert
expect(decoded).toBeDefined();
expect(decoded).not.toBeNull();
expect(decoded).toHaveProperty('id');
expect(decoded).toHaveProperty('iat');
expect(decoded).toHaveProperty('exp');
expect(decoded).toHaveProperty('session');
});
it('Should correctly trim and lowercase email', async () => {
// Arrange
const email = faker.internet.email();
// Act
await AuthService.register({ username: email, password: 'test' });
const user = await User.findOne({ where: { username: email.toLowerCase().trim() } });
// Assert
expect(user).toBeDefined();
expect(user?.username).toBe(email.toLowerCase().trim());
});
it('Should throw if user already exists', async () => {
// Arrange
const email = faker.internet.email();
// Act & Assert
await createUser(email);
await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
});
it('Should throw if email is not provided', async () => {
await expect(AuthService.register({ username: '', password: 'test' })).rejects.toThrowError('Missing email or password');
});
it('Should throw if password is not provided', async () => {
await expect(AuthService.register({ username: faker.internet.email(), password: '' })).rejects.toThrowError('Missing email or password');
});
it('Password is correctly hashed', async () => {
// Arrange
const email = faker.internet.email().toLowerCase().trim();
// Act
await AuthService.register({ username: email, password: 'test' });
const user = await User.findOne({ where: { username: email } });
const isPasswordValid = await argon2.verify(user?.password || '', 'test');
// Assert
expect(isPasswordValid).toBe(true);
});
it('Should throw if email is invalid', async () => {
await expect(AuthService.register({ username: 'test', password: 'test' })).rejects.toThrowError('Invalid username');
});
});
describe('Test: logout', () => {
it('Should return true if there is no session to delete', async () => {
// Act
// @ts-ignore
const result = await AuthService.logout();
// Assert
expect(result).toBe(true);
});
it('Should delete session from cache', async () => {
// Arrange
const session = faker.random.alphaNumeric(32);
await TipiCache.set(session, 'test');
expect(await TipiCache.get(session)).toBe('test');
// Act
const result = await AuthService.logout(session);
// Assert
expect(result).toBe(true);
expect(await TipiCache.get('session')).toBeUndefined();
});
});
describe('Test: refreshToken', () => {
it('Should return null if session is not provided', async () => {
// Act
const result = await AuthService.refreshToken();
// Assert
expect(result).toBeNull();
});
it('Should return null if session is not found in cache', async () => {
// Act
const result = await AuthService.refreshToken('test');
// Assert
expect(result).toBeNull();
});
it('Should return a new token if session is found in cache', async () => {
// Arrange
const session = faker.random.alphaNumeric(32);
await TipiCache.set(session, 'test');
// Act
const result = await AuthService.refreshToken(session);
// Assert
expect(result).not.toBeNull();
expect(result).toHaveProperty('token');
expect(result?.token).not.toBe(session);
});
it('Should put expiration in 6 seconds for old session', async () => {
// Arrange
const session = faker.random.alphaNumeric(32);
await TipiCache.set(session, '1');
// Act
const result = await AuthService.refreshToken(session);
const expiration = await TipiCache.ttl(session);
// Assert
expect(result).not.toBeNull();
expect(result).toHaveProperty('token');
expect(result?.token).not.toBe(session);
expect(expiration).toMatchObject({ EX: 6 });
});
});
describe('Test: me', () => {
it('Should return null if userId is not provided', async () => {
// Act
const result = await AuthService.me();
// Assert
expect(result).toBeNull();
});
it('Should return null if user does not exist', async () => {
// Act
const result = await AuthService.me(1);
// Assert
expect(result).toBeNull();
});
it('Should return user if user exists', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser(email);
// Act
const result = await AuthService.me(user.id);
// Assert
expect(result).not.toBeNull();
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('username');
expect(result).toHaveProperty('createdAt');
expect(result).toHaveProperty('updatedAt');
});
});

View file

@ -1,54 +0,0 @@
import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql';
import { MyContext } from '../../types';
import { TokenResponse, UsernamePasswordInput } from './auth.types';
import AuthService from './auth.service';
import User from './user.entity';
@Resolver()
export default class AuthResolver {
@Query(() => User, { nullable: true })
async me(@Ctx() ctx: MyContext): Promise<User | null> {
return AuthService.me(ctx.req?.session?.userId);
}
@Mutation(() => TokenResponse)
async register(@Arg('input', () => UsernamePasswordInput) input: UsernamePasswordInput): Promise<TokenResponse> {
const { token } = await AuthService.register(input);
return { token };
}
@Mutation(() => TokenResponse)
async login(@Arg('input', () => UsernamePasswordInput) input: UsernamePasswordInput): Promise<TokenResponse> {
const { token } = await AuthService.login(input);
return { token };
}
@Mutation(() => Boolean)
async logout(@Ctx() { req }: MyContext): Promise<boolean> {
if (req.session.id) {
await AuthService.logout(req.session?.id);
}
req.session.userId = undefined;
req.session.id = undefined;
return true;
}
@Query(() => Boolean)
async isConfigured(): Promise<boolean> {
const users = await User.find();
return users.length > 0;
}
@Query(() => TokenResponse, { nullable: true })
async refreshToken(@Ctx() { req }: MyContext): Promise<TokenResponse | null> {
const res = await AuthService.refreshToken(req.session?.id);
return res;
}
}

View file

@ -1,102 +0,0 @@
import * as argon2 from 'argon2';
import { v4 } from 'uuid';
import jwt from 'jsonwebtoken';
import validator from 'validator';
import { getConfig } from '../../core/config/TipiConfig';
import { TokenResponse, UsernamePasswordInput } from './auth.types';
import User from './user.entity';
import TipiCache from '../../config/TipiCache';
const login = async (input: UsernamePasswordInput): Promise<TokenResponse> => {
const { password, username } = input;
const user = await User.findOne({ where: { username: username.trim().toLowerCase() } });
if (!user) {
throw new Error('User not found');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new Error('Wrong password');
}
const session = v4();
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
await TipiCache.set(session, user.id.toString());
return { token };
};
const register = async (input: UsernamePasswordInput): Promise<TokenResponse> => {
const { password, username } = input;
const email = username.trim().toLowerCase();
if (!username || !password) {
throw new Error('Missing email or password');
}
if (username.length < 3 || !validator.isEmail(email)) {
throw new Error('Invalid username');
}
const user = await User.findOne({ where: { username: email } });
if (user) {
throw new Error('User already exists');
}
const hash = await argon2.hash(password);
const newUser = await User.create({ username: email, password: hash }).save();
const session = v4();
const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(session, newUser.id.toString());
return { token };
};
const me = async (userId?: number): Promise<User | null> => {
if (!userId) return null;
const user = await User.findOne({ where: { id: userId } });
if (!user) return null;
return user;
};
const logout = async (session: string): Promise<boolean> => {
await TipiCache.del(session);
return true;
};
const refreshToken = async (session?: string): Promise<TokenResponse | null> => {
if (!session) return null;
const userId = await TipiCache.get(session);
if (!userId) return null;
// Expire token in 6 seconds
await TipiCache.set(session, userId, 6);
const newSession = v4();
const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(newSession, userId);
return { token };
};
const AuthService = {
login,
register,
me,
logout,
refreshToken,
};
export default AuthService;

View file

@ -1,18 +0,0 @@
import { Field, InputType, ObjectType } from 'type-graphql';
@InputType()
class UsernamePasswordInput {
@Field(() => String)
username!: string;
@Field(() => String)
password!: string;
}
@ObjectType()
class TokenResponse {
@Field(() => String, { nullable: false })
token!: string;
}
export { UsernamePasswordInput, TokenResponse };

View file

@ -2,11 +2,10 @@ import { GraphQLSchema } from 'graphql';
import { buildSchema } from 'type-graphql';
import { customAuthChecker } from './core/middlewares/authChecker';
import AppsResolver from './modules/apps/apps.resolver';
import AuthResolver from './modules/auth/auth.resolver';
const createSchema = (): Promise<GraphQLSchema> =>
buildSchema({
resolvers: [AppsResolver, AuthResolver],
resolvers: [AppsResolver],
validate: true,
authChecker: customAuthChecker,
});

3
pnpm-lock.yaml generated
View file

@ -51,6 +51,7 @@ importers:
'@types/validator': ^13.7.2
'@typescript-eslint/eslint-plugin': ^5.47.1
'@typescript-eslint/parser': ^5.47.1
argon2: ^0.29.1
clsx: ^1.1.1
dotenv-cli: ^6.0.0
eslint: 8.30.0
@ -91,6 +92,7 @@ importers:
ts-node: ^10.9.1
tslib: ^2.4.0
typescript: 4.9.4
uuid: ^9.0.0
validator: ^13.7.0
whatwg-fetch: ^3.6.2
winston: ^3.7.2
@ -107,6 +109,7 @@ importers:
'@trpc/next': 10.7.0_gmaturtqmtlrknaw65ek5pmv2i
'@trpc/react-query': 10.7.0_x4ie6nhblo2vtx2aafrgzlfqy4
'@trpc/server': 10.7.0
argon2: 0.29.1
clsx: 1.1.1
fs-extra: 10.1.0
graphql: 15.8.0