Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/dev/configs/api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "4.25.2",
"version": "4.25.3",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
Expand Down
6 changes: 6 additions & 0 deletions api/dev/notifications/unread/Hashtag_Test_1730937650.notify
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
@@ -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&#8201;&#176;C (threshold: 40&#8201;&#176;C)<br><br>Current temperatures:<br>Parity - 32&#8201;&#176;C [OK]<br>Disk 1 - 45&#8201;&#176;C [WARNING]<br>Disk 2 - 38&#8201;&#176;C [OK]<br>Cache - 28&#8201;&#176;C [OK]<br><br>Please check cooling system.
importance=warning

1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('fs/promises')>('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
Expand Down Expand Up @@ -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',
Expand All @@ -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<NotificationIni, 'event'> = {
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',
Expand All @@ -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',
Expand All @@ -145,16 +146,12 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});

it('should handle missing description field (should return masked warning notification)', async () => {
const mockNotificationIni: Omit<NotificationIni, 'description'> = {
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',
Expand All @@ -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',
Expand All @@ -201,17 +194,12 @@ describe('NotificationsService - loadNotificationFile (minimal mocks)', () => {
});

it('should handle missing timestamp field gracefully', async () => {
const mockNotificationIni: Omit<NotificationIni, 'timestamp'> = {
// 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',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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';
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';

Expand Down Expand Up @@ -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<Notification> {
const rawContent = await readFile(path, 'utf-8');
const decodedContent = decode(rawContent);

const notificationFile = parseConfig<NotificationIni>({
filePath: path,
file: decodedContent,
type: 'ini',
Comment on lines 651 to 657

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hash characters in notification values are still stripped

Decoding the file before parseConfig removes HTML entities but the INI parser still treats # as a comment delimiter. If a notification subject or description legitimately contains a hash (for example the new Hashtag Test sample or real alerts like #1 disk is hot or hashtags), ini.parse will drop everything after the first #, so clients receive truncated text. Consider escaping hashes or configuring the parser so literal # characters survive.

Useful? React with 👍 / 👎.

});

Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading