diff --git a/.gitignore b/.gitignore index 487653c..4aa2284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,23 @@ *.pyc __pycache__/ -env/ -.env .ipynb_checkpoints/ + + +# Secrets +.env +.flaskenv + +# Virutal Environments +/venv/ +env/ +.venv/ + +# Local Database +my_database.db + +# Logs +*.logs +logs/ + +migrations/__pycache__/ +migrations/versions/__pycache__/ \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/dashboard.py b/api/dashboard.py index e6eda35..50d3fb3 100644 --- a/api/dashboard.py +++ b/api/dashboard.py @@ -4,10 +4,12 @@ # alternatively, find edited files on planner board > 'Add Endpoint for the Dashboard'. import sys -from flask import Blueprint, jsonify -from models.user import db, UserProfile +from flask import Blueprint, jsonify, request +from models import db, UserProfile from flask_cors import CORS from datetime import datetime, timezone +from flask_login import login_required, current_user +from extensions import csrf # Create the Blueprint for dashboard dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/api/dashboard') @@ -38,3 +40,40 @@ def get_dashboard_data(): }) return jsonify({'message': 'User not found'}), 404 + + +def _is_intlike(x): + try: + int(x) + return True + except Exception: + return False + +@dashboard_bp.route('/activity', methods=['POST']) +@login_required +@csrf.exempt +def post_activity(): + body = request.get_json(silent=True) or {} + + # required field + date = body.get("date") + if not isinstance(date, str): + return jsonify({"error": "date is required (YYYY-MM-DD)"}), 400 + + # optional numeric fields; default to 0 if missing but validate type if present + numeric_fields = [ + "steps", + "minutes_running", + "minutes_cycling", + "minutes_swimming", + "minutes_exercise", + "calories", + ] + out = {"date": date, "user_id": getattr(current_user, "id", None)} + for f in numeric_fields: + v = body.get(f, 0) + if v != 0 and not _is_intlike(v): + return jsonify({"error": f"{f} must be an integer"}), 400 + out[f] = int(v) + + return jsonify(out), 201 diff --git a/api/goals.py b/api/goals.py index 0a85b04..1b7588d 100644 --- a/api/goals.py +++ b/api/goals.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify -from models.goal import db, Goal # Correctly import db and Goal +from models import db, Goal # Correctly import db and Goal from datetime import datetime +from flask_login import login_required, current_user # Create the Blueprint for goals goals_bp = Blueprint('goals', __name__, url_prefix='/api/goals') @@ -37,6 +38,7 @@ def create_goal(): # Return the goal as a response with a 201 status return jsonify(goal.as_dict()), 201 + @goals_bp.route('/', methods=['POST']) def create_goal_for_user(user_id): data = request.get_json() @@ -80,33 +82,55 @@ def get_user_goals(user_id): # Update Goal @goals_bp.route('/', methods=['PUT']) +@login_required def update_goal(goal_id): - data = request.get_json() + data = request.get_json() or {} # Retrieve the goal with the specified goal_id goal = Goal.query.get_or_404(goal_id) + if goal.user_id != current_user.id: + return jsonify({"error": "forbidden"}), 403 + + # Validation: dates must be ISO format (YYYY-MM-DD) + if "start_date" in data: + try: + goal.start_date = datetime.strptime(data["start_date"], "%Y-%m-%d").date() + except ValueError: + return jsonify({"error": "Invalid start_date format, expected YYYY-MM-DD"}), 422 + + if "end_date" in data: + try: + goal.end_date = datetime.strptime(data["end_date"], "%Y-%m-%d").date() + except ValueError: + return jsonify({"error": "Invalid end_date format, expected YYYY-MM-DD"}), 422 + # Loop through all the fields to update and check if they exist in the data for field in ['start_date', 'end_date', 'steps', 'minutes_running', 'minutes_cycling', 'minutes_swimming', 'minutes_exercise', 'calories']: if field in data: - # If it's a date field, convert it - if field in ['start_date', 'end_date']: - setattr(goal, field, datetime.strptime(data[field], '%Y-%m-%d').date()) - else: - setattr(goal, field, data[field]) + try: + val = int(data[field]) + except (TypeError, ValueError): + return jsonify({"error": f"{field} must be a number"}), 422 + if val < 0: + return jsonify({"error": f"{field} cannot be negative value"}), 422 + setattr(goal, field, val) # Commit the changes to the database db.session.commit() # Return the updated goal as a response - return jsonify(goal.as_dict()) + return jsonify(goal.as_dict()), 200 # Delete Goal @goals_bp.route('/', methods=['DELETE']) def delete_goal(goal_id): # Retrieve the goal by goal_id goal = Goal.query.get_or_404(goal_id) + + if goal.user_id != current_user.id: + return jsonify({"error": "forbidden"}), 403 # Delete the goal from the session db.session.delete(goal) diff --git a/api/profile.py b/api/profile.py index 0496bc3..4380001 100644 --- a/api/profile.py +++ b/api/profile.py @@ -1,51 +1,89 @@ # /api/profile.py from flask import Blueprint, jsonify, request -from models.user import db, UserProfile +from flask_login import login_required, current_user +from extensions import db, csrf +import re +from models import db, UserProfile from flask_cors import CORS api = Blueprint('profile_api', __name__) # Profile Endpoints # - +DATE_RX = re.compile(r"^\d{4}-\d{2}-\d{2}$") # These routes are used by the frontend to fetch/update the user profile. # Requires frontend request structure from ProfilePage.tsx @api.route('', methods=['GET']) +@login_required def get_profile(): # In future develpment get the user_id from a session or token - user_id = 1 # Replace with authenticated user ID - user = UserProfile.query.filter_by(id=user_id).first() + # user_id = 1 # Replace with authenticated user ID + # user = UserProfile.query.filter_by(id=user_id).first() + + # if user: + # return jsonify({ + # 'name': user.name, + # 'account': user.account, + # 'birthDate': user.birthDate, + # 'gender': user.gender, + # 'avatar': user.avatar + # }) + # return jsonify({'message': 'User not found'}), 404 - if user: - return jsonify({ - 'name': user.name, - 'account': user.account, - 'birthDate': user.birthDate, - 'gender': user.gender, - 'avatar': user.avatar - }) - return jsonify({'message': 'User not found'}), 404 + profile = UserProfile.query.filter_by(user_id=current_user.id).first() + if not profile: + return jsonify({"message": "User not found"}), 404 + + return jsonify({ + "user_id": profile.user_id, + "name": profile.name, + "account": profile.account, + "birthDate": profile.birthDate, + "gender": profile.gender, + }), 200 @api.route('', methods=['POST']) +@login_required +@csrf.exempt def update_profile(): - data = request.get_json() # Get the JSON data sent from the frontend + data = request.get_json(silent=True) or {} # Get the JSON data sent from the frontend + errors = {} + + if "name" in data: + if not isinstance(data["name"], str) or not data["name"].strip(): + errors["name"] = "must be a non-empty string" + + if "account" in data: + if not isinstance(data["account"], str) or not data["account"].strip(): + errors["account"] = "must be a non-empty string" + + if "birthDate" in data: + if not isinstance(data["birthDate"], str) or not DATE_RX.match(data["birthDate"]): + errors["birthDate"] = "use YYYY-MM-DD" + + if "gender" in data: + if not isinstance(data["gender"], str): + errors["gender"] = "must be a string" - # In future develpment get the user_id from a session or token - user_id = 1 # Replace with authenticated user ID - user = UserProfile.query.filter_by(id=user_id).first() + if errors: + return jsonify({"error": "validation_error", "fields": errors}), 422 - if not user: - return jsonify({'message': 'User not found'}), 404 + profile = UserProfile.query.filter_by(user_id=current_user.id).first() + if not profile: + return jsonify({"message": "User not found"}), 404 - # Update the user's profile with the new data - user.name = data['name'] - user.account = data['account'] - user.birthDate = data['birthDate'] - user.gender = data['gender'] - user.avatar = data.get('avatar', user.avatar) # Only update avatar if provided + # Only update fields that were provided + if "name" in data: + profile.name = data["name"].strip() + if "account" in data: + profile.account = data["account"].strip() + if "birthDate" in data: + profile.birthDate = data["birthDate"] + if "gender" in data: + profile.gender = data["gender"] db.session.commit() # Save changes to the database - return jsonify({'message': 'Profile updated successfully'}), 200 \ No newline at end of file + return jsonify({'message': "Profile Updated Successfully", "user_id": profile.user_id}), 200 \ No newline at end of file diff --git a/api/routes.py b/api/routes.py index 0979394..12b5790 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1,8 +1,27 @@ -# /api/routes.py -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, request +from flask_login import login_required +import time api = Blueprint('api', __name__) @api.route('/test', methods=['GET']) def test_endpoint(): return jsonify({"message": "Hello, API!"}) + + +@api.route("/time-sync", methods=["GET", "POST"]) +@login_required # keep if you want it behind login +def time_sync(): + # GET: simple “what time is it on the server?” + if request.method == "GET": + return jsonify({"server_time": int(time.time())}), 200 + + # POST: minimal schema check (adjust later as your design evolves) + body = request.get_json(silent=True) or {} + device_id = body.get("device_id") + readings = body.get("readings") + if not isinstance(device_id, str) or not isinstance(readings, list): + return jsonify({"error": "invalid request"}), 400 + + # TODO: store/process readings here if needed + return jsonify({"server_time": int(time.time())}), 200 \ No newline at end of file diff --git a/app.py b/app.py index f9f3767..0185c8f 100644 --- a/app.py +++ b/app.py @@ -1,108 +1,111 @@ -from flask import Flask, jsonify, session, render_template, request, redirect +import os +from flask import Flask, jsonify, session, render_template, redirect, url_for from flask_cors import CORS +from flask_login import login_required, current_user +from dotenv import load_dotenv +from flask_wtf.csrf import CSRFError + +# Local imports +from models import UserCredential +from extensions import db, login_manager, migrate, limiter, csrf from api.routes import api from api.goals import goals_bp from api.profile import api as profile_api from api.dashboard import dashboard_bp -from models import db -from dotenv import load_dotenv -import os -import pyrebase +from auth.routes import auth_bp # Import scripts here from scripts.add_default_user import add_default_user + # Load environment variables from .env file load_dotenv() -app = Flask(__name__) -CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}}) - -# Firebase configuration -config = { - 'apiKey': os.getenv('FIREBASE_API_KEY'), - 'authDomain': os.getenv('FIREBASE_AUTH_DOMAIN'), - 'projectId': os.getenv('FIREBASE_PROJECT_ID'), - 'storageBucket': os.getenv('FIREBASE_STORAGE_BUCKET'), - 'messagingSenderId': os.getenv('FIREBASE_MESSAGING_SENDER_ID'), - 'appId': os.getenv('FIREBASE_APP_ID'), - 'measurementId': os.getenv('FIREBASE_MEASUREMENT_ID'), - 'databaseURL': os.getenv('FIREBASE_DATABASE_URL') -} - -firebase = pyrebase.initialize_app(config) -auth = firebase.auth() - -# Flask config -app.secret_key = os.getenv("SECRET_KEY", "default_secret_key") -app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///goals.db") -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -# Initialize database -db.init_app(app) -with app.app_context(): - db.create_all() - - # Call function to create the default user - add_default_user() - -# Register Blueprints -app.register_blueprint(api, url_prefix='/api') -app.register_blueprint(goals_bp, url_prefix='/api/goals') -app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') -app.register_blueprint(profile_api, url_prefix='/api/profile') - -# Main index route (login + welcome) -@app.route('/', methods=['GET', 'POST']) -def index(): - error = None - if request.method == 'POST': - email = request.form.get('email') - password = request.form.get('password') - try: - user = auth.sign_in_with_email_and_password(email, password) - session['user'] = email - return redirect('/home') - except: - error = "Login failed. Please check your credentials." - - return render_template('index.html', user=session.get('user'), error=error) - -# Signup route -@app.route('/signup', methods=['GET', 'POST']) -def signup(): - error = None - if request.method == 'POST': - email = request.form.get('email') - password = request.form.get('password') - try: - auth.create_user_with_email_and_password(email, password) - session['user'] = email - return redirect('/home') - except Exception as e: - error = "Signup failed. " + str(e).split("]")[-1].strip().strip('"') - - return render_template('signup.html', error=error) - -# Logout route -@app.route('/logout') -def logout(): - session.pop('user', None) - return redirect('/') - -@app.route('/home') -def home(): - if 'user' in session: - return render_template('home.html', user=session['user']) - return redirect('/') - -# Example API route -@app.route('/api/hello', methods=['GET']) -def hello(): - return jsonify({'message': 'Hello from Flask!'}), 200 +login_manager.login_view = "auth.login" + +@login_manager.unauthorized_handler +def unauthorized(): + return redirect(url_for('auth.login')) + +@login_manager.user_loader +def load_user(user_id: str): + try: + return db.session.get(UserCredential, int(user_id)) + except (TypeError, ValueError): + return None + +def create_app(): + app = Flask(__name__) + + # Flask config (Core) + app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret") + app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///goals.db") + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config["WTF_CSRF_ENABLED"] = True + + # Security settings for cookies + app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=False, + REMEMBER_COOKIE_HTTPONLY=True, + REMEMBER_COOKIE_SECURE=False, + ) + + + # Initialize database + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + if limiter: + limiter.init_app(app) + csrf.init_app(app) + CORS( + app, + resources={r"/api/*": {"origins": os.getenv("CORS_ORIGINS", "http://localhost:5173")}}, + supports_credentials=True, + ) + + app.cli.add_command(add_default_user) + + # Register Blueprints + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(api, url_prefix='/api') + app.register_blueprint(goals_bp, url_prefix='/api/goals') + app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') + app.register_blueprint(profile_api, url_prefix='/api/profile') + + @app.errorhandler(CSRFError) + def handle_csrf_error(e): + return f"CSRF failed: {e.description}", 400 + + # Routes + @app.route('/') + def index(): + # send to login if not logged in + return redirect(url_for('auth.login')) + + @app.route('/home') + @login_required + def home(): + return render_template('home.html', user=current_user) + + # Example API route + @app.route('/api/hello', methods=['GET']) + def hello(): + return jsonify({'message': 'Hello from Flask!'}), 200 + + # Create database tables if they don't exist + with app.app_context(): + db.create_all() + + return app + if __name__ == '__main__': - debug_mode = os.getenv("FLASK_DEBUG", "False").lower() == "true" - app.run(debug=debug_mode, port=int(os.getenv("PORT", 5000))) + app = create_app() + debug = os.environ.get("FLASK_DEBUG") == "1" + port = int(os.getenv("PORT", 5000)) + app.run(debug=debug, port=port) diff --git a/auth/routes.py b/auth/routes.py new file mode 100644 index 0000000..7dd3ae8 --- /dev/null +++ b/auth/routes.py @@ -0,0 +1,110 @@ +from flask import Blueprint, render_template, request, redirect, url_for +from flask_login import login_user, logout_user, login_required +from models import UserCredential, UserProfile +from urllib.parse import urlparse, urljoin +from extensions import db, limiter +from sqlalchemy.exc import IntegrityError + +auth_bp = Blueprint('auth', __name__, template_folder='templates') + +def validate_password(pwd: str): + if len(pwd) < 8: + raise ValueError("Password must be at least 8 characters long.") + if pwd.isdigit() or pwd.isalpha(): + raise ValueError("Password must contain both letters and numbers.") + return True + +def is_safe_url(target: str) -> bool: + if not target: + return False + ref = urlparse(request.host_url) + test = urlparse(urljoin(request.host_url, target)) + return test.scheme in ('http', 'https') and ref.netloc == test.netloc + +@auth_bp.route('/signup', methods=['GET', 'POST']) +def signup(): + error = None + if request.method == 'POST': + name = request.form.get('name').strip() + email = request.form.get('email').strip().lower() + password = request.form.get('password') + + try: + # Validate inputs + if not name or not email or not password: + raise ValueError("Name, email and password are required.") + validate_password(password) + + # Check if email already exists + if UserCredential.query.filter_by(email=email).first(): + error = "signup failed. email already registered." + return render_template('signup.html', error=error), 400 + + + # Create user credentials + new_user = UserCredential(email=email) + new_user.set_password(password) + db.session.add(new_user) + db.session.flush() # Ensure new_user.id is available + + + profile = UserProfile( + user_id=new_user.id, + name=name, + account=email, + birthDate="---", #placeholder + gender="---", #placeholder + ) + db.session.add(profile) + db.session.commit() + + # Auto-login after signup + login_user(new_user) + return redirect(url_for("home")) + + except ValueError as e: + db.session.rollback() + error = "Signup failed. " + str(e) + return render_template('signup.html', error=error), 400 + + except IntegrityError: + db.session.rollback() + error = "signup failed. email already registered." + return render_template('signup.html', error=error), 400 + + except Exception as e: + db.session.rollback() + error = "Signup failed. "+ str(e) + return render_template('signup.html', error=error), 400 + + return render_template('signup.html', error=error) + +@auth_bp.route('/login', methods=['GET', 'POST']) +@limiter.limit("5 per minute", methods=['POST'], per_method=True) # Rate limiting to prevent brute-force attacks +def login(): + error = None + if request.method == 'POST': + email = request.form.get('email').strip().lower() + password = request.form.get('password') + next_page = request.args.get('next') or request.form.get('next') + remember = bool(request.form.get('remember')) + + # Loopkup user + user = UserCredential.query.filter_by(email=email).first() + + #Auth check + if user and user.check_password(password): + login_user(user, remember=remember) + dest = next_page if is_safe_url(next_page) else url_for("home") + return redirect(dest) + else: + return "Login failed. Incorrect email or password.", 400 + + return render_template('login.html', error=None) + +@auth_bp.route('/logout', methods=['GET']) +@login_required +def logout(): + logout_user() + return redirect(url_for('auth.login')) + diff --git a/docs/api-auth.md b/docs/api-auth.md new file mode 100644 index 0000000..7a8b5d8 --- /dev/null +++ b/docs/api-auth.md @@ -0,0 +1,132 @@ +# Authentication API +This document describes the **login** and **logout** endpoints implemented in the `auth` blueprint. + +Authentication uses **Flask-Login** session cookies. +- Login expects **form POST**. +- Successful login redirects (302). +- Failed login returns **400** with a plain-text error message. +- Logout requires an active session and always redirects back to the login page. + +Base path: `/auth` + +--- + +## Password Policy +To ensure account security, all user must meet the following requirements: +- Minimum length: 8 character +- Must include atleast: one letter (a-z) and one number (0-9) + +### Validation Error Messages +- Too short -> Password must be at least 8 characters +- Missing complexity -> Password must contain both letters and numbers. + +## POST `/auth/login` + +Log a user in. + +### Request +- **Body fields** + - `email` *(string, required)* – user email + - `password` *(string, required)* + - `remember` *(optional, any truthy value)* – set a long-lived session + - `next` *(optional)* – relative path to redirect after login (validated for safety) + +### Responses +- **302**: Redirect to: + - `next` (if provided & safe), otherwise `/home` +- **400**: Plain text error + +### Rate limiting +- Limited to **5 POST requests per minute** per IP (`flask-limiter`). + +### Examples + + **Success (redirect to /home)** + ```bash + curl -i -X POST http://localhost:5000/auth/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "email=test@example.com&password=Password123&remember=1" + + curl -i -X POST "http://localhost:5000/auth/login?next=/dashboard" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "email=test@example.com&password=Password123" + + + curl -i -X POST http://localhost:5000/auth/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "email=test@example.com&password=wrong" + # HTTP/1.1 400 BAD REQUEST + # body: Login failed. Incorrect email or password. + +## GET `/auth/logout` + +Logs out the current user (if logged in) and redirects to login + +### Auth +- Requires an active session (`@login_required`) +- If not logged in, user is redirected to `\auth\login` + +### Responses +- **302**: Redirect to `/auth/login` + +### Examples + curl -i http://localhost:5000/auth/logout + +## POST `/auth/logout` + +Same as GET, but uses POST (sometimes preferred fro CSRF-protected UIs) + +### Responses +- **302**: Redirect to `/auth/login` +- If not logged in -> **302** to `/auth/login` + +### Examples + curl -i -X POST http://localhost:5000/auth/logout + +## GET `/auth/signup` + +Render the signup page (HTML) + +### Responses +- **200** OK: return HTML signup form + +## POST `/auth/signup` +Create a new account (UserCredential + UserProfile) + +### Request +- Content-Type: application/x-www-form-urlencoded +- Body fields + - name (string, required) + - email (string, required, unique) + - password (string, required, must meet Password Policy) + +### Responses +- **302**: Redirect to `/home` after successfull signup and auto-login +- **400**: Validation error (missing fields, weak password, etc.) +- **409**: Duplicate email + +### Examples + **Success (redirect to /home)** + ```bash + curl -i -X POST http://localhost:5000/auth/signup \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "name=Test User&email=new@example.com&password=Password123!" + + curl -i -X POST http://localhost:5000/auth/signup \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "name=Test User&email=test@example.com&password=Password123!" + # HTTP/1.1 409 CONFLICT + # body: Signup failed. Email already registered. + +## Error Reference +| Endpoint | Scenario | Status | Behavior / Message | +| -------------- | ------------------------------ | ------ | -------------------------------------------- | +| `/auth/login` | Missing or wrong password | 400 | `Login failed. Incorrect email or password.` | +| `/auth/login` | Success no `next` | 302 | Redirect to `/home` | +| `/auth/login` | Success with safe `next` | 302 | Redirect to that path | +| `/auth/logout` | Not logged in | 302 | Redirect to `/auth/login` | +| `/auth/logout` | Success | 302 | Redirect to `/auth/login` | +| `/auth/signup` | Success | 302 | Redirect to `/home` | +| `/auth/signup` | Duplicate email | 409 | `Signup failed. Email already registered.` | +| `/auth/signup` | Weak password / missing fields | 400 | Error message (e.g. "Password too short") | + diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..ffa3992 --- /dev/null +++ b/extensions.py @@ -0,0 +1,18 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_wtf import CSRFProtect +import os + + +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() +csrf = CSRFProtect() +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200 per hour"], + storage_uri=os.getenv("RATELIMIT_STORAGE_URL", "memory://"), +) \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/83ce6751bfcd_user_profile_make_gender_nullable_with_.py b/migrations/versions/83ce6751bfcd_user_profile_make_gender_nullable_with_.py new file mode 100644 index 0000000..b2ceddb --- /dev/null +++ b/migrations/versions/83ce6751bfcd_user_profile_make_gender_nullable_with_.py @@ -0,0 +1,36 @@ +"""user_profile make gender nullable with default + +Revision ID: 83ce6751bfcd +Revises: +Create Date: 2025-08-27 15:11:21.146287 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '83ce6751bfcd' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.alter_column('gender', + existing_type=sa.VARCHAR(length=10), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_profile', schema=None) as batch_op: + batch_op.alter_column('gender', + existing_type=sa.VARCHAR(length=10), + nullable=False) + + # ### end Alembic commands ### diff --git a/models/__init__.py b/models/__init__.py index 589c64f..2d472d7 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,2 +1,8 @@ from flask_sqlalchemy import SQLAlchemy -db = SQLAlchemy() \ No newline at end of file +from extensions import db + + +from .user_credential import UserCredential +from .user_profile import UserProfile +from .goal import Goal + diff --git a/models/user_credential.py b/models/user_credential.py new file mode 100644 index 0000000..73374ff --- /dev/null +++ b/models/user_credential.py @@ -0,0 +1,23 @@ +from extensions import db +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import UserMixin + +class UserCredential(db.Model, UserMixin): + __tablename__="user_credential" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), index=True, nullable=False, unique=True) + password_hash = db.Column(db.String(255), nullable=False) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at =db.Column(db.DateTime, server_default=db.func.now(), nullable=False) + + profile = db.relationship("UserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") + + def set_password(self, raw_password: str): + self.password_hash = generate_password_hash(raw_password) + + def check_password(self, raw_password: str) -> bool: + return check_password_hash(self.password_hash, raw_password) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/models/user.py b/models/user_profile.py similarity index 66% rename from models/user.py rename to models/user_profile.py index abb7d30..d55064e 100644 --- a/models/user.py +++ b/models/user_profile.py @@ -1,15 +1,20 @@ -from models import db +from extensions import db class UserProfile(db.Model): __tablename__ = 'user_profile' id = db.Column(db.Integer, primary_key=True) + # Link to UserCredential + user_id = db.Column(db.Integer, db.ForeignKey("user_credential.id"), nullable=False, unique=True) + user = db.relationship("UserCredential", back_populates="profile") + name = db.Column(db.String(100), nullable=False) account = db.Column(db.String(100), unique=True, nullable=False) birthDate = db.Column(db.String(10), nullable=False) - gender = db.Column(db.String(10), nullable=False) + gender = db.Column(db.String(10), nullable=True, server_default="---") avatar = db.Column(db.String(200), nullable=True) + def as_dict(self): return { "id": self.id, diff --git a/requirements.txt b/requirements.txt index 96b5b35..2974687 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/scripts/add_default_user.py b/scripts/add_default_user.py index 6fc9cc8..ca345e0 100644 --- a/scripts/add_default_user.py +++ b/scripts/add_default_user.py @@ -1,18 +1,43 @@ -from models import db -from models.user import UserProfile +from models import db, UserCredential, UserProfile +from flask.cli import with_appcontext +import click -def add_default_user(): - # Only add a default user if the user_profile table is completely empty - if UserProfile.query.first() is None: - default_user = UserProfile( - name='Austin Blaze', - account='redback.operations@deakin.edu.au', - birthDate='2000-01-01', - gender='Male', - avatar='src/assets/ProfilePic.png' +def ensure_default_user(): + + email = 'redback.operations@deakin.edu.au' + name = 'Austin Blaze' + password = 'Redback2024' + birthDate = '2000-01-01' + gender = "Male" + avatar='src/assets/ProfilePic.png' + + # 1) Ensure credential exists + user = UserCredential.query.filter_by(email=email.strip().lower()).first() + if not user: + user = UserCredential(email=email.strip().lower()) + user.set_password(password) + db.session.add(user) + db.session.flush() + + # 2) Ensure profile exists and is linked + profile = UserProfile.query.filter_by(user_id=user.id).first() + if not profile: + profile = UserProfile( + user_id=user.id, + name=name, + account=email, + birthDate=birthDate, + gender=gender, + avatar=avatar, ) - db.session.add(default_user) - db.session.commit() - print("Default user added because user table was empty.") - else: - print("User table is not empty. Default user not added.") + db.session.add(profile) + + db.session.commit() + return email + + +@click.command('add-default-user') +@with_appcontext +def add_default_user(): + email = ensure_default_user() + click.echo(f"Default user ensured with email: {email}") diff --git a/scripts/cli_backfill.py b/scripts/cli_backfill.py new file mode 100644 index 0000000..fd79b26 --- /dev/null +++ b/scripts/cli_backfill.py @@ -0,0 +1,21 @@ +import click +from flask.cli import with_appcontext +from models import db, UserCredential, UserProfile + +@click.command("backfill-user-links") +@with_appcontext +def backfill_user_links(): + count = 0 + for p in UserProfile.query.filter(UserProfile.user_id.is_(None)).all(): + email = (p.account or "").lower().strip() + if not email: + continue + u = UserCredential.query.filter_by(email=email).first() + if not u: + u = UserCredential(email=email, firebase_uid=f"pending:{email}") + db.session.add(u) + db.session.flush() + p.user_id = u.id + count += 1 + db.session.commit() + click.echo(f"Linked/created {count} profiles.") \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..560f11c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,35 @@ + + + + + {% block title %}Flask Auth App{% endblock %} + + + + + +
+

