From 0d8837a4f869bba9b47096b948c9b0f2c0712793 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 22 Jun 2026 18:42:11 -0600 Subject: [PATCH 01/15] fix register endpoint --- frontend/src/features/auth/auth-types.ts | 4 +- frontend/src/lib/http/api-client.ts | 81 ++++++++++++++++++- frontend/src/pages/register/register-page.tsx | 3 +- scripts/run-compose-stack.sh | 8 +- webapi/api/endpoints/v1/auths.py | 2 +- webapi/schemas/user_schema.py | 4 +- webapi/tests/conftest.py | 2 +- webapi/tests/functional/test_auth_routes.py | 25 ++++-- webapi/tests/functional/test_cd.py | 2 +- webapi/tests/nonfunctional/test_ci.py | 2 +- 10 files changed, 112 insertions(+), 21 deletions(-) diff --git a/frontend/src/features/auth/auth-types.ts b/frontend/src/features/auth/auth-types.ts index b0c77a7..07d7388 100644 --- a/frontend/src/features/auth/auth-types.ts +++ b/frontend/src/features/auth/auth-types.ts @@ -28,7 +28,9 @@ export type UserRecord = { role: UserRole } -export type SignupResponse = UserRecord +export type SignupResponse = { + message: string +} export type RecoveryGenerateInput = { username: string diff --git a/frontend/src/lib/http/api-client.ts b/frontend/src/lib/http/api-client.ts index 0bc8fc0..202c598 100644 --- a/frontend/src/lib/http/api-client.ts +++ b/frontend/src/lib/http/api-client.ts @@ -30,10 +30,7 @@ export async function apiRequest( const data = raw ? tryParseJson(raw) : null if (!response.ok) { - const detail = - typeof data === 'object' && data && 'detail' in data - ? String(data.detail) - : `Request failed with status ${response.status}` + const detail = getErrorDetail(data, response.status) throw new ApiError(response.status, detail) } @@ -48,3 +45,79 @@ function tryParseJson(value: string): unknown { } } +function getErrorDetail(data: unknown, status: number): string { + if (isRecord(data) && 'detail' in data) { + return formatDetail(data.detail, status) + } + + return fallbackMessage(status) +} + +function formatDetail(detail: unknown, status: number): string { + if (typeof detail === 'string') { + return detail + } + + if (Array.isArray(detail)) { + const messages = detail + .map(formatValidationItem) + .filter((message): message is string => Boolean(message)) + + return messages.length > 0 ? messages.join('; ') : fallbackMessage(status) + } + + if (isRecord(detail)) { + const json = safeStringify(detail) + return json ?? fallbackMessage(status) + } + + return fallbackMessage(status) +} + +function formatValidationItem(item: unknown): string | null { + if (typeof item === 'string') { + return item + } + + if (!isRecord(item)) { + return null + } + + const message = typeof item.msg === 'string' ? item.msg : null + const location = formatLocation(item.loc) + + if (message && location) { + return `${location}: ${message}` + } + + return message ?? safeStringify(item) +} + +function formatLocation(loc: unknown): string | null { + if (!Array.isArray(loc)) { + return null + } + + const parts = loc + .filter((part): part is string | number => typeof part === 'string' || typeof part === 'number') + .filter((part) => part !== 'body') + + return parts.length > 0 ? parts.join('.') : null +} + +function safeStringify(value: unknown): string | null { + try { + const json = JSON.stringify(value) + return typeof json === 'string' ? json : null + } catch { + return null + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function fallbackMessage(status: number): string { + return `Request failed with status ${status}` +} diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx index aff1e45..bf789c9 100644 --- a/frontend/src/pages/register/register-page.tsx +++ b/frontend/src/pages/register/register-page.tsx @@ -5,6 +5,7 @@ import { Card } from '../../components/ui/card' import { InlineError } from '../../components/ui/inline-error' import { Input } from '../../components/ui/input' import { signup } from '../../features/auth/auth-service' +import { ApiError } from '../../lib/http/api-error' import { registerSchema } from '../../lib/validation/auth-schemas' export function RegisterPage() { @@ -32,7 +33,7 @@ export function RegisterPage() { navigate('/login?registered=1', { replace: true }) } catch (err) { const message = err instanceof Error ? err.message : 'Unable to create account' - setError(message.includes('400') ? 'Username or email is already registered' : message) + setError(err instanceof ApiError && err.status === 400 ? 'Username is already taken' : message) } finally { setLoading(false) } diff --git a/scripts/run-compose-stack.sh b/scripts/run-compose-stack.sh index cf1f1ec..a1bad2c 100755 --- a/scripts/run-compose-stack.sh +++ b/scripts/run-compose-stack.sh @@ -313,7 +313,7 @@ wait_for_http() { } verify_api_proxy() { - local url="http://127.0.0.1:80/api/v1/auth/login" + local url="http://127.0.0.1/api/v1/auth/login" local status log "Checking nginx API proxy: ${url}" @@ -442,7 +442,7 @@ start_stack wait_for_health mariadb "${TIMEOUT_SECONDS}" wait_for_health redis "${TIMEOUT_SECONDS}" wait_for_health backend "${TIMEOUT_SECONDS}" -wait_for_http "frontend" "http://127.0.0.1:80/" "${TIMEOUT_SECONDS}" +wait_for_http "frontend" "http://127.0.0.1/" "${TIMEOUT_SECONDS}" wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}" verify_api_proxy verify_database @@ -450,9 +450,9 @@ verify_redis log "Stack is ready." printf '\nURLs:\n' -printf ' Frontend: http://127.0.0.1:80\n' +printf ' Frontend: http://127.0.0.1\n' printf ' Backend direct: http://127.0.0.1:8000/docs\n' -printf ' Backend through nginx: http://127.0.0.1:80/api/v1/...\n' +printf ' Backend through nginx: http://127.0.0.1/api/v1/...\n' printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}" printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}" printf '\nUseful commands:\n' diff --git a/webapi/api/endpoints/v1/auths.py b/webapi/api/endpoints/v1/auths.py index d42fe61..28ed757 100644 --- a/webapi/api/endpoints/v1/auths.py +++ b/webapi/api/endpoints/v1/auths.py @@ -42,7 +42,7 @@ def signup(payload: UserCreate, session: Session = Depends(get_session)): name=payload.name, last_name=payload.last_name, email=payload.email, - hashed_password=sha256_crypt.hash(payload.hashed_password), + hashed_password=sha256_crypt.hash(payload.password), role=payload.role, ) session.add(user) diff --git a/webapi/schemas/user_schema.py b/webapi/schemas/user_schema.py index 585fdb3..623c822 100644 --- a/webapi/schemas/user_schema.py +++ b/webapi/schemas/user_schema.py @@ -12,7 +12,7 @@ class UserCreate(BaseModel): "name": "user", "last_name": "testing", "email": "user@example.com", - "hashed_password": "usertest", + "password": "usertest", "role": "user", } }, @@ -22,7 +22,7 @@ class UserCreate(BaseModel): name: str = Field(max_length=100) last_name: str = Field(max_length=100) email: EmailStr = Field(max_length=100) - hashed_password: str = Field(max_length=255) + password: str = Field(max_length=255) role: str = "user" diff --git a/webapi/tests/conftest.py b/webapi/tests/conftest.py index a4263d1..ba435fe 100644 --- a/webapi/tests/conftest.py +++ b/webapi/tests/conftest.py @@ -77,7 +77,7 @@ def user_payload(): "name": "Py", "last_name": "Tester", "email": "pytest_user@example.com", - "hashed_password": "pytest_password", + "password": "pytest_password", } diff --git a/webapi/tests/functional/test_auth_routes.py b/webapi/tests/functional/test_auth_routes.py index 8b98b20..ff49b7b 100644 --- a/webapi/tests/functional/test_auth_routes.py +++ b/webapi/tests/functional/test_auth_routes.py @@ -17,8 +17,8 @@ def test_signup_success(client, user_payload, db_session): created = db_session.exec(select(User).where(User.username == user_payload["username"])).first() assert created is not None assert isinstance(created.id, int) - assert created.hashed_password != user_payload["hashed_password"] - assert sha256_crypt.verify(user_payload["hashed_password"], created.hashed_password) + assert created.hashed_password != user_payload["password"] + assert sha256_crypt.verify(user_payload["password"], created.hashed_password) def test_signup_rejects_client_supplied_id(client, user_payload, db_session): @@ -29,7 +29,16 @@ def test_signup_rejects_client_supplied_id(client, user_payload, db_session): assert created is None -def test_signup_openapi_schema_does_not_expose_id(client): +def test_signup_rejects_client_supplied_hashed_password(client, user_payload, db_session): + payload = {**user_payload, "hashed_password": user_payload["password"]} + response = client.post("/api/v1/auth/signup", json=payload) + + assert response.status_code == 422 + created = db_session.exec(select(User).where(User.username == user_payload["username"])).first() + assert created is None + + +def test_signup_openapi_schema_does_not_expose_storage_fields(client): response = client.get("/openapi.json") assert response.status_code == 200 @@ -41,6 +50,12 @@ def test_signup_openapi_schema_does_not_expose_id(client): assert "id" not in user_create_schema["properties"] assert "id" not in user_create_schema.get("required", []) assert "id" not in example + assert "hashed_password" not in user_create_schema["properties"] + assert "hashed_password" not in user_create_schema.get("required", []) + assert "hashed_password" not in example + assert "password" in user_create_schema["properties"] + assert "password" in user_create_schema.get("required", []) + assert "password" in example def test_signup_duplicate_username_returns_400(client, db_session): @@ -62,7 +77,7 @@ def test_signup_duplicate_username_returns_400(client, db_session): "name": "new", "last_name": "user", "email": "new_dup_user@example.com", - "hashed_password": "password", + "password": "password", }, ) @@ -85,7 +100,7 @@ def test_login_success_returns_token(client, user_payload): response = client.post( "/api/v1/auth/login", - json={"username": user_payload["username"], "password": user_payload["hashed_password"]}, + json={"username": user_payload["username"], "password": user_payload["password"]}, ) assert response.status_code == 200 diff --git a/webapi/tests/functional/test_cd.py b/webapi/tests/functional/test_cd.py index e8587b6..551213f 100644 --- a/webapi/tests/functional/test_cd.py +++ b/webapi/tests/functional/test_cd.py @@ -12,7 +12,7 @@ def test_login_and_access_private(client): "name": "Py", "last_name": "Tester", "email": "pytest@example.com", - "hashed_password": "pytest", + "password": "pytest", }, ) assert signup_response.status_code in (200, 400) diff --git a/webapi/tests/nonfunctional/test_ci.py b/webapi/tests/nonfunctional/test_ci.py index 24a6982..d236776 100644 --- a/webapi/tests/nonfunctional/test_ci.py +++ b/webapi/tests/nonfunctional/test_ci.py @@ -22,7 +22,7 @@ def test_register_user(): "name": "user to test", "last_name": "my webapp", "email": "pytestmyapp@testing.com", - "hashed_password": TEST_PSW + "password": TEST_PSW }) assert response.status_code in (200, 400) if response.status_code == 400: From 6a39638892dcd26cbab6be15161ef19361592543 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 22 Jun 2026 18:55:53 -0600 Subject: [PATCH 02/15] update(model): increase the lenght of prompt text --- frontend/src/lib/validation/prompt-schemas.ts | 7 +++- plans/dbdiagram.md | 2 +- webapi/models/prompts.py | 13 ++++++-- webapi/schemas/prompt_schema.py | 3 +- .../tests/functional/test_prompts_routes.py | 32 ++++++++++++++++++- 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/validation/prompt-schemas.ts b/frontend/src/lib/validation/prompt-schemas.ts index 5162ab7..e8e11cb 100644 --- a/frontend/src/lib/validation/prompt-schemas.ts +++ b/frontend/src/lib/validation/prompt-schemas.ts @@ -1,11 +1,16 @@ import { z } from 'zod' +const PROMPT_TEXT_MAX_CHARS = 1_000_000 + export const promptSchema = z.object({ model_name: z.string().min(1, 'Model name is required'), prompt_text: z .string() .min(1, 'Prompt text is required') - .max(150, 'Prompt text must be at most 150 characters'), + .max( + PROMPT_TEXT_MAX_CHARS, + `Prompt text must be at most ${PROMPT_TEXT_MAX_CHARS.toLocaleString()} characters`, + ), category: z.string().min(1, 'Category is required'), rate: z.coerce .number() diff --git a/plans/dbdiagram.md b/plans/dbdiagram.md index 8f86753..f016b18 100644 --- a/plans/dbdiagram.md +++ b/plans/dbdiagram.md @@ -36,7 +36,7 @@ Table prompts { id int [pk, increment] user_id int [not null, ref: > user.id] model_name varchar(30) [not null] - prompt_text varchar(150) [not null] + prompt_text longtext [not null] category varchar(30) [not null] rate varchar(30) [not null] diff --git a/webapi/models/prompts.py b/webapi/models/prompts.py index 1664392..a9c3157 100644 --- a/webapi/models/prompts.py +++ b/webapi/models/prompts.py @@ -1,18 +1,27 @@ from typing import Optional +from sqlalchemy import Column, Text +from sqlalchemy.dialects.mysql import LONGTEXT from sqlmodel import Field, Relationship, SQLModel +PROMPT_TEXT_MAX_CHARS = 1_000_000 +PROMPT_TEXT_SQL_TYPE = ( + Text() + .with_variant(LONGTEXT(), "mysql") + .with_variant(LONGTEXT(), "mariadb") +) + class Prompts(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id", nullable=False) model_name: str = Field(max_length=30, nullable=False) - prompt_text: str = Field(max_length=150, nullable=False) + prompt_text: str = Field(sa_column=Column(PROMPT_TEXT_SQL_TYPE, nullable=False)) category: str = Field(max_length=30, nullable=False) rate: int = Field(nullable=False, ge=1, le=5) user: Optional["User"] = Relationship( sa_relationship_kwargs={"lazy": "selectin"}, - back_populates="prompts" + back_populates="prompts", ) diff --git a/webapi/schemas/prompt_schema.py b/webapi/schemas/prompt_schema.py index 2b7899c..7166d54 100644 --- a/webapi/schemas/prompt_schema.py +++ b/webapi/schemas/prompt_schema.py @@ -1,12 +1,13 @@ from typing import Optional +from models.prompts import PROMPT_TEXT_MAX_CHARS from pydantic import BaseModel, Field class PromptCreate(BaseModel): user_id: Optional[int] = None model_name: str - prompt_text: str = Field(max_length=150) + prompt_text: str = Field(min_length=1, max_length=PROMPT_TEXT_MAX_CHARS) category: str rate: int = Field(ge=1, le=5) diff --git a/webapi/tests/functional/test_prompts_routes.py b/webapi/tests/functional/test_prompts_routes.py index f47cf9c..a114af3 100644 --- a/webapi/tests/functional/test_prompts_routes.py +++ b/webapi/tests/functional/test_prompts_routes.py @@ -1,6 +1,6 @@ import api.endpoints.v1.prompts as prompts_module from auth.auth_service import crear_jwt -from models.prompts import Prompts +from models.prompts import PROMPT_TEXT_MAX_CHARS, Prompts from models.user import User @@ -54,6 +54,36 @@ def test_create_prompt_success(client, auth_header, created_user): assert response.json()["model_name"] == "gpt-4.1" +def test_create_prompt_accepts_prompt_text_longer_than_150_chars(client, auth_header, created_user): + prompt_text = "x" * 151 + payload = { + "user_id": created_user.id, + "model_name": "gpt-4.1", + "prompt_text": prompt_text, + "category": "qa", + "rate": 5, + } + + response = client.post("/api/v1/prompts", json=payload, headers=auth_header) + + assert response.status_code == 200 + assert response.json()["prompt_text"] == prompt_text + + +def test_create_prompt_rejects_prompt_text_above_max_chars(client, auth_header, created_user): + payload = { + "user_id": created_user.id, + "model_name": "gpt-4.1", + "prompt_text": "x" * (PROMPT_TEXT_MAX_CHARS + 1), + "category": "qa", + "rate": 5, + } + + response = client.post("/api/v1/prompts", json=payload, headers=auth_header) + + assert response.status_code == 422 + + def test_create_prompt_unauthorized_returns_401(client, created_user): payload = { "user_id": created_user.id, From c52ea20a09e864066bffd45865e7ee5dd38275b5 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 22 Jun 2026 19:44:37 -0600 Subject: [PATCH 03/15] update(deployment): hibrid way to deploy prod or local --- .env.example | 3 + .gitignore | 3 + Caddyfile.example | 3 + docker-compose.local.yml | 16 ++++ docker-compose.prod.yml | 16 ++++ docker-compose.yml | 12 +-- scripts/run-compose-stack.sh | 173 +++++++++++++++++++++++++++++------ 7 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 Caddyfile.example create mode 100644 docker-compose.local.yml create mode 100644 docker-compose.prod.yml diff --git a/.env.example b/.env.example index a44a386..0c83744 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ # and CI/CD documentation. Replace placeholder values in real environments. # Docker Compose host ports for local inspection. +# Valid deployment modes are local and production. Local publishes service ports +# directly. Production publishes only Caddy ports 80/443 and requires ./Caddyfile. +DEPLOY_ENV=local MARIADB_HOST_PORT=3306 REDIS_HOST_PORT=6379 diff --git a/.gitignore b/.gitignore index fa58472..7b2e6ee 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ htmlcov/ # Environment variables .env +# Production deployment config +Caddyfile + # Local runtime logs backend.log frontend.log diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..7cd10bc --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,3 @@ +example.com { + reverse_proxy frontend:80 +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..361c0ad --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,16 @@ +services: + mariadb: + ports: + - "${MARIADB_HOST_PORT:-3306}:3306" + + redis: + ports: + - "${REDIS_HOST_PORT:-6379}:6379" + + backend: + ports: + - "8000:8000" + + frontend: + ports: + - "80:80" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..90ceacf --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,16 @@ +services: + caddy: + image: caddy:2 + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - frontend + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml index 37e6add..feae478 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,6 @@ services: MARIADB_DATABASE: ${MARIADB_DATABASE:-crud_data} MARIADB_USER: ${MARIADB_USER:-webapi_user} MARIADB_PASSWORD: ${MARIADB_PASSWORD:-replace_with_local_database_password} - ports: - - "${MARIADB_HOST_PORT:-3306}:3306" volumes: - webapi-mariadb-data:/var/lib/mysql healthcheck: @@ -19,8 +17,6 @@ services: redis: image: redis:7-alpine - ports: - - "${REDIS_HOST_PORT:-6379}:6379" healthcheck: test: [ "CMD", "redis-cli", "ping" ] interval: 10s @@ -40,8 +36,8 @@ services: ENV_MAIL_PASSWORD: ${ENV_MAIL_PASSWORD:-replace_with_local_mail_app_password} ENV_MAIL_FROM: ${ENV_MAIL_FROM:-test@example.com} ENV_SECRET_KEY: ${ENV_SECRET_KEY:-replace_with_local_jwt_secret} - ports: - - "8000:8000" + expose: + - "8000" depends_on: mariadb: condition: service_healthy @@ -57,8 +53,8 @@ services: frontend: build: context: ./frontend - ports: - - "80:80" + expose: + - "80" depends_on: backend: condition: service_healthy diff --git a/scripts/run-compose-stack.sh b/scripts/run-compose-stack.sh index a1bad2c..e8a260f 100755 --- a/scripts/run-compose-stack.sh +++ b/scripts/run-compose-stack.sh @@ -6,11 +6,14 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" TIMEOUT_SECONDS=120 BUILD_FLAG="--build" ENV_FILE="${COMPOSE_ENV_FILE:-${REPO_ROOT}/.env}" +COMPOSE_FILE_ARGS=() COMPOSE_ENV_ARGS=() COMPOSE_PROJECT_ARGS=() CLEAN_MODE="false" RESET_DEFAULT="false" PROJECT_NAME="" +CADDYFILE_MISSING="false" +CADDYFILE_FINAL_WARNING="DEPLOY_ENV=production but ./Caddyfile was not found. The Caddy container cannot run correctly without a Caddyfile. Create ./Caddyfile and rerun the production deployment." log() { printf '[compose-stack] %s\n' "$*" @@ -18,17 +21,21 @@ log() { fail() { printf '[compose-stack] ERROR: %s\n' "$*" >&2 + if [[ "${CADDYFILE_MISSING:-false}" == "true" ]]; then + printf '[compose-stack] WARNING: %s\n' "${CADDYFILE_FINAL_WARNING}" >&2 + fi exit 1 } usage() { cat <<'USAGE' -Usage: ./scripts/run-compose-stack.sh [--clean] [--project-name NAME] [--reset-default] [--no-build] [--timeout SECONDS] +Usage: ./scripts/run-compose-stack.sh [--env local|production] [--clean] [--project-name NAME] [--reset-default] [--no-build] [--timeout SECONDS] -Starts the MariaDB, Redis, backend, and frontend services with Docker Compose, -waits for readiness, and verifies minimum connectivity. +Starts the MariaDB, Redis, backend, frontend, and production Caddy services +with Docker Compose, waits for readiness, and verifies minimum connectivity. Options: + --env VALUE Deployment mode: local or production. Defaults to DEPLOY_ENV or local. --clean Use an isolated Compose project and clean its containers/volumes before startup. --project-name NAME Override Compose project name. Defaults to webapi_clean_ with --clean. --reset-default With --clean, also remove default project containers/volumes before startup. @@ -44,6 +51,13 @@ while [[ $# -gt 0 ]]; do CLEAN_MODE="true" shift ;; + --env) + [[ $# -ge 2 ]] || fail "--env requires a value" + [[ "$2" == "local" || "$2" == "production" ]] || fail "--env must be local or production" + DEPLOY_ENV="$2" + export DEPLOY_ENV + shift 2 + ;; --project-name) [[ $# -ge 2 ]] || fail "--project-name requires a value" [[ -n "$2" ]] || fail "--project-name must not be empty" @@ -89,13 +103,23 @@ detect_compose() { } compose() { - "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}" "$@" + "${COMPOSE_CMD[@]}" "${COMPOSE_FILE_ARGS[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}" "$@" } warn() { printf '[compose-stack] WARNING: %s\n' "$*" >&2 } +compose_command_prefix() { + printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_FILE_ARGS[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}" +} + +print_final_caddyfile_warning() { + if [[ "${CADDYFILE_MISSING}" == "true" ]]; then + warn "${CADDYFILE_FINAL_WARNING}" + fi +} + is_set() { local name="$1" [[ -n "${!name+x}" && -n "${!name}" ]] @@ -136,6 +160,32 @@ load_env_file() { done < "${ENV_FILE}" } +configure_deploy_env() { + DEPLOY_ENV="${DEPLOY_ENV:-local}" + + case "${DEPLOY_ENV}" in + local) + COMPOSE_FILE_ARGS=(-f docker-compose.yml -f docker-compose.local.yml) + ;; + production) + COMPOSE_FILE_ARGS=(-f docker-compose.yml -f docker-compose.prod.yml) + ;; + *) + fail "DEPLOY_ENV must be local or production, got: ${DEPLOY_ENV}" + ;; + esac + + export DEPLOY_ENV +} + +check_caddyfile_for_production() { + CADDYFILE_MISSING="false" + if [[ "${DEPLOY_ENV}" == "production" && ! -f "${REPO_ROOT}/Caddyfile" ]]; then + CADDYFILE_MISSING="true" + warn "DEPLOY_ENV=production but ${REPO_ROOT}/Caddyfile was not found. Caddy will not run correctly without it." + fi +} + configure_project_args() { if [[ -n "${PROJECT_NAME}" ]]; then COMPOSE_PROJECT_ARGS=(-p "${PROJECT_NAME}") @@ -158,7 +208,7 @@ reset_default_project() { fi log "Removing default Compose project containers and volumes..." - "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" down --volumes --remove-orphans || true + "${COMPOSE_CMD[@]}" "${COMPOSE_FILE_ARGS[@]}" "${COMPOSE_ENV_ARGS[@]}" down --volumes --remove-orphans || true } clean_project_state() { @@ -173,6 +223,7 @@ clean_project_state() { check_required_files() { local missing=() local required=( + "${REPO_ROOT}/docker-compose.yml" "${REPO_ROOT}/webapi/db/__init__.py" "${REPO_ROOT}/webapi/db/db_connection.py" "${REPO_ROOT}/webapi/db/redis_connection.py" @@ -182,6 +233,12 @@ check_required_files() { ) local path + if [[ "${DEPLOY_ENV}" == "local" ]]; then + required+=("${REPO_ROOT}/docker-compose.local.yml") + else + required+=("${REPO_ROOT}/docker-compose.prod.yml") + fi + for path in "${required[@]}"; do [[ -f "${path}" ]] || missing+=("${path}") done @@ -352,6 +409,20 @@ docker_published_port_in_use() { configure_ports() { local candidate + if [[ "${DEPLOY_ENV}" == "production" ]]; then + if [[ "${CLEAN_MODE}" == "true" ]]; then + if docker_published_port_in_use 80; then + fail "Host port 80 is already used. Production Caddy publishes port 80, so stop the conflicting container before clean startup." + fi + + if docker_published_port_in_use 443; then + fail "Host port 443 is already used. Production Caddy publishes port 443, so stop the conflicting container before clean startup." + fi + fi + + return + fi + if [[ "${CLEAN_MODE}" == "true" ]]; then if [[ -z "${MARIADB_HOST_PORT:-}" ]] && docker_published_port_in_use 3306; then fail "Host port 3306 is already used. Stop the conflicting container or rerun with MARIADB_HOST_PORT=." @@ -362,11 +433,11 @@ configure_ports() { fi if docker_published_port_in_use 8000; then - fail "Host port 8000 is already used. docker-compose.yml hardcodes backend port 8000, so stop the conflicting container before clean startup." + fail "Host port 8000 is already used. docker-compose.local.yml publishes backend port 8000, so stop the conflicting container before clean startup." fi if docker_published_port_in_use 80; then - fail "Host port 80 is already used. docker-compose.yml hardcodes frontend port 80, so stop the conflicting container before clean startup." + fail "Host port 80 is already used. docker-compose.local.yml publishes frontend port 80, so stop the conflicting container before clean startup." fi return @@ -386,6 +457,10 @@ configure_ports() { } start_stack() { + local compose_prefix + + compose_prefix="$(compose_command_prefix)" + if [[ -n "${BUILD_FLAG}" ]]; then log "Starting stack with image builds..." if compose up "${BUILD_FLAG}" -d; then @@ -404,36 +479,44 @@ start_stack() { printf '\nIf legacy docker-compose hit the ContainerConfig recreate bug, remove only containers for this project and retry:\n' >&2 printf " docker ps -a --format '{{.Names}}' | grep '%s' | xargs -r docker rm -f\n" "${PROJECT_NAME}" >&2 if [[ -n "${BUILD_FLAG}" ]]; then - printf " %s -p '%s' up %s -d\n" "${COMPOSE_CMD[*]}" "${PROJECT_NAME}" "${BUILD_FLAG}" >&2 + printf " %s up %s -d\n" "${compose_prefix}" "${BUILD_FLAG}" >&2 else - printf " %s -p '%s' up -d\n" "${COMPOSE_CMD[*]}" "${PROJECT_NAME}" >&2 + printf " %s up -d\n" "${compose_prefix}" >&2 fi - printf 'Clean project teardown: %s down --volumes --remove-orphans\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" >&2 + printf 'Clean project teardown: %s down --volumes --remove-orphans\n' "${compose_prefix}" >&2 else - printf '\nIf port 3306, 6379, 8000, or 80 is already used by an old webapi container, stop it first.\n' >&2 + if [[ "${DEPLOY_ENV}" == "production" ]]; then + printf '\nIf port 80 or 443 is already used by an old webapi container, stop it first.\n' >&2 + else + printf '\nIf port 3306, 6379, 8000, or 80 is already used by an old webapi container, stop it first.\n' >&2 + fi printf 'Manual backend workflow cleanup example: docker rm -f webapi-mariadb webapi-redis webapi-backend\n' >&2 fi if [[ -n "${BUILD_FLAG}" ]]; then - printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up %s -d\n' "${COMPOSE_CMD[*]}" "${BUILD_FLAG}" >&2 + printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up %s -d\n' "${compose_prefix}" "${BUILD_FLAG}" >&2 else - printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up -d\n' "${COMPOSE_CMD[*]}" >&2 + printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up -d\n' "${compose_prefix}" >&2 fi - printf 'Compose cleanup example: %s down\n' "${COMPOSE_CMD[*]}" >&2 - exit 1 + printf 'Compose cleanup example: %s down\n' "${compose_prefix}" >&2 + fail "Docker Compose failed to start the stack." } cd "${REPO_ROOT}" detect_compose load_env_file +configure_deploy_env configure_project_args check_required_files check_environment +check_caddyfile_for_production reset_default_project clean_project_state configure_ports log "Using Compose command: ${COMPOSE_CMD[*]}" +log "Using deployment environment: ${DEPLOY_ENV}" +log "Using Compose files: ${COMPOSE_FILE_ARGS[*]}" if [[ "${CLEAN_MODE}" == "true" ]]; then log "Using isolated Compose project: ${PROJECT_NAME}" fi @@ -442,23 +525,53 @@ start_stack wait_for_health mariadb "${TIMEOUT_SECONDS}" wait_for_health redis "${TIMEOUT_SECONDS}" wait_for_health backend "${TIMEOUT_SECONDS}" -wait_for_http "frontend" "http://127.0.0.1/" "${TIMEOUT_SECONDS}" -wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}" -verify_api_proxy + +if [[ "${DEPLOY_ENV}" == "local" ]]; then + wait_for_http "frontend" "http://127.0.0.1/" "${TIMEOUT_SECONDS}" + wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}" + verify_api_proxy +elif [[ "${CADDYFILE_MISSING}" == "true" ]]; then + log "Skipping Caddy HTTP verification because ./Caddyfile is missing." +else + wait_for_health caddy "${TIMEOUT_SECONDS}" + wait_for_http "frontend through Caddy" "http://127.0.0.1/" "${TIMEOUT_SECONDS}" + verify_api_proxy +fi + verify_database verify_redis -log "Stack is ready." +if [[ "${CADDYFILE_MISSING}" == "true" ]]; then + log "Core services are ready; Caddy needs ./Caddyfile before production traffic can work." +else + log "Stack is ready." +fi + +COMPOSE_PREFIX="$(compose_command_prefix)" + printf '\nURLs:\n' -printf ' Frontend: http://127.0.0.1\n' -printf ' Backend direct: http://127.0.0.1:8000/docs\n' -printf ' Backend through nginx: http://127.0.0.1/api/v1/...\n' -printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}" -printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}" +if [[ "${DEPLOY_ENV}" == "local" ]]; then + printf ' Frontend: http://127.0.0.1\n' + printf ' Backend direct: http://127.0.0.1:8000/docs\n' + printf ' Backend through nginx: http://127.0.0.1/api/v1/...\n' + printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}" + printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}" +else + printf ' Frontend through Caddy: http://127.0.0.1\n' + printf ' Backend through Caddy: http://127.0.0.1/api/v1/...\n' + printf ' HTTPS through Caddy: https://\n' + printf ' MariaDB/Redis: internal Compose network only\n' +fi printf '\nUseful commands:\n' -printf ' View services: %s ps\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" -printf ' View logs: %s logs backend frontend mariadb redis\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" -printf ' Check Redis: %s exec redis redis-cli ping\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" -printf ' Stop stack: %s down\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" -printf ' Reset DB: %s down -v\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" -printf ' Get into DB container: %s exec mariadb sh -c '\''MYSQL_PWD="$MARIADB_PASSWORD" mariadb "$MARIADB_DATABASE" -u "$MARIADB_USER"'\''\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" +if [[ "${DEPLOY_ENV}" == "production" ]]; then + printf ' View logs: %s logs caddy backend frontend mariadb redis\n' "${COMPOSE_PREFIX}" +else + printf ' View logs: %s logs backend frontend mariadb redis\n' "${COMPOSE_PREFIX}" +fi +printf ' View services: %s ps\n' "${COMPOSE_PREFIX}" +printf ' Check Redis: %s exec redis redis-cli ping\n' "${COMPOSE_PREFIX}" +printf ' Stop stack: %s down\n' "${COMPOSE_PREFIX}" +printf ' Reset DB: %s down -v\n' "${COMPOSE_PREFIX}" +printf ' Get into DB container: %s exec mariadb sh -c '\''MYSQL_PWD="$MARIADB_PASSWORD" mariadb "$MARIADB_DATABASE" -u "$MARIADB_USER"'\''\n' "${COMPOSE_PREFIX}" + +print_final_caddyfile_warning From 37070d403090238f71fcbcb997c6c8b9c15ac133 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 11:01:29 -0600 Subject: [PATCH 04/15] feat: validate prompt model names --- webapi/api/endpoints/v1/options.py | 16 +-------- webapi/core/prompt_options.py | 22 ++++++++++++ webapi/schemas/prompt_schema.py | 19 +++++++++-- .../tests/functional/test_options_routes.py | 2 ++ .../tests/functional/test_prompts_routes.py | 34 +++++++++++++++++++ 5 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 webapi/core/prompt_options.py diff --git a/webapi/api/endpoints/v1/options.py b/webapi/api/endpoints/v1/options.py index 54edd70..ac5234a 100644 --- a/webapi/api/endpoints/v1/options.py +++ b/webapi/api/endpoints/v1/options.py @@ -1,26 +1,12 @@ from fastapi import APIRouter, Depends from auth.auth_service import get_current_db_user +from core.prompt_options import CATEGORY_OPTIONS, MODEL_OPTIONS from models.user import User router = APIRouter() -CATEGORY_OPTIONS = [ - {"value": "qa", "label": "QA"}, - {"value": "dev", "label": "Development"}, - {"value": "ops", "label": "Operations"}, - {"value": "writing", "label": "Writing"}, - {"value": "research", "label": "Research"}, -] - -MODEL_OPTIONS = [ - {"value": "gpt-4.1", "label": "GPT-4.1"}, - {"value": "gpt-4o-mini", "label": "GPT-4o mini"}, - {"value": "gpt-5", "label": "GPT-5"}, - {"value": "gpt-5-mini", "label": "GPT-5 mini"}, -] - @router.get("/categories") def read_categories(_current_user: User = Depends(get_current_db_user)): diff --git a/webapi/core/prompt_options.py b/webapi/core/prompt_options.py new file mode 100644 index 0000000..897cc3b --- /dev/null +++ b/webapi/core/prompt_options.py @@ -0,0 +1,22 @@ +MODEL_NAME_MAX_CHARS = 30 + +CATEGORY_OPTIONS = [ + {"value": "qa", "label": "QA"}, + {"value": "dev", "label": "Development"}, + {"value": "ops", "label": "Operations"}, + {"value": "writing", "label": "Writing"}, + {"value": "research", "label": "Research"}, +] + +MODEL_OPTIONS = [ + {"value": "gpt-4.1", "label": "GPT-4.1"}, + {"value": "gpt-4o-mini", "label": "GPT-4o mini"}, + {"value": "gpt-5", "label": "GPT-5"}, + {"value": "gpt-5-mini", "label": "GPT-5 mini"}, +] + +MODEL_OPTION_VALUES = frozenset(option["value"] for option in MODEL_OPTIONS) + + +def is_valid_model_name(value: str) -> bool: + return value in MODEL_OPTION_VALUES diff --git a/webapi/schemas/prompt_schema.py b/webapi/schemas/prompt_schema.py index 7166d54..84f8fe6 100644 --- a/webapi/schemas/prompt_schema.py +++ b/webapi/schemas/prompt_schema.py @@ -1,16 +1,31 @@ from typing import Optional +from core.prompt_options import MODEL_NAME_MAX_CHARS, is_valid_model_name from models.prompts import PROMPT_TEXT_MAX_CHARS -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class PromptCreate(BaseModel): user_id: Optional[int] = None - model_name: str + model_name: str = Field(min_length=1, max_length=MODEL_NAME_MAX_CHARS) prompt_text: str = Field(min_length=1, max_length=PROMPT_TEXT_MAX_CHARS) category: str rate: int = Field(ge=1, le=5) + @field_validator("model_name", mode="before") + @classmethod + def strip_model_name(cls, value: str) -> str: + if isinstance(value, str): + return value.strip() + return value + + @field_validator("model_name") + @classmethod + def validate_model_name(cls, value: str) -> str: + if not is_valid_model_name(value): + raise ValueError("Unknown model_name") + return value + class PromptRead(BaseModel): id: int diff --git a/webapi/tests/functional/test_options_routes.py b/webapi/tests/functional/test_options_routes.py index d6f5e58..f8137f8 100644 --- a/webapi/tests/functional/test_options_routes.py +++ b/webapi/tests/functional/test_options_routes.py @@ -1,4 +1,5 @@ from api.endpoints.v1.options import CATEGORY_OPTIONS, MODEL_OPTIONS +from core.prompt_options import MODEL_OPTION_VALUES def test_read_category_options(client, auth_header): @@ -53,3 +54,4 @@ def test_prompt_options_align_with_known_backend_values(client, auth_header): assert {item["value"] for item in model_response.json()["items"]} == { item["value"] for item in MODEL_OPTIONS } + assert {item["value"] for item in model_response.json()["items"]} == MODEL_OPTION_VALUES diff --git a/webapi/tests/functional/test_prompts_routes.py b/webapi/tests/functional/test_prompts_routes.py index a114af3..2acd15a 100644 --- a/webapi/tests/functional/test_prompts_routes.py +++ b/webapi/tests/functional/test_prompts_routes.py @@ -2,6 +2,7 @@ from auth.auth_service import crear_jwt from models.prompts import PROMPT_TEXT_MAX_CHARS, Prompts from models.user import User +from sqlmodel import select def auth_headers_for(user: User) -> dict[str, str]: @@ -84,6 +85,22 @@ def test_create_prompt_rejects_prompt_text_above_max_chars(client, auth_header, assert response.status_code == 422 +def test_create_prompt_rejects_unknown_model_name(client, auth_header, created_user, db_session): + payload = { + "user_id": created_user.id, + "model_name": "unknown-model", + "prompt_text": "Unknown model should not persist", + "category": "qa", + "rate": 5, + } + + response = client.post("/api/v1/prompts", json=payload, headers=auth_header) + + assert response.status_code == 422 + prompts = db_session.exec(select(Prompts)).all() + assert prompts == [] + + def test_create_prompt_unauthorized_returns_401(client, created_user): payload = { "user_id": created_user.id, @@ -265,6 +282,23 @@ def test_update_prompt_success(client, auth_header, created_prompt, created_user assert response.json()["prompt_text"] == "Updated prompt" +def test_update_prompt_rejects_unknown_model_name(client, auth_header, created_prompt, created_user, db_session): + payload = { + "user_id": created_user.id, + "model_name": "unknown-model", + "prompt_text": "Unknown model should not update", + "category": "dev", + "rate": 3, + } + + response = client.put(f"/api/v1/prompts/{created_prompt.id}", json=payload, headers=auth_header) + + assert response.status_code == 422 + db_session.refresh(created_prompt) + assert created_prompt.model_name == "gpt-4.1" + assert created_prompt.prompt_text == "existing prompt" + + def test_update_prompt_missing_returns_404(client, auth_header, created_user): payload = { "user_id": created_user.id, From 67b4ac0eb9720fec10a1ca7c599174df58284c60 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 11:01:34 -0600 Subject: [PATCH 05/15] feat: add prompt model combobox --- .../src/components/prompts/prompt-form.tsx | 15 +- frontend/src/components/ui/combobox-input.tsx | 149 ++++++++++++++++++ .../src/lib/validation/prompt-schemas.test.ts | 29 ++++ frontend/src/lib/validation/prompt-schemas.ts | 2 +- frontend/src/styles/base.css | 41 +++++ 5 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/ui/combobox-input.tsx create mode 100644 frontend/src/lib/validation/prompt-schemas.test.ts diff --git a/frontend/src/components/prompts/prompt-form.tsx b/frontend/src/components/prompts/prompt-form.tsx index 79aeab1..e37b666 100644 --- a/frontend/src/components/prompts/prompt-form.tsx +++ b/frontend/src/components/prompts/prompt-form.tsx @@ -3,6 +3,7 @@ import type { SelectOption } from '../../features/options/options-types' import type { PromptInput, PromptRecord } from '../../features/prompts/prompts-types' import { promptSchema } from '../../lib/validation/prompt-schemas' import { Button } from '../ui/button' +import { ComboboxInput } from '../ui/combobox-input' import { Input } from '../ui/input' import { RatingInput } from '../ui/rating-input' import { Select } from '../ui/select' @@ -59,6 +60,14 @@ export function PromptForm({ return } + const selectedModel = modelOptions.find((option) => option.value === parsed.data.model_name) + if (!selectedModel) { + const message = 'Select a known model from the list.' + setFieldErrors({ model_name: message }) + setError(message) + return + } + setError(null) setFieldErrors({}) await onSubmit(parsed.data) @@ -69,12 +78,12 @@ export function PromptForm({ return (
- {error}

: null}
- {isEdit ? ( diff --git a/frontend/src/components/ui/combobox-input.tsx b/frontend/src/components/ui/combobox-input.tsx new file mode 100644 index 0000000..6fd34f4 --- /dev/null +++ b/frontend/src/components/ui/combobox-input.tsx @@ -0,0 +1,149 @@ +import { + useRef, + useState, + type ChangeEvent, + type KeyboardEvent, +} from 'react' +import type { SelectOption } from '../../features/options/options-types' + +type ComboboxInputProps = { + label: string + options: SelectOption[] + value: string + disabled?: boolean + error?: string + id?: string + placeholder?: string + onChange: (value: string) => void +} + +function optionDisplayValue(value: string, options: SelectOption[]) { + return options.find((option) => option.value === value)?.label ?? value +} + +function optionMatches(option: SelectOption, search: string) { + const normalizedSearch = search.toLowerCase() + return ( + option.label.toLowerCase().includes(normalizedSearch) || + option.value.toLowerCase().includes(normalizedSearch) + ) +} + +export function ComboboxInput({ + label, + options, + value, + disabled = false, + error, + id, + placeholder = 'Type to search', + onChange, +}: ComboboxInputProps) { + const inputId = id ?? label.toLowerCase().replace(/\s+/g, '-') + const listboxId = `${inputId}-options` + const inputRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + + const inputValue = optionDisplayValue(value, options) + const search = inputValue.trim() + const filteredOptions = search + ? options.filter((option) => optionMatches(option, search)) + : options + const selectedOption = options.find((option) => option.value === value) + const hasUnknownValue = value.trim().length > 0 && options.length > 0 && !selectedOption + const guidance = hasUnknownValue ? 'Select a known model from the list.' : null + const showOptions = isOpen && !disabled && filteredOptions.length > 0 + const activeOptionIndex = Math.min(activeIndex, Math.max(filteredOptions.length - 1, 0)) + + const selectOption = (option: SelectOption) => { + onChange(option.value) + setIsOpen(false) + setActiveIndex(0) + inputRef.current?.focus() + } + + const handleInputChange = (event: ChangeEvent) => { + const nextValue = event.target.value + setActiveIndex(0) + setIsOpen(true) + onChange(nextValue) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + setIsOpen(true) + setActiveIndex((current) => Math.min(current + 1, Math.max(filteredOptions.length - 1, 0))) + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + setActiveIndex((current) => Math.max(current - 1, 0)) + return + } + + if (event.key === 'Enter' && showOptions) { + event.preventDefault() + const option = filteredOptions[activeOptionIndex] + if (option) { + selectOption(option) + } + } + } + + return ( +
+ + setIsOpen(false)} + onChange={handleInputChange} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + /> + {showOptions ? ( +
+ {filteredOptions.map((option, index) => ( + + ))} +
+ ) : null} + {error ?

{error}

: null} + {!error && guidance ?

{guidance}

: null} +
+ ) +} diff --git a/frontend/src/lib/validation/prompt-schemas.test.ts b/frontend/src/lib/validation/prompt-schemas.test.ts new file mode 100644 index 0000000..fdd7ecd --- /dev/null +++ b/frontend/src/lib/validation/prompt-schemas.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { promptSchema } from './prompt-schemas' + +const validPrompt = { + model_name: 'gpt-4.1', + prompt_text: 'Generate answer', + category: 'qa', + rate: 5, +} + +describe('promptSchema', () => { + it('trims model_name before submit', () => { + const parsed = promptSchema.parse({ + ...validPrompt, + model_name: ' gpt-4.1 ', + }) + + expect(parsed.model_name).toBe('gpt-4.1') + }) + + it('rejects blank model_name', () => { + const parsed = promptSchema.safeParse({ + ...validPrompt, + model_name: ' ', + }) + + expect(parsed.success).toBe(false) + }) +}) diff --git a/frontend/src/lib/validation/prompt-schemas.ts b/frontend/src/lib/validation/prompt-schemas.ts index e8e11cb..0bc74e9 100644 --- a/frontend/src/lib/validation/prompt-schemas.ts +++ b/frontend/src/lib/validation/prompt-schemas.ts @@ -3,7 +3,7 @@ import { z } from 'zod' const PROMPT_TEXT_MAX_CHARS = 1_000_000 export const promptSchema = z.object({ - model_name: z.string().min(1, 'Model name is required'), + model_name: z.string().trim().min(1, 'Model name is required'), prompt_text: z .string() .min(1, 'Prompt text is required') diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index df057d8..751c8a6 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -220,6 +220,47 @@ h2 { outline-offset: 1px; } +.field-combobox { + position: relative; +} + +.combobox-options { + position: absolute; + top: calc(100% - 0.2rem); + left: 0; + z-index: 20; + display: grid; + width: 100%; + max-height: 14rem; + overflow: auto; + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + box-shadow: var(--shadow); +} + +.combobox-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + width: 100%; + border: 0; + border-radius: var(--radius-sm); + padding: 0.55rem 0.65rem; + color: var(--text); + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.combobox-option:hover, +.combobox-option-active { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + .rating-group { display: flex; gap: var(--space-2); From 0e5afe714244eb584285d8bbd3f8cc1e8b9e01e8 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 11:01:39 -0600 Subject: [PATCH 06/15] fix: initialize auth readiness state --- frontend/src/features/auth/auth-store.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/auth/auth-store.tsx b/frontend/src/features/auth/auth-store.tsx index d7aa92e..3002416 100644 --- a/frontend/src/features/auth/auth-store.tsx +++ b/frontend/src/features/auth/auth-store.tsx @@ -34,27 +34,26 @@ type AuthProviderProps = { } export function AuthProvider({ children }: AuthProviderProps) { + const [initialToken] = useState(() => + typeof sessionStorage === 'undefined' ? null : sessionStorage.getItem(SESSION_TOKEN_KEY), + ) const [session, setSession] = useState({ token: null, username: null, userId: null, role: null, }) - const [isReady, setIsReady] = useState(false) + const [isReady, setIsReady] = useState(() => !initialToken) useEffect(() => { - const token = sessionStorage.getItem(SESSION_TOKEN_KEY) - if (!token) { - setIsReady(true) - return - } + if (!initialToken) return let cancelled = false - getCurrentUser(token) + getCurrentUser(initialToken) .then((user) => { if (cancelled) return setSession({ - token, + token: initialToken, username: user.username, userId: user.id, role: user.role, @@ -70,7 +69,7 @@ export function AuthProvider({ children }: AuthProviderProps) { return () => { cancelled = true } - }, []) + }, [initialToken]) const login = useCallback(async (username: string, password: string) => { const resolved = await loginAndResolveUserId({ username, password }) From a9b5fdc0dbb566834bfaf7a597e52193a2f845fc Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 11:23:26 -0600 Subject: [PATCH 07/15] place prod models and viable category options --- webapi/core/prompt_options.py | 37 +++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/webapi/core/prompt_options.py b/webapi/core/prompt_options.py index 897cc3b..4471394 100644 --- a/webapi/core/prompt_options.py +++ b/webapi/core/prompt_options.py @@ -1,18 +1,39 @@ MODEL_NAME_MAX_CHARS = 30 CATEGORY_OPTIONS = [ - {"value": "qa", "label": "QA"}, - {"value": "dev", "label": "Development"}, - {"value": "ops", "label": "Operations"}, - {"value": "writing", "label": "Writing"}, + {"value": "others", "label": "Others"}, + {"value": "development", "label": "Software Development"}, + {"value": "data_analysis", "label": "Data Analysis"}, + {"value": "writing", "label": "Writing & Editing"}, {"value": "research", "label": "Research"}, + {"value": "marketing_sales", "label": "Marketing & Sales"}, + {"value": "finance", "label": "Finance & Investing"}, + {"value": "science_education", "label": "Science & Education"}, + {"value": "personal_development", "label": "Personal Development"}, + {"value": "productivity_ops", "label": "Productivity & Operations"}, ] MODEL_OPTIONS = [ - {"value": "gpt-4.1", "label": "GPT-4.1"}, - {"value": "gpt-4o-mini", "label": "GPT-4o mini"}, - {"value": "gpt-5", "label": "GPT-5"}, - {"value": "gpt-5-mini", "label": "GPT-5 mini"}, + {"value": "gpt", "label": "GPT"}, + {"value": "claude", "label": "Claude"}, + {"value": "gemini", "label": "Gemini"}, + {"value": "deepseek", "label": "DeepSeek"}, + {"value": "llama", "label": "Llama"}, + {"value": "qwen", "label": "Qwen"}, + {"value": "mistral", "label": "Mistral"}, + {"value": "grok", "label": "Grok"}, + {"value": "command", "label": "Command"}, + {"value": "kimi", "label": "Kimi"}, + {"value": "gemma", "label": "Gemma"}, + {"value": "phi", "label": "Phi"}, + {"value": "glm", "label": "GLM"}, + {"value": "nova", "label": "Nova"}, + {"value": "jamba", "label": "Jamba"}, + {"value": "yi", "label": "Yi"}, + {"value": "falcon", "label": "Falcon"}, + {"value": "mixtral", "label": "Mixtral"}, + {"value": "sonar", "label": "Sonar"}, + {"value": "dbrx", "label": "DBRX"}, ] MODEL_OPTION_VALUES = frozenset(option["value"] for option in MODEL_OPTIONS) From 5ed4918ba5435532c8c60bc749218a1f4e786a65 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 11:33:04 -0600 Subject: [PATCH 08/15] update(frontend): compacted promprt view --- .../prompts/prompt-detail-dialog.tsx | 67 ++++++++++++++ .../src/components/prompts/prompt-list.tsx | 65 ++++++++----- frontend/src/styles/base.css | 92 +++++++++++++++++++ 3 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/prompts/prompt-detail-dialog.tsx diff --git a/frontend/src/components/prompts/prompt-detail-dialog.tsx b/frontend/src/components/prompts/prompt-detail-dialog.tsx new file mode 100644 index 0000000..6c0dcab --- /dev/null +++ b/frontend/src/components/prompts/prompt-detail-dialog.tsx @@ -0,0 +1,67 @@ +import type { PromptRecord } from '../../features/prompts/prompts-types' +import { Button } from '../ui/button' + +type PromptDetailDialogProps = { + prompt: PromptRecord | null + onClose: () => void + onEdit: (prompt: PromptRecord) => void + onDelete: (prompt: PromptRecord) => void +} + +export function PromptDetailDialog({ + prompt, + onClose, + onEdit, + onDelete, +}: PromptDetailDialogProps) { + if (!prompt) return null + + const handleEdit = () => { + onClose() + onEdit(prompt) + } + + const handleDelete = () => { + onClose() + onDelete(prompt) + } + + return ( +
+
event.stopPropagation()} + > +

Prompt details

+
+
+ Model +

{prompt.model_name}

+
+
+ Prompt +

{prompt.prompt_text}

+
+
+ {prompt.category} + Rating {prompt.rate}/5 +
+
+
+ + + +
+
+
+ ) +} diff --git a/frontend/src/components/prompts/prompt-list.tsx b/frontend/src/components/prompts/prompt-list.tsx index dfc7a4a..ba7f2ab 100644 --- a/frontend/src/components/prompts/prompt-list.tsx +++ b/frontend/src/components/prompts/prompt-list.tsx @@ -1,6 +1,7 @@ +import { useState } from 'react' import type { PromptRecord } from '../../features/prompts/prompts-types' -import { Button } from '../ui/button' import { EmptyState } from '../ui/empty-state' +import { PromptDetailDialog } from './prompt-detail-dialog' type PromptListProps = { prompts: PromptRecord[] @@ -8,7 +9,19 @@ type PromptListProps = { onDelete: (prompt: PromptRecord) => void } +function summarizePromptText(text: string, maxLength = 140) { + const normalizedText = text.trim().replace(/\s+/g, ' ') + + if (normalizedText.length <= maxLength) { + return normalizedText + } + + return `${normalizedText.slice(0, maxLength).trimEnd()}...` +} + export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) { + const [selectedPrompt, setSelectedPrompt] = useState(null) + if (!prompts.length) { return ( - {prompts.map((prompt) => ( -
-
-

{prompt.model_name}

-

{prompt.prompt_text}

-

- {prompt.category} ยท Rating {prompt.rate}/5 -

-
-
- - -
-
- ))} -
+ <> +
+ {prompts.map((prompt) => { + const summary = summarizePromptText(prompt.prompt_text) + + return ( + + ) + })} +
+ setSelectedPrompt(null)} + onEdit={onEdit} + onDelete={onDelete} + /> + ) } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 751c8a6..4b6b452 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -313,6 +313,89 @@ h2 { gap: var(--space-4); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.prompt-list { + gap: var(--space-2); +} + +.prompt-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--space-3); + color: inherit; + background: var(--surface); + font: inherit; + text-align: left; + cursor: pointer; + transition: + border-color var(--transition-fast), + background-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +.prompt-list-item:hover, +.prompt-list-item:focus-visible { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + background: color-mix(in srgb, var(--accent) 6%, var(--surface)); +} + +.prompt-list-item:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 2px; +} + +.prompt-list-summary { + min-width: 0; + overflow: hidden; + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.prompt-list-meta, +.prompt-detail-meta { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.prompt-detail-dialog { + width: min(640px, 100%); +} + +.prompt-detail-body { + display: grid; + gap: var(--space-4); +} + +.prompt-detail-field { + display: grid; + gap: var(--space-1); +} + +.prompt-detail-text { + max-height: min(46vh, 28rem); + overflow: auto; + white-space: pre-wrap; +} + .section-heading { display: flex; justify-content: space-between; @@ -502,6 +585,15 @@ h2 { flex-direction: column; } + .prompt-list-item { + grid-template-columns: 1fr; + align-items: flex-start; + } + + .prompt-list-summary { + white-space: normal; + } + .topbar { flex-direction: column; align-items: flex-start; From 65cf55204788e79b1612876ce4af9779f207b16e Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 13:00:19 -0600 Subject: [PATCH 09/15] fix(auth): force public signup to create default users --- webapi/api/endpoints/v1/auths.py | 4 +--- webapi/models/user.py | 7 ++++++- webapi/schemas/user_schema.py | 2 -- webapi/tests/functional/test_auth_routes.py | 13 +++++++++---- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/webapi/api/endpoints/v1/auths.py b/webapi/api/endpoints/v1/auths.py index 28ed757..99b41d2 100644 --- a/webapi/api/endpoints/v1/auths.py +++ b/webapi/api/endpoints/v1/auths.py @@ -35,15 +35,13 @@ def signup(payload: UserCreate, session: Session = Depends(get_session)): user_exists = result.one_or_none() if user_exists: raise HTTPException(status_code=400, detail="username already taken") - if payload.role not in {"user", "admin", "god"}: - raise HTTPException(status_code=400, detail="invalid role") user = User( username=payload.username, name=payload.name, last_name=payload.last_name, email=payload.email, hashed_password=sha256_crypt.hash(payload.password), - role=payload.role, + role="user", ) session.add(user) session.commit() diff --git a/webapi/models/user.py b/webapi/models/user.py index 1a3eb5d..52974b3 100644 --- a/webapi/models/user.py +++ b/webapi/models/user.py @@ -11,7 +11,12 @@ class User(SQLModel, table=True): last_name: str = Field(max_length=100, index=True, nullable=False) email: EmailStr = Field(max_length=100, nullable=False) hashed_password: str = Field(max_length=255) # Longer for bcrypt hashes - role: str = Field(default="user", max_length=20, nullable=False) + role: str = Field( + default="user", + max_length=20, + nullable=False, + sa_column_kwargs={"server_default": "user"}, + ) prompts: List["Prompts"] = Relationship( sa_relationship_kwargs={"lazy": "selectin"}, diff --git a/webapi/schemas/user_schema.py b/webapi/schemas/user_schema.py index 623c822..a940b3f 100644 --- a/webapi/schemas/user_schema.py +++ b/webapi/schemas/user_schema.py @@ -13,7 +13,6 @@ class UserCreate(BaseModel): "last_name": "testing", "email": "user@example.com", "password": "usertest", - "role": "user", } }, ) @@ -23,7 +22,6 @@ class UserCreate(BaseModel): last_name: str = Field(max_length=100) email: EmailStr = Field(max_length=100) password: str = Field(max_length=255) - role: str = "user" class UserRead(BaseModel): diff --git a/webapi/tests/functional/test_auth_routes.py b/webapi/tests/functional/test_auth_routes.py index ff49b7b..8e89f76 100644 --- a/webapi/tests/functional/test_auth_routes.py +++ b/webapi/tests/functional/test_auth_routes.py @@ -17,6 +17,7 @@ def test_signup_success(client, user_payload, db_session): created = db_session.exec(select(User).where(User.username == user_payload["username"])).first() assert created is not None assert isinstance(created.id, int) + assert created.role == "user" assert created.hashed_password != user_payload["password"] assert sha256_crypt.verify(user_payload["password"], created.hashed_password) @@ -56,6 +57,9 @@ def test_signup_openapi_schema_does_not_expose_storage_fields(client): assert "password" in user_create_schema["properties"] assert "password" in user_create_schema.get("required", []) assert "password" in example + assert "role" not in user_create_schema["properties"] + assert "role" not in user_create_schema.get("required", []) + assert "role" not in example def test_signup_duplicate_username_returns_400(client, db_session): @@ -85,14 +89,15 @@ def test_signup_duplicate_username_returns_400(client, db_session): assert response.json()["detail"] == "username already taken" -def test_signup_invalid_role_returns_400(client, user_payload): +def test_signup_rejects_client_supplied_role(client, user_payload, db_session): response = client.post( "/api/v1/auth/signup", - json={**user_payload, "role": "superuser"}, + json={**user_payload, "role": "god"}, ) - assert response.status_code == 400 - assert response.json()["detail"] == "invalid role" + assert response.status_code == 422 + created = db_session.exec(select(User).where(User.username == user_payload["username"])).first() + assert created is None def test_login_success_returns_token(client, user_payload): From 5f78f57d98806ab7b0564111bfea270559ccc794 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 13:00:31 -0600 Subject: [PATCH 10/15] fix(auth): omit role from frontend registration --- frontend/src/features/auth/auth-types.ts | 1 - frontend/src/pages/register/register-page.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/features/auth/auth-types.ts b/frontend/src/features/auth/auth-types.ts index 07d7388..a64fd2d 100644 --- a/frontend/src/features/auth/auth-types.ts +++ b/frontend/src/features/auth/auth-types.ts @@ -14,7 +14,6 @@ export type RegisterInput = { name: string last_name: string email: string - role: 'user' } export type UserRole = 'user' | 'admin' | 'god' diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx index bf789c9..46ae9ad 100644 --- a/frontend/src/pages/register/register-page.tsx +++ b/frontend/src/pages/register/register-page.tsx @@ -29,7 +29,7 @@ export function RegisterPage() { try { setLoading(true) setError(null) - await signup({ ...parsed.data, role: 'user' }) + await signup(parsed.data) navigate('/login?registered=1', { replace: true }) } catch (err) { const message = err instanceof Error ? err.message : 'Unable to create account' From 8726ff735d5bfff5ab8b03b077c5089294b03e67 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 13:00:37 -0600 Subject: [PATCH 11/15] feat(auth): notify registered users of default role --- frontend/src/pages/login/login-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/login/login-page.tsx b/frontend/src/pages/login/login-page.tsx index 190bbf2..aaf982f 100644 --- a/frontend/src/pages/login/login-page.tsx +++ b/frontend/src/pages/login/login-page.tsx @@ -44,7 +44,7 @@ export function LoginPage() {

Use your API account credentials.

{searchParams.get('registered') ? ( -
Account created. Sign in with your new credentials.
+
Account created with the default user role. Sign in with your new credentials.
) : null} Date: Mon, 13 Jul 2026 13:00:45 -0600 Subject: [PATCH 12/15] feat(auth): add trusted god user bootstrap CLI --- webapi/admin_cli.py | 134 ++++++++++++++++++++++ webapi/tests/functional/test_admin_cli.py | 90 +++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 webapi/admin_cli.py create mode 100644 webapi/tests/functional/test_admin_cli.py diff --git a/webapi/admin_cli.py b/webapi/admin_cli.py new file mode 100644 index 0000000..efae62d --- /dev/null +++ b/webapi/admin_cli.py @@ -0,0 +1,134 @@ +import argparse +import getpass +import logging +import os +from dataclasses import dataclass + +from passlib.hash import sha256_crypt +from sqlmodel import Session, select + +from db.db_connection import engine +from models.user import User + + +SUPER_ADMIN_ROLE = "god" +LOGGER = logging.getLogger(__name__) + + +class SuperAdminBootstrapError(RuntimeError): + pass + + +@dataclass(frozen=True) +class SuperAdminBootstrapResult: + user: User + action: str + + +def bootstrap_super_admin( + session: Session, + *, + username: str, + email: str, + password: str, + name: str = "Super", + last_name: str = "Admin", +) -> SuperAdminBootstrapResult: + existing_super_admin = session.exec( + select(User).where(User.role == SUPER_ADMIN_ROLE) + ).first() + if existing_super_admin: + if existing_super_admin.username == username: + return SuperAdminBootstrapResult(user=existing_super_admin, action="already_exists") + raise SuperAdminBootstrapError("super admin already exists") + + user = session.exec(select(User).where(User.username == username)).first() + if user: + user.name = name + user.last_name = last_name + user.email = email + user.hashed_password = sha256_crypt.hash(password) + user.role = SUPER_ADMIN_ROLE + action = "promoted" + else: + user = User( + username=username, + name=name, + last_name=last_name, + email=email, + hashed_password=sha256_crypt.hash(password), + role=SUPER_ADMIN_ROLE, + ) + action = "created" + + session.add(user) + session.commit() + session.refresh(user) + LOGGER.warning( + "super_admin_bootstrapped action=%s user_id=%s username=%s", + action, + user.id, + user.username, + ) + return SuperAdminBootstrapResult(user=user, action=action) + + +def _resolve_password(password_env: str | None) -> str: + if password_env: + password = os.getenv(password_env) + if not password: + raise SuperAdminBootstrapError(f"environment variable {password_env} is not set") + return password + + password = getpass.getpass("Super admin password: ") + if not password: + raise SuperAdminBootstrapError("password is required") + return password + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Trusted administrative operations") + subparsers = parser.add_subparsers(dest="command", required=True) + + bootstrap = subparsers.add_parser( + "bootstrap-super-admin", + help="Create or promote the first super admin without exposing a public endpoint", + ) + bootstrap.add_argument("--username", required=True) + bootstrap.add_argument("--email", required=True) + bootstrap.add_argument("--name", default="Super") + bootstrap.add_argument("--last-name", default="Admin") + bootstrap.add_argument( + "--password-env", + help="Name of an environment variable containing the password; prompts securely when omitted", + ) + return parser + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + args = build_parser().parse_args() + + try: + if args.command == "bootstrap-super-admin": + password = _resolve_password(args.password_env) + with Session(engine) as session: + result = bootstrap_super_admin( + session, + username=args.username, + email=args.email, + password=password, + name=args.name, + last_name=args.last_name, + ) + print(f"super admin {result.action}: {result.user.username}") + return 0 + except SuperAdminBootstrapError as exc: + print(f"error: {exc}") + return 1 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/webapi/tests/functional/test_admin_cli.py b/webapi/tests/functional/test_admin_cli.py new file mode 100644 index 0000000..02809db --- /dev/null +++ b/webapi/tests/functional/test_admin_cli.py @@ -0,0 +1,90 @@ +import pytest +from passlib.hash import sha256_crypt +from sqlmodel import select + +from admin_cli import SuperAdminBootstrapError, bootstrap_super_admin +from models.user import User + + +def test_bootstrap_super_admin_creates_first_god_user(db_session): + result = bootstrap_super_admin( + db_session, + username="root_user", + email="root@example.com", + password="secret_password", + ) + + assert result.action == "created" + assert result.user.role == "god" + assert sha256_crypt.verify("secret_password", result.user.hashed_password) + + stored = db_session.exec(select(User).where(User.username == "root_user")).one() + assert stored.role == "god" + + +def test_bootstrap_super_admin_promotes_existing_user_when_no_god_exists(db_session): + user = User( + username="operator", + name="Regular", + last_name="User", + email="operator@example.com", + hashed_password=sha256_crypt.hash("old_password"), + ) + db_session.add(user) + db_session.commit() + + result = bootstrap_super_admin( + db_session, + username="operator", + email="operator-admin@example.com", + password="new_password", + name="Ops", + last_name="Admin", + ) + + assert result.action == "promoted" + assert result.user.role == "god" + assert result.user.email == "operator-admin@example.com" + assert sha256_crypt.verify("new_password", result.user.hashed_password) + + +def test_bootstrap_super_admin_is_idempotent_for_same_god_user(db_session): + first = bootstrap_super_admin( + db_session, + username="root_user", + email="root@example.com", + password="secret_password", + ) + original_hash = first.user.hashed_password + + second = bootstrap_super_admin( + db_session, + username="root_user", + email="changed@example.com", + password="different_password", + ) + + assert second.action == "already_exists" + assert second.user.id == first.user.id + assert second.user.email == "root@example.com" + assert second.user.hashed_password == original_hash + + +def test_bootstrap_super_admin_refuses_when_another_god_exists(db_session): + bootstrap_super_admin( + db_session, + username="root_user", + email="root@example.com", + password="secret_password", + ) + + with pytest.raises(SuperAdminBootstrapError, match="super admin already exists"): + bootstrap_super_admin( + db_session, + username="second_root", + email="second@example.com", + password="secret_password", + ) + + second = db_session.exec(select(User).where(User.username == "second_root")).first() + assert second is None From c98217ab1a107920949c54f7707f8e80c07694fa Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 13:00:50 -0600 Subject: [PATCH 13/15] docs(auth): document admin and god user elevation --- README.md | 2 +- webapi/README.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 10db201..5bc9b0b 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ If Gmail SMTP is used, use an app password rather than an account password. Scop ## Documentation - [`frontend/README.md`](frontend/README.md): frontend local development, production build, Docker, nginx proxying, and environment notes. -- [`webapi/README.md`](webapi/README.md): backend deployment, MariaDB operations, API checks, configuration, and troubleshooting. +- [`webapi/README.md`](webapi/README.md): backend deployment, MariaDB operations, API checks, [`admin` and `god` user elevation](webapi/README.md#administrative-users-and-roles), configuration, and troubleshooting. - [`webapi/tests/README.md`](webapi/tests/README.md): backend test commands and CI/CD test mapping. - [`plans/dbdiagram.md`](plans/dbdiagram.md): database and ORM diagram details for dbdiagram.io. diff --git a/webapi/README.md b/webapi/README.md index 554c9af..5c99401 100644 --- a/webapi/README.md +++ b/webapi/README.md @@ -207,10 +207,10 @@ Check the backend root endpoint directly: curl http://127.0.0.1:8000/ ``` -Create a user through the frontend proxy. Change `username` or `email` before rerunning because those fields must be unique: +Create a user through the frontend proxy. Change `username` or `email` before rerunning because those fields must be unique. The signup payload intentionally uses `password` and omits `role`; public registration always creates a default `user` account: ```bash -curl -X POST http://127.0.0.1:8080/api/v1/auth/signup -H "Content-Type: application/json" -d '{"username":"demo_user_001","name":"Demo","last_name":"User","email":"demo001@example.com","hashed_password":"demo-password"}' +curl -X POST http://127.0.0.1:8080/api/v1/auth/signup -H "Content-Type: application/json" -d '{"username":"demo_user_001","name":"Demo","last_name":"User","email":"demo001@example.com","password":"demo-password"}' ``` Login and store the token without requiring `jq`: @@ -233,6 +233,119 @@ curl http://127.0.0.1:8080/api/v1/prompts -H "Authorization: Bearer $TOKEN" For direct backend checks, replace `http://127.0.0.1:8080/api/v1` with `http://127.0.0.1:8000/api/v1` in the same API commands. +## Administrative Users And Roles + +The current system stores authorization level in the `role` column on the `user` table. Role checks are enforced by the backend; the frontend must not be treated as an authorization boundary. + +Supported roles: + +- `user`: default role for every account created through the frontend registration UI or `/api/v1/auth/signup`. +- `admin`: elevated administrative role used by prompt routes for broader read access. +- `god`: highest privilege role used for cross-user prompt creation, update, delete, and full administrative access. + +Public registration behavior: + +- Users created through the frontend registration UI are always assigned `user`. +- Users created through `/api/v1/auth/signup` are always assigned `user` server-side. +- Signup requests must not include `role`, `id`, or `hashed_password`. +- Sending `role` to signup is rejected by backend validation instead of creating an elevated account. +- There is intentionally no public HTTP endpoint or frontend form for granting `admin` or `god` privileges. + +Bootstrap the first `god` user with the backend CLI from `webapi/`: + +```bash +cd webapi +python -m admin_cli bootstrap-super-admin \ + --username root_admin \ + --email root-admin@example.com +``` + +When `--password-env` is omitted, the command prompts for the password securely. For non-interactive automation, pass the name of an environment variable containing the password, source that value from a trusted secret store, and remove it immediately afterward: + +```bash +cd webapi +export BOOTSTRAP_GOD_PASSWORD='replace-with-a-local-secret' +python -m admin_cli bootstrap-super-admin \ + --username root_admin \ + --email root-admin@example.com \ + --password-env BOOTSTRAP_GOD_PASSWORD +unset BOOTSTRAP_GOD_PASSWORD +``` + +Run the same bootstrap command inside the Compose backend container from the repository root: + +```bash +docker compose exec backend python -m admin_cli bootstrap-super-admin \ + --username root_admin \ + --email root-admin@example.com +``` + +Use `docker-compose exec backend ...` if your environment only has the legacy Compose command. + +Expected CLI outcomes: + +- `super admin created: root_admin`: a new `god` user was created. +- `super admin promoted: root_admin`: an existing ordinary user was promoted because no `god` user existed yet. +- `super admin already_exists: root_admin`: that same `god` user already exists and credentials were not reset. +- `error: super admin already exists`: a different `god` user already exists; use controlled manual elevation only if an operator intentionally wants another `god` user. + +Manual elevation to `admin` or `god` should be performed only from a trusted operational environment. First, create the target account through the frontend UI or signup endpoint; it will start as `user`. + +Open a MariaDB shell in the Compose database service: + +```bash +docker compose exec mariadb sh -c 'MYSQL_PWD="$MARIADB_PASSWORD" mariadb "$MARIADB_DATABASE" -u "$MARIADB_USER"' +``` + +Inspect the target account before changing it: + +```sql +SELECT id, username, email, role FROM `user` WHERE username = 'target_username'; +``` + +Promote the account to `admin`: + +```sql +UPDATE `user` SET role = 'admin' WHERE username = 'target_username'; +``` + +Promote the account to `god` only when intentionally granting the highest privilege level: + +```sql +UPDATE `user` SET role = 'god' WHERE username = 'target_username'; +``` + +Verify the final role: + +```sql +SELECT id, username, email, role FROM `user` WHERE username = 'target_username'; +``` + +After manual elevation, the user should refresh the frontend session or log out and back in. Backend permissions read the current database user, but frontend navigation state can remain stale until profile/session state refreshes. + +For local SQLite development without MariaDB, open the SQLite database and use quoted table names: + +```bash +cd webapi +sqlite3 crud_data.db +``` + +```sql +SELECT id, username, email, role FROM "user" WHERE username = 'target_username'; +UPDATE "user" SET role = 'admin' WHERE username = 'target_username'; +UPDATE "user" SET role = 'god' WHERE username = 'target_username'; +``` + +Security guardrails: + +- Do not add role selection to the frontend registration form. +- Do not send `role` in public signup requests. +- Do not commit admin passwords, password hashes, bootstrap environment variables, or SQL dumps containing credentials. +- Prefer the CLI for first `god` bootstrap because it avoids exposing a public HTTP privilege-escalation endpoint. +- Use direct SQL elevation only from trusted operational environments. +- Record who performed manual role elevation and why, because the current app does not have a persistent audit-events table. +- Rotate or unset bootstrap password environment variables immediately after use. + ## Database Access Open a MariaDB shell inside the Compose database service: From 6c57552687db50080aa11de3a11580beec335f70 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Mon, 13 Jul 2026 13:36:17 -0600 Subject: [PATCH 14/15] fix(auth): repair admin cli db connection --- webapi/README.md | 8 +- webapi/admin_cli.py | 94 ++++++++++++++++++++--- webapi/tests/functional/test_admin_cli.py | 24 +++++- 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/webapi/README.md b/webapi/README.md index 5c99401..e581909 100644 --- a/webapi/README.md +++ b/webapi/README.md @@ -251,13 +251,14 @@ Public registration behavior: - Sending `role` to signup is rejected by backend validation instead of creating an elevated account. - There is intentionally no public HTTP endpoint or frontend form for granting `admin` or `god` privileges. -Bootstrap the first `god` user with the backend CLI from `webapi/`: +Bootstrap the first `god` user with the backend CLI from `webapi/`. When running this command directly from your host shell, the database must be reachable through the published MariaDB host port. If you pass a Compose URL that uses host `mariadb`, the CLI retries through `127.0.0.1:${MARIADB_HOST_PORT:-3306}` because `mariadb` only resolves inside Compose containers: ```bash cd webapi python -m admin_cli bootstrap-super-admin \ --username root_admin \ - --email root-admin@example.com + --email root-admin@example.com \ + --db-url "mariadb+mariadbconnector://webapi_user:replace_with_local_database_password@mariadb:3306/crud_data" ``` When `--password-env` is omitted, the command prompts for the password securely. For non-interactive automation, pass the name of an environment variable containing the password, source that value from a trusted secret store, and remove it immediately afterward: @@ -268,7 +269,8 @@ export BOOTSTRAP_GOD_PASSWORD='replace-with-a-local-secret' python -m admin_cli bootstrap-super-admin \ --username root_admin \ --email root-admin@example.com \ - --password-env BOOTSTRAP_GOD_PASSWORD + --password-env BOOTSTRAP_GOD_PASSWORD \ + --db-url "mariadb+mariadbconnector://webapi_user:replace_with_local_database_password@127.0.0.1:3306/crud_data" unset BOOTSTRAP_GOD_PASSWORD ``` diff --git a/webapi/admin_cli.py b/webapi/admin_cli.py index efae62d..942199b 100644 --- a/webapi/admin_cli.py +++ b/webapi/admin_cli.py @@ -5,9 +5,11 @@ from dataclasses import dataclass from passlib.hash import sha256_crypt -from sqlmodel import Session, select +from sqlalchemy.engine import make_url +from sqlalchemy.exc import OperationalError +from sqlmodel import SQLModel, Session, create_engine, select -from db.db_connection import engine +from core import config from models.user import User @@ -102,9 +104,67 @@ def build_parser() -> argparse.ArgumentParser: "--password-env", help="Name of an environment variable containing the password; prompts securely when omitted", ) + bootstrap.add_argument( + "--db-url", + help="Database URL override. Defaults to DB_URL from the environment or app config.", + ) return parser +def create_cli_engine(db_url: str | None = None): + return create_engine(db_url or config.DB_URL, echo=True) + + +def _compose_host_fallback_db_url(db_url: str | None = None) -> str | None: + configured_url = make_url(db_url or config.DB_URL) + if configured_url.host != "mariadb": + return None + + port_text = os.getenv("MARIADB_HOST_PORT") or str(configured_url.port or 3306) + try: + port = int(port_text) + except ValueError as exc: + raise SuperAdminBootstrapError("MARIADB_HOST_PORT must be an integer") from exc + + return configured_url.set(host="127.0.0.1", port=port).render_as_string( + hide_password=False + ) + + +def _database_error_message(db_url: str | None = None) -> str: + configured_url = make_url(db_url or config.DB_URL) + message = ( + "error: unable to connect to the configured database. " + "Verify DB_URL is reachable from where this command is running." + ) + + if configured_url.host == "mariadb": + message += ( + " The hostname 'mariadb' only resolves inside the Docker Compose " + "network. Run this command with `docker compose exec backend ...` " + "or use a host-reachable DB URL such as " + "`mariadb+mariadbconnector://USER:PASSWORD@127.0.0.1:3306/DB_NAME`." + ) + + return message + + +def _run_bootstrap_command(args, password: str, db_url: str | None = None) -> int: + engine = create_cli_engine(db_url) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + result = bootstrap_super_admin( + session, + username=args.username, + email=args.email, + password=password, + name=args.name, + last_name=args.last_name, + ) + print(f"super admin {result.action}: {result.user.username}") + return 0 + + def main() -> int: logging.basicConfig(level=logging.INFO) args = build_parser().parse_args() @@ -112,20 +172,30 @@ def main() -> int: try: if args.command == "bootstrap-super-admin": password = _resolve_password(args.password_env) - with Session(engine) as session: - result = bootstrap_super_admin( - session, - username=args.username, - email=args.email, - password=password, - name=args.name, - last_name=args.last_name, + try: + return _run_bootstrap_command(args, password, args.db_url) + except OperationalError as exc: + fallback_url = _compose_host_fallback_db_url(args.db_url) + if not fallback_url: + raise + + fallback_port = make_url(fallback_url).port or 3306 + print( + "warning: could not connect to Compose hostname 'mariadb'; " + f"retrying through 127.0.0.1:{fallback_port}." ) - print(f"super admin {result.action}: {result.user.username}") - return 0 + try: + return _run_bootstrap_command(args, password, fallback_url) + except OperationalError: + LOGGER.debug("database fallback connection failed", exc_info=True) + raise except SuperAdminBootstrapError as exc: print(f"error: {exc}") return 1 + except OperationalError as exc: + print(_database_error_message(args.db_url if hasattr(args, "db_url") else None)) + LOGGER.debug("database connection failed", exc_info=True) + return 1 return 1 diff --git a/webapi/tests/functional/test_admin_cli.py b/webapi/tests/functional/test_admin_cli.py index 02809db..4d545c9 100644 --- a/webapi/tests/functional/test_admin_cli.py +++ b/webapi/tests/functional/test_admin_cli.py @@ -2,7 +2,11 @@ from passlib.hash import sha256_crypt from sqlmodel import select -from admin_cli import SuperAdminBootstrapError, bootstrap_super_admin +from admin_cli import ( + SuperAdminBootstrapError, + _compose_host_fallback_db_url, + bootstrap_super_admin, +) from models.user import User @@ -88,3 +92,21 @@ def test_bootstrap_super_admin_refuses_when_another_god_exists(db_session): second = db_session.exec(select(User).where(User.username == "second_root")).first() assert second is None + + +def test_compose_host_fallback_rewrites_mariadb_to_localhost(monkeypatch): + monkeypatch.setenv("MARIADB_HOST_PORT", "3307") + + fallback = _compose_host_fallback_db_url( + "mariadb+mariadbconnector://webapi_user:devepass@mariadb:3306/crud_data" + ) + + assert fallback == "mariadb+mariadbconnector://webapi_user:devepass@127.0.0.1:3307/crud_data" + + +def test_compose_host_fallback_ignores_non_compose_hosts(): + fallback = _compose_host_fallback_db_url( + "mariadb+mariadbconnector://webapi_user:devepass@127.0.0.1:3306/crud_data" + ) + + assert fallback is None From ef981ed716daa3385c0bed91be4d0d15b77268f7 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Tue, 14 Jul 2026 09:02:51 -0600 Subject: [PATCH 15/15] fix(tests): align prompt models with catalog --- .../src/lib/validation/prompt-schemas.test.ts | 6 +- webapi/tests/conftest.py | 6 +- .../tests/functional/test_options_routes.py | 9 +-- .../tests/functional/test_prompts_routes.py | 66 ++++++++++++------- webapi/tests/nonfunctional/test_ci.py | 6 +- 5 files changed, 58 insertions(+), 35 deletions(-) diff --git a/frontend/src/lib/validation/prompt-schemas.test.ts b/frontend/src/lib/validation/prompt-schemas.test.ts index fdd7ecd..dd6c928 100644 --- a/frontend/src/lib/validation/prompt-schemas.test.ts +++ b/frontend/src/lib/validation/prompt-schemas.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { promptSchema } from './prompt-schemas' const validPrompt = { - model_name: 'gpt-4.1', + model_name: 'gpt', prompt_text: 'Generate answer', category: 'qa', rate: 5, @@ -12,10 +12,10 @@ describe('promptSchema', () => { it('trims model_name before submit', () => { const parsed = promptSchema.parse({ ...validPrompt, - model_name: ' gpt-4.1 ', + model_name: ' gpt ', }) - expect(parsed.model_name).toBe('gpt-4.1') + expect(parsed.model_name).toBe('gpt') }) it('rejects blank model_name', () => { diff --git a/webapi/tests/conftest.py b/webapi/tests/conftest.py index ba435fe..136ee7f 100644 --- a/webapi/tests/conftest.py +++ b/webapi/tests/conftest.py @@ -13,10 +13,14 @@ from db.db_connection import get_session from db.redis_connection import get_redis from auth.auth_service import crear_jwt +from core.prompt_options import MODEL_OPTIONS from models.user import User from models.prompts import Prompts +VALID_MODEL_NAME = MODEL_OPTIONS[0]["value"] + + class FakeRedis: def __init__(self): self.store: dict[str, str] = {} @@ -106,7 +110,7 @@ def auth_header(created_user): def created_prompt(db_session, created_user): prompt = Prompts( user_id=created_user.id, - model_name="gpt-4.1", + model_name=VALID_MODEL_NAME, prompt_text="existing prompt", category="qa", rate=5, diff --git a/webapi/tests/functional/test_options_routes.py b/webapi/tests/functional/test_options_routes.py index f8137f8..e456223 100644 --- a/webapi/tests/functional/test_options_routes.py +++ b/webapi/tests/functional/test_options_routes.py @@ -1,19 +1,20 @@ -from api.endpoints.v1.options import CATEGORY_OPTIONS, MODEL_OPTIONS -from core.prompt_options import MODEL_OPTION_VALUES +from core.prompt_options import CATEGORY_OPTIONS, MODEL_OPTION_VALUES, MODEL_OPTIONS def test_read_category_options(client, auth_header): response = client.get("/api/v1/options/categories", headers=auth_header) assert response.status_code == 200 - assert response.json()["items"][0] == {"value": "qa", "label": "QA"} + assert response.json()["items"] + assert response.json()["items"][0] == CATEGORY_OPTIONS[0] def test_read_model_options(client, auth_header): response = client.get("/api/v1/options/models", headers=auth_header) assert response.status_code == 200 - assert response.json()["items"][0] == {"value": "gpt-4.1", "label": "GPT-4.1"} + assert response.json()["items"] + assert response.json()["items"][0] == MODEL_OPTIONS[0] def test_read_category_options_unauthorized(client): diff --git a/webapi/tests/functional/test_prompts_routes.py b/webapi/tests/functional/test_prompts_routes.py index 2acd15a..5e0a200 100644 --- a/webapi/tests/functional/test_prompts_routes.py +++ b/webapi/tests/functional/test_prompts_routes.py @@ -1,10 +1,15 @@ import api.endpoints.v1.prompts as prompts_module from auth.auth_service import crear_jwt +from core.prompt_options import MODEL_OPTIONS from models.prompts import PROMPT_TEXT_MAX_CHARS, Prompts from models.user import User from sqlmodel import select +VALID_MODEL_NAME = MODEL_OPTIONS[0]["value"] +ALTERNATE_MODEL_NAME = MODEL_OPTIONS[1]["value"] + + def auth_headers_for(user: User) -> dict[str, str]: token = crear_jwt({"sub": user.username, "role": user.role, "user_id": user.id}) return {"Authorization": f"Bearer {token}"} @@ -25,7 +30,13 @@ def create_user(db_session, username: str, role: str = "user") -> User: return user -def create_prompt(db_session, user_id: int, category: str = "qa", model_name: str = "gpt-4.1", rate: int = 5) -> Prompts: +def create_prompt( + db_session, + user_id: int, + category: str = "qa", + model_name: str = VALID_MODEL_NAME, + rate: int = 5, +) -> Prompts: prompt = Prompts( user_id=user_id, model_name=model_name, @@ -42,7 +53,7 @@ def create_prompt(db_session, user_id: int, category: str = "qa", model_name: st def test_create_prompt_success(client, auth_header, created_user): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Generate answer", "category": "qa", "rate": 5, @@ -52,14 +63,14 @@ def test_create_prompt_success(client, auth_header, created_user): assert response.status_code == 200 assert response.json()["user_id"] == created_user.id - assert response.json()["model_name"] == "gpt-4.1" + assert response.json()["model_name"] == VALID_MODEL_NAME def test_create_prompt_accepts_prompt_text_longer_than_150_chars(client, auth_header, created_user): prompt_text = "x" * 151 payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": prompt_text, "category": "qa", "rate": 5, @@ -74,7 +85,7 @@ def test_create_prompt_accepts_prompt_text_longer_than_150_chars(client, auth_he def test_create_prompt_rejects_prompt_text_above_max_chars(client, auth_header, created_user): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "x" * (PROMPT_TEXT_MAX_CHARS + 1), "category": "qa", "rate": 5, @@ -104,7 +115,7 @@ def test_create_prompt_rejects_unknown_model_name(client, auth_header, created_u def test_create_prompt_unauthorized_returns_401(client, created_user): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Generate answer", "category": "qa", "rate": 5, @@ -123,7 +134,7 @@ def test_create_prompt_unauthorized_returns_401(client, created_user): def test_create_prompt_user_not_found_returns_404(client, auth_header): payload = { "user_id": 9999, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Generate answer", "category": "qa", "rate": 5, @@ -139,7 +150,7 @@ def test_regular_user_cannot_create_prompt_for_another_user(client, auth_header, other_user = create_user(db_session, "create_other_user") payload = { "user_id": other_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "not allowed", "category": "qa", "rate": 5, @@ -163,7 +174,7 @@ async def fake_send_email(to, username, message_body=""): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Notify me", "category": "qa", "rate": 3, @@ -184,7 +195,7 @@ async def failing_send_email(*args, **kwargs): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Ignore email failure", "category": "ops", "rate": 1, @@ -222,12 +233,17 @@ def test_read_prompts_unauthorized_returns_401(client): def test_admin_can_filter_prompts(client, db_session, created_prompt): owner = db_session.get(User, created_prompt.user_id) admin = create_user(db_session, "filter_admin", role="admin") - create_prompt(db_session, owner.id, category="dev", model_name="gpt-4o-mini", rate=3) - create_prompt(db_session, admin.id, category="qa", model_name="gpt-4.1", rate=5) + create_prompt(db_session, owner.id, category="dev", model_name=ALTERNATE_MODEL_NAME, rate=3) + create_prompt(db_session, admin.id, category="qa", model_name=VALID_MODEL_NAME, rate=5) response = client.get( "/api/v1/prompts", - params={"user_id": owner.id, "category": "dev", "model_name": "gpt-4o-mini", "rate": 3}, + params={ + "user_id": owner.id, + "category": "dev", + "model_name": ALTERNATE_MODEL_NAME, + "rate": 3, + }, headers=auth_headers_for(admin), ) @@ -235,7 +251,7 @@ def test_admin_can_filter_prompts(client, db_session, created_prompt): assert len(response.json()) == 1 assert response.json()[0]["user_id"] == owner.id assert response.json()[0]["category"] == "dev" - assert response.json()[0]["model_name"] == "gpt-4o-mini" + assert response.json()[0]["model_name"] == ALTERNATE_MODEL_NAME assert response.json()[0]["rate"] == 3 @@ -269,7 +285,7 @@ def test_get_prompt_missing_returns_404(client, auth_header): def test_update_prompt_success(client, auth_header, created_prompt, created_user): payload = { "user_id": created_user.id, - "model_name": "gpt-4o-mini", + "model_name": ALTERNATE_MODEL_NAME, "prompt_text": "Updated prompt", "category": "dev", "rate": 3, @@ -278,7 +294,7 @@ def test_update_prompt_success(client, auth_header, created_prompt, created_user response = client.put(f"/api/v1/prompts/{created_prompt.id}", json=payload, headers=auth_header) assert response.status_code == 200 - assert response.json()["model_name"] == "gpt-4o-mini" + assert response.json()["model_name"] == ALTERNATE_MODEL_NAME assert response.json()["prompt_text"] == "Updated prompt" @@ -295,14 +311,14 @@ def test_update_prompt_rejects_unknown_model_name(client, auth_header, created_p assert response.status_code == 422 db_session.refresh(created_prompt) - assert created_prompt.model_name == "gpt-4.1" + assert created_prompt.model_name == VALID_MODEL_NAME assert created_prompt.prompt_text == "existing prompt" def test_update_prompt_missing_returns_404(client, auth_header, created_user): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Updated prompt", "category": "qa", "rate": 5, @@ -317,7 +333,7 @@ def test_update_prompt_missing_returns_404(client, auth_header, created_user): def test_update_prompt_user_not_found_returns_404(client, auth_header, created_prompt): payload = { "user_id": 9999, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Updated prompt", "category": "qa", "rate": 5, @@ -333,7 +349,7 @@ def test_god_can_update_another_users_prompt(client, db_session, created_prompt) god = create_user(db_session, "update_god", role="god") payload = { "user_id": created_prompt.user_id, - "model_name": "gpt-5", + "model_name": ALTERNATE_MODEL_NAME, "prompt_text": "god update", "category": "research", "rate": 4, @@ -346,14 +362,14 @@ def test_god_can_update_another_users_prompt(client, db_session, created_prompt) ) assert response.status_code == 200 - assert response.json()["model_name"] == "gpt-5" + assert response.json()["model_name"] == ALTERNATE_MODEL_NAME assert response.json()["prompt_text"] == "god update" def test_prompt_rating_validation_rejects_out_of_range_values(client, auth_header, created_user): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "bad rating", "category": "qa", "rate": 6, @@ -367,7 +383,7 @@ def test_prompt_rating_validation_rejects_out_of_range_values(client, auth_heade def test_prompt_rating_validation_rejects_non_integer_values(client, auth_header, created_user): payload = { "user_id": created_user.id, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "bad rating", "category": "qa", "rate": 3.5, @@ -424,7 +440,7 @@ def test_regular_user_cannot_read_another_users_prompt(client, auth_header, db_s db_session.refresh(other_user) prompt = Prompts( user_id=other_user.id, - model_name="gpt-4.1", + model_name=VALID_MODEL_NAME, prompt_text="private prompt", category="qa", rate=4, @@ -461,7 +477,7 @@ def test_admin_can_read_all_prompts_but_cannot_update(client, db_session, create f"/api/v1/prompts/{created_prompt.id}", json={ "user_id": created_prompt.user_id, - "model_name": "gpt-4o-mini", + "model_name": ALTERNATE_MODEL_NAME, "prompt_text": "admin update", "category": "dev", "rate": 3, diff --git a/webapi/tests/nonfunctional/test_ci.py b/webapi/tests/nonfunctional/test_ci.py index d236776..ce48128 100644 --- a/webapi/tests/nonfunctional/test_ci.py +++ b/webapi/tests/nonfunctional/test_ci.py @@ -2,11 +2,13 @@ import sys from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[2])) +from core.prompt_options import MODEL_OPTIONS from main import myapp client = TestClient(myapp) TEST_USER = "testuser" TEST_PSW = "testPSW" +VALID_MODEL_NAME = MODEL_OPTIONS[0]["value"] def test_root(): response = client.get("/") @@ -47,14 +49,14 @@ def test_register_prompt(): headers = {"Authorization": f"Bearer {token}", "send_email": "false"} response = client.post("/api/v1/prompts", json={ "user_id": 1, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Generate a test response", "category": "qa", "rate": 5, }, headers=headers) assert response.status_code == 200 assert response.json()["user_id"] == 1 - assert response.json()["model_name"] == "gpt-4.1" + assert response.json()["model_name"] == VALID_MODEL_NAME assert response.json()["prompt_text"] == "Generate a test response" assert response.json()["category"] == "qa" assert response.json()["rate"] == 5