diff --git a/Containers/UserSettings/Integrations/Integrations.tsx b/Containers/UserSettings/Integrations/Integrations.tsx index 379fc74..58c1e95 100644 --- a/Containers/UserSettings/Integrations/Integrations.tsx +++ b/Containers/UserSettings/Integrations/Integrations.tsx @@ -5,7 +5,6 @@ import classes from '../UserSettings.module.css'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { SpinnerDotted } from 'spinners-react'; -import { v4 as uuidv4 } from 'uuid'; import { fromUnixTime } from 'date-fns'; import { IconTrash, IconExternalLink } from '@tabler/icons-react'; import Link from 'next/link'; @@ -115,9 +114,6 @@ export default function Integrations() { clearError(); setIsLoading(true); - const token = uuidv4(); - setLastGeneratedToken({ name: data.tokenName, value: token }); - // Post API to send the new token integration try { const response = await fetch('/api/account/tokenManager', { @@ -127,13 +123,11 @@ export default function Integrations() { }, body: JSON.stringify({ name: data.tokenName, - token: token, - creation: Math.floor(Date.now() / 1000), - expiration: null, permissions: permissions, }), }); const result = await response.json(); + setLastGeneratedToken({ name: data.tokenName, value: result.token }); if (!response.ok) { setIsLoading(false); diff --git a/pages/api/account/tokenManager.js b/pages/api/account/tokenManager.js deleted file mode 100644 index 3843d53..0000000 --- a/pages/api/account/tokenManager.js +++ /dev/null @@ -1,229 +0,0 @@ -//Lib -import { promises as fs } from 'fs'; -import path from 'path'; -import { authOptions } from '../auth/[...nextauth]'; -import { getServerSession } from 'next-auth/next'; - -export default async function handler(req, res) { - if (req.method == 'POST') { - //Verify that the user is logged in. - const session = await getServerSession(req, res, authOptions); - if (!session) { - res.status(401).json({ message: 'You must be logged in.' }); - return; - } - - //The data we expect to receive - let { name, token, creation, expiration, permissions } = req.body; - - //Read the users file - //Find the absolute path of the json directory - const jsonDirectory = path.join(process.cwd(), '/config'); - let usersList = await fs.readFile(jsonDirectory + '/users.json', 'utf8'); - //Parse the usersList - usersList = JSON.parse(usersList); - - //1 : We check that we receive data for each variable. - if (!name || !token || !creation || !permissions) { - res.status(400).json({ message: 'A field is missing.' }); - return; - } - - //Control the data - const nameRegex = new RegExp('^[a-zA-Z0-9_-]{1,25}$'); - if (!nameRegex.test(name)) { - res.status(400).json({ message: 'Your token name is not valid' }); - return; - } - - //2 : Verify that the user of the session exists - const userIndex = usersList.map((user) => user.username).indexOf(session.user.name); - if (userIndex === -1) { - res.status(400).json({ message: 'User is incorrect.' }); - return; - } - const user = usersList[userIndex]; - - //3 : Check that the tokenName or tokenValue already exists - const tokenExists = - user.tokens && user.tokens.some((existingToken) => existingToken.name === name); - if (tokenExists) { - res.status(400).json({ - message: 'A token with this name already exists.', - }); - return; - } - - //4 : Add the new token - try { - let newUsersList = usersList.map((user) => - user.username == session.user.name - ? { - ...user, - tokens: [ - ...(user.tokens || []), - { - name, - token, - creation, - expiration, - permissions, - }, - ], - } - : user - ); - //Stringify the new users list - newUsersList = JSON.stringify(newUsersList); - //Write the new JSON - await fs.writeFile(jsonDirectory + '/users.json', newUsersList, (err) => { - if (err) console.log(err); - }); - res.status(200).json({ message: 'Successful API send' }); - } catch (error) { - //Log for backend - console.log(error); - //Log for frontend - if (error.code == 'ENOENT') { - res.status(500).json({ - status: 500, - message: 'No such file or directory', - }); - } else { - res.status(500).json({ - status: 500, - message: 'API error, contact the administrator', - }); - } - return; - } - } else if (req.method == 'GET') { - //Verify that the user is logged in. - const session = await getServerSession(req, res, authOptions); - if (!session) { - res.status(401).json({ message: 'You must be logged in.' }); - return; - } - try { - //Read the users file - //Find the absolute path of the json directory - const jsonDirectory = path.join(process.cwd(), '/config'); - let usersList = await fs.readFile(jsonDirectory + '/users.json', 'utf8'); - //Parse the usersList - usersList = JSON.parse(usersList); - - //Verify that the user of the session exists - const userIndex = usersList.map((user) => user.username).indexOf(session.user.name); - if (userIndex === -1) { - res.status(400).json({ - message: 'User is incorrect. Please, logout to update your session.', - }); - return; - } else { - //Send the token list without tokens - res.status(200).json([ - ...(usersList[userIndex].tokens && Array.isArray(usersList[userIndex].tokens) - ? usersList[userIndex].tokens.map((token) => ({ - name: token.name, - creation: token.creation, - expiration: token.expiration, - permissions: token.permissions, - })) - : []), - ]); - return; - } - } catch (error) { - //Log for backend - console.log(error); - //Log for frontend - if (error.code == 'ENOENT') { - res.status(500).json({ - status: 500, - message: 'No such file or directory', - }); - } else { - res.status(500).json({ - status: 500, - message: 'API error, contact the administrator', - }); - } - return; - } - } else if (req.method == 'DELETE') { - //Verify that the user is logged in. - const session = await getServerSession(req, res, authOptions); - if (!session) { - res.status(401).json({ message: 'You must be logged in.' }); - return; - } - - //The data we expect to receive - let { name } = req.body; - - //Read the users file - //Find the absolute path of the json directory - const jsonDirectory = path.join(process.cwd(), '/config'); - let usersList = await fs.readFile(jsonDirectory + '/users.json', 'utf8'); - //Parse the usersList - usersList = JSON.parse(usersList); - - //1 : We check that we receive data for each variable. - if (!name) { - res.status(400).json({ message: 'A field is missing.' }); - return; - } - - //2 : Verify that the user of the session exists - const userIndex = usersList.map((user) => user.username).indexOf(session.user.name); - if (userIndex === -1) { - res.status(400).json({ message: 'User is incorrect.' }); - return; - } - const user = usersList[userIndex]; - - //Control the data - const tokenExists = user.tokens.some((existingToken) => existingToken.name === name); - if (!tokenExists) { - res.status(400).json({ message: 'Token not found.' }); - return; - } - - //3 : Delete the token object if it exists - try { - let newUsersList = usersList.map((user) => - user.username == session.user.name - ? { - ...user, - tokens: user.tokens.filter((token) => token.name != name), - } - : user - ); - //Stringify the new users list - newUsersList = JSON.stringify(newUsersList); - //Write the new JSON - await fs.writeFile(jsonDirectory + '/users.json', newUsersList, (err) => { - if (err) console.log(err); - }); - res.status(200).json({ message: 'Successful API send' }); - } catch (error) { - //Log for backend - console.log(error); - //Log for frontend - if (error.code == 'ENOENT') { - res.status(500).json({ - status: 500, - message: 'No such file or directory', - }); - } else { - res.status(500).json({ - status: 500, - message: 'API error, contact the administrator', - }); - } - return; - } - } else { - res.status(405).json({ message: 'Bad request on API' }); - } -} diff --git a/pages/api/account/tokenManager.ts b/pages/api/account/tokenManager.ts new file mode 100644 index 0000000..313b1dd --- /dev/null +++ b/pages/api/account/tokenManager.ts @@ -0,0 +1,142 @@ +import { authOptions } from '../auth/[...nextauth]'; +import { getServerSession } from 'next-auth/next'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { IntegrationTokenType, TokenPermissionsType } from '~/types/api/integration.types'; +import ApiResponse from '~/helpers/functions/apiResponse'; +import { getUsersList, updateUsersList } from '~/helpers/functions'; +import { getUnixTime } from 'date-fns'; +import { v4 as uuidv4 } from 'uuid'; +import { BorgWarehouseApiResponse } from '~/types/api/error.types'; + +export default async function handler( + req: NextApiRequest & { body: Partial }, + res: NextApiResponse< + BorgWarehouseApiResponse | { token: string } | Omit[] + > +) { + // Auth + const session = await getServerSession(req, res, authOptions); + if (!session) { + return ApiResponse.unauthorized(res); + } + + if (req.method == 'POST') { + try { + validateRequestBody(req); + } catch (error) { + if (error instanceof Error) { + return ApiResponse.badRequest(res, error.message); + } + return ApiResponse.badRequest(res, 'Invalid request data'); + } + + try { + const { name, permissions } = req.body as IntegrationTokenType; + + const usersList = await getUsersList(); + const user = usersList.find((u) => u.username === session.user.name); + if (!user) { + return ApiResponse.unauthorized(res); + } + + const isTokenNameAlreadyExists = user.tokens?.some((t) => t.name === name); + if (isTokenNameAlreadyExists) { + return ApiResponse.badRequest(res, 'Token name already exists'); + } + + const newToken: IntegrationTokenType = { + token: uuidv4(), + name, + permissions, + creation: getUnixTime(new Date()), + }; + + const updatedUsersList = usersList.map((u) => { + if (u.username === user.username) { + u.tokens = u.tokens ? [...u.tokens, newToken] : [newToken]; + } + return u; + }); + + await updateUsersList(updatedUsersList); + return res.status(200).json({ token: newToken.token }); + } catch (error) { + console.log(error); + return ApiResponse.serverError(res); + } + } else if (req.method == 'GET') { + try { + const usersList = await getUsersList(); + const user = usersList.find((u) => u.username === session.user.name); + if (!user) { + return ApiResponse.unauthorized(res); + } + // Send the token list without the token value + const tokenList: Omit[] = + user.tokens?.map((t) => ({ + name: t.name, + creation: t.creation, + permissions: t.permissions, + })) || []; + + return res.status(200).json(tokenList); + } catch (error) { + console.log(error); + return ApiResponse.serverError(res); + } + } else if (req.method == 'DELETE') { + try { + const usersList = await getUsersList(); + const user = usersList.find((u) => u.username === session.user.name); + if (!user) { + return ApiResponse.unauthorized(res); + } + + let { name } = req.body; + if (!name) { + return ApiResponse.badRequest(res, 'Missing token name'); + } + + const isTokenNameExists = user.tokens?.some((t) => t.name === name); + if (!isTokenNameExists) { + return ApiResponse.badRequest(res, 'Token name not found'); + } + + const updatedUsersList = usersList.map((u) => { + if (u.username === user.username) { + u.tokens = u.tokens?.filter((t) => t.name !== name); + } + return u; + }); + + await updateUsersList(updatedUsersList); + return ApiResponse.success(res, 'Token deleted'); + } catch (error) { + console.log(error); + return ApiResponse.serverError; + } + } else { + return ApiResponse.methodNotAllowed(res); + } +} + +const validateRequestBody = ( + req: NextApiRequest & { body: { name: string; permissions: TokenPermissionsType } } +) => { + const { name, permissions } = req.body as { name: string; permissions: TokenPermissionsType }; + if (!name || !permissions) { + throw new Error('Missing required fields'); + } + if ( + typeof permissions.create !== 'boolean' || + typeof permissions.read !== 'boolean' || + typeof permissions.update !== 'boolean' || + typeof permissions.delete !== 'boolean' + ) { + throw new Error('Invalid permissions'); + } + const nameRegex = new RegExp('^[a-zA-Z0-9_-]{1,25}$'); + if (!nameRegex.test(name)) { + throw new Error('Your token name is not valid'); + } +}; diff --git a/tests/supertest/tokenManager.test.ts b/tests/supertest/tokenManager.test.ts new file mode 100644 index 0000000..e62ef8e --- /dev/null +++ b/tests/supertest/tokenManager.test.ts @@ -0,0 +1,164 @@ +import handler from '~/pages/api/account/tokenManager'; +import { createMocks } from 'node-mocks-http'; +import { getServerSession } from 'next-auth/next'; +import { getUsersList, updateUsersList } from '~/helpers/functions'; +import ApiResponse from '~/helpers/functions/apiResponse'; + +jest.mock('next-auth', () => { + return jest.fn(() => { + return { + auth: { session: {} }, + GET: jest.fn(), + POST: jest.fn(), + }; + }); +}); + +jest.mock('next-auth/next', () => ({ + getServerSession: jest.fn(), +})); + +jest.mock('~/helpers/functions', () => ({ + getUsersList: jest.fn(), + updateUsersList: jest.fn(), +})); + +jest.mock('~/helpers/functions/apiResponse', () => ({ + unauthorized: jest.fn(), + badRequest: jest.fn(), + serverError: jest.fn(), + methodNotAllowed: jest.fn(), + success: jest.fn(), +})); + +describe('Token Manager API', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should return unauthorized if session is not found', async () => { + (getServerSession as jest.Mock).mockResolvedValue(null); + + const { req, res } = createMocks({ + method: 'POST', + body: { + name: 'testToken', + permissions: { create: true, read: true, update: true, delete: true }, + }, + }); + + await handler(req, res); + + expect(ApiResponse.unauthorized).toHaveBeenCalledWith(res); + }); + + it('should create a new token if valid data is provided', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { name: 'testUser' } }); + (getUsersList as jest.Mock).mockResolvedValue([{ username: 'testUser', tokens: [] }]); + (updateUsersList as jest.Mock).mockResolvedValue(true); + + const { req, res } = createMocks({ + method: 'POST', + body: { + name: 'testToken', + permissions: { create: true, read: true, update: true, delete: true }, + }, + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toHaveProperty('token'); + expect(updateUsersList).toHaveBeenCalled(); + }); + + it('should return bad request if token name already exists', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { name: 'testUser' } }); + (getUsersList as jest.Mock).mockResolvedValue([ + { username: 'testUser', tokens: [{ name: 'testToken', permissions: {}, creation: 123 }] }, + ]); + + const { req, res } = createMocks({ + method: 'POST', + body: { + name: 'testToken', + permissions: { create: true, read: true, update: true, delete: true }, + }, + }); + + await handler(req, res); + + expect(ApiResponse.badRequest).toHaveBeenCalledWith(res, 'Token name already exists'); + }); + + it('should return token list for GET request', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { name: 'testUser' } }); + (getUsersList as jest.Mock).mockResolvedValue([ + { + username: 'testUser', + tokens: [ + { name: 'token1', permissions: {}, creation: 123 }, + { name: 'token2', permissions: {}, creation: 456 }, + ], + }, + ]); + + const { req, res } = createMocks({ method: 'GET' }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual([ + { name: 'token1', permissions: {}, creation: 123 }, + { name: 'token2', permissions: {}, creation: 456 }, + ]); + }); + + it('should delete a token for DELETE request', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { name: 'testUser' } }); + (getUsersList as jest.Mock).mockResolvedValue([ + { + username: 'testUser', + tokens: [ + { name: 'token1', permissions: {}, creation: 123 }, + { name: 'token2', permissions: {}, creation: 456 }, + ], + }, + ]); + (updateUsersList as jest.Mock).mockResolvedValue(true); + + const { req, res } = createMocks({ + method: 'DELETE', + body: { name: 'token1' }, + }); + + await handler(req, res); + + expect(ApiResponse.success).toHaveBeenCalledWith(res, 'Token deleted'); + expect(updateUsersList).toHaveBeenCalled(); + }); + + it('should return bad request if token name is missing in DELETE request', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { name: 'testUser' } }); + + const { req, res } = createMocks({ + method: 'DELETE', + body: {}, + }); + + await handler(req, res); + + expect(ApiResponse.badRequest).toHaveBeenCalledWith(res, 'Missing token name'); + }); + + it('should return method not allowed for unsupported HTTP methods', async () => { + const { req, res } = createMocks({ method: 'PUT' }); + + await handler(req, res); + + expect(ApiResponse.methodNotAllowed).toHaveBeenCalledWith(res); + }); +});