Flask Auth Demo

+ {% if current_user.is_authenticated %} +

Logged in as {{ current_user.email }}

+ {% endif %} +
+ +
+ {% block content %}{% endblock %} +
+ + + diff --git a/templates/home.html b/templates/home.html index c7acb54..d565302 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,18 +1,15 @@ - - - - - Home - - - -

Welcome, {{ user }}!

-

You have successfully logged in.

- Logout - - \ No newline at end of file +{% extends "base.html" %} + +{% block title %}Home{% endblock %} + +{% block content %} +
+ {% if current_user.profile %} +

Welcome, {{ current_user.profile.name }}!

+ {% else %} +

Welcome, {{ current_user.email }}!

+ {% endif %} + + Logout +
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d77e16d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+

Login

+
+ + + + + + + +
+ + {% if error %} +

{{ error }}

+ {% endif %} + +

Don’t have an account? Sign up

+
+{% endblock %} diff --git a/templates/signup.html b/templates/signup.html index 4d42d6f..5c8e62f 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -1,21 +1,24 @@ - - - - Signup - - -

Create an Account

+{% extends "base.html" %} - {% if error %} -

{{ error }}

- {% endif %} +{% block title %}Sign Up{% endblock %} -
- Email:

- Password:

- -
+{% block content %} +
+

