diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json
index 346c6ceeb6..acaf5daa92 100644
--- a/api/dev/configs/api.json
+++ b/api/dev/configs/api.json
@@ -1,5 +1,5 @@
{
- "version": "4.25.2",
+ "version": "4.25.3",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
diff --git a/api/dev/notifications/unread/Hashtag_Test_1730937650.notify b/api/dev/notifications/unread/Hashtag_Test_1730937650.notify
new file mode 100644
index 0000000000..4998c09e92
--- /dev/null
+++ b/api/dev/notifications/unread/Hashtag_Test_1730937650.notify
@@ -0,0 +1,6 @@
+timestamp=1730937600
+event=Hashtag Test
+subject=Warning [UNRAID] - #1 OS is cooking
+description=Disk 1 temperature has reached #epic # levels of proportion
+importance=warning
+
diff --git a/api/dev/notifications/unread/Temperature_Test_1730937600.notify b/api/dev/notifications/unread/Temperature_Test_1730937600.notify
new file mode 100644
index 0000000000..bbbc79f11c
--- /dev/null
+++ b/api/dev/notifications/unread/Temperature_Test_1730937600.notify
@@ -0,0 +1,6 @@
+timestamp=1730937600
+event=Temperature Test
+subject=Warning [UNRAID] - High disk temperature detected: 45 °C
+description=Disk 1 temperature has reached 45 °C (threshold: 40 °C)
Current temperatures:
Parity - 32 °C [OK]
Disk 1 - 45 °C [WARNING]
Disk 2 - 38 °C [OK]
Cache - 28 °C [OK]
Please check cooling system.
+importance=warning
+
diff --git a/api/package.json b/api/package.json
index 160261f346..fd2ff9183b 100644
--- a/api/package.json
+++ b/api/package.json
@@ -116,6 +116,7 @@
"graphql-subscriptions": "3.0.0",
"graphql-tag": "2.12.6",
"graphql-ws": "6.0.6",
+ "html-entities": "^2.6.0",
"ini": "5.0.0",
"ip": "2.0.1",
"jose": "6.0.13",
diff --git a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts
index e95858e4c2..1c582ddd33 100644
--- a/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts
+++ b/api/src/unraid-api/graph/resolvers/notifications/loadNotificationsFile.test.ts
@@ -14,6 +14,16 @@ import {
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
+// Mock fs/promises for unit tests
+vi.mock('fs/promises', async () => {
+ const actual = await vi.importActual('fs/promises');
+ const mockReadFile = vi.fn();
+ return {
+ ...actual,
+ readFile: mockReadFile,
+ };
+});
+
// Mock getters.dynamix, Logger, and pubsub
vi.mock('@app/store/index.js', () => {
// Create test directory path inside factory function
@@ -61,24 +71,24 @@ const testNotificationsDir = join(tmpdir(), 'unraid-api-test-notifications');
describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
let service: NotificationsService;
+ let mockReadFile: any;
- beforeEach(() => {
+ beforeEach(async () => {
+ const fsPromises = await import('fs/promises');
+ mockReadFile = fsPromises.readFile as any;
+ vi.mocked(mockReadFile).mockClear();
service = new NotificationsService();
});
it('should load and validate a valid notification file', async () => {
- const mockNotificationIni: NotificationIni = {
- timestamp: '1609459200',
- event: 'Test Event',
- subject: 'Test Subject',
- description: 'Test Description',
- importance: 'alert',
- link: 'http://example.com',
- };
-
- vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
- mockNotificationIni
- );
+ const mockFileContent = `timestamp=1609459200
+event=Test Event
+subject=Test Subject
+description=Test Description
+importance=alert
+link=http://example.com`;
+
+ vi.mocked(mockReadFile).mockResolvedValue(mockFileContent);
const result = await (service as any).loadNotificationFile(
'/test/path/test.notify',
@@ -99,17 +109,12 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});
it('should return masked warning notification on validation error (missing required fields)', async () => {
- const invalidNotificationIni: Omit = {
- timestamp: '1609459200',
- // event: 'Missing Event', // missing required field
- subject: 'Test Subject',
- description: 'Test Description',
- importance: 'alert',
- };
-
- vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
- invalidNotificationIni
- );
+ const mockFileContent = `timestamp=1609459200
+subject=Test Subject
+description=Test Description
+importance=alert`;
+
+ vi.mocked(mockReadFile).mockResolvedValue(mockFileContent);
const result = await (service as any).loadNotificationFile(
'/test/path/invalid.notify',
@@ -121,17 +126,13 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});
it('should handle invalid enum values', async () => {
- const invalidNotificationIni: NotificationIni = {
- timestamp: '1609459200',
- event: 'Test Event',
- subject: 'Test Subject',
- description: 'Test Description',
- importance: 'not-a-valid-enum' as any,
- };
-
- vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
- invalidNotificationIni
- );
+ const mockFileContent = `timestamp=1609459200
+event=Test Event
+subject=Test Subject
+description=Test Description
+importance=not-a-valid-enum`;
+
+ vi.mocked(mockReadFile).mockResolvedValue(mockFileContent);
const result = await (service as any).loadNotificationFile(
'/test/path/invalid-enum.notify',
@@ -145,16 +146,12 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});
it('should handle missing description field (should return masked warning notification)', async () => {
- const mockNotificationIni: Omit = {
- timestamp: '1609459200',
- event: 'Test Event',
- subject: 'Test Subject',
- importance: 'normal',
- };
-
- vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
- mockNotificationIni
- );
+ const mockFileContent = `timestamp=1609459200
+event=Test Event
+subject=Test Subject
+importance=normal`;
+
+ vi.mocked(mockReadFile).mockResolvedValue(mockFileContent);
const result = await (service as any).loadNotificationFile(
'/test/path/test.notify',
@@ -166,19 +163,15 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});
it('should preserve passthrough data from notification file (only known fields)', async () => {
- const mockNotificationIni: NotificationIni & { customField: string } = {
- timestamp: '1609459200',
- event: 'Test Event',
- subject: 'Test Subject',
- description: 'Test Description',
- importance: 'normal',
- link: 'http://example.com',
- customField: 'custom value',
- };
-
- vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
- mockNotificationIni
- );
+ const mockFileContent = `timestamp=1609459200
+event=Test Event
+subject=Test Subject
+description=Test Description
+importance=normal
+link=http://example.com
+customField=custom value`;
+
+ vi.mocked(mockReadFile).mockResolvedValue(mockFileContent);
const result = await (service as any).loadNotificationFile(
'/test/path/test.notify',
@@ -201,17 +194,12 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});
it('should handle missing timestamp field gracefully', async () => {
- const mockNotificationIni: Omit = {
- // timestamp is missing
- event: 'Test Event',
- subject: 'Test Subject',
- description: 'Test Description',
- importance: 'alert',
- };
-
- vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
- mockNotificationIni
- );
+ const mockFileContent = `event=Test Event
+subject=Test Subject
+description=Test Description
+importance=alert`;
+
+ vi.mocked(mockReadFile).mockResolvedValue(mockFileContent);
const result = await (service as any).loadNotificationFile(
'/test/path/missing-timestamp.notify',
@@ -225,17 +213,13 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});
it('should handle malformed timestamp field gracefully', async () => {
- const mockNotificationIni: NotificationIni = {
- timestamp: 'not-a-timestamp',
- event: 'Test Event',
- subject: 'Test Subject',
- description: 'Test Description',
- importance: 'alert',
- };
-
- vi.spyOn(await import('@app/core/utils/misc/parse-config.js'), 'parseConfig').mockReturnValue(
- mockNotificationIni
- );
+ const mockFileContent = `timestamp=not-a-timestamp
+event=Test Event
+subject=Test Subject
+description=Test Description
+importance=alert`;
+
+ vi.mocked(mockReadFile).mockResolvedValue(mockFileContent);
const result = await (service as any).loadNotificationFile(
'/test/path/malformed-timestamp.notify',
diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts
index 1e59dc39b4..6ec780d666 100644
--- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts
+++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
-import { readdir, rename, stat, unlink, writeFile } from 'fs/promises';
+import { readdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises';
import { basename, join } from 'path';
import type { Stats } from 'fs';
@@ -7,6 +7,7 @@ import { FSWatcher, watch } from 'chokidar';
import { ValidationError } from 'class-validator';
import { execa } from 'execa';
import { emptyDir } from 'fs-extra';
+import { decode } from 'html-entities';
import { encode as encodeIni } from 'ini';
import { v7 as uuidv7 } from 'uuid';
@@ -648,8 +649,11 @@ export class NotificationsService {
* @throws File system errors (file not found, permission issues) or unexpected validation errors.
*/
private async loadNotificationFile(path: string, type: NotificationType): Promise {
+ const rawContent = await readFile(path, 'utf-8');
+ const decodedContent = decode(rawContent);
+
const notificationFile = parseConfig({
- filePath: path,
+ file: decodedContent,
type: 'ini',
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1156f11f2e..1140a871e9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -223,6 +223,9 @@ importers:
graphql-ws:
specifier: 6.0.6
version: 6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3)
+ html-entities:
+ specifier: ^2.6.0
+ version: 2.6.0
ini:
specifier: 5.0.0
version: 5.0.0
@@ -8264,6 +8267,9 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
+ html-entities@2.6.0:
+ resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
+
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -20667,6 +20673,8 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
+ html-entities@2.6.0: {}
+
html-escaper@2.0.2: {}
html-escaper@3.0.3: {}