From 4d44c8dcd6ec540508346bb270cdf0796cab50bf Mon Sep 17 00:00:00 2001 From: Roi Solomon <roisol144@gmail.com> Date: Sun, 3 Nov 2024 21:52:11 +0200 Subject: [PATCH] Deposit, Withdraw & Transfer Handlers --- .env | 3 +- .github/workflows/ci.yml | 6 +- __pycache__/auth_utils.cpython-39.pyc | Bin 1612 -> 2103 bytes __pycache__/users.cpython-39.pyc | Bin 3697 -> 4037 bytes auth_utils.py | 21 ++- bank_accounts.py | 195 ++++++++++++++++++++------ db.py | 46 ++++-- exceptions.py | 2 +- requirements.txt | 6 +- server.py | 54 ++----- test_app.py | 95 +++++++------ users.py | 14 +- 12 files changed, 288 insertions(+), 154 deletions(-) diff --git a/.env b/.env index e93aba1..4fb7102 100644 --- a/.env +++ b/.env @@ -3,4 +3,5 @@ FERNET_KEY=cYLeyg4Ljx31xEcdd9kWqSnIgo3RkusJEmOwZcKqrNM= DATABASE_URL = postgres://postgres:solo6755@postgres:5432/postgres TEST_DATABASE_URL = postgres://postgres:solo6755@postgres:5432/test_db # fix it FLASK_ENV = development -TOKEN_EXPIRATION_HOURS = 1 \ No newline at end of file +TOKEN_EXPIRATION_HOURS = 1 +SALT = 52c9f9f1cf12deafde09e6257abbd06b \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 673292f..69e9eb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,9 +54,10 @@ jobs: # Install dependencies - name: Install dependencies run: | - source venv/bin/activate + python -m venv venv + source venv/bin/activate pip install -r requirements.txt - + - name: Lint with Flake8 run: | source venv/bin/activate @@ -64,4 +65,5 @@ jobs: - name: Tests run: | + source venv/bin/activate python test_app.py diff --git a/__pycache__/auth_utils.cpython-39.pyc b/__pycache__/auth_utils.cpython-39.pyc index 8f5f708417266d4470e39d39e17b4d020d69065a..90cd73883104b18aab47417f291b3e3676a996ff 100644 GIT binary patch delta 1154 zcma)4O>f&q5Zzf)BqdR_6iIgck%n%Ox@sH62-2X3ph#LMhqf?^+6WL`1X{b6=uk^z zxpFK+fq|&K6zIX=9}s^-4?XwZKTra_7yS!Gi_XeQ5%^RRe6u_Au-tj`eszDYdO@X9 zBKUY>XCRzQZwvms{OI!wh>G1JBg~9SqjI+l1lNVg9aXv&AiO*yh|aM$Utv}T-Rg2} z<GRLc48}zMz;7^T2Csovcx_(K$PBs--dr+^8Q^}E+sAA8-DD+h&(~Qw1B@FPX%8rK ze>Cv773O_Mx|?i;RWWX{8oPn<7OS%c#&;O(lUDQckN4ej6bG!wV#UR2T84Lb&)ZKs z-|lq|dS7*(`yy8UQ9KsR4~376P{v}!h4N1W83u<DPuf4-g;l6Ks6}oZzf`%!ML%X- z+UYa84eRL{-CNtjYT|0WXagLee_Mqr$j$V3dJ9zg7k%{j8$fA*C}VE2LIyydqjdYk zB#Inc6Y*9Rg?m%oU2t0Vo?J<1#;0`;e{k*{#4ot;J7*`M<SfmNZLfj(++uurucG;Q z=O(7)nuhiNU_;Z~Q!c}!Nl)oh?xZJX14`-l<~}sj-^|^MYwT5QR!&Y-FX;DCCe;(; z(IJ=j@ub8=AmD*~4`UznZuuA(b@dDUlBWoY%z;4$uV4y{pbIt>WmLOB8B?On8BIRM zj4`{=slwcvk}0f_xt);==1xXl(J94=ntQpq|K#g~^A9xspXr_ZPp)q6U)|gvjT7Y` zazF4BB||Z2w~9H9liV6`w8+J2UV0WpW8RT6mU$^mLXoII^m%R|KevZLGK|8*+)9SQ z{ZAgsCU%z>hy09%1D>dsA-8qLOoB+|P0i-)nt0bIQrD~{=k6PYA8P6Jya|-hQ4U3< zt03=TrgdBDiOX90WubK>@96c12xx(Y&1C^M-TGp1@jwPA!%6#yOTm@2G|gTbJvXpt zX=`%ns@{9SCvpc9i@v0<*+wXobkbi64~Cn%a$AEQ`Qk2bHM6kJ!eh>(QOw2>|4f@A jX(M0<Pz6_ag0G9OMP2F|x-%E57%5c$u}r7vR2=hPW40a( delta 658 zcmYjO&2G~`5Z>{B9NTpplfTj?E#i<?LKSe}5Fv!b4TVEM2n(qy-AzcE*wMx<xIzL6 zt{keBxKyC{892ZjaN!Aj;7lI^#B2yCyV`GNzxifoM*GG8?sx~TYZF`_9&Pro8fV^R z_<FW`_q8@EjP1|{!mMF&=1@I@&|UZj`W~~HH(SZbTL^u&x`>z=;C+pm!xEl=TFjhX zqJ<35mNU{<sQq3ALW|USN5TqqsfV^oSE!G+Mxjeus~6af*o1BI02-e^K^aOWGPyc@ zk>m!84r9u=A-*YFP!$(SrxIXv>L3s$01K2~hVBEei;lVhZ^TG#-+u~3?nIqwRDT6j ztC4d;jgM*uK%Njvx<n%h85-&7k`jmc7u}m{T2_a9;-`A6;bO7V%ES1GMa_FJCj-uC zvlsK`@s#mq4&sKk<&{vA8<Z{jeWu-MSCHam3Dy5llQQRo@j)-$Pvnam$V}x>1Qb|< z0BrD}EPiNR2*h=Lueg*f#`^mDj!e+z2_En(=(Orwmgl~3%vL8?;%Qhq<mn`d`}|<? zJiXRqJYosgQIwl3IvMad%GEwn22!E@H@<(wQZ75;O(|3dQNm4eZr*%cmt;eNEVney qytnyGR(l-N;}N?JsF-deSok|WIEWTF3U2g$6@Lu;--c!tEcXvWS&U`? diff --git a/__pycache__/users.cpython-39.pyc b/__pycache__/users.cpython-39.pyc index 46a8e2607bc6458e934809b50c658b56acf33d96..a3c4367ccbc5fe61170384a84b9b4391f3e9f645 100644 GIT binary patch delta 1214 zcmZ8g&2Jk;6rY*>%zC|bz8pJol5G_TK|mrkAVK<}mYODj%psIQK&p&q8##4$)8}>C zSZjPJN05RBacL^q;=~0Bapu?_koX5I4oIBB0ffXUQY+qDLipIx{^srb+&Awxzn31) zdev;!Vfd84pWECT{l?oA{1>$=2J;*7@ICf{=s2BB;`ByjuFpA3rQyU%m>elG#8LH` zc6Q|poyFt(eD7n<Ixc7JS7NJY%dtee&*XUG-e(W=G&<AE_ZergCbKeM=l8grP`_zY z^Ai~R2xGGt`)q7&*|H#ua`IDg>^qhEmekAo>FMLTX?3&6<n)1#d1h{kb+$13<sF^d zZS!*Z>RM$*ePVp4w#_SQ)tnnS^47vQfUw%|%K#A^{-M4$XLv#VVD=`#z!MnQ5H%Vf zR%^EgL*=`*U?;9eq3T$>nGJF6odFNNVsKtPv2LH{4lYykAM3i!ZSHD0p2vMo<haM5 z*5Kl|`}Vt>PpQM9bNM6hS~CE@62<;&Q7e>(pL52J)QVGbrm-r_ARMWx^VZTbN(&l{ z)4%8~41#myaQ*}?kj35%;u~arL;d8O?XID&?Jkm{x!8y{>tPKwCy#{^&G%J&o3CT# znDuy{b#Qi2l00F%yjhZhc6crpQj>am)&`wt!g`wIecnMolmtqm?Z$e7lj~^LS-7uv zS#0cu9EXadqHr?}w{8lPy~oxW62sg(IJVPZZ8;>Jbg<-|Nxj{Fc@ORVh(yp&h>-n{ zzxTKQbw>Fm>vr^y^mP<yUt&uv(e^pK6g$04qRCUp644i_#@%vzS?t`1J+$PC40|w< zr>Ep_!jY#ViGeu!z#yh#D`SZ%#}5Q@D^F*UHaC%$FVMHd<`#;TZaOZ=<MF~ILfbEI zSL+SG8cKhs+HBs5K>Ae(d<bs0>JZ2mpg_Kt31}#A1>tR&BJqR|?b4;tZ-v3#ouC#6 z(nl&p;77Gu3or$g(Rq+f)2p8|?;E#*5H6`z_o<~EXq4y-flhFhfCvCCL55(SfPNO1 zRo8vK`wWT;7Q9FrBHv%#tdc|o+;CD_-H7Yk0lZ1t|1r`??EzYvL}4auZH(sxUJ@a= zi)##D57DFRAqseTTwdUU))P}R#be!oiaL}1b@n2qCrU$w;GaUNbU`h8E7r2doubG8 E1e3uK*Z=?k delta 903 zcmZ9K&rcIU6vt;~e|Fkkp|r(9f%@B;7*LECe#BpBBoPk;e-IPWFiTT@?CNwtY_@XX zs)S^`0m(L=I2zBM)Pw(l>B%b>6BABGjc-<h3A5SHym|BH<vZ`q=e{2Wx9m6+!6+A< z%3}LFw?gnQxk6gkg=k+#Pe_9{k|AYi8P)c~q?$J|H-*L=)>g&qn6=N5hiDRoG)l8{ zXoZM%<Pk6<pKF6_8#)r%P5fd5qecp&`UPRl+bqj+tn)R&=x81jJw^;7U#2Dx<^cp5 za**LGWN;wEWno1&J05CVh~-0P3+?Dle2}RtR@AK&{mUdT>76|XdSX>(9<jo<4!V2h z$RsNDzV&pR{psHIg~$E!VsVV~fbZ>NgyfR(K?de2Su)28yLyXM8Sj8f@r*(iu=fQ} z>XaYNsI8S4Tb=cuifZ6<1KhG<tt4C6n<i5ab`c+zf2?OG2*qifCZ<;Wts6GBaf-BI z7oP^^@L7$IzTdPTVcaA462rN=J5}>|aUu}K+rhHWs&7D4T@KL#?N$m3fVwQx`vZeY zh89a|>C14452=9RLyUmcvMZjLQM>o#H#)d>3w-rdzjD<27lTUGp9Rn1Fu}6mzX^Zs z6+oZ}H&Fvx8xe+^xPfDffJKBB!c%Byp~hE*9vPv=G$=P|5?Gt_W@N!hUD)8)^9Ujh zaVHT`W`r2JXofn_+A-ojCYc=?EU`_XM^yDlBw3QB;?6p1&EU0_+==+QJSPpuquzqg zuYpG1B?IS7I>j}$IH_<$fyz(L#qv32o>$NTC6ixKwrV8LDX6PX)3_8fqPpa@3=u4N z{?R%baYO}zYrOzOY)s*^Nk*343?7#&?)Sb?*x;(fd|cswD`IOzt{)q+uDY1!UHliN C63f&8 diff --git a/auth_utils.py b/auth_utils.py index 0b70144..21eee39 100644 --- a/auth_utils.py +++ b/auth_utils.py @@ -4,11 +4,19 @@ from functools import wraps import logging import os +import hashlib +from dotenv import load_dotenv from cryptography.fernet import Fernet +load_dotenv() SECRET_KEY = os.environ.get('SECRET_KEY') # Keys fernet_key = os.environ.get('FERNET_KEY') + +# Check if fernet_key is None and raise an error if it is +if fernet_key is None: + raise ValueError("FERNET_KEY not found in environment variables.") + fernet = Fernet(fernet_key.encode()) TOKEN_EXPIRATION_HOURS = int(os.environ.get('TOKEN_EXPIRATION_HOURS')) @@ -32,7 +40,7 @@ def verify_token(token): logging.debug(f"Authentication token valid for user {payload['sub']}") return payload['sub'] except jwt.ExpiredSignatureError: - logging.debug(f"Token Expired for user {payload['sub']}.") + logging.debug("Token Expired.") return None except jwt.InvalidTokenError: logging.debug("Invalid token was supplied.") @@ -42,6 +50,17 @@ def verify_token(token): def encrypt_account_number(account_number): encrypted_account_number = fernet.encrypt(account_number.encode()) return encrypted_account_number + +def hash_account_number(account_number): + salt = os.getenv('SALT') + if salt is None: + raise ValueError("SALT not found in environment variables.") + + if not isinstance(account_number, str): + raise ValueError("Account number must be a string.") + + hashed_account_number = hashlib.sha256((account_number + salt).encode()).hexdigest() + return hashed_account_number \ No newline at end of file diff --git a/bank_accounts.py b/bank_accounts.py index 727e645..9c4085a 100644 --- a/bank_accounts.py +++ b/bank_accounts.py @@ -1,14 +1,15 @@ from flask import Blueprint, request, jsonify, g import logging from flask_bcrypt import Bcrypt -from db import get_db_connection, get_user_by_email, check_is_valid_user_id, check_is_valid_account_number, get_account_id_by_user_id +from db import get_db_connection, get_accounts_numbers_by_user_id, check_is_valid_user_id, check_is_valid_account_number, get_accounts_id_by_user_id from exceptions import UserNotFoundError, DatabaseConnectionError, InsufficientFundsError, AccountNotFoundError from uuid import uuid4 import datetime import os -from auth_utils import encrypt_account_number +from auth_utils import encrypt_account_number, hash_account_number import hashlib from enum import Enum +import random # logging config @@ -30,8 +31,6 @@ class Status(Enum): SUSPENDED = 'SUSPENDED' CLOSED = 'CLOSED' - - # Create a Blueprint for users bank_accounts_bp = Blueprint('bank_accounts', __name__) bcrypt = Bcrypt() @@ -62,43 +61,50 @@ def get_bank_accounts(): ] return jsonify(accounts_list), 200 - + + except DatabaseConnectionError as e: + return jsonify({'error': 'Internal Error'}), 500 except Exception as e: logging.debug("General Error occured.") return jsonify({'error': 'Internal Error'}), 500 - except DatabaseConnectionError as e: - return jsonify({'error': 'Internal Error'}), 500 + @bank_accounts_bp.route('/bank_accounts', methods=['POST']) def create_bank_account(): data = request.get_json() user_id = data['user_id'] + currency = data['currency'] + account_type = data['account_type'] try: user_id = check_is_valid_user_id(str(data['user_id'])) if not user_id: raise UserNotFoundError("Invalid user id.") + if not currency: # Might need to validate it in the case of an unexpected value + currency = Currency.USD.value + if not account_type: + account_type = AccountType.CHECKINGS.value # Might need to validate it in the case of an unexpected value # Required params account_id = str(uuid4()) - account_number = str(int(uuid4()))[:6] + account_number = str(random.randint(100000, 999999)) logging.debug(f"Account number: {account_number}") - salt = os.urandom(16).hex() + salt = str(os.getenv('SALT')) hashed_account_number = hashlib.sha256((account_number + salt).encode()).hexdigest() encrypted_account_number = encrypt_account_number(account_number).decode('utf-8') - currency = Currency.USD.value balance = 0 # change it to other default value that will be config - account_type = AccountType.CHECKINGS.value created_at = datetime.datetime.now() status = Status.ACTIVE.value cursor, conn = get_db_connection() + cursor.execute("BEGIN;") cursor.execute(""" INSERT INTO bank_accounts (id, user_id, account_number, balance, type, currency, created_at, status, hashed_account_number) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id; """, (account_id, user_id, encrypted_account_number, balance, account_type, currency, created_at, status, hashed_account_number)) + conn.commit() cursor.close() conn.close() @@ -107,71 +113,178 @@ def create_bank_account(): except UserNotFoundError as e: return jsonify({'error' : 'User not found'}), 404 except Exception as e: + conn.rollback() return jsonify({'error': 'Internal Error'}), 500 return jsonify({'account_id': account_id, 'account_number': encrypted_account_number, 'created_at': created_at}), 201 + +@bank_accounts_bp.route('/bank_accounts/deposit', methods=['POST']) +def deposit(): + data = request.get_json() + amount = data['amount'] + transfer_id = str(uuid4()) + account_number_for_deposit = data['account_number'] + current_user_id = g.current_user_id + transaction_type = "DEPOSIT" + user_hashed_accounts_numbers_list = get_accounts_numbers_by_user_id(current_user_id) + hashed_account_number = hash_account_number(account_number_for_deposit) + + if not amount or not isinstance(amount, (int, float)): + raise ValueError("Invalid/Missing amount.") + if not user_hashed_accounts_numbers_list: + raise AccountNotFoundError("User has no bank accounts.") + + if hashed_account_number not in user_hashed_accounts_numbers_list: + raise AccountNotFoundError("No account found.") + + try: + if amount <= 0: + return jsonify({'error': 'Amount must be positive.'}), 400 + + cursor, conn = get_db_connection() + cursor.execute("BEGIN;") + + cursor.execute("UPDATE bank_accounts SET balance = balance + %s WHERE hashed_account_number = %s", (amount, hashed_account_number)) + cursor.execute(""" + INSERT INTO transactions (id, from_account, to_account, amount, type, transaction_date) + VALUES (%s, %s, %s, %s, %s, %s)""", + (transfer_id, hashed_account_number, hashed_account_number, amount, transaction_type, datetime.datetime.now())) + conn.commit() + return jsonify({'message': 'Deposit Successfully!'}), 200 + + except ValueError as e: + conn.rollback() + return jsonify({'error': str(e)}), 400 + + except AccountNotFoundError as e: + conn.rollback() + return jsonify({'error': str(e)}), 400 + + except Exception as e: + conn.rollback() + return jsonify({'error': 'Deposit failed.'}), 400 + + finally: + if cursor: + cursor.close() + if conn: + conn.close() + +@bank_accounts_bp.route('/bank_accounts/withdraw', methods=['POST']) +def withdraw(): + data = request.get_json() + amount_to_withdraw = data['amount'] + transaction_type = "WITHDRAW" + account_number_for_withdrawal = data['account_number'] + transfer_id = str(uuid4()) + current_user_id = g.current_user_id + user_hashed_accounts_numbers_list = get_accounts_numbers_by_user_id(current_user_id) + hashed_account_number = hash_account_number(account_number_for_withdrawal) + + if not amount_to_withdraw or not isinstance(amount_to_withdraw, (int, float)): + raise ValueError("Invalid/Missing amount.") + if not user_hashed_accounts_numbers_list: + raise AccountNotFoundError("User has no bank accounts.") + + if hashed_account_number not in user_hashed_accounts_numbers_list: + raise AccountNotFoundError("No account found.") + + try: + if amount_to_withdraw <= 0: + return jsonify({'error': 'Amount must be positive.'}),400 + cursor, conn = get_db_connection() + + if hashed_account_number not in user_hashed_accounts_numbers_list: + raise AccountNotFoundError("No account found.") + + cursor.execute("SELECT balance FROM bank_accounts WHERE hashed_account_number = %s", (hashed_account_number,)) + account_to_withdraw_from = cursor.fetchone() + if account_to_withdraw_from['balance'] < amount_to_withdraw: + raise InsufficientFundsError("Insufficient Funds In Account.") + + cursor.execute("BEGIN;") + cursor.execute("UPDATE bank_accounts SET balance = balance - %s WHERE hashed_account_number = %s", (amount_to_withdraw, hashed_account_number)) + cursor.execute(""" + INSERT INTO transactions (id, from_account, to_account, amount, type, transaction_date) + VALUES (%s, %s, %s, %s, %s, %s)""", + (transfer_id, hashed_account_number, hashed_account_number, amount_to_withdraw, transaction_type, datetime.datetime.now())) + conn.commit() + return jsonify({'message': 'Withdraw Succeded!'}), 200 + + except InsufficientFundsError as e: + return jsonify({'error': str(e)}), 400 + + except AccountNotFoundError as e: + return jsonify({'error': str(e)}), 400 + + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + except Exception as e: + logging.debug(f"Error: {e}") + return jsonify({'error' : 'General Erorr'}), 500 + + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + + @bank_accounts_bp.route('/bank_accounts/transfer', methods=['POST']) def transfer(): data = request.get_json() transfer_id = str(uuid4()) to_account_number = data.get('to_account_number') + from_account_number = data.get('from_account_number') + hashed_account_number_of_reciever = hash_account_number(to_account_number) + hashed_account_nunber_of_sender = hash_account_number(from_account_number) + tansaction_type = "TRANSFER" amount = data.get('amount') - transaction_type = data.get('transaction_type') - logging.debug(f"Parsed data: transfer_id={transfer_id}, to_account_number={to_account_number}, amount={amount}, transaction_type={transaction_type}") - # Validate input data if not amount or not isinstance(amount, (int, float)): return jsonify({'error': 'Invalid amount'}), 400 - if not transaction_type or transaction_type not in ['WITHDRAW', 'DEPOSIT']: - return jsonify({'error': 'Invalid transaction type'}), 400 try: if amount <= 0: return jsonify({'error': 'Amount must be positive'}), 400 - from_account_id = get_account_id_by_user_id(g.current_user_id) - logging.debug(f"From account ID: {from_account_id}") - - if not from_account_id: - raise AccountNotFoundError("Account not found for the current user") + if not to_account_number: + raise ValueError("Account number is required for deposit.") + # Establish db connection and transaction cursor, conn = get_db_connection() - + cursor.execute("BEGIN;") + # Check if the sender's account exists - cursor.execute("SELECT balance FROM bank_accounts WHERE id = %s", (from_account_id,)) + # Note to self - Check how to auth works here, how its possible to take money from account - will it auth first ? + cursor.execute("SELECT balance FROM bank_accounts WHERE hashed_account_number = %s", (hashed_account_nunber_of_sender,)) sender_account = cursor.fetchone() if not sender_account: raise AccountNotFoundError("Sender's account not found") sender_balance = sender_account['balance'] - if sender_balance < amount: raise InsufficientFundsError("Insufficient funds.") - if transaction_type == 'WITHDRAW': - to_account_id = None - cursor.execute("UPDATE bank_accounts SET balance = balance - %s WHERE id = %s", (amount, from_account_id)) - - elif transaction_type == 'DEPOSIT': - if not to_account_number: - raise ValueError("Account number is required for deposit.") - - receiver_account_id = check_is_valid_account_number(to_account_number) - if not receiver_account_id: - raise ValueError("Invalid receiver account number") + # Validate Bank Account Number - + cursor.execute("SELECT balance FROM bank_accounts WHERE hashed_account_number = %s", (hashed_account_number_of_reciever,)) + account_to_transfer_money_to = cursor.fetchone() + # Check if no account found + if not account_to_transfer_money_to: + raise AccountNotFoundError("No Account Found.") - cursor.execute("UPDATE bank_accounts SET balance = balance - %s WHERE id = %s", (amount, from_account_id)) - cursor.execute("UPDATE bank_accounts SET balance = balance + %s WHERE id = %s", (amount, receiver_account_id)) - to_account_id = receiver_account_id - + cursor.execute("UPDATE bank_accounts SET balance = balance - %s WHERE hashed_account_number = %s", (amount, hashed_account_nunber_of_sender)) + cursor.execute("UPDATE bank_accounts SET balance = balance + %s WHERE hashed_account_number = %s", (amount, hashed_account_number_of_reciever)) cursor.execute(""" INSERT INTO transactions (id, from_account, to_account, amount, type, transaction_date) VALUES (%s, %s, %s, %s, %s, %s)""", - (transfer_id, from_account_id, to_account_id, amount, transaction_type, datetime.datetime.now())) - + (transfer_id, hashed_account_number_of_reciever, hashed_account_nunber_of_sender, amount, tansaction_type, datetime.datetime.now())) + conn.commit() return jsonify({'message': 'Transfer successful'}), 200 except (DatabaseConnectionError, InsufficientFundsError, ValueError, AccountNotFoundError) as e: diff --git a/db.py b/db.py index 10aada0..0a6e7dc 100644 --- a/db.py +++ b/db.py @@ -15,19 +15,17 @@ def get_db_connection(): - if os.getenv('FLASK_ENV') == 'TEST': - db_url = os.getenv('TEST_DATABASE_URL') - else: - db_url = os.getenv('DATABASE_URL') + db_url = os.getenv('DATABASE_URL') try: conn = psycopg2.connect(db_url) - conn.autocommit = True cursor = conn.cursor(cursor_factory=RealDictCursor) + conn.set_isolation_level('SERIALIZABLE') return cursor, conn except Exception as e: logging.error("Failed to connect to DB.", exc_info=True) - raise DatabaseConnectionError("Could not connect to DB.") + raise DatabaseConnectionError("Could not connect to DB.") + def get_user_by_email(email): try: cursor, conn = get_db_connection() @@ -86,7 +84,6 @@ def check_is_valid_account_number(account_number): cursor, conn = get_db_connection() if cursor is None or conn is None: raise DatabaseConnectionError("Failed to connect") - # Here change the encrypted with the hashed account number - hash it and then search for it inside the db hashed_account_number = hashlib.sha256(account_number.encode()).hexdigest() logging.debug(f"Hashed account number: {hashed_account_number}") cursor.execute("SELECT * FROM bank_accounts WHERE hashed_account_number = %s", (hashed_account_number,)) @@ -111,19 +108,41 @@ def check_is_valid_account_number(account_number): if conn: conn.close() -def get_account_id_by_user_id(user_id): +def get_accounts_id_by_user_id(user_id): try: cursor, conn = get_db_connection() if cursor is None or conn is None: raise DatabaseConnectionError("Failed to connect to db.") cursor.execute("SELECT id FROM bank_accounts WHERE user_id = %s", (user_id,)) - result = cursor.fetchone() + results = cursor.fetchall() # Fetch all results - if result: - return result['id'] - else: - return None + return [result['id'] for result in results] if results else None + + except DatabaseConnectionError: + logging.error(f"Database connection error: {user_id}", exc_info=True) + return None + except AccountNotFoundError: + logging.error(f"Account not found for user: {user_id}", exc_info=True) + return None + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + +def get_accounts_numbers_by_user_id(user_id): + try: + cursor, conn = get_db_connection() + if cursor is None or conn is None: + raise DatabaseConnectionError("Failed to connect to db.") + + cursor.execute("SELECT hashed_account_number FROM bank_accounts WHERE user_id = %s", (user_id,)) + results = cursor.fetchall() # Fetch all results + + return [result['hashed_account_number'] for result in results] if results else None + except DatabaseConnectionError: logging.error(f"Database connection error: {user_id}", exc_info=True) return None @@ -135,3 +154,4 @@ def get_account_id_by_user_id(user_id): cursor.close() if conn: conn.close() + diff --git a/exceptions.py b/exceptions.py index 1ab2e9f..00d63a6 100644 --- a/exceptions.py +++ b/exceptions.py @@ -8,7 +8,7 @@ def __init__(self): super().__init__("Failed to connect to the database.") class TokenVerificationError(Exception): - def __init__(self): + def __init__(self,message): super().__init__("Token verification failed.") class InsufficientFundsError(Exception): diff --git a/requirements.txt b/requirements.txt index 6cbb840..7fde641 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -cryptography==43.0.1 +cryptography==43.0.3 +Faker==30.8.2 Flask==3.0.3 Flask_Bcrypt==1.0.1 psycopg2_binary==2.9.9 PyJWT==2.9.0 PyJWT==2.9.0 -pytest==8.3.3 -flake8==7.1.1 +python-dotenv==1.0.1 diff --git a/server.py b/server.py index 74a05ab..6b26c95 100644 --- a/server.py +++ b/server.py @@ -45,13 +45,6 @@ def log_reponse_info(response): ('/users/register', 'POST'): 'register' } -closed_routes = { - ('/users', 'GET'): 'get_user', - ('/bank_accounts', 'POST'): 'create_bank_account', - ('/bank_accounts', 'GET') : 'get_bank_account', - ('/bank_accounts/transfer', 'POST') : 'transfer' -} - # Middleware for token auth @app.before_request def auth_token(): @@ -60,73 +53,46 @@ def auth_token(): request_key = (request_path, request_method) logging.debug(f"Request key: {request_key}") logging.debug(f"Open routes: {open_routes}") - logging.debug(f"Closed routes: {closed_routes}") - logging.debug(f"All registered routes: {[str(rule) for rule in app.url_map.iter_rules()]}") - + if request_key in open_routes: logging.debug(f"Matched open route: {request_key}") return - elif request_key in closed_routes: - logging.debug(f"Matched closed route: {request_key}") + + route_exists = any(rule.rule == request_path and rule.methods.intersection({request_method}) for rule in app.url_map.iter_rules()) + + if route_exists: # Check if the route is valid + logging.debug(f"Route requires authorization: {request_key}") auth_header = request.headers.get('Authorization') if not auth_header: - return jsonify({'error' : 'Authorization header is missing.'}), 401 + return jsonify({'error': 'Authorization header is missing.'}), 401 auth_parts = auth_header.split() - if len(auth_parts) < 2: - return jsonify({'error': 'Invalid authorizaion header format.'}), 401 + return jsonify({'error': 'Invalid authorization header format.'}), 401 scheme = auth_parts[0].lower() if scheme == 'bearer': - # Assuming after the bearer the token should appear token = auth_parts[1] - try: current_user_id = verify_token(token) if not current_user_id: raise TokenVerificationError - return jsonify({'error': 'Invalid token'}), 401 - g.current_user_id = current_user_id # setting the current user id to the global variable + g.current_user_id = current_user_id # setting the current user id to the global variable - except Exception as e: - return jsonify({'error': 'General Internal Error'}), 500 - except TokenVerificationError as e: logging.debug(f"Failed to authenticate the token of user {current_user_id}", exc_info=True) return jsonify({'error': 'Authentication failed'}), 401 - # incase there are more than two parts to the auth_parts - # After meeting this part of code is redudant - # Delete after this part after inserting role as part of the token - # For later use - elif scheme == 'token': - token = auth_parts[1] - role = auth_parts[2] if len(auth_parts) > 2 else None - - try: - current_user_id = verify_token(token) - - if not current_user_id: - raise TokenVerificationError - return jsonify({'error': 'Invalid token'}), 401 - g.current_user_id = current_user_id - g.role = role - except TokenVerificationError as e: - logging.debug(f"Failed to authenticate the token of user {current_user_id}", exc_info=True) - return jsonify({'error': 'Authentication failed'}), 401 else: logging.debug(f"Invalid authorization scheme {scheme}") return jsonify({'error': 'Unsupported authorization scheme.'}) - - else: # route not in open/close routes + else: # Route not found logging.debug(f"Route not found: {request_key}") return jsonify({'error': 'Not Found'}), 404 - def check_database_connection(): cursor, conn = get_db_connection() diff --git a/test_app.py b/test_app.py index 5b8feec..2a26cf0 100644 --- a/test_app.py +++ b/test_app.py @@ -7,6 +7,7 @@ import json from datetime import datetime from dotenv import load_dotenv +from faker import Faker load_dotenv() @@ -17,6 +18,7 @@ def setUp(self): self.app.register_blueprint(bank_accounts_bp) self.app.register_blueprint(users_bp) self.client = self.app.test_client() + self.fake = Faker() # Bank Account Tests @@ -28,24 +30,24 @@ def test_get_bank_accounts(self, mock_get_db_connection): mock_cursor.fetchall.return_value = [ { - 'id': '123', - 'user_id': '456', - 'account_number': '789', - 'balance': 1000, - 'type': 'CHECKINGS', + 'id': self.fake.uuid4(), + 'user_id': self.fake.uuid4(), + 'account_number': self.fake.bban(), + 'balance': self.fake.random_int(min=100, max=10000), + 'type': self.fake.random_element(elements=('CHECKINGS', 'SAVINGS')), 'currency': 'USD', - 'created_at': '2023-01-01', + 'created_at': self.fake.date_this_decade().isoformat(), 'status': 'ACTIVE' } ] - response = self.client.get('/bank_accounts?user_id=456') + response = self.client.get(f'/bank_accounts?user_id={mock_cursor.fetchall()[0]["user_id"]}') self.assertEqual(response.status_code, 200) data = json.loads(response.data) self.assertEqual(len(data), 1) - self.assertEqual(data[0]['id'], '123') - self.assertEqual(data[0]['user_id'], '456') + self.assertEqual(data[0]['id'], mock_cursor.fetchall()[0]['id']) + self.assertEqual(data[0]['user_id'], mock_cursor.fetchall()[0]['user_id']) @patch('bank_accounts.get_db_connection') @patch('bank_accounts.check_is_valid_user_id') @@ -56,18 +58,21 @@ def test_create_bank_account(self, mock_encrypt, mock_uuid4, mock_check_user, mo mock_conn = MagicMock() mock_get_db_connection.return_value = (mock_cursor, mock_conn) - mock_check_user.return_value = '456' - mock_uuid4.return_value.int = 123456 - mock_uuid4.return_value = '123' + mock_check_user.return_value = self.fake.uuid4() + mock_uuid4.return_value = self.fake.uuid4() mock_encrypt.return_value = b'encrypted_account_number' response = self.client.post('/bank_accounts', - data=json.dumps({'user_id': '456'}), + data=json.dumps({ + 'user_id': mock_check_user.return_value, + 'currency': 'USD', + 'account_type': self.fake.random_element(elements=('CHECKINGS', 'SAVINGS')) + }), content_type='application/json') self.assertEqual(response.status_code, 201) data = json.loads(response.data) - self.assertEqual(data['account_id'], '123') + self.assertEqual(data['account_id'], mock_uuid4.return_value) self.assertEqual(data['account_number'], 'encrypted_account_number') # User Tests @@ -80,50 +85,56 @@ def test_register_user(self, mock_uuid4, mock_hash, mock_get_db_connection): mock_conn = MagicMock() mock_get_db_connection.return_value = (mock_cursor, mock_conn) - mock_uuid4.return_value = '123' + mock_uuid4.return_value = self.fake.uuid4() mock_hash.return_value = b'hashed_password' + # Store generated values + first_name = self.fake.first_name() + last_name = self.fake.last_name() + email = self.fake.email() + password = self.fake.password() + response = self.client.post('/users/register', data=json.dumps({ - 'first_name': 'John', - 'last_name': 'Doe', - 'email': 'john@example.com', - 'password': 'password123' + 'first_name': first_name, + 'last_name': last_name, + 'email': email, + 'password': password }), content_type='application/json') self.assertEqual(response.status_code, 201) data = json.loads(response.data) - self.assertEqual(data['id'], '123') - self.assertEqual(data['first_name'], 'John') - self.assertEqual(data['last_name'], 'Doe') - self.assertEqual(data['email'], 'john@example.com') + self.assertEqual(data['id'], mock_uuid4.return_value) + self.assertEqual(data['first_name'], first_name) # Use stored first name + self.assertEqual(data['last_name'], last_name) # Use stored last name + self.assertEqual(data['email'], email) # Use stored email @patch('users.get_user_by_email') def test_get_user(self, mock_get_user): mock_get_user.return_value = { - 'id': '123', - 'first_name': 'John', - 'last_name': 'Doe', - 'email': 'john@example.com' + 'id': self.fake.uuid4(), + 'first_name': self.fake.first_name(), + 'last_name': self.fake.last_name(), + 'email': self.fake.email() } - response = self.client.get('/users?email=john@example.com') + response = self.client.get(f'/users?email={mock_get_user.return_value["email"]}') self.assertEqual(response.status_code, 200) data = json.loads(response.data) - self.assertEqual(data['id'], '123') - self.assertEqual(data['first_name'], 'John') - self.assertEqual(data['last_name'], 'Doe') - self.assertEqual(data['email'], 'john@example.com') + self.assertEqual(data['id'], mock_get_user.return_value['id']) + self.assertEqual(data['first_name'], mock_get_user.return_value['first_name']) + self.assertEqual(data['last_name'], mock_get_user.return_value['last_name']) + self.assertEqual(data['email'], mock_get_user.return_value['email']) @patch('users.get_user_by_email') @patch('users.bcrypt.check_password_hash') @patch('users.generate_token') def test_login_user(self, mock_generate_token, mock_check_password, mock_get_user): mock_get_user.return_value = { - 'id': '123', - 'email': 'john@example.com', + 'id': self.fake.uuid4(), + 'email': self.fake.email(), 'password_hash': 'hashed_password' } mock_check_password.return_value = True @@ -131,7 +142,7 @@ def test_login_user(self, mock_generate_token, mock_check_password, mock_get_use response = self.client.post('/users/login', data=json.dumps({ - 'email': 'john@example.com', + 'email': mock_get_user.return_value['email'], 'password': 'password123' }), content_type='application/json') @@ -139,14 +150,14 @@ def test_login_user(self, mock_generate_token, mock_check_password, mock_get_use self.assertEqual(response.status_code, 200) data = json.loads(response.data) self.assertEqual(data['message'], 'Logged In Successfully') - self.assertEqual(data['toekn'], 'fake_token') # Note: There's a typo in your original code ('toekn' instead of 'token') + self.assertEqual(data['token'], 'fake_token') def test_register_user_missing_field(self): response = self.client.post('/users/register', data=json.dumps({ - 'first_name': 'John', - 'last_name': 'Doe', - 'email': 'john@example.com' + 'first_name': self.fake.first_name(), + 'last_name': self.fake.last_name(), + 'email': self.fake.email() # Missing password field }), content_type='application/json') @@ -161,7 +172,7 @@ def test_login_user_invalid_credentials(self, mock_get_user): response = self.client.post('/users/login', data=json.dumps({ - 'email': 'nonexistent@example.com', + 'email': self.fake.email(), 'password': 'wrongpassword' }), content_type='application/json') @@ -173,9 +184,9 @@ def test_login_user_invalid_credentials(self, mock_get_user): @patch('users.get_user_by_email') def test_get_user_not_found(self, mock_get_user): # Provide the required argument for UserNotFoundError - mock_get_user.side_effect = UserNotFoundError(email='nonexistent@example.com') + mock_get_user.side_effect = UserNotFoundError(email=self.fake.email()) - response = self.client.get('/users?email=nonexistent@example.com') + response = self.client.get(f'/users?email={mock_get_user.side_effect.email}') self.assertEqual(response.status_code, 404) data = json.loads(response.data) diff --git a/users.py b/users.py index 98268ce..3406bec 100644 --- a/users.py +++ b/users.py @@ -48,24 +48,26 @@ def register(): # Connect to the database and insert new user (handle database exceptions) try: - # psycopg2 sql injection cursor, conn = get_db_connection() - + cursor.execute("BEGIN;") cursor.execute(""" INSERT INTO users (id, first_name, last_name, email, password_hash, created_at, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id; """, (user_id, data['first_name'], data['last_name'], email, hashed_password, str(created_at), str(updated_at))) + conn.commit() cursor.close() conn.close() + + except UniqueViolation as UV: + logging.debug(f"Unique constraint violation for email {email}: {UV}") + conn.rollback() + return jsonify({'error': 'Try another email.'}), 409 + except Exception as e: logging.debug("Error creating user: ", exc_info=True) return jsonify({'error': 'Internal Error'}), 500 - except UniqueViolation as UV: - logging.debug(f"Unique constraint violation for email {email}: {UV}") - return jsonify({'error': 'Try another email.'}), 409 - # Return the created user data return jsonify({'id': user_id, 'first_name': data['first_name'], 'last_name': data['last_name'], 'email': data['email']}), 201