Skip to content

Commit c2f4e8d

Browse files
committed
feat: rollback if patch exists before applying
1 parent a5447aa commit c2f4e8d

File tree

5 files changed

+83
-18
lines changed

5 files changed

+83
-18
lines changed

api/src/unraid-api/unraid-file-modifier/file-modification.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { access, readFile, unlink, writeFile } from 'fs/promises';
44
import { basename, dirname, join } from 'path';
55

66
import { applyPatch, parsePatch, reversePatch } from 'diff';
7-
import { patch } from 'semver';
87

98
export interface ShouldApplyWithReason {
109
shouldApply: boolean;
@@ -28,11 +27,16 @@ export abstract class FileModification {
2827
}
2928

3029
private async savePatch(patchResult: string): Promise<void> {
31-
const patchFile = this.getPatchFilePath(this.filePath);
32-
await writeFile(patchFile, patchResult, 'utf8');
30+
const patchFilePath = this.getPatchFilePath(this.filePath);
31+
await writeFile(patchFilePath, patchResult, 'utf8');
3332
}
3433

35-
private async loadSavedPatch(targetFile: string): Promise<string | null> {
34+
/**
35+
* Loads the applied patch for the target file if it exists
36+
* @param targetFile - The path to the file to be patched
37+
* @returns The patch contents if it exists (targetFile.patch), null otherwise
38+
*/
39+
private async loadPatchedFilePatch(targetFile: string): Promise<string | null> {
3640
const patchFile = this.getPatchFilePath(targetFile);
3741
try {
3842
await access(patchFile, constants.R_OK);
@@ -80,9 +84,13 @@ export abstract class FileModification {
8084
await writeFile(this.filePath, results);
8185
}
8286

83-
// Default implementation of apply that uses the patch
8487
async apply(): Promise<void> {
8588
try {
89+
const savedPatch = await this.loadPatchedFilePatch(this.filePath);
90+
if (savedPatch) {
91+
// Rollback the saved patch before applying the new patch
92+
await this.rollback();
93+
}
8694
// First attempt to apply the patch that was generated
8795
const staticPatch = await this.getPregeneratedPatch();
8896
if (staticPatch) {
@@ -104,12 +112,11 @@ export abstract class FileModification {
104112
}
105113
}
106114

107-
// Update rollback to use the shared utility
108115
async rollback(): Promise<void> {
109116
let patch: string;
110117

111118
// Try to load saved patch first
112-
const savedPatch = await this.loadSavedPatch(this.filePath);
119+
const savedPatch = await this.loadPatchedFilePatch(this.filePath);
113120
if (savedPatch) {
114121
this.logger.debug(`Using saved patch file for ${this.id}`);
115122
patch = savedPatch;
@@ -141,15 +148,37 @@ export abstract class FileModification {
141148
await access(patchFile, constants.W_OK);
142149
await unlink(patchFile);
143150
} catch {
144-
// Ignore errors when trying to delete the patch file
151+
this.logger.warn(`Failed to delete patch file for ${this.id}`);
145152
}
146153
}
147154

148155
// Default implementation that can be overridden if needed
149156
async shouldApply(): Promise<ShouldApplyWithReason> {
150-
return {
151-
shouldApply: true,
152-
reason: 'Default behavior is to always apply modifications',
153-
};
157+
try {
158+
if (!this.filePath || !this.id) {
159+
throw new Error('Invalid file modification configuration');
160+
}
161+
162+
const fileExists = await access(this.filePath, constants.R_OK | constants.W_OK)
163+
.then(() => true)
164+
.catch(() => false);
165+
166+
if (!fileExists) {
167+
return {
168+
shouldApply: false,
169+
reason: `Target file ${this.filePath} does not exist or is not accessible`,
170+
};
171+
}
172+
return {
173+
shouldApply: true,
174+
reason: 'Default behavior is to apply modifications if the file exists',
175+
};
176+
} catch (err) {
177+
this.logger.error(`Failed to check if file ${this.filePath} should be applied: ${err}`);
178+
return {
179+
shouldApply: false,
180+
reason: `Failed to check if file ${this.filePath} should be applied: ${err}`,
181+
};
182+
}
154183
}
155184
}
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1738614986599
1+
1738681678475
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1738614986764
1+
1738681678771
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1738614987214
1+
1738681679144

api/src/unraid-api/unraid-file-modifier/modifications/__test__/generic-modification.spec.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@ import { Logger } from '@nestjs/common';
22
import { readFile, writeFile } from 'fs/promises';
33
import { basename, resolve } from 'path';
44

5-
import { describe, expect, test } from 'vitest';
5+
import { describe, expect, test, vi } from 'vitest';
66

77
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification';
88
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification';
99
import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification';
1010
import { LogRotateModification } from '@app/unraid-api/unraid-file-modifier/modifications/log-rotate.modification';
1111
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification';
1212
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification';
13+
import { afterEach } from 'node:test';
1314

1415
interface ModificationTestCase {
1516
ModificationClass: new (...args: ConstructorParameters<typeof FileModification>) => FileModification;
1617
fileUrl: string;
1718
fileName: string;
1819
}
19-
20+
let patcher: FileModification;
2021
const testCases: ModificationTestCase[] = [
2122
{
2223
ModificationClass: DefaultPageLayoutModification,
@@ -80,7 +81,7 @@ async function testModification(testCase: ModificationTestCase) {
8081
const filePath = resolve(__dirname, `../__fixtures__/downloaded/${fileName}`);
8182
const originalContent = await downloadOrRetrieveOriginalFile(filePath, testCase.fileUrl);
8283
const logger = new Logger();
83-
const patcher = await new testCase.ModificationClass(logger);
84+
patcher = await new testCase.ModificationClass(logger);
8485
const originalPath = patcher.filePath;
8586
// @ts-expect-error - Ignore for testing purposes
8687
patcher.filePath = filePath;
@@ -103,8 +104,43 @@ async function testModification(testCase: ModificationTestCase) {
103104
await expect(revertedContent).toMatch(originalContent);
104105
}
105106

107+
async function testInvalidModification(testCase: ModificationTestCase) {
108+
const mockLogger = {
109+
log: vi.fn(),
110+
error: vi.fn(),
111+
warn: vi.fn(),
112+
debug: vi.fn(),
113+
verbose: vi.fn(),
114+
};
115+
116+
patcher = new testCase.ModificationClass(mockLogger as unknown as Logger);
117+
118+
// @ts-expect-error - Testing invalid pregenerated patches
119+
patcher.getPregeneratedPatch = vi.fn().mockResolvedValue('I AM NOT A VALID PATCH');
120+
121+
const path = patcher.filePath;
122+
const filePath = resolve(__dirname, `../__fixtures__/downloaded/${testCase.fileName}`);
123+
124+
// @ts-expect-error - Testing invalid pregenerated patches
125+
patcher.filePath = filePath;
126+
await patcher.apply();
127+
128+
expect(mockLogger.error.mock.calls[0][0]).toContain(`Failed to apply static patch to ${filePath}`);
129+
130+
expect(mockLogger.error.mock.calls.length).toBe(1);
131+
await patcher.rollback();
132+
}
133+
106134
describe('File modifications', () => {
107135
test.each(testCases)(`$fileName modifier correctly applies to fresh install`, async (testCase) => {
108136
await testModification(testCase);
109137
});
138+
139+
test.each(testCases)(`$fileName modifier correctly handles invalid content`, async (testCase) => {
140+
await testInvalidModification(testCase);
141+
});
142+
143+
afterEach(async () => {
144+
await patcher?.rollback();
145+
});
110146
});

0 commit comments

Comments
 (0)