diff --git a/.github/workflows/publish-api.yaml b/.github/workflows/publish-api.yaml index 048681e..84358f8 100644 --- a/.github/workflows/publish-api.yaml +++ b/.github/workflows/publish-api.yaml @@ -37,10 +37,10 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: Test with pytest + - name: Run tests working-directory: ./api run: | - python3 -m pytest -s + python3 -m unittest discover -p 'test_*.py' build-and-push-image: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 47b666d..9008463 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ python3 -m flask --app api run --port 8000 ``` cd api -python3 -m pytest -s +python3 -m unittest discover tests ``` ### Run Flake8 and isort diff --git a/api/api/const.py b/api/api/const.py index 341f849..c66ed71 100644 --- a/api/api/const.py +++ b/api/api/const.py @@ -1,6 +1,7 @@ from enum import Enum -NATIVE_TOKEN_ADDRESS = 'native' +ZERO_ADDRESS = "0x" + '0' * 40 +NATIVE_TOKEN_ADDRESS = ZERO_ADDRESS DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY = 0.01 DEFAULT_ERC20_MAX_AMOUNT_PER_DAY = 0.01 @@ -14,3 +15,8 @@ class FaucetRequestType(Enum): web = 'web' cli = 'cli' + + +class TokenType(Enum): + native = 'native' + erc20 = 'erc20' diff --git a/api/api/routes.py b/api/api/routes.py index da0f966..a2c3e1f 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -1,10 +1,8 @@ -from datetime import datetime - from flask import Blueprint, current_app, jsonify, request from web3 import Web3 -from .const import FaucetRequestType -from .services import (Strategy, Web3Singleton, captcha_verify, claim_native, +from .const import FaucetRequestType, TokenType +from .services import (AskEndpointValidator, Web3Singleton, claim_native, claim_token) from .services.database import AccessKey, Token, Transaction @@ -35,69 +33,6 @@ def info(): ), 200 -def _ask_route_validation(request_data, validate_captcha=True): - """Validate `ask/` endpoint request data - - Args: - request_data (object): request object - validate_captcha (bool, optional): True if captcha must be validated, False otherwise. Defaults to True. - - Returns: - tuple: validation errors, amount, recipient, token address - """ - validation_errors = [] - - # Captcha validation - if validate_captcha: - # check hcatpcha - catpcha_verified = captcha_verify( - request_data.get('captcha'), - current_app.config['CAPTCHA_VERIFY_ENDPOINT'], current_app.config['CAPTCHA_SECRET_KEY'] - ) - - if not catpcha_verified: - validation_errors.append('captcha: validation failed') - - if request_data.get('chainId') != current_app.config['FAUCET_CHAIN_ID']: - validation_errors.append('chainId: %s is not supported. Supported chainId: %s' % (request_data.get('chainId'), current_app.config['FAUCET_CHAIN_ID'])) - - recipient = request_data.get('recipient', None) - if not Web3.is_address(recipient): - validation_errors.append('recipient: A valid recipient address must be specified') - - if not recipient or recipient.lower() == current_app.config['FAUCET_ADDRESS']: - validation_errors.append('recipient: address cant\'t be the Faucet address itself') - - amount = request_data.get('amount', None) - token_address = request_data.get('tokenAddress', None) - - if token_address: - try: - # Clean up Token address - if token_address.lower() != 'native': - token_address = Web3.to_checksum_address(token_address) - - token = Token.query.with_entities(Token.enabled,Token.max_amount_day).filter_by( - address=token_address, - chain_id=request_data.get('chainId')).first() - - if token and token.enabled is True: - if not amount: - validation_errors.append('amount: is required') - if amount and amount > token.max_amount_day: - validation_errors.append('amount: a valid amount must be specified and must be less or equals to %s' % token[1]) - # except ValueError as e: - # message = "".join([arg for arg in e.args]) - # validation_errors.append(message) - else: - validation_errors.append('tokenAddress: %s is not enabled' % token_address) - except: - validation_errors.append('tokenAddress: invalid token address'), 400 - else: - validation_errors.append('tokenAddress: A valid token address or string \"native\" must be specified') - return validation_errors, amount, recipient, token_address - - def _ask(request_data, validate_captcha=True, access_key=None): """Process /ask request @@ -106,64 +41,49 @@ def _ask(request_data, validate_captcha=True, access_key=None): validate_captcha (bool, optional): True if captcha must be validated, False otherwise. Defaults to True. access_key (object, optional): AccessKey instance. Defaults to None. - Raises: - NotImplementedError: - Returns: tuple: json content, status code """ - validation_errors, amount, recipient, token_address = _ask_route_validation(request_data, validate_captcha) - - if len(validation_errors) > 0: - return jsonify(errors=validation_errors), 400 - - ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) - - if current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.address.value: - # Check last claim by recipient - transaction = Transaction.last_by_recipient(recipient) - elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip.value: - # Check last claim by IP - transaction = Transaction.last_by_ip(ip_address) - elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_and_address: - raise NotImplementedError - - # Check if the recipient can claim funds, they must not have claimed any tokens - # in the period of time defined by FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS - if transaction: - time_diff_seconds = (datetime.utcnow() - transaction.created).total_seconds() - if time_diff_seconds < current_app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']: - time_diff_hours = 24-(time_diff_seconds/(24*60)) - return jsonify(errors=['recipient: you have exceeded the limit for today. Try again in %d hours' % time_diff_hours]), 429 + validator = AskEndpointValidator(request_data, + validate_captcha, + access_key=access_key) + ok = validator.validate() + if not ok: + return jsonify(message=validator.errors), validator.http_return_code # convert amount to wei format - amount_wei = Web3.to_wei(amount, 'ether') + amount_wei = Web3.to_wei(validator.amount, 'ether') try: # convert recipient address to checksum address - recipient = Web3.to_checksum_address(recipient) + recipient = Web3.to_checksum_address(validator.recipient) - w3 = Web3Singleton(current_app.config['FAUCET_RPC_URL'], current_app.config['FAUCET_PRIVATE_KEY']) + w3 = Web3Singleton(current_app.config['FAUCET_RPC_URL'], + current_app.config['FAUCET_PRIVATE_KEY']) - token = Token.get_by_address(token_address) - if token.type == 'native': - tx_hash = claim_native(w3, current_app.config['FAUCET_ADDRESS'], recipient, amount_wei) + if validator.token.type == TokenType.native.value: + tx_hash = claim_native(w3, + current_app.config['FAUCET_ADDRESS'], + recipient, + amount_wei) else: - tx_hash = claim_token(w3, current_app.config['FAUCET_ADDRESS'], recipient, amount_wei, token_address) - + tx_hash = claim_token(w3, current_app.config['FAUCET_ADDRESS'], + recipient, + amount_wei, + validator.token.address) + # save transaction data on DB transaction = Transaction() transaction.hash = tx_hash transaction.recipient = recipient - transaction.amount = amount - transaction.token = token_address - transaction.requester_ip = ip_address + transaction.amount = validator.amount + transaction.token = validator.token.address + transaction.requester_ip = validator.ip_address if access_key: transaction.type = FaucetRequestType.cli.value transaction.access_key_id = access_key.access_key_id else: transaction.type = FaucetRequestType.web.value transaction.save() - return jsonify(transactionHash=tx_hash), 200 except ValueError as e: message = "".join([arg['message'] for arg in e.args]) diff --git a/api/api/services/__init__.py b/api/api/services/__init__.py index 93a405f..ec2fec9 100644 --- a/api/api/services/__init__.py +++ b/api/api/services/__init__.py @@ -1,5 +1,5 @@ -from .captcha import captcha_verify from .database import DatabaseSingleton from .rate_limit import RateLimitStrategy, Strategy from .token import Token from .transaction import Web3Singleton, claim_native, claim_token +from .validator import AskEndpointValidator diff --git a/api/api/services/database.py b/api/api/services/database.py index 9c70e17..87c8ae9 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -2,11 +2,20 @@ from datetime import datetime from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import MetaData from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType) -db = SQLAlchemy() +flask_db_convention = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" +} +db_metadata = MetaData(naming_convention=flask_db_convention) +db = SQLAlchemy(metadata=db_metadata) class Database: @@ -80,10 +89,15 @@ class Token(BaseModel): @classmethod def enabled_tokens(cls): return cls.query.filter_by(enabled=True).all() - + @classmethod def get_by_address(cls, address): return cls.query.filter_by(address=address).first() + + @classmethod + def get_by_address_and_chain_id(cls, address, chain_id): + return cls.query.filter_by(address=address, + chain_id=chain_id).first() class AccessKey(BaseModel): @@ -92,10 +106,17 @@ class AccessKey(BaseModel): enabled = db.Column(db.Boolean, default=True, nullable=False) __tablename__ = "access_keys" + __table_args__ = ( + db.UniqueConstraint('secret_access_key'), + ) def __repr__(self): return f"" + @classmethod + def get_by_key_id(cls, access_key_id): + return cls.query.filter_by(access_key_id=access_key_id).first() + class AccessKeyConfig(BaseModel): id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -105,10 +126,15 @@ class AccessKeyConfig(BaseModel): chain_id = db.Column(db.Integer, nullable=False) __tablename__ = "access_keys_config" - __table_args__ = tuple( - db.PrimaryKeyConstraint('access_key_id', 'chain_id') + __table_args__ = ( + db.UniqueConstraint('access_key_id', 'chain_id'), ) + @classmethod + def get_by_key_id_and_chain_id(cls, access_key_id, chain_id): + return cls.query.filter_by(access_key_id=access_key_id, + chain_id=chain_id).first() + class Transaction(BaseModel): id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -123,8 +149,8 @@ class Transaction(BaseModel): updated = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) __tablename__ = "transactions" - __table_args__ = tuple( - db.PrimaryKeyConstraint('hash', 'token') + __table_args__ = ( + db.UniqueConstraint('hash'), ) @classmethod @@ -134,3 +160,39 @@ def last_by_recipient(cls, recipient): @classmethod def last_by_ip(cls, ip): return cls.query.filter_by(requester_ip=ip).order_by(cls.created.desc()).first() + + @classmethod + def last_by_ip_and_recipient(cls, ip, recipient): + return cls.query.filter_by(requester_ip=ip, recipient=recipient).order_by(cls.created.desc()).first() + + @classmethod + def last_by_ip_or_recipient(cls, ip, recipient): + return cls.query.filter( + ((cls.requester_ip == ip) | (cls.recipient == recipient)) + ).order_by(cls.created.desc()).first() + + @classmethod + def get_by_hash(cls, hash): + return cls.query.filter_by(hash=hash).first() + + @classmethod + def get_amount_sum_by_access_key_and_token(cls, + access_key_id, + token_address, + custom_timerange=None): + if custom_timerange: + return cls.query.with_entities( + db.func.sum(cls.amount).label('amount') + ).filter_by( + access_key_id=access_key_id, + token=token_address, + ).filter( + cls.created >= custom_timerange + ).first().amount + else: + return cls.query.with_entities( + db.func.sum(cls.amount).label('amount') + ).filter_by( + access_key_id=access_key_id, + token=token_address + ).first().amount diff --git a/api/api/services/rate_limit.py b/api/api/services/rate_limit.py index b5cd239..543dd71 100644 --- a/api/api/services/rate_limit.py +++ b/api/api/services/rate_limit.py @@ -5,10 +5,16 @@ class Strategy(Enum): ip = 'IP' address = 'ADDRESS' ip_and_address = 'IP_AND_ADDRESS' + ip_or_address = 'IP_OR_ADDRESS' class RateLimitStrategy: - _strategies = set([Strategy.ip.value, Strategy.address.value, Strategy.ip_and_address.value]) + _strategies = set([ + Strategy.ip.value, + Strategy.address.value, + Strategy.ip_and_address.value, + Strategy.ip_or_address.value + ]) _strategy = None _default_strategy = Strategy.address.value diff --git a/api/api/services/validator.py b/api/api/services/validator.py new file mode 100644 index 0000000..e84d608 --- /dev/null +++ b/api/api/services/validator.py @@ -0,0 +1,185 @@ +import datetime +import pdb + +from flask import current_app, request +from web3 import Web3 + +from api.const import TokenType + +from .captcha import captcha_verify +from .database import AccessKeyConfig, Token, Transaction +from .rate_limit import Strategy + + +class AskEndpointValidator: + errors = [] + http_return_code = None + + messages = { + 'UNSUPPORTED_CHAIN': 'chainId: %s is not supported. Supported chainId: %s', + 'INVALID_RECIPIENT': 'recipient: A valid recipient address must be specified', + 'INVALID_RECIPIENT_ITSELF': 'recipient: address cant\'t be the Faucet address itself', + 'REQUIRED_AMOUNT': 'amount: is required', + 'AMOUNT_ZERO': 'amount: must be greater than 0', + 'INVALID_TOKEN_ADDRESS': 'tokenAddress: A valid token address must be specified' + } + + def __init__(self, request_data, validate_captcha, access_key=None, *args, **kwargs): + self.request_data = request_data + self.validate_captcha = validate_captcha + self.access_key = access_key + self.ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + self.errors = [] + + def validate(self): + self.data_validation() + if len(self.errors) > 0: + return False + + self.token_validation() + if len(self.errors) > 0: + return False + + if self.validate_captcha: + self.captcha_validation() + if len(self.errors) > 0: + return False + + self.web_request_amount_validation() + if len(self.errors) > 0: + return False + + self.web_request_limit_validation() + if len(self.errors) > 0: + return False + + if self.access_key: + self.access_key_validation() + if len(self.errors) > 0: + return False + return True + + def data_validation(self): + if self.request_data.get('chainId') != current_app.config['FAUCET_CHAIN_ID']: + self.errors.append(self.messages['UNSUPPORTED_CHAIN'] % ( + self.request_data.get('chainId'), + current_app.config['FAUCET_CHAIN_ID'])) + + recipient = self.request_data.get('recipient', None) + if not Web3.is_address(recipient): + self.errors.append(self.messages['INVALID_RECIPIENT']) + + if not recipient or recipient.lower() == current_app.config['FAUCET_ADDRESS']: + self.errors.append(self.messages['INVALID_RECIPIENT_ITSELF']) + + amount = self.request_data.get('amount', None) + if not amount: + self.errors.append(self.messages['REQUIRED_AMOUNT']) + if amount and float(amount) <= 0: + self.errors.append(self.messages['AMOUNT_ZERO']) + + token_address = self.request_data.get('tokenAddress', None) + if not Web3.is_address(token_address): + self.errors.append(self.messages['INVALID_TOKEN_ADDRESS']) + + if len(self.errors) > 0: + self.http_return_code = 400 + else: + self.token_address = Web3.to_checksum_address(token_address) + self.amount = float(amount) + self.chain_id = self.request_data.get('chainId') + self.recipient = recipient + + def captcha_validation(self): + error_key = 'captcha' + # check hcatpcha + catpcha_verified = captcha_verify( + self.request_data.get('captcha'), + current_app.config['CAPTCHA_VERIFY_ENDPOINT'], current_app.config['CAPTCHA_SECRET_KEY'] + ) + + if not catpcha_verified: + self.errors.append('%s: validation failed' % error_key) + + if len(self.errors) > 0: + self.http_return_code = 400 + + def token_validation(self): + self.token = Token.get_by_address_and_chain_id(self.token_address, + self.chain_id) + error_key = 'tokenAddress' + + if not self.token: + self.errors.append('%s: token not available for chainId %s' % ( + error_key, + self.chain_id)) + if self.token and self.token.enabled is False: + self.errors.append('%s: %s is not enabled' % (error_key, + self.token_address)) + + if len(self.errors) > 0: + self.http_return_code = 400 + + def web_request_amount_validation(self): + if self.amount > self.token.max_amount_day: + self.errors.append('amount: must be less or equals to %s' % self.token.max_amount_day) + # except ValueError as e: + # message = "".join([arg for arg in e.args]) + # validation_errors.append(message) + + if len(self.errors) > 0: + self.http_return_code = 400 + + def web_request_limit_validation(self): + if current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.address.value: + # Check last claim by recipient + transaction = Transaction.last_by_recipient(self.recipient) + elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip.value: + # Check last claim by IP + transaction = Transaction.last_by_ip(self.ip_address) + elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_and_address.value: + transaction = Transaction.last_by_ip_and_recipient(self.ip_address, + self.recipient) + elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_or_address.value: + transaction = Transaction.last_by_ip_or_recipient(self.ip_address, + self.recipient) + else: + raise NotImplementedError + + # Check if the recipient can claim funds, they must not have claimed any tokens + # in the period of time defined by FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS + if transaction: + time_diff_seconds = (datetime.datetime.utcnow() - transaction.created).total_seconds() + if time_diff_seconds < current_app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']: + time_diff_hours = 24-(time_diff_seconds/(24*60)) + self.errors.append('recipient: you have exceeded the limit for today. Try again in %d hours' % time_diff_hours) + + if len(self.errors) > 0: + self.http_return_code = 429 + + def access_key_validation(self): + # check available amount for the given access key and token + access_key_config = AccessKeyConfig.get_by_key_id_and_chain_id( + self.access_key.access_key_id, self.token.chain_id) + + timerange = datetime.datetime.now() - datetime.timedelta( + seconds=current_app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']) + + # get amount from the last X hours + past_amount = Transaction.get_amount_sum_by_access_key_and_token( + self.access_key.access_key_id, + self.token.address, + custom_timerange=timerange) + total_amount = (past_amount or 0) + self.amount + + if self.token.type == TokenType.native.value: + if total_amount and total_amount >= access_key_config.native_max_amount_day: + self.errors.append("requested amount exceeds the limit for today") + elif self.token.type == TokenType.erc20.value: + if total_amount and total_amount >= access_key_config.erc20_max_amount_day: + self.errors.append("requested amount exceeds the limit for today") + else: + raise Exception('Unkown token type %s' % self.token.type) + + if len(self.errors) > 0: + self.http_return_code = 429 diff --git a/api/api/settings.py b/api/api/settings.py index 45d0bd5..e4bdd24 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -1,4 +1,3 @@ -import json import os from dotenv import load_dotenv @@ -8,8 +7,6 @@ from .const import CHAIN_NAMES from .services import RateLimitStrategy -# from .utils import get_chain_name - load_dotenv() rate_limit_strategy = RateLimitStrategy() diff --git a/api/api/utils.py b/api/api/utils.py index 1827d19..8dd5399 100644 --- a/api/api/utils.py +++ b/api/api/utils.py @@ -1,9 +1,5 @@ import secrets -from web3 import Web3 - -from .const import NATIVE_TOKEN_ADDRESS - def get_chain_name(chain_id): chains = { @@ -15,51 +11,6 @@ def get_chain_name(chain_id): return chains.get(int(chain_id), 'Undefined') -def is_token_enabled(address, tokens_list): - # Native token enabled by default - if address.lower() == 'native': - return True - - is_enabled = False - checksum_address = Web3.to_checksum_address(address) - for enabled_token in tokens_list: - if checksum_address == enabled_token['address']: - is_enabled = True - break - return is_enabled - - -def is_amount_valid(amount, token_address, tokens_list): - - if not token_address: - raise ValueError( - 'Token address not supported', - str(token_address), - 'supported tokens', - " ".join(list(map(lambda x: x['address'], tokens_list))) - ) - - token_address_to_check = None - if token_address.lower() == NATIVE_TOKEN_ADDRESS: - token_address_to_check = NATIVE_TOKEN_ADDRESS - else: - token_address_to_check = Web3.to_checksum_address(token_address) - - for enabled_token in tokens_list: - if token_address_to_check == enabled_token['address']: - return ( - amount <= enabled_token['maximumAmount'], - enabled_token['maximumAmount'] - ) - - raise ValueError( - 'Token address not supported', - token_address, - 'supported tokens', - " ".join(list(map(lambda x: x['address'], tokens_list))) - ) - - def generate_access_key(): access_key_id = secrets.token_hex(8) # returns a 16 chars long string secret_access_key = secrets.token_hex(16) # returns a 32 chars long string diff --git a/api/migrations/versions/4cacf36b2356_.py b/api/migrations/versions/4cacf36b2356_.py new file mode 100644 index 0000000..5a6e4f8 --- /dev/null +++ b/api/migrations/versions/4cacf36b2356_.py @@ -0,0 +1,45 @@ +"""empty message + +Revision ID: 4cacf36b2356 +Revises: 022497197c7a +Create Date: 2024-03-09 11:37:03.009350 + +""" +from alembic import op +import sqlalchemy as sa + +from api.services.database import flask_db_convention + + +# revision identifiers, used by Alembic. +revision = '4cacf36b2356' +down_revision = '022497197c7a' +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('access_keys', schema=None, naming_convention=flask_db_convention) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_access_keys_secret_access_key'), ['secret_access_key']) + + with op.batch_alter_table('access_keys_config', schema=None, naming_convention=flask_db_convention) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_access_keys_config_access_key_id'), ['access_key_id', 'chain_id']) + + with op.batch_alter_table('transactions', schema=None, naming_convention=flask_db_convention) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_transactions_hash'), ['hash']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('transactions', schema=None, naming_convention=flask_db_convention) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_transactions_hash'), type_='unique') + + with op.batch_alter_table('access_keys_config', schema=None, naming_convention=flask_db_convention) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_access_keys_config_access_key_id'), type_='unique') + + with op.batch_alter_table('access_keys', schema=None, naming_convention=flask_db_convention) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_access_keys_secret_access_key'), type_='unique') + + # ### end Alembic commands ### diff --git a/api/scripts/local_run_migrations.sh b/api/scripts/local_run_migrations.sh index 445ada6..b37e80a 100644 --- a/api/scripts/local_run_migrations.sh +++ b/api/scripts/local_run_migrations.sh @@ -3,12 +3,12 @@ set -x # DB MIGRATIONS: -FLASK_APP=api FAUCET_ENABLED_CHAIN_IDS=10200 FAUCET_DATABASE_URI=sqlite:// python3 -m flask db init # only the first time we initialize the DB -FLASK_APP=api FAUCET_ENABLED_CHAIN_IDS=10200 FAUCET_DATABASE_URI=sqlite:// python3 -m flask db migrate +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:// python3 -m flask db init # only the first time we initialize the DB +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:// python3 -m flask db migrate # Reflect migrations into the database: # FLASK_APP=api python3 -m flask db upgrade # Valid SQLite URL forms are: -# sqlite:///:memory: (or, sqlite://) +# sqlite:// (in-memory) # sqlite:///relative/path/to/file.db # sqlite:////absolute/path/to/file.db \ No newline at end of file diff --git a/api/scripts/local_test_run.sh b/api/scripts/local_test_run.sh new file mode 100644 index 0000000..34e9d33 --- /dev/null +++ b/api/scripts/local_test_run.sh @@ -0,0 +1,6 @@ +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask db upgrade +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask create_enabled_token xDAI 10200 0x0000000000000000000000000000000000000000 10 native +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask create_access_keys +# Take note of the access keys +# Run API on port 3000 +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask run -p 3000 \ No newline at end of file diff --git a/api/scripts/sample_cli_request.py b/api/scripts/sample_cli_request.py new file mode 100644 index 0000000..f44a5e9 --- /dev/null +++ b/api/scripts/sample_cli_request.py @@ -0,0 +1,25 @@ +import requests + + +ASK_API_ENDPOINT = 'https://api.faucet.dev.gnosisdev.com/api/v1/cli/ask' +ACCESS_KEY_ID = '__ACCESS_KEY_ID__' +ACCESS_KEY_SECRET = '__ACCESS_KEY_SECRET__' + +headers = { + 'X-faucet-access-key-id': ACCESS_KEY_ID, + 'X-faucet-secret-access-key': ACCESS_KEY_SECRET, + 'content-type': 'application/json' +} + +json_data = { + 'tokenAddress': '0x0000000000000000000000000000000000000000', # stands for Native token + 'amount': 0.01, + 'chainId': 10200, + 'recipient': '0xf8d0b3c2578aee1fceb9830eb92b5da007d71ba9' +} + +response = requests.post(ASK_API_ENDPOINT, + headers=headers, + json=json_data) + +print(response.json()) diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 9223b4e..7cfac1b 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,43 +1,49 @@ import os +from unittest import TestCase, TestResult, mock -import pytest -from flask_migrate import upgrade -from temp_env_var import (FAUCET_ENABLED_TOKENS, NATIVE_TRANSFER_TX_HASH, - TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH) +from flask.testing import FlaskClient from api import create_app -from api.services.database import Token +from api.services import Strategy +from api.services.database import Token, db + +from .temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS api_prefix = '/api/v1' -class BaseTest: - def _mock(self, mocker, env_variables=None): +class BaseTest(TestCase): + + def mock_claim_native(self, *args): + tx_hash = '0x0' + '%d' % self.native_tx_counter * 63 + self.native_tx_counter += 1 + return tx_hash + + def mock_claim_erc20(self, *args): + tx_hash = '0x1' + '%d' % self.erc20_tx_counter * 63 + self.erc20_tx_counter += 1 + return tx_hash + + def _mock(self, env_variables=None): # Mock values - mocker.patch('api.routes.claim_native', return_value=NATIVE_TRANSFER_TX_HASH) - mocker.patch('api.routes.claim_token', return_value=TOKEN_TRANSFER_TX_HASH) - mocker.patch('api.routes.captcha_verify', return_value=True) - mocker.patch('api.api.print_info', return_value=None) + self.patchers = [ + mock.patch('api.routes.claim_native', self.mock_claim_native), + mock.patch('api.routes.claim_token', self.mock_claim_erc20), + mock.patch('api.services.validator.captcha_verify', return_value=True), + mock.patch('api.api.print_info', return_value=None) + ] if env_variables: - mocker.patch.dict(os.environ, env_variables) - return mocker - - def _create_app(self): - return create_app() - - @pytest.fixture - def app(self, mocker): - mocker = self._mock(mocker, TEMP_ENV_VARS) - app = self._create_app() - with app.app_context(): - upgrade() - self.populate_db() - yield app - - @pytest.fixture - def client(self, app): - return app.test_client() - + self.patchers.append(mock.patch.dict(os.environ, env_variables)) + + for p in self.patchers: + p.start() + + def _reset_db(self): + print("#== Reset DB ==#") + db.drop_all() + db.create_all() + self.populate_db() + def populate_db(self): for enabled_token in FAUCET_ENABLED_TOKENS: token = Token() @@ -47,3 +53,67 @@ def populate_db(self): token.max_amount_day = enabled_token['maximumAmount'] token.type = enabled_token['type'] token.save() + + def setUp(self): + ''' + Set up to do before running each test + ''' + self.native_tx_counter = 0 + self.erc20_tx_counter = 0 + self._mock(TEMP_ENV_VARS) + + self.app = create_app() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + + self.client = self.app.test_client() + + with self.app_ctx: + self._reset_db() + + def tearDown(self): + ''' + Cleanup to do after running each test + ''' + for p in self.patchers: + p.stop() + + self.app_ctx.pop() + self.app_ctx = None + + +class RateLimitIPBaseTest(BaseTest): + def setUp(self): + self.native_tx_counter = 0 + self.erc20_tx_counter = 0 + env_vars = TEMP_ENV_VARS.copy() + env_vars['FAUCET_RATE_LIMIT_STRATEGY'] = Strategy.ip.value + self._mock(env_vars) + + self.app = create_app() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + + self.client = self.app.test_client() + + with self.app_ctx: + self._reset_db() + + +class RateLimitIPorAddressBaseTest(BaseTest): + def setUp(self): + self.native_tx_counter = 0 + self.erc20_tx_counter = 0 + # Set rate limit strategy to IP + env_vars = TEMP_ENV_VARS.copy() + env_vars['FAUCET_RATE_LIMIT_STRATEGY'] = Strategy.ip_or_address.value + self._mock(env_vars) + + self.app = create_app() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + + self.client = self.app.test_client() + + with self.app_ctx: + self._reset_db() diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index e2446bf..8d78740 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -1,11 +1,10 @@ from secrets import token_bytes from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, - DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, NATIVE_TOKEN_ADDRESS) + DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, NATIVE_TOKEN_ADDRESS, + TokenType) -ZERO_ADDRESS = "0x" + '0' * 40 - -ERC20_TOKEN_ADDRESS = ZERO_ADDRESS +ERC20_TOKEN_ADDRESS = "0x" + '1' * 40 CAPTCHA_TEST_SECRET_KEY = '0x0000000000000000000000000000000000000000' CAPTCHA_TEST_RESPONSE_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001' @@ -18,14 +17,14 @@ "name": "Native", "maximumAmount": DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, "chainId": FAUCET_CHAIN_ID, - "type": "native" + "type": TokenType.native.value }, { "address": ERC20_TOKEN_ADDRESS, "name": "TestToken", "maximumAmount": DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, "chainId": FAUCET_CHAIN_ID, - "type": "erc20" + "type": TokenType.erc20.value } ] @@ -35,6 +34,7 @@ 'FAUCET_PRIVATE_KEY': token_bytes(32).hex(), 'FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS': '10', 'FAUCET_DATABASE_URI': 'sqlite://', # run in-memory + # 'FAUCET_DATABASE_URI': 'sqlite:///test.db', 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY } diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 0627048..64f74a3 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,204 +1,135 @@ -import pytest -from conftest import BaseTest, api_prefix -from flask_migrate import upgrade -# from mock import patch -from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, - DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, - DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, - ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID, - NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH, - TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) +import unittest + +from api.const import ZERO_ADDRESS +from api.services.database import Transaction -from api.services import Strategy -from api.services.database import AccessKey, Transaction -from api.utils import generate_access_key +from .conftest import BaseTest, api_prefix +# from mock import patch +from .temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, + DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID, + NATIVE_TOKEN_ADDRESS) class TestAPI(BaseTest): - def test_status_route(self, client): - response = client.get(api_prefix + '/status') - assert response.status_code == 200 + def test_status_route(self): + response = self.client.get(api_prefix + '/status') + self.assertEqual(response.status_code, 200) assert response.get_json().get('status') == 'ok' - def test_info_route(self, client): - response = client.get(api_prefix + '/info') - assert response.status_code == 200 + def test_info_route(self): + response = self.client.get(api_prefix + '/info') + self.assertEqual(response.status_code, 200) - def test_ask_route_parameters(self, client): - response = client.post(api_prefix + '/ask', json={}) - assert response.status_code == 400 + def test_ask_route_parameters(self): + response = self.client.post(api_prefix + '/ask', json={}) + self.assertEqual(response.status_code, 400) # wrong chainid should return 400 - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': -1, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) # wrong amount, should return 400 - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) # missing recipient, should return 400 - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) # wrong recipient recipient, should return 400 - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': 'not an address', 'tokenAddress': NATIVE_TOKEN_ADDRESS }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': '0x00000123', 'tokenAddress': ERC20_TOKEN_ADDRESS }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) # missing token address, should return 400 - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) # wrong token address, should return 400 - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': 'non existing token address' }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) - def test_ask_route_native_transaction(self, client): - response = client.post(api_prefix + '/ask', json={ + def test_ask_route_native_transaction(self): + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) - assert response.status_code == 200 - assert response.get_json().get('transactionHash') == NATIVE_TRANSFER_TX_HASH + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) - def test_ask_route_token_transaction(self, client): + def test_ask_route_token_transaction(self): # not supported token, should return 400 - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, - 'tokenAddress': '0x' + '1' * 40 + 'tokenAddress': '0x' + '1234' * 10 }) - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) - response = client.post(api_prefix + '/ask', json={ + response = self.client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) - assert response.status_code == 200 - assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH - - transaction = Transaction.query.with_entities(Transaction.hash).filter_by(hash=TOKEN_TRANSFER_TX_HASH).first() - assert len(transaction) == 1 - assert transaction[0] == TOKEN_TRANSFER_TX_HASH - + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) -class TestCliAPI(BaseTest): - def test_ask_route_parameters(self, client): - access_key_id, secret_access_key = generate_access_key() - http_headers = { - 'X-faucet-access-key-id': access_key_id, - 'X-faucet-secret-access-key': secret_access_key - } - - response = client.post(api_prefix + '/cli/ask', json={}) - # Missing headers - assert response.status_code == 400 - - response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ - 'chainId': FAUCET_CHAIN_ID, - 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, - 'recipient': ZERO_ADDRESS, - 'tokenAddress': ERC20_TOKEN_ADDRESS - }) - # Access denied, not existing keys - assert response.status_code == 403 - - # Create keys on DB - AccessKey(access_key_id=access_key_id, secret_access_key=secret_access_key).save() - - response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ - 'chainId': FAUCET_CHAIN_ID, - 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, - 'recipient': ZERO_ADDRESS, - 'tokenAddress': ERC20_TOKEN_ADDRESS - }) - assert response.status_code == 200 - -class TestAPIWithIPLimitStrategy(BaseTest): - - @pytest.fixture - def app(self, mocker): - # Set rate limit strategy to IP - env_vars = TEMP_ENV_VARS.copy() - env_vars['FAUCET_RATE_LIMIT_STRATEGY'] = Strategy.ip.value - - mocker = self._mock(mocker, env_vars) - - app = self._create_app() - with app.app_context(): - upgrade() - self.populate_db() - yield app - - def test_ask_route_limit_by_ip(self, client): - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': FAUCET_CHAIN_ID, - 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, - 'recipient': ZERO_ADDRESS, - 'tokenAddress': ERC20_TOKEN_ADDRESS - }) - assert response.status_code == 200 - assert response.status_code == 200 - - # Second request should return 429 - response = client.post(api_prefix + '/ask', json={ - 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': FAUCET_CHAIN_ID, - 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, - 'recipient': ZERO_ADDRESS, - 'tokenAddress': ERC20_TOKEN_ADDRESS - }) - assert response.status_code == 429 +if __name__ == '__main__': + unittest.main() diff --git a/api/tests/test_api_claim_rate_limit.py b/api/tests/test_api_claim_rate_limit.py new file mode 100644 index 0000000..6436013 --- /dev/null +++ b/api/tests/test_api_claim_rate_limit.py @@ -0,0 +1,80 @@ +import unittest + +from api.const import ZERO_ADDRESS +from api.services.database import Transaction + +from .conftest import (RateLimitIPBaseTest, RateLimitIPorAddressBaseTest, + api_prefix) +# from mock import patch +from .temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, + DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) + + +class TestAPIWithIPLimitStrategy(RateLimitIPBaseTest): + + def test_ask_route_limit_by_ip(self): + response = self.client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 200) + + # Second request should return 429 + response = self.client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 429) + + +class TestAPIWithIPorRecipientLimitStrategy(RateLimitIPorAddressBaseTest): + + def test_ask_route_limit_by_ip_or_address(self): + response = self.client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + + self.assertEqual(response.status_code, 200) + # let's store the tx_hash + tx_hash = response.get_json()['transactionHash'] + + # Second request should return 429, either IP or recipient did + # create a transaction in the last X hours + response = self.client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 429) + + # Change IP on DB + fake_ip = '192.168.10.155' + transaction = Transaction.get_by_hash(tx_hash) + transaction.requester_ip = fake_ip + transaction.save() + + response = self.client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 429) + + +if __name__ == '__main__': + unittest.main() diff --git a/api/tests/test_api_cli.py b/api/tests/test_api_cli.py new file mode 100644 index 0000000..608dccb --- /dev/null +++ b/api/tests/test_api_cli.py @@ -0,0 +1,65 @@ +import unittest + +from api.const import ZERO_ADDRESS +from api.services.database import AccessKey, AccessKeyConfig +from api.utils import generate_access_key + +from .conftest import BaseTest, api_prefix +# from mock import patch +from .temp_env_var import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) + + +class TestAPICli(BaseTest): + def test_ask_route_parameters(self): + access_key_id, secret_access_key = generate_access_key() + http_headers = { + 'X-faucet-access-key-id': access_key_id, + 'X-faucet-secret-access-key': secret_access_key + } + + response = self.client.post(api_prefix + '/cli/ask', json={}) + # Missing headers + self.assertEqual(response.status_code, 400) + + response = self.client.post(api_prefix + '/cli/ask', headers=http_headers, json={ + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + # Access denied, not existing keys + self.assertEqual(response.status_code, 403) + + # Create access keys on DB + access_key = AccessKey() + access_key.access_key_id = access_key_id + access_key.secret_access_key = secret_access_key + access_key.save() + + config = AccessKeyConfig() + config.access_key_id = access_key.access_key_id + config.chain_id = 10200 + config.erc20_max_amount_day = 10 + config.native_max_amount_day = 20 + config.save() + + response = self.client.post(api_prefix + '/cli/ask', headers=http_headers, json={ + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 200) + + response = self.client.post(api_prefix + '/cli/ask', headers=http_headers, json={ + 'chainId': FAUCET_CHAIN_ID, + 'amount': 30, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 429) + + +if __name__ == '__main__': + unittest.main() diff --git a/api/tests/test_database.py b/api/tests/test_database.py index 5d3f645..e436ca5 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,12 +1,19 @@ -from conftest import BaseTest +import unittest -from api.services.database import AccessKey +from sqlalchemy.exc import IntegrityError + +from api.const import ZERO_ADDRESS +from api.services.database import (AccessKey, AccessKeyConfig, Token, + Transaction) from api.utils import generate_access_key +from .conftest import BaseTest +from .temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH + class TestDatabase(BaseTest): - def test_models(self, client): + def test_access_keys(self): access_key_id, secret_access_key = generate_access_key() assert len(access_key_id) == 16 assert len(secret_access_key) == 32 @@ -19,3 +26,59 @@ def test_models(self, client): assert result[0].access_key_id == access_key_id assert result[0].secret_access_key == secret_access_key assert result[0].enabled is True + + # Duplicates for secret_access_key are not allowed + with self.assertRaises(IntegrityError): + access_key_id2, _ = generate_access_key() + access_key = AccessKey() + access_key.access_key_id = access_key_id2 + access_key.secret_access_key = secret_access_key + access_key.save() + + def test_access_key_config(self): + access_key_id, secret_access_key = generate_access_key() + access_key = AccessKey() + access_key.access_key_id = access_key_id + access_key.secret_access_key = secret_access_key + access_key.save() + + config = AccessKeyConfig() + config.access_key_id = access_key.access_key_id + config.chain_id = 10200 + config.erc20_max_amount_day = 10 + config.native_max_amount_day = 20 + config.save() + + # Duplicates for (access_key_id, chain_id) are not allowed + with self.assertRaises(IntegrityError): + config = AccessKeyConfig() + config.access_key_id = access_key.access_key_id + config.chain_id = 10200 + config.erc20_max_amount_day = 5 + config.native_max_amount_day = 10 + config.save() + + def test_transactions(self): + token = Token.get_by_address(NATIVE_TOKEN_ADDRESS) + + transaction = Transaction() + transaction.hash = NATIVE_TRANSFER_TX_HASH + transaction.recipient = ZERO_ADDRESS + transaction.amount = 1 + transaction.token = token.address + transaction.requester_ip = '192.168.0.1' + transaction.save() + + # Duplicates for tx hash are not allowed + with self.assertRaises(IntegrityError): + transaction = Transaction() + transaction.hash = NATIVE_TRANSFER_TX_HASH + transaction.recipient = ZERO_ADDRESS + transaction.amount = 1 + transaction.token = token.address + transaction.requester_ip = '192.168.0.1' + transaction.save() + + +if __name__ == '__main__': + unittest.main() diff --git a/app/src/components/FaucetForm/Faucet.css b/app/src/components/FaucetForm/Faucet.css index cb3ad92..361e7ac 100644 --- a/app/src/components/FaucetForm/Faucet.css +++ b/app/src/components/FaucetForm/Faucet.css @@ -110,6 +110,7 @@ strong { .faucet-container button:hover { background-color: #f0713b; + cursor: pointer; } .faucet-container button:disabled { diff --git a/app/src/components/FaucetForm/Faucet.tsx b/app/src/components/FaucetForm/Faucet.tsx index 4908ac0..6a9dd74 100644 --- a/app/src/components/FaucetForm/Faucet.tsx +++ b/app/src/components/FaucetForm/Faucet.tsx @@ -29,6 +29,13 @@ function Faucet({ enabledTokens, chainId, setLoading }: FaucetProps): JSX.Elemen useEffect(() => { const handleResize = () => setWindowWidth(window.innerWidth) window.addEventListener("resize", handleResize) + + const searchParams = new URLSearchParams(window.location.search) + const addressFromURL = searchParams.get("address") + if (addressFromURL) { + setWalletAddress(addressFromURL) + } + return () => window.removeEventListener("resize", handleResize) }, []) @@ -40,7 +47,16 @@ function Faucet({ enabledTokens, chainId, setLoading }: FaucetProps): JSX.Elemen name: enabledTokens[0].name, maximumAmount: Number(enabledTokens[0].maximumAmount) }) - } + } else { + const defaultToken = enabledTokens.find(token => token.address === "0x0000000000000000000000000000000000000000") + if (defaultToken !== undefined) { + setToken({ + address: defaultToken.address, + name: defaultToken.name, + maximumAmount: Number(defaultToken.maximumAmount) + }) + } + } }, [enabledTokens.length]) function formatErrors(errors: string[]) {