msteams-mcp 0.27.2 → 0.28.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/README.md +20 -3
- package/dist/api/assignments-api.d.ts +124 -0
- package/dist/api/assignments-api.js +327 -0
- package/dist/api/assignments-api.test.d.ts +7 -0
- package/dist/api/assignments-api.test.js +302 -0
- package/dist/api/csa-api.js +8 -8
- package/dist/api/graph-files-api.d.ts +34 -0
- package/dist/api/graph-files-api.js +169 -0
- package/dist/api/graph-files-api.test.d.ts +1 -0
- package/dist/api/graph-files-api.test.js +152 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +10 -0
- package/dist/auth/assignments-refresh.test.d.ts +1 -0
- package/dist/auth/assignments-refresh.test.js +43 -0
- package/dist/auth/assignments-token.test.d.ts +1 -0
- package/dist/auth/assignments-token.test.js +36 -0
- package/dist/auth/crypto.d.ts +0 -4
- package/dist/auth/crypto.js +0 -12
- package/dist/auth/crypto.test.js +1 -18
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/session-store.d.ts +9 -6
- package/dist/auth/session-store.js +12 -88
- package/dist/auth/token-extractor.d.ts +31 -74
- package/dist/auth/token-extractor.js +171 -432
- package/dist/auth/token-refresh-http.d.ts +3 -10
- package/dist/auth/token-refresh-http.js +155 -219
- package/dist/auth/token-refresh-http.test.js +125 -19
- package/dist/auth/token-refresh.d.ts +6 -21
- package/dist/auth/token-refresh.js +62 -103
- package/dist/auth/token-refresh.test.d.ts +1 -0
- package/dist/auth/token-refresh.test.js +31 -0
- package/dist/browser/auth.d.ts +0 -35
- package/dist/browser/auth.js +18 -57
- package/dist/browser/context.d.ts +4 -14
- package/dist/browser/context.js +7 -18
- package/dist/cli.js +1 -1
- package/dist/constants.d.ts +11 -12
- package/dist/constants.js +11 -15
- package/dist/index.js +0 -0
- package/dist/research/auth-research.d.ts +10 -0
- package/dist/research/auth-research.js +175 -0
- package/dist/research/explore.d.ts +11 -0
- package/dist/research/explore.js +287 -0
- package/dist/research/search-research.d.ts +17 -0
- package/dist/research/search-research.js +317 -0
- package/dist/server.d.ts +1 -53
- package/dist/server.js +10 -167
- package/dist/test/debug-search.d.ts +10 -0
- package/dist/test/debug-search.js +147 -0
- package/dist/test/mcp-harness.d.ts +17 -0
- package/dist/test/mcp-harness.js +476 -0
- package/dist/tools/assignment-tools.d.ts +90 -0
- package/dist/tools/assignment-tools.js +151 -0
- package/dist/tools/assignment-tools.test.d.ts +1 -0
- package/dist/tools/assignment-tools.test.js +13 -0
- package/dist/tools/auth-tools.js +36 -77
- package/dist/tools/file-tools.d.ts +24 -3
- package/dist/tools/file-tools.js +31 -4
- package/dist/tools/index.d.ts +2 -8
- package/dist/tools/index.js +1 -0
- package/dist/tools/meeting-tools.js +2 -2
- package/dist/tools/message-tools.d.ts +6 -6
- package/dist/tools/message-tools.js +21 -21
- package/dist/tools/people-tools.js +4 -4
- package/dist/tools/registry.d.ts +4 -4
- package/dist/tools/registry.js +5 -3
- package/dist/tools/registry.test.js +4 -14
- package/dist/tools/search-tools.js +4 -4
- package/dist/tools/tag-tools.js +1 -1
- package/dist/types/errors.d.ts +4 -0
- package/dist/types/errors.js +11 -0
- package/dist/utils/api-config.d.ts +40 -0
- package/dist/utils/api-config.js +50 -0
- package/dist/utils/assignments-auth.test.d.ts +1 -0
- package/dist/utils/assignments-auth.test.js +57 -0
- package/dist/utils/auth-guards-assignments.test.d.ts +5 -0
- package/dist/utils/auth-guards-assignments.test.js +89 -0
- package/dist/utils/auth-guards.d.ts +23 -16
- package/dist/utils/auth-guards.js +81 -65
- package/dist/utils/auth-guards.test.js +37 -2
- package/package.json +1 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the EDU Assignments API client.
|
|
3
|
+
*
|
|
4
|
+
* Mocks the HTTP layer and the auth guard so we exercise the real URL/filter
|
|
5
|
+
* building and response parsing without a live session.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
import { httpRequest } from '../utils/http.js';
|
|
9
|
+
import { requireAssignmentsTokenAsync } from '../utils/auth-guards.js';
|
|
10
|
+
import { ok, err } from '../types/result.js';
|
|
11
|
+
import { ErrorCode, createError } from '../types/errors.js';
|
|
12
|
+
import { invalidateAccessToken } from '../auth/token-extractor.js';
|
|
13
|
+
import { listMyAssignments, getAssignment, actOnSubmission } from './assignments-api.js';
|
|
14
|
+
vi.mock('../utils/http.js', () => ({ httpRequest: vi.fn() }));
|
|
15
|
+
vi.mock('../utils/auth-guards.js', () => ({
|
|
16
|
+
requireAssignmentsTokenAsync: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('../auth/token-extractor.js', () => ({ invalidateAccessToken: vi.fn() }));
|
|
19
|
+
const mockHttp = vi.mocked(httpRequest);
|
|
20
|
+
const mockToken = vi.mocked(requireAssignmentsTokenAsync);
|
|
21
|
+
function httpOk(data) {
|
|
22
|
+
return ok({ status: 200, headers: new Headers(), data });
|
|
23
|
+
}
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
mockToken.mockResolvedValue(ok('fake-token'));
|
|
27
|
+
});
|
|
28
|
+
describe('listMyAssignments', () => {
|
|
29
|
+
it('parses assignments and the caller submission with a merged grade outcome', async () => {
|
|
30
|
+
mockHttp.mockResolvedValue(httpOk({
|
|
31
|
+
value: [
|
|
32
|
+
{
|
|
33
|
+
id: 'a1',
|
|
34
|
+
classId: 'c1',
|
|
35
|
+
displayName: 'Causes of climate change',
|
|
36
|
+
status: 'assigned',
|
|
37
|
+
dueDateTime: '2026-09-23T23:59:59Z',
|
|
38
|
+
allowLateSubmissions: true,
|
|
39
|
+
grading: { maxPoints: 5 },
|
|
40
|
+
instructions: { content: '<p>Read chapter 3</p>' },
|
|
41
|
+
webUrl: 'https://teams.microsoft.com/l/...',
|
|
42
|
+
submissions: [
|
|
43
|
+
{
|
|
44
|
+
id: 's1',
|
|
45
|
+
status: 'returned',
|
|
46
|
+
submittedDateTime: '2026-09-22T10:00:00Z',
|
|
47
|
+
returnedDateTime: '2026-09-24T09:00:00Z',
|
|
48
|
+
outcomes: [
|
|
49
|
+
{ '@odata.type': '#...educationPointsOutcome', points: { points: 4 }, publishedPoints: { points: 4 } },
|
|
50
|
+
{ '@odata.type': '#...educationFeedbackOutcome', feedback: { text: { content: 'Good work' } } },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}));
|
|
57
|
+
const result = await listMyAssignments();
|
|
58
|
+
expect(result.ok).toBe(true);
|
|
59
|
+
if (!result.ok)
|
|
60
|
+
return;
|
|
61
|
+
expect(result.value.returned).toBe(1);
|
|
62
|
+
const a = result.value.assignments[0];
|
|
63
|
+
expect(a).toMatchObject({
|
|
64
|
+
id: 'a1',
|
|
65
|
+
classId: 'c1',
|
|
66
|
+
displayName: 'Causes of climate change',
|
|
67
|
+
maxPoints: 5,
|
|
68
|
+
instructions: '<p>Read chapter 3</p>',
|
|
69
|
+
});
|
|
70
|
+
expect(a.submission).toMatchObject({
|
|
71
|
+
id: 's1',
|
|
72
|
+
status: 'returned',
|
|
73
|
+
outcome: { points: 4, publishedPoints: 4, feedback: 'Good work' },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it("builds the 'active' filter and default query params by default", async () => {
|
|
77
|
+
mockHttp.mockResolvedValue(httpOk({ value: [] }));
|
|
78
|
+
await listMyAssignments();
|
|
79
|
+
const url = mockHttp.mock.calls[0][0];
|
|
80
|
+
const qs = new URL(url).searchParams;
|
|
81
|
+
expect(qs.get('$filter')).toBe("status eq microsoft.education.assignments.api.educationAssignmentStatus'assigned' and isCompleted eq false");
|
|
82
|
+
expect(qs.get('$top')).toBe('25');
|
|
83
|
+
expect(qs.get('$orderby')).toBe('dueDateTime asc,createdDateTime asc');
|
|
84
|
+
expect(qs.get('$expand')).toBe('submissions($expand=outcomes)');
|
|
85
|
+
});
|
|
86
|
+
it("omits the filter for statusFilter 'all' and honours top", async () => {
|
|
87
|
+
mockHttp.mockResolvedValue(httpOk({ value: [] }));
|
|
88
|
+
await listMyAssignments({ statusFilter: 'all', top: 10 });
|
|
89
|
+
const qs = new URL(mockHttp.mock.calls[0][0]).searchParams;
|
|
90
|
+
expect(qs.has('$filter')).toBe(false);
|
|
91
|
+
expect(qs.get('$top')).toBe('10');
|
|
92
|
+
});
|
|
93
|
+
it("builds the 'completed' filter", async () => {
|
|
94
|
+
mockHttp.mockResolvedValue(httpOk({ value: [] }));
|
|
95
|
+
await listMyAssignments({ statusFilter: 'completed' });
|
|
96
|
+
const qs = new URL(mockHttp.mock.calls[0][0]).searchParams;
|
|
97
|
+
expect(qs.get('$filter')).toBe('isCompleted eq true');
|
|
98
|
+
});
|
|
99
|
+
it('handles assignments with no submissions or outcomes', async () => {
|
|
100
|
+
mockHttp.mockResolvedValue(httpOk({
|
|
101
|
+
value: [{ id: 'a1', classId: 'c1', displayName: 'No submission yet' }],
|
|
102
|
+
}));
|
|
103
|
+
const result = await listMyAssignments();
|
|
104
|
+
expect(result.ok).toBe(true);
|
|
105
|
+
if (!result.ok)
|
|
106
|
+
return;
|
|
107
|
+
expect(result.value.assignments[0].submission).toBeUndefined();
|
|
108
|
+
expect(result.value.assignments[0].maxPoints).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
it('propagates an auth error from the token guard without calling http', async () => {
|
|
111
|
+
mockToken.mockResolvedValue(err(createError(ErrorCode.AUTH_REQUIRED, 'no token')));
|
|
112
|
+
const result = await listMyAssignments();
|
|
113
|
+
expect(result.ok).toBe(false);
|
|
114
|
+
expect(mockHttp).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('getAssignment', () => {
|
|
118
|
+
it('reads grades from the submissions collection, not the assignment expand', async () => {
|
|
119
|
+
mockHttp.mockImplementation(async (url) => String(url).includes('/submissions')
|
|
120
|
+
? httpOk({ value: [{ id: 's1', status: 'returned', outcomes: [
|
|
121
|
+
{ '@odata.type': '#microsoft.education.assignments.api.educationPointsOutcome', points: { points: 8 } },
|
|
122
|
+
] }] })
|
|
123
|
+
: httpOk({ id: 'a1', classId: 'c1', displayName: 'Detail', submissions: [{ id: 's1', status: 'returned', outcomes: [] }] }));
|
|
124
|
+
const result = await getAssignment('c1', 'a1');
|
|
125
|
+
expect(result).toMatchObject({ ok: true, value: { submission: { id: 's1', status: 'returned', outcome: { points: 8 } } } });
|
|
126
|
+
const urls = mockHttp.mock.calls.map(call => new URL(call[0]));
|
|
127
|
+
expect(urls.map(u => u.pathname)).toEqual([
|
|
128
|
+
'/api/v1.0/edu/classes/c1/assignments/a1',
|
|
129
|
+
'/api/v1.0/edu/classes/c1/assignments/a1/submissions',
|
|
130
|
+
]);
|
|
131
|
+
expect(urls[1].searchParams.get('$expand')).toBe('outcomes,resources,submittedResources');
|
|
132
|
+
});
|
|
133
|
+
it('returns teacher attachments and the student\'s own files', async () => {
|
|
134
|
+
const file = (type, name, id) => ({
|
|
135
|
+
id: `r-${id}`, resource: { '@odata.type': `#microsoft.education.assignments.api.education${type}Resource`, displayName: name,
|
|
136
|
+
fileUrl: `https://graph.microsoft.com/v1.0/drives/b!d/items/${id}` }
|
|
137
|
+
});
|
|
138
|
+
mockHttp.mockImplementation(async (url) => String(url).includes('/submissions')
|
|
139
|
+
? httpOk({ value: [{ id: 's1', status: 'working', resources: [file('Word', 'My copy.docx', 'mine')], submittedResources: [] }] })
|
|
140
|
+
: httpOk({ id: 'a1', classId: 'c1', displayName: 'Detail', resources: [
|
|
141
|
+
{ ...file('PowerPoint', 'Lesson.pptx', 'ppt'), distributeForStudentWork: false },
|
|
142
|
+
{ ...file('Word', 'Worksheet.docx', 'doc'), distributeForStudentWork: true },
|
|
143
|
+
{ id: 'r-form', resource: { '@odata.type': '#microsoft.education.assignments.api.educationFormResource', displayName: 'Quiz', viewUrl: 'https://forms.office.com/x' } },
|
|
144
|
+
] }));
|
|
145
|
+
const result = await getAssignment('c1', 'a1');
|
|
146
|
+
expect(result).toMatchObject({ ok: true, value: {
|
|
147
|
+
attachments: [
|
|
148
|
+
{ id: 'r-ppt', name: 'Lesson.pptx', type: 'powerpoint', fileUrl: 'https://graph.microsoft.com/v1.0/drives/b!d/items/ppt' },
|
|
149
|
+
{ name: 'Worksheet.docx', type: 'word', copiedForEachStudent: true },
|
|
150
|
+
{ name: 'Quiz', type: 'form', url: 'https://forms.office.com/x', fileUrl: undefined },
|
|
151
|
+
],
|
|
152
|
+
submission: { attachments: [{ name: 'My copy.docx', type: 'word', fileUrl: 'https://graph.microsoft.com/v1.0/drives/b!d/items/mine' }], submittedAttachments: [] },
|
|
153
|
+
} });
|
|
154
|
+
const urls = mockHttp.mock.calls.map(call => new URL(call[0]));
|
|
155
|
+
expect(urls[0].searchParams.get('$expand')).toBe('resources');
|
|
156
|
+
expect(urls[1].searchParams.get('$expand')).toBe('outcomes,resources,submittedResources');
|
|
157
|
+
});
|
|
158
|
+
it('fails rather than hiding the submission when it cannot be read', async () => {
|
|
159
|
+
mockHttp.mockImplementation(async (url) => String(url).includes('/submissions')
|
|
160
|
+
? err(createError(ErrorCode.API_ERROR, 'HTTP 500'))
|
|
161
|
+
: httpOk({ id: 'a1', classId: 'c1', displayName: 'Detail' }));
|
|
162
|
+
expect(await getAssignment('c1', 'a1')).toMatchObject({ ok: false, error: { code: ErrorCode.API_ERROR } });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe('actOnSubmission', () => {
|
|
166
|
+
it('POSTs to the submit endpoint for submit', async () => {
|
|
167
|
+
mockHttp.mockResolvedValue(httpOk({ id: 's1', status: 'submitted' }));
|
|
168
|
+
const result = await actOnSubmission('submit', 'c1', 'a1', 's1');
|
|
169
|
+
expect(result.ok).toBe(true);
|
|
170
|
+
if (!result.ok)
|
|
171
|
+
return;
|
|
172
|
+
expect(result.value).toMatchObject({ action: 'submit', submissionId: 's1', status: 'submitted' });
|
|
173
|
+
const [url, opts] = mockHttp.mock.calls[0];
|
|
174
|
+
expect(url).toContain('/submissions/s1/submit');
|
|
175
|
+
expect(opts.method).toBe('POST');
|
|
176
|
+
});
|
|
177
|
+
it('POSTs to the unsubmit endpoint for unsubmit', async () => {
|
|
178
|
+
mockHttp.mockResolvedValue(httpOk({ id: 's1', status: 'working' }));
|
|
179
|
+
await actOnSubmission('unsubmit', 'c1', 'a1', 's1');
|
|
180
|
+
const [url, opts] = mockHttp.mock.calls[0];
|
|
181
|
+
expect(url).toContain('/submissions/s1/unsubmit');
|
|
182
|
+
expect(opts.method).toBe('POST');
|
|
183
|
+
});
|
|
184
|
+
it('PATCHes the view endpoint for view', async () => {
|
|
185
|
+
mockHttp.mockResolvedValue(httpOk({ id: 's1', status: 'working' }));
|
|
186
|
+
await actOnSubmission('view', 'c1', 'a1', 's1');
|
|
187
|
+
const [url, opts] = mockHttp.mock.calls[0];
|
|
188
|
+
expect(url).toContain('/submissions/s1/view');
|
|
189
|
+
expect(opts.method).toBe('PATCH');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('Assignments regression cases', () => {
|
|
193
|
+
it('exposes and follows server pagination without rebuilding its cursor', async () => {
|
|
194
|
+
const nextLink = 'https://assignments.edu.cloud.microsoft/api/v1.0/edu/me/work?$skiptoken=opaque&$top=25';
|
|
195
|
+
mockHttp.mockResolvedValueOnce(httpOk({ value: [{ id: 'a1' }], '@odata.nextLink': nextLink }));
|
|
196
|
+
const first = await listMyAssignments();
|
|
197
|
+
expect(first).toMatchObject({ ok: true, value: { nextLink: new URL(nextLink).toString() } });
|
|
198
|
+
mockHttp.mockResolvedValueOnce(httpOk({ value: [{ id: 'a2' }] }));
|
|
199
|
+
expect(await listMyAssignments({ nextLink })).toMatchObject({ ok: true, value: { assignments: [{ id: 'a2' }] } });
|
|
200
|
+
expect(mockHttp.mock.calls[1][0]).toBe(new URL(nextLink).toString());
|
|
201
|
+
});
|
|
202
|
+
it('offers an offset continuation for a full page without nextLink', async () => {
|
|
203
|
+
mockHttp.mockResolvedValue(httpOk({ value: [{ id: 'a1' }] }));
|
|
204
|
+
const result = await listMyAssignments({ top: 1 });
|
|
205
|
+
expect(result.ok).toBe(true);
|
|
206
|
+
if (result.ok)
|
|
207
|
+
expect(new URL(result.value.nextLink).searchParams.get('$skip')).toBe('1');
|
|
208
|
+
});
|
|
209
|
+
it('stops instead of looping when the service ignores a synthesised $skip', async () => {
|
|
210
|
+
mockHttp.mockResolvedValue(httpOk({ value: [{ id: 'a1' }] }));
|
|
211
|
+
const first = await listMyAssignments({ top: 1 });
|
|
212
|
+
if (!first.ok)
|
|
213
|
+
throw new Error('expected first page');
|
|
214
|
+
expect(await listMyAssignments({ nextLink: first.value.nextLink })).toMatchObject({
|
|
215
|
+
ok: false, error: { code: ErrorCode.API_ERROR, retryable: false },
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
it('keeps offering offset pages while $skip advances the results', async () => {
|
|
219
|
+
mockHttp.mockResolvedValueOnce(httpOk({ value: [{ id: 'a1' }] }));
|
|
220
|
+
const first = await listMyAssignments({ top: 1 });
|
|
221
|
+
if (!first.ok)
|
|
222
|
+
throw new Error('expected first page');
|
|
223
|
+
mockHttp.mockResolvedValueOnce(httpOk({ value: [{ id: 'a2' }] }));
|
|
224
|
+
const second = await listMyAssignments({ nextLink: first.value.nextLink });
|
|
225
|
+
expect(second).toMatchObject({ ok: true, value: { assignments: [{ id: 'a2' }] } });
|
|
226
|
+
if (second.ok)
|
|
227
|
+
expect(new URL(second.value.nextLink).searchParams.get('$skip')).toBe('2');
|
|
228
|
+
});
|
|
229
|
+
it('reports the status slice the continuation actually queries', async () => {
|
|
230
|
+
mockHttp.mockResolvedValue(httpOk({ value: [] }));
|
|
231
|
+
const first = await listMyAssignments({ statusFilter: 'completed', top: 1 });
|
|
232
|
+
const link = new URL(mockHttp.mock.calls[0][0]);
|
|
233
|
+
link.searchParams.set('$skiptoken', 'opaque');
|
|
234
|
+
expect(first).toMatchObject({ ok: true, value: { statusFilter: 'completed' } });
|
|
235
|
+
expect(await listMyAssignments({ statusFilter: 'active', nextLink: link.toString() }))
|
|
236
|
+
.toMatchObject({ ok: true, value: { statusFilter: 'completed' } });
|
|
237
|
+
});
|
|
238
|
+
it.each([
|
|
239
|
+
'https://evil.example/api/v1.0/edu/me/work',
|
|
240
|
+
'https://assignments.edu.cloud.microsoft/api/v1.0/edu/classes/c1',
|
|
241
|
+
'https://assignments.edu.cloud.microsoft/api/v1.0/edu/me/work?$top=2.5',
|
|
242
|
+
])('rejects unsafe continuations: %s', async (nextLink) => {
|
|
243
|
+
expect(await listMyAssignments({ nextLink })).toMatchObject({ ok: false, error: { code: ErrorCode.INVALID_INPUT } });
|
|
244
|
+
expect(mockHttp).not.toHaveBeenCalled();
|
|
245
|
+
expect(mockToken).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
it.each(['../other', 's1?x=y', '.', '%2e%2e', 's1#fragment'])('rejects malformed IDs: %s', async (id) => {
|
|
248
|
+
expect(await actOnSubmission('submit', 'c1', 'a1', id)).toMatchObject({ ok: false, error: { code: ErrorCode.INVALID_INPUT } });
|
|
249
|
+
expect(await getAssignment(id, 'a1')).toMatchObject({ ok: false });
|
|
250
|
+
expect(mockHttp).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
it('rejects fractional page sizes before auth or HTTP', async () => {
|
|
253
|
+
expect(await listMyAssignments({ top: 2.5 })).toMatchObject({ ok: false, error: { code: ErrorCode.INVALID_INPUT } });
|
|
254
|
+
expect(mockToken).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
it('invalidates a rejected token on a 401 without triggering Teams re-login', async () => {
|
|
257
|
+
mockHttp.mockResolvedValue(err(createError(ErrorCode.AUTH_EXPIRED, 'HTTP 401')));
|
|
258
|
+
expect(await listMyAssignments()).toMatchObject({ ok: false, error: { code: ErrorCode.API_ERROR, retryable: true } });
|
|
259
|
+
expect(invalidateAccessToken).toHaveBeenCalledWith('fake-token');
|
|
260
|
+
});
|
|
261
|
+
it('does not trigger login for permission failures', async () => {
|
|
262
|
+
mockHttp.mockResolvedValue(err(createError(ErrorCode.AUTH_REQUIRED, 'HTTP 403')));
|
|
263
|
+
expect(await listMyAssignments()).toMatchObject({ ok: false, error: { code: ErrorCode.ACCESS_DENIED, retryable: false } });
|
|
264
|
+
expect(invalidateAccessToken).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
it('verifies turn-in with a separate GET and never replays the POST', async () => {
|
|
267
|
+
mockHttp.mockResolvedValueOnce(httpOk('')).mockResolvedValueOnce(httpOk({ id: 's1', status: 'submitted' }));
|
|
268
|
+
expect(await actOnSubmission('submit', 'c1', 'a1', 's1')).toMatchObject({ ok: true, value: { status: 'submitted' } });
|
|
269
|
+
expect(mockHttp.mock.calls[0][1]).toMatchObject({ method: 'POST', maxRetries: 1 });
|
|
270
|
+
expect(mockHttp.mock.calls[1][0]).toMatch(/\/submissions\/s1$/);
|
|
271
|
+
expect(mockHttp.mock.calls[1][1]).toMatchObject({ method: 'GET' });
|
|
272
|
+
});
|
|
273
|
+
it.each(['<html>Login</html>', { id: 's1', status: 'working' }, { id: 'other', status: 'submitted' }])('does not report unconfirmed turn-in as success', async (data) => {
|
|
274
|
+
mockHttp.mockResolvedValueOnce(httpOk({ id: 's1', status: 'submitted' })).mockResolvedValueOnce(httpOk(data));
|
|
275
|
+
expect(await actOnSubmission('submit', 'c1', 'a1', 's1')).toMatchObject({ ok: false, error: { code: ErrorCode.API_ERROR, retryable: false } });
|
|
276
|
+
});
|
|
277
|
+
it('does not trigger automatic mutation replay if verification needs authentication', async () => {
|
|
278
|
+
mockHttp.mockResolvedValueOnce(httpOk({ id: 's1', status: 'submitted' }))
|
|
279
|
+
.mockResolvedValueOnce(err(createError(ErrorCode.AUTH_EXPIRED, 'HTTP 401')));
|
|
280
|
+
expect(await actOnSubmission('submit', 'c1', 'a1', 's1')).toMatchObject({ ok: false, error: { code: ErrorCode.API_ERROR, retryable: false } });
|
|
281
|
+
expect(invalidateAccessToken).toHaveBeenCalledWith('fake-token');
|
|
282
|
+
});
|
|
283
|
+
it('does not report an HTML view response as success', async () => {
|
|
284
|
+
mockHttp.mockResolvedValue(httpOk('<html>Login</html>'));
|
|
285
|
+
expect(await actOnSubmission('view', 'c1', 'a1', 's1')).toMatchObject({ ok: false });
|
|
286
|
+
});
|
|
287
|
+
it('does not surface a 401 on a mutation as an auth error the server would replay', async () => {
|
|
288
|
+
mockHttp.mockResolvedValue(err(createError(ErrorCode.AUTH_EXPIRED, 'HTTP 401')));
|
|
289
|
+
expect(await actOnSubmission('submit', 'c1', 'a1', 's1')).toMatchObject({ ok: false, error: { code: ErrorCode.API_ERROR } });
|
|
290
|
+
expect(mockHttp).toHaveBeenCalledTimes(1);
|
|
291
|
+
});
|
|
292
|
+
it('marks a timed-out mutation as uncertain rather than inviting a retry', async () => {
|
|
293
|
+
mockHttp.mockResolvedValue(err(createError(ErrorCode.TIMEOUT, 'Timed out', { retryable: true })));
|
|
294
|
+
expect(await actOnSubmission('submit', 'c1', 'a1', 's1')).toMatchObject({ ok: false, error: { code: ErrorCode.TIMEOUT, retryable: false } });
|
|
295
|
+
expect(mockHttp).toHaveBeenCalledTimes(1);
|
|
296
|
+
});
|
|
297
|
+
it('does not invent another cursor page after the server ends pagination', async () => {
|
|
298
|
+
mockHttp.mockResolvedValue(httpOk({ value: [{ id: 'a1' }] }));
|
|
299
|
+
const result = await listMyAssignments({ nextLink: 'https://assignments.edu.cloud.microsoft/api/v1.0/edu/me/work?$skiptoken=last&$top=1' });
|
|
300
|
+
expect(result).toMatchObject({ ok: true, value: { nextLink: undefined } });
|
|
301
|
+
});
|
|
302
|
+
});
|
package/dist/api/csa-api.js
CHANGED
|
@@ -20,8 +20,8 @@ export async function getFavorites() {
|
|
|
20
20
|
return authResult;
|
|
21
21
|
}
|
|
22
22
|
const { auth, csaToken } = authResult.value;
|
|
23
|
-
const {
|
|
24
|
-
const url = CSA_API.conversationFolders(
|
|
23
|
+
const { csaRegion, baseUrl } = getApiConfig();
|
|
24
|
+
const url = CSA_API.conversationFolders(csaRegion, baseUrl);
|
|
25
25
|
const response = await httpRequest(url, {
|
|
26
26
|
method: 'GET',
|
|
27
27
|
headers: getCsaHeaders(auth.skypeToken, csaToken, baseUrl),
|
|
@@ -94,7 +94,7 @@ async function modifyFavorite(conversationId, action) {
|
|
|
94
94
|
return authResult;
|
|
95
95
|
}
|
|
96
96
|
const { auth, csaToken } = authResult.value;
|
|
97
|
-
const {
|
|
97
|
+
const { csaRegion, baseUrl } = getApiConfig();
|
|
98
98
|
// Get current folder state
|
|
99
99
|
const currentState = await getFavorites();
|
|
100
100
|
if (!currentState.ok) {
|
|
@@ -103,7 +103,7 @@ async function modifyFavorite(conversationId, action) {
|
|
|
103
103
|
if (!currentState.value.folderId) {
|
|
104
104
|
return err(createError(ErrorCode.NOT_FOUND, 'Could not find Favorites folder'));
|
|
105
105
|
}
|
|
106
|
-
const url = CSA_API.conversationFolders(
|
|
106
|
+
const url = CSA_API.conversationFolders(csaRegion, baseUrl);
|
|
107
107
|
const response = await httpRequest(url, {
|
|
108
108
|
method: 'POST',
|
|
109
109
|
headers: getCsaHeaders(auth.skypeToken, csaToken, baseUrl),
|
|
@@ -135,8 +135,8 @@ export async function getMyTeamsAndChannels() {
|
|
|
135
135
|
return authResult;
|
|
136
136
|
}
|
|
137
137
|
const { auth, csaToken } = authResult.value;
|
|
138
|
-
const {
|
|
139
|
-
const url = CSA_API.teamsList(
|
|
138
|
+
const { csaRegion, baseUrl } = getApiConfig();
|
|
139
|
+
const url = CSA_API.teamsList(csaRegion, baseUrl);
|
|
140
140
|
const response = await httpRequest(url, {
|
|
141
141
|
method: 'GET',
|
|
142
142
|
headers: getCsaHeaders(auth.skypeToken, csaToken, baseUrl),
|
|
@@ -156,8 +156,8 @@ export async function getCustomEmojis() {
|
|
|
156
156
|
return authResult;
|
|
157
157
|
}
|
|
158
158
|
const { auth, csaToken } = authResult.value;
|
|
159
|
-
const {
|
|
160
|
-
const url = CSA_API.customEmojis(
|
|
159
|
+
const { csaRegion, baseUrl } = getApiConfig();
|
|
160
|
+
const url = CSA_API.customEmojis(csaRegion, baseUrl);
|
|
161
161
|
const response = await httpRequest(url, {
|
|
162
162
|
method: 'GET',
|
|
163
163
|
headers: getCsaHeaders(auth.skypeToken, csaToken, baseUrl),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File downloads via Microsoft Graph.
|
|
3
|
+
*
|
|
4
|
+
* Accepts either form Teams hands out:
|
|
5
|
+
* - Graph drive-item URLs (`https://graph.microsoft.com/v1.0/drives/{driveId}/items/{itemId}`),
|
|
6
|
+
* used by assignment attachments;
|
|
7
|
+
* - SharePoint/OneDrive web URLs (direct file paths, `Doc.aspx` viewer links,
|
|
8
|
+
* sharing links), used by shared chat and channel files. These are resolved
|
|
9
|
+
* through Graph's shares API (`/shares/u!{base64url}/driveItem`).
|
|
10
|
+
*
|
|
11
|
+
* As the Teams Assignments client does, we read the item's
|
|
12
|
+
* `@microsoft.graph.downloadUrl` (a short-lived, pre-authenticated SharePoint
|
|
13
|
+
* link) and stream it to disk.
|
|
14
|
+
*
|
|
15
|
+
* Auth: the Teams client's own Graph token (`Files.ReadWrite.All`), obtained on
|
|
16
|
+
* demand via `requireGraphTokenAsync()`. Like Assignments, it is optional: a
|
|
17
|
+
* failure never surfaces as AUTH_REQUIRED/AUTH_EXPIRED, so it can't trigger the
|
|
18
|
+
* server's auto-login.
|
|
19
|
+
*/
|
|
20
|
+
import { type Result } from '../types/result.js';
|
|
21
|
+
export interface DownloadedFile {
|
|
22
|
+
name: string;
|
|
23
|
+
outputPath: string;
|
|
24
|
+
size: number;
|
|
25
|
+
contentType: string;
|
|
26
|
+
sha256: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Downloads a file to `outputPath` (absolute; parent must exist; never
|
|
30
|
+
* overwrites). `url` is a Graph drive-item URL or a SharePoint/OneDrive web
|
|
31
|
+
* URL. Streams to disk, verifies the byte count against Graph's reported size,
|
|
32
|
+
* and removes the partial file on any failure.
|
|
33
|
+
*/
|
|
34
|
+
export declare function downloadFile(url: string, outputPath: string): Promise<Result<DownloadedFile>>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File downloads via Microsoft Graph.
|
|
3
|
+
*
|
|
4
|
+
* Accepts either form Teams hands out:
|
|
5
|
+
* - Graph drive-item URLs (`https://graph.microsoft.com/v1.0/drives/{driveId}/items/{itemId}`),
|
|
6
|
+
* used by assignment attachments;
|
|
7
|
+
* - SharePoint/OneDrive web URLs (direct file paths, `Doc.aspx` viewer links,
|
|
8
|
+
* sharing links), used by shared chat and channel files. These are resolved
|
|
9
|
+
* through Graph's shares API (`/shares/u!{base64url}/driveItem`).
|
|
10
|
+
*
|
|
11
|
+
* As the Teams Assignments client does, we read the item's
|
|
12
|
+
* `@microsoft.graph.downloadUrl` (a short-lived, pre-authenticated SharePoint
|
|
13
|
+
* link) and stream it to disk.
|
|
14
|
+
*
|
|
15
|
+
* Auth: the Teams client's own Graph token (`Files.ReadWrite.All`), obtained on
|
|
16
|
+
* demand via `requireGraphTokenAsync()`. Like Assignments, it is optional: a
|
|
17
|
+
* failure never surfaces as AUTH_REQUIRED/AUTH_EXPIRED, so it can't trigger the
|
|
18
|
+
* server's auto-login.
|
|
19
|
+
*/
|
|
20
|
+
import { open, unlink } from 'node:fs/promises';
|
|
21
|
+
import { isAbsolute } from 'node:path';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { Readable, Transform } from 'node:stream';
|
|
24
|
+
import { pipeline } from 'node:stream/promises';
|
|
25
|
+
import { httpRequest } from '../utils/http.js';
|
|
26
|
+
import { ok, err } from '../types/result.js';
|
|
27
|
+
import { ErrorCode, createError } from '../types/errors.js';
|
|
28
|
+
import { requireGraphTokenAsync } from '../utils/auth-guards.js';
|
|
29
|
+
import { invalidateAccessToken } from '../auth/token-extractor.js';
|
|
30
|
+
import { DOWNLOAD_INACTIVITY_TIMEOUT_MS } from '../constants.js';
|
|
31
|
+
/** Graph drive items are requested as-is; the Graph token only ever goes to Graph. */
|
|
32
|
+
const DRIVE_ITEM_URL = /^https:\/\/graph\.microsoft\.com\/v1\.0\/drives\/[A-Za-z0-9!_-]+\/items\/[A-Za-z0-9!_-]+$/;
|
|
33
|
+
/** SharePoint/OneDrive hosts: web URLs to resolve, and the only download-link hosts. */
|
|
34
|
+
const SHAREPOINT_HOST = /\.sharepoint(?:-mil)?\.(?:com|us|de|cn)$/;
|
|
35
|
+
/**
|
|
36
|
+
* Map an accepted URL to the Graph drive-item URL to request, or null. A
|
|
37
|
+
* SharePoint URL is only ever sent to Graph, base64url-encoded as a share ID.
|
|
38
|
+
*/
|
|
39
|
+
function toDriveItemUrl(url) {
|
|
40
|
+
if (DRIVE_ITEM_URL.test(url))
|
|
41
|
+
return url;
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(url);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || !SHAREPOINT_HOST.test(parsed.hostname)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return `https://graph.microsoft.com/v1.0/shares/u!${Buffer.from(url).toString('base64url')}/driveItem`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Downloads a file to `outputPath` (absolute; parent must exist; never
|
|
56
|
+
* overwrites). `url` is a Graph drive-item URL or a SharePoint/OneDrive web
|
|
57
|
+
* URL. Streams to disk, verifies the byte count against Graph's reported size,
|
|
58
|
+
* and removes the partial file on any failure.
|
|
59
|
+
*/
|
|
60
|
+
export async function downloadFile(url, outputPath) {
|
|
61
|
+
const itemUrl = toDriveItemUrl(url);
|
|
62
|
+
if (!itemUrl) {
|
|
63
|
+
return err(createError(ErrorCode.INVALID_INPUT, 'url must be a Microsoft Graph drive-item URL (https://graph.microsoft.com/v1.0/drives/{id}/items/{id}) or an HTTPS SharePoint/OneDrive file URL (*.sharepoint.com)'));
|
|
64
|
+
}
|
|
65
|
+
if (!isAbsolute(outputPath)) {
|
|
66
|
+
return err(createError(ErrorCode.INVALID_INPUT, 'outputPath must be an absolute file path'));
|
|
67
|
+
}
|
|
68
|
+
const tokenResult = await requireGraphTokenAsync();
|
|
69
|
+
if (!tokenResult.ok)
|
|
70
|
+
return tokenResult;
|
|
71
|
+
const params = new URLSearchParams({ '$select': 'name,size,file,currentUserRole,content.downloadUrl' });
|
|
72
|
+
const meta = await httpRequest(`${itemUrl}?${params.toString()}`, {
|
|
73
|
+
method: 'GET',
|
|
74
|
+
headers: { Authorization: `Bearer ${tokenResult.value}`, Accept: 'application/json' },
|
|
75
|
+
});
|
|
76
|
+
if (!meta.ok) {
|
|
77
|
+
if (meta.error.code === ErrorCode.AUTH_EXPIRED) {
|
|
78
|
+
invalidateAccessToken(tokenResult.value);
|
|
79
|
+
return err(createError(ErrorCode.API_ERROR, 'Microsoft Graph rejected its access token; it has been discarded, so a retry will request a new one.', { retryable: true }));
|
|
80
|
+
}
|
|
81
|
+
if (meta.error.code === ErrorCode.AUTH_REQUIRED) {
|
|
82
|
+
return err(createError(ErrorCode.ACCESS_DENIED, `You don't have access to this file: ${meta.error.message}`, { retryable: false }));
|
|
83
|
+
}
|
|
84
|
+
if (meta.error.code === ErrorCode.NOT_FOUND) {
|
|
85
|
+
return err(createError(ErrorCode.NOT_FOUND, 'File not found. It may have been moved or deleted, or the URL is not a file you can access.', { retryable: false }));
|
|
86
|
+
}
|
|
87
|
+
return meta;
|
|
88
|
+
}
|
|
89
|
+
const item = meta.value.data;
|
|
90
|
+
if (!item?.file) {
|
|
91
|
+
return err(createError(ErrorCode.INVALID_INPUT, 'This item is not a file (it may be a folder)', { retryable: false }));
|
|
92
|
+
}
|
|
93
|
+
if (item.currentUserRole?.blocksDownload) {
|
|
94
|
+
return err(createError(ErrorCode.ACCESS_DENIED, 'The owner has blocked downloading this file', { retryable: false }));
|
|
95
|
+
}
|
|
96
|
+
const downloadUrl = item['@microsoft.graph.downloadUrl'];
|
|
97
|
+
let downloadHost = '';
|
|
98
|
+
try {
|
|
99
|
+
downloadHost = downloadUrl ? new URL(downloadUrl).hostname : '';
|
|
100
|
+
}
|
|
101
|
+
catch { /* handled below */ }
|
|
102
|
+
if (!downloadUrl?.startsWith('https://') || !SHAREPOINT_HOST.test(downloadHost)) {
|
|
103
|
+
return err(createError(ErrorCode.API_ERROR, 'Microsoft Graph did not return a SharePoint download link for this file', { retryable: false }));
|
|
104
|
+
}
|
|
105
|
+
const saved = await streamToFile(downloadUrl, outputPath, item.size);
|
|
106
|
+
if (!saved.ok)
|
|
107
|
+
return saved;
|
|
108
|
+
return ok({
|
|
109
|
+
name: item.name ?? '',
|
|
110
|
+
outputPath,
|
|
111
|
+
size: saved.value.size,
|
|
112
|
+
contentType: saved.value.contentType ?? item.file.mimeType ?? 'application/octet-stream',
|
|
113
|
+
sha256: saved.value.sha256,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Stream a pre-authenticated link to a new file, with an inactivity timeout. */
|
|
117
|
+
async function streamToFile(url, outputPath, expectedSize) {
|
|
118
|
+
let file;
|
|
119
|
+
try {
|
|
120
|
+
// Exclusive creation: never overwrite an existing file or follow a symlink.
|
|
121
|
+
file = await open(outputPath, 'wx', 0o600);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return err(createError(ErrorCode.INVALID_INPUT, `Could not create ${outputPath}: ${error instanceof Error ? error.message : String(error)}`, { retryable: false, suggestions: ['The parent directory must exist and the file must not already exist'] }));
|
|
125
|
+
}
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
let timer = setTimeout(() => controller.abort(), DOWNLOAD_INACTIVITY_TIMEOUT_MS);
|
|
128
|
+
const touch = () => {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
timer = setTimeout(() => controller.abort(), DOWNLOAD_INACTIVITY_TIMEOUT_MS);
|
|
131
|
+
};
|
|
132
|
+
let complete = false;
|
|
133
|
+
try {
|
|
134
|
+
// No Authorization header: the link is pre-authenticated. Refuse redirects
|
|
135
|
+
// so a sign-in page can never be saved as file contents.
|
|
136
|
+
const response = await fetch(url, { redirect: 'error', signal: controller.signal });
|
|
137
|
+
if (!response.ok || !response.body) {
|
|
138
|
+
return err(createError(ErrorCode.API_ERROR, `File download failed: HTTP ${response.status}`, { retryable: response.status >= 500 }));
|
|
139
|
+
}
|
|
140
|
+
const hash = createHash('sha256');
|
|
141
|
+
let size = 0;
|
|
142
|
+
const meter = new Transform({
|
|
143
|
+
transform(chunk, _encoding, callback) {
|
|
144
|
+
touch();
|
|
145
|
+
hash.update(chunk);
|
|
146
|
+
size += chunk.length;
|
|
147
|
+
callback(null, chunk);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
await pipeline(Readable.fromWeb(response.body), meter, file.createWriteStream());
|
|
151
|
+
if (expectedSize !== undefined && size !== expectedSize) {
|
|
152
|
+
return err(createError(ErrorCode.API_ERROR, `Download was incomplete: received ${size} of ${expectedSize} bytes`, { retryable: true }));
|
|
153
|
+
}
|
|
154
|
+
complete = true;
|
|
155
|
+
return ok({ size, sha256: hash.digest('hex'), contentType: response.headers.get('content-type') ?? undefined });
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const timedOut = controller.signal.aborted;
|
|
159
|
+
return err(createError(timedOut ? ErrorCode.TIMEOUT : ErrorCode.NETWORK_ERROR, timedOut
|
|
160
|
+
? `File download stalled for ${DOWNLOAD_INACTIVITY_TIMEOUT_MS / 1000}s and was cancelled`
|
|
161
|
+
: `File download failed: ${error instanceof Error ? error.message : String(error)}`, { retryable: true }));
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
await file.close().catch(() => { });
|
|
166
|
+
if (!complete)
|
|
167
|
+
await unlink(outputPath).catch(() => { });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|