Skip to content

Commit 876567b

Browse files
committed
Fixed rest of GH actions tests
1 parent cab2e25 commit 876567b

12 files changed

Lines changed: 115 additions & 42 deletions

File tree

mod_api/middleware/auth.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from mod_api.middleware.error_handler import make_error_response
2020
from mod_api.models.api_token import ApiToken
2121

22-
# Reused across every 401 response to keep the message consistent.
2322
_AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.'
2423

2524
# These endpoints bypass auth entirely.
@@ -31,7 +30,8 @@
3130

3231
def _unauthorized():
3332
"""Shorthand for a 401 response with the standard auth failure message."""
34-
return make_error_response('unauthorized', _AUTH_FAILED_MSG, http_status=401)
33+
return make_error_response(
34+
'unauthorized', _AUTH_FAILED_MSG, http_status=401)
3535

3636

3737
@mod_api.before_request
@@ -78,7 +78,7 @@ def authenticate_request():
7878

7979

8080
def require_scope(scope: str):
81-
"""Decorator: reject the request if the token lacks ``scope``."""
81+
"""Reject the request if the token lacks ``scope``."""
8282
def decorator(f):
8383
@functools.wraps(f)
8484
def decorated_function(*args, **kwargs):
@@ -88,7 +88,7 @@ def decorated_function(*args, **kwargs):
8888
if not token.has_scope(scope):
8989
return make_error_response(
9090
'forbidden',
91-
'Token does not have the required scope for this operation.',
91+
'Token lacks the required scope for this operation.',
9292
details={
9393
'required_scope': scope,
9494
'token_scopes': token.scopes,
@@ -101,7 +101,7 @@ def decorated_function(*args, **kwargs):
101101

102102

103103
def require_roles(roles: List[str]):
104-
"""Decorator: reject the request if the user's role is not in ``roles``."""
104+
"""Reject the request if the user's role is not in ``roles``."""
105105
def decorator(f):
106106
@functools.wraps(f)
107107
def decorated_function(*args, **kwargs):

mod_api/middleware/error_handler.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414

1515
from mod_api import mod_api
1616

17-
# All error handlers check this prefix before intercepting — non-API
18-
# routes (the legacy web UI) should still get their HTML error pages.
1917
_API_PREFIX = '/api/v1'
2018

2119

@@ -86,7 +84,7 @@ def handle_404(error):
8684

8785
@mod_api.app_errorhandler(405)
8886
def handle_405(error):
89-
"""Method not allowed."""
87+
"""Handle method-not-allowed errors for API routes."""
9088
if not _is_api_request():
9189
raise error
9290
return make_error_response(
@@ -103,7 +101,10 @@ def handle_422(error):
103101
raise error
104102
return make_error_response(
105103
'unprocessable',
106-
getattr(error, 'description', 'Request is valid JSON but semantically invalid.'),
104+
getattr(
105+
error,
106+
'description',
107+
'Request is valid JSON but semantically invalid.'),
107108
http_status=422,
108109
)
109110

@@ -123,7 +124,7 @@ def handle_429(error):
123124

124125
@mod_api.app_errorhandler(500)
125126
def handle_500(error):
126-
"""Internal server error."""
127+
"""Handle unexpected server errors for API routes."""
127128
if not _is_api_request():
128129
raise error
129130
return make_error_response(

mod_api/middleware/validation.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@
2525
# Whitelist of allowed sort params. Never pass raw user input to the ORM.
2626
ALLOWED_RUN_SORTS = frozenset([
2727
'created_at', '-created_at',
28-
'started_at', '-started_at',
2928
'run_id', '-run_id',
3029
])
3130

3231

3332
def validate_body(schema_class):
34-
"""Validate the JSON body with a Marshmallow schema, pass result as ``validated_data``."""
33+
"""Validate the JSON body with a schema, pass result as ``validated_data``."""
3534
def decorator(f):
3635
@wraps(f)
3736
def decorated(*args, **kwargs):
@@ -66,7 +65,7 @@ def decorated(*args, **kwargs):
6665

6766

6867
def validate_pagination(f):
69-
"""Extract and validate ``limit`` (1-100) and ``offset`` (>= 0) query params."""
68+
"""Extract and validate ``limit`` and ``offset`` query params."""
7069
@wraps(f)
7170
def decorated(*args, **kwargs):
7271
try:
@@ -75,7 +74,9 @@ def decorated(*args, **kwargs):
7574
return make_error_response(
7675
'validation_error',
7776
'limit must be an integer.',
78-
details={'fields': {'limit': 'Must be an integer between 1 and 100.'}},
77+
details={
78+
'fields': {
79+
'limit': 'Must be an integer between 1 and 100.'}},
7980
http_status=400,
8081
)
8182

@@ -85,7 +86,9 @@ def decorated(*args, **kwargs):
8586
return make_error_response(
8687
'validation_error',
8788
'offset must be a non-negative integer.',
88-
details={'fields': {'offset': 'Must be a non-negative integer.'}},
89+
details={
90+
'fields': {
91+
'offset': 'Must be a non-negative integer.'}},
8992
http_status=400,
9093
)
9194

@@ -123,14 +126,20 @@ def decorated(*args, **kwargs):
123126
return make_error_response(
124127
'validation_error',
125128
f'{param_name} must be a positive integer.',
126-
details={'fields': {param_name: 'Must be a positive integer.'}},
129+
details={
130+
'fields': {
131+
param_name: 'Must be a positive integer.'}},
127132
http_status=400,
128133
)
129134
if int_value < 1:
130135
return make_error_response(
131136
'validation_error',
132137
f'{param_name} must be >= 1.',
133-
details={'fields': {param_name: 'Must be >= 1. Zero and negative IDs are rejected.'}},
138+
details={
139+
'fields': {
140+
param_name: 'Must be >= 1. Zero and negative IDs are rejected.'
141+
}
142+
},
134143
http_status=400,
135144
)
136145
kwargs[param_name] = int_value
@@ -140,7 +149,7 @@ def decorated(*args, **kwargs):
140149

141150

142151
def validate_date_range(f):
143-
"""Parse ``created_after``/``created_before`` query params and reject inverted ranges."""
152+
"""Parse date query params and reject inverted ranges."""
144153
@wraps(f)
145154
def decorated(*args, **kwargs):
146155
from datetime import datetime
@@ -152,23 +161,29 @@ def decorated(*args, **kwargs):
152161

153162
if created_after_str:
154163
try:
155-
created_after = datetime.fromisoformat(created_after_str.replace('Z', '+00:00'))
164+
created_after = datetime.fromisoformat(
165+
created_after_str.replace('Z', '+00:00'))
156166
except ValueError:
157167
return make_error_response(
158168
'validation_error',
159169
'created_after must be a valid ISO 8601 datetime.',
160-
details={'fields': {'created_after': 'Invalid ISO 8601 format.'}},
170+
details={
171+
'fields': {
172+
'created_after': 'Invalid ISO 8601 format.'}},
161173
http_status=400,
162174
)
163175

164176
if created_before_str:
165177
try:
166-
created_before = datetime.fromisoformat(created_before_str.replace('Z', '+00:00'))
178+
created_before = datetime.fromisoformat(
179+
created_before_str.replace('Z', '+00:00'))
167180
except ValueError:
168181
return make_error_response(
169182
'validation_error',
170183
'created_before must be a valid ISO 8601 datetime.',
171-
details={'fields': {'created_before': 'Invalid ISO 8601 format.'}},
184+
details={
185+
'fields': {
186+
'created_before': 'Invalid ISO 8601 format.'}},
172187
http_status=400,
173188
)
174189

@@ -202,7 +217,10 @@ def decorated(*args, **kwargs):
202217
return make_error_response(
203218
'validation_error',
204219
f'sort must be one of: {", ".join(sorted(allowed))}',
205-
details={'fields': {'sort': f'Must be one of: {sorted(allowed)}'}},
220+
details={
221+
'fields': {
222+
'sort': f'Must be one of: {
223+
sorted(allowed)}'}},
206224
http_status=400,
207225
)
208226
kwargs['sort'] = sort

mod_api/models/api_token.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,35 +77,42 @@ def __init__(
7777
self.expires_at = self.created_at + timedelta(days=expires_in_days)
7878

7979
def __repr__(self) -> str:
80-
return f'<ApiToken {self.id} user={self.user_id} name={self.token_name}>'
80+
"""Return a debug representation of the token."""
81+
return f'<ApiToken {self.id} user={self.user_id}>'
8182

8283
@property
8384
def scopes(self) -> List[str]:
85+
"""Parse the JSON scopes column into a list."""
8486
return json.loads(self.scopes_json)
8587

8688
@property
8789
def is_expired(self) -> bool:
90+
"""Check whether this token has passed its expiration time."""
8891
now = datetime.now(timezone.utc)
8992
expires = self.expires_at
9093
if expires is None:
9194
return True
9295
# MySQL DATETIME columns don't preserve tzinfo; treat naive as UTC.
9396
if expires.tzinfo is None:
9497
expires = expires.replace(tzinfo=timezone.utc)
95-
return now > expires
98+
return bool(now > expires)
9699

97100
@property
98101
def is_revoked(self) -> bool:
99-
return self.revoked_at is not None
102+
"""Check whether this token has been explicitly revoked."""
103+
return bool(self.revoked_at is not None)
100104

101105
@property
102106
def is_valid(self) -> bool:
107+
"""Return True if the token is neither expired nor revoked."""
103108
return not self.is_expired and not self.is_revoked
104109

105110
def has_scope(self, scope: str) -> bool:
111+
"""Return True if the token grants the given scope."""
106112
return scope in self.scopes
107113

108114
def revoke(self) -> None:
115+
"""Mark this token as revoked with the current timestamp."""
109116
self.revoked_at = datetime.now(timezone.utc)
110117

111118
@staticmethod

mod_api/schemas/auth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class TokenCreateRequestSchema(Schema):
3434
)
3535

3636
class Meta:
37+
"""Reject unknown fields."""
38+
3739
unknown = RAISE
3840

3941

@@ -48,7 +50,7 @@ class AuthTokenSchema(Schema):
4850

4951

5052
class ApiTokenItemSchema(Schema):
51-
"""Token metadata for list responses — never includes the plaintext value."""
53+
"""Token metadata for list responses — never includes the plaintext."""
5254

5355
id = fields.Integer(required=True)
5456
user_id = fields.Integer(required=True)
@@ -61,4 +63,5 @@ class ApiTokenItemSchema(Schema):
6163
revoked_at = fields.DateTime(allow_none=True)
6264

6365
def get_scopes(self, obj):
66+
"""Deserialize scopes from the model's JSON column."""
6467
return obj.scopes

mod_api/schemas/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55

66
class ErrorResponseSchema(Schema):
77
"""Standard JSON error body returned by all error responses."""
8+
89
code = fields.String(required=True)
910
message = fields.String(required=True)
1011
details = fields.Dict(keys=fields.String(), required=True, load_default={})
1112

1213

1314
class PaginationSchema(Schema):
1415
"""Offset-based pagination metadata."""
16+
1517
limit = fields.Integer(required=True)
1618
offset = fields.Integer(required=True)
1719
total = fields.Integer(required=True)
@@ -20,5 +22,6 @@ class PaginationSchema(Schema):
2022

2123
class CursorPaginationSchema(Schema):
2224
"""Cursor-based pagination metadata."""
25+
2326
limit = fields.Integer(required=True)
2427
next_cursor = fields.String(allow_none=True, load_default=None)

mod_api/schemas/errors.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
class ErrorItemSchema(Schema):
77
"""A single error derived from run results or infra progress."""
8+
89
error_id = fields.String(required=True)
910
run_id = fields.Integer(required=True)
1011
sample_id = fields.Integer(allow_none=True)
@@ -22,6 +23,7 @@ class ErrorItemSchema(Schema):
2223

2324
class ErrorSummaryBucketSchema(Schema):
2425
"""One bucket in a grouped error summary."""
26+
2527
key = fields.String(required=True)
2628
count = fields.Integer(required=True)
2729
severity = fields.String(required=True)
@@ -32,14 +34,17 @@ class ErrorSummaryBucketSchema(Schema):
3234

3335
class LogLineSchema(Schema):
3436
"""A single parsed line from a build log."""
37+
3538
timestamp = fields.DateTime(allow_none=True)
3639
level = fields.String(
3740
required=True,
38-
validate=validate.OneOf(['debug', 'info', 'warning', 'error', 'critical']),
41+
validate=validate.OneOf(
42+
['debug', 'info', 'warning', 'error', 'critical']),
3943
)
4044
source = fields.String(
4145
required=True,
42-
validate=validate.OneOf(['orchestrator', 'worker', 'build', 'test_runner', 'web']),
46+
validate=validate.OneOf(
47+
['orchestrator', 'worker', 'build', 'test_runner', 'web']),
4348
)
4449
message = fields.String(required=True)
4550
run_id = fields.Integer(required=True)

0 commit comments

Comments
 (0)