From a8a0a3b5f6fc2f165421bf40b1d688dbd8b933ca Mon Sep 17 00:00:00 2001 From: borja Date: Tue, 14 Oct 2025 14:55:26 +0200 Subject: [PATCH] =?UTF-8?q?test:=20a=C3=B1adir=20tests=20E2E=20ICS=20para?= =?UTF-8?q?=20grupo,=20personal=20y=20agregado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/web/ics.aggregate.test.ts | 179 ++++++++++++++++++++++++++++++++ tests/web/ics.group.test.ts | 166 +++++++++++++++++++++++++++++ tests/web/ics.personal.test.ts | 178 +++++++++++++++++++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 tests/web/ics.aggregate.test.ts create mode 100644 tests/web/ics.group.test.ts create mode 100644 tests/web/ics.personal.test.ts diff --git a/tests/web/ics.aggregate.test.ts b/tests/web/ics.aggregate.test.ts new file mode 100644 index 0000000..c5a57c4 --- /dev/null +++ b/tests/web/ics.aggregate.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, afterAll } from 'bun:test'; +import Database from 'bun:sqlite'; +import { startWebServer } from './helpers/server'; +import { createTempDb } from './helpers/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +function ymdUTC(date = new Date()): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, '0'); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function addDays(date: Date, days: number): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCDate(d.getUTCDate() + days); + return d; +} + +function pad4(n: number): string { + const s = String(Math.floor(n)); + return s.length >= 4 ? s : '0'.repeat(4 - s.length) + s; +} + +describe('ICS - aggregate feed', () => { + const PORT = 19133; + const BASE = `http://127.0.0.1:${PORT}`; + const USER = '34600123456'; + const G1 = 'g1@g.us'; + const G2 = 'g2@g.us'; + const G3 = 'g3@g.us'; // no permitido + const SID = 'sid-ics-aggregate-1'; + + const tmp = createTempDb(); + const db: any = tmp.db as Database; + + // Sembrar datos mínimos + db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`); + for (const [gid, name] of [ + [G1, 'Group A'], + [G2, 'Group B'], + [G3, 'Group C'], + ] as const) { + db.exec( + `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${gid}', 'comm', '${name}', 1)` + ); + } + db.exec( + `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${G1}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')` + ); + db.exec( + `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${G2}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')` + ); + db.exec( + `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${G3}', 'blocked', '${toIsoSql()}', '${toIsoSql()}')` + ); + for (const gid of [G1, G2]) { + db.exec( + `INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('${gid}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')` + ); + } + + const today = new Date(); + const dueIn3 = ymdUTC(addDays(today, 3)); + const dueIn6 = ymdUTC(addDays(today, 6)); + const dueFar = ymdUTC(addDays(today, 400)); + + const insTask = db.prepare( + `INSERT INTO tasks (description, due_date, group_id, created_by, completed, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ); + const createdBy = USER; + + // G1 unassigned -> incluido + const r1 = insTask.run('G1 unassigned', dueIn3, G1, createdBy, 0, toIsoSql()); + const t1 = Number(r1.lastInsertRowid); + + // G1 assigned -> excluido (aggregate sólo "sin responsable") + const r2 = insTask.run('G1 assigned', dueIn3, G1, createdBy, 0, toIsoSql()); + const t2 = Number(r2.lastInsertRowid); + db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at) + VALUES (?, ?, ?, ?)`).run(t2, USER, USER, toIsoSql()); + + // G2 unassigned -> incluido + const r3 = insTask.run('G2 unassigned', dueIn6, G2, createdBy, 0, toIsoSql()); + const t3 = Number(r3.lastInsertRowid); + + // G3 unassigned (no permitido) -> excluido + const r4 = insTask.run('G3 unassigned', dueIn6, G3, createdBy, 0, toIsoSql()); + const t4 = Number(r4.lastInsertRowid); + + // G1 far future -> excluido por horizonte + const r5 = insTask.run('G1 far', dueFar, G1, createdBy, 0, toIsoSql()); + const t5 = Number(r5.lastInsertRowid); + + const sidHashPromise = sha256Hex(SID); + const serverPromise = startWebServer({ + port: PORT, + env: { + DB_PATH: tmp.path, + WEB_BASE_URL: BASE, + }, + }); + + let server: Awaited | null = null; + + afterAll(async () => { + try { + await server?.stop(); + } catch {} + try { + tmp.cleanup(); + } catch {} + }); + + it('serves ICS for aggregate token with correct filtering, supports ETag, and returns 410 when revoked', async () => { + server = await serverPromise; + + const sidHash = await sidHashPromise; + db.exec(` + INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at) + VALUES ('sess-ics-aggregate', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql( + addDays(new Date(), 1) + )}') + `); + + // Obtener URLs de feeds + const resFeeds = await fetch(`${BASE}/api/integrations/feeds`, { + headers: { cookie: `sid=${SID}` }, + }); + expect(resFeeds.status).toBe(200); + const feeds = await resFeeds.json(); + expect(feeds.aggregate && typeof feeds.aggregate.url === 'string').toBe(true); + + const aggregateUrl: string = feeds.aggregate.url; + const token = new URL(aggregateUrl).pathname.split('/').pop()!.replace(/\.ics$/i, ''); + + // Primera petición ICS + const resIcs = await fetch(aggregateUrl); + expect(resIcs.status).toBe(200); + expect((resIcs.headers.get('content-type') || '').includes('text/calendar')).toBe(true); + const body1 = await resIcs.text(); + + // Incluidos: t1, t3 + expect(body1.includes(`[T${pad4(t1)}]`)).toBe(true); + expect(body1.includes(`[T${pad4(t3)}]`)).toBe(true); + + // Excluidos: t2 (assigned), t4 (no permitido), t5 (far) + expect(body1.includes(`[T${pad4(t2)}]`)).toBe(false); + expect(body1.includes(`[T${pad4(t4)}]`)).toBe(false); + expect(body1.includes(`[T${pad4(t5)}]`)).toBe(false); + + const etag = resIcs.headers.get('etag') || ''; + const res304 = await fetch(aggregateUrl, { headers: { 'if-none-match': etag } }); + expect(res304.status).toBe(304); + + const row = db + .prepare(`SELECT last_used_at FROM calendar_tokens WHERE token_plain = ?`) + .get(token) as any; + expect(row && row.last_used_at).toBeTruthy(); + + db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE token_plain = ?`).run(toIsoSql(), token); + const resGone = await fetch(aggregateUrl); + expect(resGone.status).toBe(410); + }); +}); diff --git a/tests/web/ics.group.test.ts b/tests/web/ics.group.test.ts new file mode 100644 index 0000000..7b33975 --- /dev/null +++ b/tests/web/ics.group.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, afterAll } from 'bun:test'; +import Database from 'bun:sqlite'; +import { startWebServer } from './helpers/server'; +import { createTempDb } from './helpers/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +function ymdUTC(date = new Date()): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, '0'); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function addDays(date: Date, days: number): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCDate(d.getUTCDate() + days); + return d; +} + +function pad4(n: number): string { + const s = String(Math.floor(n)); + return s.length >= 4 ? s : '0'.repeat(4 - s.length) + s; +} + +describe('ICS - group feed', () => { + const PORT = 19131; + const BASE = `http://127.0.0.1:${PORT}`; + const USER = '34600123456'; + const GROUP = '123@g.us'; + const SID = 'sid-ics-group-1'; + + const tmp = createTempDb(); + const db: any = tmp.db as Database; + + // Sembrar datos mínimos + db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`); + db.exec( + `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP}', 'comm1', 'Group One', 1)` + ); + db.exec( + `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')` + ); + db.exec( + `INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('${GROUP}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')` + ); + + // Tareas del grupo (varias condiciones) + const today = new Date(); + const dueIn2 = ymdUTC(addDays(today, 2)); + const dueIn400 = ymdUTC(addDays(today, 400)); + + const insTask = db.prepare( + `INSERT INTO tasks (description, due_date, group_id, created_by, completed, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ); + const createdBy = USER; + + // t1: sin responsable, dentro de horizonte -> debe aparecer + const r1 = insTask.run('Task unassigned in range', dueIn2, GROUP, createdBy, 0, toIsoSql()); + const t1 = Number(r1.lastInsertRowid); + + // t2: con responsable -> NO debe aparecer + const r2 = insTask.run('Task assigned', dueIn2, GROUP, createdBy, 0, toIsoSql()); + const t2 = Number(r2.lastInsertRowid); + db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at) + VALUES (?, ?, ?, ?)`).run(t2, USER, USER, toIsoSql()); + + // t3: completada -> NO debe aparecer + const r3 = insTask.run('Task completed', dueIn2, GROUP, createdBy, 1, toIsoSql()); + const t3 = Number(r3.lastInsertRowid); + + // t4: muy lejos (fuera de horizonte) -> NO debe aparecer + const r4 = insTask.run('Task far future', dueIn400, GROUP, createdBy, 0, toIsoSql()); + const t4 = Number(r4.lastInsertRowid); + + // t5: sin due_date -> NO debe aparecer + const r5 = insTask.run('Task without due', null, GROUP, createdBy, 0, toIsoSql()); + const t5 = Number(r5.lastInsertRowid); + + const sidHashPromise = sha256Hex(SID); + const serverPromise = startWebServer({ + port: PORT, + env: { + DB_PATH: tmp.path, + WEB_BASE_URL: BASE, + }, + }); + + let server: Awaited | null = null; + + afterAll(async () => { + try { + await server?.stop(); + } catch {} + try { + tmp.cleanup(); + } catch {} + }); + + it('serves ICS for group token with correct filtering, supports ETag, and returns 410 when revoked', async () => { + server = await serverPromise; + + const sidHash = await sidHashPromise; + db.exec(` + INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at) + VALUES ('sess-ics-group', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql( + addDays(new Date(), 1) + )}') + `); + + // Obtener URLs de feeds (autogenera tokens) + const resFeeds = await fetch(`${BASE}/api/integrations/feeds`, { + headers: { cookie: `sid=${SID}` }, + }); + expect(resFeeds.status).toBe(200); + const feeds = await resFeeds.json(); + const groupItem = (feeds.groups || []).find((g: any) => g.groupId === GROUP); + expect(groupItem && typeof groupItem.url === 'string' && groupItem.url.endsWith('.ics')).toBe(true); + + const groupUrl = groupItem.url as string; + const token = new URL(groupUrl).pathname.split('/').pop()!.replace(/\.ics$/i, ''); + + // Primera petición ICS + const resIcs = await fetch(groupUrl); + expect(resIcs.status).toBe(200); + expect((resIcs.headers.get('content-type') || '').includes('text/calendar')).toBe(true); + const body1 = await resIcs.text(); + + // Debe contener solo t1 + expect(body1.includes(`[T${pad4(t1)}]`)).toBe(true); + expect(body1.includes(`[T${pad4(t2)}]`)).toBe(false); + expect(body1.includes(`[T${pad4(t3)}]`)).toBe(false); + expect(body1.includes(`[T${pad4(t4)}]`)).toBe(false); + expect(body1.includes(`[T${pad4(t5)}]`)).toBe(false); + + const etag = resIcs.headers.get('etag') || ''; + + // Segunda petición con If-None-Match -> 304 + const res304 = await fetch(groupUrl, { headers: { 'if-none-match': etag } }); + expect(res304.status).toBe(304); + + // last_used_at actualizado + const row = db + .prepare(`SELECT last_used_at FROM calendar_tokens WHERE token_plain = ?`) + .get(token) as any; + expect(row && row.last_used_at).toBeTruthy(); + + // Revocar el token y comprobar 410 + db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE token_plain = ?`).run(toIsoSql(), token); + const resGone = await fetch(groupUrl); + expect(resGone.status).toBe(410); + }); +}); diff --git a/tests/web/ics.personal.test.ts b/tests/web/ics.personal.test.ts new file mode 100644 index 0000000..10cb466 --- /dev/null +++ b/tests/web/ics.personal.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, afterAll } from 'bun:test'; +import Database from 'bun:sqlite'; +import { startWebServer } from './helpers/server'; +import { createTempDb } from './helpers/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +function ymdUTC(date = new Date()): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, '0'); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function addDays(date: Date, days: number): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCDate(d.getUTCDate() + days); + return d; +} + +function pad4(n: number): string { + const s = String(Math.floor(n)); + return s.length >= 4 ? s : '0'.repeat(4 - s.length) + s; +} + +describe('ICS - personal feed', () => { + const PORT = 19132; + const BASE = `http://127.0.0.1:${PORT}`; + const USER = '34600123456'; + const GROUP_ALLOWED = '111@g.us'; + const GROUP_BLOCKED = '222@g.us'; + const SID = 'sid-ics-personal-1'; + + const tmp = createTempDb(); + const db: any = tmp.db as Database; + + // Sembrar datos mínimos + db.exec(`INSERT OR IGNORE INTO users (id) VALUES ('${USER}')`); + db.exec( + `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP_ALLOWED}', 'comm1', 'Allowed', 1)` + ); + db.exec( + `INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('${GROUP_BLOCKED}', 'comm2', 'Blocked', 1)` + ); + db.exec( + `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP_ALLOWED}', 'allowed', '${toIsoSql()}', '${toIsoSql()}')` + ); + db.exec( + `INSERT OR IGNORE INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('${GROUP_BLOCKED}', 'blocked', '${toIsoSql()}', '${toIsoSql()}')` + ); + db.exec( + `INSERT OR IGNORE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('${GROUP_ALLOWED}', '${USER}', 0, 1, '${toIsoSql()}', '${toIsoSql()}')` + ); + + const today = new Date(); + const dueIn2 = ymdUTC(addDays(today, 2)); + const dueIn5 = ymdUTC(addDays(today, 5)); + const duePast = ymdUTC(addDays(today, -2)); + + const insTask = db.prepare( + `INSERT INTO tasks (description, due_date, group_id, created_by, completed, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ); + const createdBy = USER; + + // Privada asignada (incluida) + const r1 = insTask.run('Private assigned', dueIn2, null, createdBy, 0, toIsoSql()); + const t1 = Number(r1.lastInsertRowid); + db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at) + VALUES (?, ?, ?, ?)`).run(t1, USER, USER, toIsoSql()); + + // Grupo allowed asignada (incluida) + const r2 = insTask.run('Allowed group assigned', dueIn5, GROUP_ALLOWED, createdBy, 0, toIsoSql()); + const t2 = Number(r2.lastInsertRowid); + db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at) + VALUES (?, ?, ?, ?)`).run(t2, USER, USER, toIsoSql()); + + // Grupo blocked asignada (excluida) + const r3 = insTask.run('Blocked group assigned', dueIn5, GROUP_BLOCKED, createdBy, 0, toIsoSql()); + const t3 = Number(r3.lastInsertRowid); + db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at) + VALUES (?, ?, ?, ?)`).run(t3, USER, USER, toIsoSql()); + + // Grupo allowed sin due_date (excluida) + const r4 = insTask.run('No due date', null, GROUP_ALLOWED, createdBy, 0, toIsoSql()); + const t4 = Number(r4.lastInsertRowid); + db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at) + VALUES (?, ?, ?, ?)`).run(t4, USER, USER, toIsoSql()); + + // Grupo allowed completada (excluida) + const r5 = insTask.run('Completed assigned', dueIn2, GROUP_ALLOWED, createdBy, 1, toIsoSql()); + const t5 = Number(r5.lastInsertRowid); + db.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by, assigned_at) + VALUES (?, ?, ?, ?)`).run(t5, USER, USER, toIsoSql()); + + const sidHashPromise = sha256Hex(SID); + const serverPromise = startWebServer({ + port: PORT, + env: { + DB_PATH: tmp.path, + WEB_BASE_URL: BASE, + }, + }); + + let server: Awaited | null = null; + + afterAll(async () => { + try { + await server?.stop(); + } catch {} + try { + tmp.cleanup(); + } catch {} + }); + + it('serves ICS for personal token with correct filtering, supports ETag, and returns 410 when revoked', async () => { + server = await serverPromise; + + const sidHash = await sidHashPromise; + db.exec(` + INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at) + VALUES ('sess-ics-personal', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql( + addDays(new Date(), 1) + )}') + `); + + // Obtener URLs de feeds + const resFeeds = await fetch(`${BASE}/api/integrations/feeds`, { + headers: { cookie: `sid=${SID}` }, + }); + expect(resFeeds.status).toBe(200); + const feeds = await resFeeds.json(); + expect(feeds.personal && typeof feeds.personal.url === 'string').toBe(true); + + const personalUrl: string = feeds.personal.url; + const token = new URL(personalUrl).pathname.split('/').pop()!.replace(/\.ics$/i, ''); + + // Primera petición ICS + const resIcs = await fetch(personalUrl); + expect(resIcs.status).toBe(200); + expect((resIcs.headers.get('content-type') || '').includes('text/calendar')).toBe(true); + const body1 = await resIcs.text(); + + // Debe contener solo t1 y t2 + expect(body1.includes(`[T${pad4(t1)}]`)).toBe(true); + expect(body1.includes(`[T${pad4(t2)}]`)).toBe(true); + + // Excluidos + expect(body1.includes(`[T${pad4(t3)}]`)).toBe(false); + expect(body1.includes(`[T${pad4(t4)}]`)).toBe(false); + expect(body1.includes(`[T${pad4(t5)}]`)).toBe(false); + + const etag = resIcs.headers.get('etag') || ''; + const res304 = await fetch(personalUrl, { headers: { 'if-none-match': etag } }); + expect(res304.status).toBe(304); + + const row = db + .prepare(`SELECT last_used_at FROM calendar_tokens WHERE token_plain = ?`) + .get(token) as any; + expect(row && row.last_used_at).toBeTruthy(); + + db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE token_plain = ?`).run(toIsoSql(), token); + const resGone = await fetch(personalUrl); + expect(resGone.status).toBe(410); + }); +});