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
1 change: 1 addition & 0 deletions backend/config/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export const ERROR_CODES = {
NOTIFICATIONS_MISSING_REQUIRED_FIELDS: 'NOTIFICATIONS_MISSING_REQUIRED_FIELDS',
NOTIFICATIONS_INVALID_SCHEDULED_DATE: 'NOTIFICATIONS_INVALID_SCHEDULED_DATE',
NOTIFICATIONS_INVALID_MODULE: 'NOTIFICATIONS_INVALID_MODULE',
NOTIFICATIONS_INVALID_RECIPIENTS: 'NOTIFICATIONS_INVALID_RECIPIENTS',
NOTIFICATIONS_NOT_FOUND: 'NOTIFICATIONS_NOT_FOUND',
NOTIFICATIONS_FETCH_ERROR: 'NOTIFICATIONS_FETCH_ERROR',
NOTIFICATIONS_FETCH_UNREAD_ERROR: 'NOTIFICATIONS_FETCH_UNREAD_ERROR',
Expand Down
27 changes: 27 additions & 0 deletions backend/controllers/notifications/notification-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NotificationModule,
} from '../../models/notifications/index'
import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js'
import { UserRepository } from '../../repositories/auth/user-repository.js'
import { logger } from '../../config/logger.js'
import {
generalNotificationSchema,
Expand Down Expand Up @@ -351,6 +352,19 @@ export class NotificationController {
}
}

// Only users that exist and are active can receive it (checked before
// creating the notification, so a bad id leaves nothing behind)
if (
userIds?.length &&
(await UserRepository.getActiveIds(userIds)).length !== userIds.length
) {
return res.status(400).json({
success: false,
error: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS,
code: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS,
})
}

await NotificationGeneratorService.generateManualNotification(
groupId,
title,
Expand Down Expand Up @@ -421,6 +435,19 @@ export class NotificationController {
}
}

// Only users that exist and are active can receive it (checked before
// creating the notification, so a bad id leaves nothing behind)
if (
userIds?.length &&
(await UserRepository.getActiveIds(userIds)).length !== userIds.length
) {
return res.status(400).json({
success: false,
error: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS,
code: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS,
})
}

await NotificationGeneratorService.generateGeneralNotification({
title,
message,
Expand Down
12 changes: 11 additions & 1 deletion backend/repositories/auth/user-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import crypto from 'crypto'
import { SALT_ROUNDS } from '../../config/config.js'
import db from '../../config/db.js'
import { ResultSetHeader } from 'mysql2'
import { ResultSetHeader, type RowDataPacket } from 'mysql2'
import { logger } from '../../config/logger.js'
import type {
User,
Expand Down Expand Up @@ -86,10 +86,10 @@
created_at: new Date(createdAt),
is_active: 1,
}
} catch (error: any) {

Check warning on line 89 in backend/repositories/auth/user-repository.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
logger.error({ err: error }, 'Error creating user')
if (error.code === 'ER_DUP_ENTRY') {
const err = new Error('El nombre de usuario o email ya existe') as any

Check warning on line 92 in backend/repositories/auth/user-repository.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
err.code = 'ER_DUP_ENTRY'
throw err
}
Expand Down Expand Up @@ -130,7 +130,7 @@
try {
Validation.username(username)
Validation.password(password)
} catch (err: any) {

Check warning on line 133 in backend/repositories/auth/user-repository.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
throw new Error(err.message, { cause: err })
}

Expand Down Expand Up @@ -226,6 +226,16 @@
}
}

/** Of the given ids, the ones that exist and are active */
static async getActiveIds(ids: readonly string[]): Promise<string[]> {
if (ids.length === 0) return []
const [rows] = await db.query<(RowDataPacket & { id: string })[]>(
'SELECT id FROM users WHERE is_active = 1 AND id IN (?)',
[ids]
)
return rows.map((r) => r.id)
}

/**
* Obtener por ID con JOIN directo
*/
Expand Down Expand Up @@ -397,7 +407,7 @@
// Devolver el usuario completo actualizado
const updatedUser = await this.getById(id)
return updatedUser
} catch (error: any) {

Check warning on line 410 in backend/repositories/auth/user-repository.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
await dbConnection.rollback()
logger.error({ err: error }, 'Error en update')

Expand Down Expand Up @@ -551,7 +561,7 @@
}

return updatedUser
} catch (error: any) {

Check warning on line 564 in backend/repositories/auth/user-repository.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
await dbConnection.rollback()
logger.error({ err: error }, 'Error en updateProfile')
throw error
Expand Down Expand Up @@ -613,7 +623,7 @@
logger.info({ event: 'password_changed', userId }, '[AUTH] password changed')

return true
} catch (error: any) {

Check warning on line 626 in backend/repositories/auth/user-repository.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
await dbConnection.rollback()
logger.error({ err: error }, 'Error en updatePassword')
throw error
Expand Down Expand Up @@ -646,7 +656,7 @@
logger.info({ event: 'password_reset_by_admin', userId }, '[AUTH] password reset by admin')

return true
} catch (error: any) {

Check warning on line 659 in backend/repositories/auth/user-repository.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
logger.error({ err: error }, 'Error en resetPassword')
throw error
}
Expand Down
88 changes: 88 additions & 0 deletions backend/tests/notifications/manual-recipients.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// tests/notifications/manual-recipients.test.ts
// Regression tests: manual notifications accepted any user id. An inactive
// user got the notice, and an unknown one broke the recipients insert after
// the notification was created, leaving it without recipients. Both are now
// refused with NOTIFICATIONS_INVALID_RECIPIENTS before anything is written.

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Request, Response } from 'express'

