feat: migrate user.service to use drizzle
This commit is contained in:
parent
bf89c24702
commit
edec96bc90
11 changed files with 234 additions and 128 deletions
|
@ -24,6 +24,7 @@ module.exports = {
|
|||
},
|
||||
rules: {
|
||||
'no-restricted-exports': 0,
|
||||
'no-redeclare': 0, // already handled by @typescript-eslint/no-redeclare
|
||||
'react/display-name': 0,
|
||||
'react/prop-types': 0,
|
||||
'react/function-component-definition': 0,
|
||||
|
|
|
@ -61,7 +61,7 @@ export const handlers = [
|
|||
path: ['auth', 'me'],
|
||||
type: 'query',
|
||||
response: {
|
||||
totp_enabled: false,
|
||||
totpEnabled: false,
|
||||
id: faker.datatype.number(),
|
||||
username: faker.internet.userName(),
|
||||
},
|
||||
|
|
|
@ -28,7 +28,7 @@ describe('<OtpForm />', () => {
|
|||
|
||||
it('should prompt for password when disabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test' } }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
|
@ -46,7 +46,7 @@ describe('<OtpForm />', () => {
|
|||
|
||||
it('should show show error toast if password is incorrect while enabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: false, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: false, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
|
@ -74,7 +74,7 @@ describe('<OtpForm />', () => {
|
|||
|
||||
it('should show show error toast if password is incorrect while disabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
|
||||
render(<OtpForm />);
|
||||
|
||||
|
@ -103,7 +103,7 @@ describe('<OtpForm />', () => {
|
|||
|
||||
it('should show success toast if password is correct while disabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
|
||||
|
||||
render(<OtpForm />);
|
||||
|
@ -262,7 +262,7 @@ describe('<OtpForm />', () => {
|
|||
|
||||
it('can close the disable modal by clicking on the esc key', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, username: '', id: 1 } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, username: '', id: 1 } }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
|
|
|
@ -66,7 +66,7 @@ export const OtpForm = () => {
|
|||
});
|
||||
|
||||
const renderSetupQr = () => {
|
||||
if (!uri || me.data?.totp_enabled) return null;
|
||||
if (!uri || me.data?.totpEnabled) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
|
@ -99,7 +99,7 @@ export const OtpForm = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
|
||||
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totpEnabled} label="Enable two-factor authentication" />}
|
||||
{getTotpUri.isLoading && (
|
||||
<div className="progress w-50">
|
||||
<div className="progress-bar progress-bar-indeterminate bg-green" />
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
import { AuthServiceClass } from '../../services/auth/auth.service';
|
||||
import { router, publicProcedure, protectedProcedure } from '../../trpc';
|
||||
import { prisma } from '../../db/client';
|
||||
import { db } from '../../db';
|
||||
|
||||
const AuthService = new AuthServiceClass(prisma);
|
||||
const AuthService = new AuthServiceClass(db);
|
||||
|
||||
export const authRouter = router({
|
||||
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.login({ ...input })),
|
||||
|
|
|
@ -1,44 +1,44 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import fs from 'fs-extra';
|
||||
import * as argon2 from 'argon2';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { TotpAuthenticator } from '@/server/utils/totp';
|
||||
import { generateSessionId } from '@/server/common/get-server-auth-session';
|
||||
import { fromAny } from '@total-typescript/shoehorn';
|
||||
import { mockInsert, mockSelect } from '@/server/tests/drizzle-helpers';
|
||||
import { createDatabase, clearDatabase, closeDatabase, TestDatabase } from '@/server/tests/test-utils';
|
||||
import { encrypt } from '../../utils/encryption';
|
||||
import { setConfig } from '../../core/TipiConfig';
|
||||
import { createUser } from '../../tests/user.factory';
|
||||
import { createUser, getUserByEmail, getUserById } from '../../tests/user.factory';
|
||||
import { AuthServiceClass } from './auth.service';
|
||||
import TipiCache from '../../core/TipiCache';
|
||||
import { getTestDbClient } from '../../../../tests/server/db-connection';
|
||||
|
||||
let db: PrismaClient;
|
||||
let AuthService: AuthServiceClass;
|
||||
let database: TestDatabase;
|
||||
const TEST_SUITE = 'authservice';
|
||||
|
||||
beforeAll(async () => {
|
||||
setConfig('jwtSecret', 'test');
|
||||
db = await getTestDbClient(TEST_SUITE);
|
||||
AuthService = new AuthServiceClass(db);
|
||||
database = await createDatabase(TEST_SUITE);
|
||||
AuthService = new AuthServiceClass(database.db);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('redis');
|
||||
await setConfig('demoMode', false);
|
||||
await db.user.deleteMany();
|
||||
await clearDatabase(database);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany();
|
||||
await db.$disconnect();
|
||||
await closeDatabase(database);
|
||||
});
|
||||
|
||||
describe('Login', () => {
|
||||
it('Should return a valid jsonwebtoken containing a user id', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// Act
|
||||
const { token } = await AuthService.login({ username: email, password: 'password' });
|
||||
|
@ -61,16 +61,16 @@ describe('Login', () => {
|
|||
|
||||
it('Should throw if password is incorrect', async () => {
|
||||
const email = faker.internet.email();
|
||||
await createUser({ email }, db);
|
||||
await createUser({ email }, database);
|
||||
await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
|
||||
});
|
||||
|
||||
// TOTP
|
||||
it('should return a totp session id the user totp_enabled is true', async () => {
|
||||
it('should return a totp session id the user totpEnabled is true', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const totpSecret = TotpAuthenticator.generateSecret();
|
||||
await createUser({ email, totp_enabled: true, totp_secret: totpSecret }, db);
|
||||
await createUser({ email, totpEnabled: true, totpSecret }, database);
|
||||
|
||||
// act
|
||||
const { totpSessionId, token } = await AuthService.login({ username: email, password: 'password' });
|
||||
|
@ -90,7 +90,7 @@ describe('Test: verifyTotp', () => {
|
|||
const totpSecret = TotpAuthenticator.generateSecret();
|
||||
|
||||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
|
||||
const user = await createUser({ email, totpEnabled: true, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
const otp = TotpAuthenticator.generate(totpSecret);
|
||||
|
||||
|
@ -110,7 +110,7 @@ describe('Test: verifyTotp', () => {
|
|||
const salt = faker.random.word();
|
||||
const totpSecret = TotpAuthenticator.generateSecret();
|
||||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
|
||||
const user = await createUser({ email, totpEnabled: true, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
await TipiCache.set(totpSessionId, user.id.toString());
|
||||
|
||||
|
@ -124,7 +124,7 @@ describe('Test: verifyTotp', () => {
|
|||
const salt = faker.random.word();
|
||||
const totpSecret = TotpAuthenticator.generateSecret();
|
||||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
|
||||
const user = await createUser({ email, totpEnabled: true, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
const otp = TotpAuthenticator.generate(totpSecret);
|
||||
|
||||
|
@ -143,13 +143,13 @@ describe('Test: verifyTotp', () => {
|
|||
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' })).rejects.toThrowError('User not found');
|
||||
});
|
||||
|
||||
it('should throw if the user totp_enabled is false', async () => {
|
||||
it('should throw if the user totpEnabled is false', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const salt = faker.random.word();
|
||||
const totpSecret = TotpAuthenticator.generateSecret();
|
||||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
const user = await createUser({ email, totp_enabled: false, totp_secret: encryptedTotpSecret, salt }, db);
|
||||
const user = await createUser({ email, totpEnabled: false, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
const otp = TotpAuthenticator.generate(totpSecret);
|
||||
|
||||
|
@ -164,7 +164,7 @@ describe('Test: getTotpUri', () => {
|
|||
it('should return a valid totp uri', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// act
|
||||
const { uri, key } = await AuthService.getTotpUri({ userId: user.id, password: 'password' });
|
||||
|
@ -180,16 +180,16 @@ describe('Test: getTotpUri', () => {
|
|||
it('should create a new totp secret if the user does not have one', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// act
|
||||
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
|
||||
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
|
||||
const userFromDb = await getUserById(user.id, database);
|
||||
|
||||
// assert
|
||||
expect(userFromDb).toBeDefined();
|
||||
expect(userFromDb).not.toBeNull();
|
||||
expect(userFromDb).toHaveProperty('totp_secret');
|
||||
expect(userFromDb).toHaveProperty('totpSecret');
|
||||
expect(userFromDb).toHaveProperty('salt');
|
||||
});
|
||||
|
||||
|
@ -199,25 +199,25 @@ describe('Test: getTotpUri', () => {
|
|||
const salt = faker.random.word();
|
||||
const totpSecret = TotpAuthenticator.generateSecret();
|
||||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
|
||||
const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
|
||||
// act
|
||||
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
|
||||
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
|
||||
const userFromDb = await getUserById(user.id, database);
|
||||
|
||||
// assert
|
||||
expect(userFromDb).toBeDefined();
|
||||
expect(userFromDb).not.toBeNull();
|
||||
expect(userFromDb).toHaveProperty('totp_secret');
|
||||
expect(userFromDb).toHaveProperty('totpSecret');
|
||||
expect(userFromDb).toHaveProperty('salt');
|
||||
expect(userFromDb?.totp_secret).not.toEqual(encryptedTotpSecret);
|
||||
expect(userFromDb?.totpSecret).not.toEqual(encryptedTotpSecret);
|
||||
expect(userFromDb?.salt).toEqual(salt);
|
||||
});
|
||||
|
||||
it('should thorw an error if user has already configured totp', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email, totp_enabled: true }, db);
|
||||
const user = await createUser({ email, totpEnabled: true }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is already enabled for this user');
|
||||
|
@ -226,7 +226,7 @@ describe('Test: getTotpUri', () => {
|
|||
it('should throw an error if the user password is incorrect', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
|
||||
|
@ -244,7 +244,7 @@ describe('Test: getTotpUri', () => {
|
|||
// arrange
|
||||
await setConfig('demoMode', true);
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('2FA is not available in demo mode');
|
||||
|
@ -259,24 +259,24 @@ describe('Test: setupTotp', () => {
|
|||
const salt = faker.random.word();
|
||||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
|
||||
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
|
||||
const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
const otp = TotpAuthenticator.generate(totpSecret);
|
||||
|
||||
// act
|
||||
await AuthService.setupTotp({ userId: user.id, totpCode: otp });
|
||||
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
|
||||
const userFromDb = await getUserById(user.id, database);
|
||||
|
||||
// assert
|
||||
expect(userFromDb).toBeDefined();
|
||||
expect(userFromDb).not.toBeNull();
|
||||
expect(userFromDb).toHaveProperty('totp_enabled');
|
||||
expect(userFromDb?.totp_enabled).toBeTruthy();
|
||||
expect(userFromDb).toHaveProperty('totpEnabled');
|
||||
expect(userFromDb?.totpEnabled).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if the user has already enabled totp', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email, totp_enabled: true }, db);
|
||||
const user = await createUser({ email, totpEnabled: true }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('TOTP is already enabled for this user');
|
||||
|
@ -297,7 +297,7 @@ describe('Test: setupTotp', () => {
|
|||
const salt = faker.random.word();
|
||||
const encryptedTotpSecret = encrypt(totpSecret, salt);
|
||||
|
||||
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
|
||||
const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('Invalid TOTP code');
|
||||
|
@ -307,7 +307,7 @@ describe('Test: setupTotp', () => {
|
|||
// arrange
|
||||
await setConfig('demoMode', true);
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('2FA is not available in demo mode');
|
||||
|
@ -318,23 +318,23 @@ describe('Test: disableTotp', () => {
|
|||
it('should disable totp for the user', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email, totp_enabled: true }, db);
|
||||
const user = await createUser({ email, totpEnabled: true }, database);
|
||||
|
||||
// act
|
||||
await AuthService.disableTotp({ userId: user.id, password: 'password' });
|
||||
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
|
||||
const userFromDb = await getUserById(user.id, database);
|
||||
|
||||
// assert
|
||||
expect(userFromDb).toBeDefined();
|
||||
expect(userFromDb).not.toBeNull();
|
||||
expect(userFromDb).toHaveProperty('totp_enabled');
|
||||
expect(userFromDb?.totp_enabled).toBeFalsy();
|
||||
expect(userFromDb).toHaveProperty('totpEnabled');
|
||||
expect(userFromDb?.totpEnabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should throw if the user has already disabled totp', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email, totp_enabled: false }, db);
|
||||
const user = await createUser({ email, totpEnabled: false }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is not enabled for this user');
|
||||
|
@ -351,7 +351,7 @@ describe('Test: disableTotp', () => {
|
|||
it('should throw if the password is invalid', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email, totp_enabled: true }, db);
|
||||
const user = await createUser({ email, totpEnabled: true }, database);
|
||||
|
||||
// act & assert
|
||||
await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
|
||||
|
@ -382,7 +382,7 @@ describe('Register', () => {
|
|||
|
||||
// Act
|
||||
await AuthService.register({ username: email, password: 'test' });
|
||||
const user = await db.user.findFirst({ where: { username: email.toLowerCase().trim() } });
|
||||
const user = await getUserByEmail(email.toLowerCase().trim(), database);
|
||||
|
||||
// Assert
|
||||
expect(user).toBeDefined();
|
||||
|
@ -394,7 +394,7 @@ describe('Register', () => {
|
|||
const email = faker.internet.email();
|
||||
|
||||
// Act & Assert
|
||||
await createUser({ email, operator: true }, db);
|
||||
await createUser({ email, operator: true }, database);
|
||||
await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('There is already an admin user. Please login to create a new user from the admin panel.');
|
||||
});
|
||||
|
||||
|
@ -403,7 +403,7 @@ describe('Register', () => {
|
|||
const email = faker.internet.email();
|
||||
|
||||
// Act & Assert
|
||||
await createUser({ email, operator: false }, db);
|
||||
await createUser({ email, operator: false }, database);
|
||||
await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
|
||||
});
|
||||
|
||||
|
@ -421,7 +421,7 @@ describe('Register', () => {
|
|||
|
||||
// Act
|
||||
await AuthService.register({ username: email, password: 'test' });
|
||||
const user = await db.user.findUnique({ where: { username: email } });
|
||||
const user = await getUserByEmail(email, database);
|
||||
const isPasswordValid = await argon2.verify(user?.password || '', 'test');
|
||||
|
||||
// Assert
|
||||
|
@ -431,6 +431,16 @@ describe('Register', () => {
|
|||
it('Should throw if email is invalid', async () => {
|
||||
await expect(AuthService.register({ username: 'test', password: 'test' })).rejects.toThrowError('Invalid username');
|
||||
});
|
||||
|
||||
it('should throw if db fails to insert user', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
const mockDatabase = { select: mockSelect([]), insert: mockInsert([]) };
|
||||
const newAuthService = new AuthServiceClass(fromAny(mockDatabase));
|
||||
|
||||
// Act & Assert
|
||||
await expect(newAuthService.register({ username: email, password: 'test' })).rejects.toThrowError('Error creating user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: logout', () => {
|
||||
|
@ -526,7 +536,7 @@ describe('Test: me', () => {
|
|||
it('Should return user if user exists', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
|
||||
// Act
|
||||
const result = await AuthService.me(user.id);
|
||||
|
@ -550,7 +560,7 @@ describe('Test: isConfigured', () => {
|
|||
it('Should return true if user exists', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
await createUser({ email }, db);
|
||||
await createUser({ email }, database);
|
||||
|
||||
// Act
|
||||
const result = await AuthService.isConfigured();
|
||||
|
@ -564,7 +574,7 @@ describe('Test: changeOperatorPassword', () => {
|
|||
it('should change the password of the operator user', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
// @ts-expect-error - mocking fs
|
||||
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
|
||||
|
@ -574,14 +584,14 @@ describe('Test: changeOperatorPassword', () => {
|
|||
|
||||
// Assert
|
||||
expect(result.email).toBe(email.toLowerCase());
|
||||
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
|
||||
const updatedUser = await getUserById(user.id, database);
|
||||
expect(updatedUser?.password).not.toBe(user.password);
|
||||
});
|
||||
|
||||
it('should throw if the password change request file does not exist', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
await createUser({ email }, db);
|
||||
await createUser({ email }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
// @ts-expect-error - mocking fs
|
||||
fs.__createMockFiles({});
|
||||
|
@ -593,7 +603,7 @@ describe('Test: changeOperatorPassword', () => {
|
|||
it('should throw if there is no operator user', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
await createUser({ email, operator: false }, db);
|
||||
await createUser({ email, operator: false }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
// @ts-expect-error - mocking fs
|
||||
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
|
||||
|
@ -602,10 +612,10 @@ describe('Test: changeOperatorPassword', () => {
|
|||
await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('Operator user not found');
|
||||
});
|
||||
|
||||
it('should reset totp_secret and totp_enabled if totp is enabled', async () => {
|
||||
it('should reset totpSecret and totpEnabled if totp is enabled', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email, totp_enabled: true }, db);
|
||||
const user = await createUser({ email, totpEnabled: true }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
// @ts-expect-error - mocking fs
|
||||
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
|
||||
|
@ -615,10 +625,10 @@ describe('Test: changeOperatorPassword', () => {
|
|||
|
||||
// Assert
|
||||
expect(result.email).toBe(email.toLowerCase());
|
||||
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
|
||||
const updatedUser = await getUserById(user.id, database);
|
||||
expect(updatedUser?.password).not.toBe(user.password);
|
||||
expect(updatedUser?.totp_enabled).toBe(false);
|
||||
expect(updatedUser?.totp_secret).toBeNull();
|
||||
expect(updatedUser?.totpEnabled).toBe(false);
|
||||
expect(updatedUser?.totpSecret).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -666,14 +676,14 @@ describe('Test: changePassword', () => {
|
|||
it('should change the password of the user', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
|
||||
// act
|
||||
await AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' });
|
||||
|
||||
// assert
|
||||
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
|
||||
const updatedUser = await getUserById(user.id, database);
|
||||
expect(updatedUser?.password).not.toBe(user.password);
|
||||
});
|
||||
|
||||
|
@ -688,7 +698,7 @@ describe('Test: changePassword', () => {
|
|||
it('should throw if the password is incorrect', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
|
||||
// act & assert
|
||||
|
@ -698,7 +708,7 @@ describe('Test: changePassword', () => {
|
|||
it('should throw if password is less than 8 characters', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
const newPassword = faker.internet.password(7);
|
||||
|
||||
// act & assert
|
||||
|
@ -709,7 +719,7 @@ describe('Test: changePassword', () => {
|
|||
// arrange
|
||||
await setConfig('demoMode', true);
|
||||
const email = faker.internet.email();
|
||||
const user = await createUser({ email }, db);
|
||||
const user = await createUser({ email }, database);
|
||||
const newPassword = faker.internet.password();
|
||||
|
||||
// act & assert
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import * as argon2 from 'argon2';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import validator from 'validator';
|
||||
import { TotpAuthenticator } from '@/server/utils/totp';
|
||||
import { generateSessionId } from '@/server/common/get-server-auth-session';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { userTable } from '@/server/db/schema';
|
||||
import { getConfig } from '../../core/TipiConfig';
|
||||
import TipiCache from '../../core/TipiCache';
|
||||
import { fileExists, unlinkFile } from '../../common/fs.helpers';
|
||||
|
@ -19,10 +21,10 @@ type TokenResponse = {
|
|||
};
|
||||
|
||||
export class AuthServiceClass {
|
||||
private prisma;
|
||||
private db;
|
||||
|
||||
constructor(p: PrismaClient) {
|
||||
this.prisma = p;
|
||||
constructor(p: NodePgDatabase) {
|
||||
this.db = p;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -34,7 +36,8 @@ export class AuthServiceClass {
|
|||
public login = async (input: UsernamePasswordInput) => {
|
||||
const { password, username } = input;
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } });
|
||||
const users = await this.db.select().from(userTable).where(eq(userTable.username, username.trim().toLowerCase()));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
|
@ -48,7 +51,7 @@ export class AuthServiceClass {
|
|||
|
||||
const session = generateSessionId('auth');
|
||||
|
||||
if (user.totp_enabled) {
|
||||
if (user.totpEnabled) {
|
||||
const totpSessionId = generateSessionId('otp');
|
||||
await TipiCache.set(totpSessionId, user.id.toString());
|
||||
return { totpSessionId };
|
||||
|
@ -77,17 +80,21 @@ export class AuthServiceClass {
|
|||
throw new Error('TOTP session not found');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } });
|
||||
const users = await this.db
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, Number(userId)));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
if (!user.totp_enabled || !user.totp_secret || !user.salt) {
|
||||
if (!user.totpEnabled || !user.totpSecret || !user.salt) {
|
||||
throw new Error('TOTP is not enabled for this user');
|
||||
}
|
||||
|
||||
const totpSecret = decrypt(user.totp_secret, user.salt);
|
||||
const totpSecret = decrypt(user.totpSecret, user.salt);
|
||||
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
|
||||
|
||||
if (!isValid) {
|
||||
|
@ -116,7 +123,9 @@ export class AuthServiceClass {
|
|||
}
|
||||
|
||||
const { userId, password } = params;
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
|
@ -127,7 +136,7 @@ export class AuthServiceClass {
|
|||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
if (user.totp_enabled) {
|
||||
if (user.totpEnabled) {
|
||||
throw new Error('TOTP is already enabled for this user');
|
||||
}
|
||||
|
||||
|
@ -140,13 +149,7 @@ export class AuthServiceClass {
|
|||
|
||||
const encryptedTotpSecret = encrypt(newTotpSecret, salt);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
totp_secret: encryptedTotpSecret,
|
||||
salt,
|
||||
},
|
||||
});
|
||||
await this.db.update(userTable).set({ totpSecret: encryptedTotpSecret, salt }).where(eq(userTable.id, userId));
|
||||
|
||||
const uri = TotpAuthenticator.keyuri(user.username, 'Runtipi', newTotpSecret);
|
||||
|
||||
|
@ -159,29 +162,25 @@ export class AuthServiceClass {
|
|||
}
|
||||
|
||||
const { userId, totpCode } = params;
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
if (user.totp_enabled || !user.totp_secret || !user.salt) {
|
||||
if (user.totpEnabled || !user.totpSecret || !user.salt) {
|
||||
throw new Error('TOTP is already enabled for this user');
|
||||
}
|
||||
|
||||
const totpSecret = decrypt(user.totp_secret, user.salt);
|
||||
const totpSecret = decrypt(user.totpSecret, user.salt);
|
||||
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid TOTP code');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
totp_enabled: true,
|
||||
},
|
||||
});
|
||||
await this.db.update(userTable).set({ totpEnabled: true }).where(eq(userTable.id, userId));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
@ -189,13 +188,14 @@ export class AuthServiceClass {
|
|||
public disableTotp = async (params: { userId: number; password: string }) => {
|
||||
const { userId, password } = params;
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
if (!user.totp_enabled) {
|
||||
if (!user.totpEnabled) {
|
||||
throw new Error('TOTP is not enabled for this user');
|
||||
}
|
||||
|
||||
|
@ -204,13 +204,7 @@ export class AuthServiceClass {
|
|||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
totp_enabled: false,
|
||||
totp_secret: null,
|
||||
},
|
||||
});
|
||||
await this.db.update(userTable).set({ totpEnabled: false, totpSecret: null }).where(eq(userTable.id, userId));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
@ -223,9 +217,9 @@ export class AuthServiceClass {
|
|||
* @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
|
||||
*/
|
||||
public register = async (input: UsernamePasswordInput) => {
|
||||
const registeredUser = await this.prisma.user.findFirst({ where: { operator: true } });
|
||||
const operator = await this.db.select().from(userTable).where(eq(userTable.operator, true));
|
||||
|
||||
if (registeredUser) {
|
||||
if (operator.length > 0) {
|
||||
throw new Error('There is already an admin user. Please login to create a new user from the admin panel.');
|
||||
}
|
||||
|
||||
|
@ -240,14 +234,21 @@ export class AuthServiceClass {
|
|||
throw new Error('Invalid username');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { username: email } });
|
||||
const users = await this.db.select().from(userTable).where(eq(userTable.username, email));
|
||||
const user = users[0];
|
||||
|
||||
if (user) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const hash = await argon2.hash(password);
|
||||
const newUser = await this.prisma.user.create({ data: { username: email, password: hash, operator: true } });
|
||||
|
||||
const newUsers = await this.db.insert(userTable).values({ username: email, password: hash, operator: true }).returning();
|
||||
const newUser = newUsers[0];
|
||||
|
||||
if (!newUser) {
|
||||
throw new Error('Error creating user');
|
||||
}
|
||||
|
||||
const session = generateSessionId('auth');
|
||||
const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
|
||||
|
@ -266,7 +267,8 @@ export class AuthServiceClass {
|
|||
public me = async (userId: number | undefined) => {
|
||||
if (!userId) return null;
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true, totp_enabled: true } });
|
||||
const users = await this.db.select({ id: userTable.id, username: userTable.username, totpEnabled: userTable.totpEnabled }).from(userTable).where(eq(userTable.id, userId));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
|
@ -315,9 +317,9 @@ export class AuthServiceClass {
|
|||
* @returns {Promise<boolean>} - A boolean indicating if the system is configured or not
|
||||
*/
|
||||
public isConfigured = async (): Promise<boolean> => {
|
||||
const count = await this.prisma.user.count({ where: { operator: true } });
|
||||
const operators = await this.db.select().from(userTable).where(eq(userTable.operator, true));
|
||||
|
||||
return count > 0;
|
||||
return operators.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -334,14 +336,16 @@ export class AuthServiceClass {
|
|||
}
|
||||
|
||||
const { newPassword } = params;
|
||||
const user = await this.prisma.user.findFirst({ where: { operator: true } });
|
||||
|
||||
const users = await this.db.select().from(userTable).where(eq(userTable.operator, true));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Operator user not found');
|
||||
}
|
||||
|
||||
const hash = await argon2.hash(newPassword);
|
||||
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash, totp_enabled: false, totp_secret: null } });
|
||||
await this.db.update(userTable).set({ password: hash, totpEnabled: false, totpSecret: null }).where(eq(userTable.id, user.id));
|
||||
|
||||
await unlinkFile(`/runtipi/state/password-change-request`);
|
||||
|
||||
|
@ -384,7 +388,8 @@ export class AuthServiceClass {
|
|||
|
||||
const { currentPassword, newPassword, userId } = params;
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
|
@ -401,7 +406,7 @@ export class AuthServiceClass {
|
|||
}
|
||||
|
||||
const hash = await argon2.hash(newPassword);
|
||||
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
|
||||
await this.db.update(userTable).set({ password: hash }).where(eq(userTable.id, user.id));
|
||||
|
||||
await TipiCache.delByValue(userId.toString(), 'auth');
|
||||
|
||||
|
|
3
src/server/tests/drizzle-helpers.ts
Normal file
3
src/server/tests/drizzle-helpers.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const mockSelect = <T>(returnValue: T) => jest.fn(() => ({ from: jest.fn(() => ({ where: jest.fn(() => returnValue) })) }));
|
||||
|
||||
export const mockInsert = <T>(returnValue: T) => jest.fn(() => ({ values: jest.fn(() => ({ returning: jest.fn(() => returnValue) })) }));
|
56
src/server/tests/test-utils.ts
Normal file
56
src/server/tests/test-utils.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import pg, { Pool } from 'pg';
|
||||
import { NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { runPostgresMigrations } from '../run-migration';
|
||||
import { getConfig } from '../core/TipiConfig';
|
||||
import { appTable, userTable } from '../db/schema';
|
||||
|
||||
export type TestDatabase = {
|
||||
client: Pool;
|
||||
db: NodePgDatabase;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a test suite name, create a new database and return a client to it.
|
||||
*
|
||||
* @param {string} testsuite - name of the test suite
|
||||
*/
|
||||
const createDatabase = async (testsuite: string): Promise<TestDatabase> => {
|
||||
const pgClient = new pg.Client({
|
||||
user: getConfig().postgresUsername,
|
||||
host: getConfig().postgresHost,
|
||||
database: getConfig().postgresDatabase,
|
||||
password: getConfig().postgresPassword,
|
||||
port: getConfig().postgresPort,
|
||||
});
|
||||
await pgClient.connect();
|
||||
|
||||
await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
|
||||
await pgClient.query(`CREATE DATABASE ${testsuite}`);
|
||||
|
||||
await pgClient.end();
|
||||
|
||||
await runPostgresMigrations(testsuite);
|
||||
|
||||
const client = new Pool({
|
||||
connectionString: `postgresql://${getConfig().postgresUsername}:${getConfig().postgresPassword}@${getConfig().postgresHost}:${getConfig().postgresPort}/${testsuite}?connect_timeout=300`,
|
||||
});
|
||||
|
||||
return { client, db: drizzle(client) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the database and close the connection.
|
||||
*
|
||||
* @param {TestDatabase} database - database to clear
|
||||
*/
|
||||
const clearDatabase = async (database: TestDatabase) => {
|
||||
await database.db.delete(userTable);
|
||||
await database.db.delete(appTable);
|
||||
};
|
||||
|
||||
const closeDatabase = async (database: TestDatabase) => {
|
||||
await clearDatabase(database);
|
||||
await database.client.end();
|
||||
};
|
||||
|
||||
export { createDatabase, clearDatabase, closeDatabase };
|
|
@ -1,17 +1,41 @@
|
|||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { faker } from '@faker-js/faker';
|
||||
import * as argon2 from 'argon2';
|
||||
import { User } from '@prisma/client';
|
||||
import { prisma } from '../db/client';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { userTable, User } from '../db/schema';
|
||||
import { TestDatabase } from './test-utils';
|
||||
|
||||
const createUser = async (params: Partial<User & { email?: string }>, db = prisma) => {
|
||||
/**
|
||||
*
|
||||
* @param {User} params - user params
|
||||
* @param {TestDatabase} database - database client
|
||||
*/
|
||||
async function createUser(params: Partial<User & { email?: string }>, database: TestDatabase) {
|
||||
const { email, operator = true, ...rest } = params;
|
||||
const hash = await argon2.hash('password');
|
||||
|
||||
const username = email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim();
|
||||
const user = await db.user.create({ data: { username, password: hash, operator, ...rest } });
|
||||
|
||||
return user;
|
||||
const users = await database.db
|
||||
.insert(userTable)
|
||||
.values({ username, password: hash, operator, ...rest })
|
||||
.returning();
|
||||
const user = users[0];
|
||||
|
||||
return user as User;
|
||||
}
|
||||
|
||||
const getUserById = async (id: number, database: TestDatabase) => {
|
||||
const usersFromDb = await database.db.select().from(userTable).where(eq(userTable.id, id));
|
||||
const userFromDb = usersFromDb[0];
|
||||
|
||||
return userFromDb as User;
|
||||
};
|
||||
|
||||
export { createUser };
|
||||
const getUserByEmail = async (email: string, database: TestDatabase) => {
|
||||
const usersFromDb = await database.db.select().from(userTable).where(eq(userTable.username, email));
|
||||
const userFromDb = usersFromDb[0];
|
||||
|
||||
return userFromDb as User;
|
||||
};
|
||||
|
||||
export { createUser, getUserById, getUserByEmail };
|
||||
|
|
|
@ -3,7 +3,12 @@ import pg from 'pg';
|
|||
import { runPostgresMigrations } from '../../src/server/run-migration';
|
||||
import { getConfig } from '../../src/server/core/TipiConfig';
|
||||
|
||||
export const getTestDbClient = async (testsuite: string) => {
|
||||
/**
|
||||
* Given a test suite name, create a new database and return a client to it.
|
||||
*
|
||||
* @param {string} testsuite - name of the test suite
|
||||
*/
|
||||
async function getTestDbClient(testsuite: string) {
|
||||
const pgClient = new pg.Client({
|
||||
user: getConfig().postgresUsername,
|
||||
host: getConfig().postgresHost,
|
||||
|
@ -27,4 +32,6 @@ export const getTestDbClient = async (testsuite: string) => {
|
|||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export { getTestDbClient };
|
||||
|
|
Loading…
Reference in a new issue