Sign Up

+
+ + + + + + +
+ + {% if error %} +

{{ error }}

+ {% endif %} + +

Already have an account? Log in

+
+{% endblock %} -

Already have an account? Log in here

- - diff --git a/test_vul.py b/test_vul.py index be74eab..305b0e8 100644 --- a/test_vul.py +++ b/test_vul.py @@ -1,31 +1,28 @@ import os -import flask -from flask import Flask, request +from flask import Flask, request, render_template_string +from werkzeug.security import check_password_hash import sqlite3 app = Flask(__name__) -# Hardcoded secret (should be flagged) -SECRET_KEY = "my_hardcoded_secret_key_12345" +# Load secret from environment +app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-only-not-for-prod") -# Vulnerable SQL query (should be flagged) @app.route('/login', methods=['POST']) def login(): username = request.form['username'] password = request.form['password'] - query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" - conn = sqlite3.connect('users.db') - cursor = conn.cursor() - cursor.execute(query) - user = cursor.fetchone() - return "Logged in" if user else "Login failed" -# XSS-like template rendering with unescaped input (should be flagged) -@app.route('/welcome') + with sqlite3.connect("users.db") as conn: + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE username = ?", (username,)) + row = cursor.fetchone() + + return "Logged in" if (row and check_password_hash(row[0], password)) else "Login failed" + def welcome(): - user_input = request.args.get('name') - return flask.render_template_string("