// The controller also loads NotificationRepository: without this the pool's
// config needs a real database password (CI has none)
vi.mock('../../config/db.js', () => ({ default: { query: vi.fn(), getConnection: vi.fn() } }))
vi.mock('../../repositories/auth/user-repository.js', () => ({
UserRepository: { getActiveIds: vi.fn() },
}))
vi.mock('../../services/notifications/notification-generator-service.js', () => ({
NotificationGeneratorService: {
generateManualNotification: vi.fn(),
generateGeneralNotification: vi.fn(),
},
}))

const { NotificationController } =
await import('../../controllers/notifications/notification-controller.js')
const { UserRepository } = await import('../../repositories/auth/user-repository.js')
const { NotificationGeneratorService } =
await import('../../services/notifications/notification-generator-service.js')
const { ERROR_CODES } = await import('../../config/error-codes.js')

const ACTIVE = '11111111-1111-4111-8111-111111111111'
const INACTIVE = '22222222-2222-4222-8222-222222222222'

function call(
handler: (req: Request, res: Response) => Promise<Response>,
body: object,
params = {}
) {
const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() }
const req = { user: { id: 'u1' }, params, body } as unknown as Request
return handler(req, res as unknown as Response).then(() => res)
}

const endpoints = [
['general', NotificationController.createGeneralNotification, {}],
['group', NotificationController.createManualNotification, { id: '5' }],
] as const

describe('manual notification recipients', () => {
beforeEach(() => vi.clearAllMocks())

it.each(endpoints)(
'%s: refuses a recipient that is inactive or unknown',
async (_, handler, params) => {
vi.mocked(UserRepository.getActiveIds).mockResolvedValue([ACTIVE])

const res = await call(
handler,
{ title: 'T', message: 'M', userIds: [ACTIVE, INACTIVE] },
params
)

expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: ERROR_CODES.NOTIFICATIONS_INVALID_RECIPIENTS })
)
expect(NotificationGeneratorService.generateGeneralNotification).not.toHaveBeenCalled()
expect(NotificationGeneratorService.generateManualNotification).not.toHaveBeenCalled()
}
)

it.each(endpoints)('%s: sends when every recipient is active', async (_, handler, params) => {
vi.mocked(UserRepository.getActiveIds).mockResolvedValue([ACTIVE])

const res = await call(handler, { title: 'T', message: 'M', userIds: [ACTIVE] }, params)

expect(res.status).toHaveBeenCalledWith(201)
})

it.each(endpoints)(
'%s: without userIds it does not look users up',
async (_, handler, params) => {
const res = await call(handler, { title: 'T', message: 'M' }, params)

expect(res.status).toHaveBeenCalledWith(201)
expect(UserRepository.getActiveIds).not.toHaveBeenCalled()
}
)
})
1 change: 1 addition & 0 deletions frontend/app/lib/helpers/errorCodeMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ const TRANSLATED = new Set([
'NOTIFICATIONS_INVALID_GROUP_ID',
'NOTIFICATIONS_INVALID_ID',
'NOTIFICATIONS_INVALID_MODULE',
'NOTIFICATIONS_INVALID_RECIPIENTS',
'NOTIFICATIONS_INVALID_SCHEDULED_DATE',
'NOTIFICATIONS_MARK_ALL_ERROR',
'NOTIFICATIONS_MARK_ERROR',
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/en/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@
"NOTIFICATIONS_MISSING_REQUIRED_FIELDS": "Missing required fields: title, message",
"NOTIFICATIONS_INVALID_SCHEDULED_DATE": "Invalid date format for scheduled_for",
"NOTIFICATIONS_INVALID_MODULE": "Invalid module. Allowed values: groups, parking, logbooks, system",
"NOTIFICATIONS_INVALID_RECIPIENTS": "Some recipient does not exist or is not active",
"NOTIFICATIONS_NOT_FOUND": "Notification not found",
"NOTIFICATIONS_FETCH_ERROR": "Error fetching notifications",
"NOTIFICATIONS_FETCH_UNREAD_ERROR": "Error fetching unread notifications",
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/es/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@
"NOTIFICATIONS_MISSING_REQUIRED_FIELDS": "Faltan campos obligatorios: título, mensaje",
"NOTIFICATIONS_INVALID_SCHEDULED_DATE": "Formato de fecha inválido para scheduled_for",
"NOTIFICATIONS_INVALID_MODULE": "Módulo inválido. Valores permitidos: groups, parking, logbooks, system",
"NOTIFICATIONS_INVALID_RECIPIENTS": "Algún destinatario no existe o no está activo",
"NOTIFICATIONS_NOT_FOUND": "Notificación no encontrada",
"NOTIFICATIONS_FETCH_ERROR": "Error al obtener notificaciones",
"NOTIFICATIONS_FETCH_UNREAD_ERROR": "Error al obtener notificaciones no leídas",
Expand Down
Loading