From dac0c41df4bcd66419d1d22d9f71bcf6f99771d4 Mon Sep 17 00:00:00 2001 From: Ravinou Date: Sun, 9 Mar 2025 11:51:43 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20=E2=9A=A1=20status=20API=20+=20misc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppriseMode/AppriseMode.tsx | 2 +- .../AppriseURLs/AppriseURLs.tsx | 2 +- .../EmailAlertSettings/EmailAlertSettings.tsx | 2 +- .../EmailSettings/EmailSettings.tsx | 2 +- .../Integrations/Integrations.tsx | 2 +- .../PasswordSettings/PasswordSettings.tsx | 2 +- .../UsernameSettings/UsernameSettings.tsx | 2 +- helpers/functions/apiResponse.ts | 27 ++ helpers/functions/fileHelpers.ts | 37 +- helpers/functions/isSshPubKeyDuplicate.ts | 10 +- helpers/functions/shell.utils.ts | 12 + helpers/templates/emailAlertStatus.ts | 4 +- pages/api/account/getAppriseAlert.ts | 2 +- pages/api/account/getAppriseMode.ts | 2 +- pages/api/account/getAppriseServices.ts | 2 +- pages/api/account/getEmailAlert.ts | 2 +- pages/api/account/updateAppriseAlert.ts | 2 +- pages/api/account/updateAppriseMode.ts | 2 +- pages/api/account/updateAppriseServices.ts | 2 +- pages/api/account/updateEmail.ts | 2 +- pages/api/account/updateEmailAlert.ts | 2 +- pages/api/account/updatePassword.ts | 2 +- pages/api/account/updateUsername.ts | 2 +- pages/api/cronjob/checkStatus.js | 223 ---------- pages/api/cronjob/checkStatus.ts | 101 +++++ tests/supertest/checkStatus.test.ts | 387 ++++++++++++++++++ types/api/error.types.ts | 6 + ...grations.types.ts => integration.types.ts} | 0 ...cations.types.ts => notification.types.ts} | 0 .../{settings.types.ts => setting.types.ts} | 0 types/api/shell.types.ts | 4 + types/domain/config.types.ts | 3 +- 32 files changed, 600 insertions(+), 250 deletions(-) create mode 100644 helpers/functions/apiResponse.ts create mode 100644 helpers/functions/shell.utils.ts delete mode 100644 pages/api/cronjob/checkStatus.js create mode 100644 pages/api/cronjob/checkStatus.ts create mode 100644 tests/supertest/checkStatus.test.ts rename types/api/{integrations.types.ts => integration.types.ts} (100%) rename types/api/{notifications.types.ts => notification.types.ts} (100%) rename types/api/{settings.types.ts => setting.types.ts} (100%) create mode 100644 types/api/shell.types.ts diff --git a/Containers/UserSettings/AppriseAlertSettings/AppriseMode/AppriseMode.tsx b/Containers/UserSettings/AppriseAlertSettings/AppriseMode/AppriseMode.tsx index 1ec3668..b430db9 100644 --- a/Containers/UserSettings/AppriseAlertSettings/AppriseMode/AppriseMode.tsx +++ b/Containers/UserSettings/AppriseAlertSettings/AppriseMode/AppriseMode.tsx @@ -9,7 +9,7 @@ import { AppriseModeEnum } from '~/types/domain/config.types'; //Components import Error from '~/Components/UI/Error/Error'; import { Optional } from '~/types'; -import { AppriseModeDTO } from '~/types/api/notifications.types'; +import { AppriseModeDTO } from '~/types/api/notification.types'; import { useFormStatus } from '~/hooks/useFormStatus'; type AppriseModeDataForm = { diff --git a/Containers/UserSettings/AppriseAlertSettings/AppriseURLs/AppriseURLs.tsx b/Containers/UserSettings/AppriseAlertSettings/AppriseURLs/AppriseURLs.tsx index 572178b..590dc6e 100644 --- a/Containers/UserSettings/AppriseAlertSettings/AppriseURLs/AppriseURLs.tsx +++ b/Containers/UserSettings/AppriseAlertSettings/AppriseURLs/AppriseURLs.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form'; //Components import Error from '~/Components/UI/Error/Error'; import { Optional } from '~/types'; -import { AppriseServicesDTO } from '~/types/api/notifications.types'; +import { AppriseServicesDTO } from '~/types/api/notification.types'; import { useFormStatus } from '~/hooks/useFormStatus'; type AppriseURLsDataForm = { diff --git a/Containers/UserSettings/EmailAlertSettings/EmailAlertSettings.tsx b/Containers/UserSettings/EmailAlertSettings/EmailAlertSettings.tsx index 8ec5010..c5c4b10 100644 --- a/Containers/UserSettings/EmailAlertSettings/EmailAlertSettings.tsx +++ b/Containers/UserSettings/EmailAlertSettings/EmailAlertSettings.tsx @@ -12,7 +12,7 @@ import Error from '~/Components/UI/Error/Error'; import Switch from '~/Components/UI/Switch/Switch'; import { useFormStatus } from '~/hooks/useFormStatus'; import { Optional } from '~/types'; -import { EmailAlertDTO } from '~/types/api/notifications.types'; +import { EmailAlertDTO } from '~/types/api/notification.types'; export default function EmailAlertSettings() { //Var diff --git a/Containers/UserSettings/EmailSettings/EmailSettings.tsx b/Containers/UserSettings/EmailSettings/EmailSettings.tsx index 86f6e87..da352db 100644 --- a/Containers/UserSettings/EmailSettings/EmailSettings.tsx +++ b/Containers/UserSettings/EmailSettings/EmailSettings.tsx @@ -10,7 +10,7 @@ import { SpinnerDotted } from 'spinners-react'; import Error from '~/Components/UI/Error/Error'; import Info from '~/Components/UI/Info/Info'; import { useFormStatus } from '~/hooks/useFormStatus'; -import { EmailSettingDTO } from '~/types/api/settings.types'; +import { EmailSettingDTO } from '~/types/api/setting.types'; export default function EmailSettings(props: EmailSettingDTO) { //Var diff --git a/Containers/UserSettings/Integrations/Integrations.tsx b/Containers/UserSettings/Integrations/Integrations.tsx index 1d0ec81..379fc74 100644 --- a/Containers/UserSettings/Integrations/Integrations.tsx +++ b/Containers/UserSettings/Integrations/Integrations.tsx @@ -18,7 +18,7 @@ import { IntegrationTokenType, TokenPermissionEnum, TokenPermissionsType, -} from '~/types/api/integrations.types'; +} from '~/types/api/integration.types'; import { useFormStatus } from '~/hooks/useFormStatus'; import { Optional } from '~/types'; diff --git a/Containers/UserSettings/PasswordSettings/PasswordSettings.tsx b/Containers/UserSettings/PasswordSettings/PasswordSettings.tsx index 980ea47..7ee6e4a 100644 --- a/Containers/UserSettings/PasswordSettings/PasswordSettings.tsx +++ b/Containers/UserSettings/PasswordSettings/PasswordSettings.tsx @@ -8,7 +8,7 @@ import { SpinnerDotted } from 'spinners-react'; //Components import Error from '~/Components/UI/Error/Error'; import { useFormStatus } from '~/hooks/useFormStatus'; -import { PasswordSettingDTO } from '~/types/api/settings.types'; +import { PasswordSettingDTO } from '~/types/api/setting.types'; export default function PasswordSettings() { //Var diff --git a/Containers/UserSettings/UsernameSettings/UsernameSettings.tsx b/Containers/UserSettings/UsernameSettings/UsernameSettings.tsx index 3f82539..b93e7ea 100644 --- a/Containers/UserSettings/UsernameSettings/UsernameSettings.tsx +++ b/Containers/UserSettings/UsernameSettings/UsernameSettings.tsx @@ -10,7 +10,7 @@ import { SpinnerDotted } from 'spinners-react'; import Error from '~/Components/UI/Error/Error'; import Info from '~/Components/UI/Info/Info'; import { useFormStatus } from '~/hooks/useFormStatus'; -import { UsernameSettingDTO } from '~/types/api/settings.types'; +import { UsernameSettingDTO } from '~/types/api/setting.types'; export default function UsernameSettings(props: UsernameSettingDTO) { //Var diff --git a/helpers/functions/apiResponse.ts b/helpers/functions/apiResponse.ts new file mode 100644 index 0000000..a632e3b --- /dev/null +++ b/helpers/functions/apiResponse.ts @@ -0,0 +1,27 @@ +import { NextApiResponse } from 'next'; + +export default class ApiResponse { + static success(res: NextApiResponse, message = 'Success', data?: T) { + res.status(200).json({ status: 200, message, data }); + } + + static unauthorized(res: NextApiResponse, message = 'Unauthorized') { + res.status(401).json({ status: 401, message }); + } + + static forbidden(res: NextApiResponse, message = 'Forbidden') { + res.status(403).json({ status: 403, message }); + } + + static notFound(res: NextApiResponse, message = 'Not Found') { + res.status(404).json({ status: 404, message }); + } + + static validationError(res: NextApiResponse, message = 'Validation Error') { + res.status(422).json({ status: 422, message }); + } + + static serverError(res: NextApiResponse, message = 'API error, contact the administrator.') { + res.status(500).json({ status: 500, message }); + } +} diff --git a/helpers/functions/fileHelpers.ts b/helpers/functions/fileHelpers.ts index 6072453..dd2aef3 100644 --- a/helpers/functions/fileHelpers.ts +++ b/helpers/functions/fileHelpers.ts @@ -1,16 +1,45 @@ import { promises as fs } from 'fs'; import path from 'path'; -import { BorgWarehouseUser } from '~/types/domain/config.types'; +import { Optional } from '~/types'; +import { BorgWarehouseUser, Repository } from '~/types/domain/config.types'; // Paths definition const jsonDirectory = path.join(process.cwd(), '/config'); const usersFilePath = path.join(jsonDirectory, 'users.json'); +const repoFilePath = path.join(jsonDirectory, 'repo.json'); export const getUsersList = async (): Promise => { - const fileContent = await fs.readFile(usersFilePath, 'utf8'); - return JSON.parse(fileContent); + try { + const fileContent = await fs.readFile(usersFilePath, 'utf8'); + return JSON.parse(fileContent) || []; + } catch (error) { + console.error('Error reading users.json:', error); + return []; + } }; export const updateUsersList = async (usersList: BorgWarehouseUser[]): Promise => { - await fs.writeFile(usersFilePath, JSON.stringify(usersList, null, 2)); + try { + await fs.writeFile(usersFilePath, JSON.stringify(usersList, null, 2)); + } catch (error) { + console.error('Error writing users.json:', error); + } +}; + +export const getRepoList = async (): Promise => { + try { + const fileContent = await fs.readFile(repoFilePath, 'utf8'); + return JSON.parse(fileContent) || []; + } catch (error) { + console.error('Error reading repo.json:', error); + return []; + } +}; + +export const updateRepoList = async (repoList: Repository[]): Promise => { + try { + await fs.writeFile(repoFilePath, JSON.stringify(repoList, null, 2)); + } catch (error) { + console.error('Error writing repo.json:', error); + } }; diff --git a/helpers/functions/isSshPubKeyDuplicate.ts b/helpers/functions/isSshPubKeyDuplicate.ts index 456f062..f33c60e 100644 --- a/helpers/functions/isSshPubKeyDuplicate.ts +++ b/helpers/functions/isSshPubKeyDuplicate.ts @@ -1,3 +1,6 @@ +import { Optional } from '~/types'; +import { Repository } from '~/types/domain/config.types'; + /** * Checks if the given SSH public key is duplicated in the provided repository list by removing the comment part. * @@ -6,7 +9,10 @@ * @returns {boolean} - Returns true if the SSH public key is duplicated, otherwise false. * @throws {Error} - Throws an error if required parameters are missing or invalid. */ -export default function isSshPubKeyDuplicate(pubKey, repoList) { +export default function isSshPubKeyDuplicate( + pubKey: string, + repoList: Array> +) { if (!pubKey || !repoList || !Array.isArray(repoList)) { throw new Error('Missing or invalid parameters for duplicate SSH public key check.'); } @@ -16,7 +22,7 @@ export default function isSshPubKeyDuplicate(pubKey, repoList) { // Check if the normalized key is already in the repository list return repoList.some((repo) => { - const repoSshKeyWithoutComment = repo.sshPublicKey.split(' ').slice(0, 2).join(' '); + const repoSshKeyWithoutComment = repo?.sshPublicKey.split(' ').slice(0, 2).join(' '); return repoSshKeyWithoutComment === pubKeyWithoutComment; }); } diff --git a/helpers/functions/shell.utils.ts b/helpers/functions/shell.utils.ts new file mode 100644 index 0000000..75b55c6 --- /dev/null +++ b/helpers/functions/shell.utils.ts @@ -0,0 +1,12 @@ +import path from 'path'; +import { promisify } from 'util'; +import { exec as execCallback } from 'node:child_process'; +import { LastSaveDTO } from '~/types/api/shell.types'; + +const exec = promisify(execCallback); + +export const getLastSaveList = async (): Promise => { + const shellsDirectory = path.join(process.cwd(), '/helpers'); + const { stdout } = await exec(`${shellsDirectory}/shells/getLastSave.sh`); + return JSON.parse(stdout || '[]'); +}; diff --git a/helpers/templates/emailAlertStatus.ts b/helpers/templates/emailAlertStatus.ts index 75f0713..e1dbee4 100644 --- a/helpers/templates/emailAlertStatus.ts +++ b/helpers/templates/emailAlertStatus.ts @@ -1,5 +1,5 @@ -export default function emailTest(mailTo, username, aliasList) { - const aliasTemplate = (x) => { +export default function emailTest(mailTo: string, username: string, aliasList: string[]) { + const aliasTemplate = (x: string[]) => { let str = ''; for (const alias of x) { str = str + '
  • ' + alias + '
  • '; diff --git a/pages/api/account/getAppriseAlert.ts b/pages/api/account/getAppriseAlert.ts index 3a073fd..993ffe0 100644 --- a/pages/api/account/getAppriseAlert.ts +++ b/pages/api/account/getAppriseAlert.ts @@ -5,7 +5,7 @@ import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; import { BorgWarehouseUser } from '~/types/domain/config.types'; -import { AppriseAlertResponse } from '~/types/api/notifications.types'; +import { AppriseAlertResponse } from '~/types/api/notification.types'; import { ErrorResponse } from '~/types/api/error.types'; export default async function handler( diff --git a/pages/api/account/getAppriseMode.ts b/pages/api/account/getAppriseMode.ts index 4f54580..76eabd8 100644 --- a/pages/api/account/getAppriseMode.ts +++ b/pages/api/account/getAppriseMode.ts @@ -4,7 +4,7 @@ import path from 'path'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; -import { AppriseModeDTO } from '~/types/api/notifications.types'; +import { AppriseModeDTO } from '~/types/api/notification.types'; import { ErrorResponse } from '~/types/api/error.types'; import { BorgWarehouseUser } from '~/types/domain/config.types'; diff --git a/pages/api/account/getAppriseServices.ts b/pages/api/account/getAppriseServices.ts index 78376cd..fbc376e 100644 --- a/pages/api/account/getAppriseServices.ts +++ b/pages/api/account/getAppriseServices.ts @@ -4,7 +4,7 @@ import path from 'path'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; -import { AppriseServicesDTO } from '~/types/api/notifications.types'; +import { AppriseServicesDTO } from '~/types/api/notification.types'; import { ErrorResponse } from '~/types/api/error.types'; import { BorgWarehouseUser } from '~/types/domain/config.types'; diff --git a/pages/api/account/getEmailAlert.ts b/pages/api/account/getEmailAlert.ts index b575f53..ea68acb 100644 --- a/pages/api/account/getEmailAlert.ts +++ b/pages/api/account/getEmailAlert.ts @@ -4,7 +4,7 @@ import path from 'path'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; -import { EmailAlertDTO } from '~/types/api/notifications.types'; +import { EmailAlertDTO } from '~/types/api/notification.types'; import { ErrorResponse } from '~/types/api/error.types'; import { BorgWarehouseUser } from '~/types/domain/config.types'; diff --git a/pages/api/account/updateAppriseAlert.ts b/pages/api/account/updateAppriseAlert.ts index 1fa6720..3d28d5c 100644 --- a/pages/api/account/updateAppriseAlert.ts +++ b/pages/api/account/updateAppriseAlert.ts @@ -3,7 +3,7 @@ import { getUsersList, updateUsersList } from '~/helpers/functions/fileHelpers'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; -import { AppriseAlertDTO, AppriseAlertResponse } from '~/types/api/notifications.types'; +import { AppriseAlertDTO, AppriseAlertResponse } from '~/types/api/notification.types'; import { ErrorResponse } from '~/types/api/error.types'; export default async function handler( diff --git a/pages/api/account/updateAppriseMode.ts b/pages/api/account/updateAppriseMode.ts index 2e6b15b..7f49880 100644 --- a/pages/api/account/updateAppriseMode.ts +++ b/pages/api/account/updateAppriseMode.ts @@ -2,7 +2,7 @@ import { getUsersList, updateUsersList } from '~/helpers/functions/fileHelpers'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; -import { AppriseModeDTO } from '~/types/api/notifications.types'; +import { AppriseModeDTO } from '~/types/api/notification.types'; import { ErrorResponse } from '~/types/api/error.types'; export default async function handler( diff --git a/pages/api/account/updateAppriseServices.ts b/pages/api/account/updateAppriseServices.ts index b7246e5..84bf921 100644 --- a/pages/api/account/updateAppriseServices.ts +++ b/pages/api/account/updateAppriseServices.ts @@ -2,7 +2,7 @@ import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; -import { AppriseServicesDTO } from '~/types/api/notifications.types'; +import { AppriseServicesDTO } from '~/types/api/notification.types'; import { ErrorResponse } from '~/types/api/error.types'; import { getUsersList, updateUsersList } from '~/helpers/functions/fileHelpers'; diff --git a/pages/api/account/updateEmail.ts b/pages/api/account/updateEmail.ts index 5614437..60dd2b3 100644 --- a/pages/api/account/updateEmail.ts +++ b/pages/api/account/updateEmail.ts @@ -2,7 +2,7 @@ import { getUsersList, updateUsersList } from '~/helpers/functions/fileHelpers'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; -import { EmailSettingDTO } from '~/types/api/settings.types'; +import { EmailSettingDTO } from '~/types/api/setting.types'; import { ErrorResponse } from '~/types/api/error.types'; export default async function handler( diff --git a/pages/api/account/updateEmailAlert.ts b/pages/api/account/updateEmailAlert.ts index 116ee46..d8aea2b 100644 --- a/pages/api/account/updateEmailAlert.ts +++ b/pages/api/account/updateEmailAlert.ts @@ -1,7 +1,7 @@ import { getUsersList, updateUsersList } from '~/helpers/functions/fileHelpers'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; -import { EmailAlertDTO } from '~/types/api/notifications.types'; +import { EmailAlertDTO } from '~/types/api/notification.types'; import { NextApiRequest, NextApiResponse } from 'next'; import { ErrorResponse } from '~/types/api/error.types'; diff --git a/pages/api/account/updatePassword.ts b/pages/api/account/updatePassword.ts index d0dac57..c211eab 100644 --- a/pages/api/account/updatePassword.ts +++ b/pages/api/account/updatePassword.ts @@ -3,7 +3,7 @@ import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; import { NextApiRequest, NextApiResponse } from 'next'; import { ErrorResponse } from '~/types/api/error.types'; -import { PasswordSettingDTO } from '~/types/api/settings.types'; +import { PasswordSettingDTO } from '~/types/api/setting.types'; export default async function handler( req: NextApiRequest & { body: PasswordSettingDTO }, diff --git a/pages/api/account/updateUsername.ts b/pages/api/account/updateUsername.ts index c4e04b9..10e062a 100644 --- a/pages/api/account/updateUsername.ts +++ b/pages/api/account/updateUsername.ts @@ -1,7 +1,7 @@ import { getUsersList, updateUsersList } from '~/helpers/functions'; import { authOptions } from '../auth/[...nextauth]'; import { getServerSession } from 'next-auth/next'; -import { UsernameSettingDTO } from '~/types/api/settings.types'; +import { UsernameSettingDTO } from '~/types/api/setting.types'; import { NextApiRequest, NextApiResponse } from 'next'; import { ErrorResponse } from '~/types/api/error.types'; diff --git a/pages/api/cronjob/checkStatus.js b/pages/api/cronjob/checkStatus.js deleted file mode 100644 index 26658e8..0000000 --- a/pages/api/cronjob/checkStatus.js +++ /dev/null @@ -1,223 +0,0 @@ -// This API is design to be used by a cron (of your choice). Call it with curl for example -//(e.g : curl --request POST --url 'http://localhost:3000/api/cronjob/checkStatus' --header 'Authorization: Bearer 5173f388c0f4a0df92d1412c3036ddc897c22e4448') - -//Lib -import { promises as fs } from 'fs'; -import path from 'path'; -const util = require('node:util'); -const exec = util.promisify(require('node:child_process').exec); -import nodemailerSMTP from '../../../helpers/functions/nodemailerSMTP'; -import emailAlertStatus from '../../../helpers/templates/emailAlertStatus'; - -export default async function handler(req, res) { - if (req.headers.authorization == null) { - res.status(401).json({ - status: 401, - message: 'Unauthorized', - }); - return; - } - - const CRONJOB_KEY = process.env.CRONJOB_KEY; - const ACTION_KEY = req.headers.authorization.split(' ')[1]; - - if (req.method == 'POST' && ACTION_KEY === CRONJOB_KEY) { - //Var - let newRepoList; - let repoListToSendAlert = []; - let usersList; - const date = Math.round(Date.now() / 1000); - const jsonDirectory = path.join(process.cwd(), '/config'); - - ////PART 1 : Status - try { - //Check if there are some repositories - let repoList = await fs.readFile(jsonDirectory + '/repo.json', 'utf8'); - repoList = JSON.parse(repoList); - if (repoList.length === 0) { - res.status(200).json({ - success: 'Status cron has been executed. No repository to check.', - }); - return; - } - - //Call the shell : getLastSave.sh - //Find the absolute path of the shells directory - const shellsDirectory = path.join(process.cwd(), '/helpers'); - //Exec the shell - const { stdout, stderr } = await exec(`${shellsDirectory}/shells/getLastSave.sh`); - if (stderr) { - console.log('stderr:', stderr); - res.status(500).json({ - status: 500, - message: 'Error on getting the date for last save, contact the administrator.', - }); - return; - } - //Parse the JSON output of getLastSave.sh to use it - const lastSave = JSON.parse(stdout); - - //Rebuild a newRepoList with the lastSave timestamp updated and the status updated. - newRepoList = repoList; - for (let index in newRepoList) { - const repoFiltered = lastSave.filter( - (x) => x.repositoryName === newRepoList[index].repositoryName - ); - if (repoFiltered.length === 1) { - //Write the timestamp of the last save - newRepoList[index].lastSave = repoFiltered[0].lastSave; - //Trigger the status if the last save is older than alert setting. - if (date - newRepoList[index].lastSave > newRepoList[index].alert) { - newRepoList[index].status = false; - } else if (date - newRepoList[index].lastSave < newRepoList[index].alert) { - newRepoList[index].status = true; - } - } - } - } catch (err) { - res.status(500).json({ - status: 500, - message: "API error : can't update the status.", - }); - return; - } - - //// PART 2 : check if there is a repo that need an alert - try { - //Here, a mail is sent every 24H (90000) if a repo has down status - for (let index in newRepoList) { - if ( - !newRepoList[index].status && - newRepoList[index].alert !== 0 && - (!newRepoList[index].lastStatusAlertSend || - date - newRepoList[index].lastStatusAlertSend > 90000) - ) { - repoListToSendAlert.push(newRepoList[index].alias); - newRepoList[index].lastStatusAlertSend = date; - } - } - } catch (err) { - res.status(500).json({ - status: 500, - message: "API error : can't check if a repo needs an email alert.", - }); - return; - } - - //PART 3 : Save the new repoList - try { - //Stringify the repoList to write it into the json file. - newRepoList = JSON.stringify(newRepoList); - //Write the new json - await fs.writeFile(jsonDirectory + '/repo.json', newRepoList, (err) => { - if (err) console.log(err); - }); - } catch (err) { - res.status(500).json({ - status: 500, - message: "API error : can't write the new repoList.", - }); - return; - } - //PART 4 : Send the alerts - if (repoListToSendAlert.length > 0) { - // Read user informations - try { - //Read the email of the user - usersList = await fs.readFile(jsonDirectory + '/users.json', 'utf8'); - //Parse the usersList - usersList = JSON.parse(usersList); - } catch (err) { - res.status(500).json({ - status: 500, - message: "API error : can't read user information.", - }); - return; - } - ////EMAIL - // If the user has enabled email alerts - if (usersList[0].emailAlert) { - //Send mail - //Create the SMTP Transporter - const transporter = nodemailerSMTP(); - //Mail options - const mailData = emailAlertStatus( - usersList[0].email, - usersList[0].username, - repoListToSendAlert - ); - transporter.sendMail(mailData, function (err, info) { - if (err) { - console.log(err); - } else { - console.log(info); - } - }); - } - ////APPRISE - // If the user has enabled Apprise alerts - if (usersList[0].appriseAlert) { - let appriseServicesURLs = ''; - for (let service of usersList[0].appriseServices) { - appriseServicesURLs = appriseServicesURLs + service + ' '; - } - //Mode : package - if (usersList[0].appriseMode === 'package') { - try { - //Send notification via local package. - await exec( - `apprise -v -b '🔴 Some repositories on BorgWarehouse need attention !\nList of down repositories :\n ${repoListToSendAlert}' ${appriseServicesURLs}` - ); - } catch (err) { - console.log(err.stderr); - res.status(500).json({ - message: 'Error : ' + err.stderr, - }); - return; - } - - //Mode : stateless - } else if (usersList[0].appriseMode === 'stateless') { - try { - await fetch(usersList[0].appriseStatelessURL + '/notify', { - method: 'POST', - headers: { - 'Content-type': 'application/json', - }, - body: JSON.stringify({ - urls: appriseServicesURLs, - body: - '🔴 Some repositories on BorgWarehouse need attention !\nList of down repositories :\n' + - repoListToSendAlert, - }), - }); - } catch (err) { - console.log(err); - res.status(500).json({ - message: 'Error : ' + err.message, - }); - return; - } - - //Mode : unknown - } else { - res.status(422).json({ - message: 'No Apprise Mode selected or supported.', - }); - } - } - } - - //PART 5 : Sucess - res.status(200).json({ - success: 'Status cron has been executed.', - }); - return; - } else { - res.status(401).json({ - status: 401, - message: 'Unauthorized', - }); - return; - } -} diff --git a/pages/api/cronjob/checkStatus.ts b/pages/api/cronjob/checkStatus.ts new file mode 100644 index 0000000..2b6804e --- /dev/null +++ b/pages/api/cronjob/checkStatus.ts @@ -0,0 +1,101 @@ +import { getUnixTime } from 'date-fns'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { exec as execCallback } from 'node:child_process'; +import { promisify } from 'util'; +import ApiResponse from '~/helpers/functions/apiResponse'; +import { getRepoList, getUsersList, updateRepoList } from '~/helpers/functions/fileHelpers'; +import nodemailerSMTP from '~/helpers/functions/nodemailerSMTP'; +import { getLastSaveList } from '~/helpers/functions/shell.utils'; +import emailAlertStatus from '~/helpers/templates/emailAlertStatus'; +import { BorgWarehouseApiResponse } from '~/types/api/error.types'; + +const exec = promisify(execCallback); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (!req.headers.authorization) { + return ApiResponse.unauthorized(res); + } + + const CRONJOB_KEY = process.env.CRONJOB_KEY; + const ACTION_KEY = req.headers.authorization.split(' ')[1]; + + if (req.method !== 'POST' || ACTION_KEY !== CRONJOB_KEY) { + return ApiResponse.unauthorized(res); + } + + try { + const repoList = await getRepoList(); + const lastSaveList = await getLastSaveList(); + if (repoList.length === 0 || lastSaveList.length === 0) { + return ApiResponse.success(res, 'Status cron executed. No repository to check.'); + } + const date = getUnixTime(new Date()); + + // Update the status and the last timestamp backup of each repository + const updatedRepoList = repoList.map((repo) => { + const repoFiltered = lastSaveList.find((x) => x.repositoryName === repo.repositoryName); + if (!repoFiltered) return repo; + const lastSaveTimestamp = repoFiltered.lastSave; + return { + ...repo, + lastSave: lastSaveTimestamp, + status: date - lastSaveTimestamp <= (repo?.alert ?? 0), + }; + }); + + const repoListToSendAlert: string[] = []; + updatedRepoList.forEach((repo) => { + if ( + !repo.status && + repo.alert !== 0 && + (!repo.lastStatusAlertSend || date - repo.lastStatusAlertSend > 90000) + ) { + repo.lastStatusAlertSend = date; + repoListToSendAlert.push(repo.alias); + } + }); + + if (repoListToSendAlert.length > 0) { + const usersList = await getUsersList(); + + // Send Email Alert + if (usersList[0].emailAlert) { + const transporter = nodemailerSMTP(); + const mailData = emailAlertStatus( + usersList[0].email, + usersList[0].username, + repoListToSendAlert + ); + transporter.sendMail(mailData, (err) => { + if (err) console.log(err); + }); + } + + // Send Apprise Alert + if (usersList[0].appriseAlert) { + const appriseServicesURLs = usersList[0].appriseServices?.join(' '); + const message = `🔴 Some repositories on BorgWarehouse need attention !\nList of down repositories :\n ${repoListToSendAlert}`; + if (usersList[0].appriseMode === 'package') { + await exec(`apprise -v -b '${message}' ${appriseServicesURLs}`); + } else if (usersList[0].appriseMode === 'stateless') { + await fetch(`${usersList[0].appriseStatelessURL}/notify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ urls: appriseServicesURLs, body: message }), + }); + } else { + return ApiResponse.validationError(res, 'No Apprise Mode selected or supported.'); + } + } + } + + await updateRepoList(updatedRepoList); + return ApiResponse.success(res, 'Status cron executed successfully.'); + } catch (error) { + console.error(error); + return ApiResponse.serverError(res); + } +} diff --git a/tests/supertest/checkStatus.test.ts b/tests/supertest/checkStatus.test.ts new file mode 100644 index 0000000..e3e77e5 --- /dev/null +++ b/tests/supertest/checkStatus.test.ts @@ -0,0 +1,387 @@ +import { createMocks } from 'node-mocks-http'; +import handler from '~/pages/api/cronjob/checkStatus'; +import { getRepoList, getUsersList, updateRepoList } from '~/helpers/functions/fileHelpers'; +import { getLastSaveList } from '~/helpers/functions/shell.utils'; +import nodemailerSMTP from '~/helpers/functions/nodemailerSMTP'; + +jest.mock('~/helpers/functions/fileHelpers', () => ({ + getRepoList: jest.fn(), + getUsersList: jest.fn(), + updateRepoList: jest.fn(), +})); + +jest.mock('~/helpers/functions/shell.utils', () => ({ + getLastSaveList: jest.fn(), +})); + +jest.mock('~/helpers/functions/nodemailerSMTP', () => ({ + __esModule: true, + default: jest.fn(() => ({ + sendMail: jest.fn().mockResolvedValue({ messageId: 'fake-message-id' }), + })), +})); + +jest.mock('~/helpers/templates/emailAlertStatus', () => + jest.fn(() => ({ + subject: 'Alert', + text: 'Alert text', + })) +); + +jest.mock('node:child_process', () => ({ + exec: jest.fn( + ( + command: string, + callback: (err: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + callback(null, { stdout: 'mocked output', stderr: '' }); + } + ), +})); + +describe('Cronjob API Handler', () => { + beforeEach(() => { + process.env.CRONJOB_KEY = 'test-key'; + jest.clearAllMocks(); + jest.resetModules(); + }); + + it('should return 401 if no authorization header', async () => { + const { req, res } = createMocks({ method: 'POST' }); + await handler(req, res); + expect(res._getStatusCode()).toBe(401); + }); + + it('should return 401 if method is not POST', async () => { + const { req, res } = createMocks({ + method: 'GET', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + expect(res._getStatusCode()).toBe(401); + }); + + it('should return 401 if wrong authorization key', async () => { + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer wrong-key' }, + }); + await handler(req, res); + expect(res._getStatusCode()).toBe(401); + }); + + it('should return 200 with message if no repository to check (empty repoList)', async () => { + (getRepoList as jest.Mock).mockResolvedValue([]); + (getLastSaveList as jest.Mock).mockResolvedValue([{ repositoryName: 'repo1', lastSave: 123 }]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + expect(res._getJSONData()).toEqual({ + status: 200, + message: 'Status cron executed. No repository to check.', + }); + }); + + it('should return 200 with message if no repository to check (empty lastSaveList)', async () => { + (getRepoList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', alert: 100, alias: 'Repo1' }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + expect(res._getJSONData()).toEqual({ + status: 200, + message: 'Status cron executed. No repository to check.', + }); + }); + + it('should execute successfully without alerts if all repositories are OK', async () => { + const currentTime = Math.floor(Date.now() / 1000); + (getRepoList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', alert: 1000, alias: 'Repo1', status: true }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: currentTime }, + ]); + (updateRepoList as jest.Mock).mockResolvedValue(undefined); + (getUsersList as jest.Mock).mockResolvedValue([]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + expect(res._getJSONData()).toEqual({ + status: 200, + message: 'Status cron executed successfully.', + }); + expect(updateRepoList).toHaveBeenCalled(); + }); + + it('should return 500 if an error occurs', async () => { + (getRepoList as jest.Mock).mockRejectedValue(new Error('Test error')); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(500); + expect(res._getJSONData()).toEqual({ + status: 500, + message: 'API error, contact the administrator.', + }); + }); + + it('should not send email alert if emailAlert is false', async () => { + const currentTime = Math.floor(Date.now() / 1000); + + (getRepoList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', alert: 100, alias: 'Repo1' }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: currentTime - 200 }, + ]); + // User has disabled email alert but enabled Apprise alert + (getUsersList as jest.Mock).mockResolvedValue([ + { + emailAlert: false, + appriseAlert: true, + appriseServices: ['http://example.com'], + appriseMode: 'package', + appriseStatelessURL: 'http://example.com', + email: 'test@example.com', + username: 'testuser', + }, + ]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + + expect(nodemailerSMTP).not.toHaveBeenCalled(); + }); + + it('should not send apprise alert if appriseAlert is false', async () => { + const currentTime = Math.floor(Date.now() / 1000); + (getRepoList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', alert: 100, alias: 'Repo1' }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: currentTime - 200 }, + ]); + // User has disabled Apprise alert but enabled email alert + (getUsersList as jest.Mock).mockResolvedValue([ + { + emailAlert: true, + appriseAlert: false, + appriseServices: ['http://example.com'], + appriseMode: 'package', + appriseStatelessURL: 'http://example.com', + email: 'test@example.com', + username: 'testuser', + }, + ]); + + // Spy on exec to check if it is called + const execSpy = jest.spyOn(require('node:child_process'), 'exec'); + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + + expect(execSpy).not.toHaveBeenCalled(); + execSpy.mockRestore(); + }); + + it('should not send alert if alert is disabled on repo (repo.alert === 0)', async () => { + const currentTime = Math.floor(Date.now() / 1000); + (getRepoList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', alert: 0, alias: 'Repo1' }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: currentTime - 1000 }, + ]); + (getUsersList as jest.Mock).mockResolvedValue([ + { + emailAlert: true, + appriseAlert: true, + appriseServices: ['http://example.com'], + appriseMode: 'package', + appriseStatelessURL: 'http://example.com', + email: 'test@example.com', + username: 'testuser', + }, + ]); + + // Spy on exec to check if it is called + const nodemailerSpy = jest.spyOn(require('~/helpers/functions/nodemailerSMTP'), 'default'); + const execSpy = jest.spyOn(require('node:child_process'), 'exec'); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + await handler(req, res); + + expect(nodemailerSpy).not.toHaveBeenCalled(); + expect(execSpy).not.toHaveBeenCalled(); + + nodemailerSpy.mockRestore(); + execSpy.mockRestore(); + }); + + it('should not update lastStatusAlertSend or add to repoListToSendAlert if repo status is OK', async () => { + (getRepoList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', status: true, alert: 100 }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: Math.floor(Date.now() / 1000) }, + ]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + + await handler(req, res); + + expect(updateRepoList).toHaveBeenCalledWith([ + { + repositoryName: 'repo1', + status: true, + alert: 100, + lastSave: expect.any(Number), + }, + ]); + expect(res._getStatusCode()).toBe(200); + }); + + it('should update lastStatusAlertSend if repo is down and alert is enabled', async () => { + const currentTime = 1741535661; + (getRepoList as jest.Mock).mockResolvedValue([ + { + repositoryName: 'repo1', + alias: 'Repo1', + status: false, + alert: 100, + }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: currentTime - 200 }, + ]); + (getUsersList as jest.Mock).mockResolvedValue([ + { emailAlert: true, email: 'test@example.com', username: 'TestUser' }, + ]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + + await handler(req, res); + + expect(updateRepoList).toHaveBeenCalledWith([ + { + repositoryName: 'repo1', + alias: 'Repo1', + status: false, + alert: 100, + lastStatusAlertSend: expect.any(Number), + lastSave: expect.any(Number), + }, + ]); + expect(res._getStatusCode()).toBe(200); + }); + + it('should not update lastStatusAlertSend or send alerts if alert is disabled', async () => { + const currentTime = Math.floor(Date.now() / 1000); + (getRepoList as jest.Mock).mockResolvedValue([ + { + repositoryName: 'repo1', + alias: 'Repo1', + status: false, + alert: 0, + lastStatusAlertSend: null, + }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: currentTime - 200 }, + ]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + + await handler(req, res); + + expect(updateRepoList).toHaveBeenCalledWith([ + { + repositoryName: 'repo1', + alias: 'Repo1', + status: false, + alert: 0, + lastStatusAlertSend: null, + lastSave: currentTime - 200, + }, + ]); + expect(nodemailerSMTP).not.toHaveBeenCalled(); + expect(res._getStatusCode()).toBe(200); + }); + + it('should update lastStatusAlertSend only if the last alert was sent more than 90000 seconds ago', async () => { + const currentTime = Math.floor(Date.now() / 1000); + (getRepoList as jest.Mock).mockResolvedValue([ + { + repositoryName: 'repo1', + alias: 'Repo1', + status: false, + alert: 100, + lastStatusAlertSend: currentTime - 80000, + }, + ]); + (getLastSaveList as jest.Mock).mockResolvedValue([ + { repositoryName: 'repo1', lastSave: currentTime - 200 }, + ]); + (getUsersList as jest.Mock).mockResolvedValue([ + { emailAlert: true, email: 'test@example.com', username: 'TestUser' }, + ]); + + const { req, res } = createMocks({ + method: 'POST', + headers: { authorization: 'Bearer test-key' }, + }); + + await handler(req, res); + + expect(updateRepoList).toHaveBeenCalledWith([ + { + repositoryName: 'repo1', + alias: 'Repo1', + status: false, + alert: 100, + lastStatusAlertSend: expect.any(Number), + lastSave: currentTime - 200, + }, + ]); + expect(res._getStatusCode()).toBe(200); + }); +}); diff --git a/types/api/error.types.ts b/types/api/error.types.ts index f68b82e..37502d8 100644 --- a/types/api/error.types.ts +++ b/types/api/error.types.ts @@ -6,3 +6,9 @@ export type ErrorResponse = { export type SuccessResponse = { message?: string; }; + +export type BorgWarehouseApiResponse = { + status: number; + message: string; + data?: T; +}; diff --git a/types/api/integrations.types.ts b/types/api/integration.types.ts similarity index 100% rename from types/api/integrations.types.ts rename to types/api/integration.types.ts diff --git a/types/api/notifications.types.ts b/types/api/notification.types.ts similarity index 100% rename from types/api/notifications.types.ts rename to types/api/notification.types.ts diff --git a/types/api/settings.types.ts b/types/api/setting.types.ts similarity index 100% rename from types/api/settings.types.ts rename to types/api/setting.types.ts diff --git a/types/api/shell.types.ts b/types/api/shell.types.ts new file mode 100644 index 0000000..ca82056 --- /dev/null +++ b/types/api/shell.types.ts @@ -0,0 +1,4 @@ +export type LastSaveDTO = { + repositoryName: string; + lastSave: number; +}; diff --git a/types/domain/config.types.ts b/types/domain/config.types.ts index 236f88d..19378fb 100644 --- a/types/domain/config.types.ts +++ b/types/domain/config.types.ts @@ -1,4 +1,4 @@ -import { IntegrationTokenType } from '../api/integrations.types'; +import { IntegrationTokenType } from '../api/integration.types'; export type Repository = { id: number; @@ -15,6 +15,7 @@ export type Repository = { unixUser: string; lanCommand?: boolean; appendOnlyMode?: boolean; + lastStatusAlertSend?: number; }; export type BorgWarehouseUser = {