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