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/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/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/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-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/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/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/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 }) diff --git a/frontend/src/features/auth/auth-types.ts b/frontend/src/features/auth/auth-types.ts index b0c77a7..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' @@ -28,7 +27,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/lib/validation/prompt-schemas.test.ts b/frontend/src/lib/validation/prompt-schemas.test.ts new file mode 100644 index 0000000..dd6c928 --- /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', + prompt_text: 'Generate answer', + category: 'qa', + rate: 5, +} + +describe('promptSchema', () => { + it('trims model_name before submit', () => { + const parsed = promptSchema.parse({ + ...validPrompt, + model_name: ' gpt ', + }) + + expect(parsed.model_name).toBe('gpt') + }) + + 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 5162ab7..0bc74e9 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'), + model_name: z.string().trim().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/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} 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/scripts/run-compose-stack.sh b/scripts/run-compose-stack.sh index cf1f1ec..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 @@ -313,7 +370,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}" @@ -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:80/" "${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:80\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 ' 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 diff --git a/webapi/README.md b/webapi/README.md index 554c9af..e581909 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,121 @@ 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/`. 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 \ + --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: + +```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 \ + --db-url "mariadb+mariadbconnector://webapi_user:replace_with_local_database_password@127.0.0.1:3306/crud_data" +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: diff --git a/webapi/admin_cli.py b/webapi/admin_cli.py new file mode 100644 index 0000000..942199b --- /dev/null +++ b/webapi/admin_cli.py @@ -0,0 +1,204 @@ +import argparse +import getpass +import logging +import os +from dataclasses import dataclass + +from passlib.hash import sha256_crypt +from sqlalchemy.engine import make_url +from sqlalchemy.exc import OperationalError +from sqlmodel import SQLModel, Session, create_engine, select + +from core import config +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", + ) + 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() + + try: + if args.command == "bootstrap-super-admin": + password = _resolve_password(args.password_env) + 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}." + ) + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/webapi/api/endpoints/v1/auths.py b/webapi/api/endpoints/v1/auths.py index d42fe61..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.hashed_password), - role=payload.role, + hashed_password=sha256_crypt.hash(payload.password), + role="user", ) session.add(user) session.commit() 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..4471394 --- /dev/null +++ b/webapi/core/prompt_options.py @@ -0,0 +1,43 @@ +MODEL_NAME_MAX_CHARS = 30 + +CATEGORY_OPTIONS = [ + {"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", "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) + + +def is_valid_model_name(value: str) -> bool: + return value in MODEL_OPTION_VALUES 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/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/prompt_schema.py b/webapi/schemas/prompt_schema.py index 2b7899c..84f8fe6 100644 --- a/webapi/schemas/prompt_schema.py +++ b/webapi/schemas/prompt_schema.py @@ -1,15 +1,31 @@ from typing import Optional -from pydantic import BaseModel, Field +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, field_validator class PromptCreate(BaseModel): user_id: Optional[int] = None - model_name: str - prompt_text: str = Field(max_length=150) + 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/schemas/user_schema.py b/webapi/schemas/user_schema.py index 585fdb3..a940b3f 100644 --- a/webapi/schemas/user_schema.py +++ b/webapi/schemas/user_schema.py @@ -12,8 +12,7 @@ class UserCreate(BaseModel): "name": "user", "last_name": "testing", "email": "user@example.com", - "hashed_password": "usertest", - "role": "user", + "password": "usertest", } }, ) @@ -22,8 +21,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) - role: str = "user" + password: str = Field(max_length=255) class UserRead(BaseModel): diff --git a/webapi/tests/conftest.py b/webapi/tests/conftest.py index a4263d1..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] = {} @@ -77,7 +81,7 @@ def user_payload(): "name": "Py", "last_name": "Tester", "email": "pytest_user@example.com", - "hashed_password": "pytest_password", + "password": "pytest_password", } @@ -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_admin_cli.py b/webapi/tests/functional/test_admin_cli.py new file mode 100644 index 0000000..4d545c9 --- /dev/null +++ b/webapi/tests/functional/test_admin_cli.py @@ -0,0 +1,112 @@ +import pytest +from passlib.hash import sha256_crypt +from sqlmodel import select + +from admin_cli import ( + SuperAdminBootstrapError, + _compose_host_fallback_db_url, + 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 + + +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 diff --git a/webapi/tests/functional/test_auth_routes.py b/webapi/tests/functional/test_auth_routes.py index 8b98b20..8e89f76 100644 --- a/webapi/tests/functional/test_auth_routes.py +++ b/webapi/tests/functional/test_auth_routes.py @@ -17,8 +17,9 @@ 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.role == "user" + 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 +30,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 +51,15 @@ 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 + 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): @@ -62,7 +81,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", }, ) @@ -70,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): @@ -85,7 +105,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/functional/test_options_routes.py b/webapi/tests/functional/test_options_routes.py index d6f5e58..e456223 100644 --- a/webapi/tests/functional/test_options_routes.py +++ b/webapi/tests/functional/test_options_routes.py @@ -1,18 +1,20 @@ -from api.endpoints.v1.options import CATEGORY_OPTIONS, MODEL_OPTIONS +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): @@ -53,3 +55,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 f47cf9c..5e0a200 100644 --- a/webapi/tests/functional/test_prompts_routes.py +++ b/webapi/tests/functional/test_prompts_routes.py @@ -1,7 +1,13 @@ import api.endpoints.v1.prompts as prompts_module from auth.auth_service import crear_jwt -from models.prompts import Prompts +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]: @@ -24,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, @@ -41,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, @@ -51,13 +63,59 @@ 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": VALID_MODEL_NAME, + "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": VALID_MODEL_NAME, + "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_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, - "model_name": "gpt-4.1", + "model_name": VALID_MODEL_NAME, "prompt_text": "Generate answer", "category": "qa", "rate": 5, @@ -76,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, @@ -92,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, @@ -116,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, @@ -137,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, @@ -175,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), ) @@ -188,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 @@ -222,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, @@ -231,14 +294,31 @@ 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" +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 == 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, @@ -253,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, @@ -269,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, @@ -282,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, @@ -303,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, @@ -360,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, @@ -397,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 24a6982..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("/") @@ -22,7 +24,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: @@ -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