refactor: status API + misc

This commit is contained in:
Ravinou 2025-03-09 11:51:43 +01:00
commit dac0c41df4
No known key found for this signature in database
GPG key ID: EEEE670C40F6A4D7
32 changed files with 600 additions and 250 deletions

View file

@ -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 = {

View file

@ -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 = {

View file

@ -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

View file

@ -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

View file

@ -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';

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,27 @@
import { NextApiResponse } from 'next';
export default class ApiResponse {
static success<T>(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 });
}
}

View file

@ -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<BorgWarehouseUser[]> => {
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<void> => {
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<Repository[]> => {
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<void> => {
try {
await fs.writeFile(repoFilePath, JSON.stringify(repoList, null, 2));
} catch (error) {
console.error('Error writing repo.json:', error);
}
};

View file

@ -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<Optional<Repository>>
) {
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;
});
}

View file

@ -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<LastSaveDTO[]> => {
const shellsDirectory = path.join(process.cwd(), '/helpers');
const { stdout } = await exec(`${shellsDirectory}/shells/getLastSave.sh`);
return JSON.parse(stdout || '[]');
};

View file

@ -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 + '<li>' + alias + '</li>';

View file

@ -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(

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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(

View file

@ -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(

View file

@ -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';

View file

@ -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(

View file

@ -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';

View file

@ -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 },

View file

@ -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';

View file

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

View file

@ -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<BorgWarehouseApiResponse>
) {
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);
}
}

View file

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

View file

@ -6,3 +6,9 @@ export type ErrorResponse = {
export type SuccessResponse = {
message?: string;
};
export type BorgWarehouseApiResponse<T = any> = {
status: number;
message: string;
data?: T;
};

4
types/api/shell.types.ts Normal file
View file

@ -0,0 +1,4 @@
export type LastSaveDTO = {
repositoryName: string;
lastSave: number;
};

View file

@ -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 = {