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.
Files changed (92) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +916 -0
  3. package/package.json +50 -0
  4. package/src/cli.ts +100 -0
  5. package/src/commands/auto-reply.ts +182 -0
  6. package/src/commands/calendar.ts +576 -0
  7. package/src/commands/counter.ts +87 -0
  8. package/src/commands/create-event.ts +544 -0
  9. package/src/commands/delegates.ts +286 -0
  10. package/src/commands/delete-event.ts +321 -0
  11. package/src/commands/drafts.ts +502 -0
  12. package/src/commands/files.ts +532 -0
  13. package/src/commands/find.ts +195 -0
  14. package/src/commands/findtime.ts +270 -0
  15. package/src/commands/folders.ts +177 -0
  16. package/src/commands/forward-event.ts +49 -0
  17. package/src/commands/graph-calendar.ts +217 -0
  18. package/src/commands/login.ts +195 -0
  19. package/src/commands/mail.ts +950 -0
  20. package/src/commands/oof.ts +263 -0
  21. package/src/commands/outlook-categories.ts +173 -0
  22. package/src/commands/outlook-graph.ts +880 -0
  23. package/src/commands/planner.ts +1678 -0
  24. package/src/commands/respond.ts +291 -0
  25. package/src/commands/rooms.ts +210 -0
  26. package/src/commands/rules.ts +511 -0
  27. package/src/commands/schedule.ts +109 -0
  28. package/src/commands/send.ts +204 -0
  29. package/src/commands/serve.ts +14 -0
  30. package/src/commands/sharepoint.ts +179 -0
  31. package/src/commands/site-pages.ts +163 -0
  32. package/src/commands/subscribe.ts +103 -0
  33. package/src/commands/subscriptions.ts +29 -0
  34. package/src/commands/suggest.ts +155 -0
  35. package/src/commands/todo.ts +2092 -0
  36. package/src/commands/update-event.ts +608 -0
  37. package/src/commands/update.ts +88 -0
  38. package/src/commands/verify-token.ts +62 -0
  39. package/src/commands/whoami.ts +74 -0
  40. package/src/index.ts +190 -0
  41. package/src/lib/atomic-write.ts +20 -0
  42. package/src/lib/attach-link-spec.test.ts +24 -0
  43. package/src/lib/attach-link-spec.ts +70 -0
  44. package/src/lib/attachments.ts +79 -0
  45. package/src/lib/auth.ts +192 -0
  46. package/src/lib/calendar-range.test.ts +41 -0
  47. package/src/lib/calendar-range.ts +103 -0
  48. package/src/lib/dates.test.ts +74 -0
  49. package/src/lib/dates.ts +137 -0
  50. package/src/lib/delegate-client.test.ts +74 -0
  51. package/src/lib/delegate-client.ts +322 -0
  52. package/src/lib/ews-client.ts +3418 -0
  53. package/src/lib/git-commit.ts +4 -0
  54. package/src/lib/glitchtip-eligibility.ts +220 -0
  55. package/src/lib/glitchtip.ts +253 -0
  56. package/src/lib/global-env.ts +3 -0
  57. package/src/lib/graph-auth.ts +223 -0
  58. package/src/lib/graph-calendar-client.test.ts +118 -0
  59. package/src/lib/graph-calendar-client.ts +112 -0
  60. package/src/lib/graph-client.test.ts +107 -0
  61. package/src/lib/graph-client.ts +1058 -0
  62. package/src/lib/graph-constants.ts +12 -0
  63. package/src/lib/graph-directory.ts +116 -0
  64. package/src/lib/graph-event.ts +134 -0
  65. package/src/lib/graph-schedule.ts +173 -0
  66. package/src/lib/graph-subscriptions.ts +94 -0
  67. package/src/lib/graph-user-path.ts +13 -0
  68. package/src/lib/jwt-utils.ts +34 -0
  69. package/src/lib/markdown.test.ts +21 -0
  70. package/src/lib/markdown.ts +174 -0
  71. package/src/lib/mime-type.ts +106 -0
  72. package/src/lib/oof-client.test.ts +59 -0
  73. package/src/lib/oof-client.ts +122 -0
  74. package/src/lib/outlook-graph-client.test.ts +146 -0
  75. package/src/lib/outlook-graph-client.ts +649 -0
  76. package/src/lib/outlook-master-categories.ts +145 -0
  77. package/src/lib/package-info.ts +59 -0
  78. package/src/lib/places-client.ts +144 -0
  79. package/src/lib/planner-client.ts +1226 -0
  80. package/src/lib/rules-client.ts +178 -0
  81. package/src/lib/sharepoint-client.ts +101 -0
  82. package/src/lib/site-pages-client.ts +73 -0
  83. package/src/lib/todo-client.test.ts +298 -0
  84. package/src/lib/todo-client.ts +1309 -0
  85. package/src/lib/url-validation.ts +40 -0
  86. package/src/lib/utils.ts +45 -0
  87. package/src/lib/webhook-server.ts +51 -0
  88. package/src/test/auth.test.ts +104 -0
  89. package/src/test/cli.integration.test.ts +1083 -0
  90. package/src/test/ews-client.test.ts +268 -0
  91. package/src/test/mocks/index.ts +375 -0
  92. package/src/test/mocks/responses.ts +861 -0
