From 7a9d4b1311e876f126ffc27d1f0b9623782acca7 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 01:20:07 -0700 Subject: [PATCH 01/26] fix: change session ID to be generated from 32 bytes of entropy --- README.md | 7 ++-- src/.env.example | 11 ++++++ ...0f_change_session_id_to_be_32_bytes_of_.py | 38 +++++++++++++++++++ src/auth/tables.py | 5 ++- src/auth/urls.py | 6 +-- src/constants.py | 2 +- 6 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 src/.env.example create mode 100644 src/alembic/versions/060623cf940f_change_session_id_to_be_32_bytes_of_.py diff --git a/README.md b/README.md index d2d0cb5c..02adb11a 100755 --- a/README.md +++ b/README.md @@ -33,11 +33,12 @@ pip install ".[dev, test]" # or: uv sync --all-extras ``` 5. Follow the database setup instructions on the [wiki](https://github.com/CSSS/csss-site-backend/wiki/1.-Local-Setup#database-setup). The recommended way is to do it through Docker, but both should work. -6. You will need to set the following environment variables +6. You will need to set the following environment variables set ```bash -export DB_PORT=5444 # If you're using Docker -export LOCAL=true # Should be true if you're running this locally +DB_PORT=5444 # If you're using Docker +ENVIRONMENT=dev # Set this to `test` if you want to use the test database instead ``` +You can also create a `src/.env` file and set those in there. See `src/.env.example` for more information. ## Important Directories diff --git a/src/.env.example b/src/.env.example new file mode 100644 index 00000000..6b61fd18 --- /dev/null +++ b/src/.env.example @@ -0,0 +1,11 @@ +# Required +# Support `dev`, `prod`, or `test` +ENVIRONMENT=dev +DB_PORT=5444 + +# Authentication +COOKIE_SECURE=false +AUTH_URL=https://cas.sfu.ca/cas/serviceValidate + +# Redirect after authentication +FRONTEND_ORIGIN=http://localhost:8080 diff --git a/src/alembic/versions/060623cf940f_change_session_id_to_be_32_bytes_of_.py b/src/alembic/versions/060623cf940f_change_session_id_to_be_32_bytes_of_.py new file mode 100644 index 00000000..1cc75c83 --- /dev/null +++ b/src/alembic/versions/060623cf940f_change_session_id_to_be_32_bytes_of_.py @@ -0,0 +1,38 @@ +"""change session ID to be 32 bytes of base64 + +Revision ID: 060623cf940f +Revises: f0c99d0db277 +Create Date: 2026-07-01 01:17:28.430756 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from constants import SESSION_ID_LEN + + +# revision identifiers, used by Alembic. +revision: str = '060623cf940f' +down_revision: Union[str, None] = 'f0c99d0db277' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user_session', 'session_id', + existing_type=sa.VARCHAR(length=512), + type_=sa.String(length=SESSION_ID_LEN), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user_session', 'session_id', + existing_type=sa.String(length=SESSION_ID_LEN), + type_=sa.VARCHAR(length=512), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/src/auth/tables.py b/src/auth/tables.py index f553f3a7..8eeee366 100644 --- a/src/auth/tables.py +++ b/src/auth/tables.py @@ -3,6 +3,7 @@ from sqlalchemy import DateTime, ForeignKey, String, Text, func from sqlalchemy.orm import Mapped, mapped_column +from auth.urls import generate_session_id from constants import COMPUTING_ID_LEN, SESSION_ID_LEN from database import Base @@ -22,8 +23,8 @@ class UserSessionDB(Base): issue_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) session_id: Mapped[str] = mapped_column( - String(SESSION_ID_LEN), nullable=False, unique=True - ) # the space needed to store 256 bytes in base64 + String(SESSION_ID_LEN), nullable=False, unique=True, default=generate_session_id + ) # the space needed to store 32 bytes in base64 class SiteUserDB(Base): diff --git a/src/auth/urls.py b/src/auth/urls.py index cb7b622d..8ae50ddf 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -20,8 +20,8 @@ # ex: rsa4096 is 512 bytes -def generate_session_id_b64(num_bytes: int) -> str: - return base64.b64encode(os.urandom(num_bytes)).decode("utf-8") +def generate_session_id() -> str: + return base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=") # ----------------------- # @@ -61,7 +61,7 @@ async def login_user( _logger.info(f"User failed to login, with response {cas_response}") raise HTTPException(status_code=401, detail="authentication error") else: - session_id = generate_session_id_b64(256) + session_id = generate_session_id() computing_id = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:user"] await crud.create_user_session(db_session, session_id, computing_id) diff --git a/src/constants.py b/src/constants.py index 7a8fc546..3cb44155 100644 --- a/src/constants.py +++ b/src/constants.py @@ -12,7 +12,7 @@ CSSS_GUILD_ID = "228761314644852736" ACTIVE_GUILD_ID = W3_GUILD_ID if not IS_PROD else CSSS_GUILD_ID -SESSION_ID_LEN = 512 +SESSION_ID_LEN = 43 # technically a max of 8 digits https://www.sfu.ca/computing/about/support/tips/sfu-userid.html COMPUTING_ID_LEN = 32 COMPUTING_ID_MAX = 8 From 1921ca33dc70e3cd26a1d1081ba6016332777578 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 02:02:23 -0700 Subject: [PATCH 02/26] fix: remove site profile pics --- .../caa26d56d842_remove_site_profile_pics.py | 30 +++++++++++++++++++ src/auth/__init__.py | 1 - src/auth/tables.py | 17 ++--------- 3 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 src/alembic/versions/caa26d56d842_remove_site_profile_pics.py delete mode 100644 src/auth/__init__.py diff --git a/src/alembic/versions/caa26d56d842_remove_site_profile_pics.py b/src/alembic/versions/caa26d56d842_remove_site_profile_pics.py new file mode 100644 index 00000000..50f0b33b --- /dev/null +++ b/src/alembic/versions/caa26d56d842_remove_site_profile_pics.py @@ -0,0 +1,30 @@ +"""remove site profile pics + +Revision ID: caa26d56d842 +Revises: 060623cf940f +Create Date: 2026-07-01 02:01:26.412179 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'caa26d56d842' +down_revision: Union[str, None] = '060623cf940f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('site_user', 'profile_picture_url') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('site_user', sa.Column('profile_picture_url', sa.TEXT(), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/src/auth/__init__.py b/src/auth/__init__.py deleted file mode 100644 index 2bad3942..00000000 --- a/src/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from auth import crud diff --git a/src/auth/tables.py b/src/auth/tables.py index 8eeee366..22d6c124 100644 --- a/src/auth/tables.py +++ b/src/auth/tables.py @@ -1,9 +1,8 @@ from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy.orm import Mapped, mapped_column -from auth.urls import generate_session_id from constants import COMPUTING_ID_LEN, SESSION_ID_LEN from database import Base @@ -23,7 +22,7 @@ class UserSessionDB(Base): issue_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) session_id: Mapped[str] = mapped_column( - String(SESSION_ID_LEN), nullable=False, unique=True, default=generate_session_id + String(SESSION_ID_LEN), nullable=False, unique=True ) # the space needed to store 32 bytes in base64 @@ -40,15 +39,3 @@ class SiteUserDB(Base): # first and last time logged into the CSSS API first_logged_in: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_logged_in: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - - # optional user information for display purposes - profile_picture_url: Mapped[str | None] = mapped_column(Text, nullable=True) - - def serialize(self) -> dict[str, str | int | bool | None]: - res = {"computing_id": self.computing_id, "profile_picture_url": self.profile_picture_url} - if self.first_logged_in is not None: - res["first_logged_in"] = self.first_logged_in.isoformat() - if self.last_logged_in is not None: - res["last_logged_in"] = self.last_logged_in.isoformat() - - return res From 745455120858f5d52345d28a6761fe28e9ad74f5 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 02:27:54 -0700 Subject: [PATCH 03/26] feat: rewrite login * use environment variables to set CAS URL * move database commit to right before the response is sent to ensure sessions aren't made until the last second * only redirect to the admin app URL * change the cookie to use env variables and only accept one domain --- src/auth/urls.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/auth/urls.py b/src/auth/urls.py index 8ae50ddf..81775426 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -4,13 +4,13 @@ import urllib.parse import xmltodict -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse import database from auth import crud from auth.models import LoginBodyParams, SiteUserModel, UpdateUserParams -from constants import DOMAIN, IS_PROD, SAMESITE +from config import settings from utils.shared_models import DetailModel, MessageModel _logger = logging.getLogger(__name__) @@ -19,7 +19,6 @@ # utils -# ex: rsa4096 is 512 bytes def generate_session_id() -> str: return base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=") @@ -50,9 +49,9 @@ async def login_user( request: Request, db_session: database.DBSession, background_tasks: BackgroundTasks, body: LoginBodyParams ): # verify the ticket is valid - service_url = body.service - service = urllib.parse.quote(service_url) - service_validate_url = f"https://cas.sfu.ca/cas/serviceValidate?service={service}&ticket={body.ticket}" + service = urllib.parse.quote(body.service) + ticket = urllib.parse.quote(body.ticket) + service_validate_url = f"{settings.auth_url}?service={service}&ticket={ticket}" client = request.app.state.http_client response = await client.get(service_validate_url) cas_response = xmltodict.parse(response.text) @@ -65,23 +64,23 @@ async def login_user( computing_id = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:user"] await crud.create_user_session(db_session, session_id, computing_id) - await db_session.commit() # clean old sessions after sending the response background_tasks.add_task(crud.task_clean_expired_user_sessions, db_session) - if body.redirect_url: - origin = request.headers.get("origin") - if origin: - response = RedirectResponse(origin + body.redirect_url) - else: - raise HTTPException(status_code=400, detail="bad origin") - else: - response = Response() + if not settings.frontend_origin: + raise HTTPException(status_code=500, detail="authentication error") + response = RedirectResponse(settings.frontend_origin) response.set_cookie( - key="session_id", value=session_id, secure=IS_PROD, httponly=True, samesite=SAMESITE, domain=DOMAIN + key="session_id", + value=session_id, + secure=settings.cookie_secure, + httponly=True, + samesite="strict", + domain=settings.cookie_domain, ) # this overwrites any past, possibly invalid, session_id + await db_session.commit() return response From ba494e92e747ecef4d837eaf3297c8e0e4cb11b2 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 02:46:33 -0700 Subject: [PATCH 04/26] feat: environment variable support --- src/config.py | 10 +++++++++- src/constants.py | 14 +++----------- src/database.py | 9 +++++---- src/main.py | 34 ++++++++++++++-------------------- 4 files changed, 31 insertions(+), 36 deletions(-) diff --git a/src/config.py b/src/config.py index 903d81fe..ded0d64f 100644 --- a/src/config.py +++ b/src/config.py @@ -4,7 +4,15 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") + environment: str + db_port: int | None = None + + cookie_secure: bool + cookie_domain: str | None = None + + frontend_origin: str + auth_url: str | None = None translink_api_key: str | None = None -settings = Settings() +settings = Settings() # pyright: ignore[reportCallIssue] diff --git a/src/constants.py b/src/constants.py index 3cb44155..48e2cd31 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,21 +1,17 @@ -import os from zoneinfo import ZoneInfo # TODO(future): replace new.sfucsss.org with sfucsss.org during migration # TODO(far-future): branch-specific root IP addresses (e.g., devbranch.sfucsss.org) -ENV_LOCAL = os.environ.get("LOCAL") -IS_PROD = True if not ENV_LOCAL or ENV_LOCAL.lower() != "true" else False -GITHUB_ORG_NAME = "CSSS-Test-Organization" if not IS_PROD else "CSSS" +# GITHUB_ORG_NAME = "CSSS-Test-Organization" if not IS_PROD else "CSSS" TZ_INFO = ZoneInfo("America/Vancouver") W3_GUILD_ID = "1260652618875797504" CSSS_GUILD_ID = "228761314644852736" -ACTIVE_GUILD_ID = W3_GUILD_ID if not IS_PROD else CSSS_GUILD_ID +# ACTIVE_GUILD_ID = W3_GUILD_ID if not IS_PROD else CSSS_GUILD_ID SESSION_ID_LEN = 43 # technically a max of 8 digits https://www.sfu.ca/computing/about/support/tips/sfu-userid.html -COMPUTING_ID_LEN = 32 -COMPUTING_ID_MAX = 8 +COMPUTING_ID_LEN = 8 # see https://support.discord.com/hc/en-us/articles/4407571667351-How-to-Find-User-IDs-for-Law-Enforcement#:~:text=Each%20Discord%20user%20is%20assigned,user%20and%20cannot%20be%20changed. # NOTE: the length got updated to 19 in july 2024. See https://www.reddit.com/r/discordapp/comments/ucrp1r/only_3_months_until_discord_ids_hit_19_digits/ @@ -28,7 +24,3 @@ # https://docs.github.com/en/enterprise-server@3.10/admin/identity-and-access-management/iam-configuration-reference/username-considerations-for-external-authentication GITHUB_USERNAME_LEN = 39 - -# COOKIE -SAMESITE = "none" if IS_PROD else "lax" -DOMAIN = ".sfucsss.org" if IS_PROD else None diff --git a/src/database.py b/src/database.py index 54ddc356..7edb57c2 100644 --- a/src/database.py +++ b/src/database.py @@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase +from config import settings + convention = { "ix": "ix_%(column_0_label)s", # index "uq": "uq_%(table_name)s_%(column_0_name)s", # unique @@ -84,11 +86,10 @@ async def session(self) -> AsyncGenerator[AsyncSession]: await session.close() -if os.environ.get("DB_PORT") is not None: +if settings.db_port: # using a remote (or docker) database - db_port = os.environ.get("DB_PORT") - SQLALCHEMY_DATABASE_URL = f"postgresql+asyncpg://localhost:{db_port}/main" - SQLALCHEMY_TEST_DATABASE_URL = f"postgresql+asyncpg://localhost:{db_port}/test" + SQLALCHEMY_DATABASE_URL = f"postgresql+asyncpg://localhost:{settings.db_port}/main" + SQLALCHEMY_TEST_DATABASE_URL = f"postgresql+asyncpg://localhost:{settings.db_port}/test" else: SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg:///main" SQLALCHEMY_TEST_DATABASE_URL = "postgresql+asyncpg:///test" diff --git a/src/main.py b/src/main.py index d7b30145..6f0b2956 100755 --- a/src/main.py +++ b/src/main.py @@ -18,7 +18,7 @@ import officers.urls import permission.urls import translink.urls -from constants import IS_PROD +from config import settings logging.basicConfig(level=logging.DEBUG) database.setup_database() @@ -37,38 +37,32 @@ async def lifespan(app: FastAPI): await database.sessionmanager.close() -# Enable OpenAPI docs only for local development -if not IS_PROD: - print("Running local environment") - origins = [ - "http://localhost:4200", # default Angular - "http://localhost:8080", # for existing applications/sites - ] +# If on production, disable viewing the docs +if settings.environment == "prod": + print("Running production environment") app = FastAPI( lifespan=lifespan, title="CSSS Site Backend", root_path="/api", + docs_url=None, # disables Swagger UI + redoc_url=None, # disables ReDoc + openapi_url=None, # disables OpenAPI schema ) -# if on production, disable viewing the docs +# Enable OpenAPI docs only for local development else: - print("Running production environment") - origins = [ - "https://sfucsss.org", - "https://test.sfucsss.org", - "https://admin.sfucsss.org", - "https://madness.sfucsss.org", - ] + print("Running local environment") app = FastAPI( lifespan=lifespan, title="CSSS Site Backend", root_path="/api", - docs_url=None, # disables Swagger UI - redoc_url=None, # disables ReDoc - openapi_url=None, # disables OpenAPI schema ) app.add_middleware( - CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"] + CORSMiddleware, + allow_origins=[settings.frontend_origin], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) app.include_router(auth.urls.router) From f7d66c6fce341d223b049ff30b1eb0a2d6dbb152 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 03:14:23 -0700 Subject: [PATCH 05/26] fix: make logout idempotent --- src/auth/crud.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index 88f28bb6..bc4505c2 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -50,8 +50,9 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi async def remove_user_session(db_session: AsyncSession, session_id: str): query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id) - user_session = await db_session.scalars(query) - await db_session.delete(user_session.first()) + user_session = await db_session.scalar(query) + if user_session is not None: + await db_session.delete(user_session) async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | None: From cb47eb95a470b09a5a3244f3c059759f3de8dba8 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 03:16:11 -0700 Subject: [PATCH 06/26] refactor: change the variables of create_user_session --- src/auth/crud.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index bc4505c2..2b32c9c1 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -8,6 +8,8 @@ _logger = logging.getLogger(__name__) +SESSION_EXPIRATION = timedelta(hours=12) + async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str): """ @@ -63,9 +65,9 @@ async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | N # remove all out of date user sessions async def task_clean_expired_user_sessions(db_session: AsyncSession): - one_day_ago = datetime.now(UTC) - timedelta(days=0.5) + expiration = datetime.now(UTC) - SESSION_EXPIRATION - query = sqlalchemy.delete(UserSessionDB).where(UserSessionDB.issue_time < one_day_ago) + query = sqlalchemy.delete(UserSessionDB).where(UserSessionDB.issue_time < expiration) await db_session.execute(query) await db_session.commit() From c856202f554139b3fc821a83273c789fb169eae6 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 03:17:06 -0700 Subject: [PATCH 07/26] feat: creating a user session now uses upserts --- src/auth/crud.py | 55 +++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index 2b32c9c1..f445cdfc 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -2,6 +2,7 @@ from datetime import UTC, datetime, timedelta import sqlalchemy +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from auth.tables import SiteUserDB, UserSessionDB @@ -17,37 +18,33 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi Also, adds the new user to the SiteUser table if it's their first time logging in. """ - existing_user_session = await db_session.scalar( - sqlalchemy.select(UserSessionDB).where(UserSessionDB.computing_id == computing_id) + now = datetime.now(UTC) + + # Upsert the site user + # Create a new user if it's their first login... + user_query = insert(SiteUserDB).values( + computing_id=computing_id, + first_logged_in=now, + last_logged_in=now, ) - existing_user = await db_session.scalar( - sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == computing_id) + # ...or just update their "last_logged_in" time + user_query = user_query.on_conflict_do_update( + index_elements=[SiteUserDB.computing_id], set_={"last_logged_in": now} ) - - if existing_user is None: - if existing_user_session is not None: - # log this strange case that shouldn't be possible - _logger.warning(f"User session {session_id} exists for non-existent user {computing_id} ... !") - - # add new user to User table if it's their first time logging in - db_session.add( - SiteUserDB(computing_id=computing_id, first_logged_in=datetime.now(UTC), last_logged_in=datetime.now(UTC)) - ) - - if existing_user_session is not None: - existing_user_session.issue_time = datetime.now(UTC) - existing_user_session.session_id = session_id - if existing_user is not None: - # update the last time the user logged in to now - existing_user.last_logged_in = datetime.now(UTC) - else: - db_session.add( - UserSessionDB( - session_id=session_id, - computing_id=computing_id, - issue_time=datetime.now(UTC), - ) - ) + await db_session.execute(user_query) + + # Upsert the user session + # Create a new session... + session_query = insert(UserSessionDB).values( + session_id=session_id, + computing_id=computing_id, + issue_time=now, + ) + # ...or update their current session + session_query = session_query.on_conflict_do_update( + index_elements=[UserSessionDB.computing_id], set_={"session_id": session_id, "issue_time": now} + ) + await db_session.execute(session_query) async def remove_user_session(db_session: AsyncSession, session_id: str): From 2c8425bc1a8cdc298cbeed4875c0b675c4783afb Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 03:19:18 -0700 Subject: [PATCH 08/26] fix: cookie wouldn't be delete if the domain was set --- src/auth/models.py | 28 +++++++++++++++------------- src/auth/urls.py | 11 +++++++++-- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/auth/models.py b/src/auth/models.py index 15bfd35a..f3647f2a 100644 --- a/src/auth/models.py +++ b/src/auth/models.py @@ -1,26 +1,28 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + +from constants import COMPUTING_ID_LEN, SESSION_ID_LEN class LoginBodyParams(BaseModel): service: str = Field(description="Service URL used for SFU's CAS system") ticket: str = Field(description="Ticket return from SFU's CAS system") - redirect_url: str | None = Field(None, description="Optional redirect URL") -class UpdateUserParams(BaseModel): - profile_picture_url: str +class UserBaseModel(BaseModel): + computing_id: str = Field(..., max_length=COMPUTING_ID_LEN, description="Student's computing ID") + + +class UserSession(UserBaseModel): + model_config = ConfigDict(from_attributes=True) + issue_time: datetime = Field(..., description="Time the session was created") + session_id: str = Field(..., max_length=SESSION_ID_LEN, description="Unique session ID of the user") -class UserSessionModel(BaseModel): - computing_id: str - issue_time: datetime - session_id: str +class SiteUser(UserBaseModel): + model_config = ConfigDict(from_attributes=True) -class SiteUserModel(BaseModel): - computing_id: str - first_logged_in: datetime - last_logged_in: datetime - profile_picture_url: str | None = None + first_logged_in: datetime | None = Field(..., description="Time the user was created") + last_logged_in: datetime | None = Field(..., description="Time the user last logged in") diff --git a/src/auth/urls.py b/src/auth/urls.py index 81775426..2ef11f1b 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -17,6 +17,7 @@ # ----------------------- # # utils +SESSION_ID_KEY = "session_id" def generate_session_id() -> str: @@ -73,7 +74,7 @@ async def login_user( response = RedirectResponse(settings.frontend_origin) response.set_cookie( - key="session_id", + key=SESSION_ID_KEY, value=session_id, secure=settings.cookie_secure, httponly=True, @@ -104,7 +105,13 @@ async def logout_user( response_dict = {"message": "user was not logged in"} response = JSONResponse(response_dict) - response.delete_cookie(key="session_id") + response.delete_cookie( + key=SESSION_ID_KEY, + domain=settings.cookie_domain, + secure=settings.cookie_secure, + httponly=True, + samesite="strict", + ) return response From 831ebe7814a24ac1e3a0b77902c8dd1165bf8c68 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 03:46:35 -0700 Subject: [PATCH 09/26] feat: improve auth login handling * Document the login route as a 303 redirect instead of a string response, and return the redirect with an explicit 303 status code. * Handle missing auth configuration with a 503 before attempting CAS validation * Removed PATCH /auth/user endpoint * Refactor some selects to gets from the database --- src/auth/crud.py | 19 +------ src/auth/urls.py | 110 ++++++++++++++++++--------------------- src/utils/permissions.py | 2 +- 3 files changed, 53 insertions(+), 78 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index f445cdfc..a9000643 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -81,22 +81,5 @@ async def get_site_user(db_session: AsyncSession, session_id: str) -> SiteUserDB async def site_user_exists(db_session: AsyncSession, computing_id: str) -> bool: - user = await db_session.scalar(sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == computing_id)) + user = await db_session.get(SiteUserDB, computing_id) return user is not None - - -# update the optional user info for a given site user (e.g., display name, profile picture, ...) -async def update_site_user(db_session: AsyncSession, session_id: str, profile_picture_url: str) -> bool: - query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id) - user_session = await db_session.scalar(query) - if user_session is None: - return False - - query = ( - sqlalchemy.update(SiteUserDB) - .where(SiteUserDB.computing_id == user_session.computing_id) - .values(profile_picture_url=profile_picture_url) - ) - await db_session.execute(query) - - return True diff --git a/src/auth/urls.py b/src/auth/urls.py index 2ef11f1b..b5d01222 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -2,6 +2,7 @@ import logging import os import urllib.parse +from typing import Any import xmltodict from fastapi import APIRouter, BackgroundTasks, HTTPException, Request @@ -9,7 +10,7 @@ import database from auth import crud -from auth.models import LoginBodyParams, SiteUserModel, UpdateUserParams +from auth.models import LoginBodyParams, SiteUser from config import settings from utils.shared_models import DetailModel, MessageModel @@ -38,10 +39,9 @@ def generate_session_id() -> str: "/login", description="Create a login session.", response_description="Successfully validated with SFU's CAS", - response_model=str, + status_code=303, responses={ - 307: {"description": "Successful validation, with redirect"}, - 400: {"description": "Origin is missing.", "model": DetailModel}, + 303: {"description": "Successful validation, with redirect"}, 401: {"description": "Failed to validate ticket with SFU's CAS", "model": DetailModel}, }, operation_id="login", @@ -52,37 +52,55 @@ async def login_user( # verify the ticket is valid service = urllib.parse.quote(body.service) ticket = urllib.parse.quote(body.ticket) + if not settings.auth_url: + raise HTTPException(status_code=503, detail="authentication error") service_validate_url = f"{settings.auth_url}?service={service}&ticket={ticket}" client = request.app.state.http_client - response = await client.get(service_validate_url) - cas_response = xmltodict.parse(response.text) - if "cas:authenticationFailure" in cas_response["cas:serviceResponse"]: - _logger.info(f"User failed to login, with response {cas_response}") + try: + response = await client.get(service_validate_url) + response.raise_for_status() + cas_response = xmltodict.parse(response.text) + except Exception: + _logger.exception(f"CAS Login failure: service={body.service}") + raise HTTPException(status_code=502, detail="authentication error") from None + + service_response = cas_response.get("cas:serviceResponse") + if not isinstance(service_response, dict): + raise HTTPException(status_code=502, detail="authentication error") + + if "cas:authenticationFailure" in service_response: + _logger.info(f"CAS Login failure: service={body.service}") raise HTTPException(status_code=401, detail="authentication error") - else: - session_id = generate_session_id() - computing_id = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:user"] - - await crud.create_user_session(db_session, session_id, computing_id) - - # clean old sessions after sending the response - background_tasks.add_task(crud.task_clean_expired_user_sessions, db_session) - - if not settings.frontend_origin: - raise HTTPException(status_code=500, detail="authentication error") - - response = RedirectResponse(settings.frontend_origin) - response.set_cookie( - key=SESSION_ID_KEY, - value=session_id, - secure=settings.cookie_secure, - httponly=True, - samesite="strict", - domain=settings.cookie_domain, - ) # this overwrites any past, possibly invalid, session_id - await db_session.commit() - return response + + auth_success = service_response.get("cas:authenticationSuccess") + if not isinstance(auth_success, dict): + raise HTTPException(status_code=502, detail="authentication error") + + session_id = generate_session_id() + computing_id = auth_success.get("cas:user") + if not isinstance(computing_id, str) or not computing_id: + raise HTTPException(status_code=502, detail="authentication error") + + # clean old sessions after sending the response + # TODO: Convert this to a daily CRON job + background_tasks.add_task(crud.task_clean_expired_user_sessions, db_session) + + if not settings.frontend_origin: + raise HTTPException(status_code=500, detail="authentication error") + + response = RedirectResponse(url=settings.frontend_origin, status_code=303) + response.set_cookie( + key=SESSION_ID_KEY, + value=session_id, + secure=settings.cookie_secure, + httponly=True, + samesite="strict", + domain=settings.cookie_domain, + ) # this overwrites any past, possibly invalid, session_id + await crud.create_user_session(db_session, session_id, computing_id) + await db_session.commit() + return response @router.get( @@ -119,7 +137,7 @@ async def logout_user( "/user", operation_id="get_user", description="Get info about the current user. Only accessible by that user", - response_model=SiteUserModel, + response_model=SiteUser, responses={401: {"description": "Not logged in.", "model": DetailModel}}, ) async def get_user( @@ -137,30 +155,4 @@ async def get_user( if user_info is None: raise HTTPException(status_code=401, detail="could not find user with session_id, please log in") - return JSONResponse(user_info.serialize()) - - -# TODO: We should change this so that the admins can change people's pictures too, so they can remove offensive stuff -@router.patch( - "/user", - operation_id="update_user", - description="Update information for the currently logged in user. Only accessible by that user", - response_model=str, - responses={401: {"description": "Not logged in.", "model": DetailModel}}, -) -async def update_user( - body: UpdateUserParams, - request: Request, - db_session: database.DBSession, -): - """ - Returns the info stored in the site_user table in the auth module, if the user is logged in. - """ - session_id = request.cookies.get("session_id") - if session_id is None: - raise HTTPException(status_code=401, detail="user must be authenticated to get their info") - - ok = await crud.update_site_user(db_session, session_id, body.profile_picture_url) - await db_session.commit() - if not ok: - raise HTTPException(status_code=401, detail="could not find user with session_id, please log in") + return user_info diff --git a/src/utils/permissions.py b/src/utils/permissions.py index 37cdd0fa..103e017b 100644 --- a/src/utils/permissions.py +++ b/src/utils/permissions.py @@ -4,7 +4,7 @@ import auth import database -import officers +import officers.crud from officers.constants import OfficerPositionEnum WEBSITE_ADMIN_POSITIONS: list[OfficerPositionEnum] = [ From 871d842a54da793ac45ac6400571fab1fba37e55 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 15:10:41 -0700 Subject: [PATCH 10/26] fix: let client handle post authentication --- README.md | 18 +++++++++--------- src/auth/urls.py | 23 +++++++++++------------ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 02adb11a..15d48074 100755 --- a/README.md +++ b/README.md @@ -45,15 +45,15 @@ You can also create a `src/.env` file and set those in there. See `src/.env.exam - `config/` configuration files for the server machine - `src/` - - `alembic` for database migrations - - `access/` for controlling officer access to the google drive, bitwarden, and github. TODO: discord as well? - - `blog/` for running an editable csss-blog - - `dashboard/` for controlling the server, website, jobs, and access to services & stuff. - - `elections/` for the mangement of current elections & past elections - - `jobs/` for cronjobs that run regularly on the server - - `misc/` for anything that can't be easily categorized or is very small - - `officers/` for officer contact information + photos -- `test/` for html pages which interact with the backend's local api + - `alembic` database migrations + - `auth/` controlling authentication and sessions + - `candidates/` management of those who run in large elections + - `elections/` mangement of current elections & past elections + - `event/` management of events + - `nominees/` management of nominees to large elections + - `officers/` management of officer information and terms + - `translink/` management of TransLink REST API +- `test/` unit and integration tests ## Developer Tools diff --git a/src/auth/urls.py b/src/auth/urls.py index b5d01222..7409c77f 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -2,11 +2,10 @@ import logging import os import urllib.parse -from typing import Any import xmltodict -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request -from fastapi.responses import JSONResponse, RedirectResponse +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response, status +from fastapi.responses import JSONResponse import database from auth import crud @@ -39,7 +38,7 @@ def generate_session_id() -> str: "/login", description="Create a login session.", response_description="Successfully validated with SFU's CAS", - status_code=303, + status_code=status.HTTP_204_NO_CONTENT, responses={ 303: {"description": "Successful validation, with redirect"}, 401: {"description": "Failed to validate ticket with SFU's CAS", "model": DetailModel}, @@ -53,7 +52,7 @@ async def login_user( service = urllib.parse.quote(body.service) ticket = urllib.parse.quote(body.ticket) if not settings.auth_url: - raise HTTPException(status_code=503, detail="authentication error") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentication error") service_validate_url = f"{settings.auth_url}?service={service}&ticket={ticket}" client = request.app.state.http_client @@ -63,33 +62,33 @@ async def login_user( cas_response = xmltodict.parse(response.text) except Exception: _logger.exception(f"CAS Login failure: service={body.service}") - raise HTTPException(status_code=502, detail="authentication error") from None + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentication error") from None service_response = cas_response.get("cas:serviceResponse") if not isinstance(service_response, dict): - raise HTTPException(status_code=502, detail="authentication error") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentication error") if "cas:authenticationFailure" in service_response: _logger.info(f"CAS Login failure: service={body.service}") - raise HTTPException(status_code=401, detail="authentication error") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication error") auth_success = service_response.get("cas:authenticationSuccess") if not isinstance(auth_success, dict): - raise HTTPException(status_code=502, detail="authentication error") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentication error") session_id = generate_session_id() computing_id = auth_success.get("cas:user") if not isinstance(computing_id, str) or not computing_id: - raise HTTPException(status_code=502, detail="authentication error") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentication error") # clean old sessions after sending the response # TODO: Convert this to a daily CRON job background_tasks.add_task(crud.task_clean_expired_user_sessions, db_session) if not settings.frontend_origin: - raise HTTPException(status_code=500, detail="authentication error") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="authentication error") - response = RedirectResponse(url=settings.frontend_origin, status_code=303) + response = Response(status_code=status.HTTP_204_NO_CONTENT) response.set_cookie( key=SESSION_ID_KEY, value=session_id, From 4f6b4a1b9f1b65f1642880d85bf9eee461c83510 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 16:28:33 -0700 Subject: [PATCH 11/26] fix: status codes in auth module --- src/auth/urls.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/auth/urls.py b/src/auth/urls.py index 7409c77f..0405f646 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -40,8 +40,9 @@ def generate_session_id() -> str: response_description="Successfully validated with SFU's CAS", status_code=status.HTTP_204_NO_CONTENT, responses={ - 303: {"description": "Successful validation, with redirect"}, 401: {"description": "Failed to validate ticket with SFU's CAS", "model": DetailModel}, + 502: {"description": "Failed to validate ticket with SFU's CAS", "model": DetailModel}, + 503: {"description": "Authentication not configured", "model": DetailModel}, }, operation_id="login", ) @@ -148,10 +149,12 @@ async def get_user( """ session_id = request.cookies.get("session_id", None) if session_id is None: - raise HTTPException(status_code=401, detail="user must be authenticated to get their info") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="user must be authenticated to get their info" + ) user_info = await crud.get_site_user(db_session, session_id) if user_info is None: - raise HTTPException(status_code=401, detail="could not find user with session_id, please log in") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="could not find user with session_id") return user_info From 9e33d93aafd62a2f36c490cb6aca7ee69bf17200 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 16:34:47 -0700 Subject: [PATCH 12/26] fix: no session provided still deleted the cookie --- src/auth/urls.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auth/urls.py b/src/auth/urls.py index 0405f646..c2d24fb3 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -115,13 +115,12 @@ async def logout_user( ): session_id = request.cookies.get("session_id", None) - if session_id: - await crud.remove_user_session(db_session, session_id) - await db_session.commit() - response_dict = {"message": "logout successful"} - else: + if not session_id: response_dict = {"message": "user was not logged in"} + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session provided") + await crud.remove_user_session(db_session, session_id) + response_dict = {"message": "logout successful"} response = JSONResponse(response_dict) response.delete_cookie( key=SESSION_ID_KEY, @@ -130,6 +129,7 @@ async def logout_user( httponly=True, samesite="strict", ) + await db_session.commit() return response From d7a347368cdb162365857c779ebb6b8968a21141 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Wed, 1 Jul 2026 18:59:23 -0700 Subject: [PATCH 13/26] feat(cookie): add MAX-AGE of 2 hours --- src/auth/constants.py | 5 +++++ src/auth/urls.py | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 src/auth/constants.py diff --git a/src/auth/constants.py b/src/auth/constants.py new file mode 100644 index 00000000..de5b3bfb --- /dev/null +++ b/src/auth/constants.py @@ -0,0 +1,5 @@ +from datetime import timedelta + +COOKIE_SESSION_KEY = "session_id" +COOKIE_MAX_AGE = 60 * 60 * 2 # 2 hours in seconds +SESSION_MAX_AGE = timedelta(seconds=COOKIE_MAX_AGE) diff --git a/src/auth/urls.py b/src/auth/urls.py index c2d24fb3..37c861c7 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -9,6 +9,7 @@ import database from auth import crud +from auth.constants import COOKIE_MAX_AGE, COOKIE_SESSION_KEY from auth.models import LoginBodyParams, SiteUser from config import settings from utils.shared_models import DetailModel, MessageModel @@ -17,10 +18,9 @@ # ----------------------- # # utils -SESSION_ID_KEY = "session_id" -def generate_session_id() -> str: +def _generate_session_id() -> str: return base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=") @@ -77,7 +77,7 @@ async def login_user( if not isinstance(auth_success, dict): raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentication error") - session_id = generate_session_id() + session_id = _generate_session_id() computing_id = auth_success.get("cas:user") if not isinstance(computing_id, str) or not computing_id: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentication error") @@ -91,12 +91,13 @@ async def login_user( response = Response(status_code=status.HTTP_204_NO_CONTENT) response.set_cookie( - key=SESSION_ID_KEY, + key=COOKIE_SESSION_KEY, value=session_id, secure=settings.cookie_secure, httponly=True, samesite="strict", domain=settings.cookie_domain, + max_age=COOKIE_MAX_AGE, ) # this overwrites any past, possibly invalid, session_id await crud.create_user_session(db_session, session_id, computing_id) await db_session.commit() @@ -123,7 +124,7 @@ async def logout_user( response_dict = {"message": "logout successful"} response = JSONResponse(response_dict) response.delete_cookie( - key=SESSION_ID_KEY, + key=COOKIE_SESSION_KEY, domain=settings.cookie_domain, secure=settings.cookie_secure, httponly=True, From 85bf6561f3d8ff33de746b34df0b1cd2ed989338 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 14:58:24 -0700 Subject: [PATCH 14/26] fix: removed unused import --- src/load_test_db.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/load_test_db.py b/src/load_test_db.py index 024d1830..53b80cd0 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -10,7 +10,7 @@ # NOTE: make sure you import from a file in your module which (at least) indirectly contains those # tables, or the current python context will not be able to find them & they won't be loaded -from auth.crud import create_user_session, update_site_user +from auth.crud import create_user_session from candidates.crud import add_candidate from candidates.tables import CandidateDB from database import SQLALCHEMY_TEST_DATABASE_URL, Base, DatabaseSessionManager @@ -72,7 +72,6 @@ async def reset_db(engine): async def load_test_auth_data(db_session: AsyncSession): await create_user_session(db_session, "temp_id_314", "abc314") - await update_site_user(db_session, "temp_id_314", "www.my_profile_picture_url.ca/test") await db_session.commit() From f94114ec4619cfb8c839bfa51bb67ad78c72e361 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 14:59:39 -0700 Subject: [PATCH 15/26] fix: made session expiration consistently 2 hours --- src/auth/crud.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index a9000643..63471d16 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -1,16 +1,15 @@ import logging -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime import sqlalchemy from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession +from auth.constants import SESSION_MAX_AGE from auth.tables import SiteUserDB, UserSessionDB _logger = logging.getLogger(__name__) -SESSION_EXPIRATION = timedelta(hours=12) - async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str): """ @@ -62,7 +61,7 @@ async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | N # remove all out of date user sessions async def task_clean_expired_user_sessions(db_session: AsyncSession): - expiration = datetime.now(UTC) - SESSION_EXPIRATION + expiration = datetime.now(UTC) - SESSION_MAX_AGE query = sqlalchemy.delete(UserSessionDB).where(UserSessionDB.issue_time < expiration) await db_session.execute(query) From ab8ae265e91be1fd87bab1331eb37a666653f8ce Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 15:04:06 -0700 Subject: [PATCH 16/26] feat: remove 401 from logout if user is missing the session cookie --- src/auth/urls.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/auth/urls.py b/src/auth/urls.py index 37c861c7..50d4f102 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -115,13 +115,12 @@ async def logout_user( db_session: database.DBSession, ): session_id = request.cookies.get("session_id", None) + response_dict = {"message": "logout successful"} if not session_id: - response_dict = {"message": "user was not logged in"} - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session provided") + return JSONResponse(response_dict) await crud.remove_user_session(db_session, session_id) - response_dict = {"message": "logout successful"} response = JSONResponse(response_dict) response.delete_cookie( key=COOKIE_SESSION_KEY, From 200a808079339ce85de5603a21a9d5ceadaed39b Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 15:05:27 -0700 Subject: [PATCH 17/26] fix: expired sessions were still usable --- src/auth/crud.py | 20 +++++++++++++++++--- src/dependencies.py | 5 +++-- src/officers/urls.py | 2 +- src/utils/permissions.py | 3 ++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index 63471d16..e9536eeb 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -53,10 +53,23 @@ async def remove_user_session(db_session: AsyncSession, session_id: str): await db_session.delete(user_session) -async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | None: +async def get_session_computing_id(db_session: AsyncSession, session_id: str) -> str | None: + """ + Retrieves the computing ID from a session. + + Args: + db_session: database transaction + session_id: session ID the computing ID is using + + Returns: + The computing ID associated with the session, or None if the session is invalid or expired. + """ query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id) existing_user_session = (await db_session.scalars(query)).first() - return existing_user_session.computing_id if existing_user_session else None + if existing_user_session is None or existing_user_session.issue_time < datetime.now(UTC) - SESSION_MAX_AGE: + return None + + return existing_user_session.computing_id # remove all out of date user sessions @@ -72,7 +85,8 @@ async def task_clean_expired_user_sessions(db_session: AsyncSession): async def get_site_user(db_session: AsyncSession, session_id: str) -> SiteUserDB | None: query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id) user_session = await db_session.scalar(query) - if user_session is None: + + if user_session is None or user_session.issue_time < datetime.now(UTC) - SESSION_MAX_AGE: return None query = sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == user_session.computing_id) diff --git a/src/dependencies.py b/src/dependencies.py index 0f43390e..c5c0c52b 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -3,6 +3,7 @@ from fastapi import Cookie, Depends, HTTPException, status import auth +import auth.crud import database from utils.permissions import is_user_election_admin, is_user_website_admin @@ -11,7 +12,7 @@ async def user(db_session: database.DBSession, session_id: Annotated[str | None, if session_id is None: return None - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + session_computing_id = await auth.crud.get_session_computing_id(db_session, session_id) return session_computing_id @@ -23,7 +24,7 @@ async def logged_in_user(db_session: database.DBSession, session_id: Annotated[s if session_id is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session id") - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + session_computing_id = await auth.crud.get_session_computing_id(db_session, session_id) if session_computing_id is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no computing id") diff --git a/src/officers/urls.py b/src/officers/urls.py index aed1b741..2701c31a 100755 --- a/src/officers/urls.py +++ b/src/officers/urls.py @@ -37,7 +37,7 @@ async def _has_officer_private_info_access( if session_id is None: return False, None - computing_id = await auth.crud.get_computing_id(db_session, session_id) + computing_id = await auth.crud.get_session_computing_id(db_session, session_id) if computing_id is None: return False, None diff --git a/src/utils/permissions.py b/src/utils/permissions.py index 103e017b..7ffe5d47 100644 --- a/src/utils/permissions.py +++ b/src/utils/permissions.py @@ -3,6 +3,7 @@ from fastapi import HTTPException, Request, status import auth +import auth.crud import database import officers.crud from officers.constants import OfficerPositionEnum @@ -54,7 +55,7 @@ async def get_user(request: Request, db_session: database.DBSession) -> tuple[st if session_id is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session id") - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + session_computing_id = await auth.crud.get_session_computing_id(db_session, session_id) if session_computing_id is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no computing id") From 12713abf32f33fb6a4423462c0fe1bdeaa25accc Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 15:20:35 -0700 Subject: [PATCH 18/26] fix: added the missing status code to /auth/login --- src/auth/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth/urls.py b/src/auth/urls.py index 50d4f102..a2bcb30b 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -37,11 +37,11 @@ def _generate_session_id() -> str: @router.post( "/login", description="Create a login session.", - response_description="Successfully validated with SFU's CAS", status_code=status.HTTP_204_NO_CONTENT, responses={ + 204: {"description": "Successfully validated with SFU's CAS"}, 401: {"description": "Failed to validate ticket with SFU's CAS", "model": DetailModel}, - 502: {"description": "Failed to validate ticket with SFU's CAS", "model": DetailModel}, + 502: {"description": "Failed to connect to SFU's CAS", "model": DetailModel}, 503: {"description": "Authentication not configured", "model": DetailModel}, }, operation_id="login", From 84f24cf1cc6ba7dc03a31cedcf9a26851bdda3ac Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 15:22:09 -0700 Subject: [PATCH 19/26] fix: frontend origin removed from /auth/login --- src/auth/urls.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/auth/urls.py b/src/auth/urls.py index a2bcb30b..30b43d80 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -86,9 +86,6 @@ async def login_user( # TODO: Convert this to a daily CRON job background_tasks.add_task(crud.task_clean_expired_user_sessions, db_session) - if not settings.frontend_origin: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="authentication error") - response = Response(status_code=status.HTTP_204_NO_CONTENT) response.set_cookie( key=COOKIE_SESSION_KEY, From 55040ae9a65da2a20353162d7569c4e21bfbfc32 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 15:39:53 -0700 Subject: [PATCH 20/26] chore: move .env to root --- src/.env.example => .env.example | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/.env.example => .env.example (84%) diff --git a/src/.env.example b/.env.example similarity index 84% rename from src/.env.example rename to .env.example index 6b61fd18..02a302c6 100644 --- a/src/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Required -# Support `dev`, `prod`, or `test` +# Supports `dev`, `prod`, or `test` ENVIRONMENT=dev DB_PORT=5444 diff --git a/README.md b/README.md index 15d48074..61e1c54c 100755 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ pip install ".[dev, test]" # or: uv sync --all-extras DB_PORT=5444 # If you're using Docker ENVIRONMENT=dev # Set this to `test` if you want to use the test database instead ``` -You can also create a `src/.env` file and set those in there. See `src/.env.example` for more information. +You can also create a `.env` file and set those in there. See `.env.example` for more information. ## Important Directories From a566d83026699234e6e4e15d7b8bf186d3025228 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 15:42:46 -0700 Subject: [PATCH 21/26] chore: removed some dead code --- src/auth/types.py | 17 ----------------- src/event/urls.py | 5 +---- src/utils/__init__.py | 10 +--------- 3 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 src/auth/types.py diff --git a/src/auth/types.py b/src/auth/types.py deleted file mode 100644 index 5e478e3d..00000000 --- a/src/auth/types.py +++ /dev/null @@ -1,17 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class SiteUserData: - computing_id: str - first_logged_in: str - last_logged_in: str - profile_picture_url: None | str - - def serializable_dict(self): - return { - "computing_id": self.computing_id, - "first_logged_in": self.first_logged_in, - "last_logged_in": self.last_logged_in, - "profile_picture_url": self.profile_picture_url, - } diff --git a/src/event/urls.py b/src/event/urls.py index 76968d69..98214909 100644 --- a/src/event/urls.py +++ b/src/event/urls.py @@ -1,8 +1,5 @@ -from datetime import date, datetime - from fastapi import APIRouter, Depends, HTTPException, status from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse from pydantic import ValidationError import database @@ -10,7 +7,7 @@ from dependencies import perm_admin from event.models import Event, EventCreate, EventDelete, EventUpdate from event.tables import EventDB -from utils.shared_models import DetailModel, SuccessResponse +from utils.shared_models import DetailModel router = APIRouter( prefix="/event", diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 2384cb4b..7adb385a 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,5 +1,5 @@ import re -from datetime import date, datetime +from datetime import date from sqlalchemy import Select @@ -9,14 +9,6 @@ from officers.tables import OfficerTermDB -def is_iso_format(date_str: str) -> bool: - try: - datetime.fromisoformat(date_str) - return True - except ValueError: - return False - - def is_active_officer(query: Select) -> Select: """ An active officer is one who is currently part of the CSSS officer team. From ca55b6b22ac1f4a54241021c0c27c629491dfcbc Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 16:31:19 -0700 Subject: [PATCH 22/26] feat: add auth tests and update pytest config --- pyproject.toml | 18 +++- tests/integration/test_auth.py | 167 +++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_auth.py diff --git a/pyproject.toml b/pyproject.toml index 88fcc07f..9eb1503b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,14 +37,22 @@ Homepage = "https://api.sfucsss.org/" # Pytest: Test framework [tool.pytest.ini_options] pythonpath = ["src"] +testpaths = ["tests"] +norecursedirs = ["tests/wip"] # Don't test these paths +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +asyncio_default_test_loop_scope = "function" log_cli = true log_cli_level = "INFO" -testpaths = [ - "tests", +addopts = [ + "--strict-markers", + "--strict-config", + "--tb=short" +] +markers = [ + "integration: tests that require database or full app setup", + "unit: isolated tests that do not require external services" ] -norecursedirs = "tests/wip" -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" # Ruff: Formatter and linter [tool.ruff] diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 00000000..e4969ed6 --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,167 @@ +from datetime import UTC, datetime +from http import HTTPStatus +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy +from httpx import AsyncClient, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from auth.constants import COOKIE_MAX_AGE, COOKIE_SESSION_KEY, SESSION_MAX_AGE +from auth.crud import create_user_session +from auth.tables import UserSessionDB +from config import settings +from main import app + +pytestmark = pytest.mark.asyncio(loop_scope="session") + +TEST_SERVICE = "http://localhost:8080" +TEST_TICKET = "ST-test-ticket" +TEST_COMPUTING_ID = "auth123" + + +@pytest.fixture(autouse=True) +def auth_settings(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(settings, "auth_url", "https://cas.sfu.ca/cas/serviceValidate") + monkeypatch.setattr(settings, "cookie_secure", False) + monkeypatch.setattr(settings, "cookie_domain", None) + + +def _cas_response(content: str, status_code: int = HTTPStatus.OK) -> Response: + return Response( + status_code=status_code, + text=content, + request=Request("GET", settings.auth_url or "https://cas.example.test/serviceValidate"), + ) + + +def _mock_cas_response(monkeypatch: pytest.MonkeyPatch, content: str, status_code: int = HTTPStatus.OK) -> AsyncMock: + client = AsyncMock(spec=AsyncClient) + client.get = AsyncMock(return_value=_cas_response(content, status_code)) + monkeypatch.setattr(app.state, "http_client", client, raising=False) + return client + + +def _successful_cas_xml(computing_id: str = TEST_COMPUTING_ID) -> str: + return f""" + + + {computing_id} + + + """ + + +def _failed_cas_xml() -> str: + return """ + + + Ticket validation failed + + + """ + + +async def test__login_creates_session_cookie_and_get_user(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + client.cookies.clear() + cas_client = _mock_cas_response(monkeypatch, _successful_cas_xml()) + + response = await client.post("/auth/login", json={"service": TEST_SERVICE, "ticket": TEST_TICKET}) + + assert response.status_code == HTTPStatus.NO_CONTENT + assert response.content == b"" + assert COOKIE_SESSION_KEY in client.cookies + + set_cookie = response.headers["set-cookie"] + assert f"{COOKIE_SESSION_KEY}=" in set_cookie + assert f"Max-Age={COOKIE_MAX_AGE}" in set_cookie + assert "HttpOnly" in set_cookie + assert "SameSite=strict" in set_cookie + + cas_client.get.assert_awaited_once() + + response = await client.get("/auth/user") + + assert response.status_code == HTTPStatus.OK + assert response.json()["computing_id"] == TEST_COMPUTING_ID + assert response.json()["first_logged_in"] is not None + assert response.json()["last_logged_in"] is not None + + +async def test__login_returns_unauthorized_for_failed_cas_ticket(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + client.cookies.clear() + _mock_cas_response(monkeypatch, _failed_cas_xml()) + + response = await client.post("/auth/login", json={"service": TEST_SERVICE, "ticket": TEST_TICKET}) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + assert COOKIE_SESSION_KEY not in client.cookies + + +async def test__login_returns_bad_gateway_for_cas_connection_failure( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +): + client.cookies.clear() + _mock_cas_response(monkeypatch, "CAS unavailable", HTTPStatus.SERVICE_UNAVAILABLE) + + response = await client.post("/auth/login", json={"service": TEST_SERVICE, "ticket": TEST_TICKET}) + + assert response.status_code == HTTPStatus.BAD_GATEWAY + assert COOKIE_SESSION_KEY not in client.cookies + + +async def test__login_returns_service_unavailable_when_auth_is_not_configured( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +): + client.cookies.clear() + cas_client = _mock_cas_response(monkeypatch, _successful_cas_xml()) + monkeypatch.setattr(settings, "auth_url", None) + + response = await client.post("/auth/login", json={"service": TEST_SERVICE, "ticket": TEST_TICKET}) + + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + cas_client.get.assert_not_awaited() + assert COOKIE_SESSION_KEY not in client.cookies + + +async def test__logout_is_idempotent_and_deletes_session_cookie(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + client.cookies.clear() + + response = await client.get("/auth/logout") + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"message": "logout successful"} + + _mock_cas_response(monkeypatch, _successful_cas_xml()) + response = await client.post("/auth/login", json={"service": TEST_SERVICE, "ticket": TEST_TICKET}) + assert response.status_code == HTTPStatus.NO_CONTENT + assert COOKIE_SESSION_KEY in client.cookies + + response = await client.get("/auth/logout") + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"message": "logout successful"} + assert COOKIE_SESSION_KEY not in client.cookies + assert f"{COOKIE_SESSION_KEY}=" in response.headers["set-cookie"] + assert "Max-Age=0" in response.headers["set-cookie"] + + response = await client.get("/auth/user") + assert response.status_code == HTTPStatus.UNAUTHORIZED + + +async def test__expired_session_cannot_get_user(client: AsyncClient, db_session: AsyncSession): + client.cookies.clear() + session_id = "expired-session-id" + computing_id = "expired" + await create_user_session(db_session, session_id, computing_id) + await db_session.execute( + sqlalchemy.update(UserSessionDB) + .where(UserSessionDB.session_id == session_id) + .values(issue_time=datetime.now(UTC) - (2 * SESSION_MAX_AGE)) + ) + await db_session.commit() + client.cookies.set(COOKIE_SESSION_KEY, session_id) + + response = await client.get("/auth/user") + + assert response.status_code == HTTPStatus.UNAUTHORIZED From 8428a0985942e25228d5dacdd41c81561949b007 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 16:42:00 -0700 Subject: [PATCH 23/26] ci: update workflows to use uv and fix env vars --- .github/workflows/alembic.yml | 32 +++++++++++++++++-------------- .github/workflows/pytest_unit.yml | 11 +++++++---- .github/workflows/ruff.yml | 14 +++++++++++++- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.github/workflows/alembic.yml b/.github/workflows/alembic.yml index 860422c2..3ab50195 100644 --- a/.github/workflows/alembic.yml +++ b/.github/workflows/alembic.yml @@ -4,6 +4,11 @@ on: pull_request jobs: alembic_upgrade_head: runs-on: ubuntu-latest + env: + ENVIRONMENT: dev + DB_PORT: "5432" + COOKIE_SECURE: "false" + FRONTEND_ORIGIN: http://localhost:8080 services: postgres: @@ -32,19 +37,18 @@ jobs: sleep 1 done + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install Python + run: uv python install 3.13 + - name: Install dependencies - run: | - sudo add-apt-repository ppa:deadsnakes/ppa - sudo apt install python3.13 python3.13-venv - python3.13 -m pip install --upgrade pip - python3.13 -m venv venv - source ./venv/bin/activate - pip install . - - # This will fail if there are divergent heads and alembic gets confused; - # e.g., un-sanitarily merging main into a dev branch. + run: uv sync --locked + + # This will fail if there are divergent heads and alembic gets confused + # e.g. merging into main without passing this check - name: Run alembic upgrade head - run: | - source ./venv/bin/activate - cd src - alembic upgrade head + working-directory: src + run: uv run alembic upgrade head diff --git a/.github/workflows/pytest_unit.yml b/.github/workflows/pytest_unit.yml index d0cebdba..32cc172b 100644 --- a/.github/workflows/pytest_unit.yml +++ b/.github/workflows/pytest_unit.yml @@ -5,18 +5,21 @@ jobs: unit_tests: runs-on: ubuntu-latest timeout-minutes: 5 + env: + ENVIRONMENT: test + COOKIE_SECURE: "false" + FRONTEND_ORIGIN: http://localhost:8080 steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - uses: astral-sh/setup-uv@v6 with: enable-cache: true + - name: Install Python + run: uv python install 3.13 + - name: Install dependencies run: uv sync --extra test --locked diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index b213240b..f8999fa7 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -5,4 +5,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: astral-sh/ruff-action@v3 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --extra dev --locked + + - name: Run Ruff + run: uv run ruff check . From 4b31a3233f1b003c09545dfcae5abb3e53d096a5 Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 17:19:01 -0700 Subject: [PATCH 24/26] fix: `.env` in root can be in sub-directories --- src/config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index ded0d64f..63fec33b 100644 --- a/src/config.py +++ b/src/config.py @@ -1,8 +1,12 @@ +from pathlib import Path + from pydantic_settings import BaseSettings, SettingsConfigDict +ENV_FILE = Path(__file__).parent.parent / ".env" + class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env") + model_config = SettingsConfigDict(env_file=ENV_FILE) environment: str db_port: int | None = None From 59cbd3b8752d21343ae132c8f4aeae1506564ecc Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 17:32:33 -0700 Subject: [PATCH 25/26] fix: test runner failure due to date difference --- tests/unit/test_translink.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_translink.py b/tests/unit/test_translink.py index 1cf9b13d..de5fa350 100644 --- a/tests/unit/test_translink.py +++ b/tests/unit/test_translink.py @@ -1,6 +1,6 @@ import io import zipfile -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -466,6 +466,7 @@ async def test__get_or_fetch_realtime_feed_returns_none_on_http_error_status(): async def test__get_or_fetch_static_schedule_cache_hit(): """When the DB has today's row, no HTTP call should be made.""" + today = datetime.now(tz=TZ_INFO).date() cached_records = [ { "trip_id": "trip_143", @@ -475,13 +476,13 @@ async def test__get_or_fetch_static_schedule_cache_hit(): "departure_seconds": 82800, } ] - cached_row = TransLinkStaticScheduleDB(id=1, date_fetched=date.today(), schedule=cached_records) + cached_row = TransLinkStaticScheduleDB(id=1, date_fetched=today, schedule=cached_records) session = mock_db_session(cached_row=cached_row) client = AsyncMock(spec=AsyncClient) result_date, result_df = await get_or_fetch_static_schedule(session, client) - assert result_date == date.today() + assert result_date == today assert not result_df.empty assert result_df.iloc[0]["bus_number"] == "143" client.get.assert_not_called() @@ -489,12 +490,13 @@ async def test__get_or_fetch_static_schedule_cache_hit(): async def test__get_or_fetch_static_schedule_cache_miss_fetches(): """On a cache miss the function should call the API and persist the result.""" + today = datetime.now(tz=TZ_INFO).date() session = mock_db_session(cached_row=None) client = mock_http_client(make_gtfs_zip()) result_date, result_df = await get_or_fetch_static_schedule(session, client) - assert result_date == date.today() + assert result_date == today assert not result_df.empty session.merge.assert_awaited_once() session.commit.assert_awaited_once() @@ -504,13 +506,14 @@ async def test__get_or_fetch_static_schedule_db_write_failure_still_returns(): """If the DB write fails, the function should still return the fetched data.""" import sqlalchemy.exc + today = datetime.now(tz=TZ_INFO).date() session = mock_db_session(cached_row=None) session.merge = AsyncMock(side_effect=sqlalchemy.exc.SQLAlchemyError("disk full")) client = mock_http_client(make_gtfs_zip()) result_date, result_df = await get_or_fetch_static_schedule(session, client) - assert result_date == date.today() + assert result_date == today assert not result_df.empty session.rollback.assert_awaited_once() @@ -578,7 +581,7 @@ async def test__endpoint_realtime_returns_200(client): async def test__endpoint_static_returns_schedule(client): - today = date.today() + today = datetime.now(tz=TZ_INFO).date() mock_df = pd.DataFrame( [ { From 7e4ad491e51162057a5d59a48357c3265f220d6d Mon Sep 17 00:00:00 2001 From: Jon Andre Briones Date: Thu, 2 Jul 2026 23:22:30 -0700 Subject: [PATCH 26/26] fix: database setup does not respect the app settings --- src/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database.py b/src/database.py index 7edb57c2..6f434607 100644 --- a/src/database.py +++ b/src/database.py @@ -100,7 +100,7 @@ async def session(self) -> AsyncGenerator[AsyncSession]: def setup_database(): global sessionmanager - db_url = SQLALCHEMY_TEST_DATABASE_URL if os.environ.get("ENV") == "test" else SQLALCHEMY_DATABASE_URL + db_url = SQLALCHEMY_TEST_DATABASE_URL if settings.environment == "test" else SQLALCHEMY_DATABASE_URL # TODO: where is sys.stdout piped to? I want all these to go to a specific logs folder sessionmanager = DatabaseSessionManager( db_url,