From 805bc5bfc0017d47873cc4c72dedbd34e9ba3e67 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 31 Jan 2025 12:44:43 -0500 Subject: [PATCH] feat: initial patcher implementation using the diff tool --- api/package-lock.json | 17 + api/package.json | 2 + .../unraid-file-modifier/file-modification.ts | 112 ++ .../__fixtures__/default-page-layout.patch | 60 + .../__fixtures__/test-patch-file.txt | 0 .../__test__/DefaultPageLayout.original.php | 1208 +++++++++++++++++ .../__test__/DefaultPageLayout.patch.php | 60 + .../default-page-layout.modification.spec.ts | 36 +- .../auth-request.modification.ts | 68 +- .../default-page-layout.modification.ts | 103 +- .../modifications/log-rotate.modification.ts | 42 +- .../notifications-page.modification.ts | 42 +- .../modifications/sso.modification.ts | 64 +- .../unraid-file-modifier/patch-utils.ts | 20 + .../unraid-file-modifier.service.ts | 69 +- .../unraid-file-modifier.spec.ts | 135 +- api/src/utils.ts | 72 - 17 files changed, 1782 insertions(+), 328 deletions(-) create mode 100644 api/src/unraid-api/unraid-file-modifier/file-modification.ts create mode 100644 api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/default-page-layout.patch create mode 100644 api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/test-patch-file.txt create mode 100644 api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.original.php create mode 100644 api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.patch.php create mode 100644 api/src/unraid-api/unraid-file-modifier/patch-utils.ts diff --git a/api/package-lock.json b/api/package-lock.json index 34d6beed2..695fa82bf 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -27,6 +27,7 @@ "@reduxjs/toolkit": "^2.3.0", "@reflet/cron": "^1.3.1", "@runonflux/nat-upnp": "^1.0.2", + "@types/diff": "^7.0.1", "accesscontrol": "^2.2.1", "bycontract": "^2.0.11", "bytes": "^3.1.2", @@ -40,6 +41,7 @@ "convert": "^5.5.1", "cookie": "^1.0.2", "cross-fetch": "^4.0.0", + "diff": "^7.0.0", "docker-event-emitter": "^0.3.0", "dockerode": "^3.3.5", "dotenv": "^16.4.5", @@ -5489,6 +5491,12 @@ "moment": ">=2.14.0" } }, + "node_modules/@types/diff": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz", + "integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==", + "license": "MIT" + }, "node_modules/@types/docker-modem": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", @@ -8738,6 +8746,15 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT" }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", diff --git a/api/package.json b/api/package.json index 56c6ca00f..f2b543181 100644 --- a/api/package.json +++ b/api/package.json @@ -60,6 +60,7 @@ "@reduxjs/toolkit": "^2.3.0", "@reflet/cron": "^1.3.1", "@runonflux/nat-upnp": "^1.0.2", + "@types/diff": "^7.0.1", "accesscontrol": "^2.2.1", "bycontract": "^2.0.11", "bytes": "^3.1.2", @@ -73,6 +74,7 @@ "convert": "^5.5.1", "cookie": "^1.0.2", "cross-fetch": "^4.0.0", + "diff": "^7.0.0", "docker-event-emitter": "^0.3.0", "dockerode": "^3.3.5", "dotenv": "^16.4.5", diff --git a/api/src/unraid-api/unraid-file-modifier/file-modification.ts b/api/src/unraid-api/unraid-file-modifier/file-modification.ts new file mode 100644 index 000000000..9759049ac --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/file-modification.ts @@ -0,0 +1,112 @@ +import { Logger } from '@nestjs/common'; +import { readFile, writeFile, access } from 'fs/promises'; +import { constants } from 'fs'; +import { join, dirname } from 'path'; +import { applyPatch, parsePatch, reversePatch } from 'diff'; + +export interface PatchResult { + targetFile: string; + patch: string; +} + +export interface ShouldApplyWithReason { + shouldApply: boolean; + reason: string; +} + +// Convert interface to abstract class with default implementations +export abstract class FileModification { + abstract id: string; + + protected constructor(protected readonly logger: Logger) {} + + // This is the main method that child classes need to implement + protected abstract generatePatch(): Promise; + + private getPatchFilePath(targetFile: string): string { + const dir = dirname(targetFile); + const filename = `${this.id}.patch`; + return join(dir, filename); + } + + private async savePatch(patchResult: PatchResult): Promise { + const patchFile = this.getPatchFilePath(patchResult.targetFile); + await writeFile(patchFile, patchResult.patch, 'utf8'); + } + + private async loadSavedPatch(targetFile: string): Promise { + const patchFile = this.getPatchFilePath(targetFile); + try { + await access(patchFile, constants.R_OK); + return await readFile(patchFile, 'utf8'); + } catch { + return null; + } + } + + // Default implementation of apply that uses the patch + async apply(): Promise { + const patchResult = await this.generatePatch(); + const { targetFile, patch } = patchResult; + const currentContent = await readFile(targetFile, 'utf8'); + const parsedPatch = parsePatch(patch)[0]; + + const results = applyPatch(currentContent, parsedPatch); + if (results === false) { + throw new Error(`Failed to apply patch to ${targetFile}`); + } + + await writeFile(targetFile, results); + await this.savePatch(patchResult); + } + + // Update rollback to use the shared utility + async rollback(): Promise { + const { targetFile } = await this.generatePatch(); + let patch: string; + + // Try to load saved patch first + const savedPatch = await this.loadSavedPatch(targetFile); + if (savedPatch) { + this.logger.debug(`Using saved patch file for ${this.id}`); + patch = savedPatch; + } else { + this.logger.debug(`No saved patch found for ${this.id}, generating new patch`); + const patchResult = await this.generatePatch(); + patch = patchResult.patch; + } + + const currentContent = await readFile(targetFile, 'utf8'); + const parsedPatch = parsePatch(patch)[0]; + + if (!parsedPatch || !parsedPatch.hunks || parsedPatch.hunks.length === 0) { + throw new Error('Invalid or empty patch content'); + } + + const reversedPatch = reversePatch(parsedPatch); + const results = applyPatch(currentContent, reversedPatch); + + if (results === false) { + throw new Error(`Failed to rollback patch from ${targetFile}`); + } + + await writeFile(targetFile, results); + + // Clean up the patch file after successful rollback + try { + const patchFile = this.getPatchFilePath(targetFile); + await access(patchFile, constants.W_OK); + await unlink(patchFile); + } catch { + // Ignore errors when trying to delete the patch file + } + } + + // Default implementation that can be overridden if needed + async shouldApply(): Promise { + return { + shouldApply: true, + reason: 'Default behavior is to always apply modifications', + }; + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/default-page-layout.patch b/api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/default-page-layout.patch new file mode 100644 index 000000000..32d063efe --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/default-page-layout.patch @@ -0,0 +1,60 @@ +Index: /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php +=================================================================== +--- /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php ++++ /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php +@@ -557,14 +557,5 @@ + $.post('/webGui/include/Notify.php',{cmd:'get',csrf_token:csrf_token},function(msg) { + $.each($.parseJSON(msg), function(i, notify){ +- $.jGrowl(notify.subject+'
'+notify.description,{ +- group: notify.importance, +- header: notify.event+': '+notify.timestamp, +- theme: notify.file, +- sticky: true, +- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);}, +- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');}, +- click: function(e,m,o){if (notify.link) location.replace(notify.link);}, +- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});} +- }); ++ + }); + }); +@@ -680,6 +671,6 @@ + } + +-echo ""; + ++ + if ($themes2) echo ""; + echo ""; +@@ -886,20 +877,12 @@ + + if (notify.show) { +- $.jGrowl(notify.subject+'
'+notify.description,{ +- group: notify.importance, +- header: notify.event+': '+notify.timestamp, +- theme: notify.file, +- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);}, +- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');}, +- click: function(e,m,o){if (notify.link) location.replace(notify.link);}, +- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'hide',file:""+notify.file,csrf_token:csrf_token},function(){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});});} +- }); ++ + } + + }); +- $('#bell').removeClass('red-orb yellow-orb green-orb').prop('title'," ["+bell1+']\n'+" ["+bell2+']\n'+" ["+bell3+']'); +- if (bell1) $('#bell').addClass('red-orb'); else +- if (bell2) $('#bell').addClass('yellow-orb'); else +- if (bell3) $('#bell').addClass('green-orb'); ++ ++ ++ ++ + break; + } +@@ -1204,4 +1187,5 @@ + }); + ++ + + diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/test-patch-file.txt b/api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/test-patch-file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.original.php b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.original.php new file mode 100644 index 000000000..2214f524e --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.original.php @@ -0,0 +1,1208 @@ + +/dev/null &"); + +function annotate($text) {echo "\n\n";} +?> + +lang=""> + +<?=_var($var,'NAME')?>/<?=_var($myPage,'name')?> + + + + + + + + +"> +"> +"> +"> +"> +"> +"> +"> + + + + + + + + + + + +
+ + + +"; +if ($themes2) echo "
"; +foreach ($buttons as $button) { + annotate($button['file']); + // include page specific stylesheets (if existing) + $css = "/{$button['root']}/sheets/{$button['name']}"; + $css_stock = "$css.css"; + $css_theme = "$css-$theme.css"; + if (is_file($docroot.$css_stock)) echo '',"\n"; + if (is_file($docroot.$css_theme)) echo '',"\n"; + // create page content + eval('?>'.parse_text($button['text'])); +} +unset($buttons,$button); + +// Build page content +// Reload page every X minutes during extended viewing? +if (isset($myPage['Load']) && $myPage['Load']>0) echo "\n\n"; +echo "
"; +$tab = 1; +$pages = []; +if (!empty($myPage['text'])) $pages[$myPage['name']] = $myPage; +if (_var($myPage,'Type')=='xmenu') $pages = array_merge($pages, find_pages($myPage['name'])); +if (isset($myPage['Tabs'])) $display['tabs'] = strtolower($myPage['Tabs'])=='true' ? 0 : 1; +$tabbed = $display['tabs']==0 && count($pages)>1; + +foreach ($pages as $page) { + $close = false; + if (isset($page['Title'])) { + eval("\$title=\"".htmlspecialchars($page['Title'])."\";"); + if ($tabbed) { + echo "
"; + $close = true; + } else { + if ($tab==1) echo "
"; + echo "
"; + echo tab_title($title,$page['root'],_var($page,'Tag',false)); + echo "
"; + } + $tab++; + } + if (isset($page['Type']) && $page['Type']=='menu') { + $pgs = find_pages($page['name']); + foreach ($pgs as $pg) { + @eval("\$title=\"".htmlspecialchars($pg['Title'])."\";"); + $icon = _var($pg,'Icon',""); + if (substr($icon,-4)=='.png') { + $root = $pg['root']; + if (file_exists("$docroot/$root/images/$icon")) { + $icon = ""; + } elseif (file_exists("$docroot/$root/$icon")) { + $icon = ""; + } else { + $icon = ""; + } + } elseif (substr($icon,0,5)=='icon-') { + $icon = ""; + } elseif ($icon[0]!='<') { + if (substr($icon,0,3)!='fa-') $icon = "fa-$icon"; + $icon = ""; + } + echo ""; + } + } + // create list of nchan scripts to be started + if (isset($page['Nchan'])) nchan_merge($page['root'], $page['Nchan']); + annotate($page['file']); + // include page specific stylesheets (if existing) + $css = "/{$page['root']}/sheets/{$page['name']}"; + $css_stock = "$css.css"; + $css_theme = "$css-$theme.css"; + if (is_file($docroot.$css_stock)) echo '',"\n"; + if (is_file($docroot.$css_theme)) echo '',"\n"; + // create page content + empty($page['Markdown']) || $page['Markdown']=='true' ? eval('?>'.Markdown(parse_text($page['text']))) : eval('?>'.parse_text($page['text'])); + if ($close) echo "
"; +} +if (count($pages)) { + $running = file_exists($nchan_pid) ? file($nchan_pid,FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) : []; + $start = array_diff($nchan, $running); // returns any new scripts to be started + $stop = array_diff($running, $nchan); // returns any old scripts to be stopped + $running = array_merge($start, $running); // update list of current running nchan scripts + // start nchan scripts which are new + foreach ($start as $row) { + $script = explode(':',$row)[0]; + exec("$docroot/$script &>/dev/null &"); + } + // stop nchan scripts with the :stop option + foreach ($stop as $row) { + [$script,$opt] = my_explode(':',$row); + if ($opt == 'stop') { + exec("pkill -f $docroot/$script &>/dev/null &"); + array_splice($running,array_search($row,$running),1); + } + } + if (count($running)) file_put_contents($nchan_pid,implode("\n",$running)."\n"); else @unlink($nchan_pid); +} +unset($pages,$page,$pgs,$pg,$icon,$nchan,$running,$start,$stop,$row,$script,$opt,$nchan_run); +?> +
+
+
+ +'; +$progress = (_var($var,'fsProgress')!='') ? "•{$var['fsProgress']}" : ""; +switch (_var($var,'fsState')) { +case 'Stopped': + echo " ",_('Array Stopped'),"$progress"; break; +case 'Starting': + echo " ",_('Array Starting'),"$progress"; break; +case 'Stopping': + echo " ",_('Array Stopping'),"$progress"; break; +default: + echo " ",_('Array Started'),"$progress"; break; +} +echo ""; +echo "Unraid® webGui ©2024, Lime Technology, Inc."; +echo " "._('manual').""; +echo "
"; +?> + + + + diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.patch.php b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.patch.php new file mode 100644 index 000000000..32d063efe --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/DefaultPageLayout.patch.php @@ -0,0 +1,60 @@ +Index: /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php +=================================================================== +--- /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php ++++ /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php +@@ -557,14 +557,5 @@ + $.post('/webGui/include/Notify.php',{cmd:'get',csrf_token:csrf_token},function(msg) { + $.each($.parseJSON(msg), function(i, notify){ +- $.jGrowl(notify.subject+'
'+notify.description,{ +- group: notify.importance, +- header: notify.event+': '+notify.timestamp, +- theme: notify.file, +- sticky: true, +- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);}, +- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');}, +- click: function(e,m,o){if (notify.link) location.replace(notify.link);}, +- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});} +- }); ++ + }); + }); +@@ -680,6 +671,6 @@ + } + +-echo ""; + ++ + if ($themes2) echo ""; + echo ""; +@@ -886,20 +877,12 @@ + + if (notify.show) { +- $.jGrowl(notify.subject+'
'+notify.description,{ +- group: notify.importance, +- header: notify.event+': '+notify.timestamp, +- theme: notify.file, +- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);}, +- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');}, +- click: function(e,m,o){if (notify.link) location.replace(notify.link);}, +- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'hide',file:""+notify.file,csrf_token:csrf_token},function(){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});});} +- }); ++ + } + + }); +- $('#bell').removeClass('red-orb yellow-orb green-orb').prop('title'," ["+bell1+']\n'+" ["+bell2+']\n'+" ["+bell3+']'); +- if (bell1) $('#bell').addClass('red-orb'); else +- if (bell2) $('#bell').addClass('yellow-orb'); else +- if (bell3) $('#bell').addClass('green-orb'); ++ ++ ++ ++ + break; + } +@@ -1204,4 +1187,5 @@ + }); + ++ + + diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/default-page-layout.modification.spec.ts b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/default-page-layout.modification.spec.ts index d0ffee416..b6ac7cd0e 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/default-page-layout.modification.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/default-page-layout.modification.spec.ts @@ -1,19 +1,47 @@ +import { Logger } from '@nestjs/common'; import { readFile } from 'fs/promises'; import { resolve } from 'path'; + + +import { applyPatch } from 'diff'; import { describe, expect, test } from 'vitest'; + + import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification'; + + + + describe('DefaultPageLayout.php modifier', () => { test('correctly applies to fresh install', async () => { const fileContent = await readFile( resolve(__dirname, '../__fixtures__/DefaultPageLayout.php'), 'utf-8' ); + const path = resolve(__dirname, '../__fixtures__/DefaultPageLayout.php'); expect(fileContent.length).toBeGreaterThan(0); - await expect(DefaultPageLayoutModification.applyToSource(fileContent)).toMatchFileSnapshot( - 'DefaultPageLayout.modified.php' - ); + const logger = new Logger(); + const patcher = await new DefaultPageLayoutModification(logger); + patcher.filePath = path; + const patch = await patcher.generatePatch(); + + await expect(patch.patch).toMatchFileSnapshot('DefaultPageLayout.patch.php'); + // Now we need to apply the patch + + const newContent = applyPatch(fileContent, patch.patch, { + fuzzFactor: 1, + }); + await expect(newContent).toMatchFileSnapshot('DefaultPageLayout.modified.php'); + + // Now apply the patch + await patcher.apply(); + + // Now rollback the patch and check that the file is back to the original + await patcher.rollback(); + const revertedContent = await readFile(path, 'utf-8'); + await expect(revertedContent).toMatchFileSnapshot('DefaultPageLayout.original.php'); }); -}); +}); \ No newline at end of file diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts index 9d7b3bd88..722475e01 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts @@ -1,12 +1,14 @@ import { Logger } from '@nestjs/common'; import { existsSync } from 'fs'; -import { readFile, writeFile } from 'fs/promises'; +import { readFile } from 'fs/promises'; + +import { createPatch } from 'diff'; import { FileModification, + PatchResult, ShouldApplyWithReason, -} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; -import { backupFile } from '@app/utils'; +} from '@app/unraid-api/unraid-file-modifier/file-modification'; const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php' as const; const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/' as const; @@ -17,47 +19,47 @@ const getJsFiles = async (dir: string) => { return files.map((file) => file.replace('/usr/local/emhttp', '')); }; -export default class AuthRequestModification implements FileModification { +export default class AuthRequestModification extends FileModification { id: string = 'auth-request'; - constructor(private readonly logger: Logger) { - this.logger = logger; + constructor(logger: Logger) { + super(logger); } - async apply(): Promise { + protected async generatePatch(): Promise { const JS_FILES = await getJsFiles(WEB_COMPS_DIR); this.logger.debug(`Found ${JS_FILES.length} .js files in ${WEB_COMPS_DIR}`); const FILES_TO_ADD = ['/webGui/images/partner-logo.svg', ...JS_FILES]; - if (existsSync(AUTH_REQUEST_FILE)) { - const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); - - if (fileContent.includes('$arrWhitelist')) { - backupFile(AUTH_REQUEST_FILE, true); - this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`); - - const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); - - const updatedContent = fileContent.replace( - /(\$arrWhitelist\s*=\s*\[)/, - `$1\n${filesToAddString}` - ); - - await writeFile(AUTH_REQUEST_FILE, updatedContent); - this.logger.debug( - `Default values and .js files from ${WEB_COMPS_DIR} added to $arrWhitelist.` - ); - } else { - this.logger.debug(`$arrWhitelist array not found in the file.`); - } - } else { - this.logger.debug(`File ${AUTH_REQUEST_FILE} not found.`); + if (!existsSync(AUTH_REQUEST_FILE)) { + throw new Error(`File ${AUTH_REQUEST_FILE} not found.`); } + + const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); + + if (!fileContent.includes('$arrWhitelist')) { + throw new Error(`$arrWhitelist array not found in the file.`); + } + + this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`); + + const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); + + // Create new content by finding the array declaration and adding our files after it + const newContent = fileContent.replace(/(\$arrWhitelist\s*=\s*\[)/, `$1\n${filesToAddString}`); + + // Generate and return patch + const patch = createPatch(AUTH_REQUEST_FILE, fileContent, newContent, undefined, undefined, { + context: 3, + }); + + return { + targetFile: AUTH_REQUEST_FILE, + patch, + }; } - async rollback(): Promise { - // No rollback needed, this is safe to preserve - } + async shouldApply(): Promise { return { shouldApply: true, diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.ts index 02c585fed..0be5c79d3 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.ts @@ -1,34 +1,66 @@ import type { Logger } from '@nestjs/common'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; + +import { createPatch } from 'diff'; import { FileModification, + PatchResult, ShouldApplyWithReason, -} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; -import { backupFile, restoreFile } from '@app/utils'; +} from '@app/unraid-api/unraid-file-modifier/file-modification'; + +export default class DefaultPageLayoutModification extends FileModification { + id: string = 'default-page-layout'; + private readonly filePath: string = + '/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php'; -export default class DefaultPageLayoutModification implements FileModification { - id: string = 'DefaultPageLayout.php'; - logger: Logger; - filePath: string = '/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php'; constructor(logger: Logger) { - this.logger = logger; + super(logger); } - async apply(): Promise { - await backupFile(this.filePath, true); - const fileContent = await readFile(this.filePath, 'utf-8'); - await writeFile(this.filePath, DefaultPageLayoutModification.applyToSource(fileContent)); - this.logger.log(`${this.id} replaced successfully.`); - } - - async rollback(): Promise { - const restored = await restoreFile(this.filePath, false); - if (restored) { - this.logger.debug(`${this.id} restored.`); - } else { - this.logger.warn(`Could not restore ${this.id}`); + private addToaster(source: string): string { + if (source.includes('unraid-toaster')) { + return source; } + const insertion = ``; + return source.replace(/<\/body>/, `${insertion}\n`); + } + + private removeNotificationBell(source: string): string { + return source.replace(/^.*(id='bell'|#bell).*$/gm, ''); + } + + private replaceToasts(source: string): string { + // matches jgrowl calls up to the second `)};` + const jGrowlPattern = + /\$\.jGrowl\(notify\.subject\+'
'\+notify\.description,\s*\{(?:[\s\S]*?\}\);[\s\S]*?)}\);/g; + + return source.replace(jGrowlPattern, ''); + } + + private applyToSource(fileContent: string): string { + const transformers = [ + this.removeNotificationBell.bind(this), + this.replaceToasts.bind(this), + this.addToaster.bind(this), + ]; + return transformers.reduce((content, fn) => fn(content), fileContent); + } + + protected async generatePatch(): Promise { + const fileContent = await readFile(this.filePath, 'utf-8'); + + + const newContent = this.applyToSource(fileContent); + + const patch = createPatch(this.filePath, fileContent, newContent, undefined, undefined, { + context: 2, + }); + + return { + targetFile: this.filePath, + patch, + }; } async shouldApply(): Promise { @@ -37,33 +69,4 @@ export default class DefaultPageLayoutModification implements FileModification { reason: 'Always apply the allowed file changes to ensure compatibility.', }; } - - static applyToSource(fileContent: string): string { - const transformers = [ - DefaultPageLayoutModification.removeNotificationBell, - DefaultPageLayoutModification.replaceToasts, - DefaultPageLayoutModification.addToaster, - ]; - return transformers.reduce((content, fn) => fn(content), fileContent); - } - - static addToaster(source: string): string { - if (source.includes('unraid-toaster')) { - return source; - } - const insertion = ``; - return source.replace(/<\/body>/, `${insertion}\n`); - } - - static removeNotificationBell(source: string): string { - return source.replace(/^.*(id='bell'|#bell).*$/gm, ''); - } - - static replaceToasts(source: string): string { - // matches jgrowl calls up to the second `)};` - const jGrowlPattern = - /\$\.jGrowl\(notify\.subject\+'
'\+notify\.description,\s*\{(?:[\s\S]*?\}\);[\s\S]*?)}\);/g; - - return source.replace(jGrowlPattern, ''); - } } diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/log-rotate.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/log-rotate.modification.ts index ad478ca22..8bc52d613 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/log-rotate.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/log-rotate.modification.ts @@ -1,19 +1,20 @@ import { Logger } from '@nestjs/common'; -import { rm, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; +import { createPatch } from 'diff'; import { execa } from 'execa'; import { fileExists } from '@app/core/utils/files/file-exists'; import { FileModification, + PatchResult, ShouldApplyWithReason, -} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; +} from '@app/unraid-api/unraid-file-modifier/file-modification'; -export class LogRotateModification implements FileModification { +export class LogRotateModification extends FileModification { id: string = 'log-rotate'; - filePath: string = '/etc/logrotate.d/unraid-api' as const; - logger: Logger; - logRotateConfig: string = ` + private readonly filePath: string = '/etc/logrotate.d/unraid-api' as const; + private readonly logRotateConfig: string = ` /var/log/unraid-api/*.log { rotate 1 missingok @@ -27,17 +28,32 @@ export class LogRotateModification implements FileModification { `; constructor(logger: Logger) { - this.logger = logger; + super(logger); } - async apply(): Promise { - await writeFile(this.filePath, this.logRotateConfig, { mode: '644' }); - // Ensure file is owned by root:root + protected async generatePatch(): Promise { + const currentContent = (await fileExists(this.filePath)) + ? await readFile(this.filePath, 'utf8') + : ''; + + const patch = createPatch( + this.filePath, + currentContent, + this.logRotateConfig, + undefined, + undefined, + { + context: 3, + } + ); + + // After applying patch, ensure file permissions are correct await execa('chown', ['root:root', this.filePath]).catch((err) => this.logger.error(err)); - } - async rollback(): Promise { - await rm(this.filePath); + return { + targetFile: this.filePath, + patch, + }; } async shouldApply(): Promise { diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts index 2778c2aff..73386137f 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts @@ -1,34 +1,35 @@ import type { Logger } from '@nestjs/common'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; + +import { createPatch } from 'diff'; import { FileModification, + PatchResult, ShouldApplyWithReason, -} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; -import { backupFile, restoreFile } from '@app/utils'; +} from '@app/unraid-api/unraid-file-modifier/file-modification'; -export default class NotificationsPageModification implements FileModification { +export default class NotificationsPageModification extends FileModification { id: string = 'Notifications.page'; - logger: Logger; - filePath: string = '/usr/local/emhttp/plugins/dynamix/Notifications.page'; + private readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/Notifications.page'; + constructor(logger: Logger) { - this.logger = logger; + super(logger); } - async apply(): Promise { - await backupFile(this.filePath, true); + protected async generatePatch(): Promise { const fileContent = await readFile(this.filePath, 'utf-8'); - await writeFile(this.filePath, NotificationsPageModification.applyToSource(fileContent)); - this.logger.log(`${this.id} replaced successfully.`); - } - async rollback(): Promise { - const restored = await restoreFile(this.filePath, false); - if (restored) { - this.logger.debug(`${this.id} restored.`); - } else { - this.logger.warn(`Could not restore ${this.id}`); - } + const newContent = NotificationsPageModification.applyToSource(fileContent); + + const patch = createPatch(this.filePath, fileContent, newContent, undefined, undefined, { + context: 3, + }); + + return { + targetFile: this.filePath, + patch, + }; } async shouldApply(): Promise { @@ -38,12 +39,11 @@ export default class NotificationsPageModification implements FileModification { }; } - static applyToSource(fileContent: string): string { + private static applyToSource(fileContent: string): string { return ( fileContent // Remove lines between _(Date format)_: and :notifications_date_format_help: .replace(/^\s*_\(Date format\)_:(?:[^\n]*\n)*?\s*:notifications_date_format_help:/gm, '') - // Remove lines between _(Time format)_: and :notifications_time_format_help: .replace(/^\s*_\(Time format\)_:(?:[^\n]*\n)*?\s*:notifications_time_format_help:/gm, '') ); diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts index 6f8298835..8b9c0c74d 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts @@ -1,21 +1,17 @@ import type { Logger } from '@nestjs/common'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; +import { createPatch } from 'diff'; +import { FileModification, PatchResult, ShouldApplyWithReason } from '@app/unraid-api/unraid-file-modifier/file-modification'; -import { - FileModification, - ShouldApplyWithReason, -} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; -import { backupFile, restoreFile } from '@app/utils'; - -export default class SSOFileModification implements FileModification { +export default class SSOFileModification extends FileModification { id: string = 'sso'; - logger: Logger; - loginFilePath: string = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; + private loginFilePath: string = '/usr/local/emhttp/plugins/dynamix/include/.login.php'; + constructor(logger: Logger) { - this.logger = logger; + super(logger); } - async apply(): Promise { + protected async generatePatch(): Promise { // Define the new PHP function to insert /* eslint-disable no-useless-escape */ const newFunction = ` @@ -49,39 +45,31 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool const tagToInject = ''; - // Restore the original file if exists - await restoreFile(this.loginFilePath, false); - // Backup the original content - await backupFile(this.loginFilePath, true); - // Read the file content - let fileContent = await readFile(this.loginFilePath, 'utf-8'); + const originalContent = await readFile(this.loginFilePath, 'utf-8'); + + // Create modified content + let newContent = originalContent; - // Add new function after the opening PHP tag ( tag - fileContent = fileContent.replace(/<\/form>/i, `\n${tagToInject}`); + // Inject the PHP include tag + newContent = newContent.replace(/<\/form>/i, `\n${tagToInject}`); - // Write the updated content back to the file - await writeFile(this.loginFilePath, fileContent); - - this.logger.log('Login Function replaced successfully.'); - } - async rollback(): Promise { - const restored = await restoreFile(this.loginFilePath, false); - if (restored) { - this.logger.debug('SSO login file restored.'); - } else { - this.logger.debug('No SSO login file backup found.'); - } + // Create and return the patch + const patch = createPatch(this.loginFilePath, originalContent, newContent, 'original', 'modified'); + + return { + targetFile: this.loginFilePath, + patch + }; } async shouldApply(): Promise { diff --git a/api/src/unraid-api/unraid-file-modifier/patch-utils.ts b/api/src/unraid-api/unraid-file-modifier/patch-utils.ts new file mode 100644 index 000000000..d9eb5b9aa --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/patch-utils.ts @@ -0,0 +1,20 @@ +import { readFile, writeFile } from 'fs/promises'; +import { applyPatch, parsePatch, reversePatch } from 'diff'; + +export async function rollbackPatch(targetFile: string, patch: string): Promise { + const currentContent = await readFile(targetFile, 'utf8'); + const parsedPatch = parsePatch(patch)[0]; + + if (!parsedPatch || !parsedPatch.hunks || parsedPatch.hunks.length === 0) { + throw new Error('Invalid or empty patch content'); + } + + const reversedPatch = reversePatch(parsedPatch); + const results = applyPatch(currentContent, reversedPatch); + + if (results === false) { + throw new Error(`Failed to rollback patch from ${targetFile}`); + } + + await writeFile(targetFile, results); +} \ No newline at end of file diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts index 8ad6d22cc..ed8013d5a 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts @@ -1,29 +1,15 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; - import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification'; import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification'; import { LogRotateModification } from '@app/unraid-api/unraid-file-modifier/modifications/log-rotate.modification'; import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification'; import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification'; +import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification'; -export interface ShouldApplyWithReason { - shouldApply: boolean; - reason: string; -} - -// Step 1: Define the interface -export interface FileModification { - id: string; // Unique identifier for the operation - apply(): Promise; // Method to apply the modification - rollback(): Promise; // Method to roll back the modification - shouldApply(): Promise; // Method to determine if the modification should be applied -} - -// Step 2: Create a FileModificationService @Injectable() export class UnraidFileModificationService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(UnraidFileModificationService.name); - private history: FileModification[] = []; // Keeps track of applied modifications + private appliedModifications: FileModification[] = []; async onModuleInit() { try { @@ -31,17 +17,19 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest const mods = await this.loadModifications(); await this.applyModifications(mods); } catch (err) { - this.logger.error(`Failed to apply modifications: ${err}`); + if (err instanceof Error) { + this.logger.error(`Failed to apply modifications: ${err.message}`); + } else { + this.logger.error('Failed to apply modifications: Unknown error'); + } } } + async onModuleDestroy() { this.logger.log('Rolling back all modifications...'); await this.rollbackAll(); } - /** - * Dynamically load all file modifications from the specified folder. - */ async loadModifications(): Promise { const modifications: FileModification[] = []; const modificationClasses: Array FileModification> = [ @@ -64,10 +52,6 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest } } - /** - * Apply a file modification. - * @param modification - The file modification to apply - */ async applyModification(modification: FileModification): Promise { try { const shouldApplyWithReason = await modification.shouldApply(); @@ -76,7 +60,7 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest `Applying modification: ${modification.id} - ${shouldApplyWithReason.reason}` ); await modification.apply(); - this.history.push(modification); // Store modification in history + this.appliedModifications.push(modification); this.logger.log(`Modification applied successfully: ${modification.id}`); } else { this.logger.log( @@ -90,35 +74,26 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest error.stack ); } else { - this.logger.error(`Failed to apply modification: ${modification.id}: ${error}`); + this.logger.error(`Failed to apply modification: ${modification.id}: Unknown error`); } } } - /** - * Roll back all applied modifications in reverse order. - */ async rollbackAll(): Promise { - while (this.history.length > 0) { - const modification = this.history.pop(); // Get the last applied modification - if (modification) { - try { - this.logger.log(`Rolling back modification: ${modification.id}`); - await modification.rollback(); - this.logger.log(`Modification rolled back successfully: ${modification.id}`); - } catch (error) { - if (error instanceof Error) { - this.logger.error( - `Failed to roll back modification: ${modification.id}: ${error.message}`, - error.stack - ); - } else { - this.logger.error( - `Failed to roll back modification: ${modification.id}: ${error}` - ); - } + // Process modifications in reverse order + for (const modification of [...this.appliedModifications].reverse()) { + try { + this.logger.log(`Rolling back modification: ${modification.id}`); + await modification.rollback(); + this.logger.log(`Successfully rolled back modification: ${modification.id}`); + } catch (error) { + if (error instanceof Error) { + this.logger.error(`Failed to roll back modification: ${error.message}`); + } else { + this.logger.error('Failed to roll back modification: Unknown error'); } } } + this.appliedModifications = []; } } diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts index e833a547a..0803eed20 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -1,32 +1,46 @@ import { Logger } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { join } from 'path'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - FileModification, - UnraidFileModificationService, -} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; -class TestFileModification implements FileModification { - constructor( - public applyImplementation?: () => Promise, - public rollbackImplementation?: () => Promise - ) {} +import { createPatch } from 'diff'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + + + +import { FileModification, PatchResult, ShouldApplyWithReason } from '@app/unraid-api/unraid-file-modifier/file-modification'; +import { UnraidFileModificationService } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; + + + + + +class TestFileModification extends FileModification { id = 'test'; - async apply() { - if (this.applyImplementation) { - return this.applyImplementation(); - } - throw new Error('Application not implemented.'); + + constructor(logger: Logger) { + super(logger); } - async rollback() { - if (this.rollbackImplementation) { - return this.rollbackImplementation(); - } - throw new Error('Rollback not implemented.'); + + protected async generatePatch(): Promise { + return { + targetFile: join(__dirname, '__fixtures__/text-patch-file.txt'), + patch: createPatch( + '__fixtures__/text-patch-file.txt', + 'original', + 'modified', + 'original', + 'modified' + ), + }; } - async shouldApply() { + + apply = vi.fn(); + + rollback = vi.fn(); + + async shouldApply(): Promise { return { shouldApply: true, reason: 'Always Apply this mod' }; } } @@ -40,6 +54,8 @@ describe('FileModificationService', () => { verbose: ReturnType; }; let service: UnraidFileModificationService; + let logger: Logger; + beforeEach(async () => { mockLogger = { log: vi.fn(), @@ -54,6 +70,9 @@ describe('FileModificationService', () => { vi.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn); vi.spyOn(Logger.prototype, 'debug').mockImplementation(mockLogger.debug); vi.spyOn(Logger.prototype, 'verbose').mockImplementation(mockLogger.verbose); + + logger = new Logger('test'); + const module: TestingModule = await Test.createTestingModule({ providers: [UnraidFileModificationService], }).compile(); @@ -71,7 +90,8 @@ describe('FileModificationService', () => { }); it('should apply modifications', async () => { - await expect(service.applyModification(new TestFileModification())).resolves.toBe(undefined); + const mod = new TestFileModification(logger); + await expect(service.applyModification(mod)).resolves.toBe(undefined); }); it('should not rollback any mods without loaded', async () => { @@ -80,47 +100,62 @@ describe('FileModificationService', () => { it('should rollback all mods', async () => { await service.loadModifications(); - const applyFn = vi.fn(); - const rollbackFn = vi.fn(); - await service.applyModification(new TestFileModification(applyFn, rollbackFn)); - await expect(service.rollbackAll()).resolves.toBe(undefined); + const mod = new TestFileModification(logger); + + await service.applyModification(mod); + await service.rollbackAll(); + expect(mockLogger.error).not.toHaveBeenCalled(); - expect(mockLogger.log).toHaveBeenCalledTimes(5); - expect(applyFn).toHaveBeenCalled(); - expect(rollbackFn).toHaveBeenCalled(); - expect(mockLogger.log).toHaveBeenNthCalledWith(1, 'RootTestModule dependencies initialized'); - expect(mockLogger.log).toHaveBeenNthCalledWith( - 2, - 'Applying modification: test - Always Apply this mod' - ); - expect(mockLogger.log).toHaveBeenNthCalledWith(3, 'Modification applied successfully: test'); - expect(mockLogger.log).toHaveBeenNthCalledWith(4, 'Rolling back modification: test'); - expect(mockLogger.log).toHaveBeenNthCalledWith(5, 'Modification rolled back successfully: test'); + expect(mockLogger.log.mock.calls).toEqual([ + ['RootTestModule dependencies initialized'], + ['Applying modification: test - Always Apply this mod'], + ['Modification applied successfully: test'], + ['Rolling back modification: test'], + ['Successfully rolled back modification: test'], + ]); }); it('should handle errors during rollback', async () => { - const errorMod = new TestFileModification(vi.fn(), () => - Promise.reject(new Error('Rollback failed')) - ); - await service.applyModification(errorMod); + // Mock the logger to track error calls + + const mod = new TestFileModification(logger); + await service.applyModification(mod); + console.log(service.appliedModifications); + expect(mockLogger.log.mock.calls).toEqual([ + ['RootTestModule dependencies initialized'], + ['Applying modification: test - Always Apply this mod'], + ['Modification applied successfully: test'], + ]); + + service.appliedModifications[0].appliedPatch = null; + console.log(service.appliedModifications); + // Now break the appliedModifications array so that the rollbackAll method fails await service.rollbackAll(); - expect(mockLogger.error).toHaveBeenCalled(); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to roll back modification') + ); }); it('should handle concurrent modifications', async () => { - const mods = [ - new TestFileModification(vi.fn(), vi.fn()), - new TestFileModification(vi.fn(), vi.fn()), - ]; + vi.mock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue('modified'), + writeFile: vi.fn(), + })); + const mods = [new TestFileModification(logger), new TestFileModification(logger)]; + await Promise.all(mods.map((mod) => service.applyModification(mod))); await service.rollbackAll(); - mods.forEach((mod) => { - expect(mod.rollbackImplementation).toHaveBeenCalled(); - }); + + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Successfully rolled back modification') + ); }); afterEach(async () => { await service.rollbackAll(); vi.clearAllMocks(); + vi.resetModules(); }); -}); +}); \ No newline at end of file diff --git a/api/src/utils.ts b/api/src/utils.ts index 16856f137..ab9f72d4a 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -245,75 +245,3 @@ export function handleAuthError( throw new UnauthorizedException(`${operation}: ${errorMessage}`); } - -/** - * Helper method to allow backing up a single file to a .bak file. - * @param path the file to backup, creates a .bak file in the same directory - * @throws Error if the file cannot be copied - */ -export const backupFile = async (path: string, throwOnMissing = true): Promise => { - try { - // Validate path - if (!path) { - throw new Error('File path cannot be empty'); - } - - // Check if source file exists and is readable - await access(path, constants.R_OK); - - // Check if backup directory is writable - await access(dirname(path), constants.W_OK); - - const backupPath = path + '.bak'; - await copyFile(path, backupPath); - } catch (err) { - const error = err as NodeJS.ErrnoException; - if (throwOnMissing) { - throw new Error( - error.code === 'ENOENT' - ? `File does not exist: ${path}` - : error.code === 'EACCES' - ? `Permission denied: ${path}` - : `Failed to backup file: ${error.message}` - ); - } - } -}; - -/** - * - * @param path Path to original (not .bak) file - * @param throwOnMissing Whether to throw an error if the backup file does not exist - * @throws Error if the backup file does not exist and throwOnMissing is true - * @returns boolean indicating whether the restore was successful - */ -export const restoreFile = async (path: string, throwOnMissing = true): Promise => { - if (!path) { - throw new Error('File path cannot be empty'); - } - - const backupPath = path + '.bak'; - try { - // Check if backup file exists and is readable - await access(backupPath, constants.R_OK); - - // Check if target directory is writable - await access(dirname(path), constants.W_OK); - - await copyFile(backupPath, path); - await unlink(backupPath); - return true; - } catch (err) { - const error = err as NodeJS.ErrnoException; - if (throwOnMissing) { - throw new Error( - error.code === 'ENOENT' - ? `Backup file does not exist: ${backupPath}` - : error.code === 'EACCES' - ? `Permission denied: ${path}` - : `Failed to restore file: ${error.message}` - ); - } - return false; - } -};