m365-agent-cli 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +916 -0
- package/package.json +50 -0
- package/src/cli.ts +100 -0
- package/src/commands/auto-reply.ts +182 -0
- package/src/commands/calendar.ts +576 -0
- package/src/commands/counter.ts +87 -0
- package/src/commands/create-event.ts +544 -0
- package/src/commands/delegates.ts +286 -0
- package/src/commands/delete-event.ts +321 -0
- package/src/commands/drafts.ts +502 -0
- package/src/commands/files.ts +532 -0
- package/src/commands/find.ts +195 -0
- package/src/commands/findtime.ts +270 -0
- package/src/commands/folders.ts +177 -0
- package/src/commands/forward-event.ts +49 -0
- package/src/commands/graph-calendar.ts +217 -0
- package/src/commands/login.ts +195 -0
- package/src/commands/mail.ts +950 -0
- package/src/commands/oof.ts +263 -0
- package/src/commands/outlook-categories.ts +173 -0
- package/src/commands/outlook-graph.ts +880 -0
- package/src/commands/planner.ts +1678 -0
- package/src/commands/respond.ts +291 -0
- package/src/commands/rooms.ts +210 -0
- package/src/commands/rules.ts +511 -0
- package/src/commands/schedule.ts +109 -0
- package/src/commands/send.ts +204 -0
- package/src/commands/serve.ts +14 -0
- package/src/commands/sharepoint.ts +179 -0
- package/src/commands/site-pages.ts +163 -0
- package/src/commands/subscribe.ts +103 -0
- package/src/commands/subscriptions.ts +29 -0
- package/src/commands/suggest.ts +155 -0
- package/src/commands/todo.ts +2092 -0
- package/src/commands/update-event.ts +608 -0
- package/src/commands/update.ts +88 -0
- package/src/commands/verify-token.ts +62 -0
- package/src/commands/whoami.ts +74 -0
- package/src/index.ts +190 -0
- package/src/lib/atomic-write.ts +20 -0
- package/src/lib/attach-link-spec.test.ts +24 -0
- package/src/lib/attach-link-spec.ts +70 -0
- package/src/lib/attachments.ts +79 -0
- package/src/lib/auth.ts +192 -0
- package/src/lib/calendar-range.test.ts +41 -0
- package/src/lib/calendar-range.ts +103 -0
- package/src/lib/dates.test.ts +74 -0
- package/src/lib/dates.ts +137 -0
- package/src/lib/delegate-client.test.ts +74 -0
- package/src/lib/delegate-client.ts +322 -0
- package/src/lib/ews-client.ts +3418 -0
- package/src/lib/git-commit.ts +4 -0
- package/src/lib/glitchtip-eligibility.ts +220 -0
- package/src/lib/glitchtip.ts +253 -0
- package/src/lib/global-env.ts +3 -0
- package/src/lib/graph-auth.ts +223 -0
- package/src/lib/graph-calendar-client.test.ts +118 -0
- package/src/lib/graph-calendar-client.ts +112 -0
- package/src/lib/graph-client.test.ts +107 -0
- package/src/lib/graph-client.ts +1058 -0
- package/src/lib/graph-constants.ts +12 -0
- package/src/lib/graph-directory.ts +116 -0
- package/src/lib/graph-event.ts +134 -0
- package/src/lib/graph-schedule.ts +173 -0
- package/src/lib/graph-subscriptions.ts +94 -0
- package/src/lib/graph-user-path.ts +13 -0
- package/src/lib/jwt-utils.ts +34 -0
- package/src/lib/markdown.test.ts +21 -0
- package/src/lib/markdown.ts +174 -0
- package/src/lib/mime-type.ts +106 -0
- package/src/lib/oof-client.test.ts +59 -0
- package/src/lib/oof-client.ts +122 -0
- package/src/lib/outlook-graph-client.test.ts +146 -0
- package/src/lib/outlook-graph-client.ts +649 -0
- package/src/lib/outlook-master-categories.ts +145 -0
- package/src/lib/package-info.ts +59 -0
- package/src/lib/places-client.ts +144 -0
- package/src/lib/planner-client.ts +1226 -0
- package/src/lib/rules-client.ts +178 -0
- package/src/lib/sharepoint-client.ts +101 -0
- package/src/lib/site-pages-client.ts +73 -0
- package/src/lib/todo-client.test.ts +298 -0
- package/src/lib/todo-client.ts +1309 -0
- package/src/lib/url-validation.ts +40 -0
- package/src/lib/utils.ts +45 -0
- package/src/lib/webhook-server.ts +51 -0
- package/src/test/auth.test.ts +104 -0
- package/src/test/cli.integration.test.ts +1083 -0
- package/src/test/ews-client.test.ts +268 -0
- package/src/test/mocks/index.ts +375 -0
- package/src/test/mocks/responses.ts +861 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
const okUpdateResponse = `<?xml version="1.0" encoding="utf-8"?>
|
|
4
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
|
5
|
+
<soap:Body>
|
|
6
|
+
<m:UpdateItemResponse>
|
|
7
|
+
<m:ResponseMessages>
|
|
8
|
+
<m:UpdateItemResponseMessage ResponseClass="Success">
|
|
9
|
+
<m:ResponseCode>NoError</m:ResponseCode>
|
|
10
|
+
<m:Items>
|
|
11
|
+
<t:CalendarItem>
|
|
12
|
+
<t:ItemId Id="updated-id" ChangeKey="new-ck" />
|
|
13
|
+
</t:CalendarItem>
|
|
14
|
+
</m:Items>
|
|
15
|
+
</m:UpdateItemResponseMessage>
|
|
16
|
+
</m:ResponseMessages>
|
|
17
|
+
</m:UpdateItemResponse>
|
|
18
|
+
</soap:Body>
|
|
19
|
+
</soap:Envelope>`;
|
|
20
|
+
|
|
21
|
+
describe('ews-client safety and conflict behavior', () => {
|
|
22
|
+
const originalFetch = globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
globalThis.fetch = originalFetch;
|
|
26
|
+
mock.restore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('retries updateEvent with AlwaysOverwrite after conflict when ChangeKey is provided', async () => {
|
|
30
|
+
const fetchCalls: string[] = [];
|
|
31
|
+
let callCount = 0;
|
|
32
|
+
|
|
33
|
+
globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
34
|
+
const body = String(init?.body || '');
|
|
35
|
+
fetchCalls.push(body);
|
|
36
|
+
callCount += 1;
|
|
37
|
+
|
|
38
|
+
if (callCount === 1) {
|
|
39
|
+
return new Response(
|
|
40
|
+
`<?xml version="1.0" encoding="utf-8"?>
|
|
41
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
|
|
42
|
+
<soap:Body>
|
|
43
|
+
<m:UpdateItemResponse>
|
|
44
|
+
<m:ResponseMessages>
|
|
45
|
+
<m:UpdateItemResponseMessage ResponseClass="Error">
|
|
46
|
+
<m:ResponseCode>ErrorIrresolvableConflict</m:ResponseCode>
|
|
47
|
+
<m:MessageText>The change key passed in the request does not match.</m:MessageText>
|
|
48
|
+
</m:UpdateItemResponseMessage>
|
|
49
|
+
</m:ResponseMessages>
|
|
50
|
+
</m:UpdateItemResponse>
|
|
51
|
+
</soap:Body>
|
|
52
|
+
</soap:Envelope>`,
|
|
53
|
+
{ status: 200 }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return new Response(okUpdateResponse, { status: 200 });
|
|
58
|
+
}) as unknown as typeof fetch;
|
|
59
|
+
|
|
60
|
+
const { updateEvent } = await import('../lib/ews-client.js');
|
|
61
|
+
const result = await updateEvent({
|
|
62
|
+
token: 'token',
|
|
63
|
+
eventId: 'event-id',
|
|
64
|
+
subject: 'Updated title',
|
|
65
|
+
changeKey: 'client-ck'
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.ok).toBe(true);
|
|
69
|
+
expect(fetchCalls.length).toBe(2);
|
|
70
|
+
expect(fetchCalls[0]).toContain('ConflictResolution="AutoResolve"');
|
|
71
|
+
expect(fetchCalls[0]).toContain('<t:ItemId Id="event-id" ChangeKey="client-ck" />');
|
|
72
|
+
expect(fetchCalls[1]).toContain('ConflictResolution="AlwaysOverwrite"');
|
|
73
|
+
expect(fetchCalls[1]).toContain('<t:ItemId Id="event-id" />');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('sanitizes EWS QueryString control syntax in getEmails search', async () => {
|
|
77
|
+
const fetchCalls: string[] = [];
|
|
78
|
+
|
|
79
|
+
globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
80
|
+
const body = String(init?.body || '');
|
|
81
|
+
fetchCalls.push(body);
|
|
82
|
+
return new Response(
|
|
83
|
+
`<?xml version="1.0" encoding="utf-8"?>
|
|
84
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
|
|
85
|
+
<soap:Body>
|
|
86
|
+
<m:FindItemResponse>
|
|
87
|
+
<m:ResponseMessages>
|
|
88
|
+
<m:FindItemResponseMessage ResponseClass="Success">
|
|
89
|
+
<m:ResponseCode>NoError</m:ResponseCode>
|
|
90
|
+
<m:RootFolder IncludesLastItemInRange="true" TotalItemsInView="0" IndexedPagingOffset="0" />
|
|
91
|
+
</m:FindItemResponseMessage>
|
|
92
|
+
</m:ResponseMessages>
|
|
93
|
+
</m:FindItemResponse>
|
|
94
|
+
</soap:Body>
|
|
95
|
+
</soap:Envelope>`,
|
|
96
|
+
{ status: 200 }
|
|
97
|
+
);
|
|
98
|
+
}) as unknown as typeof fetch;
|
|
99
|
+
|
|
100
|
+
const { getEmails } = await import('../lib/ews-client.js');
|
|
101
|
+
const query = 'urgent OR from:bob@example.com AND "project x"';
|
|
102
|
+
const result = await getEmails({ token: 'token', search: query });
|
|
103
|
+
|
|
104
|
+
expect(result.ok).toBe(true);
|
|
105
|
+
expect(fetchCalls.length).toBe(1);
|
|
106
|
+
expect(fetchCalls[0]).toContain(
|
|
107
|
+
'<m:QueryString>urgent OR from:bob@example.com AND "project x"</m:QueryString>'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns explicit error when getOwaUserInfo fails instead of silent fallback', async () => {
|
|
112
|
+
globalThis.fetch = mock(async () => {
|
|
113
|
+
return new Response('gateway timeout', { status: 504 });
|
|
114
|
+
}) as unknown as typeof fetch;
|
|
115
|
+
|
|
116
|
+
const { getOwaUserInfo } = await import('../lib/ews-client.js');
|
|
117
|
+
const result = await getOwaUserInfo('token');
|
|
118
|
+
|
|
119
|
+
expect(result.ok).toBe(false);
|
|
120
|
+
expect(result.error?.code).toBe('EWS_ERROR');
|
|
121
|
+
expect(result.error?.message).toContain('Failed to resolve OWA user info');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('parses TimeZone correctly from CalendarItem StartTimeZone and EndTimeZone', async () => {
|
|
125
|
+
globalThis.fetch = mock(async () => {
|
|
126
|
+
return new Response(
|
|
127
|
+
`<?xml version="1.0" encoding="utf-8"?>
|
|
128
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
|
129
|
+
<soap:Body>
|
|
130
|
+
<m:GetItemResponse>
|
|
131
|
+
<m:ResponseMessages>
|
|
132
|
+
<m:GetItemResponseMessage ResponseClass="Success">
|
|
133
|
+
<m:ResponseCode>NoError</m:ResponseCode>
|
|
134
|
+
<m:Items>
|
|
135
|
+
<t:CalendarItem>
|
|
136
|
+
<t:ItemId Id="event-id" ChangeKey="ck" />
|
|
137
|
+
<t:Subject>Timezone Test Event</t:Subject>
|
|
138
|
+
<t:Start>2026-03-30T10:00:00Z</t:Start>
|
|
139
|
+
<t:End>2026-03-30T11:00:00Z</t:End>
|
|
140
|
+
<t:StartTimeZone Id="Pacific Standard Time" />
|
|
141
|
+
<t:EndTimeZone Id="Pacific Standard Time" />
|
|
142
|
+
<t:IsAllDayEvent>false</t:IsAllDayEvent>
|
|
143
|
+
<t:IsCancelled>false</t:IsCancelled>
|
|
144
|
+
<t:Organizer><t:Mailbox><t:Name>Bob</t:Name><t:EmailAddress>bob@example.com</t:EmailAddress></t:Mailbox></t:Organizer>
|
|
145
|
+
</t:CalendarItem>
|
|
146
|
+
</m:Items>
|
|
147
|
+
</m:GetItemResponseMessage>
|
|
148
|
+
</m:ResponseMessages>
|
|
149
|
+
</m:GetItemResponse>
|
|
150
|
+
</soap:Body>
|
|
151
|
+
</soap:Envelope>`,
|
|
152
|
+
{ status: 200 }
|
|
153
|
+
);
|
|
154
|
+
}) as unknown as typeof fetch;
|
|
155
|
+
|
|
156
|
+
const { getCalendarEvent } = await import('../lib/ews-client.js');
|
|
157
|
+
const result = await getCalendarEvent('token', 'event-id');
|
|
158
|
+
|
|
159
|
+
expect(result.ok).toBe(true);
|
|
160
|
+
expect(result.data?.Start.TimeZone).toBe('Pacific Standard Time');
|
|
161
|
+
expect(result.data?.End.TimeZone).toBe('Pacific Standard Time');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('replyToEmail sends ReferenceItemId with ChangeKey after GetItem', async () => {
|
|
165
|
+
const fetchCalls: string[] = [];
|
|
166
|
+
let callCount = 0;
|
|
167
|
+
|
|
168
|
+
globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
169
|
+
const body = String(init?.body || '');
|
|
170
|
+
fetchCalls.push(body);
|
|
171
|
+
callCount += 1;
|
|
172
|
+
if (callCount === 1) {
|
|
173
|
+
return new Response(
|
|
174
|
+
`<?xml version="1.0" encoding="utf-8"?>
|
|
175
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
|
176
|
+
<soap:Body>
|
|
177
|
+
<m:ResponseCode>NoError</m:ResponseCode>
|
|
178
|
+
<m:GetItemResponse>
|
|
179
|
+
<m:Items>
|
|
180
|
+
<t:Message>
|
|
181
|
+
<t:ItemId Id="msg-1" ChangeKey="ck-from-get" />
|
|
182
|
+
<t:Subject>Subj</t:Subject>
|
|
183
|
+
</t:Message>
|
|
184
|
+
</m:Items>
|
|
185
|
+
</m:GetItemResponse>
|
|
186
|
+
</soap:Body>
|
|
187
|
+
</soap:Envelope>`,
|
|
188
|
+
{ status: 200 }
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return new Response(
|
|
192
|
+
`<?xml version="1.0" encoding="utf-8"?>
|
|
193
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
|
|
194
|
+
<soap:Body>
|
|
195
|
+
<m:ResponseCode>NoError</m:ResponseCode>
|
|
196
|
+
</soap:Body>
|
|
197
|
+
</soap:Envelope>`,
|
|
198
|
+
{ status: 200 }
|
|
199
|
+
);
|
|
200
|
+
}) as unknown as typeof fetch;
|
|
201
|
+
|
|
202
|
+
const { replyToEmail } = await import('../lib/ews-client.js');
|
|
203
|
+
const result = await replyToEmail('token', 'msg-1', 'Thanks', false, false, undefined);
|
|
204
|
+
|
|
205
|
+
expect(result.ok).toBe(true);
|
|
206
|
+
expect(fetchCalls.length).toBe(2);
|
|
207
|
+
expect(fetchCalls[0]).toContain('<m:GetItem>');
|
|
208
|
+
expect(fetchCalls[1]).toContain('ReferenceItemId');
|
|
209
|
+
expect(fetchCalls[1]).toContain('ChangeKey="ck-from-get"');
|
|
210
|
+
expect(fetchCalls[1]).toContain('Id="msg-1"');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('replyToEmailDraft sends ReferenceItemId with ChangeKey after GetItem', async () => {
|
|
214
|
+
const fetchCalls: string[] = [];
|
|
215
|
+
let callCount = 0;
|
|
216
|
+
|
|
217
|
+
globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
218
|
+
const body = String(init?.body || '');
|
|
219
|
+
fetchCalls.push(body);
|
|
220
|
+
callCount += 1;
|
|
221
|
+
if (callCount === 1) {
|
|
222
|
+
return new Response(
|
|
223
|
+
`<?xml version="1.0" encoding="utf-8"?>
|
|
224
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
|
225
|
+
<soap:Body>
|
|
226
|
+
<m:ResponseCode>NoError</m:ResponseCode>
|
|
227
|
+
<m:GetItemResponse>
|
|
228
|
+
<m:Items>
|
|
229
|
+
<t:Message>
|
|
230
|
+
<t:ItemId Id="msg-2" ChangeKey="ck-draft" />
|
|
231
|
+
<t:Subject>Subj</t:Subject>
|
|
232
|
+
</t:Message>
|
|
233
|
+
</m:Items>
|
|
234
|
+
</m:GetItemResponse>
|
|
235
|
+
</soap:Body>
|
|
236
|
+
</soap:Envelope>`,
|
|
237
|
+
{ status: 200 }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return new Response(
|
|
241
|
+
`<?xml version="1.0" encoding="utf-8"?>
|
|
242
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
|
243
|
+
<soap:Body>
|
|
244
|
+
<m:ResponseCode>NoError</m:ResponseCode>
|
|
245
|
+
<m:CreateItemResponse>
|
|
246
|
+
<m:Items>
|
|
247
|
+
<t:Message>
|
|
248
|
+
<t:ItemId Id="reply-draft-x" ChangeKey="rck" />
|
|
249
|
+
</t:Message>
|
|
250
|
+
</m:Items>
|
|
251
|
+
</m:CreateItemResponse>
|
|
252
|
+
</soap:Body>
|
|
253
|
+
</soap:Envelope>`,
|
|
254
|
+
{ status: 200 }
|
|
255
|
+
);
|
|
256
|
+
}) as unknown as typeof fetch;
|
|
257
|
+
|
|
258
|
+
const { replyToEmailDraft } = await import('../lib/ews-client.js');
|
|
259
|
+
const result = await replyToEmailDraft('token', 'msg-2', 'Draft reply', false, false, undefined);
|
|
260
|
+
|
|
261
|
+
expect(result.ok).toBe(true);
|
|
262
|
+
expect(result.data?.draftId).toBe('reply-draft-x');
|
|
263
|
+
expect(fetchCalls.length).toBe(2);
|
|
264
|
+
expect(fetchCalls[0]).toContain('<m:GetItem>');
|
|
265
|
+
expect(fetchCalls[1]).toContain('ChangeKey="ck-draft"');
|
|
266
|
+
expect(fetchCalls[1]).toContain('MessageDisposition="SaveOnly"');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock fetch setup for CLI integration tests.
|
|
3
|
+
* Intercepts all HTTP calls and returns mock responses based on URL and request body.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
makeGetCalendarItemDetailResponse,
|
|
10
|
+
mockAddAttachmentResponse,
|
|
11
|
+
mockCalendarEventsEmptyResponse,
|
|
12
|
+
mockCalendarEventsResponse,
|
|
13
|
+
mockCancelEventSuccessResponse,
|
|
14
|
+
mockCreateDraftResponse,
|
|
15
|
+
mockCreateEventResponse,
|
|
16
|
+
mockCreateMailFolderResponse,
|
|
17
|
+
mockDeleteEventSuccessResponse,
|
|
18
|
+
mockDeleteMailFolderResponse,
|
|
19
|
+
mockForwardEmailResponse,
|
|
20
|
+
mockGetAttachmentsResponse,
|
|
21
|
+
mockGetDraftsResponse,
|
|
22
|
+
mockGetEmailDetailResponse,
|
|
23
|
+
mockGetEmailsResponse,
|
|
24
|
+
mockGetMailFoldersResponse,
|
|
25
|
+
mockGetRoomsFromListResponse,
|
|
26
|
+
mockGetRoomsResponse,
|
|
27
|
+
mockGetScheduleResponse,
|
|
28
|
+
mockGraphCheckinResponse,
|
|
29
|
+
mockGraphCreateUploadSessionResponse,
|
|
30
|
+
mockGraphDeleteResponse,
|
|
31
|
+
mockGraphGetFileMetadataResponse,
|
|
32
|
+
mockGraphListFilesResponse,
|
|
33
|
+
mockGraphSearchFilesResponse,
|
|
34
|
+
mockGraphShareResponse,
|
|
35
|
+
mockGraphUploadResponse,
|
|
36
|
+
mockMoveEmailResponse,
|
|
37
|
+
mockOAuthTokenResponse,
|
|
38
|
+
mockReplyToEmailResponse,
|
|
39
|
+
mockResolveNamesPeopleResponse,
|
|
40
|
+
mockResolveNamesResponse,
|
|
41
|
+
mockRespondListResponse,
|
|
42
|
+
mockRespondSuccessResponse,
|
|
43
|
+
mockSearchRoomsResponse,
|
|
44
|
+
mockSendEmailResponse,
|
|
45
|
+
mockUpdateEmailResponse,
|
|
46
|
+
mockUpdateEventResponse,
|
|
47
|
+
mockUpdateMailFolderResponse
|
|
48
|
+
} from './responses.js';
|
|
49
|
+
|
|
50
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
51
|
+
|
|
52
|
+
function npmRegistryMockLatestVersion(): string {
|
|
53
|
+
try {
|
|
54
|
+
const raw = readFileSync(join(REPO_ROOT, 'package.json'), 'utf8');
|
|
55
|
+
return (JSON.parse(raw) as { version: string }).version;
|
|
56
|
+
} catch {
|
|
57
|
+
return '1.0.0';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type MockFn = (url: string, request: Request) => { status: number; body: string; contentType: string } | null;
|
|
62
|
+
|
|
63
|
+
let mockFetchImpl: MockFn | null = null;
|
|
64
|
+
|
|
65
|
+
export function setMockFetch(impl: MockFn): void {
|
|
66
|
+
mockFetchImpl = impl;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function clearMockFetch(): void {
|
|
70
|
+
mockFetchImpl = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeResponse(body: string, status = 200, contentType = 'text/xml'): Response {
|
|
74
|
+
return new Response(body, {
|
|
75
|
+
status,
|
|
76
|
+
headers: { 'content-type': contentType }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeJsonResponse(body: object, status = 200): Response {
|
|
81
|
+
return new Response(JSON.stringify(body), {
|
|
82
|
+
status,
|
|
83
|
+
headers: { 'content-type': 'application/json' }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractSoapAction(body: string): string {
|
|
88
|
+
// Extract the first child element of soap:Body
|
|
89
|
+
const match = body.match(/<soap:Body[^>]*>([\s\S]*?)<\/soap:Body>/i);
|
|
90
|
+
if (!match) return '';
|
|
91
|
+
const inner = match[1].trim();
|
|
92
|
+
// Find the first tag name
|
|
93
|
+
const tagMatch = inner.match(/<(?:m:|t:)?(\w+)/);
|
|
94
|
+
return tagMatch ? tagMatch[1] : '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasTag(body: string, tag: string): boolean {
|
|
98
|
+
return body.includes(`<${tag}`) || body.includes(`<m:${tag}`) || body.includes(`<t:${tag}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
export function createMockFetch(): any {
|
|
103
|
+
return async (input: string | URL | Request, init?: RequestInit) => {
|
|
104
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
105
|
+
const body = typeof init?.body === 'string' ? init.body : '';
|
|
106
|
+
|
|
107
|
+
// Let custom mock take priority
|
|
108
|
+
if (mockFetchImpl) {
|
|
109
|
+
const custom = mockFetchImpl(url, new Request(url, init as RequestInit));
|
|
110
|
+
if (custom) return makeResponse(custom.body, custom.status, custom.contentType);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// OAuth token endpoint
|
|
114
|
+
// Uses URL hostname parsing (not string includes) to avoid CodeQL injection alert
|
|
115
|
+
// and to be more precise — login.microsoftonline.com must be the actual host, not a query string value
|
|
116
|
+
try {
|
|
117
|
+
if (new URL(url).hostname === 'login.microsoftonline.com' && url.includes('/token')) {
|
|
118
|
+
return makeJsonResponse(JSON.parse(mockOAuthTokenResponse));
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Not a valid URL, skip OAuth check
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (url.includes('registry.npmjs.org/m365-agent-cli/latest')) {
|
|
125
|
+
return makeJsonResponse({ version: npmRegistryMockLatestVersion() });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// EWS endpoint
|
|
129
|
+
if (url.includes('outlook.office365.com/EWS/Exchange.asmx')) {
|
|
130
|
+
const action = extractSoapAction(body);
|
|
131
|
+
|
|
132
|
+
// Auth check / ResolveNames (used by whoami and find)
|
|
133
|
+
if (action === 'ResolveNames' && hasTag(body, 'UnresolvedEntry')) {
|
|
134
|
+
// Check if this is calendar-related (respond list uses DistinguishedFolderId)
|
|
135
|
+
if (hasTag(body, 'DistinguishedFolderId') && hasTag(body, 'CalendarView')) {
|
|
136
|
+
return makeResponse(mockRespondListResponse);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Distinguish whoami from find:
|
|
140
|
+
// - whoami: getOwaUserInfo calls ResolveNames with EWS_USERNAME (empty in tests)
|
|
141
|
+
// - find: resolveNames calls ResolveNames with the search query
|
|
142
|
+
// whoami has empty UnresolvedEntry + no RequiredAttendees
|
|
143
|
+
// find has (non-empty UnresolvedEntry) OR (has RequiredAttendees)
|
|
144
|
+
const unresolvedContent = body.match(/<m:UnresolvedEntry>([^<]*)<\/m:UnresolvedEntry>/)?.[1] || '';
|
|
145
|
+
const isPeopleSearch = hasTag(body, 'RequiredAttendees') || unresolvedContent.length > 0;
|
|
146
|
+
|
|
147
|
+
if (isPeopleSearch) {
|
|
148
|
+
return makeResponse(mockResolveNamesPeopleResponse);
|
|
149
|
+
}
|
|
150
|
+
// whoami gets here (empty UnresolvedEntry, no RequiredAttendees)
|
|
151
|
+
return makeResponse(mockResolveNamesResponse);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Calendar events (used by calendar, respond, delete-event, update-event)
|
|
155
|
+
if (hasTag(body, 'FindItem') && hasTag(body, 'CalendarView')) {
|
|
156
|
+
// Check for specific event IDs (for respond)
|
|
157
|
+
if (body.includes('invite-')) {
|
|
158
|
+
return makeResponse(mockRespondListResponse);
|
|
159
|
+
}
|
|
160
|
+
// Default calendar events
|
|
161
|
+
return makeResponse(mockCalendarEventsResponse);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Create calendar item
|
|
165
|
+
if (hasTag(body, 'CreateItem') && hasTag(body, 'CalendarItem')) {
|
|
166
|
+
return makeResponse(mockCreateEventResponse);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// CreateItem (new mail draft — SaveOnly Message; reply/forward use ReplyToItem/ForwardItem instead)
|
|
170
|
+
if (
|
|
171
|
+
hasTag(body, 'CreateItem') &&
|
|
172
|
+
hasTag(body, 'Message') &&
|
|
173
|
+
body.includes('MessageDisposition="SaveOnly"') &&
|
|
174
|
+
!hasTag(body, 'ReplyToItem') &&
|
|
175
|
+
!hasTag(body, 'ForwardItem')
|
|
176
|
+
) {
|
|
177
|
+
return makeResponse(mockCreateDraftResponse);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Update calendar item
|
|
181
|
+
if (hasTag(body, 'UpdateItem') && hasTag(body, 'CalendarItem')) {
|
|
182
|
+
return makeResponse(mockUpdateEventResponse);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Delete calendar item
|
|
186
|
+
if (hasTag(body, 'DeleteItem')) {
|
|
187
|
+
return makeResponse(mockDeleteEventSuccessResponse);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Cancel calendar item
|
|
191
|
+
if (hasTag(body, 'CancelItem')) {
|
|
192
|
+
return makeResponse(mockCancelEventSuccessResponse);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Respond to item
|
|
196
|
+
if (
|
|
197
|
+
hasTag(body, 'RespondToItem') ||
|
|
198
|
+
hasTag(body, 'AcceptItem') ||
|
|
199
|
+
hasTag(body, 'DeclineItem') ||
|
|
200
|
+
hasTag(body, 'TentativelyAcceptItem')
|
|
201
|
+
) {
|
|
202
|
+
return makeResponse(mockRespondSuccessResponse);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// FindItem for mail folders / emails
|
|
206
|
+
if (hasTag(body, 'FindItem') && hasTag(body, 'ItemShape')) {
|
|
207
|
+
// Check if it's a drafts query
|
|
208
|
+
if (hasTag(body, 'drafts') || body.includes('Drafts')) {
|
|
209
|
+
return makeResponse(mockGetDraftsResponse);
|
|
210
|
+
}
|
|
211
|
+
if (hasTag(body, 'sentitems') || body.includes('SentItems')) {
|
|
212
|
+
return makeResponse(mockGetEmailsResponse);
|
|
213
|
+
}
|
|
214
|
+
return makeResponse(mockGetEmailsResponse);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// GetItem: calendar vs mail (calendar flows prefetch ChangeKey before CreateItem/DeleteItem)
|
|
218
|
+
if (hasTag(body, 'GetItem')) {
|
|
219
|
+
const idMatch = body.match(/<t:ItemId\s+[^>]*Id="([^"]+)"/);
|
|
220
|
+
const reqId = idMatch?.[1] ?? '';
|
|
221
|
+
if (
|
|
222
|
+
reqId.startsWith('invite-') ||
|
|
223
|
+
reqId.startsWith('event-') ||
|
|
224
|
+
reqId.startsWith('new-event-id') ||
|
|
225
|
+
reqId === 'event-id' ||
|
|
226
|
+
reqId.startsWith('occurrence-') ||
|
|
227
|
+
reqId.startsWith('exception-') ||
|
|
228
|
+
reqId.startsWith('series-') ||
|
|
229
|
+
reqId.startsWith('cal-')
|
|
230
|
+
) {
|
|
231
|
+
return makeResponse(makeGetCalendarItemDetailResponse(reqId));
|
|
232
|
+
}
|
|
233
|
+
return makeResponse(mockGetEmailDetailResponse);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// GetAttachment
|
|
237
|
+
if (hasTag(body, 'GetAttachment')) {
|
|
238
|
+
return makeResponse(mockGetAttachmentsResponse);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// UpdateItem for email (mark read/unread/flag)
|
|
242
|
+
if (hasTag(body, 'UpdateItem') && hasTag(body, 'Message')) {
|
|
243
|
+
return makeResponse(mockUpdateEmailResponse);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// MoveItem
|
|
247
|
+
if (hasTag(body, 'MoveItem')) {
|
|
248
|
+
return makeResponse(mockMoveEmailResponse);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// SendItem
|
|
252
|
+
if (hasTag(body, 'SendItem')) {
|
|
253
|
+
return makeResponse(mockSendEmailResponse);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// CreateItem (reply/forward draft)
|
|
257
|
+
if (hasTag(body, 'CreateItem') && (hasTag(body, 'ReplyToItem') || hasTag(body, 'ForwardItem'))) {
|
|
258
|
+
if (hasTag(body, 'ForwardItem')) {
|
|
259
|
+
return makeResponse(mockForwardEmailResponse);
|
|
260
|
+
}
|
|
261
|
+
return makeResponse(mockReplyToEmailResponse);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// GetFolder (mail folders)
|
|
265
|
+
if (hasTag(body, 'GetFolder') || (hasTag(body, 'FindFolder') && hasTag(body, 'DistinguishedFolderId'))) {
|
|
266
|
+
return makeResponse(mockGetMailFoldersResponse);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// CreateFolder
|
|
270
|
+
if (hasTag(body, 'CreateFolder')) {
|
|
271
|
+
return makeResponse(mockCreateMailFolderResponse);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// UpdateFolder (rename)
|
|
275
|
+
if (hasTag(body, 'UpdateFolder')) {
|
|
276
|
+
return makeResponse(mockUpdateMailFolderResponse);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// DeleteFolder
|
|
280
|
+
if (hasTag(body, 'DeleteFolder')) {
|
|
281
|
+
return makeResponse(mockDeleteMailFolderResponse);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// GetRoomLists
|
|
285
|
+
if (hasTag(body, 'GetRoomLists')) {
|
|
286
|
+
return makeResponse(mockGetRoomsResponse);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// GetRooms
|
|
290
|
+
if (hasTag(body, 'GetRooms')) {
|
|
291
|
+
return makeResponse(mockGetRoomsFromListResponse);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ExpandDL (search rooms)
|
|
295
|
+
if (hasTag(body, 'ExpandDL')) {
|
|
296
|
+
return makeResponse(mockSearchRoomsResponse);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// GetSchedule (findtime)
|
|
300
|
+
if (hasTag(body, 'GetSchedule')) {
|
|
301
|
+
return makeResponse(mockGetScheduleResponse);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// CreateAttachment
|
|
305
|
+
if (hasTag(body, 'CreateAttachment')) {
|
|
306
|
+
return makeResponse(mockAddAttachmentResponse);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Default: return empty calendar
|
|
310
|
+
return makeResponse(mockCalendarEventsEmptyResponse);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Microsoft Graph API (files commands)
|
|
314
|
+
if (url.includes('graph.microsoft.com/v1.0')) {
|
|
315
|
+
// List files
|
|
316
|
+
if (url.includes('/me/drive/root/children') || (url.includes('/me/drive/items') && url.includes('/children'))) {
|
|
317
|
+
return makeJsonResponse(mockGraphListFilesResponse);
|
|
318
|
+
}
|
|
319
|
+
// Search files
|
|
320
|
+
if (url.includes('/me/drive/root/search')) {
|
|
321
|
+
return makeJsonResponse(mockGraphSearchFilesResponse);
|
|
322
|
+
}
|
|
323
|
+
// Upload file
|
|
324
|
+
if (url.includes('/me/drive/items/') && url.includes('/content')) {
|
|
325
|
+
return makeJsonResponse(mockGraphUploadResponse);
|
|
326
|
+
}
|
|
327
|
+
// Get file metadata
|
|
328
|
+
if (
|
|
329
|
+
url.includes('/me/drive/items/') &&
|
|
330
|
+
!url.includes('/children') &&
|
|
331
|
+
!url.includes('/content') &&
|
|
332
|
+
!url.includes('/createLink') &&
|
|
333
|
+
!url.includes('/checkin') &&
|
|
334
|
+
!url.includes('/checkout') &&
|
|
335
|
+
!url.includes('/createUploadSession')
|
|
336
|
+
) {
|
|
337
|
+
return makeJsonResponse(mockGraphGetFileMetadataResponse);
|
|
338
|
+
}
|
|
339
|
+
// Create upload session
|
|
340
|
+
if (url.includes('/createUploadSession')) {
|
|
341
|
+
return makeJsonResponse(mockGraphCreateUploadSessionResponse);
|
|
342
|
+
}
|
|
343
|
+
// Delete file
|
|
344
|
+
if ((url.includes('/me/drive/items/') || url.includes('/me/drive/')) && init?.method === 'DELETE') {
|
|
345
|
+
return makeJsonResponse(mockGraphDeleteResponse);
|
|
346
|
+
}
|
|
347
|
+
// Share file / Office collaboration
|
|
348
|
+
if (url.includes('/me/drive/items/') && url.includes('/createLink')) {
|
|
349
|
+
return makeJsonResponse(mockGraphShareResponse);
|
|
350
|
+
}
|
|
351
|
+
// Checkin
|
|
352
|
+
if (url.includes('/me/drive/items/') && url.includes('/checkin')) {
|
|
353
|
+
return makeJsonResponse(mockGraphCheckinResponse);
|
|
354
|
+
}
|
|
355
|
+
// Checkout
|
|
356
|
+
if (url.includes('/me/drive/items/') && url.includes('/checkout')) {
|
|
357
|
+
return makeJsonResponse({});
|
|
358
|
+
}
|
|
359
|
+
return makeJsonResponse({ value: [] });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Default: 404
|
|
363
|
+
return new Response('Not found', { status: 404 });
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Setup/teardown helpers for use in tests
|
|
368
|
+
export function setupMockFetch(): void {
|
|
369
|
+
globalThis.fetch = createMockFetch() as typeof fetch;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function teardownMockFetch(): void {
|
|
373
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
374
|
+
(globalThis as any).fetch = undefined;
|
|
375
|
+
}
|