diff --git a/.vscode/settings.json b/.vscode/settings.json index 05eac07..7d6f68b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,4 +38,9 @@ "editor.fontFamily": "JetBrains Mono", "editor.fontSize": 13, "editor.fontLigatures": true, + "python.testing.pytestArgs": [ + "backend" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, } \ No newline at end of file diff --git a/backend/tests/test_digest_action_api.py b/backend/tests/test_digest_action_api.py index 91811b9..0ef356f 100644 --- a/backend/tests/test_digest_action_api.py +++ b/backend/tests/test_digest_action_api.py @@ -1,5 +1,7 @@ import os +import jwt import pytest +from datetime import datetime, timedelta, timezone from flask import Flask from werkzeug.security import generate_password_hash from tinydb import Query @@ -16,14 +18,26 @@ from utils.digest_token import ( from tests.conftest import TEST_SECRET_KEY TEST_USER_ID = "daa_user_id" +OTHER_USER_ID = "daa_other_user_id" TEST_CHILD_ID = "daa_child_id" TEST_TASK_ID = "daa_task_id" TEST_REWARD_ID = "daa_reward_id" FRONTEND_URL = "http://localhost:5173" +def make_auth_token(user_id: str) -> str: + """Create a JWT access token for the given user_id, for use in tests.""" + payload = { + 'user_id': user_id, + 'token_version': 0, + 'exp': datetime.now(timezone.utc) + timedelta(minutes=15), + } + return jwt.encode(payload, TEST_SECRET_KEY, algorithm='HS256') + + def seed_data(): users_db.remove(Query().id == TEST_USER_ID) + users_db.remove(Query().id == OTHER_USER_ID) child_db.remove(Query().id == TEST_CHILD_ID) task_db.remove(Query().id == TEST_TASK_ID) reward_db.remove(Query().id == TEST_REWARD_ID) @@ -44,6 +58,22 @@ def seed_data(): "marked_for_deletion_at": None, "email_digest_enabled": True, "timezone": "America/New_York", + "token_version": 0, + }) + users_db.insert({ + "id": OTHER_USER_ID, + "first_name": "Other", + "last_name": "User", + "email": "other@example.com", + "password": generate_password_hash("password"), + "verified": True, + "role": "user", + "image_id": None, + "marked_for_deletion": False, + "marked_for_deletion_at": None, + "email_digest_enabled": True, + "timezone": "America/New_York", + "token_version": 0, }) child_db.insert({ "id": TEST_CHILD_ID, @@ -111,67 +141,139 @@ def client(): seed_data() -class TestHandleDigestAction: - def test_approve_chore_redirects_to_child_view(self, client): +@pytest.fixture +def auth_client(client): + """Client pre-authenticated as TEST_USER_ID via JWT cookie.""" + client.set_cookie('access_token', make_auth_token(TEST_USER_ID)) + return client + + +class TestHandleDigestActionGet: + """GET /digest-action/: validates token, redirects with digestToken param, executes nothing.""" + + def test_redirects_to_parent_view_with_digest_token(self, client): add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') res = client.get(f'/digest-action/{token.id}') assert res.status_code == 302 location = res.headers['Location'] assert f'/parent/{TEST_CHILD_ID}' in location + assert f'digestToken={token.id}' in location assert 'scrollTo=' in location - def test_approve_chore_awards_points(self, client): + def test_does_not_execute_action(self, client): + """GET must not change any data; action is deferred to the authenticated POST.""" add_pending_chore() token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') client.get(f'/digest-action/{token.id}') child = child_db.get(Query().id == TEST_CHILD_ID) - assert child['points'] == 110 # 100 + 10 + assert child['points'] == 100 # unchanged - def test_deny_chore_removes_pending(self, client): + def test_does_not_consume_token(self, client): + """GET can be called multiple times without consuming the token.""" add_pending_chore() - token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny') - client.get(f'/digest-action/{token.id}') - pending = pending_confirmations_db.get( - (Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID) - ) - assert pending is None - - def test_approve_reward_deducts_points(self, client): - add_pending_reward() - token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve') - client.get(f'/digest-action/{token.id}') - child = child_db.get(Query().id == TEST_CHILD_ID) - assert child['points'] == 80 # 100 - 20 - - def test_deny_reward_removes_pending(self, client): - add_pending_reward() - token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny') - client.get(f'/digest-action/{token.id}') - pending = pending_confirmations_db.get( - (Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID) - ) - assert pending is None + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') + res1 = client.get(f'/digest-action/{token.id}') + res2 = client.get(f'/digest-action/{token.id}') + assert res1.status_code == 302 + assert res2.status_code == 302 def test_invalid_token_returns_400(self, client): res = client.get('/digest-action/nonexistent-token-id') assert res.status_code == 400 - def test_used_token_returns_400(self, client): - add_pending_chore() - token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') - client.get(f'/digest-action/{token.id}') # first use - res = client.get(f'/digest-action/{token.id}') # second use - assert res.status_code == 400 - - def test_approve_already_resolved_chore_still_redirects(self, client): - """If the chore was already resolved, action still returns a redirect (idempotent).""" + def test_already_resolved_chore_still_redirects(self, client): + """GET still redirects even if the pending chore no longer exists; token is valid.""" token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') # No pending chore added — already resolved res = client.get(f'/digest-action/{token.id}') assert res.status_code == 302 +class TestExecuteDigestActionPost: + """POST /digest-action/: requires auth, consumes token, executes action.""" + + def test_approve_chore_awards_points(self, auth_client): + add_pending_chore() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') + res = auth_client.post(f'/digest-action/{token.id}') + assert res.status_code == 200 + child = child_db.get(Query().id == TEST_CHILD_ID) + assert child['points'] == 110 # 100 + 10 + + def test_deny_chore_removes_pending(self, auth_client): + add_pending_chore() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny') + res = auth_client.post(f'/digest-action/{token.id}') + assert res.status_code == 200 + pending = pending_confirmations_db.get( + (Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID) + ) + assert pending is None + + def test_approve_reward_deducts_points(self, auth_client): + add_pending_reward() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve') + res = auth_client.post(f'/digest-action/{token.id}') + assert res.status_code == 200 + child = child_db.get(Query().id == TEST_CHILD_ID) + assert child['points'] == 80 # 100 - 20 + + def test_deny_reward_removes_pending(self, auth_client): + add_pending_reward() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny') + res = auth_client.post(f'/digest-action/{token.id}') + assert res.status_code == 200 + pending = pending_confirmations_db.get( + (Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID) + ) + assert pending is None + + def test_response_contains_success_payload(self, auth_client): + add_pending_chore() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') + res = auth_client.post(f'/digest-action/{token.id}') + assert res.status_code == 200 + data = res.get_json() + assert data['success'] is True + assert data['child_id'] == TEST_CHILD_ID + assert data['entity_id'] == TEST_TASK_ID + assert data['action'] == 'approve' + + def test_unauthenticated_returns_401(self, client): + add_pending_chore() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') + res = client.post(f'/digest-action/{token.id}') + assert res.status_code == 401 + + def test_wrong_user_returns_403(self, client): + """A token created for TEST_USER_ID cannot be used by OTHER_USER_ID.""" + add_pending_chore() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') + client.set_cookie('access_token', make_auth_token(OTHER_USER_ID)) + res = client.post(f'/digest-action/{token.id}') + assert res.status_code == 403 + + def test_invalid_token_returns_400(self, auth_client): + res = auth_client.post('/digest-action/nonexistent-token-id') + assert res.status_code == 400 + + def test_used_token_returns_400(self, auth_client): + add_pending_chore() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') + auth_client.post(f'/digest-action/{token.id}') # first use — consumes token + res = auth_client.post(f'/digest-action/{token.id}') # second use + assert res.status_code == 400 + + def test_get_after_post_returns_400(self, auth_client): + """Once the token is consumed via POST, the GET redirect should also fail.""" + add_pending_chore() + token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve') + auth_client.post(f'/digest-action/{token.id}') # consumes token + res = auth_client.get(f'/digest-action/{token.id}') + assert res.status_code == 400 + + class TestHandleDigestUnsubscribe: def test_valid_token_unsubscribes_user(self, client): token = create_unsubscribe_token(TEST_USER_ID)