diff --git a/apps/web/src/lib/server/task-helpers.ts b/apps/web/src/lib/server/task-helpers.ts index 83d8655..924fbc2 100644 --- a/apps/web/src/lib/server/task-helpers.ts +++ b/apps/web/src/lib/server/task-helpers.ts @@ -1,5 +1,32 @@ import { getDb } from '$lib/server/db'; +// --------------------------------------------------------------------------- +// Admin check (reads ADMIN_USERS env var, same as AdminService in main src) +// --------------------------------------------------------------------------- + +function normalizeId(raw: string | null | undefined): string { + if (!raw) return ''; + return String(raw).replace(/[@:+\-\s]/g, '').trim(); +} + +function loadAdmins(): Set { + const raw = String(process.env.ADMIN_USERS || ''); + const set = new Set(); + for (const token of raw.split(',').map(s => s.trim()).filter(Boolean)) { + const n = normalizeId(token); + if (n) set.add(n); + } + return set; +} + +function isAdmin(userId: string | null | undefined): boolean { + const n = normalizeId(userId); + if (!n) return false; + return loadAdmins().has(n); +} + +// --------------------------------------------------------------------------- + /** * Validate session and parse JSON body for POST endpoints. * Returns { userId, payload } on success, or a Response on failure. @@ -180,6 +207,12 @@ export async function loadTaskAndGating(event: { if (ctx instanceof Response) return ctx; const { db, task, userId } = ctx; + // Admin override: admins can complete any task + if (isAdmin(userId)) { + const groupId: string | null = task.group_id ? String(task.group_id) : null; + return { db, task, userId, groupId }; + } + // Gating: grupo allowed + miembro activo; si no tiene grupo, debe estar asignado const groupId: string | null = task.group_id ? String(task.group_id) : null; const gating = checkGroupAccess(db, groupId, userId); diff --git a/src/services/admin.ts b/src/services/admin.ts index cfc3271..f9f209e 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -287,7 +287,7 @@ export class AdminService { return Array.from(this.admins()); } - private static isAdmin(userId: string | null | undefined): boolean { + static isAdmin(userId: string | null | undefined): boolean { const n = normalizeWhatsAppId(userId || ''); if (!n) return false; return this.admins().has(n); diff --git a/src/services/commands/handlers/completar.ts b/src/services/commands/handlers/completar.ts index 6534e25..27ea52b 100644 --- a/src/services/commands/handlers/completar.ts +++ b/src/services/commands/handlers/completar.ts @@ -51,6 +51,11 @@ function completeOne(idInput: number, sender: string): BatchOutcome { status: 'updated', line: `${ICONS.complete} ${codeId(resolvedId, dc)} completada — ${desc}${due}`, }; + case 'forbidden': + return { + status: 'forbidden', + line: `🚫 ${codeId(resolvedId, dc)} — no tienes permiso (no estás asignado ni eres miembro del grupo).`, + }; default: return { status: 'notFound', @@ -82,6 +87,12 @@ function handleSingleComplete(idInput: number, sender: string): Msg[] { message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}`, }]; } + if (res.status === 'forbidden') { + return [{ + recipient: sender, + message: `🚫 ${codeId(resolvedId, res.task?.display_code)} — no tienes permiso para completarla (no estás asignado ni eres miembro del grupo).`, + }]; + } return [{ recipient: sender, @@ -113,7 +124,7 @@ export async function handleCompletar(context: Ctx): Promise { ids, truncated, completeOne, - { updated: 'completadas', already: 'ya estaban', notFound: 'no encontradas', blocked: 'bloqueadas' }, + { updated: 'completadas', already: 'ya estaban', notFound: 'no encontradas', blocked: 'bloqueadas', forbidden: 'sin permiso' }, '', ) as Msg[]; } diff --git a/src/tasks/service.ts b/src/tasks/service.ts index 9ef32e1..ffb2487 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -2,6 +2,7 @@ import type { Database } from 'bun:sqlite'; import { ensureUserExists } from '../db'; import { getDb as getGlobalDb } from '../db/locator'; import { AllowedGroups } from '../services/allowed-groups'; +import { AdminService } from '../services/admin'; import { isGroupId } from '../utils/whatsapp'; import { pickNextDisplayCode } from './display-code'; import { enqueueCompletionReactionIfEligible } from './complete-reaction'; @@ -148,9 +149,11 @@ export class TaskService { return Number(row?.cnt || 0); } - // Completar tarea: registra quién completó e idempotente + // Completar tarea: registra quién completó e idempotente. + // Control de acceso: admins pueden completar cualquier tarea; + // los demás necesitan estar asignados o ser miembros activos del grupo. static completeTask(taskId: number, completedBy: string): { - status: 'updated' | 'already' | 'not_found'; + status: 'updated' | 'already' | 'not_found' | 'forbidden'; task?: { id: number; description: string; due_date: string | null; display_code: number | null }; } { const ensured = ensureUserExists(completedBy, this.getDb()); @@ -179,6 +182,43 @@ export class TaskService { }; } + // --- Control de acceso --- + const isAdmin = AdminService.isAdmin(completedBy); + let allowed = isAdmin; + + if (!allowed) { + // 1. ¿Está asignado? + const assigned = this.getDb() + .prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`) + .get(taskId, ensured); + if (assigned) { + allowed = true; + } + } + + if (!allowed && existing.group_id) { + // 2. ¿Es miembro activo del grupo? + const member = this.getDb() + .prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`) + .get(existing.group_id, ensured); + if (member) { + allowed = true; + } + } + + if (!allowed) { + return { + status: 'forbidden', + task: { + id: Number(existing.id), + description: String(existing.description || ''), + due_date: existing.due_date ? String(existing.due_date) : null, + display_code: existing.display_code != null ? Number(existing.display_code) : null, + }, + }; + } + // --- Fin control de acceso --- + this.getDb() .prepare(` UPDATE tasks diff --git a/tests/unit/services/command.claim-unassign.test.ts b/tests/unit/services/command.claim-unassign.test.ts index 99e26d3..730ba2b 100644 --- a/tests/unit/services/command.claim-unassign.test.ts +++ b/tests/unit/services/command.claim-unassign.test.ts @@ -78,7 +78,7 @@ describe('CommandService - t tomar y t soltar', () => { }); it('tomar: completed', async () => { - const taskId = createTask('Tarea completa', '111', '2025-10-10'); + const taskId = createTask('Tarea completa', '111', '2025-10-10', ['111']); const comp = TaskService.completeTask(taskId, '111'); expect(comp.status).toBe('updated'); diff --git a/tests/unit/services/command.test.ts b/tests/unit/services/command.test.ts index 853e74e..2e5e03f 100644 --- a/tests/unit/services/command.test.ts +++ b/tests/unit/services/command.test.ts @@ -103,6 +103,12 @@ test('completar tarea: camino feliz, ya completada y no encontrada', async () => GroupSyncService.activeGroupsCache.clear(); GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group'); + // Hacer a 1234567890 miembro activo del grupo para permisos de completado + memDb.exec(` + INSERT OR IGNORE INTO users (id) VALUES ('1234567890'); + INSERT OR REPLACE INTO group_members (group_id, user_id, is_active) VALUES ('test-group@g.us', '1234567890', 1); + `); + const taskId = TaskService.createTask({ description: 'Completar yo', due_date: '2025-10-10', diff --git a/tests/unit/tasks/claim-unassign.test.ts b/tests/unit/tasks/claim-unassign.test.ts index 8c1eb94..088ae6a 100644 --- a/tests/unit/tasks/claim-unassign.test.ts +++ b/tests/unit/tasks/claim-unassign.test.ts @@ -52,8 +52,8 @@ describe('TaskService - claim/unassign', () => { }); it('claim: completed', () => { - const taskId = createTask('Tarea ya completada', '111', '2025-10-10'); - // marcar como completada + const taskId = createTask('Tarea ya completada', '111', '2025-10-10', ['111']); + // marcar como completada (user 111 is assigned, so allowed) const comp = TaskService.completeTask(taskId, '111'); expect(comp.status).toBe('updated'); @@ -86,7 +86,8 @@ describe('TaskService - claim/unassign', () => { }); it('unassign: completed', () => { - const taskId = createTask('Unassign bloqueada por completada', '111', null, ['222']); + const taskId = createTask('Unassign bloqueada por completada', '111', null, ['111', '222']); + // user 111 is assigned, so can complete const comp = TaskService.completeTask(taskId, '111'); expect(comp.status).toBe('updated'); diff --git a/tests/unit/tasks/complete-reaction.test.ts b/tests/unit/tasks/complete-reaction.test.ts index 3ecfa5f..18ecad8 100644 --- a/tests/unit/tasks/complete-reaction.test.ts +++ b/tests/unit/tasks/complete-reaction.test.ts @@ -49,7 +49,7 @@ describe('TaskService - reacción ✅ al completar (Fase 2)', () => { due_date: null, group_id: groupId, created_by: '600111222' - }); + }, [{ user_id: '600111222', assigned_by: '600111222' }]); // Origen reciente (dentro de TTL) const msgId = 'MSG-OK-1'; @@ -84,7 +84,7 @@ describe('TaskService - reacción ✅ al completar (Fase 2)', () => { due_date: null, group_id: groupId, created_by: '600111222' - }); + }, [{ user_id: '600111222', assigned_by: '600111222' }]); const msgId = 'MSG-OLD-1'; const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); // 8 días atrás @@ -109,7 +109,7 @@ describe('TaskService - reacción ✅ al completar (Fase 2)', () => { due_date: null, group_id: groupId, created_by: '600111222' - }); + }, [{ user_id: '600111222', assigned_by: '600111222' }]); const msgId = 'MSG-IDEMP-1'; memdb.prepare(` @@ -137,7 +137,7 @@ describe('TaskService - reacción ✅ al completar (Fase 2)', () => { due_date: null, group_id: groupId, created_by: '600111222' - }); + }, [{ user_id: '600111222', assigned_by: '600111222' }]); const msgId = 'MSG-NO-ALLOW-1'; memdb.prepare(` diff --git a/tests/unit/tasks/service.list-active.test.ts b/tests/unit/tasks/service.list-active.test.ts index 3bd85b3..12cb7c7 100644 --- a/tests/unit/tasks/service.list-active.test.ts +++ b/tests/unit/tasks/service.list-active.test.ts @@ -44,6 +44,7 @@ describe('TaskService - listAllActive', () => { const t4 = createTask('Tarea D', '2025-10-01', 'g2@g.us', c); // Completar una de ellas para que no aparezca + memdb.prepare(`INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(t4, c, c); TaskService.completeTask(t4, c); const rows = TaskService.listAllActive(10); diff --git a/tests/unit/tasks/service.test.ts b/tests/unit/tasks/service.test.ts index ae0f60c..f2f6182 100644 --- a/tests/unit/tasks/service.test.ts +++ b/tests/unit/tasks/service.test.ts @@ -285,3 +285,151 @@ describe('TaskService.createTask', () => { expect(count.c).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// Access control for completeTask +// --------------------------------------------------------------------------- + +describe('TaskService.completeTask - access control', () => { + let memDb: Database; + const ADMIN = '1000000001'; + const USER_ASSIGNED = '2000000001'; + const USER_NOT_ASSIGNED = '2000000002'; + const USER_GROUP_MEMBER = '2000000003'; + const USER_GROUP_OUTSIDER = '2000000004'; + const GROUP_ID = 'testgroup@g.us'; + + beforeAll(() => { + memDb = new Database(':memory:'); + initializeDatabase(memDb); + setDb(memDb); + }); + + afterAll(() => { + try { resetDb(); memDb.close(); } catch {} + }); + + beforeEach(() => { + process.env.NODE_ENV = 'test'; + setDb(memDb); + try { memDb.exec('DELETE FROM task_assignments'); } catch {} + try { memDb.exec('DELETE FROM tasks'); } catch {} + try { memDb.exec('DELETE FROM group_members'); } catch {} + try { memDb.exec('DELETE FROM users'); } catch {} + try { memDb.exec('DELETE FROM groups'); } catch {} + delete process.env.ADMIN_USERS; + }); + + function setupGroup(): void { + memDb.prepare(` + INSERT OR IGNORE INTO groups (id, community_id, name, active) + VALUES (?, 'comm-1', 'Test Group', 1) + `).run(GROUP_ID); + } + + function createTask(opts: { description?: string; groupId?: string | null; createdBy: string; assignees?: string[] }): number { + const creator = ensureUserExists(opts.createdBy, memDb)!; + const taskId = TaskService.createTask( + { + description: opts.description || 'Test task', + due_date: null, + group_id: opts.groupId ?? null, + created_by: creator, + }, + (opts.assignees || []).map(uid => ({ user_id: ensureUserExists(uid, memDb)!, assigned_by: creator })) + ); + return taskId; + } + + // --- Personal tasks (no group) --- + + it('permite completar tarea personal si el usuario está asignado', () => { + const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, USER_ASSIGNED); + expect(res.status).toBe('updated'); + }); + + it('rechaza tarea personal si el usuario no está asignado', () => { + const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, USER_NOT_ASSIGNED); + expect(res.status).toBe('forbidden'); + }); + + it('rechaza tarea personal sin asignatarios para no-asignados', () => { + const taskId = createTask({ createdBy: USER_ASSIGNED }); + const res = TaskService.completeTask(taskId, USER_NOT_ASSIGNED); + expect(res.status).toBe('forbidden'); + }); + + // --- Group tasks --- + + it('permite completar tarea de grupo si el usuario está asignado', () => { + setupGroup(); + const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, USER_ASSIGNED); + expect(res.status).toBe('updated'); + }); + + it('permite completar tarea de grupo si el usuario es miembro activo (aunque no esté asignado)', () => { + setupGroup(); + ensureUserExists(USER_GROUP_MEMBER, memDb); + memDb.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_active) VALUES (?, ?, 1)`).run(GROUP_ID, USER_GROUP_MEMBER); + const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, USER_GROUP_MEMBER); + expect(res.status).toBe('updated'); + }); + + it('rechaza tarea de grupo si el usuario no está asignado ni es miembro', () => { + setupGroup(); + const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, USER_GROUP_OUTSIDER); + expect(res.status).toBe('forbidden'); + }); + + it('rechaza tarea de grupo si el usuario es miembro inactivo y no está asignado', () => { + setupGroup(); + ensureUserExists(USER_GROUP_MEMBER, memDb); + memDb.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_active) VALUES (?, ?, 0)`).run(GROUP_ID, USER_GROUP_MEMBER); + const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, USER_GROUP_MEMBER); + expect(res.status).toBe('forbidden'); + }); + + // --- Admin override --- + + it('permite a un admin completar tarea personal sin estar asignado', () => { + process.env.ADMIN_USERS = ADMIN; + const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, ADMIN); + expect(res.status).toBe('updated'); + }); + + it('permite a un admin completar tarea de grupo sin estar asignado ni ser miembro', () => { + process.env.ADMIN_USERS = ADMIN; + setupGroup(); + const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, ADMIN); + expect(res.status).toBe('updated'); + }); + + it('permite a un admin con ID raw (con sufijo @s.whatsapp.net) completar', () => { + process.env.ADMIN_USERS = ADMIN; + const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] }); + const res = TaskService.completeTask(taskId, ADMIN + '@s.whatsapp.net'); + expect(res.status).toBe('updated'); + }); + + // --- Already completed / not found (unchanged behavior) --- + + it('devuelve already si la tarea ya estaba completada', () => { + const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] }); + TaskService.completeTask(taskId, USER_ASSIGNED); + const res = TaskService.completeTask(taskId, USER_ASSIGNED); + expect(res.status).toBe('already'); + }); + + it('devuelve not_found si la tarea no existe', () => { + const res = TaskService.completeTask(999999, USER_ASSIGNED); + expect(res.status).toBe('not_found'); + }); +});