Add auth-domain and browser-domain
There are two roles: airline and normal user
This commit is contained in:
parent
52ba579637
commit
2fcf3b0387
|
@ -1,3 +1,4 @@
|
||||||
.venv
|
.venv
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
node_modules
|
|
@ -0,0 +1,5 @@
|
||||||
|
# pull official base image
|
||||||
|
FROM postgres:13.3
|
||||||
|
|
||||||
|
# run create.sql on init
|
||||||
|
ADD create.sql /docker-entrypoint-initdb.d
|
|
@ -0,0 +1,3 @@
|
||||||
|
CREATE DATABASE api_prod;
|
||||||
|
CREATE DATABASE api_dev;
|
||||||
|
CREATE DATABASE api_test;
|
|
@ -0,0 +1,39 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
usermanager-api:
|
||||||
|
container_name: fids_usermanager_api
|
||||||
|
image: ${API_IMAGE}
|
||||||
|
ports:
|
||||||
|
- 5001:5000
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nc", "-vz", "-w1", "localhost", "5000"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 5
|
||||||
|
start_period: 2s
|
||||||
|
environment:
|
||||||
|
- TEST_TARGET=${TEST_TARGET}
|
||||||
|
- PORT=5000
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@usermanager-db/${POSTGRES_DB}
|
||||||
|
- APP_SETTINGS=${APP_SETTINGS}
|
||||||
|
depends_on:
|
||||||
|
usermanager-db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
usermanager-db:
|
||||||
|
container_name: fids_usermanager_db
|
||||||
|
build:
|
||||||
|
context: ./db
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
healthcheck:
|
||||||
|
test: psql postgres --command "select 1" -U ${POSTGRES_USER}
|
||||||
|
interval: 2s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 2s
|
||||||
|
expose:
|
||||||
|
- 5432
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASS}
|
|
@ -0,0 +1,5 @@
|
||||||
|
|
||||||
|
exclude_dirs:
|
||||||
|
- src/tests
|
||||||
|
#tests: ['B201', 'B301']
|
||||||
|
#skips: ['B101', 'B601']
|
|
@ -0,0 +1,3 @@
|
||||||
|
[run]
|
||||||
|
omit = src/tests/*
|
||||||
|
branch = True
|
|
@ -0,0 +1,7 @@
|
||||||
|
**/__pycache__
|
||||||
|
**/Pipfile.lock
|
||||||
|
.coverage
|
||||||
|
.pytest_cache
|
||||||
|
htmlcov
|
||||||
|
pact-nginx-ssl/nginx-selfsigned.*
|
||||||
|
src/tests/pacts
|
|
@ -0,0 +1,37 @@
|
||||||
|
# pull official base image
|
||||||
|
FROM python:3.11.2-slim-buster AS prod
|
||||||
|
|
||||||
|
# set working directory
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
ENV FLASK_DEBUG 0
|
||||||
|
ENV FLASK_ENV production
|
||||||
|
ARG SECRET_KEY
|
||||||
|
ENV SECRET_KEY $SECRET_KEY
|
||||||
|
ARG PORT
|
||||||
|
ENV PORT $PORT
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get -y install netcat gcc postgresql \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& groupadd -g 999 python \
|
||||||
|
&& useradd -r -u 999 -g python python \
|
||||||
|
&& python -m venv /usr/src/app/.venv \
|
||||||
|
&& chown -R python:python /usr/src/app
|
||||||
|
|
||||||
|
ENV PATH="/usr/src/app/.venv/bin:$PATH"
|
||||||
|
ENV PIP_NO_CACHE_DIR=off
|
||||||
|
USER 999
|
||||||
|
|
||||||
|
COPY --chown=python:python requirements.txt requirements.txt
|
||||||
|
RUN python -m pip install --upgrade pip && \
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY --chown=python:python . .
|
||||||
|
|
||||||
|
# run gunicorn
|
||||||
|
CMD ["/usr/src/app/.venv/bin/gunicorn", "manage:app"]
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
env
|
||||||
|
.venv
|
||||||
|
Dockerfile.test
|
||||||
|
Dockerfile.prod
|
||||||
|
.coverage
|
||||||
|
.pytest_cache
|
||||||
|
htmlcov
|
||||||
|
src/tests
|
||||||
|
src/.cicd
|
|
@ -0,0 +1,21 @@
|
||||||
|
# pull official base image
|
||||||
|
ARG BASE_IMAGE
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
|
ENV FLASK_DEBUG=1
|
||||||
|
ENV FLASK_ENV=development
|
||||||
|
ENV DATABASE_TEST_URL=postgresql://postgres:postgres@api-db:5432/api_test
|
||||||
|
|
||||||
|
# add and install requirements
|
||||||
|
COPY --chown=python:python ./requirements.test.txt .
|
||||||
|
RUN python -m pip install -r requirements.test.txt
|
||||||
|
|
||||||
|
# add app
|
||||||
|
COPY --chown=python:python src/tests src/tests
|
||||||
|
|
||||||
|
# new
|
||||||
|
# add entrypoint.sh
|
||||||
|
COPY --chown=python:python src/.cicd/test.sh .
|
||||||
|
RUN chmod +x /usr/src/app/test.sh
|
||||||
|
|
||||||
|
CMD ["/usr/src/app/test.sh"]
|
|
@ -0,0 +1,16 @@
|
||||||
|
[[source]]
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
flask = "==2.2.3"
|
||||||
|
flask-restx = "==1.0.6"
|
||||||
|
flask-sqlalchemy = "==3.0.3"
|
||||||
|
psycopg2-binary = "==2.9.5"
|
||||||
|
Werkzeug = "==2.3.7"
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.11"
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Waiting for postgres..."
|
||||||
|
|
||||||
|
while ! nc -z usermanager-db 5432; do
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
|
python manage.py run -h 0.0.0.0
|
|
@ -0,0 +1,26 @@
|
||||||
|
from flask.cli import FlaskGroup
|
||||||
|
|
||||||
|
from src import create_app, db
|
||||||
|
from src.api.models.users import User
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
cli = FlaskGroup(create_app=create_app)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("recreate_db")
|
||||||
|
def recreate_db():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("seed_db")
|
||||||
|
def seed_db():
|
||||||
|
db.session.add(User(username="lufthansa", email="info@lufthansa.com", password="password1234", airline=True))
|
||||||
|
db.session.add(User(username="messi", email="messi@gmail.com", password="password1234"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
|
@ -0,0 +1,10 @@
|
||||||
|
## Testing
|
||||||
|
pytest==7.2.2
|
||||||
|
pytest-cov==4.0.0
|
||||||
|
flake8==6.0.0
|
||||||
|
black==23.1.0
|
||||||
|
isort==5.12.0
|
||||||
|
bandit==1.7.5
|
||||||
|
pactman==2.3.0
|
||||||
|
pytest-xdist==3.2.0
|
||||||
|
pytest-watch==4.2.0
|
|
@ -0,0 +1,10 @@
|
||||||
|
## Prod
|
||||||
|
flask==2.2.3
|
||||||
|
flask-restx==1.0.6
|
||||||
|
Flask-SQLAlchemy==3.0.3
|
||||||
|
psycopg2-binary==2.9.5
|
||||||
|
flask-cors==3.0.10
|
||||||
|
flask-bcrypt==1.0.1
|
||||||
|
pyjwt==2.6.0
|
||||||
|
gunicorn==20.1.0
|
||||||
|
Werkzeug==2.3.7
|
|
@ -0,0 +1,2 @@
|
||||||
|
[flake8]
|
||||||
|
max-line-length = 119
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/bash -e
|
||||||
|
|
||||||
|
|
||||||
|
if [ "${TEST_TARGET:-}" = "INTEGRATION" ]; then
|
||||||
|
# Execute your command here
|
||||||
|
/usr/src/app/.venv/bin/gunicorn manage:app
|
||||||
|
else
|
||||||
|
## pytest
|
||||||
|
python -m pytest "src/tests" --junitxml=report.xml
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
python -m pytest "src/tests" -p no:warnings --cov="src" --cov-report xml
|
||||||
|
|
||||||
|
|
||||||
|
## Linting
|
||||||
|
flake8 src --extend-ignore E221
|
||||||
|
# black src --check
|
||||||
|
# isort src --check
|
||||||
|
|
||||||
|
## Security
|
||||||
|
# bandit -c .bandit.yml -r .
|
||||||
|
fi
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
# instantiate the db
|
||||||
|
db = SQLAlchemy()
|
||||||
|
cors = CORS()
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(script_info=None):
|
||||||
|
# instantiate the app
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# set config
|
||||||
|
app_settings = os.getenv("APP_SETTINGS")
|
||||||
|
app.config.from_object(app_settings)
|
||||||
|
|
||||||
|
# set up extensions
|
||||||
|
db.init_app(app)
|
||||||
|
cors.init_app(app, resources={r"*": {"origins": "*"}})
|
||||||
|
|
||||||
|
# register api
|
||||||
|
from src.api import api
|
||||||
|
|
||||||
|
api.init_app(app)
|
||||||
|
|
||||||
|
# shell context for flask cli
|
||||||
|
@app.shell_context_processor
|
||||||
|
def ctx():
|
||||||
|
return {"app": app, "db": db}
|
||||||
|
|
||||||
|
return app
|
|
@ -0,0 +1,10 @@
|
||||||
|
from flask_restx import Api
|
||||||
|
from src.api.auth import auth_namespace
|
||||||
|
from src.api.users import NAMESPACE as NAMESPACE_USERS
|
||||||
|
from src.api.users import users_namespace
|
||||||
|
|
||||||
|
api = Api(version="1.0", title="Users API", doc="/doc")
|
||||||
|
|
||||||
|
|
||||||
|
api.add_namespace(users_namespace, path=f"/{NAMESPACE_USERS}")
|
||||||
|
api.add_namespace(auth_namespace, path="/auth")
|
|
@ -0,0 +1,125 @@
|
||||||
|
import jwt
|
||||||
|
from flask import request
|
||||||
|
from flask_restx import Namespace, Resource
|
||||||
|
from src import bcrypt
|
||||||
|
from src.api.cruds.users import add_user, get_user_by_email, get_user_by_id
|
||||||
|
from src.api.models.users import User
|
||||||
|
|
||||||
|
auth_namespace = Namespace("auth")
|
||||||
|
|
||||||
|
auth_user_model = User.get_api_auth_user_model(auth_namespace)
|
||||||
|
auth_full_user_model = User.get_api_auth_full_user_model(auth_namespace)
|
||||||
|
auth_login_model = User.get_api_auth_login_model(auth_namespace)
|
||||||
|
auth_refresh_model = User.get_api_auth_refresh_model(auth_namespace)
|
||||||
|
auth_tokens_model = User.get_api_auth_tokens_model(auth_namespace)
|
||||||
|
|
||||||
|
parser = auth_namespace.parser()
|
||||||
|
parser.add_argument("Authorization", location="headers")
|
||||||
|
|
||||||
|
|
||||||
|
class Register(Resource):
|
||||||
|
@auth_namespace.marshal_with(auth_user_model)
|
||||||
|
@auth_namespace.expect(auth_full_user_model, validate=True)
|
||||||
|
@auth_namespace.response(201, "Success")
|
||||||
|
@auth_namespace.response(400, "Sorry. That email already exists.")
|
||||||
|
def post(self):
|
||||||
|
post_data = request.get_json()
|
||||||
|
username = post_data.get("username")
|
||||||
|
email = post_data.get("email")
|
||||||
|
password = post_data.get("password")
|
||||||
|
|
||||||
|
user = get_user_by_email(email)
|
||||||
|
if user:
|
||||||
|
auth_namespace.abort(400, "Sorry. That email already exists.")
|
||||||
|
user = add_user(username, email, password)
|
||||||
|
|
||||||
|
return user, 201
|
||||||
|
|
||||||
|
|
||||||
|
class Login(Resource):
|
||||||
|
@auth_namespace.marshal_with(auth_tokens_model)
|
||||||
|
@auth_namespace.expect(auth_login_model, validate=True)
|
||||||
|
@auth_namespace.response(201, "Success")
|
||||||
|
@auth_namespace.response(404, "User does not exist")
|
||||||
|
def post(self):
|
||||||
|
post_data = request.get_json()
|
||||||
|
email = post_data.get("email")
|
||||||
|
password = post_data.get("password")
|
||||||
|
response_object = {}
|
||||||
|
|
||||||
|
user = get_user_by_email(email)
|
||||||
|
if not user or not bcrypt.check_password_hash(user.password, password):
|
||||||
|
auth_namespace.abort(404, "User does not exist")
|
||||||
|
|
||||||
|
access_token = user.encode_token(user.id, "access", user.airline)
|
||||||
|
refresh_token = user.encode_token(user.id, "refresh")
|
||||||
|
|
||||||
|
response_object = {"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"user_id": user.id}
|
||||||
|
return response_object, 200
|
||||||
|
|
||||||
|
|
||||||
|
class Refresh(Resource):
|
||||||
|
@auth_namespace.marshal_with(auth_tokens_model)
|
||||||
|
@auth_namespace.expect(auth_refresh_model, validate=True)
|
||||||
|
@auth_namespace.response(200, "Success")
|
||||||
|
@auth_namespace.response(401, "Invalid token")
|
||||||
|
def post(self):
|
||||||
|
post_data = request.get_json()
|
||||||
|
refresh_token = post_data.get("refresh_token")
|
||||||
|
response_object = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = User.decode_token(refresh_token)
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
auth_namespace.abort(401, "Invalid token")
|
||||||
|
|
||||||
|
access_token = user.encode_token(user.id, "access", user.airline)
|
||||||
|
refresh_token = user.encode_token(user.id, "refresh")
|
||||||
|
|
||||||
|
response_object = {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
}
|
||||||
|
return response_object, 200
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
auth_namespace.abort(401, "Signature expired. Please log in again.")
|
||||||
|
return "Signature expired. Please log in again."
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
auth_namespace.abort(401, "Invalid token. Please log in again.")
|
||||||
|
|
||||||
|
|
||||||
|
class Status(Resource):
|
||||||
|
@auth_namespace.marshal_with(auth_user_model)
|
||||||
|
@auth_namespace.response(200, "Success")
|
||||||
|
@auth_namespace.response(401, "Invalid token")
|
||||||
|
@auth_namespace.expect(parser)
|
||||||
|
def get(self):
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header:
|
||||||
|
try:
|
||||||
|
auth_header_list = auth_header.split(" ")
|
||||||
|
if len(auth_header_list) != 2:
|
||||||
|
raise jwt.InvalidTokenError(f"Invalid header: {auth_header}")
|
||||||
|
access_token = auth_header_list[1]
|
||||||
|
resp = User.decode_token(access_token)
|
||||||
|
user = get_user_by_id(resp)
|
||||||
|
if not user:
|
||||||
|
auth_namespace.abort(401, "Invalid token")
|
||||||
|
return user, 200
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
auth_namespace.abort(401, "Signature expired. Please log in again.")
|
||||||
|
return "Signature expired. Please log in again."
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
auth_namespace.abort(401, "Invalid token. Please log in again.")
|
||||||
|
else:
|
||||||
|
auth_namespace.abort(403, "Token required")
|
||||||
|
|
||||||
|
|
||||||
|
auth_namespace.add_resource(Register, "/register")
|
||||||
|
auth_namespace.add_resource(Login, "/login")
|
||||||
|
auth_namespace.add_resource(Refresh, "/refresh")
|
||||||
|
auth_namespace.add_resource(Status, "/status")
|
|
@ -0,0 +1,34 @@
|
||||||
|
from src import db
|
||||||
|
from src.api.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users():
|
||||||
|
return User.query.all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
return User.query.filter_by(id=user_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email):
|
||||||
|
return User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
|
|
||||||
|
def add_user(username, email, password):
|
||||||
|
user = User(username=username, email=email, password=password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def update_user(user, username, email):
|
||||||
|
user.username = username
|
||||||
|
user.email = email
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(user):
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
|
@ -0,0 +1,34 @@
|
||||||
|
from src import db
|
||||||
|
from src.api.models.users import User
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users():
|
||||||
|
return User.query.all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
return User.query.filter_by(id=user_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email):
|
||||||
|
return User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
|
|
||||||
|
def add_user(username, email, password):
|
||||||
|
user = User(username=username, email=email, password=password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def update_user(user, username, email):
|
||||||
|
user.username = username
|
||||||
|
user.email = email
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(user):
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
|
@ -0,0 +1,115 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import current_app
|
||||||
|
from flask_restx import fields
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from src import bcrypt, db
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
username = db.Column(db.String(128), nullable=False)
|
||||||
|
email = db.Column(db.String(128), nullable=False)
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
active = db.Column(db.Boolean(), default=True, nullable=False)
|
||||||
|
created_date = db.Column(db.DateTime, default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
def __init__(self, username, email, password):
|
||||||
|
self.username = username
|
||||||
|
self.email = email
|
||||||
|
self.password = bcrypt.generate_password_hash(
|
||||||
|
password, current_app.config.get("BCRYPT_LOG_ROUNDS")
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_token(user_id, token_type):
|
||||||
|
print(f"encode_token(user_id={user_id}, token_type={token_type}):")
|
||||||
|
if token_type == "access":
|
||||||
|
seconds = current_app.config.get("ACCESS_TOKEN_EXPIRATION")
|
||||||
|
else:
|
||||||
|
seconds = current_app.config.get("REFRESH_TOKEN_EXPIRATION")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds),
|
||||||
|
"iat": datetime.datetime.utcnow(),
|
||||||
|
"sub": user_id,
|
||||||
|
}
|
||||||
|
return jwt.encode(
|
||||||
|
payload, current_app.config.get("SECRET_KEY"), algorithm="HS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_token(token):
|
||||||
|
decoded = jwt.decode(
|
||||||
|
token, current_app.config.get("SECRET_KEY"), algorithms=["HS256"]
|
||||||
|
)
|
||||||
|
return decoded["sub"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_user_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"User",
|
||||||
|
{
|
||||||
|
"id": fields.Integer(readOnly=True),
|
||||||
|
"username": fields.String(required=True),
|
||||||
|
"email": fields.String(required=True),
|
||||||
|
"created_date": fields.DateTime,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_user_post_model(cls, namespace):
|
||||||
|
return namespace.inherit(
|
||||||
|
"User Post",
|
||||||
|
cls.get_api_user_model(namespace),
|
||||||
|
{
|
||||||
|
"password": fields.String(required=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_user_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"User",
|
||||||
|
{
|
||||||
|
"username": fields.String(required=True),
|
||||||
|
"email": fields.String(required=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_full_user_model(cls, namespace):
|
||||||
|
return namespace.clone(
|
||||||
|
"User Full",
|
||||||
|
cls.get_api_auth_user_model(namespace),
|
||||||
|
{
|
||||||
|
"password": fields.String(required=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_login_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"User",
|
||||||
|
{
|
||||||
|
"password": fields.String(required=True),
|
||||||
|
"email": fields.String(required=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_refresh_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"Refresh", {"refresh_token": fields.String(required=True)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_tokens_model(cls, namespace):
|
||||||
|
return namespace.clone(
|
||||||
|
"Access and Refresh Token",
|
||||||
|
cls.get_api_auth_refresh_model(namespace),
|
||||||
|
{"access_token": fields.String(required=True)},
|
||||||
|
)
|
|
@ -0,0 +1,20 @@
|
||||||
|
from flask_restx import fields
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_create_response(namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"GenericCreateResponse",
|
||||||
|
{
|
||||||
|
"id": fields.Integer(readOnly=True),
|
||||||
|
"message": fields.String(required=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_error_response(namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"GenericCreateResponse",
|
||||||
|
{
|
||||||
|
"message": fields.String(required=True),
|
||||||
|
},
|
||||||
|
)
|
|
@ -0,0 +1,121 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import current_app
|
||||||
|
from flask_restx import fields
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from src import bcrypt, db
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
username = db.Column(db.String(128), nullable=False)
|
||||||
|
email = db.Column(db.String(128), nullable=False)
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
active = db.Column(db.Boolean(), default=True, nullable=False)
|
||||||
|
created_date = db.Column(db.DateTime, default=func.now(), nullable=False)
|
||||||
|
airline = db.Column(db.Boolean(), default=False, nullable=False)
|
||||||
|
|
||||||
|
def __init__(self, username, email, password, airline=False):
|
||||||
|
self.username = username
|
||||||
|
self.email = email
|
||||||
|
self.password = bcrypt.generate_password_hash(
|
||||||
|
password, current_app.config.get("BCRYPT_LOG_ROUNDS")
|
||||||
|
).decode()
|
||||||
|
self.airline = airline
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_token(user_id, token_type, airline=False):
|
||||||
|
print(f"encode_token(user_id={user_id}, token_type={token_type}):")
|
||||||
|
if token_type == "access":
|
||||||
|
seconds = current_app.config.get("ACCESS_TOKEN_EXPIRATION")
|
||||||
|
else:
|
||||||
|
seconds = current_app.config.get("REFRESH_TOKEN_EXPIRATION")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds),
|
||||||
|
"iat": datetime.datetime.utcnow(),
|
||||||
|
"sub": user_id,
|
||||||
|
"airline": airline
|
||||||
|
}
|
||||||
|
return jwt.encode(
|
||||||
|
payload, current_app.config.get("SECRET_KEY"), algorithm="HS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_token(token):
|
||||||
|
decoded = jwt.decode(
|
||||||
|
token, current_app.config.get("SECRET_KEY"), algorithms=["HS256"]
|
||||||
|
)
|
||||||
|
return decoded["sub"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_user_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"User",
|
||||||
|
{
|
||||||
|
"id": fields.Integer(readOnly=True),
|
||||||
|
"username": fields.String(required=True),
|
||||||
|
"email": fields.String(required=True),
|
||||||
|
"created_date": fields.DateTime,
|
||||||
|
"airline": fields.Boolean(readOnly=True)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_user_post_model(cls, namespace):
|
||||||
|
return namespace.inherit(
|
||||||
|
"User Post",
|
||||||
|
cls.get_api_user_model(namespace),
|
||||||
|
{
|
||||||
|
"password": fields.String(required=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_user_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"User",
|
||||||
|
{
|
||||||
|
"username": fields.String(required=True),
|
||||||
|
"email": fields.String(required=True),
|
||||||
|
"id": fields.Integer(required=True)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_full_user_model(cls, namespace):
|
||||||
|
return namespace.clone(
|
||||||
|
"User Full",
|
||||||
|
cls.get_api_auth_user_model(namespace),
|
||||||
|
{
|
||||||
|
"password": fields.String(required=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_login_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"User",
|
||||||
|
{
|
||||||
|
"password": fields.String(required=True),
|
||||||
|
"email": fields.String(required=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_refresh_model(cls, namespace):
|
||||||
|
return namespace.model(
|
||||||
|
"Refresh", {"refresh_token": fields.String(required=True)}
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_auth_tokens_model(cls, namespace):
|
||||||
|
return namespace.clone(
|
||||||
|
"Access and Refresh Token",
|
||||||
|
cls.get_api_auth_refresh_model(namespace),
|
||||||
|
{"access_token": fields.String(required=True)},
|
||||||
|
{"user_id": fields.Integer(required=True)}
|
||||||
|
)
|
|
@ -0,0 +1,101 @@
|
||||||
|
from flask import request
|
||||||
|
from flask_restx import Namespace, Resource
|
||||||
|
from src.api.models.users import User
|
||||||
|
|
||||||
|
from src.api.cruds.users import ( # isort:skip
|
||||||
|
get_all_users,
|
||||||
|
get_user_by_email,
|
||||||
|
add_user,
|
||||||
|
get_user_by_id,
|
||||||
|
update_user,
|
||||||
|
delete_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
NAMESPACE = "users"
|
||||||
|
|
||||||
|
users_namespace = Namespace(NAMESPACE)
|
||||||
|
|
||||||
|
user_api_model = User.get_api_user_model(users_namespace)
|
||||||
|
user_post_api_model = User.get_api_user_post_model(users_namespace)
|
||||||
|
|
||||||
|
|
||||||
|
class UsersList(Resource):
|
||||||
|
@users_namespace.response(200, "Success")
|
||||||
|
@users_namespace.marshal_with(user_api_model, as_list=True)
|
||||||
|
def get(self):
|
||||||
|
return get_all_users(), 200
|
||||||
|
|
||||||
|
@users_namespace.response(201, "<user_email> was added!")
|
||||||
|
@users_namespace.response(400, "Sorry. That email already exists.")
|
||||||
|
@users_namespace.expect(user_post_api_model, validate=True)
|
||||||
|
def post(self):
|
||||||
|
post_data = request.get_json()
|
||||||
|
username = post_data.get("username")
|
||||||
|
email = post_data.get("email")
|
||||||
|
password = post_data.get("password")
|
||||||
|
response_object = {}
|
||||||
|
|
||||||
|
user = get_user_by_email(email)
|
||||||
|
if user:
|
||||||
|
response_object["message"] = "Sorry. That email already exists."
|
||||||
|
return response_object, 400
|
||||||
|
|
||||||
|
user = add_user(username, email, password)
|
||||||
|
|
||||||
|
response_object["message"] = f"{email} was added!"
|
||||||
|
response_object["id"] = user.id
|
||||||
|
return response_object, 201
|
||||||
|
|
||||||
|
|
||||||
|
class Users(Resource):
|
||||||
|
@users_namespace.response(200, "Success")
|
||||||
|
@users_namespace.response(404, "User <user_id> does not exist")
|
||||||
|
@users_namespace.marshal_with(user_api_model)
|
||||||
|
def get(self, user_id):
|
||||||
|
user = get_user_by_id(user_id) # updated
|
||||||
|
if not user:
|
||||||
|
users_namespace.abort(404, f"User {user_id} does not exist")
|
||||||
|
return user, 200
|
||||||
|
|
||||||
|
@users_namespace.response(200, "<user_id> was updated!")
|
||||||
|
@users_namespace.response(404, "User <user_id> does not exist")
|
||||||
|
@users_namespace.response(400, "Sorry. That email already exists.")
|
||||||
|
@users_namespace.expect(user_api_model, validate=True)
|
||||||
|
def put(self, user_id):
|
||||||
|
post_data = request.get_json()
|
||||||
|
username = post_data.get("username")
|
||||||
|
email = post_data.get("email")
|
||||||
|
response_object = {}
|
||||||
|
|
||||||
|
user = get_user_by_id(user_id) # updated
|
||||||
|
if not user:
|
||||||
|
users_namespace.abort(404, f"User {user_id} does not exist")
|
||||||
|
|
||||||
|
if get_user_by_email(email): # updated
|
||||||
|
response_object["message"] = "Sorry. That email already exists."
|
||||||
|
return response_object, 400
|
||||||
|
|
||||||
|
user = update_user(user, username, email) # new
|
||||||
|
|
||||||
|
response_object["message"] = f"{user.id} was updated!"
|
||||||
|
response_object["id"] = user.id
|
||||||
|
return response_object, 200
|
||||||
|
|
||||||
|
@users_namespace.response(200, "<user_id> was removed!")
|
||||||
|
@users_namespace.response(404, "User <user_id> does not exist")
|
||||||
|
def delete(self, user_id):
|
||||||
|
response_object = {}
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
users_namespace.abort(404, f"User {user_id} does not exist")
|
||||||
|
|
||||||
|
delete_user(user)
|
||||||
|
|
||||||
|
response_object["message"] = f"{user.email} was removed!"
|
||||||
|
response_object["id"] = user.id
|
||||||
|
return response_object, 200
|
||||||
|
|
||||||
|
|
||||||
|
users_namespace.add_resource(UsersList, "")
|
||||||
|
users_namespace.add_resource(Users, "/<int:user_id>")
|
|
@ -0,0 +1,35 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class BaseConfig:
|
||||||
|
TESTING = False
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SECRET_KEY = "my_precious"
|
||||||
|
ACCESS_TOKEN_EXPIRATION = 900 # 15 minutes
|
||||||
|
REFRESH_TOKEN_EXPIRATION = 2592000 # 30 days
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(BaseConfig):
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
|
||||||
|
BCRYPT_LOG_ROUNDS = 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestingConfig(BaseConfig):
|
||||||
|
TESTING = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_TEST_URL")
|
||||||
|
BCRYPT_LOG_ROUNDS = 4
|
||||||
|
ACCESS_TOKEN_EXPIRATION = 5
|
||||||
|
REFRESH_TOKEN_EXPIRATION = 5
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(BaseConfig):
|
||||||
|
BCRYPT_LOG_ROUNDS = 13
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "my_precious")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.SECRET_KEY = os.getenv("SECRET_KEY", "my_precious")
|
||||||
|
url = os.environ.get("DATABASE_URL")
|
||||||
|
if url is not None and url.startswith("postgres://"):
|
||||||
|
url = url.replace("postgres://", "postgresql://", 1)
|
||||||
|
self.SQLALCHEMY_DATABASE_URI = url
|
|
@ -0,0 +1,67 @@
|
||||||
|
import pytest
|
||||||
|
from flask_restx import Namespace
|
||||||
|
from src import create_app, db
|
||||||
|
from src.api.models.users import User
|
||||||
|
from src.config import ProductionConfig
|
||||||
|
|
||||||
|
# from pactman import Consumer, Provider
|
||||||
|
|
||||||
|
|
||||||
|
# from src.tests.client.client import UsersClient
|
||||||
|
|
||||||
|
PACT_DIR = "src/tests/pacts"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def test_app():
|
||||||
|
app = create_app()
|
||||||
|
app.config.from_object("src.config.TestingConfig")
|
||||||
|
with app.app_context():
|
||||||
|
yield app # testing happens here
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def test_namespace():
|
||||||
|
ns = Namespace("testing")
|
||||||
|
yield ns
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def test_database():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
yield db # testing happens here
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.fixture(scope="function")
|
||||||
|
# def pact():
|
||||||
|
# pact = Consumer("UsersConsumer").has_pact_with(
|
||||||
|
# Provider("UsersProvider"), pact_dir=PACT_DIR
|
||||||
|
# )
|
||||||
|
# pact.start_service()
|
||||||
|
# yield pact
|
||||||
|
# pact.stop_service()
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.fixture(scope="function")
|
||||||
|
# def user_client(pact):
|
||||||
|
# cli = UsersClient(uri=pact.uri)
|
||||||
|
# yield cli
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def prod_config():
|
||||||
|
yield ProductionConfig()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def add_user():
|
||||||
|
def _add_user(username, email, password):
|
||||||
|
user = User(username=username, email=email, password=password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _add_user
|
|
@ -0,0 +1,192 @@
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
TEST_USERNAME = "fede_auth"
|
||||||
|
TEST_EMAIL = "fede_auth@gmail.com"
|
||||||
|
TEST_PASSWD = "password1234"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_registration(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"username": TEST_USERNAME,
|
||||||
|
"email": TEST_EMAIL,
|
||||||
|
"password": TEST_PASSWD,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert TEST_USERNAME in data["username"]
|
||||||
|
assert TEST_EMAIL in data["email"]
|
||||||
|
assert "password" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_registration_duplicate_email(test_app, test_database, add_user):
|
||||||
|
add_user(TEST_USERNAME, TEST_EMAIL, TEST_PASSWD)
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data=json.dumps(
|
||||||
|
{"username": "martin", "email": TEST_EMAIL, "password": "test"}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert "Sorry. That email already exists." == data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"email": TEST_EMAIL, "password": TEST_PASSWD},
|
||||||
|
{"username": TEST_USERNAME, "password": TEST_PASSWD},
|
||||||
|
{"email": TEST_EMAIL, "username": TEST_USERNAME},
|
||||||
|
{"mail": TEST_EMAIL, "username": TEST_USERNAME, "password": TEST_PASSWD},
|
||||||
|
{"email": TEST_EMAIL, "user": TEST_USERNAME, "password": TEST_PASSWD},
|
||||||
|
{"email": TEST_EMAIL, "username": TEST_USERNAME, "passwd": TEST_PASSWD},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_registration_invalid_json(test_app, test_database, payload):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert "Input payload validation failed" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_user_login(test_app, test_database, add_user):
|
||||||
|
add_user(TEST_USERNAME, TEST_EMAIL, TEST_PASSWD)
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data=json.dumps({"email": TEST_EMAIL, "password": TEST_PASSWD}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert data["access_token"]
|
||||||
|
assert data["refresh_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_registered_user_login(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data=json.dumps({"email": "invalid@gmail.com", "password": TEST_PASSWD}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert "User does not exist." in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_refresh(test_app, test_database, add_user):
|
||||||
|
add_user(TEST_USERNAME, TEST_EMAIL, TEST_PASSWD)
|
||||||
|
client = test_app.test_client()
|
||||||
|
# user login
|
||||||
|
resp_login = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data=json.dumps({"email": TEST_EMAIL, "password": TEST_PASSWD}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
# valid refresh
|
||||||
|
refresh_token = json.loads(resp_login.data.decode())["refresh_token"]
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/refresh",
|
||||||
|
data=json.dumps({"refresh_token": refresh_token}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert data["access_token"]
|
||||||
|
assert data["refresh_token"]
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_refresh_expired_token(test_app, test_database, add_user):
|
||||||
|
add_user("test5", "test5@test.com", "test")
|
||||||
|
client = test_app.test_client()
|
||||||
|
# user login
|
||||||
|
resp_login = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data=json.dumps({"email": "test5@test.com", "password": "test"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
# invalid token refresh
|
||||||
|
time.sleep(10)
|
||||||
|
refresh_token = json.loads(resp_login.data.decode())["refresh_token"]
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/refresh",
|
||||||
|
data=json.dumps({"refresh_token": refresh_token}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert "Signature expired. Please log in again." in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_refresh(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/auth/refresh",
|
||||||
|
data=json.dumps({"refresh_token": "Invalid"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert "Invalid token. Please log in again." in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_status(test_app, test_database, add_user):
|
||||||
|
add_user("test6", "test6@test.com", "test")
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp_login = client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data=json.dumps({"email": "test6@test.com", "password": "test"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
token = json.loads(resp_login.data.decode())["access_token"]
|
||||||
|
resp = client.get(
|
||||||
|
"/auth/status",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert "test6" in data["username"]
|
||||||
|
assert "test6@test.com" in data["email"]
|
||||||
|
assert "password" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_status(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.get(
|
||||||
|
"/auth/status",
|
||||||
|
headers={"Authorization": "Bearer invalid"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
assert "Invalid token. Please log in again." in data["message"]
|
|
@ -0,0 +1,26 @@
|
||||||
|
import pytest
|
||||||
|
from src.api.models.users import User
|
||||||
|
|
||||||
|
TOKEN_TYPES = ["access", "refresh"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_passwords_are_random(test_app, test_database, add_user):
|
||||||
|
user_one = add_user("fede", "fede@gmail.com", "test")
|
||||||
|
user_two = add_user("fedec", "fedec@gmail.com", "test")
|
||||||
|
assert user_one.password != user_two.password
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("token_type", TOKEN_TYPES)
|
||||||
|
def test_encode_token(test_app, test_database, add_user, token_type):
|
||||||
|
user = add_user("fede", "fede@gmail.com", "test")
|
||||||
|
token = User.encode_token(user.id, token_type)
|
||||||
|
assert isinstance(token, str)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("token_type", TOKEN_TYPES)
|
||||||
|
def test_decode_token(test_app, test_database, add_user, token_type):
|
||||||
|
user = add_user("fede", "fede@gmail.com", "test")
|
||||||
|
token = User.encode_token(user.id, token_type)
|
||||||
|
token_user_id = User.decode_token(token)
|
||||||
|
assert isinstance(token_user_id, int)
|
||||||
|
assert user.id == token_user_id
|
|
@ -0,0 +1,219 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src import bcrypt
|
||||||
|
from src.api.cruds.users import get_user_by_id
|
||||||
|
from src.api.models.users import User
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_user(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"username": "fede",
|
||||||
|
"email": "fcastaneda@itba.edu.ar",
|
||||||
|
"password": "1234566789",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert "fcastaneda@itba.edu.ar was added!" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_user_invalid_json(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Input payload validation failed" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_user_invalid_json_keys(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
data=json.dumps({"email": "john@begood.io"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Input payload validation failed" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_user_duplicate_email(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
client.post(
|
||||||
|
"/users",
|
||||||
|
data=json.dumps({"username": "fede", "email": "fcastaneda@itba.edu.ar"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"username": "federico",
|
||||||
|
"email": "fcastaneda@itba.edu.ar",
|
||||||
|
"password": "passwd1234",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Sorry. That email already exists." in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_user(test_app, test_database, add_user):
|
||||||
|
user = add_user("fede", "fede@itba.edu", "passwd1234")
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.get(f"/users/{user.id}")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "fede" in data["username"]
|
||||||
|
assert "fede@itba.edu" in data["email"]
|
||||||
|
assert "password" not in data # Password must not be returned
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_user_incorrect_id(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.get("/users/999")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "User 999 does not exist" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_users(test_app, test_database, add_user):
|
||||||
|
test_database.session.query(User).delete()
|
||||||
|
add_user("martin", "martin@itba.edu", "passwd1234")
|
||||||
|
add_user("nacho", "nacho@itba.edu", "passwd1234")
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.get("/users")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(data) == 2
|
||||||
|
assert "martin" in data[0]["username"]
|
||||||
|
assert "martin@itba.edu" in data[0]["email"]
|
||||||
|
assert "nacho" in data[1]["username"]
|
||||||
|
assert "nacho@itba.edu" in data[1]["email"]
|
||||||
|
assert "password" not in data[0]
|
||||||
|
assert "password" not in data[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_user(test_app, test_database, add_user):
|
||||||
|
USER_EMAIL = "remove-me@itba.edu"
|
||||||
|
test_database.session.query(User).delete()
|
||||||
|
user = add_user("user-to-be-removed", USER_EMAIL, "passwd1234")
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp_one = client.get("/users")
|
||||||
|
data = json.loads(resp_one.data.decode())
|
||||||
|
assert resp_one.status_code == 200
|
||||||
|
assert len(data) == 1
|
||||||
|
|
||||||
|
resp_two = client.delete(f"/users/{user.id}")
|
||||||
|
data = json.loads(resp_two.data.decode())
|
||||||
|
assert resp_two.status_code == 200
|
||||||
|
assert f"{USER_EMAIL} was removed!" in data["message"]
|
||||||
|
|
||||||
|
resp_three = client.get("/users")
|
||||||
|
data = json.loads(resp_three.data.decode())
|
||||||
|
assert resp_three.status_code == 200
|
||||||
|
assert len(data) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_user_incorrect_id(test_app, test_database):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.delete("/users/999")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "User 999 does not exist" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user(test_app, test_database, add_user):
|
||||||
|
user = add_user("user-to-be-updated", "update-me@itba.edu", "passwd1234")
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp_one = client.put(
|
||||||
|
f"/users/{user.id}",
|
||||||
|
data=json.dumps({"username": "me", "email": "me@itba.edu"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp_one.data.decode())
|
||||||
|
assert resp_one.status_code == 200
|
||||||
|
assert f"{user.id} was updated!" in data["message"]
|
||||||
|
|
||||||
|
resp_two = client.get(f"/users/{user.id}")
|
||||||
|
data = json.loads(resp_two.data.decode())
|
||||||
|
assert resp_two.status_code == 200
|
||||||
|
assert "me" in data["username"]
|
||||||
|
assert "me@itba.edu" in data["email"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_id, payload, status_code, message",
|
||||||
|
[
|
||||||
|
[1, {}, 400, "Input payload validation failed"],
|
||||||
|
[1, {"email": "me@itba.edu"}, 400, "Input payload validation failed"],
|
||||||
|
[
|
||||||
|
999,
|
||||||
|
{"username": "me", "email": "me@itba.edu"},
|
||||||
|
404,
|
||||||
|
"User 999 does not exist",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_update_user_invalid(
|
||||||
|
test_app, test_database, user_id, payload, status_code, message
|
||||||
|
):
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.put(
|
||||||
|
f"/users/{user_id}",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == status_code
|
||||||
|
assert message in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_duplicate_email(test_app, test_database, add_user):
|
||||||
|
add_user("carlos", "carlos@garca.org", "passwd1234")
|
||||||
|
user = add_user("charly", "charly@garca.org", "passwd1234")
|
||||||
|
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.put(
|
||||||
|
f"/users/{user.id}",
|
||||||
|
data=json.dumps({"username": "charly", "email": "carlos@garca.org"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Sorry. That email already exists." in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_with_passord(test_app, test_database, add_user):
|
||||||
|
password_one = "greaterthaneight"
|
||||||
|
password_two = "somethingdifferent"
|
||||||
|
|
||||||
|
user = add_user("user-to-be-updated", "update-me@testdriven.io", password_one)
|
||||||
|
assert bcrypt.check_password_hash(user.password, password_one)
|
||||||
|
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.put(
|
||||||
|
f"/users/{user.id}",
|
||||||
|
data=json.dumps(
|
||||||
|
{"username": "me", "email": "foo@testdriven.io", "password": password_two}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
user = get_user_by_id(user.id)
|
||||||
|
assert bcrypt.check_password_hash(user.password, password_one)
|
||||||
|
assert not bcrypt.check_password_hash(user.password, password_two)
|
|
@ -0,0 +1,44 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from src.config import ProductionConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_development_config(test_app):
|
||||||
|
test_app.config.from_object("src.config.DevelopmentConfig")
|
||||||
|
assert test_app.config["SECRET_KEY"] == "my_precious"
|
||||||
|
assert not test_app.config["TESTING"]
|
||||||
|
assert test_app.config["SQLALCHEMY_DATABASE_URI"] == os.environ.get("DATABASE_URL")
|
||||||
|
assert test_app.config["BCRYPT_LOG_ROUNDS"] == 4
|
||||||
|
assert test_app.config["ACCESS_TOKEN_EXPIRATION"] == 900
|
||||||
|
assert test_app.config["REFRESH_TOKEN_EXPIRATION"] == 2592000
|
||||||
|
|
||||||
|
|
||||||
|
def test_testing_config(test_app):
|
||||||
|
test_app.config.from_object("src.config.TestingConfig")
|
||||||
|
assert test_app.config["SECRET_KEY"] == "my_precious"
|
||||||
|
assert test_app.config["TESTING"]
|
||||||
|
assert test_app.config["SQLALCHEMY_DATABASE_URI"] == os.environ.get(
|
||||||
|
"DATABASE_TEST_URL"
|
||||||
|
)
|
||||||
|
assert test_app.config["BCRYPT_LOG_ROUNDS"] == 4
|
||||||
|
assert test_app.config["ACCESS_TOKEN_EXPIRATION"] == 5
|
||||||
|
assert test_app.config["REFRESH_TOKEN_EXPIRATION"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_config(test_app, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"DATABASE_URL", "postgresql://postgres:postgres@api-db:5432/api_users"
|
||||||
|
)
|
||||||
|
test_app.config.from_object(ProductionConfig())
|
||||||
|
# assert test_app.config["SECRET_KEY"] == "my_precious"
|
||||||
|
assert not test_app.config["TESTING"]
|
||||||
|
assert test_app.config["SQLALCHEMY_DATABASE_URI"] == os.environ.get("DATABASE_URL")
|
||||||
|
assert test_app.config["BCRYPT_LOG_ROUNDS"] == 13
|
||||||
|
assert test_app.config["ACCESS_TOKEN_EXPIRATION"] == 900
|
||||||
|
assert test_app.config["REFRESH_TOKEN_EXPIRATION"] == 2592000
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_db_url_rewrite(monkeypatch):
|
||||||
|
monkeypatch.setenv("DATABASE_URL", "postgres://server")
|
||||||
|
prod_config = ProductionConfig()
|
||||||
|
assert prod_config.SQLALCHEMY_DATABASE_URI == "postgresql://server"
|
|
@ -0,0 +1,13 @@
|
||||||
|
from src.api.models.generic import (get_model_create_response,
|
||||||
|
get_model_error_response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_create_response(test_namespace):
|
||||||
|
model = get_model_create_response(test_namespace)
|
||||||
|
assert model.get("id")
|
||||||
|
assert model.get("message")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_model_error_response(test_namespace):
|
||||||
|
model = get_model_error_response(test_namespace)
|
||||||
|
assert "message" in model
|
|
@ -0,0 +1,269 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import src.api.users
|
||||||
|
|
||||||
|
|
||||||
|
class AttrDict(dict):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(AttrDict, self).__init__(*args, **kwargs)
|
||||||
|
self.__dict__ = self
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_user(test_app, monkeypatch):
|
||||||
|
def mock_get_user_by_email(email):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mock_add_user(username, email, passwd):
|
||||||
|
d = AttrDict()
|
||||||
|
d.update(
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"username": "me",
|
||||||
|
"email": "me@itba.edu",
|
||||||
|
"password": "123456789",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
|
||||||
|
monkeypatch.setattr(src.api.users, "add_user", mock_add_user)
|
||||||
|
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"username": "michael",
|
||||||
|
"email": "michael@itba.edu",
|
||||||
|
"password": "123456789",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert "michael@itba.edu was added!" in data["message"]
|
||||||
|
assert data["id"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_user_duplicate_email(test_app, monkeypatch):
|
||||||
|
def mock_get_user_by_email(email):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def mock_add_user(username, email, passwd):
|
||||||
|
d = AttrDict()
|
||||||
|
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
|
||||||
|
return d
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
|
||||||
|
monkeypatch.setattr(src.api.users, "add_user", mock_add_user)
|
||||||
|
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.post(
|
||||||
|
"/users",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"username": "michael",
|
||||||
|
"email": "michael@itba.edu",
|
||||||
|
"password": "123456789",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Sorry. That email already exists." in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_user(test_app, monkeypatch):
|
||||||
|
def mock_get_user_by_id(user_id):
|
||||||
|
return {
|
||||||
|
"id": 1,
|
||||||
|
"username": "jeffrey",
|
||||||
|
"email": "jeffrey@itba.edu",
|
||||||
|
"created_date": datetime.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.get("/users/1")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "jeffrey" in data["username"]
|
||||||
|
assert "jeffrey@itba.edu" in data["email"]
|
||||||
|
assert "password" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_user_incorrect_id(test_app, monkeypatch):
|
||||||
|
def mock_get_user_by_id(user_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.get("/users/999")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "User 999 does not exist" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_users(test_app, monkeypatch):
|
||||||
|
def mock_get_all_users():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "michael",
|
||||||
|
"email": "michael@mherman.org",
|
||||||
|
"created_date": datetime.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "fletcher",
|
||||||
|
"email": "fletcher@notreal.com",
|
||||||
|
"created_date": datetime.now(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_all_users", mock_get_all_users)
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.get("/users")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(data) == 2
|
||||||
|
assert "michael" in data[0]["username"]
|
||||||
|
assert "michael@mherman.org" in data[0]["email"]
|
||||||
|
assert "fletcher" in data[1]["username"]
|
||||||
|
assert "fletcher@notreal.com" in data[1]["email"]
|
||||||
|
assert "password" not in data[0]
|
||||||
|
assert "password" not in data[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_user(test_app, monkeypatch):
|
||||||
|
def mock_get_user_by_id(user_id):
|
||||||
|
d = AttrDict()
|
||||||
|
d.update(
|
||||||
|
{"id": 1, "username": "user-to-be-removed", "email": "remove-me@itba.edu"}
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def mock_delete_user(user):
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
|
||||||
|
monkeypatch.setattr(src.api.users, "delete_user", mock_delete_user)
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp_two = client.delete("/users/1")
|
||||||
|
data = json.loads(resp_two.data.decode())
|
||||||
|
assert resp_two.status_code == 200
|
||||||
|
assert "remove-me@itba.edu was removed!" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_user_incorrect_id(test_app, monkeypatch):
|
||||||
|
def mock_get_user_by_id(user_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.delete("/users/999")
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "User 999 does not exist" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user(test_app, monkeypatch):
|
||||||
|
def mock_get_user_by_id(user_id):
|
||||||
|
d = AttrDict()
|
||||||
|
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
|
||||||
|
return d
|
||||||
|
|
||||||
|
def mock_update_user(user, username, email):
|
||||||
|
d = AttrDict()
|
||||||
|
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
|
||||||
|
return d
|
||||||
|
|
||||||
|
def mock_get_user_by_email(email):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
|
||||||
|
monkeypatch.setattr(src.api.users, "update_user", mock_update_user)
|
||||||
|
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp_one = client.put(
|
||||||
|
"/users/1",
|
||||||
|
data=json.dumps({"username": "me", "email": "me@itba.edu"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp_one.data.decode())
|
||||||
|
assert resp_one.status_code == 200
|
||||||
|
assert "1 was updated!" in data["message"]
|
||||||
|
resp_two = client.get("/users/1")
|
||||||
|
data = json.loads(resp_two.data.decode())
|
||||||
|
assert resp_two.status_code == 200
|
||||||
|
assert "me" in data["username"]
|
||||||
|
assert "me@itba.edu" in data["email"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_id, payload, status_code, message",
|
||||||
|
[
|
||||||
|
[1, {}, 400, "Input payload validation failed"],
|
||||||
|
[1, {"email": "me@itba.edu"}, 400, "Input payload validation failed"],
|
||||||
|
[
|
||||||
|
999,
|
||||||
|
{"username": "me", "email": "me@itba.edu"},
|
||||||
|
404,
|
||||||
|
"User 999 does not exist",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_update_user_invalid(
|
||||||
|
test_app, monkeypatch, user_id, payload, status_code, message
|
||||||
|
):
|
||||||
|
def mock_get_user_by_id(user_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
|
||||||
|
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.put(
|
||||||
|
f"/users/{user_id}",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == status_code
|
||||||
|
assert message in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_duplicate_email(test_app, monkeypatch):
|
||||||
|
class AttrDict(dict):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(AttrDict, self).__init__(*args, **kwargs)
|
||||||
|
self.__dict__ = self
|
||||||
|
|
||||||
|
def mock_get_user_by_id(user_id):
|
||||||
|
d = AttrDict()
|
||||||
|
d.update({"id": 1, "username": "me", "email": "me@itba.edu"})
|
||||||
|
return d
|
||||||
|
|
||||||
|
def mock_update_user(user, username, email):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def mock_get_user_by_email(email):
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_id", mock_get_user_by_id)
|
||||||
|
monkeypatch.setattr(src.api.users, "get_user_by_email", mock_get_user_by_email)
|
||||||
|
monkeypatch.setattr(src.api.users, "update_user", mock_update_user)
|
||||||
|
|
||||||
|
client = test_app.test_client()
|
||||||
|
resp = client.put(
|
||||||
|
"/users/1",
|
||||||
|
data=json.dumps({"username": "me", "email": "me@itba.edu"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
data = json.loads(resp.data.decode())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Sorry. That email already exists." in data["message"]
|
|
@ -0,0 +1 @@
|
||||||
|
node_modules
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:17.9.1 AS app
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
RUN rm -rf ./*
|
||||||
|
COPY --from=app /app/build .
|
||||||
|
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM node:17.9.1 AS app
|
||||||
|
ENV REACT_APP_ENDPOINT "https://api.fids.slc.ar/"
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
RUN rm -rf ./*
|
||||||
|
COPY --from=app /app/build .
|
||||||
|
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM node:17.9.1 AS app
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
COPY package-lock.json .
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN chmod +x /app/test.sh
|
||||||
|
ENTRYPOINT ["/app/test.sh"]
|
|
@ -0,0 +1,7 @@
|
||||||
|
Para correr la app basta con ejecutar
|
||||||
|
|
||||||
|
npm run start
|
||||||
|
|
||||||
|
para corerr los test
|
||||||
|
|
||||||
|
npm run test
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "jsdom",
|
||||||
|
};
|
|
@ -0,0 +1,15 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index unresolvable-file-html.html;
|
||||||
|
try_files $uri @index;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @index {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
expires 0;
|
||||||
|
try_files /index.html =404;
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,56 @@
|
||||||
|
{
|
||||||
|
"name": "sample-client-users",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"antd": "^5.3.3",
|
||||||
|
"axios": "^1.3.4",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router": "^6.10.0",
|
||||||
|
"react-router-dom": "^6.10.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "jest --coverage --collectCoverageFrom=\"./src/**\"",
|
||||||
|
"test:integration": "jest integration",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"docker:build": "docker build -t client-users .",
|
||||||
|
"docker:run": " docker run --rm -it -p 8080:80 client-users"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^28.1.8",
|
||||||
|
"@types/node": "^16.18.23",
|
||||||
|
"@types/react": "^18.0.32",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"jest": "^28.0.0",
|
||||||
|
"jest-environment-jsdom": "^28.0.0",
|
||||||
|
"ts-jest": "^28.0.0",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>Client Users</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
--></body>
|
||||||
|
</html>
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Axios, AxiosError } from "axios";
|
||||||
|
import { Credentials, Token, User, Flight, FlightCreate } from "./Types";
|
||||||
|
|
||||||
|
const auth_instance = new Axios({
|
||||||
|
baseURL: "http://127.0.0.1:5001/",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const flights_instance = new Axios({
|
||||||
|
baseURL: "http://127.0.0.1:5000/",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
auth_instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return JSON.parse(response.data);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
flights_instance.interceptors.request.use((request) => {
|
||||||
|
request.data = JSON.stringify(request.data);
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
|
||||||
|
flights_instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return JSON.parse(response.data);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const err = error as AxiosError;
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
auth_instance.interceptors.request.use((request) => {
|
||||||
|
request.data = JSON.stringify(request.data);
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createUser = (
|
||||||
|
credentials: Credentials
|
||||||
|
): Promise<{ id?: string; message: string }> => {
|
||||||
|
return auth_instance.post("users", credentials);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUsers = (): Promise<User[]> => {
|
||||||
|
return auth_instance.get("users");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUserById = (id: number): Promise<User> => {
|
||||||
|
return auth_instance.get("users/" + id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logIn = (
|
||||||
|
credentials: Credentials
|
||||||
|
): Promise<Token & Partial<{ message: string; user_id: number }>> => {
|
||||||
|
return auth_instance.post("auth/login", credentials);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tokenStatus = (
|
||||||
|
token: string
|
||||||
|
): Promise<User & { message?: string }> => {
|
||||||
|
return auth_instance.get("auth/status", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchZones = (origin: string | null): Promise<Flight[]> => {
|
||||||
|
return flights_instance.get("flights" + (origin ? "?origin=" + origin : ""))
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFlight = (
|
||||||
|
flight_data: FlightCreate
|
||||||
|
): Promise<Flight> => {
|
||||||
|
return flights_instance.post("flights", flight_data);
|
||||||
|
};
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { LogIn } from "./components/LogIn/LogIn";
|
||||||
|
import { Navigate, Route, RouteProps, Routes } from "react-router";
|
||||||
|
import { SignUp } from "./components/SignUp/SignUp";
|
||||||
|
import { Home } from "./components/Home/Home";
|
||||||
|
import { CreateFlight } from "./components/CreateFlight/CreateFlight";
|
||||||
|
import { Button } from "antd";
|
||||||
|
import useAuth, { AuthProvider } from "./useAuth";
|
||||||
|
|
||||||
|
function Router() {
|
||||||
|
const { user, logout, isAirline } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LogIn />} />
|
||||||
|
<Route path="/signup" element={<SignUp />} />
|
||||||
|
<Route path="/home" element={!user ? <LogIn/> :<Home/>} />
|
||||||
|
<Route path="/create-flight" element={!isAirline ? <LogIn/> :<CreateFlight/>} />
|
||||||
|
<Route path="/" element={!user ? <LogIn/> :<Home/>} />
|
||||||
|
</Routes>
|
||||||
|
<div className="LogoutButton">
|
||||||
|
{
|
||||||
|
<Button
|
||||||
|
onClick={logout}
|
||||||
|
disabled={!!!localStorage.getItem("token")}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router/>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
|
@ -0,0 +1,48 @@
|
||||||
|
export interface Credentials {
|
||||||
|
password: string;
|
||||||
|
email: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Token {
|
||||||
|
refresh_token: string;
|
||||||
|
access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenData {
|
||||||
|
sub: string;
|
||||||
|
airline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
created_date?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Zone {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Flight {
|
||||||
|
id: number,
|
||||||
|
flight_code: string;
|
||||||
|
status: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
departure_time: string;
|
||||||
|
arrival_time: string;
|
||||||
|
gate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlightCreate {
|
||||||
|
flight_code: string;
|
||||||
|
status: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
departure_time: string;
|
||||||
|
arrival_time: string;
|
||||||
|
gate: string;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import "../../matchMedia.mock";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Button } from "antd";
|
||||||
|
|
||||||
|
describe("Button Component Test", () => {
|
||||||
|
test("Display button label and clicked", async () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
|
||||||
|
render(<Button onClick={() => onClick()}>Button</Button>);
|
||||||
|
|
||||||
|
expect(screen.getByText("Button")).toBeVisible();
|
||||||
|
await userEvent.click(screen.getByText("Button"));
|
||||||
|
expect(onClick).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,112 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { FlightCreate, Flight } from "../../Types";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import "./FlightForm.css";
|
||||||
|
import { createFlight } from "../../Api";
|
||||||
|
|
||||||
|
export const CreateFlight = () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const origin = urlParams.get('origin');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flight, setFlight] = useState<Flight>();
|
||||||
|
|
||||||
|
const [flightData, setFlightData] = useState<FlightCreate>({
|
||||||
|
flight_code: "ABC123",
|
||||||
|
status: "En ruta",
|
||||||
|
origin: "Ciudad A",
|
||||||
|
destination: "Ciudad B",
|
||||||
|
departure_time: "2023-10-09 10:00 AM",
|
||||||
|
arrival_time: "2023-10-09 12:00 PM",
|
||||||
|
gate: "A1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
createFlight(flightData)
|
||||||
|
.then((data) => {
|
||||||
|
setFlight(data);
|
||||||
|
navigate("/home")
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError(error as string);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Flight Code:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={flightData.flight_code}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlightData({ ...flightData, flight_code: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Status:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={flightData.status}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlightData({ ...flightData, status: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Origin:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={flightData.origin}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlightData({ ...flightData, origin: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Destination:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={flightData.destination}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlightData({ ...flightData, destination: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Departure Time:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={flightData.departure_time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlightData({ ...flightData, departure_time: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Arrival Time:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={flightData.arrival_time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlightData({ ...flightData, arrival_time: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Gate:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={flightData.gate}
|
||||||
|
onChange={(e) => setFlightData({ ...flightData, gate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,30 @@
|
||||||
|
.flight-form {
|
||||||
|
max-width: 300px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
.flight-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
background-color: #fff;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import "../../../matchMedia.mock";
|
||||||
|
|
||||||
|
import { Card } from "./Card";
|
||||||
|
|
||||||
|
describe("Card Component Test", () => {
|
||||||
|
test("Display initial, name and icon", async () => {
|
||||||
|
// render(<Card name="Belgrano" />);
|
||||||
|
|
||||||
|
// expect(screen.getByText("Belgrano📍")).toBeVisible();
|
||||||
|
// expect(screen.getByText("B")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Avatar, Space, Typography, Tag } from "antd";
|
||||||
|
import { RightOutlined, ClockCircleOutlined, SwapOutlined, EnvironmentOutlined, CalendarOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
import "./Card.css";
|
||||||
|
|
||||||
|
interface FlightProps {
|
||||||
|
flight_code: string;
|
||||||
|
status: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
departure_time: string;
|
||||||
|
arrival_time: string;
|
||||||
|
gate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
flight: FlightProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export const Card: React.FC<CardProps> = ({ flight }) => {
|
||||||
|
return (
|
||||||
|
<div className="flight-card">
|
||||||
|
<Space size={8} align="center">
|
||||||
|
<Avatar size={64} icon={<RightOutlined />} />
|
||||||
|
<div>
|
||||||
|
<Text strong>{flight.flight_code}</Text>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">
|
||||||
|
{flight.origin} <SwapOutlined /> {flight.destination}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<div className="flight-details">
|
||||||
|
<Space size={8} direction="vertical">
|
||||||
|
<Text strong>Status:</Text>
|
||||||
|
<Tag color={flight.status === "En ruta" ? "green" : "orange"}>{flight.status}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Space size={8} direction="vertical">
|
||||||
|
<Text strong>Departure:</Text>
|
||||||
|
<Space size={2} align="baseline">
|
||||||
|
<CalendarOutlined />
|
||||||
|
{flight.departure_time}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<Space size={8} direction="vertical">
|
||||||
|
<Text strong>Arrival:</Text>
|
||||||
|
<Space size={2} align="baseline">
|
||||||
|
<CalendarOutlined />
|
||||||
|
{flight.arrival_time}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<Space size={8} direction="vertical">
|
||||||
|
<Text strong>Gate:</Text>
|
||||||
|
<Text>{flight.gate}</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
const mockedUsedNavigate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("react-router-dom", () => ({
|
||||||
|
...jest.requireActual("react-router-dom"),
|
||||||
|
useNavigate: () => mockedUsedNavigate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import "../../matchMedia.mock";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Home } from "./Home";
|
||||||
|
|
||||||
|
describe("Home View Test", () => {
|
||||||
|
test("Display initial, name and icon", async () => {
|
||||||
|
// render(
|
||||||
|
// <Home
|
||||||
|
// zones={[
|
||||||
|
// { id: 1, name: "Belgrano" },
|
||||||
|
// { id: 2, name: "San Isidro" },
|
||||||
|
// ]}
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
|
||||||
|
// expect(screen.getByText("Zones")).toBeVisible();
|
||||||
|
// expect(screen.getByText("Belgrano📍")).toBeVisible();
|
||||||
|
// expect(screen.getByText("San Isidro📍")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Card } from "./Card/Card";
|
||||||
|
import { useFetchZones } from "../../hooks/useFetchZones";
|
||||||
|
import { Flight } from "../../Types";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import useAuth from "../../useAuth";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
flights?: Flight[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Home: React.FC<Props> = (props) => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const origin = urlParams.get('origin');
|
||||||
|
const { zones, error } = useFetchZones(origin);
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { loading, isAirline } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Box">
|
||||||
|
{isAirline ? <button onClick={() => { console.log("ANA"); navigate("/create-flight") }}>CREATE FLIGHT</button> : <></>}
|
||||||
|
<h2>Flights</h2>
|
||||||
|
<div className="Items">
|
||||||
|
{(props.flights ? props.flights : zones).map((u) => {
|
||||||
|
return <Card key={u.id} flight={u} />;
|
||||||
|
})}
|
||||||
|
{error ? <div className="Disconnected">{error}</div> : <></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,45 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import useAuth from "../../useAuth";
|
||||||
|
|
||||||
|
export const LogIn = () => {
|
||||||
|
const { login, loading, error } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Box Small">
|
||||||
|
<div className="Section">
|
||||||
|
<img
|
||||||
|
alt="logo"
|
||||||
|
className="Image"
|
||||||
|
src="https://www.seekpng.com/png/full/353-3537757_logo-itba.png"
|
||||||
|
/>
|
||||||
|
<div className="Section">
|
||||||
|
<Input
|
||||||
|
placeholder="User"
|
||||||
|
onChange={(ev) => setEmail(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="Password"
|
||||||
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
onClick={async () =>
|
||||||
|
login({email, password})
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
{error ? (
|
||||||
|
<div className="Disconnected">{error}</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import { useCreateUser } from "../../hooks/useCreateUser";
|
||||||
|
|
||||||
|
export const SignUp = () => {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [repeatPassword, setRepeatPassword] = useState("");
|
||||||
|
|
||||||
|
const { createUser, isLoading, error } = useCreateUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Box Small">
|
||||||
|
<div className="Section">
|
||||||
|
<img
|
||||||
|
alt="logo"
|
||||||
|
className="Image"
|
||||||
|
src="https://www.seekpng.com/png/full/353-3537757_logo-itba.png"
|
||||||
|
/>
|
||||||
|
<div className="Section">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
onChange={(ev) => setEmail(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Username"
|
||||||
|
onChange={(ev) => setUsername(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="Password"
|
||||||
|
onChange={(ev) => setPassword(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="Repeat password"
|
||||||
|
onChange={(ev) => setRepeatPassword(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
onClick={async () =>
|
||||||
|
await createUser({ email, password, username })
|
||||||
|
}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={
|
||||||
|
email === "" ||
|
||||||
|
username === "" ||
|
||||||
|
password === "" ||
|
||||||
|
password !== repeatPassword
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Button>
|
||||||
|
{error ? (
|
||||||
|
<div className="Disconnected">{error}</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,66 @@
|
||||||
|
const mockedUsedNavigate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("react-router-dom", () => ({
|
||||||
|
...jest.requireActual("react-router-dom"),
|
||||||
|
useNavigate: () => mockedUsedNavigate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import "../matchMedia.mock";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { useAuthenticateUser } from "./useAuthenticateUser";
|
||||||
|
|
||||||
|
describe("UseAuthenticateUser Hook Test", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hook initial state", async () => {
|
||||||
|
const { result } = renderHook(() => useAuthenticateUser());
|
||||||
|
expect(result.current.isLoading).toBeFalsy();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hook fetch state - Authenticate function - Promise not resolved", async () => {
|
||||||
|
const { result } = renderHook(() => useAuthenticateUser());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.authenticate({
|
||||||
|
email: "martin@gmail.com",
|
||||||
|
password: "password1234",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBeTruthy();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hook fetch state - Authenticate function - Promise success", async () => {
|
||||||
|
const { result } = renderHook(() => useAuthenticateUser());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authenticate({
|
||||||
|
email: "martin@gmail.com",
|
||||||
|
password: "password1234",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(localStorage.getItem("token")).not.toBeNull();
|
||||||
|
expect(result.current.isLoading).toBeFalsy();
|
||||||
|
expect(result.current.error).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hook fetch state - Authenticate function - Promise failed", async () => {
|
||||||
|
const { result } = renderHook(() => useAuthenticateUser());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authenticate({
|
||||||
|
email: "notExistingUser",
|
||||||
|
password: "notExistingUser",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(localStorage.getItem("token")).toBe("undefined");
|
||||||
|
expect(result.current.isLoading).toBeFalsy();
|
||||||
|
expect(result.current.error).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,80 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Credentials, User, TokenData } from "../Types";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { fetchUserById, logIn } from "../Api";
|
||||||
|
import { tokenStatus } from "../Api";
|
||||||
|
import jwt_decode from "jwt-decode";
|
||||||
|
|
||||||
|
export const useAuthenticateUser = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isAirline, setIsAirline] = useState(false);
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tokenValidated, setTokenValidated] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const authenticate = async (credentials: Credentials): Promise<void> => {
|
||||||
|
if (!user) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const tokens = await logIn(credentials);
|
||||||
|
localStorage.setItem("token", tokens.access_token);
|
||||||
|
const airline = (jwt_decode(tokens.access_token) as TokenData).airline;
|
||||||
|
setIsAirline(airline)
|
||||||
|
|
||||||
|
if (tokens.user_id) {
|
||||||
|
const user = await fetchUserById(tokens.user_id);
|
||||||
|
setUser(user);
|
||||||
|
} else {
|
||||||
|
setError(tokens.message!.split(".")[0] + ".");
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error as string);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
navigate("/home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateToken = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const existingToken = localStorage.getItem("token");
|
||||||
|
if (existingToken && !tokenValidated) {
|
||||||
|
const response = await tokenStatus(existingToken);
|
||||||
|
|
||||||
|
const { message } = response;
|
||||||
|
if (message) throw new Error("Invalid token");
|
||||||
|
|
||||||
|
const airline = (jwt_decode(existingToken) as TokenData).airline;
|
||||||
|
setIsAirline(airline)
|
||||||
|
|
||||||
|
const user = await fetchUserById(response.id);
|
||||||
|
setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTokenValidated(true);
|
||||||
|
} catch (error) {
|
||||||
|
logout();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
setUser(null);
|
||||||
|
setTokenValidated(false)
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return { user, isLoading, authenticate, validateToken, isAirline, logout, error };
|
||||||
|
};
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { User, Flight, FlightCreate } from "../Types";
|
||||||
|
import { createFlight } from "../Api";
|
||||||
|
|
||||||
|
export const useCreateFlight = (flight_data: FlightCreate) => {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flight, setFlight] = useState<Flight>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
createFlight(flight_data)
|
||||||
|
.then((data) => {
|
||||||
|
setFlight(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError(error as string);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { flight, error };
|
||||||
|
};
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Credentials } from "../Types";
|
||||||
|
import { createUser as createUserAPI } from "../Api";
|
||||||
|
import useAuth from "../useAuth";
|
||||||
|
|
||||||
|
export const useCreateUser = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const createUser = async (credentials: Credentials): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const createResponse = await createUserAPI(credentials);
|
||||||
|
|
||||||
|
if (createResponse.id) {
|
||||||
|
login(credentials);
|
||||||
|
} else {
|
||||||
|
setError(createResponse.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error as string);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { createUser, isLoading, error };
|
||||||
|
};
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { User, Flight } from "../Types";
|
||||||
|
import { fetchZones } from "../Api";
|
||||||
|
|
||||||
|
export const useFetchZones = (origin: string | null) => {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [zones, setZones] = useState<Flight[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
fetchZones(origin)
|
||||||
|
.then((data) => {
|
||||||
|
setZones(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError(error as string);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { zones, error };
|
||||||
|
};
|
|
@ -0,0 +1,106 @@
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
||||||
|
"Helvetica Neue", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #eff2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Box {
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0px 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 50px;
|
||||||
|
gap: 30px;
|
||||||
|
background-color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Small {
|
||||||
|
width: 250px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Section {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px 50px;
|
||||||
|
gap: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Image {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Connected {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Disconnected {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FloatingStatus {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LogoutButton {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0px 10px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Items {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.List {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
gap: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import reportWebVitals from "./reportWebVitals";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById("root") as HTMLElement
|
||||||
|
);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
|
@ -0,0 +1,13 @@
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation(query => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(), // deprecated
|
||||||
|
removeListener: jest.fn(), // deprecated
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="react-scripts" />
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { ReportHandler } from 'web-vitals';
|
||||||
|
|
||||||
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
|
@ -0,0 +1,5 @@
|
||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
|
@ -0,0 +1,106 @@
|
||||||
|
import React, {createContext, ReactNode, useContext, useEffect, useMemo, useState} from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Credentials, TokenData, User } from "./Types";
|
||||||
|
import { fetchUserById, logIn, tokenStatus } from "./Api";
|
||||||
|
import jwt_decode from "jwt-decode";
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user?: User;
|
||||||
|
loading: boolean;
|
||||||
|
isAirline: boolean;
|
||||||
|
error?: any;
|
||||||
|
login: (credentials: Credentials) => void;
|
||||||
|
signUp: (email: string, name: string, password: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>(
|
||||||
|
{} as AuthContextType
|
||||||
|
);
|
||||||
|
|
||||||
|
export function AuthProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
const [user, setUser] = useState<User>();
|
||||||
|
const [error, setError] = useState<any>();
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [loadingInitial, setLoadingInitial] = useState<boolean>(true);
|
||||||
|
const [isAirline, setIsAirline] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) setError(undefined);
|
||||||
|
}, [window.location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existingToken = localStorage.getItem("token");
|
||||||
|
if (existingToken) {
|
||||||
|
const airline = (jwt_decode(existingToken) as TokenData).airline;
|
||||||
|
setIsAirline(airline)
|
||||||
|
|
||||||
|
|
||||||
|
tokenStatus(existingToken)
|
||||||
|
.then((res) => fetchUserById(res.id)
|
||||||
|
.then((res) => setUser(res))
|
||||||
|
.catch((_error) => {})
|
||||||
|
.finally(() => setLoadingInitial(false))
|
||||||
|
)
|
||||||
|
.catch((_error) => {})
|
||||||
|
// .finally(() => setLoadingInitial(false));
|
||||||
|
} else {
|
||||||
|
setLoadingInitial(false)
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function login(credentials: Credentials) {
|
||||||
|
setLoading(true);
|
||||||
|
const tokens = logIn(credentials)
|
||||||
|
.then((x) => {
|
||||||
|
localStorage.setItem("token", x.access_token);
|
||||||
|
const airline = (jwt_decode(x.access_token) as TokenData).airline;
|
||||||
|
setIsAirline(airline)
|
||||||
|
const user = fetchUserById(x.user_id as number)
|
||||||
|
.then(y => {
|
||||||
|
setUser(y);
|
||||||
|
navigate("/home")
|
||||||
|
})
|
||||||
|
.catch((error) => setError(error))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
})
|
||||||
|
.catch((error) => setError(error))
|
||||||
|
// .finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function signUp(email: string, name: string, password: string) {}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
setUser(undefined);
|
||||||
|
navigate("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoedValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isAirline,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
signUp,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[user, isAirline, loading, error]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={memoedValue}>
|
||||||
|
{!loadingInitial && children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
curl -X DELETE api:5000/ping
|
||||||
|
curl -X POST api:5000/ping
|
||||||
|
|
||||||
|
|
||||||
|
# npm test
|
||||||
|
echo "NPM TEST"
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue