canvas 0.23.0__py3-none-any.whl → 0.25.0__py3-none-any.whl
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.
Potentially problematic release.
This version of canvas might be problematic. Click here for more details.
- {canvas-0.23.0.dist-info → canvas-0.25.0.dist-info}/METADATA +1 -1
- {canvas-0.23.0.dist-info → canvas-0.25.0.dist-info}/RECORD +27 -15
- canvas_cli/utils/validators/manifest_schema.py +21 -0
- canvas_generated/messages/effects_pb2.py +2 -2
- canvas_generated/messages/effects_pb2.pyi +2 -0
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +4 -0
- canvas_sdk/effects/simple_api.py +83 -0
- canvas_sdk/handlers/base.py +4 -0
- canvas_sdk/handlers/simple_api/__init__.py +22 -0
- canvas_sdk/handlers/simple_api/api.py +328 -0
- canvas_sdk/handlers/simple_api/exceptions.py +39 -0
- canvas_sdk/handlers/simple_api/security.py +184 -0
- canvas_sdk/tests/handlers/__init__.py +0 -0
- canvas_sdk/tests/handlers/test_simple_api.py +828 -0
- plugin_runner/plugin_runner.py +27 -0
- plugin_runner/sandbox.py +1 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/CANVAS_MANIFEST.json +47 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/README.md +11 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_simple_api/protocols/my_protocol.py +43 -0
- plugin_runner/tests/test_plugin_runner.py +71 -1
- protobufs/canvas_generated/messages/effects.proto +2 -0
- protobufs/canvas_generated/messages/events.proto +4 -0
- {canvas-0.23.0.dist-info → canvas-0.25.0.dist-info}/WHEEL +0 -0
- {canvas-0.23.0.dist-info → canvas-0.25.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from base64 import b64decode, b64encode
|
|
3
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import parse_qs
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from _pytest.fixtures import SubRequest
|
|
12
|
+
|
|
13
|
+
from canvas_sdk.effects.simple_api import (
|
|
14
|
+
Effect,
|
|
15
|
+
EffectType,
|
|
16
|
+
HTMLResponse,
|
|
17
|
+
JSONResponse,
|
|
18
|
+
PlainTextResponse,
|
|
19
|
+
Response,
|
|
20
|
+
)
|
|
21
|
+
from canvas_sdk.events import Event, EventRequest, EventType
|
|
22
|
+
from canvas_sdk.handlers.simple_api import api
|
|
23
|
+
from canvas_sdk.handlers.simple_api.api import Request, SimpleAPI, SimpleAPIBase, SimpleAPIRoute
|
|
24
|
+
from canvas_sdk.handlers.simple_api.security import (
|
|
25
|
+
APIKeyAuthMixin,
|
|
26
|
+
APIKeyCredentials,
|
|
27
|
+
AuthSchemeMixin,
|
|
28
|
+
BasicAuthMixin,
|
|
29
|
+
BasicCredentials,
|
|
30
|
+
BearerCredentials,
|
|
31
|
+
Credentials,
|
|
32
|
+
)
|
|
33
|
+
from plugin_runner.exceptions import PluginError
|
|
34
|
+
|
|
35
|
+
REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
|
36
|
+
HEADERS = {"Canvas-Plugins-Test-Header": "test header"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoAuthMixin:
|
|
40
|
+
"""Mixin to bypass authentication for tests that are not related to authentication."""
|
|
41
|
+
|
|
42
|
+
def authenticate(self, credentials: Credentials) -> bool:
|
|
43
|
+
"""Authenticate the request."""
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RouteNoAuth(NoAuthMixin, SimpleAPIRoute):
|
|
48
|
+
"""Route class that bypasses authentication."""
|
|
49
|
+
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class APINoAuth(NoAuthMixin, SimpleAPI):
|
|
54
|
+
"""API class that bypasses authentication."""
|
|
55
|
+
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_event(
|
|
60
|
+
event_type: EventType,
|
|
61
|
+
method: str,
|
|
62
|
+
path: str,
|
|
63
|
+
query_string: str | None = None,
|
|
64
|
+
body: bytes | None = None,
|
|
65
|
+
headers: Mapping[str, str] | None = None,
|
|
66
|
+
) -> Event:
|
|
67
|
+
"""Make a SIMPLE_API_REQUEST event suitable for testing."""
|
|
68
|
+
if event_type == EventType.SIMPLE_API_AUTHENTICATE:
|
|
69
|
+
body = b""
|
|
70
|
+
|
|
71
|
+
return Event(
|
|
72
|
+
event_request=EventRequest(
|
|
73
|
+
type=event_type,
|
|
74
|
+
target=None,
|
|
75
|
+
context=json.dumps(
|
|
76
|
+
{
|
|
77
|
+
"method": method,
|
|
78
|
+
"path": path,
|
|
79
|
+
"query_string": query_string or "",
|
|
80
|
+
"body": b64encode(body or b"").decode(),
|
|
81
|
+
"headers": headers or {},
|
|
82
|
+
},
|
|
83
|
+
indent=None,
|
|
84
|
+
separators=(",", ":"),
|
|
85
|
+
),
|
|
86
|
+
target_type=None,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def handle_request(
|
|
92
|
+
cls: type[SimpleAPIBase],
|
|
93
|
+
method: str,
|
|
94
|
+
path: str,
|
|
95
|
+
query_string: str | None = None,
|
|
96
|
+
body: bytes | None = None,
|
|
97
|
+
headers: Mapping[str, str] | None = None,
|
|
98
|
+
) -> list[Effect]:
|
|
99
|
+
"""
|
|
100
|
+
Mimic the two-pass request handling in home-app.
|
|
101
|
+
|
|
102
|
+
First, handle the authentication event, and if it succeeds, handle the request event.
|
|
103
|
+
"""
|
|
104
|
+
handler = cls(
|
|
105
|
+
make_event(EventType.SIMPLE_API_AUTHENTICATE, method, path, query_string, body, headers)
|
|
106
|
+
)
|
|
107
|
+
effects = handler.compute()
|
|
108
|
+
|
|
109
|
+
payload = json.loads(effects[0].payload)
|
|
110
|
+
if payload["status_code"] != HTTPStatus.OK:
|
|
111
|
+
return effects
|
|
112
|
+
|
|
113
|
+
handler = cls(
|
|
114
|
+
make_event(EventType.SIMPLE_API_REQUEST, method, path, query_string, body, headers)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return handler.compute()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize(
|
|
121
|
+
argnames="method,path,query_string,body,headers",
|
|
122
|
+
argvalues=[
|
|
123
|
+
("GET", "/route", "value1=a&value2=b", b"", {}),
|
|
124
|
+
(
|
|
125
|
+
"POST",
|
|
126
|
+
"/route",
|
|
127
|
+
"value1=a&value2=b",
|
|
128
|
+
b'{"message": "JSON request"}',
|
|
129
|
+
{"Content-Type": "application/json"},
|
|
130
|
+
),
|
|
131
|
+
(
|
|
132
|
+
"POST",
|
|
133
|
+
"/route",
|
|
134
|
+
"value1=a&value2=b",
|
|
135
|
+
b"plain text request",
|
|
136
|
+
{"Content-Type": "text/plain"},
|
|
137
|
+
),
|
|
138
|
+
("POST", "/route", "value1=a&value2=b", b"<html></html>", {"Content-Type": "text/html"}),
|
|
139
|
+
],
|
|
140
|
+
ids=["no body", "JSON", "plain text", "HTML"],
|
|
141
|
+
)
|
|
142
|
+
def test_request(
|
|
143
|
+
method: str,
|
|
144
|
+
path: str,
|
|
145
|
+
query_string: str | None,
|
|
146
|
+
body: bytes,
|
|
147
|
+
headers: Mapping[str, str] | None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Test the construction of a Request object and access to its attributes."""
|
|
150
|
+
request = Request(
|
|
151
|
+
make_event(EventType.SIMPLE_API_REQUEST, method, path, query_string, body, headers)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
assert request.method == method
|
|
155
|
+
assert request.path == path
|
|
156
|
+
assert request.query_string == query_string
|
|
157
|
+
assert request.body == body
|
|
158
|
+
assert request.headers == headers
|
|
159
|
+
|
|
160
|
+
assert request.query_params == parse_qs(query_string)
|
|
161
|
+
assert request.content_type == headers.get("Content-Type")
|
|
162
|
+
assert request.content_type == request.headers.get("CONTENT-TYPE")
|
|
163
|
+
|
|
164
|
+
if request.content_type:
|
|
165
|
+
if request.content_type == "application/json":
|
|
166
|
+
assert request.json() == json.loads(body)
|
|
167
|
+
elif request.content_type.startswith("text/"):
|
|
168
|
+
assert request.text() == body.decode()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def response_body(effects: Iterable[Effect]) -> bytes:
|
|
172
|
+
"""Given a list of effects, find the response object and return the body."""
|
|
173
|
+
for effect in effects:
|
|
174
|
+
if effect.type == EffectType.SIMPLE_API_RESPONSE:
|
|
175
|
+
payload = json.loads(effect.payload)
|
|
176
|
+
return b64decode(payload["body"].encode())
|
|
177
|
+
|
|
178
|
+
pytest.fail("No response effect was found in the list of effects")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def json_response_body(effects: Iterable[Effect]) -> Any:
|
|
182
|
+
"""Given a list of effects, find the response object and return the JSON body."""
|
|
183
|
+
return json.loads(response_body(effects))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.parametrize(argnames="method", argvalues=REQUEST_METHODS, ids=REQUEST_METHODS)
|
|
187
|
+
def test_request_routing_route(method: str) -> None:
|
|
188
|
+
"""Test request routing for SimpleAPIRoute plugins."""
|
|
189
|
+
|
|
190
|
+
class Route(RouteNoAuth):
|
|
191
|
+
PATH = "/route"
|
|
192
|
+
|
|
193
|
+
def get(self) -> list[Response | Effect]:
|
|
194
|
+
return [
|
|
195
|
+
JSONResponse(
|
|
196
|
+
{"method": "GET"},
|
|
197
|
+
)
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
def post(self) -> list[Response | Effect]:
|
|
201
|
+
return [
|
|
202
|
+
JSONResponse(
|
|
203
|
+
{"method": "POST"},
|
|
204
|
+
)
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
def put(self) -> list[Response | Effect]:
|
|
208
|
+
return [
|
|
209
|
+
JSONResponse(
|
|
210
|
+
{"method": "PUT"},
|
|
211
|
+
)
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def delete(self) -> list[Response | Effect]:
|
|
215
|
+
return [
|
|
216
|
+
JSONResponse(
|
|
217
|
+
{"method": "DELETE"},
|
|
218
|
+
)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
def patch(self) -> list[Response | Effect]:
|
|
222
|
+
return [
|
|
223
|
+
JSONResponse(
|
|
224
|
+
{"method": "PATCH"},
|
|
225
|
+
)
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
effects = handle_request(Route, method, path="/route")
|
|
229
|
+
body = json_response_body(effects)
|
|
230
|
+
|
|
231
|
+
assert body["method"] == method
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.mark.parametrize(
|
|
235
|
+
argnames="path", argvalues=["/route1", "/route2"], ids=["route1", "route2"]
|
|
236
|
+
)
|
|
237
|
+
@pytest.mark.parametrize(
|
|
238
|
+
argnames="prefix",
|
|
239
|
+
argvalues=["/prefix", "", None],
|
|
240
|
+
ids=["with prefix", "empty prefix", "no prefix"],
|
|
241
|
+
)
|
|
242
|
+
@pytest.mark.parametrize(
|
|
243
|
+
argnames="decorator,method",
|
|
244
|
+
argvalues=[
|
|
245
|
+
(api.get, "GET"),
|
|
246
|
+
(api.post, "POST"),
|
|
247
|
+
(api.put, "PUT"),
|
|
248
|
+
(api.delete, "DELETE"),
|
|
249
|
+
(api.patch, "PATCH"),
|
|
250
|
+
],
|
|
251
|
+
ids=REQUEST_METHODS,
|
|
252
|
+
)
|
|
253
|
+
def test_request_routing_api(
|
|
254
|
+
decorator: Callable[[str], Callable], method: str, prefix: str | None, path: str
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Test request routing for SimpleAPI plugins."""
|
|
257
|
+
|
|
258
|
+
class API(APINoAuth):
|
|
259
|
+
PREFIX = prefix
|
|
260
|
+
|
|
261
|
+
@decorator("/route1")
|
|
262
|
+
def route1(self) -> list[Response | Effect]:
|
|
263
|
+
return [
|
|
264
|
+
JSONResponse(
|
|
265
|
+
{"method": method},
|
|
266
|
+
)
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
@decorator("/route2")
|
|
270
|
+
def route2(self) -> list[Response | Effect]:
|
|
271
|
+
return [
|
|
272
|
+
JSONResponse(
|
|
273
|
+
{"method": method},
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
effects = handle_request(API, method, path=f"{prefix or ''}{path}")
|
|
278
|
+
body = json_response_body(effects)
|
|
279
|
+
|
|
280
|
+
assert body["method"] == method
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_request_lifecycle() -> None:
|
|
284
|
+
"""Test the request-response lifecycle."""
|
|
285
|
+
|
|
286
|
+
class Route(RouteNoAuth):
|
|
287
|
+
PATH = "/route"
|
|
288
|
+
|
|
289
|
+
def post(self) -> list[Response | Effect]:
|
|
290
|
+
return [
|
|
291
|
+
JSONResponse(
|
|
292
|
+
{
|
|
293
|
+
"method": self.request.method,
|
|
294
|
+
"path": self.request.path,
|
|
295
|
+
"query_string": self.request.query_string,
|
|
296
|
+
"body": self.request.json(),
|
|
297
|
+
"headers": dict(self.request.headers),
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
effects = handle_request(
|
|
303
|
+
Route,
|
|
304
|
+
method="POST",
|
|
305
|
+
path="/route",
|
|
306
|
+
query_string="value1=a&value2=b",
|
|
307
|
+
body=b'{"message": "JSON request"}',
|
|
308
|
+
headers=HEADERS,
|
|
309
|
+
)
|
|
310
|
+
body = json_response_body(effects)
|
|
311
|
+
|
|
312
|
+
assert body == {
|
|
313
|
+
"body": {"message": "JSON request"},
|
|
314
|
+
"headers": HEADERS,
|
|
315
|
+
"method": "POST",
|
|
316
|
+
"path": "/route",
|
|
317
|
+
"query_string": "value1=a&value2=b",
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@pytest.mark.parametrize(
|
|
322
|
+
argnames="response,expected_effects",
|
|
323
|
+
argvalues=[
|
|
324
|
+
(
|
|
325
|
+
lambda: [
|
|
326
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
327
|
+
Effect(type=EffectType.ADD_BANNER_ALERT, payload="add banner alert"),
|
|
328
|
+
],
|
|
329
|
+
[
|
|
330
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
331
|
+
Effect(type=EffectType.ADD_BANNER_ALERT, payload="add banner alert"),
|
|
332
|
+
],
|
|
333
|
+
),
|
|
334
|
+
(
|
|
335
|
+
lambda: [
|
|
336
|
+
JSONResponse(
|
|
337
|
+
content={"message": "JSON response"},
|
|
338
|
+
status_code=HTTPStatus.ACCEPTED,
|
|
339
|
+
headers=HEADERS,
|
|
340
|
+
),
|
|
341
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
342
|
+
],
|
|
343
|
+
[
|
|
344
|
+
JSONResponse(
|
|
345
|
+
content={"message": "JSON response"},
|
|
346
|
+
status_code=HTTPStatus.ACCEPTED,
|
|
347
|
+
headers=HEADERS,
|
|
348
|
+
).apply(),
|
|
349
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
350
|
+
],
|
|
351
|
+
),
|
|
352
|
+
(
|
|
353
|
+
lambda: [
|
|
354
|
+
JSONResponse(
|
|
355
|
+
content={"message": "JSON response"},
|
|
356
|
+
status_code=HTTPStatus.ACCEPTED,
|
|
357
|
+
headers=HEADERS,
|
|
358
|
+
).apply(),
|
|
359
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
360
|
+
],
|
|
361
|
+
[
|
|
362
|
+
JSONResponse(
|
|
363
|
+
content={"message": "JSON response"},
|
|
364
|
+
status_code=HTTPStatus.ACCEPTED,
|
|
365
|
+
headers=HEADERS,
|
|
366
|
+
).apply(),
|
|
367
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
368
|
+
],
|
|
369
|
+
),
|
|
370
|
+
(lambda: [], []),
|
|
371
|
+
(
|
|
372
|
+
lambda: [Response(), Response()],
|
|
373
|
+
[Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()],
|
|
374
|
+
),
|
|
375
|
+
(
|
|
376
|
+
lambda: [
|
|
377
|
+
JSONResponse(
|
|
378
|
+
content={"message": "JSON response"},
|
|
379
|
+
status_code=HTTPStatus.BAD_REQUEST,
|
|
380
|
+
headers=HEADERS,
|
|
381
|
+
),
|
|
382
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
JSONResponse(
|
|
386
|
+
content={"message": "JSON response"},
|
|
387
|
+
status_code=HTTPStatus.BAD_REQUEST,
|
|
388
|
+
headers=HEADERS,
|
|
389
|
+
).apply()
|
|
390
|
+
],
|
|
391
|
+
),
|
|
392
|
+
(
|
|
393
|
+
lambda: [
|
|
394
|
+
JSONResponse(
|
|
395
|
+
content={"message": "JSON response"},
|
|
396
|
+
status_code=HTTPStatus.BAD_REQUEST,
|
|
397
|
+
headers=HEADERS,
|
|
398
|
+
).apply(),
|
|
399
|
+
Effect(type=EffectType.CREATE_TASK, payload="create task"),
|
|
400
|
+
],
|
|
401
|
+
[
|
|
402
|
+
JSONResponse(
|
|
403
|
+
content={"message": "JSON response"},
|
|
404
|
+
status_code=HTTPStatus.BAD_REQUEST,
|
|
405
|
+
headers=HEADERS,
|
|
406
|
+
).apply()
|
|
407
|
+
],
|
|
408
|
+
),
|
|
409
|
+
(
|
|
410
|
+
lambda: [
|
|
411
|
+
JSONResponse(content={"message": 1 / 0}, status_code=HTTPStatus.OK, headers=HEADERS)
|
|
412
|
+
],
|
|
413
|
+
[Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()],
|
|
414
|
+
),
|
|
415
|
+
],
|
|
416
|
+
ids=[
|
|
417
|
+
"list of effects",
|
|
418
|
+
"list of effects with response object",
|
|
419
|
+
"list of effects with response effect",
|
|
420
|
+
"no response",
|
|
421
|
+
"multiple responses",
|
|
422
|
+
"handler returns error response object",
|
|
423
|
+
"handler returns error response effect",
|
|
424
|
+
"exception in handler",
|
|
425
|
+
],
|
|
426
|
+
)
|
|
427
|
+
def test_response(response: Callable, expected_effects: Sequence[Effect]) -> None:
|
|
428
|
+
"""Test the construction and return of different kinds of responses."""
|
|
429
|
+
|
|
430
|
+
class Route(RouteNoAuth):
|
|
431
|
+
PATH = "/route"
|
|
432
|
+
|
|
433
|
+
def get(self) -> list[Response | Effect]:
|
|
434
|
+
return response()
|
|
435
|
+
|
|
436
|
+
effects = handle_request(Route, method="GET", path="/route")
|
|
437
|
+
|
|
438
|
+
assert effects == expected_effects
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@pytest.mark.parametrize(
|
|
442
|
+
argnames="response,expected_payload",
|
|
443
|
+
argvalues=[
|
|
444
|
+
(
|
|
445
|
+
Response(
|
|
446
|
+
content=b"%PDF-1.4\n%\xd3\xeb\xe9\xe1",
|
|
447
|
+
status_code=HTTPStatus.ACCEPTED,
|
|
448
|
+
headers=HEADERS,
|
|
449
|
+
content_type="application/pdf",
|
|
450
|
+
),
|
|
451
|
+
'{"headers": {"Canvas-Plugins-Test-Header": "test header", "Content-Type": '
|
|
452
|
+
'"application/pdf"}, "body": "JVBERi0xLjQKJdPr6eE=", "status_code": 202}',
|
|
453
|
+
),
|
|
454
|
+
(
|
|
455
|
+
JSONResponse(
|
|
456
|
+
content={"message": "JSON response"},
|
|
457
|
+
status_code=HTTPStatus.ACCEPTED,
|
|
458
|
+
headers=HEADERS,
|
|
459
|
+
),
|
|
460
|
+
'{"headers": {"Canvas-Plugins-Test-Header": "test header", "Content-Type": '
|
|
461
|
+
'"application/json"}, "body": "eyJtZXNzYWdlIjogIkpTT04gcmVzcG9uc2UifQ==", '
|
|
462
|
+
'"status_code": 202}',
|
|
463
|
+
),
|
|
464
|
+
(
|
|
465
|
+
PlainTextResponse(
|
|
466
|
+
content="plain text response", status_code=HTTPStatus.ACCEPTED, headers=HEADERS
|
|
467
|
+
),
|
|
468
|
+
'{"headers": {"Canvas-Plugins-Test-Header": "test header", "Content-Type": '
|
|
469
|
+
'"text/plain"}, "body": "cGxhaW4gdGV4dCByZXNwb25zZQ==", "status_code": 202}',
|
|
470
|
+
),
|
|
471
|
+
(
|
|
472
|
+
HTMLResponse(content="<html></html>", status_code=HTTPStatus.ACCEPTED, headers=HEADERS),
|
|
473
|
+
'{"headers": {"Canvas-Plugins-Test-Header": "test header", "Content-Type": '
|
|
474
|
+
'"text/html"}, "body": "PGh0bWw+PC9odG1sPg==", "status_code": 202}',
|
|
475
|
+
),
|
|
476
|
+
(
|
|
477
|
+
Response(status_code=HTTPStatus.NO_CONTENT, headers=HEADERS),
|
|
478
|
+
'{"headers": {"Canvas-Plugins-Test-Header": "test header"}, "body": "", '
|
|
479
|
+
'"status_code": 204}',
|
|
480
|
+
),
|
|
481
|
+
],
|
|
482
|
+
ids=["binary", "JSON", "plain text", "HTML", "no content"],
|
|
483
|
+
)
|
|
484
|
+
def test_response_type(response: Response, expected_payload: str) -> None:
|
|
485
|
+
"""Test the Response object with different types of content."""
|
|
486
|
+
assert response.apply() == Effect(type=EffectType.SIMPLE_API_RESPONSE, payload=expected_payload)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def test_override_base_handler_attributes_error() -> None:
|
|
490
|
+
"""Test the enforcement of the error that occurs when base handler attributes are overridden."""
|
|
491
|
+
with pytest.raises(PluginError):
|
|
492
|
+
|
|
493
|
+
class API(APINoAuth):
|
|
494
|
+
@api.get("/route")
|
|
495
|
+
def compute(self) -> list[Response | Effect]: # type: ignore[override]
|
|
496
|
+
return []
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def test_multiple_handlers_for_route_error() -> None:
|
|
500
|
+
"""
|
|
501
|
+
Test the enforcement of the error that occurs when a route is assigned to multiple handlers.
|
|
502
|
+
"""
|
|
503
|
+
with pytest.raises(PluginError):
|
|
504
|
+
|
|
505
|
+
class API(APINoAuth):
|
|
506
|
+
@api.get("/route")
|
|
507
|
+
def route1(self) -> list[Response | Effect]:
|
|
508
|
+
return []
|
|
509
|
+
|
|
510
|
+
@api.get("/route")
|
|
511
|
+
def route2(self) -> list[Response | Effect]:
|
|
512
|
+
return []
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_invalid_prefix_error() -> None:
|
|
516
|
+
"""Test the enforcement of the error that occurs when an API has an invalid prefix."""
|
|
517
|
+
with pytest.raises(PluginError):
|
|
518
|
+
|
|
519
|
+
class API(APINoAuth):
|
|
520
|
+
PREFIX = "prefix"
|
|
521
|
+
|
|
522
|
+
@api.get("/route")
|
|
523
|
+
def route(self) -> list[Response | Effect]:
|
|
524
|
+
return []
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_invalid_path_error() -> None:
|
|
528
|
+
"""Test the enforcement of the error that occurs when a route has an invalid path."""
|
|
529
|
+
with pytest.raises(PluginError):
|
|
530
|
+
|
|
531
|
+
class Route(RouteNoAuth):
|
|
532
|
+
PATH = "route"
|
|
533
|
+
|
|
534
|
+
def get(self) -> list[Response | Effect]:
|
|
535
|
+
return []
|
|
536
|
+
|
|
537
|
+
with pytest.raises(PluginError):
|
|
538
|
+
|
|
539
|
+
class API(APINoAuth):
|
|
540
|
+
@api.get("route")
|
|
541
|
+
def route(self) -> list[Response | Effect]:
|
|
542
|
+
return []
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def test_route_missing_path_error() -> None:
|
|
546
|
+
"""
|
|
547
|
+
Test the enforcement of the error that occurs when a SimpleAPIRoute is missing a PATH value.
|
|
548
|
+
"""
|
|
549
|
+
with pytest.raises(PluginError):
|
|
550
|
+
|
|
551
|
+
class Route(RouteNoAuth):
|
|
552
|
+
def get(self) -> list[Response | Effect]:
|
|
553
|
+
return []
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def test_route_has_prefix_error() -> None:
|
|
557
|
+
"""Test the enforcement of the error that occurs when a SimpleAPIRoute has a PREFIX value."""
|
|
558
|
+
with pytest.raises(PluginError):
|
|
559
|
+
|
|
560
|
+
class Route(RouteNoAuth):
|
|
561
|
+
PREFIX = "/prefix"
|
|
562
|
+
PATH = "/route"
|
|
563
|
+
|
|
564
|
+
def get(self) -> list[Response | Effect]:
|
|
565
|
+
return []
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def test_route_that_uses_api_decorator_error() -> None:
|
|
569
|
+
"""
|
|
570
|
+
Test the enforcement of the error that occurs when a SimpleAPIRoute uses the api decorator.
|
|
571
|
+
"""
|
|
572
|
+
with pytest.raises(PluginError):
|
|
573
|
+
|
|
574
|
+
class Route(RouteNoAuth):
|
|
575
|
+
PREFIX = "/prefix"
|
|
576
|
+
PATH = "/route"
|
|
577
|
+
|
|
578
|
+
def get(self) -> list[Response | Effect]:
|
|
579
|
+
return []
|
|
580
|
+
|
|
581
|
+
@api.get("/route")
|
|
582
|
+
def route(self) -> list[Response | Effect]:
|
|
583
|
+
return []
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def basic_headers(username: str, password: str) -> dict[str, str]:
|
|
587
|
+
"""Given a username and password, return headers that include a basic authentication header."""
|
|
588
|
+
return {"Authorization": f"Basic {b64encode(f'{username}:{password}'.encode()).decode()}"}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def bearer_headers(token: str) -> dict[str, str]:
|
|
592
|
+
"""Given a token, return headers that include a bearer authentication header."""
|
|
593
|
+
return {"Authorization": f"Bearer {token}"}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def api_key_headers(api_key: str) -> dict[str, str]:
|
|
597
|
+
"""Given an API key, return headers that include an API key authentication header."""
|
|
598
|
+
return {"Authorization": api_key}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def custom_headers(api_key: str, app_key: str) -> dict[str, str]:
|
|
602
|
+
"""
|
|
603
|
+
Given an API key and an app key, return headers that include custom authentication headers.
|
|
604
|
+
"""
|
|
605
|
+
return {"API-Key": api_key, "App-Key": app_key}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
USERNAME = uuid4().hex
|
|
609
|
+
PASSWORD = uuid4().hex
|
|
610
|
+
TOKEN = uuid4().hex
|
|
611
|
+
API_KEY = uuid4().hex
|
|
612
|
+
APP_KEY = uuid4().hex
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@pytest.fixture(
|
|
616
|
+
params=[
|
|
617
|
+
(
|
|
618
|
+
BasicCredentials,
|
|
619
|
+
lambda _, credentials: credentials.username == USERNAME
|
|
620
|
+
and credentials.password == PASSWORD,
|
|
621
|
+
basic_headers(USERNAME, PASSWORD),
|
|
622
|
+
),
|
|
623
|
+
(
|
|
624
|
+
BearerCredentials,
|
|
625
|
+
lambda _, credentials: credentials.token == TOKEN,
|
|
626
|
+
bearer_headers(TOKEN),
|
|
627
|
+
),
|
|
628
|
+
(
|
|
629
|
+
APIKeyCredentials,
|
|
630
|
+
lambda _, credentials: credentials.key == API_KEY,
|
|
631
|
+
api_key_headers(API_KEY),
|
|
632
|
+
),
|
|
633
|
+
(
|
|
634
|
+
Credentials,
|
|
635
|
+
lambda request, _: request.headers.get("API-Key") == API_KEY
|
|
636
|
+
and request.headers.get("App-Key") == APP_KEY,
|
|
637
|
+
custom_headers(API_KEY, APP_KEY),
|
|
638
|
+
),
|
|
639
|
+
],
|
|
640
|
+
ids=["basic", "bearer", "API key", "custom"],
|
|
641
|
+
)
|
|
642
|
+
def authenticated_route(request: SubRequest) -> SimpleNamespace:
|
|
643
|
+
"""
|
|
644
|
+
Parametrized test fixture that returns a Route class with authentication.
|
|
645
|
+
|
|
646
|
+
It will also return a set of headers that will pass authentication for the route.
|
|
647
|
+
"""
|
|
648
|
+
credentials_cls, authenticate_impl, headers = request.param
|
|
649
|
+
|
|
650
|
+
class Route(SimpleAPIRoute):
|
|
651
|
+
PATH = "/route"
|
|
652
|
+
|
|
653
|
+
def authenticate(self, credentials: credentials_cls) -> bool: # type: ignore[valid-type]
|
|
654
|
+
return authenticate_impl(self.request, credentials)
|
|
655
|
+
|
|
656
|
+
def get(self) -> list[Response | Effect]:
|
|
657
|
+
return [Effect(type=EffectType.CREATE_TASK, payload="create task")]
|
|
658
|
+
|
|
659
|
+
return SimpleNamespace(cls=Route, headers=headers)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def test_authentication(authenticated_route: SimpleNamespace) -> None:
|
|
663
|
+
"""Test that valid credentials result in a successful response."""
|
|
664
|
+
effects = handle_request(
|
|
665
|
+
authenticated_route.cls, method="GET", path="/route", headers=authenticated_route.headers
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
assert effects == [Effect(type=EffectType.CREATE_TASK, payload="create task")]
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@pytest.mark.parametrize(
|
|
672
|
+
argnames="headers",
|
|
673
|
+
argvalues=[
|
|
674
|
+
basic_headers(username=uuid4().hex, password=uuid4().hex),
|
|
675
|
+
basic_headers(username="", password=uuid4().hex),
|
|
676
|
+
basic_headers(username=uuid4().hex, password=""),
|
|
677
|
+
bearer_headers(token=uuid4().hex),
|
|
678
|
+
bearer_headers(token=""),
|
|
679
|
+
api_key_headers(api_key=uuid4().hex),
|
|
680
|
+
api_key_headers(api_key=""),
|
|
681
|
+
custom_headers(api_key=uuid4().hex, app_key=uuid4().hex),
|
|
682
|
+
custom_headers(api_key="", app_key=uuid4().hex),
|
|
683
|
+
custom_headers(api_key=uuid4().hex, app_key=""),
|
|
684
|
+
{},
|
|
685
|
+
],
|
|
686
|
+
ids=[
|
|
687
|
+
"basic",
|
|
688
|
+
"basic missing username",
|
|
689
|
+
"basic missing password",
|
|
690
|
+
"bearer",
|
|
691
|
+
"bearer missing token",
|
|
692
|
+
"API key",
|
|
693
|
+
"API key missing value",
|
|
694
|
+
"custom",
|
|
695
|
+
"custom missing API key",
|
|
696
|
+
"custom missing app key",
|
|
697
|
+
"no authentication headers",
|
|
698
|
+
],
|
|
699
|
+
)
|
|
700
|
+
def test_authentication_failure(
|
|
701
|
+
authenticated_route: SimpleNamespace, headers: Mapping[str, str]
|
|
702
|
+
) -> None:
|
|
703
|
+
"""Test that invalid credentials result in a failure response."""
|
|
704
|
+
effects = handle_request(authenticated_route.cls, method="GET", path="/route", headers=headers)
|
|
705
|
+
|
|
706
|
+
assert json.loads(effects[0].payload)["status_code"] == HTTPStatus.UNAUTHORIZED
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@pytest.mark.parametrize(
|
|
710
|
+
argnames="credentials_cls,headers",
|
|
711
|
+
argvalues=[
|
|
712
|
+
(BasicCredentials, basic_headers(USERNAME, PASSWORD)),
|
|
713
|
+
(BearerCredentials, bearer_headers(TOKEN)),
|
|
714
|
+
(APIKeyCredentials, api_key_headers(API_KEY)),
|
|
715
|
+
(Credentials, custom_headers(API_KEY, APP_KEY)),
|
|
716
|
+
],
|
|
717
|
+
ids=["basic", "bearer", "API key", "custom"],
|
|
718
|
+
)
|
|
719
|
+
def test_authentication_exception(
|
|
720
|
+
credentials_cls: type[Credentials], headers: Mapping[str, str]
|
|
721
|
+
) -> None:
|
|
722
|
+
"""Test that an exception occurring during authentication results in a failure response."""
|
|
723
|
+
|
|
724
|
+
class Route(SimpleAPIRoute):
|
|
725
|
+
PATH = "/route"
|
|
726
|
+
|
|
727
|
+
def authenticate(self, credentials: credentials_cls) -> bool: # type: ignore[valid-type]
|
|
728
|
+
raise RuntimeError
|
|
729
|
+
|
|
730
|
+
def get(self) -> list[Response | Effect]:
|
|
731
|
+
return [Effect(type=EffectType.CREATE_TASK, payload="create task")]
|
|
732
|
+
|
|
733
|
+
effects = handle_request(Route, method="GET", path="/route", headers=headers)
|
|
734
|
+
|
|
735
|
+
assert effects == [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@pytest.mark.parametrize(
|
|
739
|
+
argnames="mixin_cls,secrets,headers,expected_effects",
|
|
740
|
+
argvalues=[
|
|
741
|
+
(
|
|
742
|
+
BasicAuthMixin,
|
|
743
|
+
{"simpleapi-basic-username": USERNAME, "simpleapi-basic-password": PASSWORD},
|
|
744
|
+
basic_headers(USERNAME, PASSWORD),
|
|
745
|
+
[Effect(type=EffectType.CREATE_TASK, payload="create task")],
|
|
746
|
+
),
|
|
747
|
+
(
|
|
748
|
+
BasicAuthMixin,
|
|
749
|
+
{"simpleapi-basic-username": USERNAME, "simpleapi-basic-password": PASSWORD},
|
|
750
|
+
basic_headers(uuid4().hex, uuid4().hex),
|
|
751
|
+
[
|
|
752
|
+
JSONResponse(
|
|
753
|
+
content={"error": "Provided credentials are invalid"},
|
|
754
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
755
|
+
).apply()
|
|
756
|
+
],
|
|
757
|
+
),
|
|
758
|
+
(
|
|
759
|
+
BasicAuthMixin,
|
|
760
|
+
{},
|
|
761
|
+
basic_headers(USERNAME, PASSWORD),
|
|
762
|
+
[
|
|
763
|
+
JSONResponse(
|
|
764
|
+
content={"error": "Provided credentials are invalid"},
|
|
765
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
766
|
+
).apply()
|
|
767
|
+
],
|
|
768
|
+
),
|
|
769
|
+
(
|
|
770
|
+
APIKeyAuthMixin,
|
|
771
|
+
{"simpleapi-api-key": API_KEY},
|
|
772
|
+
api_key_headers(API_KEY),
|
|
773
|
+
[Effect(type=EffectType.CREATE_TASK, payload="create task")],
|
|
774
|
+
),
|
|
775
|
+
(
|
|
776
|
+
APIKeyAuthMixin,
|
|
777
|
+
{"simpleapi-api-key": API_KEY},
|
|
778
|
+
api_key_headers(uuid4().hex),
|
|
779
|
+
[
|
|
780
|
+
JSONResponse(
|
|
781
|
+
content={"error": "Provided credentials are invalid"},
|
|
782
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
783
|
+
).apply()
|
|
784
|
+
],
|
|
785
|
+
),
|
|
786
|
+
(
|
|
787
|
+
APIKeyAuthMixin,
|
|
788
|
+
{},
|
|
789
|
+
api_key_headers(API_KEY),
|
|
790
|
+
[
|
|
791
|
+
JSONResponse(
|
|
792
|
+
content={"error": "Provided credentials are invalid"},
|
|
793
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
794
|
+
).apply()
|
|
795
|
+
],
|
|
796
|
+
),
|
|
797
|
+
],
|
|
798
|
+
ids=[
|
|
799
|
+
"basic valid",
|
|
800
|
+
"basic invalid",
|
|
801
|
+
"basic missing secret",
|
|
802
|
+
"API key valid",
|
|
803
|
+
"API key invalid",
|
|
804
|
+
"API key missing secret",
|
|
805
|
+
],
|
|
806
|
+
)
|
|
807
|
+
def test_authentication_mixins(
|
|
808
|
+
mixin_cls: type[AuthSchemeMixin],
|
|
809
|
+
secrets: dict[str, str],
|
|
810
|
+
headers: Mapping[str, str],
|
|
811
|
+
expected_effects: Sequence[Effect],
|
|
812
|
+
) -> None:
|
|
813
|
+
"""
|
|
814
|
+
Test that the provided authentication mixins behave correctly in success and failure scenarios.
|
|
815
|
+
"""
|
|
816
|
+
|
|
817
|
+
class Route(mixin_cls, SimpleAPIRoute): # type: ignore[misc,valid-type]
|
|
818
|
+
PATH = "/route"
|
|
819
|
+
|
|
820
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
821
|
+
super().__init__(*args, **kwargs)
|
|
822
|
+
self.secrets = secrets
|
|
823
|
+
|
|
824
|
+
def get(self) -> list[Response | Effect]:
|
|
825
|
+
return [Effect(type=EffectType.CREATE_TASK, payload="create task")]
|
|
826
|
+
|
|
827
|
+
effects = handle_request(Route, method="GET", path="/route", headers=headers)
|
|
828
|
+
assert effects == expected_effects
|