const request = require('supertest'); const app = require('../../app'); const { sequelize, User, Task, CalDAVCalendar } = require('../../models'); const bcrypt = require('bcrypt'); const xml2js = require('xml2js'); const { propfind, report } = require('../helpers/caldav-test-utils'); describe('CalDAV Protocol - Phase 3', () => { let testUser; let testCalendar; let authHeader; let testTask; beforeEach(async () => { const hashedPassword = await bcrypt.hash('password123', 10); testUser = await User.create({ email: 'caldav@test.com', password_digest: hashedPassword, verified: true, }); testCalendar = await CalDAVCalendar.create({ uid: 'test-calendar-uid', user_id: testUser.id, name: 'Test Calendar', description: 'Calendar for testing', enabled: true, }); authHeader = 'Basic ' + Buffer.from('caldav@test.com:password123').toString('base64'); }); afterAll(async () => { await sequelize.close(); }); describe('Discovery', () => { test('GET /.well-known/caldav should redirect to /caldav/', async () => { const response = await request(app) .get('/.well-known/caldav') .expect(301); expect(response.headers.location).toContain('/caldav/'); }); }); describe('Authentication', () => { // eslint-disable-next-line jest/expect-expect test('should reject requests without authentication', async () => { await request(app) .get('/caldav/caldav@test.com/tasks/') .expect(401); }); // eslint-disable-next-line jest/expect-expect test('should reject requests with invalid credentials', async () => { const badAuth = 'Basic ' + Buffer.from('caldav@test.com:wrongpass').toString('base64'); await request(app) .get('/caldav/caldav@test.com/tasks/') .set('Authorization', badAuth) .expect(401); }); // eslint-disable-next-line jest/expect-expect test('should accept requests with valid HTTP Basic Auth', async () => { await request(app) .get('/caldav/caldav@test.com/tasks/') .set('Authorization', authHeader) .expect(207); }); // eslint-disable-next-line jest/expect-expect test('should reject access to other users calendars', async () => { const otherUser = await User.create({ email: 'other@test.com', password_digest: await bcrypt.hash('password', 10), verified: true, }); await request(app) .get(`/caldav/${otherUser.email}/tasks/`) .set('Authorization', authHeader) .expect(403); }); }); describe('OPTIONS', () => { test('should return DAV capabilities', async () => { const response = await request(app) .options('/caldav/caldav@test.com/tasks/') .set('Authorization', authHeader) .expect(204); expect(response.headers.dav).toContain('calendar-access'); expect(response.headers.allow).toContain('PROPFIND'); expect(response.headers.allow).toContain('REPORT'); expect(response.headers.allow).toContain('GET'); expect(response.headers.allow).toContain('PUT'); expect(response.headers.allow).toContain('DELETE'); }); }); describe('PROPFIND', () => { beforeEach(async () => { testTask = await Task.create({ uid: 'test-task-1', user_id: testUser.id, name: 'Test Task', status: 0, priority: 1, }); }); afterEach(async () => { await Task.destroy({ where: { user_id: testUser.id } }); }); test('PROPFIND on calendar collection (depth 0) should return calendar properties', async () => { const propfindXml = ` `; const response = await propfind( app, '/caldav/caldav@test.com/tasks/' ) .set('Authorization', authHeader) .set('Depth', '0') .set('Content-Type', 'application/xml') .send(propfindXml) .expect(207); expect(response.text).toContain('Tududi Tasks'); expect(response.text).toContain('getctag'); expect(response.text).toContain('resourcetype'); }); test('PROPFIND on calendar collection (depth 1) should return tasks', async () => { const propfindXml = ` `; const response = await propfind( app, '/caldav/caldav@test.com/tasks/' ) .set('Authorization', authHeader) .set('Depth', '1') .set('Content-Type', 'application/xml') .send(propfindXml) .expect(207); expect(response.text).toContain('test-task-1.ics'); expect(response.text).toContain('Test Task'); }); test('PROPFIND on individual task should return task properties', async () => { const propfindXml = ` `; const response = await propfind( app, '/caldav/caldav@test.com/tasks/test-task-1.ics' ) .set('Authorization', authHeader) .set('Depth', '0') .set('Content-Type', 'application/xml') .send(propfindXml) .expect(207); expect(response.text).toContain('getetag'); expect(response.text).toContain('getcontenttype'); expect(response.text).toContain('getlastmodified'); }); }); describe('REPORT (calendar-query)', () => { beforeEach(async () => { await Task.create({ uid: 'report-task-1', user_id: testUser.id, name: 'Report Test Task', status: 0, priority: 1, due_date: new Date('2026-06-15T10:00:00Z'), }); }); afterEach(async () => { await Task.destroy({ where: { user_id: testUser.id } }); }); test('REPORT should filter tasks by time range', async () => { const reportXml = ` `; const response = await report(app, '/caldav/caldav@test.com/tasks/') .set('Authorization', authHeader) .set('Content-Type', 'application/xml') .send(reportXml) .expect(207); expect(response.text).toContain('report-task-1.ics'); expect(response.text).toContain('BEGIN:VCALENDAR'); expect(response.text).toContain('BEGIN:VTODO'); }); test('REPORT should return only requested properties', async () => { const reportXml = ` `; const response = await report(app, '/caldav/caldav@test.com/tasks/') .set('Authorization', authHeader) .set('Content-Type', 'application/xml') .send(reportXml) .expect(207); expect(response.text).toContain('getetag'); expect(response.text).not.toContain('BEGIN:VCALENDAR'); }); }); describe('GET Task', () => { beforeEach(async () => { testTask = await Task.create({ uid: 'get-task-1', user_id: testUser.id, name: 'Get Task Test', status: 0, priority: 1, }); }); afterEach(async () => { await Task.destroy({ where: { user_id: testUser.id } }); }); test('GET should return VTODO for existing task', async () => { const response = await request(app) .get('/caldav/caldav@test.com/tasks/get-task-1.ics') .set('Authorization', authHeader) .expect(200); expect(response.text).toContain('BEGIN:VCALENDAR'); expect(response.text).toContain('BEGIN:VTODO'); expect(response.text).toContain('UID:get-task-1'); expect(response.text).toContain('SUMMARY:Get Task Test'); expect(response.text).toContain('END:VTODO'); expect(response.text).toContain('END:VCALENDAR'); expect(response.headers.etag).toBeDefined(); }); // eslint-disable-next-line jest/expect-expect test('GET should return 404 for non-existent task', async () => { await request(app) .get('/caldav/caldav@test.com/tasks/non-existent.ics') .set('Authorization', authHeader) .expect(404); }); // eslint-disable-next-line jest/expect-expect test('GET should support If-None-Match (304 Not Modified)', async () => { const firstResponse = await request(app) .get('/caldav/caldav@test.com/tasks/get-task-1.ics') .set('Authorization', authHeader) .expect(200); const etag = firstResponse.headers.etag; await request(app) .get('/caldav/caldav@test.com/tasks/get-task-1.ics') .set('Authorization', authHeader) .set('If-None-Match', etag) .expect(304); }); }); describe('PUT Task', () => { afterEach(async () => { await Task.destroy({ where: { user_id: testUser.id } }); }); test('PUT should create new task from VTODO', async () => { const vtodo = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Tududi//CalDAV//EN BEGIN:VTODO UID:put-task-new SUMMARY:New Task via PUT STATUS:NEEDS-ACTION PRIORITY:5 END:VTODO END:VCALENDAR`; const response = await request(app) .put('/caldav/caldav@test.com/tasks/put-task-new.ics') .set('Authorization', authHeader) .set('Content-Type', 'text/calendar') .send(vtodo) .expect(201); expect(response.headers.etag).toBeDefined(); const created = await Task.findOne({ where: { uid: 'put-task-new' }, }); expect(created).toBeDefined(); expect(created.name).toBe('New Task via PUT'); expect(created.status).toBe(0); }); test('PUT should update existing task', async () => { const existing = await Task.create({ uid: 'put-task-update', user_id: testUser.id, name: 'Original Name', status: 0, }); const vtodo = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Tududi//CalDAV//EN BEGIN:VTODO UID:put-task-update SUMMARY:Updated Name STATUS:COMPLETED END:VTODO END:VCALENDAR`; await request(app) .put('/caldav/caldav@test.com/tasks/put-task-update.ics') .set('Authorization', authHeader) .set('Content-Type', 'text/calendar') .send(vtodo) .expect(204); await existing.reload(); expect(existing.name).toBe('Updated Name'); expect(existing.status).toBe(2); }); // eslint-disable-next-line jest/expect-expect test('PUT should respect If-Match precondition', async () => { const existing = await Task.create({ uid: 'put-task-match', user_id: testUser.id, name: 'Test Task', status: 0, }); const vtodo = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VTODO UID:put-task-match SUMMARY:Updated END:VTODO END:VCALENDAR`; await request(app) .put('/caldav/caldav@test.com/tasks/put-task-match.ics') .set('Authorization', authHeader) .set('If-Match', '"wrong-etag"') .set('Content-Type', 'text/calendar') .send(vtodo) .expect(412); }); // eslint-disable-next-line jest/expect-expect test('PUT should reject invalid VTODO', async () => { const invalidVtodo = 'This is not valid iCalendar data'; await request(app) .put('/caldav/caldav@test.com/tasks/invalid.ics') .set('Authorization', authHeader) .set('Content-Type', 'text/calendar') .send(invalidVtodo) .expect(400); }); }); describe('DELETE Task', () => { beforeEach(async () => { testTask = await Task.create({ uid: 'delete-task-1', user_id: testUser.id, name: 'Task to Delete', status: 0, }); }); afterEach(async () => { await Task.destroy({ where: { user_id: testUser.id } }); }); test('DELETE should remove existing task', async () => { await request(app) .delete('/caldav/caldav@test.com/tasks/delete-task-1.ics') .set('Authorization', authHeader) .expect(204); const deleted = await Task.findOne({ where: { uid: 'delete-task-1' }, }); expect(deleted).toBeNull(); }); // eslint-disable-next-line jest/expect-expect test('DELETE should return 404 for non-existent task', async () => { await request(app) .delete('/caldav/caldav@test.com/tasks/non-existent.ics') .set('Authorization', authHeader) .expect(404); }); test('DELETE should respect If-Match precondition', async () => { await request(app) .delete('/caldav/caldav@test.com/tasks/delete-task-1.ics') .set('Authorization', authHeader) .set('If-Match', '"wrong-etag"') .expect(412); const stillExists = await Task.findOne({ where: { uid: 'delete-task-1' }, }); expect(stillExists).toBeDefined(); }); }); describe('ETag and CTag', () => { beforeEach(async () => { testTask = await Task.create({ uid: 'etag-task', user_id: testUser.id, name: 'ETag Test', status: 0, }); }); afterEach(async () => { await Task.destroy({ where: { user_id: testUser.id } }); }); test('ETag should change when task is updated', async () => { const response1 = await request(app) .get('/caldav/caldav@test.com/tasks/etag-task.ics') .set('Authorization', authHeader) .expect(200); const etag1 = response1.headers.etag; await testTask.update({ name: 'Updated Name' }); const response2 = await request(app) .get('/caldav/caldav@test.com/tasks/etag-task.ics') .set('Authorization', authHeader) .expect(200); const etag2 = response2.headers.etag; expect(etag1).not.toBe(etag2); }); test('CTag should be present in calendar PROPFIND', async () => { const propfindXml = ` `; const response = await propfind( app, '/caldav/caldav@test.com/tasks/' ) .set('Authorization', authHeader) .set('Depth', '0') .set('Content-Type', 'application/xml') .send(propfindXml) .expect(207); expect(response.text).toContain('getctag'); expect(response.text).toContain('ctag-'); }); }); });