Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 162 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,164 @@
.ipynb_checkpoints
# ===========================
# OS files
# ===========================
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini

# ===========================
# IDE and editors
# ===========================
.vscode/
.idea/
*.swp
*.swo
*~
.spyderproject
.spyproject
.ropeproject

# ===========================
# Environment and secrets
# ===========================
.env
.env.local
.env.*.local

# ===========================
# Python – byte-compiled / optimized
# ===========================
__pycache__/
*.py[cod]
*$py.class
*.so
cython_debug/

# ===========================
# Python – packaging and distribution
# ===========================
.Python
build/
dist/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# ===========================
# Python – virtual environments
# ===========================
.venv
__pycache__
/.idea/
/dependencies
/lambda_function.zip
venv/
env/
ENV/
env.bak/
venv.bak/
__pypackages__/

# ===========================
# Python – tools and type checkers
# ===========================
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
.pdm.toml
.pdm-python
.pdm-build/
.ruff_cache/

# ===========================
# Python – tests and coverage
# ===========================
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.cache
.pytest_cache/
.hypothesis/
nosetests.xml
coverage.xml
*.cover
*.py,cover
cover/

# ===========================
# Python – installer logs
# ===========================
pip-log.txt
pip-delete-this-directory.txt
*.manifest
*.spec

# ===========================
# Python – misc frameworks
# ===========================
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
instance/
.webassets-cache
.scrapy
celerybeat-schedule
celerybeat.pid
*.sage.py
*.mo
*.pot

# ===========================
# Documentation
# ===========================
docs/_build/
/site
.ipynb_checkpoints
profile_default/
ipython_config.py

# ===========================
# Terraform and Terragrunt
# ===========================
.terraform/
*.tfstate
*.tfstate.*
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraformrc
terraform.rc
.terraform.lock.hcl
.terragrunt-cache/