Welcome " + user_input + "

") + user_input = request.args.get('name', '') + return render_template_string("

Welcome " + user_input + "

") -# Debug mode enabled (should be flagged) if __name__ == "__main__": - app.run(debug=True) + app.run() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aead5a8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +import os, sys +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from app import create_app +from models import db + + +@pytest.fixture() +def app(): + os.environ["SECRET_KEY"] = "test" + app = create_app() + app.config.update( + TESTING = True, + SQLALCHEMY_DATABASE_URI = ":memory:", + SQLALCHEMY_TRACK_MODIFICATIONS = False, + WTF_CSRF_ENABLED = False, # Disable CSRF for testing + SERVER_NAME = "localhost.localdomain", # Needed for url_for() during tests + RATE_LIMIT_ENABLED = False, # Disable rate limiting for tests + RATELIMIT_STORAGE_URL = "memory://", # Use in-memory storage for rate limiting + SESSION_COOKIE_SECURE = None, # Disable secure cookies for testing + ) + with app.app_context(): + db.drop_all() + db.create_all() + yield app + + +@pytest.fixture() +def client(app): + return app.test_client() \ No newline at end of file diff --git a/tests/test_activity.py b/tests/test_activity.py new file mode 100644 index 0000000..4b0d20e --- /dev/null +++ b/tests/test_activity.py @@ -0,0 +1,52 @@ +import pytest + +def _signup_and_login(client, email="activity@example.com"): + client.post("/auth/signup", data={ + "name": "Act User", + "email": email, + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", + }) + client.post("/auth/login", + data={"email": email, "password": "StrongPass123!"}, + follow_redirects=True) + +ACTIVITY_PATH = "/api/dashboard/activity" + +def _route_exists(app, path): + with app.app_context(): + return any(r.rule == path for r in app.url_map.iter_rules()) + +def test_post_activity_valid(client, app): + if not _route_exists(app, ACTIVITY_PATH): + pytest.skip(f"Activity endpoint not implemented yet: expected {ACTIVITY_PATH}") + + _signup_and_login(client) + + payload = { + "date": "2025-01-02", # YYYY-MM-DD + "steps": 6200, + "minutes_running": 20, + "minutes_cycling": 0, + "minutes_swimming": 0, + "minutes_exercise": 30, + "calories": 450 + } + r = client.post(ACTIVITY_PATH, json=payload) + assert r.status_code in (200, 201), f"{r.status_code} {r.data!r}" + data = r.get_json() or {} + # minimal expectations + assert data.get("date") == "2025-01-02" + assert data.get("steps") == 6200 + +def test_post_activity_missing_field(client, app): + if not _route_exists(app, ACTIVITY_PATH): + pytest.skip(f"Activity endpoint not implemented yet: expected {ACTIVITY_PATH}") + + _signup_and_login(client) + + bad_payload = { # missing 'date' + "steps": 3000 + } + r = client.post(ACTIVITY_PATH, json=bad_payload) + assert r.status_code in (400, 422), f"{r.status_code} {r.data!r}" diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py new file mode 100644 index 0000000..4848ece --- /dev/null +++ b/tests/test_auth_flow.py @@ -0,0 +1,57 @@ +from models import db, UserCredential, UserProfile + +def test_login_wrong_password(client, app): + with app.app_context(): + u = UserCredential(email="x@y.com") + u.set_password("Password123") + db.session.add(u) + db.session.flush() + db.session.add(UserProfile(user_id=u.id, name="Test User", account="x@y", birthDate="2000-01-01")) + db.session.commit() + + res = client.post('/auth/login', data={"email": "x@y.com", "password": "badpw"}, follow_redirects=False) + assert res.status_code == 400 + assert b'Login failed. Incorrect email or password.' in res.data + +def test_logout_requires_login(client): + res = client.get('/auth/logout') + assert res.status_code == 302 # Redirect to login + +def test_next_param_is_safe(client): + response = client.get("/auth/login?next=/home") + assert response.status_code == 200 + +def _post_signup(client, payload): + # Try plain /signup first; if your blueprint is prefixed, fall back to /auth/signup + resp = client.post("/signup", data=payload, follow_redirects=False) + if resp.status_code == 404: + resp = client.post("/auth/signup", data=payload, follow_redirects=False) + return resp + +def _post_login(client, payload): + # Try plain /login; fall back to /auth/login + resp = client.post("/login", data=payload, follow_redirects=False) + if resp.status_code == 404: + resp = client.post("/auth/login", data=payload, follow_redirects=False) + return resp + +def test_login_success(client): + # 1) create a user via your signup route (send all required fields) + signup_payload = { + "name": "Test User", + "email": "testuser@example.com", + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", # keep if your route validates this + } + s = _post_signup(client, signup_payload) + assert s.status_code in (200, 201, 302) + + # 2) login with those credentials; don't follow redirects so we can read Set-Cookie + login_payload = {"email": "testuser@example.com", "password": "StrongPass123!"} + r = _post_login(client, login_payload) + + assert r.status_code in (200, 302) + # If you use Flask-Login session cookies, this will be set on the redirect response + set_cookie = r.headers.get("Set-Cookie", "") + # If your app uses JWT-in-JSON instead, comment the next line and assert on JSON below + assert "session=" in set_cookie diff --git a/tests/test_goals.py b/tests/test_goals.py new file mode 100644 index 0000000..3b0145b --- /dev/null +++ b/tests/test_goals.py @@ -0,0 +1,211 @@ +def _signup_and_login(client, email="goaluser@example.com"): + client.post("/auth/signup", data={ + "name": "Goal User", + "email": email, + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", + }) + client.post("/auth/login", + data={"email": email, "password": "StrongPass123!"}, + follow_redirects=True) + +def _get_self_profile(client): + for path in ("/api/profile/self", "/api/profile"): + r = client.get(path) + if r.status_code != 404: + return r.get_json() or {} + return {} + +def test_create_goal_success(client): + _signup_and_login(client) + me = _get_self_profile(client) + user_id = me.get("user_id") or me.get("id") + assert user_id is not None + + payload = { + "user_id": user_id, + "start_date": "2025-01-01", + "end_date": "2025-12-31", + "steps": 1000, + } + + resp = client.post("/api/goals/", json=payload, follow_redirects=True) + assert resp.status_code in (200, 201), f"{resp.status_code} {resp.data!r}" + + data = resp.get_json() + assert data is not None + # your model returns an id and the numeric fields + assert "id" in data + assert data["user_id"] == user_id + assert data["start_date"] == "2025-01-01" + assert data["end_date"] == "2025-12-31" + assert data["steps"] == 1000 + + +def test_get_goals_lists_created(client): + _signup_and_login(client) + me = _get_self_profile(client) + user_id = me.get("user_id") or me.get("id") + assert user_id is not None + + # Create a goal first + payload = { + "user_id": user_id, + "start_date": "2025-01-01", + "end_date": "2025-12-31", + "steps": 5000, + } + resp = client.post("/api/goals/", json=payload) + assert resp.status_code in (200, 201) + created = resp.get_json() + assert created is not None + assert created["user_id"] == user_id + + # Now fetch goals list + list_resp = client.get(f"/api/goals/{user_id}") + assert list_resp.status_code == 200 + goals = list_resp.get_json() + assert isinstance(goals, list) + # Ensure the one we just created is in the list + assert any(g["id"] == created["id"] for g in goals) + + +def _signup_and_login_as(client, name, email): + client.post("/auth/signup", data={ + "name": name, + "email": email, + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", + }) + client.post("/auth/login", + data={"email": email, "password": "StrongPass123!"}, + follow_redirects=True) + +def _get_me(client): + """Return current user's profile JSON.""" + for path in ("/api/profile/self", "/api/profile"): + r = client.get(path) + if r.status_code != 404: + return r.get_json() or {} + return {} + +def _create_goal_for_me(client, **extra): + me = _get_me(client) + uid = me.get("user_id") or me.get("id") + assert uid is not None, f"Could not determine user_id from profile: {me}" + payload = { + "user_id": uid, + "start_date": "2025-01-01", + "end_date": "2025-12-31", + "steps": 1000, + **extra, + } + r = client.post("/api/goals/", json=payload) + assert r.status_code in (200, 201), f"{r.status_code} {r.data!r}" + return r.get_json() + +def test_update_goal_only_owner(client): + # User A creates a goal + _signup_and_login_as(client, "Alice", "alice@example.com") + goal = _create_goal_for_me(client) + goal_id = goal["id"] + + # User B logs in and attempts to update Alice's goal + client.get("/auth/logout", follow_redirects=True) + _signup_and_login_as(client, "Bob", "bob@example.com") + + resp = client.put(f"/api/goals/{goal_id}", json={"steps": 7777}) + + # Expect forbidden (best), or 404 if you choose to hide existence + assert resp.status_code in (403, 404) + +def test_owner_can_update_goal(client): + # Owner should be able to update their own goal + _signup_and_login_as(client, "Carol", "carol@example.com") + goal = _create_goal_for_me(client) + goal_id = goal["id"] + + r = client.put(f"/api/goals/{goal_id}", json={"steps": 4321}) + assert r.status_code in (200, 204) + data = r.get_json() or {} + # depending on your update response; many of your routes return goal.as_dict() + if data: + assert data.get("steps") == 4321 + + +def test_get_goals_lists_created(client): + # sign up & login + client.post("/auth/signup", data={ + "name": "Lister", + "email": "lister@example.com", + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", + }) + client.post("/auth/login", + data={"email": "lister@example.com", "password": "StrongPass123!"}, + follow_redirects=True) + + # get my user_id from profile + me = None + for path in ("/api/profile/self", "/api/profile"): + r = client.get(path) + if r.status_code != 404: + me = r.get_json() + break + assert me is not None, "Could not fetch self profile" + user_id = me.get("user_id") or me.get("id") + assert user_id is not None + + # create a goal + create_payload = { + "user_id": user_id, + "start_date": "2025-01-01", + "end_date": "2025-12-31", + "steps": 5000, + } + create_resp = client.post("/api/goals/", json=create_payload) + assert create_resp.status_code in (200, 201), f"{create_resp.status_code} {create_resp.data!r}" + created = create_resp.get_json() + assert created and "id" in created + created_id = created["id"] + + # list goals for this user + list_resp = client.get(f"/api/goals/{user_id}") + assert list_resp.status_code == 200 + goals = list_resp.get_json() + assert isinstance(goals, list) + # the new goal should be in the list + assert any(g.get("id") == created_id for g in goals), f"Created goal {created_id} not found in {goals}" + + +def test_update_goal_validation_errors(client): + _signup_and_login_as(client, "Val", "val@example.com") + goal = _create_goal_for_me(client) + gid = goal["id"] + + # bad date format + r1 = client.put(f"/api/goals/{gid}", json={"start_date": "01-01-2025"}) + assert r1.status_code in (400, 422) + + # negative number + r2 = client.put(f"/api/goals/{gid}", json={"steps": -10}) + assert r2.status_code in (400, 422) + + +def test_delete_goal_only_owner(client): + # Owner creates a goal + _signup_and_login_as(client, "Owner", "owner@example.com") + g = _create_goal_for_me(client) + gid = g["id"] + + # Another user attempts delete + client.get("/auth/logout", follow_redirects=True) + _signup_and_login_as(client, "Intruder", "intruder@example.com") + r = client.delete(f"/api/goals/{gid}") + assert r.status_code in (403, 404) + + # Owner can delete + client.get("/auth/logout", follow_redirects=True) + _signup_and_login_as(client, "Owner", "owner@example.com") + r2 = client.delete(f"/api/goals/{gid}") + assert r2.status_code in (200, 204) \ No newline at end of file diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..e45031a --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,106 @@ +import pytest + +def _signup_and_login(client, email="profileuser@example.com"): + client.post("/auth/signup", data={ + "name": "Profile User", + "email": email, + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", + }) + client.post("/auth/login", data={"email": email, "password": "StrongPass123!"}, follow_redirects=True) + +def _discover_profile_routes(app): + with app.app_context(): + rules = list(app.url_map.iter_rules()) + # All routes mounted under /api/profile + profile_rules = [r for r in rules if str(r.rule).startswith("/api/profile")] + return profile_rules + +def _find_self_get_route(app): + """ + Try common patterns first; else fallback to any GET route under /api/profile + that doesn't look like an admin/list endpoint. + """ + with app.app_context(): + rules = list(app.url_map.iter_rules()) + # Preferred explicit names + preferred = ["/api/profile/self", "/api/profile/me", "/api/profile"] + for p in preferred: + for r in rules: + if r.rule == p and "GET" in r.methods: + return r.rule + # Fallback: first GET-able route under /api/profile (non-collection) + for r in rules: + if str(r.rule).startswith("/api/profile") and "GET" in r.methods: + return r.rule + return None + +def _find_update_route(app): + """ + Look for PUT/PATCH/POST routes under /api/profile that look like update/edit. + """ + with app.app_context(): + rules = list(app.url_map.iter_rules()) + + # Try explicit update-like paths first + keywords = ("update", "edit", "set") + for r in rules: + path = str(r.rule) + if not path.startswith("/api/profile"): + continue + if any(k in path.lower() for k in keywords) and ({"PUT","PATCH","POST"} & set(r.methods)): + return path, next(iter({"PUT","PATCH","POST"} & set(r.methods))) + + # Fallback: PUT/PATCH to /api/profile (common pattern) + for r in rules: + if r.rule == "/api/profile" and ({"PUT","PATCH"} & set(r.methods)): + m = "PUT" if "PUT" in r.methods else "PATCH" + return r.rule, m + + # Last resort: any POST route under /api/profile + for r in rules: + if str(r.rule).startswith("/api/profile") and "POST" in r.methods: + return r.rule, "POST" + + return None, None + +def test_get_profile_self(client, app): + _signup_and_login(client) + target = _find_self_get_route(app) + if not target: + # Print possible routes to help you wire it up + routes = sorted([f"{r.rule} -> {sorted(r.methods)}" for r in _discover_profile_routes(app)]) + pytest.skip(f"No GET route for self profile found under /api/profile. Available: {routes}") + + resp = client.get(target) + assert resp.status_code == 200, f"{target} returned {resp.status_code}" + data = resp.get_json() + assert data is not None, f"{target} did not return JSON" + + # Accept any of these common fields + has_any = any(k in data for k in ("email", "account", "name", "id", "user_id", "pseudoId")) + assert has_any, f"{target} JSON missing expected identity fields: {data.keys()}" + +def test_update_profile_validation(client, app): + _signup_and_login(client, email="updateprofile@example.com") + target, method = _find_update_route(app) + if not target: + routes = sorted([f"{r.rule} -> {sorted(r.methods)}" for r in _discover_profile_routes(app)]) + pytest.skip(f"No update-like route (PUT/PATCH/POST) found under /api/profile. Available: {routes}") + + # Invalid payloads to trigger validation errors (adjust as your schema evolves) + bad_payloads = [ + {"name": ""}, # empty name + {"birthDate": "not-a-date"}, # wrong format + {"gender": 123}, # wrong type + ] + + for body in bad_payloads: + if method == "PUT": + r = client.put(target, json=body) + elif method == "PATCH": + r = client.patch(target, json=body) + else: + r = client.post(target, json=body) + # If CSRF is enforced on POST, you may get 400 — that still indicates rejection. + assert r.status_code in (400, 401, 403, 422), f"{target} ({method}) returned {r.status_code}" diff --git a/tests/test_protected_route_with_token.py b/tests/test_protected_route_with_token.py new file mode 100644 index 0000000..ed96c8d --- /dev/null +++ b/tests/test_protected_route_with_token.py @@ -0,0 +1,54 @@ +def _post_signup(client, payload): + r = client.post("/signup", data=payload, follow_redirects=False) + if r.status_code == 404: + r = client.post("/auth/signup", data=payload, follow_redirects=False) + return r + +def _post_login(client, payload, follow_redirects=False): + r = client.post("/login", data=payload, follow_redirects=follow_redirects) + if r.status_code == 404: + r = client.post("/auth/login", data=payload, follow_redirects=follow_redirects) + return r + +def _get_protected(client): + """ + Try a few likely protected endpoints. + Adjust this list to match your repo if you know the exact route. + """ + candidates = [ + "/api/profile", # likely from your earlier anonymized profile work + "/profile", + "/auth/me", + "/dashboard", + "/home", + ] + last = None + for path in candidates: + last = client.get(path) + if last.status_code != 404: + return last, path + return last, candidates[-1] + +def test_protected_route_with_valid_token(client): + # Sign up and log in (session cookie is stored in client) + client.post("/auth/signup", data={ + "name": "Token User", + "email": "tokenuser@example.com", + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", + }) + + r = client.post("/auth/login", + data={"email": "tokenuser@example.com", "password": "StrongPass123!"}, + follow_redirects=True) + assert r.status_code in (200, 302) + + # Hit a known protected route + resp = client.get("/home") + assert resp.status_code == 200 + + +def test_protected_route_without_token(client): + # Not logged in → should redirect to auth.login or 401/403 (depending on config) + resp = client.get("/home") + assert resp.status_code in (302, 401, 403) diff --git a/tests/test_time_sync.py b/tests/test_time_sync.py new file mode 100644 index 0000000..317fd9e --- /dev/null +++ b/tests/test_time_sync.py @@ -0,0 +1,57 @@ +import time +import pytest + +TIME_SYNC_PATH = "/api/time-sync" # implement this in your API when ready + +def _ensure_logged_in(client): + client.post("/auth/signup", data={ + "name": "Time User", + "email": "timeuser@example.com", + "password": "StrongPass123!", + "confirm_password": "StrongPass123!", + }) + client.post("/auth/login", + data={"email": "timeuser@example.com", "password": "StrongPass123!"}, + follow_redirects=True) + +def test_sync_returns_server_time(client, app): + # Skip cleanly if the route isn't registered yet + with app.app_context(): + paths = {r.rule for r in app.url_map.iter_rules()} + if TIME_SYNC_PATH not in paths: + pytest.skip(f"Time-sync endpoint not implemented yet: expected {TIME_SYNC_PATH}") + + _ensure_logged_in(client) # in case your endpoint is protected + + # Call the endpoint (GET or POST—adjust when you implement it) + resp = client.get(TIME_SYNC_PATH) + if resp.status_code == 405: # method not allowed -> try POST + resp = client.post(TIME_SYNC_PATH, json={}) + + assert resp.status_code in (200, 201) + data = resp.get_json() + assert data is not None and "server_time" in data + + server_time = int(float(data["server_time"])) + now = int(time.time()) + assert abs(server_time - now) < 10 + + +def test_sync_rejects_invalid_request(client, app): + # Skip until the endpoint exists + with app.app_context(): + paths = {r.rule for r in app.url_map.iter_rules()} + if TIME_SYNC_PATH not in paths: + pytest.skip(f"Time-sync endpoint not implemented yet: expected {TIME_SYNC_PATH}") + + _ensure_logged_in(client) + + # Example invalid body; adjust to your schema once implemented + bad_payloads = [ + {}, # missing required fields + {"device_id": ""}, # empty id + {"device_id": "abc", "readings": "not-a-list"}, + ] + for body in bad_payloads: + r = client.post(TIME_SYNC_PATH, json=body) + assert r.status_code in (400, 422) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..5f09f15 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,21 @@ +def test_signup_rejects_short_password(client): + response = client.post('/auth/signup', data={ + 'name': 'Test User', + 'email': 'a@b.com', + 'password': 'short' + }, follow_redirects=True) + assert b'Signup failed. Password must be at least 8 characters long.' in response.data + +def test_duplicate_email(client): + client.post('/auth/signup', data={ + 'name': 'Test User', + 'email': 'b@c.com', + 'password': 'Password123' + }) + + response = client.post('/auth/signup', data={ + 'name': 'Another User', + 'email': 'b@c.com', + 'password': 'Password123' + }, follow_redirects=True) + assert b'signup failed. email already registered.' in response.data.lower() \ No newline at end of file