You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			180 lines
		
	
	
		
			6.3 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			180 lines
		
	
	
		
			6.3 KiB
		
	
	
	
		
			TypeScript
		
	
| 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<string> {
 | |
|   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<typeof serverPromise> | 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);
 | |
|   });
 | |
| });
 |