# ===========================
# Corporate CA certificates
# Actual cert files must never be committed -- provide via CI/CD secrets.
# The trusted_certs/ directory is kept (README only) to document the convention.
# ===========================
trusted_certs/*.pem
trusted_certs/*.crt

*.sarif
# ===========================
# Temporary files and logs
# ===========================
tmp/
temp/
*.tmp
logs/
39 changes: 35 additions & 4 deletions src/handlers/handler_topic.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,19 @@ def _post_topic_message(self, topic_name: str, topic_message: dict[str, Any], to
return build_error_response(404, "topic", f"Topic '{topic_name}' not found")

user = token.get("sub")
if topic_name not in self.access_config or user not in self.access_config[topic_name]:
return build_error_response(403, "auth", "User not authorized for topic")
authorized_user = self._resolve_authorized_user(topic_name, user)
if authorized_user is None:
return build_error_response(403, "auth", f"User '{user}' is not authorized for topic '{topic_name}'")
Comment thread
tmikula-dev marked this conversation as resolved.

allowed, perm_error = self._validate_user_permissions(topic_name, user, topic_message)
allowed, perm_error = self._validate_user_permissions(topic_name, authorized_user, topic_message)
if not allowed:
return build_error_response(403, "permission", perm_error or "Permission denied")
return build_error_response(
403,
"permission",
perm_error or f"Permission denied for user '{authorized_user}' for POST to topic '{topic_name}'",
)

logger.debug("Authorized user '%s' for topic '%s'.", authorized_user, topic_name)

try:
validate(instance=topic_message, schema=self.topics[topic_name])
Expand All @@ -189,18 +196,42 @@ def _post_topic_message(self, topic_name: str, topic_message: dict[str, Any], to
errors.append({"type": writer_name, "message": str(exc)})

if errors:
logger.error(
"POST to topic '%s' failed: %d of %d writer(s) reported errors.",
topic_name,
len(errors),
len(self.writers),
)
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"success": False, "statusCode": 500, "errors": errors}),
}

logger.info("Message accepted for topic '%s' from user '%s'.", topic_name, authorized_user)
return {
"statusCode": 202,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"success": True, "statusCode": 202}),
}

def _resolve_authorized_user(self, topic_name: str, user: str | None) -> str | None:
"""Match a token user to a configured user for a topic, ignoring case.
Args:
topic_name: Target topic name.
user: User identifier from the token `sub` claim.
Returns:
The configured username (original casing) when authorized, otherwise `None`.
"""
if user is None or topic_name not in self.access_config:
return None

for configured_user in self.access_config[topic_name]:
if configured_user.casefold() == user.casefold():
return configured_user

return None

def _validate_user_permissions(
self,
topic_name: str,
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/handlers/test_handler_topic.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,24 @@ def test_post_success_all_writers(event_gate_module, make_event, valid_payload):
assert 202 == body["statusCode"]


def test_post_authorized_user_case_insensitive(event_gate_module, make_event, valid_payload):
with patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "testuser"}):
for writer in event_gate_module.handler_topic.writers.values():
writer.write = MagicMock(return_value=None)

event = make_event(
"/topics/{topic_name}",
method="POST",
topic="public.cps.za.test",
body=valid_payload,
headers={"Authorization": "Bearer token"},
)
resp = event_gate_module.lambda_handler(event)
assert 202 == resp["statusCode"]
body = json.loads(resp["body"])
assert body["success"]


Comment on lines +260 to +277

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use mocker.patch.object and follow the assertion format.

As per coding guidelines, use the mocker fixture instead of unittest.mock.patch context managers, and write assertions strictly in the expected == actual format. Additionally, using mocker.patch.object on the writers ensures that the mocks are safely cleaned up after the test completes, preventing potential side effects on other tests.

♻️ Proposed refactor
-def test_post_authorized_user_case_insensitive(event_gate_module, make_event, valid_payload):
-    with patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "testuser"}):
-        for writer in event_gate_module.handler_topic.writers.values():
-            writer.write = MagicMock(return_value=None)
-
-        event = make_event(
-            "/topics/{topic_name}",
-            method="POST",
-            topic="public.cps.za.test",
-            body=valid_payload,
-            headers={"Authorization": "Bearer token"},
-        )
-        resp = event_gate_module.lambda_handler(event)
-        assert 202 == resp["statusCode"]
-        body = json.loads(resp["body"])
-        assert body["success"]
+def test_post_authorized_user_case_insensitive(event_gate_module, make_event, valid_payload, mocker):
+    mocker.patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "testuser"})
+    for writer in event_gate_module.handler_topic.writers.values():
+        mocker.patch.object(writer, "write", return_value=None)
+
+    event = make_event(
+        "/topics/{topic_name}",
+        method="POST",
+        topic="public.cps.za.test",
+        body=valid_payload,
+        headers={"Authorization": "Bearer token"},
+    )
+    resp = event_gate_module.lambda_handler(event)
+    assert 202 == resp["statusCode"]
+    body = json.loads(resp["body"])
+    assert True == body["success"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_post_authorized_user_case_insensitive(event_gate_module, make_event, valid_payload):
with patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "testuser"}):
for writer in event_gate_module.handler_topic.writers.values():
writer.write = MagicMock(return_value=None)
event = make_event(
"/topics/{topic_name}",
method="POST",
topic="public.cps.za.test",
body=valid_payload,
headers={"Authorization": "Bearer token"},
)
resp = event_gate_module.lambda_handler(event)
assert 202 == resp["statusCode"]
body = json.loads(resp["body"])
assert body["success"]
def test_post_authorized_user_case_insensitive(event_gate_module, make_event, valid_payload, mocker):
mocker.patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "testuser"})
for writer in event_gate_module.handler_topic.writers.values():
mocker.patch.object(writer, "write", return_value=None)
event = make_event(
"/topics/{topic_name}",
method="POST",
topic="public.cps.za.test",
body=valid_payload,
headers={"Authorization": "Bearer token"},
)
resp = event_gate_module.lambda_handler(event)
assert 202 == resp["statusCode"]
body = json.loads(resp["body"])
assert True == body["success"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/handlers/test_handler_topic.py` around lines 260 - 277, Update
test_post_authorized_user_case_insensitive to use the mocker fixture with
mocker.patch.object for decode_jwt and each writer.write instead of a patch
context manager; rewrite assertions consistently as expected == actual,
including the success assertion, while preserving the test behavior.

Source: Coding guidelines

def test_post_passes_topic_key_to_writers(event_gate_module, make_event, valid_payload):
"""Configured topic key field is extracted and passed to writer.write."""
with patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "TestUser"}):
Expand Down
Loading