From 96cce88759d39fc56380fcf9131848d1b3a25833 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Mon, 4 Mar 2024 14:58:13 +0100 Subject: [PATCH 01/14] [WIP] add multichain and more precise access keys capability --- api/.env.example | 2 +- api/api/api.py | 2 +- api/api/const.py | 9 ++++ api/api/manage.py | 10 +++- api/api/routes.py | 67 +++++++++++++++--------- api/api/services/database.py | 47 ++++++++++++++++- api/api/settings.py | 4 +- api/migrations/versions/71441c34724e_.py | 32 ----------- api/migrations/versions/a6e2a13b563c_.py | 64 ++++++++++++++++++++++ api/scripts/local_run_migrations.sh | 6 +-- api/tests/conftest.py | 18 +++++-- api/tests/temp_env_var.py | 28 +++++++--- api/tests/test_api.py | 63 +++++++++++----------- 13 files changed, 242 insertions(+), 110 deletions(-) delete mode 100644 api/migrations/versions/71441c34724e_.py create mode 100644 api/migrations/versions/a6e2a13b563c_.py diff --git a/api/.env.example b/api/.env.example index 8c59633..e48340f 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,7 +1,7 @@ FAUCET_AMOUNT=0.1 FAUCET_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 FAUCET_RPC_URL=https://rpc.chiadochain.net -FAUCET_CHAIN_ID=10200 +FAUCET_ENABLED_CHAIN_IDS=10200 FAUCET_ENABLED_TOKENS="[{\"address\": \"0x19C653Da7c37c66208fbfbE8908A5051B57b4C70\", \"name\":\"GNO\", \"maximumAmount\": 0.5}]" # FAUCET_ENABLED_TOKENS= FAUCET_DATABASE_URI=sqlite:///:memory diff --git a/api/api/api.py b/api/api/api.py index 2a53fb0..5857a49 100644 --- a/api/api/api.py +++ b/api/api/api.py @@ -25,7 +25,7 @@ def print_info(w3, config): logging.info("=" * 60) logging.info("RPC_URL = " + config['FAUCET_RPC_URL']) logging.info("FAUCET ADDRESS = " + config['FAUCET_ADDRESS']) - logging.info("FAUCET BALANCE = %d %s" % (w3.from_wei(faucet_native_balance, 'ether'), config['FAUCET_CHAIN_NAME'])) + # logging.info("FAUCET BALANCE = %d %s" % (w3.from_wei(faucet_native_balance, 'ether'), config['FAUCET_CHAIN_NAME'])) logging.info("=" * 60) diff --git a/api/api/const.py b/api/api/const.py index ec46c65..f6a78ee 100644 --- a/api/api/const.py +++ b/api/api/const.py @@ -1 +1,10 @@ NATIVE_TOKEN_ADDRESS = 'native' +DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY = 0.01 +DEFAULT_ERC20_MAX_AMOUNT_PER_DAY = 0.01 +DEFAULT_FAUCET_REQUEST_TYPE = 'web' + +CHAIN_NAMES = { + 1: 'ETHEREUM MAINNET', + 100: 'GNOSIS CHAIN', + 10200: 'CHIADO CHAIN' +} diff --git a/api/api/manage.py b/api/api/manage.py index 2d46961..9f48947 100644 --- a/api/api/manage.py +++ b/api/api/manage.py @@ -1,9 +1,10 @@ import logging import click +from flask import current_app from flask.cli import with_appcontext -from .services.database import AccessKey +from .services.database import AccessKey, AccessKeyConfig from .utils import generate_access_key @@ -15,5 +16,12 @@ def create_access_keys_cmd(): access_key.access_key_id = access_key_id access_key.secret_access_key = secret_access_key access_key.save() + + for chain_id in current_app.config["FAUCET_ENABLED_CHAIN_IDS"]: + config = AccessKeyConfig() + config.access_key_id = access_key.access_key_id + config.chain_id = chain_id + config.save() + logging.info(f'Access Key ID : ${access_key_id}') logging.info(f'Secret access key: ${secret_access_key}') diff --git a/api/api/routes.py b/api/api/routes.py index c81c9bb..b54d00d 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -3,7 +3,7 @@ from .services import (Strategy, Web3Singleton, captcha_verify, claim_native, claim_token) -from .services.database import AccessKey +from .services.database import AccessKey, Token from .utils import is_amount_valid, is_token_enabled apiv1 = Blueprint("version1", "version1") @@ -16,10 +16,12 @@ def status(): @apiv1.route("/info") def info(): + enabled_tokens = Token.query.with_entities(Token.name, Token.address, + Token.chain_id).filter_by(enabled=True).all() return jsonify( - enabledTokens=current_app.config['FAUCET_ENABLED_TOKENS'], - chainId=current_app.config['FAUCET_CHAIN_ID'], - chainName=current_app.config['FAUCET_CHAIN_NAME'], + enabledTokens=enabled_tokens, + # chainId=current_app.config['FAUCET_ENABLED_CHAIN_IDS'], + # chainName=current_app.config['FAUCET_CHAIN_NAME'], faucetAddress=current_app.config['FAUCET_ADDRESS'] ), 200 @@ -30,12 +32,20 @@ def _ask_route_validation(request_data, validate_captcha): # 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']) + 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'])) + if request_data.get('chainId') not in current_app.config['FAUCET_ENABLED_CHAIN_IDS']: + validation_errors.append('chainId: %s is not supported. Supported chainIds: %s' % ( + request_data.get('chainId'), + ', '.join([str(x) for x in current_app.config['FAUCET_ENABLED_CHAIN_IDS']]) + ) + ) recipient = request_data.get('recipient', None) if not Web3.is_address(recipient): @@ -44,26 +54,33 @@ def _ask_route_validation(request_data, validate_captcha): if not recipient or recipient.lower() == current_app.config['FAUCET_ADDRESS']: validation_errors.append('recipient: address cant\'t be the Faucet address itself') - token_address = request_data.get('tokenAddress', None) - if not token_address: - validation_errors.append('tokenAddress: A valid token address or string \"native\" must be specified') - - try: - if not is_token_enabled(token_address, current_app.config['FAUCET_ENABLED_TOKENS']): - validation_errors.append('tokenAddress: Token %s is not enabled' % token_address) - except: - validation_errors.append('tokenAddress: invalid token address'), 400 - amount = request_data.get('amount', None) + token_address = request_data.get('tokenAddress', None) - try: - amount_valid, amount_limit = is_amount_valid(amount, token_address, current_app.config['FAUCET_ENABLED_TOKENS']) - if not amount_valid: - validation_errors.append('amount: a valid amount must be specified and must be less or equals to %s' % amount_limit) - except ValueError as e: - message = "".join([arg for arg in e.args]) - validation_errors.append(message) - + 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[0] is True: + if not amount: + validation_errors.append('amount: is required') + if amount and amount > token[1]: + 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 diff --git a/api/api/services/database.py b/api/api/services/database.py index 88d53df..839fa2e 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -2,6 +2,10 @@ from flask_sqlalchemy import SQLAlchemy +from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + DEFAULT_FAUCET_REQUEST_TYPE, + DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY) + db = SQLAlchemy() @@ -63,11 +67,50 @@ def delete(self, commit=True): db.session.commit() +class Token(BaseModel): + name = db.Column(db.String(10), nullable=False) + chain_id = db.Column(db.Integer, nullable=False) + address = db.Column(db.String(42), primary_key=True) + enabled = db.Column(db.Boolean, default=True, nullable=False) + max_amount_day = db.Column(db.Integer, nullable=False) + type = db.Column(db.String(6), nullable=False) # native, erc20 + + __tablename__ = "tokens" + + class AccessKey(BaseModel): - __tablename__ = "access_keys" access_key_id = db.Column(db.String(16), primary_key=True) secret_access_key = db.Column(db.String(32), nullable=False) - enabled = db.Column(db.Boolean(), default=True, nullable=False) + enabled = db.Column(db.Boolean, default=True, nullable=False) + + __tablename__ = "access_keys" def __repr__(self): return f"" + + +class AccessKeyConfig(BaseModel): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + erc20_max_amount_day = db.Column(db.Integer, nullable=False, default=DEFAULT_ERC20_MAX_AMOUNT_PER_DAY) + native_max_amount_day = db.Column(db.Integer, nullable=False, default=DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY) + access_key_id = db.Column(db.String, db.ForeignKey('access_keys.access_key_id')) + chain_id = db.Column(db.Integer, nullable=False) + + __tablename__ = "access_keys_config" + __table_args__ = tuple( + db.PrimaryKeyConstraint('access_key_id', 'chain_id') + ) + + +class Transactions(BaseModel): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + hash = db.Column(db.String(32), nullable=False) + recipient = db.Column(db.String(42), nullable=False) + token = db.Column(db.String, db.ForeignKey('tokens.address')) + type = db.Column(db.String(10), nullable=False, default=DEFAULT_FAUCET_REQUEST_TYPE) + access_key_id = db.Column(db.String, db.ForeignKey('access_keys.access_key_id'), nullable=True) + + __tablename__ = "transactions" + __table_args__ = tuple( + db.PrimaryKeyConstraint('hash', 'token') + ) diff --git a/api/api/settings.py b/api/api/settings.py index bbcdf82..bb0563f 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -15,8 +15,8 @@ FAUCET_RPC_URL = os.getenv("FAUCET_RPC_URL") FAUCET_PRIVATE_KEY = os.environ.get("FAUCET_PRIVATE_KEY") -FAUCET_CHAIN_ID = os.getenv('FAUCET_CHAIN_ID') -FAUCET_CHAIN_NAME = get_chain_name(os.getenv('FAUCET_CHAIN_ID')) +FAUCET_ENABLED_CHAIN_IDS = [int(x) for x in os.getenv('FAUCET_ENABLED_CHAIN_IDS').split(',')] +# FAUCET_CHAIN_NAME = get_chain_name(os.getenv('FAUCET_CHAIN_ID')) SQLALCHEMY_DATABASE_URI = os.getenv('FAUCET_DATABASE_URI') diff --git a/api/migrations/versions/71441c34724e_.py b/api/migrations/versions/71441c34724e_.py deleted file mode 100644 index 5360806..0000000 --- a/api/migrations/versions/71441c34724e_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""empty message - -Revision ID: 71441c34724e -Revises: -Create Date: 2024-02-28 14:11:13.601403 - -""" -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = '71441c34724e' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('access_keys', - sa.Column('access_key_id', sa.String(length=16), nullable=False), - sa.Column('secret_access_key', sa.String(length=32), nullable=False), - sa.Column('enabled', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('access_key_id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('access_keys') - # ### end Alembic commands ### diff --git a/api/migrations/versions/a6e2a13b563c_.py b/api/migrations/versions/a6e2a13b563c_.py new file mode 100644 index 0000000..f023917 --- /dev/null +++ b/api/migrations/versions/a6e2a13b563c_.py @@ -0,0 +1,64 @@ +"""empty message + +Revision ID: a6e2a13b563c +Revises: +Create Date: 2024-03-03 17:44:59.725829 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'a6e2a13b563c' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('access_keys', + sa.Column('access_key_id', sa.String(length=16), nullable=False), + sa.Column('secret_access_key', sa.String(length=32), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('access_key_id') + ) + op.create_table('tokens', + sa.Column('name', sa.String(length=10), nullable=False), + sa.Column('chain_id', sa.Integer(), nullable=False), + sa.Column('address', sa.String(length=42), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('max_amount_day', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=6), nullable=False), + sa.PrimaryKeyConstraint('address') + ) + op.create_table('access_keys_config', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('erc20_max_amount_day', sa.Integer(), nullable=False), + sa.Column('native_max_amount_day', sa.Integer(), nullable=False), + sa.Column('access_key_id', sa.String(), nullable=True), + sa.Column('chain_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['access_key_id'], ['access_keys.access_key_id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('transactions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('hash', sa.String(length=32), nullable=False), + sa.Column('recipient', sa.String(length=42), nullable=False), + sa.Column('token', sa.String(), nullable=True), + sa.Column('type', sa.String(length=10), nullable=False), + sa.Column('access_key_id', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['access_key_id'], ['access_keys.access_key_id'], ), + sa.ForeignKeyConstraint(['token'], ['tokens.address'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('transactions') + op.drop_table('access_keys_config') + op.drop_table('tokens') + op.drop_table('access_keys') + # ### end Alembic commands ### diff --git a/api/scripts/local_run_migrations.sh b/api/scripts/local_run_migrations.sh index daa87d0..d17c478 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_DATABASE_URI=sqlite:///:memory python3 -m flask db init # only the first time we initialize the DB -FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///:memory python3 -m flask db migrate +FLASK_APP=api FAUCET_ENABLED_CHAIN_IDS=100,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=100,10200 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/tests/conftest.py b/api/tests/conftest.py index 68082ed..9223b4e 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -2,12 +2,11 @@ import pytest from flask_migrate import upgrade -from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, ERC20_TOKEN_ADDRESS, - ERC20_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, - NATIVE_TOKEN_AMOUNT, NATIVE_TRANSFER_TX_HASH, - TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) +from temp_env_var import (FAUCET_ENABLED_TOKENS, NATIVE_TRANSFER_TX_HASH, + TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH) from api import create_app +from api.services.database import Token api_prefix = '/api/v1' @@ -32,8 +31,19 @@ def app(self, mocker): app = self._create_app() with app.app_context(): upgrade() + self.populate_db() yield app @pytest.fixture def client(self, app): return app.test_client() + + def populate_db(self): + for enabled_token in FAUCET_ENABLED_TOKENS: + token = Token() + token.address = enabled_token['address'] + token.name = enabled_token['name'] + token.chain_id = enabled_token['chainId'] + token.max_amount_day = enabled_token['maximumAmount'] + token.type = enabled_token['type'] + token.save() diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index 4122565..97bf9ba 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -1,29 +1,41 @@ import json from secrets import token_bytes -from api.const import NATIVE_TOKEN_ADDRESS +from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, NATIVE_TOKEN_ADDRESS) ZERO_ADDRESS = "0x" + '0' * 40 -NATIVE_TOKEN_AMOUNT = 0.1 -ERC20_TOKEN_AMOUNT = 0.5 ERC20_TOKEN_ADDRESS = ZERO_ADDRESS CAPTCHA_TEST_SECRET_KEY = '0x0000000000000000000000000000000000000000' CAPTCHA_TEST_RESPONSE_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001' +FAUCET_ENABLED_CHAIN_IDS = [100000] + FAUCET_ENABLED_TOKENS = [ - {"address": NATIVE_TOKEN_ADDRESS, "name": "Native", "maximumAmount": NATIVE_TOKEN_AMOUNT}, - {"address": ERC20_TOKEN_ADDRESS, "name": "TestToken", "maximumAmount": ERC20_TOKEN_AMOUNT} + { + "address": NATIVE_TOKEN_ADDRESS, + "name": "Native", + "maximumAmount": DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, + "chainId": FAUCET_ENABLED_CHAIN_IDS[0], + "type": "native" + }, + { + "address": ERC20_TOKEN_ADDRESS, + "name": "TestToken", + "maximumAmount": DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + "chainId": FAUCET_ENABLED_CHAIN_IDS[0], + "type": "erc20" + } ] TEMP_ENV_VARS = { 'FAUCET_RPC_URL': 'http://localhost:8545', - 'FAUCET_CHAIN_ID': '100000', + 'FAUCET_ENABLED_CHAIN_IDS': ','.join([str(id) for id in FAUCET_ENABLED_CHAIN_IDS]), 'FAUCET_PRIVATE_KEY': token_bytes(32).hex(), 'FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS': '10', - 'FAUCET_ENABLED_TOKENS': json.dumps(FAUCET_ENABLED_TOKENS), - 'FAUCET_DATABASE_URI': 'sqlite:///:memory', # run in-memory + 'FAUCET_DATABASE_URI': 'sqlite://', # run in-memory 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY } diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 3da6796..eecc3d9 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,9 +1,11 @@ import pytest from conftest import BaseTest, api_prefix # from mock import patch -from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, ERC20_TOKEN_ADDRESS, - ERC20_TOKEN_AMOUNT, NATIVE_TOKEN_ADDRESS, - NATIVE_TOKEN_AMOUNT, NATIVE_TRANSFER_TX_HASH, +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_ENABLED_CHAIN_IDS, + NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH, TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) from api.services import Strategy @@ -26,7 +28,7 @@ def test_ask_route_parameters(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, 'chainId': -1, - 'amount': NATIVE_TOKEN_AMOUNT, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) @@ -35,8 +37,8 @@ def test_ask_route_parameters(self, client): # wrong amount, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) @@ -45,8 +47,8 @@ def test_ask_route_parameters(self, client): # missing recipient, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) assert response.status_code == 400 @@ -54,8 +56,8 @@ def test_ask_route_parameters(self, client): # wrong recipient recipient, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': 'not an address', 'tokenAddress': NATIVE_TOKEN_ADDRESS }) @@ -63,8 +65,8 @@ def test_ask_route_parameters(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': ERC20_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': '0x00000123', 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -73,8 +75,8 @@ def test_ask_route_parameters(self, client): # missing token address, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS }) assert response.status_code == 400 @@ -82,8 +84,8 @@ def test_ask_route_parameters(self, client): # wrong token address, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT + 1, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': 'non existing token address' }) @@ -92,12 +94,11 @@ def test_ask_route_parameters(self, client): def test_ask_route_native_transaction(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) - print(response.get_json()) assert response.status_code == 200 assert response.get_json().get('transactionHash') == NATIVE_TRANSFER_TX_HASH @@ -105,8 +106,8 @@ def test_ask_route_token_transaction(self, client): # not supported token, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': '0x' + '1' * 40 }) @@ -114,8 +115,8 @@ def test_ask_route_token_transaction(self, client): response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -136,8 +137,8 @@ def test_ask_route_parameters(self, client): assert response.status_code == 400 response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -148,8 +149,8 @@ def test_ask_route_parameters(self, client): 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': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -173,8 +174,8 @@ def test_ask_route_limit_by_ip(self, client): # First request should return 200 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) @@ -183,8 +184,8 @@ def test_ask_route_limit_by_ip(self, client): # Second request should return 429 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, - 'chainId': TEMP_ENV_VARS['FAUCET_CHAIN_ID'], - 'amount': NATIVE_TOKEN_AMOUNT, + 'chainId': FAUCET_ENABLED_CHAIN_IDS[0], + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) From 588ec7dec102d2fc0e2a68b2d1a64301d343038f Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Fri, 8 Mar 2024 16:54:04 +0100 Subject: [PATCH 02/14] Improve tests setup --- api/api/routes.py | 19 +++--- api/api/services/database.py | 16 ++++- api/api/services/rate_limit.py | 8 ++- api/api/utils.py | 49 -------------- api/scripts/local_run_migrations.sh | 9 +-- api/tests/conftest.py | 28 ++++++-- api/tests/test_api.py | 86 ++----------------------- api/tests/test_api_claim_rate_limit.py | 88 ++++++++++++++++++++++++++ api/tests/test_api_cli.py | 41 ++++++++++++ api/tests/test_database.py | 24 ++++++- 10 files changed, 212 insertions(+), 156 deletions(-) create mode 100644 api/tests/test_api_claim_rate_limit.py create mode 100644 api/tests/test_api_cli.py diff --git a/api/api/routes.py b/api/api/routes.py index 4693b4e..b76c312 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -58,12 +58,8 @@ def _ask_route_validation(request_data, validate_captcha=True): if not catpcha_verified: validation_errors.append('captcha: validation failed') - if request_data.get('chainId') not in current_app.config['FAUCET_ENABLED_CHAIN_IDS']: - validation_errors.append('chainId: %s is not supported. Supported chainIds: %s' % ( - request_data.get('chainId'), - ', '.join([str(x) for x in current_app.config['FAUCET_ENABLED_CHAIN_IDS']]) - ) - ) + 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): @@ -110,9 +106,6 @@ 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 """ @@ -129,7 +122,13 @@ def _ask(request_data, validate_captcha=True, access_key=None): 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: + elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_and_address.value: + transaction = Transaction.last_by_ip_and_recipient(ip_address, + recipient) + elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_or_address.value: + transaction = Transaction.last_by_ip_or_recipient(ip_address, + recipient) + else: raise NotImplementedError # Check if the recipient can claim funds, they must not have claimed any tokens diff --git a/api/api/services/database.py b/api/api/services/database.py index 9c70e17..1b12623 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -124,7 +124,7 @@ class Transaction(BaseModel): __tablename__ = "transactions" __table_args__ = tuple( - db.PrimaryKeyConstraint('hash', 'token') + db.UniqueConstraint('hash'), ) @classmethod @@ -134,3 +134,17 @@ 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() 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/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/scripts/local_run_migrations.sh b/api/scripts/local_run_migrations.sh index 16a2e0e..b37e80a 100644 --- a/api/scripts/local_run_migrations.sh +++ b/api/scripts/local_run_migrations.sh @@ -3,13 +3,8 @@ set -x # DB MIGRATIONS: -<<<<<<< HEAD -FLASK_APP=api FAUCET_ENABLED_CHAIN_IDS=100,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=100,10200 FAUCET_DATABASE_URI=sqlite:// python3 -m flask db migrate -======= -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 ->>>>>>> dev +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 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 9223b4e..a7c0e8a 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,12 +1,12 @@ import os 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 api import create_app -from api.services.database import Token +from api.services import Strategy +from api.services.database import Token, db api_prefix = '/api/v1' @@ -25,19 +25,23 @@ def _mock(self, mocker, env_variables=None): def _create_app(self): return create_app() + def _reset_db(self): + db.drop_all() + db.create_all() + self.populate_db() + @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() + self._reset_db() yield app @pytest.fixture def client(self, app): return app.test_client() - + def populate_db(self): for enabled_token in FAUCET_ENABLED_TOKENS: token = Token() @@ -47,3 +51,17 @@ def populate_db(self): token.max_amount_day = enabled_token['maximumAmount'] token.type = enabled_token['type'] token.save() + + +class RateLimitBaseTest(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_or_address.value + mocker = self._mock(mocker, env_vars) + + app = self._create_app() + with app.app_context(): + self._reset_db() + yield app diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 0627048..29e97d4 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,17 +1,13 @@ -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) + TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) -from api.services import Strategy -from api.services.database import AccessKey, Transaction -from api.utils import generate_access_key +from api.services.database import Transaction class TestAPI(BaseTest): @@ -107,7 +103,7 @@ def test_ask_route_native_transaction(self, client): assert response.status_code == 200 assert response.get_json().get('transactionHash') == NATIVE_TRANSFER_TX_HASH - def test_ask_route_token_transaction(self, client): + def test_ask_route_token_transaction(self, client, *args, **kwargs): # not supported token, should return 400 response = client.post(api_prefix + '/ask', json={ 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, @@ -128,77 +124,5 @@ def test_ask_route_token_transaction(self, client): 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 - - -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 + transaction = Transaction.get_by_hash(TOKEN_TRANSFER_TX_HASH) + assert transaction.hash == TOKEN_TRANSFER_TX_HASH 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..c945cf4 --- /dev/null +++ b/api/tests/test_api_claim_rate_limit.py @@ -0,0 +1,88 @@ +import pytest +from conftest import BaseTest, RateLimitBaseTest, 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, TEMP_ENV_VARS, + ZERO_ADDRESS) + +from api.services import Strategy +from api.services.database import Transaction + + +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(): + self._reset_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 + + # 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 + + +class TestAPIWithIPorRecipientLimitStrategy(RateLimitBaseTest): + + def test_ask_route_limit_by_ip_or_address(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 + # 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 = 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 + + # 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 = 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 diff --git a/api/tests/test_api_cli.py b/api/tests/test_api_cli.py new file mode 100644 index 0000000..8c1c1e7 --- /dev/null +++ b/api/tests/test_api_cli.py @@ -0,0 +1,41 @@ +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, + ZERO_ADDRESS) + +from api.services.database import AccessKey +from api.utils import generate_access_key + + +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 diff --git a/api/tests/test_database.py b/api/tests/test_database.py index 5d3f645..22d5cd7 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,12 +1,13 @@ from conftest import BaseTest +from temp_env_var import NATIVE_TRANSFER_TX_HASH, NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS -from api.services.database import AccessKey +from api.services.database import AccessKey, Transaction, Token from api.utils import generate_access_key class TestDatabase(BaseTest): - def test_models(self, client): + def test_access_keys(self, client): access_key_id, secret_access_key = generate_access_key() assert len(access_key_id) == 16 assert len(secret_access_key) == 32 @@ -19,3 +20,22 @@ 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 + + def test_transactions(self, client): + 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() + + 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() From 606dfb696f424507a5c0d9af915ae903e74c3fe7 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Fri, 8 Mar 2024 20:02:42 +0100 Subject: [PATCH 03/14] [WIP] Add Validator class, /ask route overhaul --- api/api/api.py | 2 +- api/api/const.py | 8 +- api/api/routes.py | 133 ++++--------------- api/api/services/__init__.py | 1 + api/api/services/database.py | 47 ++++++- api/api/services/validator.py | 161 +++++++++++++++++++++++ api/migrations/versions/a6e2a13b563c_.py | 64 --------- api/migrations/versions/b5d4ca37347c_.py | 43 ++++++ api/tests/conftest.py | 19 ++- api/tests/temp_env_var.py | 11 +- api/tests/test_api.py | 4 +- api/tests/test_api_claim_rate_limit.py | 25 +--- api/tests/test_api_cli.py | 28 +++- api/tests/test_database.py | 55 ++++++-- 14 files changed, 381 insertions(+), 220 deletions(-) create mode 100644 api/api/services/validator.py delete mode 100644 api/migrations/versions/a6e2a13b563c_.py create mode 100644 api/migrations/versions/b5d4ca37347c_.py diff --git a/api/api/api.py b/api/api/api.py index ff7a949..1152ddb 100644 --- a/api/api/api.py +++ b/api/api/api.py @@ -25,7 +25,7 @@ def print_info(w3, config): logging.info("=" * 60) logging.info("RPC_URL = " + config['FAUCET_RPC_URL']) logging.info("FAUCET ADDRESS = " + config['FAUCET_ADDRESS']) - # logging.info("FAUCET BALANCE = %d %s" % (w3.from_wei(faucet_native_balance, 'ether'), config['FAUCET_CHAIN_NAME'])) + logging.info("FAUCET BALANCE = %d %s" % (w3.from_wei(faucet_native_balance, 'ether'), config['FAUCET_CHAIN_NAME'])) logging.info("=" * 60) 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 b76c312..c3cbeb8 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -1,11 +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, - claim_token) +from .const import FaucetRequestType, TokenType +from .services import Validator, Web3Singleton, claim_native, claim_token from .services.database import AccessKey, Token, Transaction apiv1 = Blueprint("version1", "version1") @@ -35,69 +32,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 @@ -109,57 +43,40 @@ def _ask(request_data, validate_captcha=True, access_key=None): 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.value: - transaction = Transaction.last_by_ip_and_recipient(ip_address, - recipient) - elif current_app.config['FAUCET_RATE_LIMIT_STRATEGY'].strategy == Strategy.ip_or_address.value: - transaction = Transaction.last_by_ip_or_recipient(ip_address, - 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.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 = Validator(request_data, + validate_captcha, + access_key=access_key) + validator.validate() + if len(validator.errors) > 0: + 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 diff --git a/api/api/services/__init__.py b/api/api/services/__init__.py index 93a405f..7ca423a 100644 --- a/api/api/services/__init__.py +++ b/api/api/services/__init__.py @@ -3,3 +3,4 @@ from .rate_limit import RateLimitStrategy, Strategy from .token import Token from .transaction import Web3Singleton, claim_native, claim_token +from .validator import Validator \ No newline at end of file diff --git a/api/api/services/database.py b/api/api/services/database.py index 1b12623..5f1cf1e 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -80,10 +80,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 +97,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 +117,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,7 +140,7 @@ class Transaction(BaseModel): updated = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) __tablename__ = "transactions" - __table_args__ = tuple( + __table_args__ = ( db.UniqueConstraint('hash'), ) @@ -148,3 +165,25 @@ def last_by_ip_or_recipient(cls, ip, recipient): @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/validator.py b/api/api/services/validator.py new file mode 100644 index 0000000..6660cd4 --- /dev/null +++ b/api/api/services/validator.py @@ -0,0 +1,161 @@ +import datetime + +from flask import current_app, jsonify, 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 Validator: + errors = [] + http_return_code = None + + def __init__(self, request_data, validate_captcha, access_key=None): + 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) + + def validate(self): + self.data_validation() + if len(self.errors) > 0: + return jsonify(errors=self.errors), self.http_return_code + + if self.validate_captcha: + self.captcha_validation() + if len(self.errors) > 0: + return jsonify(errors=self.errors), self.http_return_code + + self.token_validation() + if len(self.errors) > 0: + return jsonify(errors=self.errors), self.http_return_code + + if self.validate_captcha: + self.web_request_validation() + if len(self.errors) > 0: + return jsonify(errors=self.errors), self.http_return_code + + if self.access_key: + self.access_key_validation() + if len(self.errors) > 0: + return jsonify(errors=self.errors), self.http_return_code + + def data_validation(self): + if self.request_data.get('chainId') != current_app.config['FAUCET_CHAIN_ID']: + self.errors.append('chainId: %s is not supported. Supported chainId: %s' % ( + 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('recipient: A valid recipient address must be specified') + + if not recipient or recipient.lower() == current_app.config['FAUCET_ADDRESS']: + self.errors.append('recipient: address cant\'t be the Faucet address itself') + + amount = self.request_data.get('amount', None) + if not amount: + self.errors.append('amount: is required') + if amount and float(amount) <= 0: + self.errors.append('amount: must be greater than 0') + + token_address = self.request_data.get('tokenAddress', None) + if not Web3.is_address(token_address): + self.errors.append('tokenAddress: A valid token address must be specified') + + if len(self.errors) > 0: + self.http_return_code = 400 + else: + self.token_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.request_data.get('chainId')) + error_key = 'tokenAddress' + + if not self.token: + self.errors.append('%s: token not available for chainId %s' % ( + error_key, + self.request_data.get('chainId'))) + 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_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 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) + 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 + amount = Transaction.get_amount_sum_by_access_key_and_token( + self.access_key.access_key_id, + self.token.address, + custom_timerange=timerange) + + if self.token.type == TokenType.native.value: + if amount and amount >= access_key_config.native_max_amount_day: + self.errors.append("you have exceeded the limit for today") + elif self.token.type == TokenType.erc20.value: + if amount and amount >= access_key_config.erc20_max_amount_day: + self.errors.append("you have exceeded 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/migrations/versions/a6e2a13b563c_.py b/api/migrations/versions/a6e2a13b563c_.py deleted file mode 100644 index f023917..0000000 --- a/api/migrations/versions/a6e2a13b563c_.py +++ /dev/null @@ -1,64 +0,0 @@ -"""empty message - -Revision ID: a6e2a13b563c -Revises: -Create Date: 2024-03-03 17:44:59.725829 - -""" -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = 'a6e2a13b563c' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('access_keys', - sa.Column('access_key_id', sa.String(length=16), nullable=False), - sa.Column('secret_access_key', sa.String(length=32), nullable=False), - sa.Column('enabled', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('access_key_id') - ) - op.create_table('tokens', - sa.Column('name', sa.String(length=10), nullable=False), - sa.Column('chain_id', sa.Integer(), nullable=False), - sa.Column('address', sa.String(length=42), nullable=False), - sa.Column('enabled', sa.Boolean(), nullable=False), - sa.Column('max_amount_day', sa.Integer(), nullable=False), - sa.Column('type', sa.String(length=6), nullable=False), - sa.PrimaryKeyConstraint('address') - ) - op.create_table('access_keys_config', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('erc20_max_amount_day', sa.Integer(), nullable=False), - sa.Column('native_max_amount_day', sa.Integer(), nullable=False), - sa.Column('access_key_id', sa.String(), nullable=True), - sa.Column('chain_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['access_key_id'], ['access_keys.access_key_id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('transactions', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('hash', sa.String(length=32), nullable=False), - sa.Column('recipient', sa.String(length=42), nullable=False), - sa.Column('token', sa.String(), nullable=True), - sa.Column('type', sa.String(length=10), nullable=False), - sa.Column('access_key_id', sa.String(), nullable=True), - sa.ForeignKeyConstraint(['access_key_id'], ['access_keys.access_key_id'], ), - sa.ForeignKeyConstraint(['token'], ['tokens.address'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('transactions') - op.drop_table('access_keys_config') - op.drop_table('tokens') - op.drop_table('access_keys') - # ### end Alembic commands ### diff --git a/api/migrations/versions/b5d4ca37347c_.py b/api/migrations/versions/b5d4ca37347c_.py new file mode 100644 index 0000000..ebaebf6 --- /dev/null +++ b/api/migrations/versions/b5d4ca37347c_.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: b5d4ca37347c +Revises: 022497197c7a +Create Date: 2024-03-08 17:17:14.960915 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'b5d4ca37347c' +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) as batch_op: + batch_op.create_unique_constraint(None, ['secret_access_key']) + + with op.batch_alter_table('access_keys_config', schema=None) as batch_op: + batch_op.create_unique_constraint(None, ['access_key_id', 'chain_id']) + + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.create_unique_constraint(None, ['hash']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + + with op.batch_alter_table('access_keys_config', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + + with op.batch_alter_table('access_keys', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + + # ### end Alembic commands ### diff --git a/api/tests/conftest.py b/api/tests/conftest.py index a7c0e8a..fd3b438 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -16,7 +16,7 @@ def _mock(self, mocker, 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.services.captcha_verify', return_value=True) mocker.patch('api.api.print_info', return_value=None) if env_variables: mocker.patch.dict(os.environ, env_variables) @@ -26,6 +26,7 @@ def _create_app(self): return create_app() def _reset_db(self): + print("#== Reset DB ==#") db.drop_all() db.create_all() self.populate_db() @@ -53,7 +54,21 @@ def populate_db(self): token.save() -class RateLimitBaseTest(BaseTest): +class RateLimitIPBaseTest(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(): + self._reset_db() + yield app + + +class RateLimitIPorAddressBaseTest(BaseTest): @pytest.fixture def app(self, mocker): # Set rate limit strategy to IP diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index e2446bf..a1692c7 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 } ] diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 29e97d4..2426fda 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -5,8 +5,9 @@ DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID, NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH, - TOKEN_TRANSFER_TX_HASH, ZERO_ADDRESS) + TOKEN_TRANSFER_TX_HASH) +from api.const import ZERO_ADDRESS from api.services.database import Transaction @@ -121,6 +122,7 @@ def test_ask_route_token_transaction(self, client, *args, **kwargs): 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) + print(response.get_json()) assert response.status_code == 200 assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH diff --git a/api/tests/test_api_claim_rate_limit.py b/api/tests/test_api_claim_rate_limit.py index c945cf4..d358870 100644 --- a/api/tests/test_api_claim_rate_limit.py +++ b/api/tests/test_api_claim_rate_limit.py @@ -1,28 +1,15 @@ -import pytest -from conftest import BaseTest, RateLimitBaseTest, api_prefix +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, TEMP_ENV_VARS, - ZERO_ADDRESS) + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) -from api.services import Strategy +from api.const import ZERO_ADDRESS from api.services.database import Transaction -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(): - self._reset_db() - yield app +class TestAPIWithIPLimitStrategy(RateLimitIPBaseTest): def test_ask_route_limit_by_ip(self, client): response = client.post(api_prefix + '/ask', json={ @@ -45,7 +32,7 @@ def test_ask_route_limit_by_ip(self, client): assert response.status_code == 429 -class TestAPIWithIPorRecipientLimitStrategy(RateLimitBaseTest): +class TestAPIWithIPorRecipientLimitStrategy(RateLimitIPorAddressBaseTest): def test_ask_route_limit_by_ip_or_address(self, client): response = client.post(api_prefix + '/ask', json={ diff --git a/api/tests/test_api_cli.py b/api/tests/test_api_cli.py index 8c1c1e7..4b2a356 100644 --- a/api/tests/test_api_cli.py +++ b/api/tests/test_api_cli.py @@ -1,10 +1,10 @@ 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, - ZERO_ADDRESS) + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) -from api.services.database import AccessKey +from api.const import ZERO_ADDRESS +from api.services.database import AccessKey, AccessKeyConfig from api.utils import generate_access_key @@ -29,8 +29,18 @@ def test_ask_route_parameters(self, client): # 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() + # 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 = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ 'chainId': FAUCET_CHAIN_ID, @@ -39,3 +49,11 @@ def test_ask_route_parameters(self, client): 'tokenAddress': ERC20_TOKEN_ADDRESS }) assert response.status_code == 200 + + response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ + 'chainId': FAUCET_CHAIN_ID, + 'amount': 30, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + assert response.status_code == 429 diff --git a/api/tests/test_database.py b/api/tests/test_database.py index 22d5cd7..82c2a64 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,7 +1,11 @@ +import pytest from conftest import BaseTest -from temp_env_var import NATIVE_TRANSFER_TX_HASH, NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS +from sqlalchemy.exc import IntegrityError +from temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH -from api.services.database import AccessKey, Transaction, Token +from api.const import ZERO_ADDRESS +from api.services.database import (AccessKey, AccessKeyConfig, Token, + Transaction) from api.utils import generate_access_key @@ -21,6 +25,37 @@ def test_access_keys(self, client): assert result[0].secret_access_key == secret_access_key assert result[0].enabled is True + # Duplicates for secret_access_key are not allowed + with pytest.raises(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, client): + 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 pytest.raises(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, client): token = Token.get_by_address(NATIVE_TOKEN_ADDRESS) @@ -32,10 +67,12 @@ def test_transactions(self, client): transaction.requester_ip = '192.168.0.1' transaction.save() - 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 pytest.raises(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() From 5e1f49def94198f3e638aeaa0d715d08ac8040c7 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 00:31:48 +0100 Subject: [PATCH 04/14] [WIP] replace pytest with unittest, improve handling of access keys --- .github/workflows/publish-api.yaml | 4 +- README.md | 2 +- api/api/routes.py | 14 ++-- api/api/services/__init__.py | 2 +- api/api/services/validator.py | 82 ++++++++++++------- api/tests/__init__.py | 0 api/tests/conftest.py | 107 +++++++++++++++---------- api/tests/temp_env_var.py | 3 +- api/tests/test_api.py | 66 +++++++-------- api/tests/test_api_claim_rate_limit.py | 25 +++--- api/tests/test_api_cli.py | 20 ++--- api/tests/test_database.py | 9 ++- 12 files changed, 192 insertions(+), 142 deletions(-) create mode 100644 api/tests/__init__.py diff --git a/.github/workflows/publish-api.yaml b/.github/workflows/publish-api.yaml index 048681e..21ab932 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 tests 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/routes.py b/api/api/routes.py index c3cbeb8..a2c3e1f 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -2,7 +2,8 @@ from web3 import Web3 from .const import FaucetRequestType, TokenType -from .services import Validator, Web3Singleton, claim_native, claim_token +from .services import (AskEndpointValidator, Web3Singleton, claim_native, + claim_token) from .services.database import AccessKey, Token, Transaction apiv1 = Blueprint("version1", "version1") @@ -43,11 +44,11 @@ def _ask(request_data, validate_captcha=True, access_key=None): Returns: tuple: json content, status code """ - validator = Validator(request_data, - validate_captcha, - access_key=access_key) - validator.validate() - if len(validator.errors) > 0: + 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 @@ -83,7 +84,6 @@ def _ask(request_data, validate_captcha=True, access_key=None): 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 7ca423a..0a951d4 100644 --- a/api/api/services/__init__.py +++ b/api/api/services/__init__.py @@ -3,4 +3,4 @@ from .rate_limit import RateLimitStrategy, Strategy from .token import Token from .transaction import Web3Singleton, claim_native, claim_token -from .validator import Validator \ No newline at end of file +from .validator import AskEndpointValidator diff --git a/api/api/services/validator.py b/api/api/services/validator.py index 6660cd4..3d84b5a 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -1,6 +1,6 @@ import datetime -from flask import current_app, jsonify, request +from flask import current_app, request from web3 import Web3 from api.const import TokenType @@ -10,11 +10,20 @@ from .rate_limit import Strategy -class Validator: +class AskEndpointValidator: errors = [] http_return_code = None - def __init__(self, request_data, validate_captcha, access_key=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 @@ -23,54 +32,58 @@ def __init__(self, request_data, validate_captcha, access_key=None): def validate(self): self.data_validation() if len(self.errors) > 0: - return jsonify(errors=self.errors), self.http_return_code + 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 jsonify(errors=self.errors), self.http_return_code + return False - self.token_validation() - if len(self.errors) > 0: - return jsonify(errors=self.errors), self.http_return_code + self.web_request_amount_validation() + if len(self.errors) > 0: + return False - if self.validate_captcha: - self.web_request_validation() + self.web_request_limit_validation() if len(self.errors) > 0: - return jsonify(errors=self.errors), self.http_return_code + return False if self.access_key: self.access_key_validation() if len(self.errors) > 0: - return jsonify(errors=self.errors), self.http_return_code + return False + return True def data_validation(self): if self.request_data.get('chainId') != current_app.config['FAUCET_CHAIN_ID']: - self.errors.append('chainId: %s is not supported. Supported chainId: %s' % ( + 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('recipient: A valid recipient address must be specified') + self.errors.append(self.messages['INVALID_RECIPIENT']) if not recipient or recipient.lower() == current_app.config['FAUCET_ADDRESS']: - self.errors.append('recipient: address cant\'t be the Faucet address itself') + self.errors.append(self.messages['INVALID_RECIPIENT_ITSELF']) amount = self.request_data.get('amount', None) if not amount: - self.errors.append('amount: is required') + self.errors.append(self.messages['REQUIRED_AMOUNT']) if amount and float(amount) <= 0: - self.errors.append('amount: must be greater than 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('tokenAddress: A valid token address must be specified') + self.errors.append(self.messages['INVALID_TOKEN_ADDRESS']) if len(self.errors) > 0: self.http_return_code = 400 else: - self.token_address = token_address + self.token_address = Web3.to_checksum_address(token_address) self.amount = float(amount) self.chain_id = self.request_data.get('chainId') self.recipient = recipient @@ -91,13 +104,13 @@ def captcha_validation(self): def token_validation(self): self.token = Token.get_by_address_and_chain_id(self.token_address, - self.request_data.get('chainId')) + self.chain_id) error_key = 'tokenAddress' if not self.token: self.errors.append('%s: token not available for chainId %s' % ( error_key, - self.request_data.get('chainId'))) + 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)) @@ -105,13 +118,17 @@ def token_validation(self): if len(self.errors) > 0: self.http_return_code = 400 - def web_request_validation(self): + 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) @@ -119,9 +136,11 @@ def web_request_validation(self): # 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) + 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) + transaction = Transaction.last_by_ip_or_recipient(self.ip_address, + self.recipient) else: raise NotImplementedError @@ -132,7 +151,9 @@ def web_request_validation(self): 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) - self.http_return_code = 429 + + 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 @@ -143,17 +164,18 @@ def access_key_validation(self): seconds=current_app.config['FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS']) # get amount from the last X hours - amount = Transaction.get_amount_sum_by_access_key_and_token( + 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 amount and amount >= access_key_config.native_max_amount_day: - self.errors.append("you have exceeded the limit for today") + 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 amount and amount >= access_key_config.erc20_max_amount_day: - self.errors.append("you have exceeded the limit for today") + 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) 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 fd3b438..e3b1fa8 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,8 +1,7 @@ import os +from unittest import TestCase, mock -import pytest -from temp_env_var import (FAUCET_ENABLED_TOKENS, NATIVE_TRANSFER_TX_HASH, - TEMP_ENV_VARS, TOKEN_TRANSFER_TX_HASH) +from temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS from api import create_app from api.services import Strategy @@ -11,19 +10,31 @@ 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.services.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.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() + self.patchers.append(mock.patch.dict(os.environ, env_variables)) + + for p in self.patchers: + p.start() def _reset_db(self): print("#== Reset DB ==#") @@ -31,18 +42,6 @@ def _reset_db(self): db.create_all() self.populate_db() - @pytest.fixture - def app(self, mocker): - mocker = self._mock(mocker, TEMP_ENV_VARS) - app = self._create_app() - with app.app_context(): - self._reset_db() - yield app - - @pytest.fixture - def client(self, app): - return app.test_client() - def populate_db(self): for enabled_token in FAUCET_ENABLED_TOKENS: token = Token() @@ -53,30 +52,56 @@ def populate_db(self): token.type = enabled_token['type'] token.save() + def setUp(self): + self._mock(TEMP_ENV_VARS) + self.app = create_app() + self.appctx = self.app.app_context() + self.client = self.app.test_client() + self.appctx.push() + with self.appctx: + self._reset_db() + + self.native_tx_counter = 0 + self.erc20_tx_counter = 0 + + def tearDown(self): + self.appctx.pop() + self.client = None + self.app = None + self.appctx = None + + for p in self.patchers: + p.stop() + class RateLimitIPBaseTest(BaseTest): - @pytest.fixture - def app(self, mocker): - # Set rate limit strategy to IP + def setUp(self): 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(): + self._mock(env_vars) + self.app = create_app() + self.appctx = self.app.app_context() + self.client = self.app.test_client() + self.appctx.push() + with self.appctx: self._reset_db() - yield app + + self.native_tx_counter = 0 + self.erc20_tx_counter = 0 class RateLimitIPorAddressBaseTest(BaseTest): - @pytest.fixture - def app(self, mocker): + def setUp(self): # Set rate limit strategy to IP env_vars = TEMP_ENV_VARS.copy() env_vars['FAUCET_RATE_LIMIT_STRATEGY'] = Strategy.ip_or_address.value - mocker = self._mock(mocker, env_vars) - - app = self._create_app() - with app.app_context(): + self._mock(env_vars) + self.app = create_app() + self.appctx = self.app.app_context() + self.client = self.app.test_client() + self.appctx.push() + with self.appctx: self._reset_db() - yield app + + self.native_tx_counter = 0 + self.erc20_tx_counter = 0 diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index a1692c7..4a69910 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -33,7 +33,8 @@ 'FAUCET_CHAIN_ID': str(FAUCET_CHAIN_ID), '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://', # 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 2426fda..468f9b4 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -13,117 +13,117 @@ 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 + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json().get('transactionHash'), + NATIVE_TRANSFER_TX_HASH) - def test_ask_route_token_transaction(self, client, *args, **kwargs): + 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 }) - 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 }) - print(response.get_json()) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH transaction = Transaction.get_by_hash(TOKEN_TRANSFER_TX_HASH) diff --git a/api/tests/test_api_claim_rate_limit.py b/api/tests/test_api_claim_rate_limit.py index d358870..1a5b9bc 100644 --- a/api/tests/test_api_claim_rate_limit.py +++ b/api/tests/test_api_claim_rate_limit.py @@ -11,31 +11,31 @@ class TestAPIWithIPLimitStrategy(RateLimitIPBaseTest): - def test_ask_route_limit_by_ip(self, client): - response = client.post(api_prefix + '/ask', json={ + 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 }) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) # Second request should return 429 - 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 == 429 + self.assertEqual(response.status_code, 429) class TestAPIWithIPorRecipientLimitStrategy(RateLimitIPorAddressBaseTest): - def test_ask_route_limit_by_ip_or_address(self, client): - response = client.post(api_prefix + '/ask', json={ + 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, @@ -43,20 +43,20 @@ def test_ask_route_limit_by_ip_or_address(self, client): 'tokenAddress': ERC20_TOKEN_ADDRESS }) - assert response.status_code == 200 + 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 = 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 == 429 + self.assertEqual(response.status_code, 429) # Change IP on DB fake_ip = '192.168.10.155' @@ -64,7 +64,7 @@ def test_ask_route_limit_by_ip_or_address(self, client): transaction.requester_ip = fake_ip transaction.save() - 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, @@ -72,4 +72,5 @@ def test_ask_route_limit_by_ip_or_address(self, client): 'tokenAddress': ERC20_TOKEN_ADDRESS }) - assert response.status_code == 429 + print("%s: %d" % (response.get_json(), response.status_code)) + self.assertEqual(response.status_code, 429) diff --git a/api/tests/test_api_cli.py b/api/tests/test_api_cli.py index 4b2a356..759b238 100644 --- a/api/tests/test_api_cli.py +++ b/api/tests/test_api_cli.py @@ -8,26 +8,26 @@ from api.utils import generate_access_key -class TestCliAPI(BaseTest): - def test_ask_route_parameters(self, client): +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 = client.post(api_prefix + '/cli/ask', json={}) + response = self.client.post(api_prefix + '/cli/ask', json={}) # Missing headers - assert response.status_code == 400 + self.assertEqual(response.status_code, 400) - response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ + 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 - assert response.status_code == 403 + self.assertEqual(response.status_code, 403) # Create access keys on DB access_key = AccessKey() @@ -42,18 +42,18 @@ def test_ask_route_parameters(self, client): config.native_max_amount_day = 20 config.save() - response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ + 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 }) - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) - response = client.post(api_prefix + '/cli/ask', headers=http_headers, json={ + 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 }) - assert response.status_code == 429 + self.assertEqual(response.status_code, 429) diff --git a/api/tests/test_database.py b/api/tests/test_database.py index 82c2a64..a1a21a6 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,6 +1,7 @@ import pytest -from conftest import BaseTest from sqlalchemy.exc import IntegrityError + +from conftest import BaseTest from temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH from api.const import ZERO_ADDRESS @@ -11,7 +12,7 @@ class TestDatabase(BaseTest): - def test_access_keys(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 @@ -33,7 +34,7 @@ def test_access_keys(self, client): access_key.secret_access_key = secret_access_key access_key.save() - def test_access_key_config(self, client): + 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 @@ -56,7 +57,7 @@ def test_access_key_config(self, client): config.native_max_amount_day = 10 config.save() - def test_transactions(self, client): + def test_transactions(self): token = Token.get_by_address(NATIVE_TOKEN_ADDRESS) transaction = Transaction() From 41689beaeeceb49c70d04e15fa1993549803ec37 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 00:33:50 +0100 Subject: [PATCH 05/14] Cleanup --- api/api/services/database.py | 5 ++--- api/api/services/validator.py | 5 ++--- api/api/settings.py | 1 - api/tests/conftest.py | 4 ++-- api/tests/test_api.py | 5 ++--- api/tests/test_api_claim_rate_limit.py | 5 ++--- api/tests/test_api_cli.py | 7 +++---- api/tests/test_database.py | 8 +++----- 8 files changed, 16 insertions(+), 24 deletions(-) diff --git a/api/api/services/database.py b/api/api/services/database.py index 5f1cf1e..7d07761 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -1,10 +1,9 @@ import sqlite3 from datetime import datetime -from flask_sqlalchemy import SQLAlchemy - from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType) +from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() @@ -165,7 +164,7 @@ def last_by_ip_or_recipient(cls, ip, recipient): @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, diff --git a/api/api/services/validator.py b/api/api/services/validator.py index 3d84b5a..a47fb68 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -1,10 +1,9 @@ import datetime +from api.const import TokenType 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 @@ -144,7 +143,7 @@ def web_request_limit_validation(self): else: raise NotImplementedError - # Check if the recipient can claim funds, they must not have claimed any tokens + # 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() diff --git a/api/api/settings.py b/api/api/settings.py index 45d0bd5..69468c2 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -1,4 +1,3 @@ -import json import os from dotenv import load_dotenv diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e3b1fa8..2b19928 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,11 +1,11 @@ import os from unittest import TestCase, mock +from api.services import Strategy +from api.services.database import Token, db from temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS from api import create_app -from api.services import Strategy -from api.services.database import Token, db api_prefix = '/api/v1' diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 468f9b4..de552dc 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,3 +1,5 @@ +from api.const import ZERO_ADDRESS +from api.services.database import Transaction from conftest import BaseTest, api_prefix # from mock import patch from temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, @@ -7,9 +9,6 @@ NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH, TOKEN_TRANSFER_TX_HASH) -from api.const import ZERO_ADDRESS -from api.services.database import Transaction - class TestAPI(BaseTest): diff --git a/api/tests/test_api_claim_rate_limit.py b/api/tests/test_api_claim_rate_limit.py index 1a5b9bc..4157927 100644 --- a/api/tests/test_api_claim_rate_limit.py +++ b/api/tests/test_api_claim_rate_limit.py @@ -1,3 +1,5 @@ +from api.const import ZERO_ADDRESS +from api.services.database import Transaction from conftest import (RateLimitIPBaseTest, RateLimitIPorAddressBaseTest, api_prefix) # from mock import patch @@ -5,9 +7,6 @@ DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) -from api.const import ZERO_ADDRESS -from api.services.database import Transaction - class TestAPIWithIPLimitStrategy(RateLimitIPBaseTest): diff --git a/api/tests/test_api_cli.py b/api/tests/test_api_cli.py index 759b238..397ef6d 100644 --- a/api/tests/test_api_cli.py +++ b/api/tests/test_api_cli.py @@ -1,12 +1,11 @@ +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) -from api.const import ZERO_ADDRESS -from api.services.database import AccessKey, AccessKeyConfig -from api.utils import generate_access_key - class TestAPICli(BaseTest): def test_ask_route_parameters(self): diff --git a/api/tests/test_database.py b/api/tests/test_database.py index a1a21a6..e18ea96 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,13 +1,11 @@ import pytest -from sqlalchemy.exc import IntegrityError - -from conftest import BaseTest -from temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH - 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 sqlalchemy.exc import IntegrityError +from temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH class TestDatabase(BaseTest): From d5b939110c050013e03932958b12f91bf1dad949 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 11:06:31 +0100 Subject: [PATCH 06/14] Fix tests --- api/api/services/database.py | 3 +- api/api/services/validator.py | 5 +- api/tests/conftest.py | 64 +++++++++++++++----------- api/tests/test_api.py | 32 +++++++------ api/tests/test_api_claim_rate_limit.py | 19 +++++--- api/tests/test_api_cli.py | 13 ++++-- api/tests/test_database.py | 21 ++++++--- 7 files changed, 99 insertions(+), 58 deletions(-) diff --git a/api/api/services/database.py b/api/api/services/database.py index 7d07761..a667776 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -1,9 +1,10 @@ import sqlite3 from datetime import datetime +from flask_sqlalchemy import SQLAlchemy + from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType) -from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() diff --git a/api/api/services/validator.py b/api/api/services/validator.py index a47fb68..e84d608 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -1,9 +1,11 @@ import datetime +import pdb -from api.const import TokenType 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 @@ -27,6 +29,7 @@ def __init__(self, request_data, validate_captcha, access_key=None, *args, **kwa 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() diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 2b19928..3622fd5 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,11 +1,13 @@ import os -from unittest import TestCase, mock +from unittest import TestCase, TestResult, mock +from flask.testing import FlaskClient + +from api import create_app from api.services import Strategy from api.services.database import Token, db -from temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS -from api import create_app +from .temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS api_prefix = '/api/v1' @@ -53,55 +55,65 @@ def populate_db(self): 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + with self.app_ctx: + self._reset_db() def tearDown(self): - self.appctx.pop() - self.client = None - self.app = None - self.appctx = None - + ''' + 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + with self.app_ctx: + self._reset_db() diff --git a/api/tests/test_api.py b/api/tests/test_api.py index de552dc..64f74a3 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,13 +1,15 @@ +import unittest + from api.const import ZERO_ADDRESS from api.services.database import Transaction -from conftest import BaseTest, api_prefix + +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, NATIVE_TRANSFER_TX_HASH, - TOKEN_TRANSFER_TX_HASH) +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): @@ -100,9 +102,10 @@ def test_ask_route_native_transaction(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() self.assertEqual(response.status_code, 200) - self.assertEqual(response.get_json().get('transactionHash'), - NATIVE_TRANSFER_TX_HASH) + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) def test_ask_route_token_transaction(self): # not supported token, should return 400 @@ -111,7 +114,7 @@ def test_ask_route_token_transaction(self): 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, - 'tokenAddress': '0x' + '1' * 40 + 'tokenAddress': '0x' + '1234' * 10 }) self.assertEqual(response.status_code, 400) @@ -122,8 +125,11 @@ def test_ask_route_token_transaction(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() self.assertEqual(response.status_code, 200) - assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) + - transaction = Transaction.get_by_hash(TOKEN_TRANSFER_TX_HASH) - assert transaction.hash == TOKEN_TRANSFER_TX_HASH +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 index 4157927..6436013 100644 --- a/api/tests/test_api_claim_rate_limit.py +++ b/api/tests/test_api_claim_rate_limit.py @@ -1,11 +1,14 @@ +import unittest + from api.const import ZERO_ADDRESS from api.services.database import Transaction -from conftest import (RateLimitIPBaseTest, RateLimitIPorAddressBaseTest, - api_prefix) + +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) +from .temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, + DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) class TestAPIWithIPLimitStrategy(RateLimitIPBaseTest): @@ -70,6 +73,8 @@ def test_ask_route_limit_by_ip_or_address(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) - - print("%s: %d" % (response.get_json(), response.status_code)) 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 index 397ef6d..608dccb 100644 --- a/api/tests/test_api_cli.py +++ b/api/tests/test_api_cli.py @@ -1,10 +1,13 @@ +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 .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) +from .temp_env_var import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) class TestAPICli(BaseTest): @@ -56,3 +59,7 @@ def test_ask_route_parameters(self): '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 e18ea96..e436ca5 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,11 +1,14 @@ -import pytest +import unittest + +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 sqlalchemy.exc import IntegrityError -from temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH + +from .conftest import BaseTest +from .temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH class TestDatabase(BaseTest): @@ -25,7 +28,7 @@ def test_access_keys(self): assert result[0].enabled is True # Duplicates for secret_access_key are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): access_key_id2, _ = generate_access_key() access_key = AccessKey() access_key.access_key_id = access_key_id2 @@ -47,7 +50,7 @@ def test_access_key_config(self): config.save() # Duplicates for (access_key_id, chain_id) are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): config = AccessKeyConfig() config.access_key_id = access_key.access_key_id config.chain_id = 10200 @@ -67,7 +70,7 @@ def test_transactions(self): transaction.save() # Duplicates for tx hash are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): transaction = Transaction() transaction.hash = NATIVE_TRANSFER_TX_HASH transaction.recipient = ZERO_ADDRESS @@ -75,3 +78,7 @@ def test_transactions(self): transaction.token = token.address transaction.requester_ip = '192.168.0.1' transaction.save() + + +if __name__ == '__main__': + unittest.main() From 9f4e3684e2f501ffffb8daabcba65c6b843cab1b Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 11:06:31 +0100 Subject: [PATCH 07/14] Fix tests --- api/api/services/database.py | 3 +- api/api/services/validator.py | 5 +- api/tests/conftest.py | 64 +++++++++++++++----------- api/tests/test_api.py | 32 +++++++------ api/tests/test_api_claim_rate_limit.py | 19 +++++--- api/tests/test_api_cli.py | 13 ++++-- api/tests/test_database.py | 21 ++++++--- 7 files changed, 99 insertions(+), 58 deletions(-) diff --git a/api/api/services/database.py b/api/api/services/database.py index 7d07761..a667776 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -1,9 +1,10 @@ import sqlite3 from datetime import datetime +from flask_sqlalchemy import SQLAlchemy + from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType) -from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() diff --git a/api/api/services/validator.py b/api/api/services/validator.py index a47fb68..e84d608 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -1,9 +1,11 @@ import datetime +import pdb -from api.const import TokenType 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 @@ -27,6 +29,7 @@ def __init__(self, request_data, validate_captcha, access_key=None, *args, **kwa 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() diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 2b19928..3622fd5 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,11 +1,13 @@ import os -from unittest import TestCase, mock +from unittest import TestCase, TestResult, mock +from flask.testing import FlaskClient + +from api import create_app from api.services import Strategy from api.services.database import Token, db -from temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS -from api import create_app +from .temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS api_prefix = '/api/v1' @@ -53,55 +55,65 @@ def populate_db(self): 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + with self.app_ctx: + self._reset_db() def tearDown(self): - self.appctx.pop() - self.client = None - self.app = None - self.appctx = None - + ''' + 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + with self.app_ctx: + self._reset_db() diff --git a/api/tests/test_api.py b/api/tests/test_api.py index de552dc..64f74a3 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,13 +1,15 @@ +import unittest + from api.const import ZERO_ADDRESS from api.services.database import Transaction -from conftest import BaseTest, api_prefix + +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, NATIVE_TRANSFER_TX_HASH, - TOKEN_TRANSFER_TX_HASH) +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): @@ -100,9 +102,10 @@ def test_ask_route_native_transaction(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() self.assertEqual(response.status_code, 200) - self.assertEqual(response.get_json().get('transactionHash'), - NATIVE_TRANSFER_TX_HASH) + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) def test_ask_route_token_transaction(self): # not supported token, should return 400 @@ -111,7 +114,7 @@ def test_ask_route_token_transaction(self): 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, - 'tokenAddress': '0x' + '1' * 40 + 'tokenAddress': '0x' + '1234' * 10 }) self.assertEqual(response.status_code, 400) @@ -122,8 +125,11 @@ def test_ask_route_token_transaction(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() self.assertEqual(response.status_code, 200) - assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) + - transaction = Transaction.get_by_hash(TOKEN_TRANSFER_TX_HASH) - assert transaction.hash == TOKEN_TRANSFER_TX_HASH +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 index 4157927..6436013 100644 --- a/api/tests/test_api_claim_rate_limit.py +++ b/api/tests/test_api_claim_rate_limit.py @@ -1,11 +1,14 @@ +import unittest + from api.const import ZERO_ADDRESS from api.services.database import Transaction -from conftest import (RateLimitIPBaseTest, RateLimitIPorAddressBaseTest, - api_prefix) + +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) +from .temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, + DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) class TestAPIWithIPLimitStrategy(RateLimitIPBaseTest): @@ -70,6 +73,8 @@ def test_ask_route_limit_by_ip_or_address(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) - - print("%s: %d" % (response.get_json(), response.status_code)) 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 index 397ef6d..608dccb 100644 --- a/api/tests/test_api_cli.py +++ b/api/tests/test_api_cli.py @@ -1,10 +1,13 @@ +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 .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) +from .temp_env_var import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) class TestAPICli(BaseTest): @@ -56,3 +59,7 @@ def test_ask_route_parameters(self): '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 e18ea96..e436ca5 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,11 +1,14 @@ -import pytest +import unittest + +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 sqlalchemy.exc import IntegrityError -from temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH + +from .conftest import BaseTest +from .temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH class TestDatabase(BaseTest): @@ -25,7 +28,7 @@ def test_access_keys(self): assert result[0].enabled is True # Duplicates for secret_access_key are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): access_key_id2, _ = generate_access_key() access_key = AccessKey() access_key.access_key_id = access_key_id2 @@ -47,7 +50,7 @@ def test_access_key_config(self): config.save() # Duplicates for (access_key_id, chain_id) are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): config = AccessKeyConfig() config.access_key_id = access_key.access_key_id config.chain_id = 10200 @@ -67,7 +70,7 @@ def test_transactions(self): transaction.save() # Duplicates for tx hash are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): transaction = Transaction() transaction.hash = NATIVE_TRANSFER_TX_HASH transaction.recipient = ZERO_ADDRESS @@ -75,3 +78,7 @@ def test_transactions(self): transaction.token = token.address transaction.requester_ip = '192.168.0.1' transaction.save() + + +if __name__ == '__main__': + unittest.main() From eb05e0a5464067fcfb000e593ae6b4be3cc26a6a Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 11:06:31 +0100 Subject: [PATCH 08/14] Fix tests --- api/api/services/database.py | 3 +- api/api/services/validator.py | 5 +- api/tests/conftest.py | 64 +++++++++++++++----------- api/tests/test_api.py | 32 +++++++------ api/tests/test_api_claim_rate_limit.py | 19 +++++--- api/tests/test_api_cli.py | 13 ++++-- api/tests/test_database.py | 21 ++++++--- 7 files changed, 99 insertions(+), 58 deletions(-) diff --git a/api/api/services/database.py b/api/api/services/database.py index 7d07761..a667776 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -1,9 +1,10 @@ import sqlite3 from datetime import datetime +from flask_sqlalchemy import SQLAlchemy + from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType) -from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() diff --git a/api/api/services/validator.py b/api/api/services/validator.py index a47fb68..e84d608 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -1,9 +1,11 @@ import datetime +import pdb -from api.const import TokenType 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 @@ -27,6 +29,7 @@ def __init__(self, request_data, validate_captcha, access_key=None, *args, **kwa 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() diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 2b19928..3622fd5 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,11 +1,13 @@ import os -from unittest import TestCase, mock +from unittest import TestCase, TestResult, mock +from flask.testing import FlaskClient + +from api import create_app from api.services import Strategy from api.services.database import Token, db -from temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS -from api import create_app +from .temp_env_var import FAUCET_ENABLED_TOKENS, TEMP_ENV_VARS api_prefix = '/api/v1' @@ -53,55 +55,65 @@ def populate_db(self): 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + with self.app_ctx: + self._reset_db() def tearDown(self): - self.appctx.pop() - self.client = None - self.app = None - self.appctx = None - + ''' + 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + 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.appctx = self.app.app_context() + self.app_ctx = self.app.test_request_context() + self.app_ctx.push() + self.client = self.app.test_client() - self.appctx.push() - with self.appctx: - self._reset_db() - self.native_tx_counter = 0 - self.erc20_tx_counter = 0 + with self.app_ctx: + self._reset_db() diff --git a/api/tests/test_api.py b/api/tests/test_api.py index de552dc..64f74a3 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,13 +1,15 @@ +import unittest + from api.const import ZERO_ADDRESS from api.services.database import Transaction -from conftest import BaseTest, api_prefix + +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, NATIVE_TRANSFER_TX_HASH, - TOKEN_TRANSFER_TX_HASH) +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): @@ -100,9 +102,10 @@ def test_ask_route_native_transaction(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS }) + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() self.assertEqual(response.status_code, 200) - self.assertEqual(response.get_json().get('transactionHash'), - NATIVE_TRANSFER_TX_HASH) + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) def test_ask_route_token_transaction(self): # not supported token, should return 400 @@ -111,7 +114,7 @@ def test_ask_route_token_transaction(self): 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, - 'tokenAddress': '0x' + '1' * 40 + 'tokenAddress': '0x' + '1234' * 10 }) self.assertEqual(response.status_code, 400) @@ -122,8 +125,11 @@ def test_ask_route_token_transaction(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) + transaction = Transaction.query.filter_by(recipient=ZERO_ADDRESS).first() self.assertEqual(response.status_code, 200) - assert response.get_json().get('transactionHash') == TOKEN_TRANSFER_TX_HASH + self.assertEqual(response.get_json().get('transactionHash'), + transaction.hash) + - transaction = Transaction.get_by_hash(TOKEN_TRANSFER_TX_HASH) - assert transaction.hash == TOKEN_TRANSFER_TX_HASH +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 index 4157927..6436013 100644 --- a/api/tests/test_api_claim_rate_limit.py +++ b/api/tests/test_api_claim_rate_limit.py @@ -1,11 +1,14 @@ +import unittest + from api.const import ZERO_ADDRESS from api.services.database import Transaction -from conftest import (RateLimitIPBaseTest, RateLimitIPorAddressBaseTest, - api_prefix) + +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) +from .temp_env_var import (CAPTCHA_TEST_RESPONSE_TOKEN, + DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) class TestAPIWithIPLimitStrategy(RateLimitIPBaseTest): @@ -70,6 +73,8 @@ def test_ask_route_limit_by_ip_or_address(self): 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS }) - - print("%s: %d" % (response.get_json(), response.status_code)) 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 index 397ef6d..608dccb 100644 --- a/api/tests/test_api_cli.py +++ b/api/tests/test_api_cli.py @@ -1,10 +1,13 @@ +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 .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) +from .temp_env_var import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) class TestAPICli(BaseTest): @@ -56,3 +59,7 @@ def test_ask_route_parameters(self): '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 e18ea96..e436ca5 100644 --- a/api/tests/test_database.py +++ b/api/tests/test_database.py @@ -1,11 +1,14 @@ -import pytest +import unittest + +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 sqlalchemy.exc import IntegrityError -from temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH + +from .conftest import BaseTest +from .temp_env_var import NATIVE_TOKEN_ADDRESS, NATIVE_TRANSFER_TX_HASH class TestDatabase(BaseTest): @@ -25,7 +28,7 @@ def test_access_keys(self): assert result[0].enabled is True # Duplicates for secret_access_key are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): access_key_id2, _ = generate_access_key() access_key = AccessKey() access_key.access_key_id = access_key_id2 @@ -47,7 +50,7 @@ def test_access_key_config(self): config.save() # Duplicates for (access_key_id, chain_id) are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): config = AccessKeyConfig() config.access_key_id = access_key.access_key_id config.chain_id = 10200 @@ -67,7 +70,7 @@ def test_transactions(self): transaction.save() # Duplicates for tx hash are not allowed - with pytest.raises(IntegrityError): + with self.assertRaises(IntegrityError): transaction = Transaction() transaction.hash = NATIVE_TRANSFER_TX_HASH transaction.recipient = ZERO_ADDRESS @@ -75,3 +78,7 @@ def test_transactions(self): transaction.token = token.address transaction.requester_ip = '192.168.0.1' transaction.save() + + +if __name__ == '__main__': + unittest.main() From d3af0df45e54b5bda1b89832f0c5d6ed8073e1a9 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 11:13:15 +0100 Subject: [PATCH 09/14] Fix test run in CI --- .github/workflows/publish-api.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-api.yaml b/.github/workflows/publish-api.yaml index 21ab932..6e788a5 100644 --- a/.github/workflows/publish-api.yaml +++ b/.github/workflows/publish-api.yaml @@ -40,7 +40,7 @@ jobs: - name: Run tests working-directory: ./api run: | - python3 -m unittest discover tests + python3 -m unittest discover build-and-push-image: runs-on: ubuntu-latest From fa0d43468f0ec457131c649183cd08099e1dcca0 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 11:19:58 +0100 Subject: [PATCH 10/14] CI: fix tests --- .github/workflows/publish-api.yaml | 2 +- api/api/services/__init__.py | 1 - api/api/settings.py | 2 -- api/tests/conftest.py | 2 +- api/tests/temp_env_var.py | 4 ++-- 5 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-api.yaml b/.github/workflows/publish-api.yaml index 6e788a5..84358f8 100644 --- a/.github/workflows/publish-api.yaml +++ b/.github/workflows/publish-api.yaml @@ -40,7 +40,7 @@ jobs: - name: Run tests working-directory: ./api run: | - python3 -m unittest discover + python3 -m unittest discover -p 'test_*.py' build-and-push-image: runs-on: ubuntu-latest diff --git a/api/api/services/__init__.py b/api/api/services/__init__.py index 0a951d4..ec2fec9 100644 --- a/api/api/services/__init__.py +++ b/api/api/services/__init__.py @@ -1,4 +1,3 @@ -from .captcha import captcha_verify from .database import DatabaseSingleton from .rate_limit import RateLimitStrategy, Strategy from .token import Token diff --git a/api/api/settings.py b/api/api/settings.py index 69468c2..e4bdd24 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -7,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/tests/conftest.py b/api/tests/conftest.py index 3622fd5..7cfac1b 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -29,7 +29,7 @@ def _mock(self, env_variables=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.captcha_verify', return_value=True), + mock.patch('api.services.validator.captcha_verify', return_value=True), mock.patch('api.api.print_info', return_value=None) ] if env_variables: diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index 4a69910..8d78740 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -33,8 +33,8 @@ 'FAUCET_CHAIN_ID': str(FAUCET_CHAIN_ID), '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', + 'FAUCET_DATABASE_URI': 'sqlite://', # run in-memory + # 'FAUCET_DATABASE_URI': 'sqlite:///test.db', 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY } From 6c461f4d7473d368bf9a780ca4d421bf790cf139 Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 11:38:54 +0100 Subject: [PATCH 11/14] Fix migrations, add naming convention to help sqlite DB handle ALTER commands --- api/api/services/database.py | 11 +++++- api/migrations/versions/4cacf36b2356_.py | 45 ++++++++++++++++++++++++ api/migrations/versions/b5d4ca37347c_.py | 43 ---------------------- 3 files changed, 55 insertions(+), 44 deletions(-) create mode 100644 api/migrations/versions/4cacf36b2356_.py delete mode 100644 api/migrations/versions/b5d4ca37347c_.py diff --git a/api/api/services/database.py b/api/api/services/database.py index a667776..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: 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/migrations/versions/b5d4ca37347c_.py b/api/migrations/versions/b5d4ca37347c_.py deleted file mode 100644 index ebaebf6..0000000 --- a/api/migrations/versions/b5d4ca37347c_.py +++ /dev/null @@ -1,43 +0,0 @@ -"""empty message - -Revision ID: b5d4ca37347c -Revises: 022497197c7a -Create Date: 2024-03-08 17:17:14.960915 - -""" -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = 'b5d4ca37347c' -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) as batch_op: - batch_op.create_unique_constraint(None, ['secret_access_key']) - - with op.batch_alter_table('access_keys_config', schema=None) as batch_op: - batch_op.create_unique_constraint(None, ['access_key_id', 'chain_id']) - - with op.batch_alter_table('transactions', schema=None) as batch_op: - batch_op.create_unique_constraint(None, ['hash']) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('transactions', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='unique') - - with op.batch_alter_table('access_keys_config', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='unique') - - with op.batch_alter_table('access_keys', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='unique') - - # ### end Alembic commands ### From dfc4422eebf6f991c08cae03d287410d7d38429c Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sat, 9 Mar 2024 13:06:34 +0100 Subject: [PATCH 12/14] Add sample cli request to scripts and steps to run server locally --- api/scripts/local_test_run.sh | 6 ++++++ api/scripts/sample_cli_request.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 api/scripts/local_test_run.sh create mode 100644 api/scripts/sample_cli_request.py 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()) From bd87fff9f8549a7b1b3b179b271a17320d23f596 Mon Sep 17 00:00:00 2001 From: "ilge.ustun" Date: Mon, 11 Mar 2024 12:49:57 +0100 Subject: [PATCH 13/14] pre-filled form --- app/src/components/FaucetForm/Faucet.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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[]) { From 54f51fad61d96ec5b47e6633fd696a868db602e5 Mon Sep 17 00:00:00 2001 From: "ilge.ustun" Date: Mon, 11 Mar 2024 13:07:20 +0100 Subject: [PATCH 14/14] css fix hover on button --- app/src/components/FaucetForm/Faucet.css | 1 + 1 file changed, 1 insertion(+) 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 {