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: {}