@@ -0,0 +1,223 @@
1
+ import { mkdir, readFile, rename, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { atomicWriteUtf8File } from './atomic-write.js';
5
+ import { getJwtExpiration, getMicrosoftTenantPathSegment, isValidJwtStructure } from './jwt-utils.js';
6
+
7
+ export interface GraphAuthResult {
8
+ success: boolean;
9
+ token?: string;
10
+ error?: string;
11
+ }
12
+
13
+ interface CachedGraphToken {
14
+ accessToken: string;
15
+ refreshToken: string;
16
+ expiresAt: number;
17
+ }
18
+
19
+ function assertCachedGraphToken(data: unknown): CachedGraphToken {
20
+ if (!data || typeof data !== 'object') throw new Error('invalid graph token cache');
21
+ const o = data as Record<string, unknown>;
22
+ if (typeof o.accessToken !== 'string' || o.accessToken.length > 100_000) throw new Error('invalid graph token cache');
23
+ if (typeof o.refreshToken !== 'string' || o.refreshToken.length > 100_000)
24
+ throw new Error('invalid graph token cache');
25
+ if (typeof o.expiresAt !== 'number' || !Number.isFinite(o.expiresAt)) throw new Error('invalid graph token cache');
26
+ return { accessToken: o.accessToken, refreshToken: o.refreshToken, expiresAt: o.expiresAt };
27
+ }
28
+
29
+ const GRAPH_TOKEN_CACHE_TEMPLATE = join(homedir(), '.config', 'm365-agent-cli', 'graph-token-cache-{identity}.json');
30
+ const LEGACY_GRAPH_TOKEN_CACHE_FILE = join(homedir(), '.config', 'm365-agent-cli', 'graph-token-cache.json');
31
+ const OLD_GRAPH_TOKEN_CACHE_FILE = join(homedir(), '.config', 'clippy', 'graph-token-cache.json');
32
+
33
+ function graphTokenCachePath(identity: string): string {
34
+ return GRAPH_TOKEN_CACHE_TEMPLATE.replace('{identity}', identity);
35
+ }
36
+
37
+ async function migrateGraphTokenCache(): Promise<void> {
38
+ try {
39
+ const dir = join(homedir(), '.config', 'm365-agent-cli');
40
+ const defaultPath = graphTokenCachePath('default');
41
+ const defaultStats = await stat(defaultPath).catch(() => null);
42
+ if (!defaultStats) {
43
+ const legacyStats = await stat(LEGACY_GRAPH_TOKEN_CACHE_FILE).catch(() => null);
44
+ if (legacyStats) {
45
+ await mkdir(dir, { recursive: true, mode: 0o700 });
46
+ await rename(LEGACY_GRAPH_TOKEN_CACHE_FILE, defaultPath);
47
+ return;
48
+ }
49
+ const oldClippyStats = await stat(OLD_GRAPH_TOKEN_CACHE_FILE).catch(() => null);
50
+ if (oldClippyStats) {
51
+ await mkdir(dir, { recursive: true, mode: 0o700 });
52
+ await rename(OLD_GRAPH_TOKEN_CACHE_FILE, defaultPath);
53
+ }
54
+ }
55
+ } catch (_err) {
56
+ // Ignore migration errors
57
+ }
58
+ }
59
+
60
+ const GRAPH_SCOPES = [
61
+ 'https://graph.microsoft.com/Files.ReadWrite offline_access User.Read',
62
+ 'https://graph.microsoft.com/Files.ReadWrite.All offline_access User.Read',
63
+ 'https://graph.microsoft.com/Sites.ReadWrite.All offline_access User.Read',
64
+ 'https://graph.microsoft.com/.default offline_access',
65
+ 'https://graph.microsoft.com/Tasks.ReadWrite offline_access User.Read',
66
+ 'https://graph.microsoft.com/Group.ReadWrite.All offline_access User.Read',
67
+ 'https://graph.microsoft.com/Files.Read offline_access User.Read'
68
+ ];
69
+
70
+ async function loadCachedGraphToken(identity: string): Promise<CachedGraphToken | null> {
71
+ await migrateGraphTokenCache();
72
+ try {
73
+ const data = await readFile(graphTokenCachePath(identity), 'utf-8');
74
+ return assertCachedGraphToken(JSON.parse(data));
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ async function saveCachedGraphToken(identity: string, token: CachedGraphToken): Promise<void> {
81
+ try {
82
+ const safe = assertCachedGraphToken(token);
83
+ await atomicWriteUtf8File(graphTokenCachePath(identity), JSON.stringify(safe, null, 2), 0o600);
84
+ } catch (err) {
85
+ console.error('Failed to write Graph token cache:', err instanceof Error ? err.message : err);
86
+ }
87
+ }
88
+
89
+ async function refreshGraphAccessToken(
90
+ clientId: string,
91
+ refreshToken: string,
92
+ tenant: string
93
+ ): Promise<CachedGraphToken> {
94
+ let lastError = '';
95
+
96
+ for (const scope of GRAPH_SCOPES) {
97
+ const response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
100
+ body: new URLSearchParams({
101
+ client_id: clientId,
102
+ grant_type: 'refresh_token',
103
+ refresh_token: refreshToken,
104
+ scope
105
+ }).toString()
106
+ });
107
+
108
+ const json = (await response.json()) as {
109
+ access_token?: string;
110
+ refresh_token?: string;
111
+ expires_in?: number;
112
+ error?: string;
113
+ error_description?: string;
114
+ };
115
+
116
+ if (response.ok && json.access_token) {
117
+ const accessToken = json.access_token;
118
+
119
+ // Refuse to cache tokens that are not well-formed JWTs
120
+ if (!isValidJwtStructure(accessToken)) {
121
+ throw new Error('OAuth server returned an invalid token structure — refusing to cache');
122
+ }
123
+
124
+ const expiresAt = getJwtExpiration(accessToken) ?? Date.now() + (json.expires_in || 3600) * 1000;
125
+ if (expiresAt <= Date.now()) {
126
+ throw new Error('OAuth server returned an already-expired token — refusing to cache');
127
+ }
128
+
129
+ return {
130
+ accessToken,
131
+ refreshToken: json.refresh_token || refreshToken,
132
+ expiresAt
133
+ };
134
+ }
135
+
136
+ lastError = [json.error, json.error_description].filter(Boolean).join(': ') || `HTTP ${response.status}`;
137
+ }
138
+
139
+ throw new Error(`Graph token refresh failed: ${lastError}`);
140
+ }
141
+
142
+ export async function resolveGraphAuth(options?: { token?: string; identity?: string }): Promise<GraphAuthResult> {
143
+ if (options?.token) {
144
+ return { success: true, token: options.token };
145
+ }
146
+
147
+ try {
148
+ const clientId = process.env.EWS_CLIENT_ID;
149
+ const graphRefreshToken = process.env.GRAPH_REFRESH_TOKEN;
150
+
151
+ if (!clientId) {
152
+ return {
153
+ success: false,
154
+ error: 'Missing EWS_CLIENT_ID in environment. Check your .env file or Azure app registration.'
155
+ };
156
+ }
157
+
158
+ if (!graphRefreshToken) {
159
+ if (process.env.EWS_REFRESH_TOKEN) {
160
+ console.warn(
161
+ '[graph-auth] EWS_REFRESH_TOKEN is set but GRAPH_REFRESH_TOKEN is not. ' +
162
+ 'EWS tokens cannot be used for Microsoft Graph operations — they have different OAuth scopes. ' +
163
+ 'Please set GRAPH_REFRESH_TOKEN to your Graph API refresh token.'
164
+ );
165
+ }
166
+ return {
167
+ success: false,
168
+ error:
169
+ 'Missing GRAPH_REFRESH_TOKEN in environment. ' +
170
+ 'Note: EWS_REFRESH_TOKEN cannot be used for Graph operations — Graph requires its own token with Graph scopes.'
171
+ };
172
+ }
173
+
174
+ const identity = options?.identity || 'default';
175
+ if (!/^[a-zA-Z0-9_-]+$/.test(identity)) {
176
+ return {
177
+ success: false,
178
+ error: 'Invalid identity name. Only alphanumeric characters, hyphens, and underscores are allowed.'
179
+ };
180
+ }
181
+
182
+ const tenant = getMicrosoftTenantPathSegment();
183
+
184
+ const cached = await loadCachedGraphToken(identity);
185
+ if (cached && cached.expiresAt > Date.now() + 60_000) {
186
+ // Guard against corrupted cache: validate JWT structure before returning
187
+ if (!isValidJwtStructure(cached.accessToken)) {
188
+ console.warn(
189
+ '[graph-auth] Cached Graph token has an invalid JWT structure — falling back to token refresh. ' +
190
+ 'You may need to re-authenticate if this persists.'
191
+ );
192
+ } else {
193
+ return { success: true, token: cached.accessToken };
194
+ }
195
+ }
196
+
197
+ const refreshTokens = [...new Set([cached?.refreshToken, graphRefreshToken].filter((t): t is string => !!t))];
198
+
199
+ for (let i = 0; i < refreshTokens.length; i++) {
200
+ try {
201
+ const result = await refreshGraphAccessToken(clientId, refreshTokens[i], tenant);
202
+ await saveCachedGraphToken(identity, result);
203
+ return { success: true, token: result.accessToken };
204
+ } catch (err) {
205
+ const msg = err instanceof Error ? err.message : String(err);
206
+ const isLast = i === refreshTokens.length - 1;
207
+ console.warn(
208
+ `[graph-auth] Token refresh attempt failed: ${msg}${isLast ? '.' : ' — trying next token candidate.'}`
209
+ );
210
+ }
211
+ }
212
+
213
+ return {
214
+ success: false,
215
+ error: 'Graph token refresh failed. You may need to update GRAPH_REFRESH_TOKEN in .env.'
216
+ };
217
+ } catch (err) {
218
+ return {
219
+ success: false,
220
+ error: err instanceof Error ? err.message : 'Graph authentication failed'
221
+ };
222
+ }
223
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ const token = 'test-token';
4
+ const baseUrl = 'https://graph.microsoft.com/v1.0';
5
+
6
+ describe('listCalendars', () => {
7
+ it('GETs /calendars collection', async () => {
8
+ process.env.GRAPH_BASE_URL = baseUrl;
9
+ const urls: string[] = [];
10
+ const originalFetch = globalThis.fetch;
11
+
12
+ try {
13
+ globalThis.fetch = (async (input: string | URL | Request) => {
14
+ urls.push(typeof input === 'string' ? input : input.toString());
15
+ return new Response(
16
+ JSON.stringify({
17
+ value: [{ id: 'cal-1', name: 'Calendar' }]
18
+ }),
19
+ { status: 200, headers: { 'content-type': 'application/json' } }
20
+ );
21
+ }) as typeof fetch;
22
+
23
+ const { listCalendars } = await import('./graph-calendar-client.js');
24
+ const r = await listCalendars(token);
25
+
26
+ expect(r.ok).toBe(true);
27
+ expect(r.data?.[0]?.name).toBe('Calendar');
28
+ expect(urls[0]).toContain('/me/calendars');
29
+ } finally {
30
+ globalThis.fetch = originalFetch;
31
+ }
32
+ });
33
+ });
34
+
35
+ describe('listCalendarView', () => {
36
+ it('GETs default /calendar/calendarView with start and end', async () => {
37
+ process.env.GRAPH_BASE_URL = baseUrl;
38
+ const urls: string[] = [];
39
+ const originalFetch = globalThis.fetch;
40
+
41
+ try {
42
+ globalThis.fetch = (async (input: string | URL | Request) => {
43
+ urls.push(typeof input === 'string' ? input : input.toString());
44
+ return new Response(
45
+ JSON.stringify({
46
+ value: [{ id: 'evt-1', subject: 'Standup' }]
47
+ }),
48
+ { status: 200, headers: { 'content-type': 'application/json' } }
49
+ );
50
+ }) as typeof fetch;
51
+
52
+ const { listCalendarView } = await import('./graph-calendar-client.js');
53
+ const r = await listCalendarView(token, '2026-04-01T00:00:00Z', '2026-04-02T00:00:00Z', {});
54
+
55
+ expect(r.ok).toBe(true);
56
+ expect(r.data?.[0]?.subject).toBe('Standup');
57
+ expect(urls[0]).toContain('/me/calendar/calendarView');
58
+ expect(urls[0]).toContain('startDateTime=');
59
+ expect(urls[0]).toContain('endDateTime=');
60
+ } finally {
61
+ globalThis.fetch = originalFetch;
62
+ }
63
+ });
64
+
65
+ it('GETs /calendars/{id}/calendarView when calendarId set', async () => {
66
+ process.env.GRAPH_BASE_URL = baseUrl;
67
+ const urls: string[] = [];
68
+ const originalFetch = globalThis.fetch;
69
+
70
+ try {
71
+ globalThis.fetch = (async (input: string | URL | Request) => {
72
+ urls.push(typeof input === 'string' ? input : input.toString());
73
+ return new Response(JSON.stringify({ value: [] }), {
74
+ status: 200,
75
+ headers: { 'content-type': 'application/json' }
76
+ });
77
+ }) as typeof fetch;
78
+
79
+ const { listCalendarView } = await import('./graph-calendar-client.js');
80
+ const r = await listCalendarView(token, '2026-04-01T00:00:00Z', '2026-04-02T00:00:00Z', {
81
+ calendarId: 'abc/def'
82
+ });
83
+
84
+ expect(r.ok).toBe(true);
85
+ expect(urls[0]).toContain('/me/calendars/abc%2Fdef/calendarView');
86
+ } finally {
87
+ globalThis.fetch = originalFetch;
88
+ }
89
+ });
90
+ });
91
+
92
+ describe('getEvent', () => {
93
+ it('GETs /events/{id}', async () => {
94
+ process.env.GRAPH_BASE_URL = baseUrl;
95
+ const urls: string[] = [];
96
+ const originalFetch = globalThis.fetch;
97
+
98
+ try {
99
+ globalThis.fetch = (async (input: string | URL | Request) => {
100
+ urls.push(typeof input === 'string' ? input : input.toString());
101
+ return new Response(JSON.stringify({ id: 'evt-1', subject: 'Hi' }), {
102
+ status: 200,
103
+ headers: { 'content-type': 'application/json' }
104
+ });
105
+ }) as typeof fetch;
106
+
107
+ const { getEvent } = await import('./graph-calendar-client.js');
108
+ const r = await getEvent(token, 'evt-1', undefined, 'subject,id');
109
+
110
+ expect(r.ok).toBe(true);
111
+ expect(r.data?.subject).toBe('Hi');
112
+ expect(urls[0]).toContain('/me/events/evt-1');
113
+ expect(urls[0]).toContain('$select=');
114
+ } finally {
115
+ globalThis.fetch = originalFetch;
116
+ }
117
+ });
118
+ });
@@ -0,0 +1,112 @@
1
+ import {
2
+ callGraph,
3
+ fetchAllPages,
4
+ GraphApiError,
5
+ type GraphResponse,
6
+ graphError,
7
+ graphResult
8
+ } from './graph-client.js';
9
+ import { graphUserPath } from './graph-user-path.js';
10
+
11
+ /** Graph [calendar](https://learn.microsoft.com/en-us/graph/api/resources/calendar) (subset). */
12
+ export interface GraphCalendarResource {
13
+ id: string;
14
+ name?: string;
15
+ color?: string;
16
+ hexColor?: string;
17
+ owner?: { name?: string; address?: string };
18
+ canEdit?: boolean;
19
+ canShare?: boolean;
20
+ canViewPrivateItems?: boolean;
21
+ defaultOnlineMeetingProvider?: string;
22
+ }
23
+
24
+ /** Graph [event](https://learn.microsoft.com/en-us/graph/api/resources/event) (subset). */
25
+ export interface GraphCalendarEvent {
26
+ id: string;
27
+ subject?: string;
28
+ bodyPreview?: string;
29
+ start?: { dateTime: string; timeZone: string };
30
+ end?: { dateTime: string; timeZone: string };
31
+ isAllDay?: boolean;
32
+ isCancelled?: boolean;
33
+ organizer?: { emailAddress?: { name?: string; address?: string } };
34
+ attendees?: Array<{
35
+ emailAddress?: { name?: string; address?: string };
36
+ status?: { response?: string };
37
+ }>;
38
+ location?: { displayName?: string };
39
+ webLink?: string;
40
+ onlineMeeting?: { joinUrl?: string };
41
+ }
42
+
43
+ function calendarsRoot(user?: string): string {
44
+ return graphUserPath(user, 'calendars');
45
+ }
46
+
47
+ export async function listCalendars(token: string, user?: string): Promise<GraphResponse<GraphCalendarResource[]>> {
48
+ return fetchAllPages<GraphCalendarResource>(token, calendarsRoot(user), 'Failed to list calendars');
49
+ }
50
+
51
+ export async function getCalendar(
52
+ token: string,
53
+ calendarId: string,
54
+ user?: string
55
+ ): Promise<GraphResponse<GraphCalendarResource>> {
56
+ try {
57
+ const result = await callGraph<GraphCalendarResource>(
58
+ token,
59
+ `${calendarsRoot(user)}/${encodeURIComponent(calendarId)}`
60
+ );
61
+ if (!result.ok || !result.data) {
62
+ return graphError(result.error?.message || 'Failed to get calendar', result.error?.code, result.error?.status);
63
+ }
64
+ return graphResult(result.data);
65
+ } catch (err) {
66
+ if (err instanceof GraphApiError) return graphError(err.message, err.code, err.status);
67
+ return graphError(err instanceof Error ? err.message : 'Failed to get calendar');
68
+ }
69
+ }
70
+
71
+ /**
72
+ * List events in a time range (Graph [calendarView](https://learn.microsoft.com/en-us/graph/api/calendar-list-calendarview)).
73
+ * Omit `calendarId` to use the user's default calendar (`/me/calendar/calendarView`).
74
+ */
75
+ export async function listCalendarView(
76
+ token: string,
77
+ startDateTime: string,
78
+ endDateTime: string,
79
+ options?: { calendarId?: string; user?: string }
80
+ ): Promise<GraphResponse<GraphCalendarEvent[]>> {
81
+ const params = new URLSearchParams();
82
+ params.set('startDateTime', startDateTime);
83
+ params.set('endDateTime', endDateTime);
84
+ const qs = `?${params.toString()}`;
85
+ const path = options?.calendarId
86
+ ? `${calendarsRoot(options.user)}/${encodeURIComponent(options.calendarId)}/calendarView${qs}`
87
+ : `${graphUserPath(options?.user, 'calendar/calendarView')}${qs}`;
88
+
89
+ return fetchAllPages<GraphCalendarEvent>(token, path, 'Failed to list calendar view');
90
+ }
91
+
92
+ export async function getEvent(
93
+ token: string,
94
+ eventId: string,
95
+ user?: string,
96
+ select?: string
97
+ ): Promise<GraphResponse<GraphCalendarEvent>> {
98
+ let path = `${graphUserPath(user, `events/${encodeURIComponent(eventId)}`)}`;
99
+ if (select?.trim()) {
100
+ path += `?$select=${encodeURIComponent(select.trim())}`;
101
+ }
102
+ try {
103
+ const result = await callGraph<GraphCalendarEvent>(token, path);
104
+ if (!result.ok || !result.data) {
105
+ return graphError(result.error?.message || 'Failed to get event', result.error?.code, result.error?.status);
106
+ }
107
+ return graphResult(result.data);
108
+ } catch (err) {
109
+ if (err instanceof GraphApiError) return graphError(err.message, err.code, err.status);
110
+ return graphError(err instanceof Error ? err.message : 'Failed to get event');
111
+ }
112
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { writeFileSync } from 'node:fs';
3
+ import { mkdtemp, unlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { uploadLargeFile } from './graph-client.js';
7
+
8
+ const token = 'test-token';
9
+ const baseUrl = 'https://graph.microsoft.com/v1.0';
10
+
11
+ describe('searchFiles query encoding', () => {
12
+ it('encodes single quotes in search query before interpolation', async () => {
13
+ process.env.GRAPH_BASE_URL = baseUrl;
14
+
15
+ const fetchCalls: string[] = [];
16
+ const originalFetch = globalThis.fetch;
17
+
18
+ try {
19
+ globalThis.fetch = (async (input: string | URL | Request) => {
20
+ fetchCalls.push(typeof input === 'string' ? input : input.toString());
21
+ return new Response(JSON.stringify({ value: [] }), {
22
+ status: 200,
23
+ headers: { 'content-type': 'application/json' }
24
+ });
25
+ }) as typeof fetch;
26
+
27
+ const { searchFiles } = await import('./graph-client.js');
28
+ await searchFiles(token, "') and name='anything");
29
+
30
+ expect(fetchCalls).toHaveLength(1);
31
+ expect(fetchCalls[0]).toContain("/me/drive/root/search(q='%27%29%20and%20name%3D%27anything')");
32
+ expect(fetchCalls[0]).not.toContain("q=') and name='anything'");
33
+ } finally {
34
+ globalThis.fetch = originalFetch;
35
+ }
36
+ });
37
+ });
38
+
39
+ describe('uploadLargeFile chunking', () => {
40
+ it('uploads file in chunks and returns DriveItem', async () => {
41
+ const dir = await mkdtemp(join(tmpdir(), 'm365-graph-upload-'));
42
+ const tmpFile = join(dir, 'chunk.bin');
43
+ const fileSize = 25 * 1024 * 1024; // 25MB
44
+ const buffer = new Uint8Array(fileSize);
45
+ buffer.fill(42);
46
+ writeFileSync(tmpFile, buffer);
47
+
48
+ const originalFetch = globalThis.fetch;
49
+ const fetchCalls: any[] = [];
50
+
51
+ try {
52
+ globalThis.fetch = (async (input: any, init?: any) => {
53
+ const url = typeof input === 'string' ? input : input.toString();
54
+
55
+ // 1. Create session POST
56
+ if (url.includes('createUploadSession')) {
57
+ return new Response(
58
+ JSON.stringify({
59
+ uploadUrl: 'https://upload.example.com/session-123',
60
+ expirationDateTime: '2026-04-01T00:00:00.000Z'
61
+ }),
62
+ { status: 200, headers: { 'content-type': 'application/json' } }
63
+ );
64
+ }
65
+
66
+ // 2. Chunk PUTs
67
+ if (init?.method === 'PUT') {
68
+ fetchCalls.push({
69
+ url,
70
+ range: (init.headers as any)?.['Content-Range'],
71
+ bodySize: (init.body as any)?.length
72
+ });
73
+ const range = (init.headers as any)?.['Content-Range'];
74
+ if (range?.endsWith('-26214399/26214400')) {
75
+ // Last chunk 10MB*2 to 25MB
76
+ return new Response(JSON.stringify({ id: 'item-123', name: 'test.tmp' }), {
77
+ status: 201,
78
+ headers: { 'content-type': 'application/json' }
79
+ });
80
+ }
81
+ return new Response('{"expirationDateTime": "..."}', { status: 202 });
82
+ }
83
+
84
+ return new Response('{}', { status: 200 });
85
+ }) as any;
86
+
87
+ const result = await uploadLargeFile('token', tmpFile);
88
+
89
+ if (!result.ok) throw new Error(JSON.stringify(result));
90
+ expect(result.data?.driveItem?.id).toBe('item-123');
91
+ expect(fetchCalls.length).toBeGreaterThanOrEqual(3);
92
+
93
+ const firstCall = fetchCalls[0];
94
+ expect(firstCall.range).toContain('bytes 0-');
95
+ expect(firstCall.range).toContain('/26214400');
96
+
97
+ const lastCall = fetchCalls[fetchCalls.length - 1];
98
+ expect(lastCall.range).toContain('-26214399/26214400');
99
+ expect(lastCall.bodySize).toBeGreaterThan(0);
100
+ } finally {
101
+ globalThis.fetch = originalFetch;
102
+ try {
103
+ await unlink(tmpFile).catch(() => {});
104
+ } catch {}
105
+ }
106
+ });
107
+ });