feat: calendar invitations RSVP, trust assessment, file preview
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
getInvitationActorSummary,
|
||||
getInvitationMethod,
|
||||
getInvitationTrustAssessment,
|
||||
formatEventSummary,
|
||||
findParticipantByEmail,
|
||||
} from '../calendar-invitation';
|
||||
@@ -25,6 +27,7 @@ function makeParticipant(overrides: Partial<CalendarParticipant> = {}): Calendar
|
||||
'@type': 'Participant',
|
||||
name: 'Test',
|
||||
email: 'test@example.com',
|
||||
calendarAddress: null,
|
||||
description: null,
|
||||
sendTo: null,
|
||||
kind: 'individual',
|
||||
@@ -107,6 +110,16 @@ describe('findCalendarAttachment', () => {
|
||||
expect(findCalendarAttachment(email)).toBeNull();
|
||||
});
|
||||
|
||||
it('finds attachment when text/calendar includes MIME parameters', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b6', size: 500, type: 'text/calendar; method=REQUEST; charset=UTF-8' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b6');
|
||||
});
|
||||
|
||||
it('detects text/calendar in textBody parts', () => {
|
||||
const email = makeEmail({
|
||||
textBody: [
|
||||
@@ -118,6 +131,24 @@ describe('findCalendarAttachment', () => {
|
||||
expect(result!.blobId).toBe('tb1');
|
||||
});
|
||||
|
||||
it('detects nested text/calendar body parts inside multipart structures', () => {
|
||||
const email = makeEmail({
|
||||
textBody: [
|
||||
{
|
||||
partId: 'root',
|
||||
blobId: 'rootBlob',
|
||||
size: 100,
|
||||
type: 'multipart/alternative',
|
||||
subParts: [
|
||||
{ partId: 'plain', blobId: 'plainBlob', size: 50, type: 'text/plain' },
|
||||
{ partId: 'ical', blobId: 'tbNested', size: 300, type: 'text/calendar; method=REQUEST; charset=UTF-8' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('tbNested');
|
||||
});
|
||||
|
||||
it('prioritizes attachments over textBody', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
@@ -137,6 +168,46 @@ describe('getInvitationMethod', () => {
|
||||
expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel');
|
||||
});
|
||||
|
||||
it('detects request from MIME Content-Type method parameter', () => {
|
||||
const event: Partial<CalendarEvent> = {};
|
||||
const email = makeEmail({
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; method=REQUEST; charset=UTF-8',
|
||||
},
|
||||
});
|
||||
expect(getInvitationMethod(event, { email })).toBe('request');
|
||||
});
|
||||
|
||||
it('detects reply from attachment MIME method parameter', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
att: makeParticipant({ participationStatus: 'accepted' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event, {
|
||||
attachment: { type: 'text/calendar; method=REPLY; charset=UTF-8' },
|
||||
})).toBe('reply');
|
||||
});
|
||||
|
||||
it('detects publish, add, refresh, counter, and declinecounter from MIME parameters', () => {
|
||||
const event: Partial<CalendarEvent> = {};
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=PUBLISH' } })).toBe('publish');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=ADD' } })).toBe('add');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=REFRESH' } })).toBe('refresh');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=COUNTER' } })).toBe('counter');
|
||||
expect(getInvitationMethod(event, { attachment: { type: 'text/calendar; method=DECLINECOUNTER' } })).toBe('declinecounter');
|
||||
});
|
||||
|
||||
it('does not treat text/calendar without method as iMIP metadata', () => {
|
||||
const event: Partial<CalendarEvent> = {};
|
||||
const email = makeEmail({
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=UTF-8',
|
||||
},
|
||||
});
|
||||
expect(getInvitationMethod(event, { email })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('detects request when participants have organizer role', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
@@ -168,6 +239,59 @@ describe('getInvitationMethod', () => {
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('infers reply when attendee status is present without an organizer', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
att: makeParticipant({ roles: { attendee: true }, participationStatus: 'accepted' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('reply');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvitationActorSummary', () => {
|
||||
it('picks the attendee as actor for reply-like methods', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com', name: 'Organizer' }),
|
||||
att: makeParticipant({
|
||||
roles: { attendee: true },
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
participationStatus: 'accepted',
|
||||
participationComment: 'Works for me',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect(getInvitationActorSummary(event, 'reply')).toMatchObject({
|
||||
role: 'attendee',
|
||||
name: 'Alice',
|
||||
participationStatus: 'accepted',
|
||||
participationComment: 'Works for me',
|
||||
});
|
||||
});
|
||||
|
||||
it('picks the organizer as actor for declinecounter', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com', name: 'Organizer' }),
|
||||
att: makeParticipant({
|
||||
roles: { attendee: true },
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
participationStatus: 'tentative',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect(getInvitationActorSummary(event, 'declinecounter')).toMatchObject({
|
||||
role: 'organizer',
|
||||
name: 'Organizer',
|
||||
email: 'organizer@example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEventSummary', () => {
|
||||
@@ -228,6 +352,92 @@ describe('formatEventSummary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvitationTrustAssessment', () => {
|
||||
it('warns when mail authentication fails', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'organizer@example.com' }],
|
||||
authenticationResults: {
|
||||
dmarc: { result: 'fail', domain: 'example.com', policy: 'reject' },
|
||||
},
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.reason).toBe('authentication_failed');
|
||||
});
|
||||
|
||||
it('warns when sender and organizer differ without verified authentication', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'calendar-bot@example.net' }],
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.reason).toBe('sender_mismatch_unverified');
|
||||
});
|
||||
|
||||
it('shows caution when sender and organizer differ but authentication passed', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'assistant@example.com' }],
|
||||
authenticationResults: {
|
||||
dkim: { result: 'pass', domain: 'example.com', selector: 'mail' },
|
||||
},
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('caution');
|
||||
expect(result.reason).toBe('sender_mismatch');
|
||||
});
|
||||
|
||||
it('shows caution when an invitation has no verified authentication', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'organizer@example.com' }],
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('caution');
|
||||
expect(result.reason).toBe('authentication_missing');
|
||||
});
|
||||
|
||||
it('returns trusted when sender matches organizer and authentication passed', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, email: 'organizer@example.com' }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInvitationTrustAssessment(event, makeEmail({
|
||||
from: [{ email: 'organizer@example.com' }],
|
||||
authenticationResults: {
|
||||
spf: { result: 'pass', domain: 'example.com', ip: '203.0.113.5' },
|
||||
},
|
||||
}), 'request');
|
||||
|
||||
expect(result.level).toBe('trusted');
|
||||
expect(result.reason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findParticipantByEmail', () => {
|
||||
it('finds participant by direct email match', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
|
||||
Reference in New Issue
Block a user