canvas 0.24.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.

@@ -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