From 4d26a0ab9ca2a42eda1224c6007f1b5cb5b77cae Mon Sep 17 00:00:00 2001 From: kitWarse <278602811+kitWarse@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:13:39 +0100 Subject: [PATCH] feat(validation): reject empty/whitespace-only channel names closes #479 --- listener/API_ERROR_REFERENCE.md | 2 +- listener/package.json | 5 +- listener/src/utils/batch-validator.test.ts | 63 +++++++++++++++++++++- listener/src/utils/batch-validator.ts | 60 +++++++++++++++++++-- 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/listener/API_ERROR_REFERENCE.md b/listener/API_ERROR_REFERENCE.md index b96f9bb..7d41fd2 100644 --- a/listener/API_ERROR_REFERENCE.md +++ b/listener/API_ERROR_REFERENCE.md @@ -75,7 +75,7 @@ Sample invalid date: | 400 | `errors[].code = PARSE_ERROR` | Request body is not valid JSON. | Send a JSON array or `{ "notifications": [...] }`. | | 400 | `INVALID_STRUCTURE` | Body is not an array and does not contain `notifications`. | Send an array of notification payloads. | | 400 | `EMPTY_BATCH` | Batch has no items. | Include at least one notification. | -| 400 | `MISSING_FIELD`, `EMPTY_FIELD`, `INVALID_CHANNEL`, `DUPLICATE_RECIPIENT` | One or more notification items are invalid. | Correct the item fields shown in `errors[]`. | +| 400 | `MISSING_FIELD`, `EMPTY_FIELD`, `EMPTY_CHANNEL_NAME`, `INVALID_CHANNEL`, `DUPLICATE_RECIPIENT` | One or more notification items are invalid. | Correct the item fields shown in `errors[]`. | Sample validation failure: diff --git a/listener/package.json b/listener/package.json index 1de3a88..498caf2 100644 --- a/listener/package.json +++ b/listener/package.json @@ -10,10 +10,7 @@ "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "test": "node ./node_modules/jest/bin/jest.js", "migrate": "ts-node src/scripts/migrate-db.ts", - "migrate:templates": "ts-node src/scripts/migrate-templates.ts" - "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", - "lint": "node ./node_modules/typescript/bin/tsc --noEmit", - "migrate": "ts-node src/scripts/migrate-db.ts", + "migrate:templates": "ts-node src/scripts/migrate-templates.ts", "check-migrations": "ts-node src/scripts/check-migrations.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, diff --git a/listener/src/utils/batch-validator.test.ts b/listener/src/utils/batch-validator.test.ts index 886cb68..94c3547 100644 --- a/listener/src/utils/batch-validator.test.ts +++ b/listener/src/utils/batch-validator.test.ts @@ -1,4 +1,4 @@ -import { BatchValidator } from './batch-validator'; +import { BatchValidator, validateChannelName } from './batch-validator'; import { BatchValidationService } from '../services/batch-validation-service'; describe('BatchValidator', () => { @@ -46,11 +46,21 @@ describe('BatchValidator', () => { }); it('rejects unsupported channels', () => { - const result = BatchValidator.validateBatch([{ ...validItem, channel: 'telegram' }]); + const result = BatchValidator.validateBatch([{ ...validItem, channel: 'telegram' as any }]); expect(result.isValid).toBe(false); expect(result.errors[0].code).toBe('INVALID_CHANNEL'); }); + it('rejects empty and whitespace-only channel names', () => { + const resultEmpty = BatchValidator.validateBatch([{ ...validItem, channel: '' as any }]); + expect(resultEmpty.isValid).toBe(false); + expect(resultEmpty.errors.some((e) => e.field === 'channel' && e.code === 'EMPTY_CHANNEL_NAME')).toBe(true); + + const resultWhitespace = BatchValidator.validateBatch([{ ...validItem, channel: ' ' as any }]); + expect(resultWhitespace.isValid).toBe(false); + expect(resultWhitespace.errors.some((e) => e.field === 'channel' && e.code === 'EMPTY_CHANNEL_NAME')).toBe(true); + }); + it('rejects empty string fields', () => { const result = BatchValidator.validateBatch([{ ...validItem, message: ' ' }]); expect(result.isValid).toBe(false); @@ -77,3 +87,52 @@ describe('BatchValidationService', () => { }); }); }); + +describe('validateChannelName', () => { + it('accepts valid channels', () => { + expect(validateChannelName('discord')).toEqual({ valid: true }); + expect(validateChannelName('webhook')).toEqual({ valid: true }); + expect(validateChannelName('email')).toEqual({ valid: true }); + expect(validateChannelName('sms')).toEqual({ valid: true }); + }); + + it('rejects undefined, null, or empty channel name', () => { + expect(validateChannelName(undefined)).toEqual({ + valid: false, + code: 'EMPTY_CHANNEL_NAME', + message: expect.stringContaining('must not be empty'), + }); + expect(validateChannelName(null)).toEqual({ + valid: false, + code: 'EMPTY_CHANNEL_NAME', + message: expect.stringContaining('must not be empty'), + }); + expect(validateChannelName('')).toEqual({ + valid: false, + code: 'EMPTY_CHANNEL_NAME', + message: expect.stringContaining('must not be empty'), + }); + }); + + it('rejects whitespace-only channel name', () => { + expect(validateChannelName(' ')).toEqual({ + valid: false, + code: 'EMPTY_CHANNEL_NAME', + message: expect.stringContaining('whitespace-only'), + }); + }); + + it('rejects unsupported channel types', () => { + expect(validateChannelName('telegram')).toEqual({ + valid: false, + code: 'INVALID_CHANNEL', + message: expect.stringContaining('not supported'), + }); + expect(validateChannelName(123)).toEqual({ + valid: false, + code: 'INVALID_CHANNEL', + message: expect.stringContaining('must be a string'), + }); + }); +}); + diff --git a/listener/src/utils/batch-validator.ts b/listener/src/utils/batch-validator.ts index 705bee2..007d6e1 100644 --- a/listener/src/utils/batch-validator.ts +++ b/listener/src/utils/batch-validator.ts @@ -4,6 +4,56 @@ import * as path from 'path'; export const VALID_CHANNELS = ['discord', 'webhook', 'email', 'sms'] as const; export type NotificationChannel = (typeof VALID_CHANNELS)[number]; +/** + * Issue #479: Validates a channel name, rejecting empty or whitespace-only values. + * + * Returns an object describing the validation result: + * - `valid: true` when the name is a non-empty, non-whitespace string that + * maps to a known channel. + * - `valid: false` with a `code` and `message` when it does not. + * + * Error codes: + * - `EMPTY_CHANNEL_NAME` – name is missing, empty, or whitespace-only. + * - `INVALID_CHANNEL` – name is present but not in VALID_CHANNELS. + */ +export function validateChannelName( + name: unknown, +): { valid: true } | { valid: false; code: string; message: string } { + if (name === undefined || name === null || name === '') { + return { + valid: false, + code: 'EMPTY_CHANNEL_NAME', + message: `Channel name must not be empty. Allowed values: ${VALID_CHANNELS.join(', ')}.`, + }; + } + + if (typeof name !== 'string') { + return { + valid: false, + code: 'INVALID_CHANNEL', + message: `Channel must be a string. Allowed values: ${VALID_CHANNELS.join(', ')}.`, + }; + } + + if (name.trim() === '') { + return { + valid: false, + code: 'EMPTY_CHANNEL_NAME', + message: `Channel name must not be empty or whitespace-only. Allowed values: ${VALID_CHANNELS.join(', ')}.`, + }; + } + + if (!VALID_CHANNELS.includes(name as NotificationChannel)) { + return { + valid: false, + code: 'INVALID_CHANNEL', + message: `Channel '${name}' is not supported. Allowed values: ${VALID_CHANNELS.join(', ')}.`, + }; + } + + return { valid: true }; +} + export interface NotificationPayload { id: string; recipient: string; @@ -106,13 +156,17 @@ export class BatchValidator { result.isValid = false; } + // Issue #479: reject empty/whitespace-only channel names explicitly before + // checking against VALID_CHANNELS so callers get a clear EMPTY_CHANNEL_NAME + // error rather than a misleading INVALID_CHANNEL error. if (item.channel !== undefined) { - if (!VALID_CHANNELS.includes(item.channel as NotificationChannel)) { + const channelResult = validateChannelName(item.channel); + if (!channelResult.valid) { result.errors.push({ index, field: 'channel', - code: 'INVALID_CHANNEL', - message: `Item at index [${index}]: Channel '${item.channel}' is not supported. Allowed: ${VALID_CHANNELS.join(', ')}.`, + code: channelResult.code, + message: `Item at index [${index}]: ${channelResult.message}`, }); result.isValid = false; }