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