diff --git a/.eslintrc.js b/.eslintrc.js index fcb1c806..73b88cb1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,8 +12,6 @@ module.exports = { 'plugin:react/recommended', 'plugin:jsdoc/recommended', 'plugin:jsx-a11y/recommended', - 'plugin:testing-library/react', - 'plugin:jest-dom/recommended', ], parser: '@typescript-eslint/parser', parserOptions: { @@ -39,6 +37,12 @@ module.exports = { 'class-methods-use-this': 0, 'jsdoc/require-returns': 0, }, + overrides: [ + { + files: ['*.test.ts', '*.test.tsx'], + extends: ['plugin:jest-dom/recommended', 'plugin:testing-library/react'], + }, + ], globals: { JSX: true, NodeJS: true, diff --git a/__mocks__/redis.ts b/__mocks__/redis.ts index c0c49bde..b8586df6 100644 --- a/__mocks__/redis.ts +++ b/__mocks__/redis.ts @@ -14,10 +14,11 @@ export const createClient = jest.fn(() => { ttl: (key: string) => expirations.get(key), on: jest.fn(), keys: (key: string) => { + const keyprefix = key.substring(0, key.length - 1); const keys = []; // eslint-disable-next-line no-restricted-syntax for (const [k] of values) { - if (k.startsWith(key)) { + if (k.startsWith(keyprefix)) { keys.push(k); } } diff --git a/src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx b/src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx index 2064ad43..50a2de49 100644 --- a/src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx +++ b/src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx @@ -18,6 +18,10 @@ jest.mock('next/router', () => { }; }); +beforeEach(() => { + pushFn.mockClear(); +}); + describe('Test: LoginContainer', () => { it('should render without error', () => { // Arrange diff --git a/src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx b/src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx index 0847ae8f..1682139c 100644 --- a/src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx +++ b/src/client/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx @@ -18,6 +18,10 @@ jest.mock('next/router', () => { }; }); +beforeEach(() => { + pushFn.mockClear(); +}); + describe('Test: RegisterContainer', () => { it('should render without error', () => { render(); @@ -25,7 +29,7 @@ describe('Test: RegisterContainer', () => { expect(screen.getByText('Register')).toBeInTheDocument(); }); - it.only('should redirect to / upon successful registration', async () => { + it('should redirect to / upon successful registration', async () => { // Arrange const email = faker.internet.email(); const password = faker.internet.password(); diff --git a/src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx b/src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx index 707b1806..a29d4ffc 100644 --- a/src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx +++ b/src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock'; import { server } from '@/client/mocks/server'; +import { StatusProvider } from '@/components/hoc/StatusProvider'; +import { renderHook } from '@testing-library/react'; +import { useSystemStore } from '@/client/state/systemStore'; import { GeneralActions } from './GeneralActions'; import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils'; @@ -32,9 +35,40 @@ describe('Test: GeneralActions', () => { }); }); + it('should set poll status to true if update mutation succeeds', async () => { + // arrange + server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0', body: '' } })); + server.use(getTRPCMock({ path: ['system', 'update'], type: 'mutation', response: true })); + const { result } = renderHook(() => useSystemStore()); + result.current.setStatus('RUNNING'); + + render( + + + , + ); + await waitFor(() => { + expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument(); + }); + const updateButton = screen.getByRole('button', { name: /Update/i }); + fireEvent.click(updateButton); + + // act + const updateButtonModal = screen.getByRole('button', { name: /Update/i }); + fireEvent.click(updateButtonModal); + + result.current.setStatus('UPDATING'); + + // assert + await waitFor(() => { + expect(screen.getByText('Your system is updating...')).toBeInTheDocument(); + }); + expect(result.current.pollStatus).toBe(true); + }); + it('should show toast if restart mutation fails', async () => { // arrange - server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went wrong' })); + server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went badly' })); render(); const restartButton = screen.getByRole('button', { name: /Restart/i }); @@ -45,7 +79,35 @@ describe('Test: GeneralActions', () => { // assert await waitFor(() => { - expect(screen.getByText(/Something went wrong/)).toBeInTheDocument(); + expect(screen.getByText(/Something went badly/)).toBeInTheDocument(); }); }); + + it('should set poll status to true if restart mutation succeeds', async () => { + // arrange + server.use(getTRPCMock({ path: ['system', 'restart'], type: 'mutation', response: true })); + const { result } = renderHook(() => useSystemStore()); + result.current.setStatus('RUNNING'); + + render( + + + , + ); + + const restartButton = screen.getByRole('button', { name: /Restart/i }); + + // act + fireEvent.click(restartButton); + const restartButtonModal = screen.getByRole('button', { name: /Restart/i }); + fireEvent.click(restartButtonModal); + + result.current.setStatus('RESTARTING'); + + // assert + await waitFor(() => { + expect(screen.getByText('Your system is restarting...')).toBeInTheDocument(); + }); + expect(result.current.pollStatus).toBe(true); + }); }); diff --git a/src/server/core/TipiCache/TipiCache.ts b/src/server/core/TipiCache/TipiCache.ts index e4127f6d..f9f11410 100644 --- a/src/server/core/TipiCache/TipiCache.ts +++ b/src/server/core/TipiCache/TipiCache.ts @@ -68,20 +68,6 @@ class TipiCache { return Promise.all(promises); } - public async delByValue(value: string, prefix: string) { - const client = await this.getClient(); - const keys = await client.keys(`${prefix}*`); - - const promises = keys.map(async (key) => { - const val = await client.get(key); - if (val === value) { - await client.del(key); - } - }); - - return Promise.all(promises); - } - public async close() { return this.client.quit(); } diff --git a/src/server/routers/auth/auth.router.test.ts b/src/server/routers/auth/auth.router.test.ts index 028bb819..118160e2 100644 --- a/src/server/routers/auth/auth.router.test.ts +++ b/src/server/routers/auth/auth.router.test.ts @@ -1,5 +1,137 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import { authRouter } from './auth.router'; +import { TestDatabase, clearDatabase, closeDatabase, setupTestSuite } from '@/server/tests/test-utils'; +import { createUser } from '@/server/tests/user.factory'; +import { AuthRouter } from './auth.router'; + +let db: TestDatabase; +let authRouter: AuthRouter; +const TEST_SUITE = 'authrouter'; +jest.mock('fs-extra'); + +beforeAll(async () => { + const testSuite = await setupTestSuite(TEST_SUITE); + db = testSuite; + authRouter = (await import('./auth.router')).authRouter; +}); + +beforeEach(async () => { + await clearDatabase(db); +}); + +afterAll(async () => { + await closeDatabase(db); +}); + +describe('Test: login', () => { + it('should be accessible without an account', async () => { + // arrange + const caller = authRouter.createCaller(fromPartial({ req: { session: {} } })); + let error; + + // act + try { + await caller.login({ password: '123456', username: 'test' }); + } catch (e) { + error = e as { code: string }; + } + + // assert + expect(error?.code).not.toBe('UNAUTHORIZED'); + }); +}); + +describe('Test: logout', () => { + it('should not be accessible without an account', async () => { + // arrange + // @ts-expect-error - we're testing the error case + const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456, destroy: (cb) => cb() } } })); + let error; + + // act + try { + await caller.logout(); + } catch (e) { + error = e as { code: string }; + } + + // assert + expect(error?.code).toBe('UNAUTHORIZED'); + }); + + it('should be accessible with an account', async () => { + // arrange + await createUser({ id: 123456 }, db); + const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } })); + let error; + + // act + try { + await caller.logout(); + } catch (e) { + error = e as { code: string }; + } + + // assert + expect(error?.code).not.toBe('UNAUTHORIZED'); + }); +}); + +describe('Test: register', () => { + it('should be accessible without an account', async () => { + // arrange + const caller = authRouter.createCaller(fromPartial({ req: { session: {} } })); + let error; + + // act + try { + await caller.register({ username: 'test@test.com', password: '123' }); + } catch (e) { + error = e as { code: string }; + } + + // assert + expect(error?.code).not.toBe('UNAUTHORIZED'); + }); +}); + +describe('Test: me', () => { + it('should be accessible without an account', async () => { + // arrange + const caller = authRouter.createCaller(fromPartial({ req: { session: {} } })); + + // act + const result = await caller.me(); + + // assert + expect(result).toBe(null); + }); + + it('should be accessible with an account', async () => { + // arrange + await createUser({ id: 123456 }, db); + const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } })); + + // act + const result = await caller.me(); + + // assert + expect(result).not.toBe(null); + expect(result?.id).toBe(123456); + }); +}); + +describe('Test: isConfigured', () => { + it('should be accessible without an account', async () => { + // arrange + const caller = authRouter.createCaller(fromPartial({ req: { session: {} } })); + + // act + const result = await caller.isConfigured(); + + // assert + expect(result).toBe(false); + }); +}); describe('Test: verifyTotp', () => { it('should be accessible without an account', async () => { @@ -40,6 +172,7 @@ describe('Test: getTotpUri', () => { it('should be accessible with an account', async () => { // arrange + await createUser({ id: 123456 }, db); const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } })); let error; @@ -74,6 +207,7 @@ describe('Test: setupTotp', () => { it('should be accessible with an account', async () => { // arrange + await createUser({ id: 123456 }, db); const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } })); let error; @@ -108,6 +242,7 @@ describe('Test: disableTotp', () => { it('should be accessible with an account', async () => { // arrange + await createUser({ id: 123456 }, db); const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } })); let error; @@ -177,6 +312,7 @@ describe('Test: resetPassword', () => { it('should be accessible with an account', async () => { // arrange + await createUser({ id: 122 }, db); const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 122 } } })); let error; diff --git a/src/server/routers/auth/auth.router.ts b/src/server/routers/auth/auth.router.ts index c5830228..7fc777f0 100644 --- a/src/server/routers/auth/auth.router.ts +++ b/src/server/routers/auth/auth.router.ts @@ -24,3 +24,5 @@ export const authRouter = router({ setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.req.session.userId), totpCode: input.totpCode })), disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.req.session.userId), password: input.password })), }); + +export type AuthRouter = typeof authRouter; diff --git a/src/server/services/auth/auth.service.test.ts b/src/server/services/auth/auth.service.test.ts index aa7d50ec..8e32a959 100644 --- a/src/server/services/auth/auth.service.test.ts +++ b/src/server/services/auth/auth.service.test.ts @@ -672,4 +672,20 @@ describe('Test: changePassword', () => { // act & assert await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' })).rejects.toThrowError('Changing password is not allowed in demo mode'); }); + + it('should delete all sessions for the user', async () => { + // arrange + const email = faker.internet.email(); + const user = await createUser({ email }, database); + const newPassword = faker.internet.password(); + await TipiCache.set(`session:${user.id}:${faker.random.word()}`, 'test'); + + // act + await AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' }); + + // assert + // eslint-disable-next-line testing-library/no-await-sync-query + const sessions = await TipiCache.getByPrefix(`session:${user.id}:`); + expect(sessions).toHaveLength(0); + }); }); diff --git a/src/server/services/auth/auth.service.ts b/src/server/services/auth/auth.service.ts index 960f8598..ff3c97f4 100644 --- a/src/server/services/auth/auth.service.ts +++ b/src/server/services/auth/auth.service.ts @@ -7,6 +7,7 @@ import { AuthQueries } from '@/server/queries/auth/auth.queries'; import { Context } from '@/server/context'; import { getConfig } from '../../core/TipiConfig'; import TipiCache from '../../core/TipiCache'; +import { Logger } from '../../core/Logger'; import { fileExists, unlinkFile } from '../../common/fs.helpers'; import { decrypt, encrypt } from '../../utils/encryption'; @@ -345,10 +346,12 @@ export class AuthServiceClass { * @param {number} userId - The user ID */ private destroyAllSessionsByUserId = async (userId: number) => { - const sessions = await TipiCache.getByPrefix(`session:${userId}:`); - for (const session of sessions) { - await TipiCache.del(session.key); - } + await TipiCache.getByPrefix(`session:${userId}:`).then((sessions) => { + sessions.forEach((session) => { + TipiCache.del(session.key).then(() => Logger.info('Session deleted')); + TipiCache.del(`tipi:${session.val}`).then(() => Logger.info('Session key deleted')); + }); + }); }; public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => { diff --git a/src/server/tests/test-utils.ts b/src/server/tests/test-utils.ts index 4a43a41c..fc897220 100644 --- a/src/server/tests/test-utils.ts +++ b/src/server/tests/test-utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax */ import pg, { Pool } from 'pg'; import { NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres'; import { runPostgresMigrations } from '../run-migration'; @@ -53,4 +54,19 @@ const closeDatabase = async (database: TestDatabase) => { await database.client.end(); }; +/** + * Setup a test suite by mocking the database. + * + * @param {string} testSuite - name of the test suite + */ +export async function setupTestSuite(testSuite: string) { + const db = await createDatabase(testSuite); + + jest.mock('../db', () => { + return { db: db.db }; + }); + + return { db: db.db, client: db.client }; +} + export { createDatabase, clearDatabase, closeDatabase };