feat: add access control for task completion

Users can only complete tasks if they are:
- Assigned to the task, OR
- An active member of the task's group, OR
- An admin (ADMIN_USERS env var)

Changes:
- TaskService.completeTask() now checks assignment, group membership,
  and admin status before allowing completion
- Returns 'forbidden' status when unauthorized
- completar command handler shows appropriate error message
- Web path (loadTaskAndGating) gets admin override for consistency
- AdminService.isAdmin() made public for reuse
- Updated tests to respect new access control + 12 new test cases
main
borja 1 month ago
parent 1c7a7ffdbe
commit 422747c177

@ -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<string> {
const raw = String(process.env.ADMIN_USERS || '');
const set = new Set<string>();
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);

@ -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);

@ -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<Msg[]> {
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[];
}

@ -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

@ -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');

@ -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',

@ -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');

@ -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(`

@ -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);

@ -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');
});
});

Loading…
Cancel
Save