diff --git a/docs/auth/authorization.md b/docs/auth/authorization.md index 34fa38250..9710bd2a1 100644 --- a/docs/auth/authorization.md +++ b/docs/auth/authorization.md @@ -100,7 +100,7 @@ authorization: | `query` | Submit queries | `/v1/query` | | `streaming_query` | Submit streaming queries | `/v1/streaming_query` | | `info` | Access service info | `/`, `/info`, `/readiness`, `/liveness` | -| `get_config` | View configuration | `/config` | +| `get_config` | View configuration | `/v1/config`, `/v1/saved-prompts/config` | | `get_models` | List available models | `/models` | | `get_tools` | List available tools | `/tools`, `/mcp-auth/client-options` | | `get_shields` | List safety shields | `/shields` | @@ -111,6 +111,7 @@ authorization: | `model_override` | Override model in queries | N/A (permission flag) | | `read_prompts` | List and get prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | | `manage_prompts` | Manage prompts | `/v1/prompts`, `/v1/prompts/{prompt_id}` | +| `manage_saved_prompts` | Manage own saved prompts | `/v1/saved-prompts`, `/v1/saved-prompts/{prompt_id}` | ### Conversation Actions diff --git a/docs/devel_doc/ARCHITECTURE.md b/docs/devel_doc/ARCHITECTURE.md index baa4ad12d..b3bf18635 100644 --- a/docs/devel_doc/ARCHITECTURE.md +++ b/docs/devel_doc/ARCHITECTURE.md @@ -200,6 +200,9 @@ The system defines 30+ actions that can be authorized. Examples (see `docs/auth. - `GET_CONVERSATION` - Get conversation details - `DELETE_CONVERSATION` - Delete conversations +**Saved Prompts:** +- `MANAGE_SAVED_PROMPTS` - Manage user's saved prompts (list, create, delete) + **Administrative Actions:** - `ADMIN` - Administrative operations - `FEEDBACK` - Submit user feedback diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json index 5175d2cf3..dcef0b0ea 100644 --- a/docs/devel_doc/openapi.json +++ b/docs/devel_doc/openapi.json @@ -6036,6 +6036,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -6397,6 +6413,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -6928,29 +6960,577 @@ } } } - } - } - }, - "/v1/saved-prompts/config": { - "get": { - "tags": [ - "saved-prompts" + } + } + }, + "/v1/saved-prompts/config": { + "get": { + "tags": [ + "saved-prompts" + ], + "summary": "Get Saved Prompts Config Handler", + "description": "Handle requests to the GET /saved-prompts/config endpoint.\n\nProcess GET requests that return saved prompts configuration limits so\nconsuming services can provide limits consistent with what the server\nwill enforce. For example:\n\n curl http://localhost:8080/v1/saved-prompts/config\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- auth: Authentication tuple from the auth dependency (used by middleware).\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 500 and a detail object containing `response`\n and `cause` when service configuration is not loaded.\n- HTTPException: with status 503 and a detail object containing `response`\n and `cause` when unable to connect to backend services.\n\n### Returns:\n- SavedPromptsConfigResponse: Saved prompts configuration limits.", + "operationId": "get_saved_prompts_config_handler_v1_saved_prompts_config_get", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedPromptsConfigResponse" + }, + "example": { + "max_content_length": 10000, + "max_display_name_length": 255, + "max_prompts_per_user": 50 + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "examples": { + "missing header": { + "value": { + "detail": { + "cause": "No Authorization header found", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing token": { + "value": { + "detail": { + "cause": "No token found in Authorization header", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "expired token": { + "value": { + "detail": { + "cause": "Token has expired", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid signature": { + "value": { + "detail": { + "cause": "Invalid token signature", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid key": { + "value": { + "detail": { + "cause": "Token signed by unknown key", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing claim": { + "value": { + "detail": { + "cause": "Token missing claim: user_id", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid k8s token": { + "value": { + "detail": { + "cause": "Invalid or expired Kubernetes token", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid jwk token": { + "value": { + "detail": { + "cause": "Authentication key server returned invalid data", + "response": "Missing or invalid credentials provided by client" + } + } + } + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenResponse" + }, + "examples": { + "endpoint": { + "value": { + "detail": { + "cause": "User 6789 is not authorized to access this endpoint.", + "response": "User does not have permission to access this endpoint" + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + }, + "examples": { + "configuration": { + "value": { + "detail": { + "cause": "Lightspeed Stack configuration has not been initialized.", + "response": "Configuration is not loaded" + } + } + } + } + } + } + }, + "503": { + "description": "Service unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableResponse" + }, + "examples": { + "kubernetes api": { + "value": { + "detail": { + "cause": "Failed to connect to Kubernetes API: Service Unavailable (status 503)", + "response": "Unable to connect to Kubernetes API" + } + } + } + } + } + } + } + } + } + }, + "/v1/saved-prompts": { + "get": { + "tags": [ + "saved-prompts" + ], + "summary": "List Saved Prompts Handler", + "description": "Handle requests to the GET /saved-prompts endpoint.\n\nProcess GET requests that return all saved prompts belonging to the\nauthenticated user, ordered by creation timestamp descending. For example:\n\n curl http://localhost:8080/v1/saved-prompts\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- auth: Authentication tuple from the auth dependency.\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 500 when configuration is not loaded or the\n database query fails.\n\n### Returns:\n- SavedPromptsListResponse: Saved prompts for the authenticated user.", + "operationId": "list_saved_prompts_handler_v1_saved_prompts_get", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedPromptsListResponse" + }, + "example": { + "prompts": [ + { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "examples": { + "missing header": { + "value": { + "detail": { + "cause": "No Authorization header found", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing token": { + "value": { + "detail": { + "cause": "No token found in Authorization header", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "expired token": { + "value": { + "detail": { + "cause": "Token has expired", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid signature": { + "value": { + "detail": { + "cause": "Invalid token signature", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid key": { + "value": { + "detail": { + "cause": "Token signed by unknown key", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing claim": { + "value": { + "detail": { + "cause": "Token missing claim: user_id", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid k8s token": { + "value": { + "detail": { + "cause": "Invalid or expired Kubernetes token", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid jwk token": { + "value": { + "detail": { + "cause": "Authentication key server returned invalid data", + "response": "Missing or invalid credentials provided by client" + } + } + } + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenResponse" + }, + "examples": { + "endpoint": { + "value": { + "detail": { + "cause": "User 6789 is not authorized to access this endpoint.", + "response": "User does not have permission to access this endpoint" + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + }, + "examples": { + "configuration": { + "value": { + "detail": { + "cause": "Lightspeed Stack configuration has not been initialized.", + "response": "Configuration is not loaded" + } + } + }, + "database": { + "value": { + "detail": { + "cause": "Failed to query the database", + "response": "Database query failed" + } + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "saved-prompts" + ], + "summary": "Create Saved Prompts Handler", + "description": "Handle requests to the POST /saved-prompts endpoint.\n\nProcess POST requests that create a saved prompt for the authenticated\nuser after validating name/content against configured limits. For example:\n\n curl -X POST http://localhost:8080/v1/saved-prompts \\\n -H 'Content-Type: application/json' \\\n -d '{\"name\":\"Deploy to staging\",\"content\":\"Help me write a checklist\"}'\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- body: Saved prompt name and content.\n- auth: Authentication tuple from the auth dependency.\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 409 when a prompt with the same name exists.\n- HTTPException: with status 422 when validation fails or the per-user\n limit would be exceeded.\n- HTTPException: with status 500 when configuration is not loaded or the\n database write fails.\n\n### Returns:\n- SavedPromptResponse: The created saved prompt.", + "operationId": "create_saved_prompts_handler_v1_saved_prompts_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedPromptCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedPromptResponse" + }, + "example": { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "examples": { + "missing header": { + "value": { + "detail": { + "cause": "No Authorization header found", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing token": { + "value": { + "detail": { + "cause": "No token found in Authorization header", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "expired token": { + "value": { + "detail": { + "cause": "Token has expired", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid signature": { + "value": { + "detail": { + "cause": "Invalid token signature", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid key": { + "value": { + "detail": { + "cause": "Token signed by unknown key", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "missing claim": { + "value": { + "detail": { + "cause": "Token missing claim: user_id", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid k8s token": { + "value": { + "detail": { + "cause": "Invalid or expired Kubernetes token", + "response": "Missing or invalid credentials provided by client" + } + } + }, + "invalid jwk token": { + "value": { + "detail": { + "cause": "Authentication key server returned invalid data", + "response": "Missing or invalid credentials provided by client" + } + } + } + } + } + } + }, + "403": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenResponse" + }, + "examples": { + "endpoint": { + "value": { + "detail": { + "cause": "User 6789 is not authorized to access this endpoint.", + "response": "User does not have permission to access this endpoint" + } + } + } + } + } + } + }, + "409": { + "description": "Resource already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictResponse" + }, + "examples": { + "saved prompt": { + "value": { + "detail": { + "cause": "Saved Prompt with name 'Deploy to staging' is already registered", + "response": "Saved Prompt already exists" + } + } + } + } + } + } + }, + "422": { + "description": "Request validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnprocessableEntityResponse" + }, + "examples": { + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + }, + "examples": { + "configuration": { + "value": { + "detail": { + "cause": "Lightspeed Stack configuration has not been initialized.", + "response": "Configuration is not loaded" + } + } + }, + "database": { + "value": { + "detail": { + "cause": "Failed to query the database", + "response": "Database query failed" + } + } + } + } + } + } + } + } + } + }, + "/v1/saved-prompts/{prompt_id}": { + "delete": { + "tags": [ + "saved-prompts" + ], + "summary": "Delete Saved Prompts Handler", + "description": "Handle requests to the DELETE /saved-prompts/{prompt_id} endpoint.\n\nProcess DELETE requests that remove a saved prompt belonging to the\nauthenticated user. Missing prompts and prompts owned by another user\nboth return 404 so foreign ids cannot be probed. For example:\n\n curl -X DELETE http://localhost:8080/v1/saved-prompts/{prompt_id}\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- prompt_id: Identifier of the saved prompt to delete.\n- auth: Authentication tuple from the auth dependency.\n\n### Raises:\n- HTTPException: with status 400 when ``prompt_id`` has an invalid format.\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 404 when the prompt does not exist for this user.\n- HTTPException: with status 500 when configuration is not loaded or the\n database delete fails.\n\n### Returns:\n- Response: Empty 204 No Content on successful deletion.", + "operationId": "delete_saved_prompts_handler_v1_saved_prompts__prompt_id__delete", + "parameters": [ + { + "name": "prompt_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Prompt Id" + } + } ], - "summary": "Get Saved Prompts Config Handler", - "description": "Handle requests to the GET /saved-prompts/config endpoint.\n\nProcess GET requests that return saved prompts configuration limits so\nconsuming services can provide limits consistent with what the server\nwill enforce. For example:\n\n curl http://localhost:8080/v1/saved-prompts/config\n\n### Parameters:\n- request: The incoming HTTP request (used by middleware).\n- auth: Authentication tuple from the auth dependency (used by middleware).\n\n### Raises:\n- HTTPException: with status 401 for unauthorized access.\n- HTTPException: with status 403 if permission is denied.\n- HTTPException: with status 500 and a detail object containing `response`\n and `cause` when service configuration is wrong or incomplete.\n- HTTPException: with status 503 and a detail object containing `response`\n and `cause` when unable to connect to backend services.\n\n### Returns:\n- SavedPromptsConfigResponse: Saved prompts configuration limits.", - "operationId": "get_saved_prompts_config_handler_v1_saved_prompts_config_get", "responses": { - "200": { - "description": "Successful response", + "204": { + "description": "Saved prompt deleted" + }, + "400": { + "description": "Invalid request format", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/SavedPromptsConfigResponse" + "examples": { + "saved_prompt_id": { + "value": { + "detail": { + "cause": "The saved prompt ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", + "response": "Invalid saved prompt ID format" + } + } + } }, - "example": { - "max_content_length": 10000, - "max_display_name_length": 255, - "max_prompts_per_user": 50 + "schema": { + "$ref": "#/components/schemas/BadRequestResponse" } } } @@ -6959,9 +7539,6 @@ "description": "Unauthorized", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedResponse" - }, "examples": { "missing header": { "value": { @@ -7027,6 +7604,9 @@ } } } + }, + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" } } } @@ -7035,9 +7615,6 @@ "description": "Permission denied", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ForbiddenResponse" - }, "examples": { "endpoint": { "value": { @@ -7047,46 +7624,67 @@ } } } + }, + "schema": { + "$ref": "#/components/schemas/ForbiddenResponse" } } } }, - "500": { - "description": "Internal server error", + "404": { + "description": "Resource not found", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/InternalServerErrorResponse" - }, "examples": { - "configuration": { + "saved prompt": { "value": { "detail": { - "cause": "Lightspeed Stack configuration has not been initialized.", - "response": "Configuration is not loaded" + "cause": "Saved Prompt with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Saved Prompt not found" } } } + }, + "schema": { + "$ref": "#/components/schemas/NotFoundResponse" } } } }, - "503": { - "description": "Service unavailable", + "500": { + "description": "Internal server error", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUnavailableResponse" - }, "examples": { - "kubernetes api": { + "configuration": { "value": { "detail": { - "cause": "Failed to connect to Kubernetes API: Service Unavailable (status 503)", - "response": "Unable to connect to Kubernetes API" + "cause": "Lightspeed Stack configuration has not been initialized.", + "response": "Configuration is not loaded" + } + } + }, + "database": { + "value": { + "detail": { + "cause": "Failed to query the database", + "response": "Database query failed" } } } + }, + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } @@ -9671,6 +10269,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -9997,6 +10611,22 @@ "response": "Invalid attribute value" } } + }, + "saved prompt invalid": { + "value": { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + } + } + }, + "saved prompt limit": { + "value": { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + } + } } } } @@ -11026,7 +11656,8 @@ "read_vector_stores", "manage_files", "manage_prompts", - "read_prompts" + "read_prompts", + "manage_saved_prompts" ], "title": "Action", "description": "Available actions in the system.\n\nNote: this is not a real model, just an enumeration of all action names." @@ -11971,6 +12602,13 @@ "response": "Invalid prompt ID format" }, "label": "prompt_id" + }, + { + "detail": { + "cause": "The saved prompt ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", + "response": "Invalid saved prompt ID format" + }, + "label": "saved_prompt_id" } ] }, @@ -12536,6 +13174,13 @@ "response": "Tool conflict" }, "label": "file search conflict" + }, + { + "detail": { + "cause": "Saved Prompt with name 'Deploy to staging' is already registered", + "response": "Saved Prompt already exists" + }, + "label": "saved prompt" } ] }, @@ -14950,6 +15595,13 @@ "response": "Prompt not found" }, "label": "prompt" + }, + { + "detail": { + "cause": "Saved Prompt with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Saved Prompt not found" + }, + "label": "saved prompt" } ] }, @@ -19442,6 +20094,106 @@ "title": "SQLiteDatabaseConfiguration", "description": "SQLite database configuration." }, + "SavedPromptCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Display name of the saved prompt", + "examples": [ + "Deploy to staging" + ] + }, + "content": { + "type": "string", + "title": "Content", + "description": "Prompt body text", + "examples": [ + "Help me write a deployment checklist\u2026" + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "content" + ], + "title": "SavedPromptCreateRequest", + "description": "Request body to create a user-scoped saved prompt.\n\nLength and emptiness limits are enforced by the endpoint using configured\nsaved-prompts limits, not by static field constraints here.\n\nAttributes:\n name: Display name of the saved prompt.\n content: Prompt body text.", + "examples": [ + { + "content": "Help me write a deployment checklist\u2026", + "name": "Deploy to staging" + } + ] + }, + "SavedPromptResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "Unique identifier of the saved prompt", + "examples": [ + "abc123" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "Display name of the saved prompt", + "examples": [ + "Deploy to staging" + ] + }, + "content": { + "type": "string", + "title": "Content", + "description": "Prompt body text", + "examples": [ + "Help me write a deployment checklist\u2026" + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the prompt was created", + "examples": [ + "2026-07-22T16:00:00+00:00" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At", + "description": "When the prompt was last updated", + "examples": [ + "2026-07-22T16:00:00+00:00" + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "content", + "created_at", + "updated_at" + ], + "title": "SavedPromptResponse", + "description": "Single saved prompt returned to an authenticated user.\n\nAttributes:\n id: Unique identifier of the saved prompt.\n name: Display name of the saved prompt.\n content: Prompt body text.\n created_at: When the prompt was created.\n updated_at: When the prompt was last updated.", + "examples": [ + { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + ] + }, "SavedPromptsConfigResponse": { "properties": { "max_prompts_per_user": { @@ -19489,49 +20241,69 @@ "SavedPromptsConfiguration": { "properties": { "max_prompts_per_user": { - "anyOf": [ - { - "type": "integer", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], + "type": "integer", + "maximum": 200.0, + "exclusiveMinimum": 0.0, "title": "Max prompts per user", - "description": "Maximum number of saved prompts a user can create. Defaults to 50. Cannot exceed 200." + "description": "Maximum number of saved prompts a user can create. Defaults to 50. Cannot exceed 200.", + "default": 50 }, "max_display_name_length": { - "anyOf": [ - { - "type": "integer", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], + "type": "integer", + "maximum": 255.0, + "exclusiveMinimum": 0.0, "title": "Max display name length", - "description": "Maximum character length for prompt display name (title). Defaults to 255. Cannot exceed 255." + "description": "Maximum character length for prompt display name (title). Defaults to 255. Cannot exceed 255.", + "default": 255 }, "max_content_length": { - "anyOf": [ - { - "type": "integer", - "exclusiveMinimum": 0.0 - }, - { - "type": "null" - } - ], + "type": "integer", + "maximum": 30000.0, + "exclusiveMinimum": 0.0, "title": "Max content length", - "description": "Maximum character length for the prompt content body. Defaults to 10000. Cannot exceed 30000." + "description": "Maximum character length for the prompt content body. Defaults to 10000. Cannot exceed 30000.", + "default": 10000 } }, "additionalProperties": false, "type": "object", "title": "SavedPromptsConfiguration", - "description": "Configuration for saved prompts feature limits.\n\nControls the maximum number of prompts a user can save, the maximum\ndisplay name (title) length, and the maximum prompt content length.\nAll fields are optional and default to values defined in constants.\n\nAttributes:\n max_prompts_per_user: Maximum number of saved prompts allowed per user.\n max_display_name_length: Maximum character length for the prompt display name.\n max_content_length: Maximum character length for the prompt content body." + "description": "Configuration for saved prompts feature limits.\n\nControls the maximum number of prompts a user can save, the maximum\ndisplay name (title) length, and the maximum prompt content length.\nOmitted fields use the defaults defined in constants.\n\nAttributes:\n max_prompts_per_user: Maximum number of saved prompts allowed per user.\n max_display_name_length: Maximum character length for the prompt display name.\n max_content_length: Maximum character length for the prompt content body." + }, + "SavedPromptsListResponse": { + "properties": { + "prompts": { + "items": { + "$ref": "#/components/schemas/SavedPromptResponse" + }, + "type": "array", + "title": "Prompts", + "description": "Saved prompts for the authenticated user" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "prompts" + ], + "title": "SavedPromptsListResponse", + "description": "List of saved prompts belonging to the authenticated user.\n\nAttributes:\n prompts: Saved prompts ordered by created_at descending (newest first).", + "examples": [ + { + "prompts": [ + { + "content": "Help me write a deployment checklist\u2026", + "created_at": "2026-07-22T16:00:00+00:00", + "id": "abc123", + "name": "Deploy to staging", + "updated_at": "2026-07-22T16:00:00+00:00" + } + ] + }, + { + "prompts": [] + } + ] }, "SearchRankingOptions": { "properties": { @@ -20493,6 +21265,20 @@ "response": "Invalid attribute value" }, "label": "invalid value" + }, + { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + }, + "label": "saved prompt invalid" + }, + { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + }, + "label": "saved prompt limit" } ] }, diff --git a/src/app/endpoints/saved_prompts.py b/src/app/endpoints/saved_prompts.py index 474b4126b..c75e77e9d 100644 --- a/src/app/endpoints/saved_prompts.py +++ b/src/app/endpoints/saved_prompts.py @@ -2,28 +2,71 @@ from typing import Annotated, Any -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.concurrency import run_in_threadpool +from sqlalchemy.exc import SQLAlchemyError from authentication import get_auth_dependency from authentication.interface import AuthTuple from authorization.middleware import authorize from configuration import configuration from log import get_logger +from models.api.requests import SavedPromptCreateRequest from models.api.responses.constants import UNAUTHORIZED_OPENAPI_EXAMPLES from models.api.responses.error import ( + BadRequestResponse, + ConflictResponse, ForbiddenResponse, InternalServerErrorResponse, + NotFoundResponse, ServiceUnavailableResponse, UnauthorizedResponse, + UnprocessableEntityResponse, +) +from models.api.responses.successful import ( + SavedPromptResponse, + SavedPromptsConfigResponse, + SavedPromptsListResponse, ) -from models.api.responses.successful import SavedPromptsConfigResponse from models.config import Action +from models.database.saved_prompts import SavedPrompt from utils.endpoints import check_configuration_loaded +from utils.saved_prompts import ( + SavedPromptAccessDeniedError, + SavedPromptConflictError, + SavedPromptLimitExceededError, + SavedPromptNotFoundError, + SavedPromptValidationError, + create_saved_prompt, + delete_saved_prompt_by_id_and_user, + list_saved_prompts_by_user, + validate_saved_prompt_content, + validate_saved_prompt_name, +) +from utils.suid import check_suid logger = get_logger(__name__) router = APIRouter(tags=["saved-prompts"]) +def _to_saved_prompt_response(row: SavedPrompt) -> SavedPromptResponse: + """Map a persisted saved-prompt row to the API response model. + + Parameters: + row: Saved prompt entity loaded from the database. + + Returns: + API response for a single saved prompt (excludes ``user_id``). + """ + return SavedPromptResponse( + id=row.id, + name=row.name, + content=row.content, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + get_saved_prompts_config_responses: dict[int | str, dict[str, Any]] = { 200: SavedPromptsConfigResponse.openapi_response(), 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), @@ -32,6 +75,39 @@ 503: ServiceUnavailableResponse.openapi_response(examples=["kubernetes api"]), } +list_saved_prompts_responses: dict[int | str, dict[str, Any]] = { + 200: SavedPromptsListResponse.openapi_response(), + 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), + 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), + 500: InternalServerErrorResponse.openapi_response( + examples=["configuration", "database"] + ), +} + +create_saved_prompts_responses: dict[int | str, dict[str, Any]] = { + 201: SavedPromptResponse.openapi_response(), + 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), + 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), + 409: ConflictResponse.openapi_response(examples=["saved prompt"]), + 422: UnprocessableEntityResponse.openapi_response( + examples=["saved prompt invalid", "saved prompt limit"] + ), + 500: InternalServerErrorResponse.openapi_response( + examples=["configuration", "database"] + ), +} + +delete_saved_prompts_responses: dict[int | str, dict[str, Any]] = { + 204: {"description": "Saved prompt deleted"}, + 400: BadRequestResponse.openapi_response(examples=["saved_prompt_id"]), + 401: UnauthorizedResponse.openapi_response(examples=UNAUTHORIZED_OPENAPI_EXAMPLES), + 403: ForbiddenResponse.openapi_response(examples=["endpoint"]), + 404: NotFoundResponse.openapi_response(examples=["saved prompt"]), + 500: InternalServerErrorResponse.openapi_response( + examples=["configuration", "database"] + ), +} + @router.get("/saved-prompts/config", responses=get_saved_prompts_config_responses) @authorize(Action.GET_CONFIG) @@ -56,7 +132,7 @@ async def get_saved_prompts_config_handler( - HTTPException: with status 401 for unauthorized access. - HTTPException: with status 403 if permission is denied. - HTTPException: with status 500 and a detail object containing `response` - and `cause` when service configuration is wrong or incomplete. + and `cause` when service configuration is not loaded. - HTTPException: with status 503 and a detail object containing `response` and `cause` when unable to connect to backend services. @@ -69,20 +145,215 @@ async def get_saved_prompts_config_handler( check_configuration_loaded(configuration) saved_prompts_config = configuration.configuration.saved_prompts - max_prompts_per_user = saved_prompts_config.max_prompts_per_user - max_display_name_length = saved_prompts_config.max_display_name_length - max_content_length = saved_prompts_config.max_content_length - if ( - max_prompts_per_user is None - or max_display_name_length is None - or max_content_length is None - ): - logger.error("Saved prompts configuration limits are not set") - error_response = InternalServerErrorResponse.generic() - raise HTTPException(**error_response.model_dump()) - return SavedPromptsConfigResponse( - max_prompts_per_user=max_prompts_per_user, - max_display_name_length=max_display_name_length, - max_content_length=max_content_length, + max_prompts_per_user=saved_prompts_config.max_prompts_per_user, + max_display_name_length=saved_prompts_config.max_display_name_length, + max_content_length=saved_prompts_config.max_content_length, ) + + +@router.get("/saved-prompts", responses=list_saved_prompts_responses) +@authorize(Action.MANAGE_SAVED_PROMPTS) +async def list_saved_prompts_handler( + auth: Annotated[AuthTuple, Depends(get_auth_dependency())], + request: Request, +) -> SavedPromptsListResponse: + """ + Handle requests to the GET /saved-prompts endpoint. + + Process GET requests that return all saved prompts belonging to the + authenticated user, ordered by creation timestamp descending. For example: + + curl http://localhost:8080/v1/saved-prompts + + ### Parameters: + - request: The incoming HTTP request (used by middleware). + - auth: Authentication tuple from the auth dependency. + + ### Raises: + - HTTPException: with status 401 for unauthorized access. + - HTTPException: with status 403 if permission is denied. + - HTTPException: with status 500 when configuration is not loaded or the + database query fails. + + ### Returns: + - SavedPromptsListResponse: Saved prompts for the authenticated user. + """ + _ = request + check_configuration_loaded(configuration) + + user_id = auth[0] + logger.info("Retrieving saved prompts") + + try: + rows = await run_in_threadpool(list_saved_prompts_by_user, user_id) + prompts = [_to_saved_prompt_response(row) for row in rows] + except SQLAlchemyError as exc: + logger.exception("Error retrieving saved prompts") + error_response = InternalServerErrorResponse.database_error() + raise HTTPException(**error_response.model_dump()) from exc + + logger.info("Retrieved %s saved prompts", len(prompts)) + return SavedPromptsListResponse(prompts=prompts) + + +@router.post( + "/saved-prompts", + responses=create_saved_prompts_responses, + status_code=status.HTTP_201_CREATED, +) +@authorize(Action.MANAGE_SAVED_PROMPTS) +async def create_saved_prompts_handler( + request: Request, + body: SavedPromptCreateRequest, + auth: Annotated[AuthTuple, Depends(get_auth_dependency())], +) -> SavedPromptResponse: + r""" + Handle requests to the POST /saved-prompts endpoint. + + Process POST requests that create a saved prompt for the authenticated + user after validating name/content against configured limits. For example: + + curl -X POST http://localhost:8080/v1/saved-prompts \ + -H 'Content-Type: application/json' \ + -d '{"name":"Deploy to staging","content":"Help me write a checklist"}' + + ### Parameters: + - request: The incoming HTTP request (used by middleware). + - body: Saved prompt name and content. + - auth: Authentication tuple from the auth dependency. + + ### Raises: + - HTTPException: with status 401 for unauthorized access. + - HTTPException: with status 403 if permission is denied. + - HTTPException: with status 409 when a prompt with the same name exists. + - HTTPException: with status 422 when validation fails or the per-user + limit would be exceeded. + - HTTPException: with status 500 when configuration is not loaded or the + database write fails. + + ### Returns: + - SavedPromptResponse: The created saved prompt. + """ + _ = request + check_configuration_loaded(configuration) + + saved_prompts_config = configuration.configuration.saved_prompts + + try: + name = validate_saved_prompt_name( + body.name, + max_display_name_length=saved_prompts_config.max_display_name_length, + ) + validate_saved_prompt_content( + body.content, + max_content_length=saved_prompts_config.max_content_length, + ) + except SavedPromptValidationError as exc: + error_response = UnprocessableEntityResponse( + response="Invalid attribute value", + cause=str(exc), + ) + raise HTTPException(**error_response.model_dump()) from exc + + user_id = auth[0] + logger.info("Creating saved prompt") + + try: + row = await run_in_threadpool( + create_saved_prompt, + user_id, + name, + body.content, + saved_prompts_config.max_prompts_per_user, + ) + except SavedPromptLimitExceededError as exc: + error_response = UnprocessableEntityResponse( + response="Saved prompt limit exceeded", + cause=str(exc), + ) + raise HTTPException(**error_response.model_dump()) from exc + except SavedPromptConflictError as exc: + error_response = ConflictResponse( + resource="Saved prompt", + resource_id=name, + ) + raise HTTPException(**error_response.model_dump()) from exc + except SQLAlchemyError as exc: + logger.exception("Error creating saved prompt") + error_response = InternalServerErrorResponse.database_error() + raise HTTPException(**error_response.model_dump()) from exc + + logger.info("Created saved prompt id=%s", row.id) + return _to_saved_prompt_response(row) + + +@router.delete( + "/saved-prompts/{prompt_id}", + responses=delete_saved_prompts_responses, + status_code=status.HTTP_204_NO_CONTENT, +) +@authorize(Action.MANAGE_SAVED_PROMPTS) +async def delete_saved_prompts_handler( + request: Request, + prompt_id: str, + auth: Annotated[AuthTuple, Depends(get_auth_dependency())], +) -> Response: + """ + Handle requests to the DELETE /saved-prompts/{prompt_id} endpoint. + + Process DELETE requests that remove a saved prompt belonging to the + authenticated user. Missing prompts and prompts owned by another user + both return 404 so foreign ids cannot be probed. For example: + + curl -X DELETE http://localhost:8080/v1/saved-prompts/{prompt_id} + + ### Parameters: + - request: The incoming HTTP request (used by middleware). + - prompt_id: Identifier of the saved prompt to delete. + - auth: Authentication tuple from the auth dependency. + + ### Raises: + - HTTPException: with status 400 when ``prompt_id`` has an invalid format. + - HTTPException: with status 401 for unauthorized access. + - HTTPException: with status 403 if permission is denied. + - HTTPException: with status 404 when the prompt does not exist for this user. + - HTTPException: with status 500 when configuration is not loaded or the + database delete fails. + + ### Returns: + - Response: Empty 204 No Content on successful deletion. + """ + _ = request + check_configuration_loaded(configuration) + + if not check_suid(prompt_id): + logger.error("Invalid saved prompt ID format: %s", prompt_id) + error_response = BadRequestResponse( + resource="saved prompt", + resource_id=prompt_id, + ) + raise HTTPException(**error_response.model_dump()) + + user_id = auth[0] + logger.info("Deleting saved prompt id=%s", prompt_id) + + try: + await run_in_threadpool( + delete_saved_prompt_by_id_and_user, + prompt_id, + user_id, + ) + except (SavedPromptNotFoundError, SavedPromptAccessDeniedError) as exc: + error_response = NotFoundResponse( + resource="saved prompt", + resource_id=prompt_id, + ) + raise HTTPException(**error_response.model_dump()) from exc + except SQLAlchemyError as exc: + logger.exception("Error deleting saved prompt id=%s", prompt_id) + error_response = InternalServerErrorResponse.database_error() + raise HTTPException(**error_response.model_dump()) from exc + + logger.info("Deleted saved prompt id=%s", prompt_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/models/api/requests/README.md b/src/models/api/requests/README.md index 4ebbbf41e..d5ace62f4 100644 --- a/src/models/api/requests/README.md +++ b/src/models/api/requests/README.md @@ -27,6 +27,10 @@ Request model for the OpenAI-compatible Responses API. ## [rlsapi.py](rlsapi.py) Models for rlsapi v1 REST API requests. +## [saved_prompts.py](saved_prompts.py) + +Request models for saved prompts endpoints. + ## [vector_stores.py](vector_stores.py) Request models for vector store and file endpoints. diff --git a/src/models/api/requests/__init__.py b/src/models/api/requests/__init__.py index 93c31f16f..7820ed2a0 100644 --- a/src/models/api/requests/__init__.py +++ b/src/models/api/requests/__init__.py @@ -15,6 +15,7 @@ RlsapiV1SystemInfo, RlsapiV1Terminal, ) +from models.api.requests.saved_prompts import SavedPromptCreateRequest from models.api.requests.vector_stores import ( VectorStoreCreateRequest, VectorStoreFileCreateRequest, @@ -37,6 +38,7 @@ "RlsapiV1InferRequest", "RlsapiV1SystemInfo", "RlsapiV1Terminal", + "SavedPromptCreateRequest", "StreamingInterruptRequest", "VectorStoreCreateRequest", "VectorStoreFileCreateRequest", diff --git a/src/models/api/requests/saved_prompts.py b/src/models/api/requests/saved_prompts.py new file mode 100644 index 000000000..a77ace05e --- /dev/null +++ b/src/models/api/requests/saved_prompts.py @@ -0,0 +1,38 @@ +"""Request models for saved prompts endpoints.""" + +from pydantic import BaseModel, Field + + +class SavedPromptCreateRequest(BaseModel): + """Request body to create a user-scoped saved prompt. + + Length and emptiness limits are enforced by the endpoint using configured + saved-prompts limits, not by static field constraints here. + + Attributes: + name: Display name of the saved prompt. + content: Prompt body text. + """ + + name: str = Field( + ..., + description="Display name of the saved prompt", + examples=["Deploy to staging"], + ) + content: str = Field( + ..., + description="Prompt body text", + examples=["Help me write a deployment checklist…"], + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "name": "Deploy to staging", + "content": "Help me write a deployment checklist…", + } + ] + }, + } diff --git a/src/models/api/responses/error/bad_request.py b/src/models/api/responses/error/bad_request.py index e6c286e9f..e1129d638 100644 --- a/src/models/api/responses/error/bad_request.py +++ b/src/models/api/responses/error/bad_request.py @@ -32,6 +32,16 @@ class BadRequestResponse(AbstractErrorResponse): "cause": "The prompt ID pmpt_1234567890abcdef has invalid format.", }, }, + { + "label": "saved_prompt_id", + "detail": { + "response": "Invalid saved prompt ID format", + "cause": ( + "The saved prompt ID " + "123e4567-e89b-12d3-a456-426614174000 has invalid format." + ), + }, + }, ] } } diff --git a/src/models/api/responses/error/conflict.py b/src/models/api/responses/error/conflict.py index cafbf6b29..5a5fb9023 100644 --- a/src/models/api/responses/error/conflict.py +++ b/src/models/api/responses/error/conflict.py @@ -47,6 +47,16 @@ class ConflictResponse(AbstractErrorResponse): ), }, }, + { + "label": "saved prompt", + "detail": { + "response": "Saved Prompt already exists", + "cause": ( + "Saved Prompt with name 'Deploy to staging' " + "is already registered" + ), + }, + }, ] } } diff --git a/src/models/api/responses/error/not_found.py b/src/models/api/responses/error/not_found.py index e55751629..4dbf448d4 100644 --- a/src/models/api/responses/error/not_found.py +++ b/src/models/api/responses/error/not_found.py @@ -89,6 +89,16 @@ class NotFoundResponse(AbstractErrorResponse): ), }, }, + { + "label": "saved prompt", + "detail": { + "response": "Saved Prompt not found", + "cause": ( + "Saved Prompt with ID " + "123e4567-e89b-12d3-a456-426614174000 does not exist" + ), + }, + }, ] } } diff --git a/src/models/api/responses/error/unprocessable_entity.py b/src/models/api/responses/error/unprocessable_entity.py index 90c487bdb..9088a34d5 100644 --- a/src/models/api/responses/error/unprocessable_entity.py +++ b/src/models/api/responses/error/unprocessable_entity.py @@ -37,6 +37,23 @@ class UnprocessableEntityResponse(AbstractErrorResponse): "'application/json', 'application/yaml', 'application/xml']", }, }, + { + "label": "saved prompt invalid", + "detail": { + "response": "Invalid attribute value", + "cause": "Saved prompt name must not be empty", + }, + }, + { + "label": "saved prompt limit", + "detail": { + "response": "Saved prompt limit exceeded", + "cause": ( + "Saved prompt limit exceeded: 50 existing prompts, " + "maximum is 50" + ), + }, + }, ] } } diff --git a/src/models/api/responses/successful/README.md b/src/models/api/responses/successful/README.md index e35bc39d6..1982f57da 100644 --- a/src/models/api/responses/successful/README.md +++ b/src/models/api/responses/successful/README.md @@ -37,7 +37,7 @@ Successful response model for the OpenAI-compatible Responses API. Models for rlsapi v1 REST API responses. ## [saved_prompts.py](saved_prompts.py) -Successful responses for saved prompts configuration. +Successful responses for saved prompts configuration and listing. ## [vector_stores.py](vector_stores.py) Successful responses for vector stores and vector store files. diff --git a/src/models/api/responses/successful/__init__.py b/src/models/api/responses/successful/__init__.py index 0f4a4ef2b..ae95cf3f2 100644 --- a/src/models/api/responses/successful/__init__.py +++ b/src/models/api/responses/successful/__init__.py @@ -49,7 +49,11 @@ RlsapiV1InferData, RlsapiV1InferResponse, ) -from models.api.responses.successful.saved_prompts import SavedPromptsConfigResponse +from models.api.responses.successful.saved_prompts import ( + SavedPromptResponse, + SavedPromptsConfigResponse, + SavedPromptsListResponse, +) from models.api.responses.successful.vector_stores import ( FileResponse, VectorStoreDeleteResponse, @@ -90,7 +94,9 @@ "ResponsesResponse", "RlsapiV1InferData", "RlsapiV1InferResponse", + "SavedPromptResponse", "SavedPromptsConfigResponse", + "SavedPromptsListResponse", "ShieldsResponse", "StatusResponse", "StreamingInterruptResponse", diff --git a/src/models/api/responses/successful/saved_prompts.py b/src/models/api/responses/successful/saved_prompts.py index c12e30e47..c05fd7733 100644 --- a/src/models/api/responses/successful/saved_prompts.py +++ b/src/models/api/responses/successful/saved_prompts.py @@ -1,4 +1,6 @@ -"""Successful responses for saved prompts configuration.""" +"""Successful responses for saved prompts configuration and listing.""" + +from datetime import datetime from pydantic import Field @@ -42,3 +44,89 @@ class SavedPromptsConfigResponse(AbstractSuccessfulResponse): ] }, } + + +class SavedPromptResponse(AbstractSuccessfulResponse): + """Single saved prompt returned to an authenticated user. + + Attributes: + id: Unique identifier of the saved prompt. + name: Display name of the saved prompt. + content: Prompt body text. + created_at: When the prompt was created. + updated_at: When the prompt was last updated. + """ + + id: str = Field( + ..., + description="Unique identifier of the saved prompt", + examples=["abc123"], + ) + name: str = Field( + ..., + description="Display name of the saved prompt", + examples=["Deploy to staging"], + ) + content: str = Field( + ..., + description="Prompt body text", + examples=["Help me write a deployment checklist…"], + ) + created_at: datetime = Field( + ..., + description="When the prompt was created", + examples=["2026-07-22T16:00:00+00:00"], + ) + updated_at: datetime = Field( + ..., + description="When the prompt was last updated", + examples=["2026-07-22T16:00:00+00:00"], + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "id": "abc123", + "name": "Deploy to staging", + "content": "Help me write a deployment checklist…", + "created_at": "2026-07-22T16:00:00+00:00", + "updated_at": "2026-07-22T16:00:00+00:00", + } + ] + }, + } + + +class SavedPromptsListResponse(AbstractSuccessfulResponse): + """List of saved prompts belonging to the authenticated user. + + Attributes: + prompts: Saved prompts ordered by created_at descending (newest first). + """ + + prompts: list[SavedPromptResponse] = Field( + ..., + description="Saved prompts for the authenticated user", + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "prompts": [ + { + "id": "abc123", + "name": "Deploy to staging", + "content": "Help me write a deployment checklist…", + "created_at": "2026-07-22T16:00:00+00:00", + "updated_at": "2026-07-22T16:00:00+00:00", + } + ] + }, + {"prompts": []}, + ] + }, + } diff --git a/src/models/config.py b/src/models/config.py index 28ff23264..715710334 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -1307,6 +1307,9 @@ class Action(str, Enum): MANAGE_PROMPTS = "manage_prompts" READ_PROMPTS = "read_prompts" + # User saved prompts (/v1/saved-prompts) + MANAGE_SAVED_PROMPTS = "manage_saved_prompts" + class AccessRule(ConfigurationBase): """Rule defining what actions a role can perform.""" @@ -2379,7 +2382,7 @@ class SavedPromptsConfiguration(ConfigurationBase): Controls the maximum number of prompts a user can save, the maximum display name (title) length, and the maximum prompt content length. - All fields are optional and default to values defined in constants. + Omitted fields use the defaults defined in constants. Attributes: max_prompts_per_user: Maximum number of saved prompts allowed per user. @@ -2387,94 +2390,33 @@ class SavedPromptsConfiguration(ConfigurationBase): max_content_length: Maximum character length for the prompt content body. """ - max_prompts_per_user: Optional[PositiveInt] = Field( - default=None, + max_prompts_per_user: PositiveInt = Field( + default=constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, + le=constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND, title="Max prompts per user", description="Maximum number of saved prompts a user can create. " f"Defaults to {constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER}. " f"Cannot exceed {constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND}.", ) - max_display_name_length: Optional[PositiveInt] = Field( - default=None, + max_display_name_length: PositiveInt = Field( + default=constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH, + le=constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND, title="Max display name length", description="Maximum character length for prompt display name (title). " f"Defaults to {constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH}. " f"Cannot exceed {constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND}.", ) - max_content_length: Optional[PositiveInt] = Field( - default=None, + max_content_length: PositiveInt = Field( + default=constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH, + le=constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND, title="Max content length", description="Maximum character length for the prompt content body. " f"Defaults to {constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH}. " f"Cannot exceed {constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND}.", ) - @model_validator(mode="after") - def apply_defaults_and_validate_bounds(self) -> Self: - """Apply default values for None fields and validate upper bounds. - - Logs an info message for each field that falls back to its default. - - Returns: - Self: The validated model instance with defaults applied. - - Raises: - ValueError: If any value exceeds its upper bound. - """ - if self.max_prompts_per_user is None: - self.max_prompts_per_user = constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER - logger.info( - "saved_prompts.max_prompts_per_user not configured, " - "using default: %d", - constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, - ) - elif ( - self.max_prompts_per_user > constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND - ): - raise ValueError( - f"max_prompts_per_user ({self.max_prompts_per_user}) exceeds " - f"upper bound ({constants.SAVED_PROMPTS_MAX_PER_USER_UPPER_BOUND})." - ) - - if self.max_display_name_length is None: - self.max_display_name_length = ( - constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH - ) - logger.info( - "saved_prompts.max_display_name_length not configured, " - "using default: %d", - constants.SAVED_PROMPTS_DEFAULT_MAX_DISPLAY_NAME_LENGTH, - ) - elif ( - self.max_display_name_length - > constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND - ): - raise ValueError( - f"max_display_name_length ({self.max_display_name_length}) exceeds " - f"database column limit " - f"({constants.SAVED_PROMPTS_MAX_DISPLAY_NAME_LENGTH_UPPER_BOUND})." - ) - - if self.max_content_length is None: - self.max_content_length = constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH - logger.info( - "saved_prompts.max_content_length not configured, " - + "using default: %d", - constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH, - ) - elif ( - self.max_content_length - > constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND - ): - raise ValueError( - f"max_content_length ({self.max_content_length}) exceeds " - f"upper bound ({constants.SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND})." - ) - - return self - class QuestionValidityConfig(ConfigurationBase): """Configuration for the question validity guardrail.""" diff --git a/tests/integration/test_openapi_json.py b/tests/integration/test_openapi_json.py index 7d85861e3..76fc7e695 100644 --- a/tests/integration/test_openapi_json.py +++ b/tests/integration/test_openapi_json.py @@ -257,6 +257,17 @@ def test_servers_section_present_from_url(spec_from_url: dict[str, Any]) -> None ), ("/v1/config", "get", {"200", "401", "403", "500"}), ("/v1/saved-prompts/config", "get", {"200", "401", "403", "500", "503"}), + ("/v1/saved-prompts", "get", {"200", "401", "403", "500"}), + ( + "/v1/saved-prompts", + "post", + {"201", "401", "403", "409", "422", "500"}, + ), + ( + "/v1/saved-prompts/{prompt_id}", + "delete", + {"204", "400", "401", "403", "404", "500"}, + ), ("/v1/feedback", "post", {"200", "401", "403", "404", "500"}), ("/v1/feedback/status", "get", {"200"}), ("/v1/feedback/status", "put", {"200", "401", "403", "500"}), @@ -362,6 +373,17 @@ def test_paths_and_responses_exist_from_file( ), ("/v1/config", "get", {"200", "401", "403", "500"}), ("/v1/saved-prompts/config", "get", {"200", "401", "403", "500", "503"}), + ("/v1/saved-prompts", "get", {"200", "401", "403", "500"}), + ( + "/v1/saved-prompts", + "post", + {"201", "401", "403", "409", "422", "500"}, + ), + ( + "/v1/saved-prompts/{prompt_id}", + "delete", + {"204", "400", "401", "403", "404", "500"}, + ), ("/v1/feedback", "post", {"200", "401", "403", "404", "500"}), ("/v1/feedback/status", "get", {"200"}), ("/v1/feedback/status", "put", {"200", "401", "403", "500"}), diff --git a/tests/unit/app/endpoints/README.md b/tests/unit/app/endpoints/README.md index af613e2e3..fec5a5b37 100644 --- a/tests/unit/app/endpoints/README.md +++ b/tests/unit/app/endpoints/README.md @@ -67,7 +67,7 @@ Unit tests for the rlsapi v1 /infer REST API endpoint. Unit tests for the / endpoint handler. ## [test_saved_prompts.py](test_saved_prompts.py) -Unit tests for the /saved-prompts/config REST API endpoint. +Unit tests for the /saved-prompts REST API endpoints. ## [test_shields.py](test_shields.py) Unit tests for the /shields REST API endpoint. diff --git a/tests/unit/app/endpoints/test_saved_prompts.py b/tests/unit/app/endpoints/test_saved_prompts.py index e55f350c2..0e0e59801 100644 --- a/tests/unit/app/endpoints/test_saved_prompts.py +++ b/tests/unit/app/endpoints/test_saved_prompts.py @@ -1,18 +1,44 @@ -"""Unit tests for the /saved-prompts/config REST API endpoint.""" +"""Unit tests for the /saved-prompts REST API endpoints.""" + +# pylint: disable=too-many-lines + +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Final import pytest from fastapi import FastAPI, HTTPException, Request, status from fastapi.testclient import TestClient from pytest_mock import MockerFixture +from sqlalchemy.exc import SQLAlchemyError import constants -from app.endpoints.saved_prompts import get_saved_prompts_config_handler, router +from app.endpoints.saved_prompts import ( + create_saved_prompts_handler, + delete_saved_prompts_handler, + get_saved_prompts_config_handler, + list_saved_prompts_handler, + router, +) from authentication.interface import AuthTuple from configuration import AppConfig +from models.api.requests import SavedPromptCreateRequest from models.config import Action from tests.unit.utils.auth_helpers import mock_authorization_resolvers +from utils.saved_prompts import ( + SavedPromptAccessDeniedError, + SavedPromptConflictError, + SavedPromptLimitExceededError, + SavedPromptNotFoundError, +) MOCK_AUTH: AuthTuple = ("test_user_id", "test_user", True, "test_token") +MOCK_LIST_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") +MOCK_CREATE_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") +MOCK_DELETE_AUTH: Final[AuthTuple] = ("user-1", "test_user", True, "test_token") +MOCK_DELETE_AUTH_OTHER: Final[AuthTuple] = ("user-2", "other_user", True, "test_token") +VALID_PROMPT_ID: Final[str] = "123e4567-e89b-12d3-a456-426614174000" +INVALID_PROMPT_ID: Final[str] = "not-a-valid-id" CUSTOM_MAX_PROMPTS_PER_USER = 100 CUSTOM_MAX_DISPLAY_NAME_LENGTH = 128 @@ -127,34 +153,6 @@ async def test_get_saved_prompts_config_configuration_not_loaded( ) -@pytest.mark.asyncio -async def test_get_saved_prompts_config_incomplete_limits( - mocker: MockerFixture, - minimal_config: AppConfig, - saved_prompts_http_request: Request, -) -> None: - """GET /saved-prompts/config returns 500 when a limit is unexpectedly None. - - The model validator on ``SavedPromptsConfiguration`` always fills in - defaults, so this simulates the defensive branch that guards against a - limit slipping through as ``None`` at runtime. - """ - mock_authorization_resolvers(mocker) - minimal_config.configuration.saved_prompts.max_prompts_per_user = None - mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) - - with pytest.raises(HTTPException) as exc_info: - await get_saved_prompts_config_handler( - auth=MOCK_AUTH, - request=saved_prompts_http_request, - ) - - assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR - detail = exc_info.value.detail - assert isinstance(detail, dict) - assert detail["response"] == "Internal server error" # type: ignore[index] - - @pytest.mark.asyncio async def test_get_saved_prompts_config_forbidden_without_get_config_action( mocker: MockerFixture, @@ -253,3 +251,867 @@ async def test_get_saved_prompts_config_uses_get_config_action( await_args = perform_check.await_args assert await_args is not None assert await_args.args[0] == Action.GET_CONFIG + + +# Test helper; explicit fields keep call sites readable. +def _prompt_row( # pylint: disable=too-many-arguments + *, + prompt_id: str, + user_id: str, + name: str, + content: str, + created_at: datetime, + updated_at: datetime, +) -> SimpleNamespace: + """Build a SavedPrompt-like object for handler mapping tests. + + Parameters: + prompt_id: Saved prompt identifier mapped to ``id``. + user_id: Owning user identifier (not exposed in API responses). + name: Prompt display name. + content: Prompt body text. + created_at: Creation timestamp. + updated_at: Last-update timestamp. + + Returns: + A ``SimpleNamespace`` with attributes matching a ``SavedPrompt`` row. + """ + return SimpleNamespace( + id=prompt_id, + user_id=user_id, + name=name, + content=content, + created_at=created_at, + updated_at=updated_at, + ) + + +@pytest.mark.asyncio +async def test_list_saved_prompts_happy_path( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts maps DAL rows and preserves order without user_id.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + newer_ts = datetime(2026, 7, 22, 16, 5, 0, tzinfo=UTC) + older_ts = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_list = mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[ + _prompt_row( + prompt_id="p-newer", + user_id="user-1", + name="second", + content="c2", + created_at=newer_ts, + updated_at=newer_ts, + ), + _prompt_row( + prompt_id="p-older", + user_id="user-1", + name="first", + content="c1", + created_at=older_ts, + updated_at=older_ts, + ), + ], + ) + + response = await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + mock_list.assert_called_once_with("user-1") + payload = response.model_dump(mode="json") + assert [item["id"] for item in payload["prompts"]] == ["p-newer", "p-older"] + assert payload["prompts"][0]["name"] == "second" + assert payload["prompts"][0]["content"] == "c2" + assert "user_id" not in payload["prompts"][0] + assert "user_id" not in payload + + +@pytest.mark.asyncio +async def test_list_saved_prompts_empty( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns an empty prompts list when DAL is empty.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[], + ) + + response = await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert response.model_dump() == {"prompts": []} + + +@pytest.mark.asyncio +async def test_list_saved_prompts_isolates_near_collision_users( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """Handler only asks DAL for auth user_id; response stays that user's rows.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + ts = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_list = mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[ + _prompt_row( + prompt_id="owned", + user_id="user-1", + name="deploy", + content="owned-body", + created_at=ts, + updated_at=ts, + ) + ], + ) + + response = await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + mock_list.assert_called_once_with("user-1") + assert mock_list.call_args.args[0] not in {"user-11", "user-1a"} + payload = response.model_dump(mode="json") + assert len(payload["prompts"]) == 1 + assert payload["prompts"][0]["id"] == "owned" + assert payload["prompts"][0]["name"] == "deploy" + assert payload["prompts"][0]["content"] == "owned-body" + + +@pytest.mark.asyncio +async def test_list_saved_prompts_uses_manage_saved_prompts_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts authorizes with Action.MANAGE_SAVED_PROMPTS.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + return_value=[], + ) + perform_check = mocker.patch( + "authorization.middleware._perform_authorization_check", + return_value=None, + ) + + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + perform_check.assert_awaited_once() + await_args = perform_check.await_args + assert await_args is not None + assert await_args.args[0] == Action.MANAGE_SAVED_PROMPTS + + +@pytest.mark.asyncio +async def test_list_saved_prompts_forbidden_without_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns 403 when MANAGE_SAVED_PROMPTS is denied.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + mock_role_resolver = mocker.AsyncMock() + mock_role_resolver.resolve_roles.return_value = set() + mock_access_resolver = mocker.Mock() + mock_access_resolver.check_access.return_value = False + mocker.patch( + "authorization.middleware.get_authorization_resolvers", + return_value=(mock_role_resolver, mock_access_resolver), + ) + + with pytest.raises(HTTPException) as exc_info: + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_list_saved_prompts_returns_401_when_auth_rejects( + mocker: MockerFixture, + minimal_config: AppConfig, +) -> None: + """GET /v1/saved-prompts returns 401 when auth dependency rejects.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_authorization_resolvers(mocker) + + async def _reject(_self: object, _request: Request) -> None: + """Simulate auth rejection.""" + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "response": "Missing or invalid credentials provided by client", + "cause": "No Authorization header found", + }, + ) + + mocker.patch( + "authentication.noop.NoopAuthDependency.__call__", + _reject, + ) + + app = FastAPI() + app.include_router(router, prefix="/v1") + client = TestClient(app) + response = client.get("/v1/saved-prompts") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_list_saved_prompts_configuration_not_loaded( + mocker: MockerFixture, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns 500 when configuration is not loaded.""" + mock_authorization_resolvers(mocker) + mock_config = AppConfig() + mock_config._configuration = None # pylint: disable=protected-access + mocker.patch("app.endpoints.saved_prompts.configuration", mock_config) + + with pytest.raises(HTTPException) as exc_info: + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + +@pytest.mark.asyncio +async def test_list_saved_prompts_database_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """GET /saved-prompts returns 500 when the database query fails.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.list_saved_prompts_by_user", + side_effect=SQLAlchemyError("db down"), + ) + + with pytest.raises(HTTPException) as exc_info: + await list_saved_prompts_handler( + auth=MOCK_LIST_AUTH, + request=saved_prompts_http_request, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Database query failed" # type: ignore[index] + + +@pytest.mark.asyncio +async def test_create_saved_prompts_happy_path( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns mapped prompt without user_id.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + created_at = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_create = mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + return_value=_prompt_row( + prompt_id="prompt-1", + user_id="user-1", + name="Deploy to staging", + content="Help me write a checklist", + created_at=created_at, + updated_at=created_at, + ), + ) + + response = await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest( + name="Deploy to staging", + content="Help me write a checklist", + ), + auth=MOCK_CREATE_AUTH, + ) + + mock_create.assert_called_once_with( + "user-1", + "Deploy to staging", + "Help me write a checklist", + constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, + ) + payload = response.model_dump(mode="json") + assert set(payload.keys()) == { + "id", + "name", + "content", + "created_at", + "updated_at", + } + assert payload["id"] == "prompt-1" + assert payload["name"] == "Deploy to staging" + assert payload["content"] == "Help me write a checklist" + assert "user_id" not in payload + + +@pytest.mark.asyncio +async def test_create_saved_prompts_normalizes_name_before_dal( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts strips name whitespace before calling DAL.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + created_at = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mock_create = mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + return_value=_prompt_row( + prompt_id="prompt-1", + user_id="user-1", + name="Deploy", + content="body", + created_at=created_at, + updated_at=created_at, + ), + ) + + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name=" Deploy ", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + mock_create.assert_called_once_with( + "user-1", + "Deploy", + "body", + constants.SAVED_PROMPTS_DEFAULT_MAX_PER_USER, + ) + + +@pytest.mark.asyncio +async def test_create_saved_prompts_uses_manage_saved_prompts_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts authorizes with Action.MANAGE_SAVED_PROMPTS.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + created_at = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + return_value=_prompt_row( + prompt_id="prompt-1", + user_id="user-1", + name="Deploy", + content="body", + created_at=created_at, + updated_at=created_at, + ), + ) + perform_check = mocker.patch( + "authorization.middleware._perform_authorization_check", + return_value=None, + ) + + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + perform_check.assert_awaited_once() + await_args = perform_check.await_args + assert await_args is not None + assert await_args.args[0] == Action.MANAGE_SAVED_PROMPTS + + +@pytest.mark.asyncio +async def test_create_saved_prompts_quota_exceeded( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 422 when the per-user quota is exceeded.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + side_effect=SavedPromptLimitExceededError( + "Saved prompt limit exceeded: 50 existing prompts, maximum is 50" + ), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved prompt limit exceeded" # type: ignore[index] + assert "maximum is 50" in detail["cause"] # type: ignore[index] + + +@pytest.mark.asyncio +async def test_create_saved_prompts_validation_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 422 for empty/invalid name or content.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_create = mocker.patch("app.endpoints.saved_prompts.create_saved_prompt") + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name=" ", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Invalid attribute value" # type: ignore[index] + assert "must not be empty" in detail["cause"] # type: ignore[index] + mock_create.assert_not_called() + + +@pytest.mark.asyncio +async def test_create_saved_prompts_duplicate_name_conflict( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 409 when the prompt name already exists.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + side_effect=SavedPromptConflictError("Saved prompt name already exists"), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_409_CONFLICT + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved Prompt already exists" # type: ignore[index] + assert "Deploy" in detail["cause"] # type: ignore[index] + + +@pytest.mark.asyncio +async def test_create_saved_prompts_database_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 500 when the database write fails.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.create_saved_prompt", + side_effect=SQLAlchemyError("db down"), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Database query failed" # type: ignore[index] + + +@pytest.mark.asyncio +async def test_create_saved_prompts_configuration_not_loaded( + mocker: MockerFixture, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 500 when configuration is not loaded.""" + mock_authorization_resolvers(mocker) + mock_config = AppConfig() + mock_config._configuration = None # pylint: disable=protected-access + mocker.patch("app.endpoints.saved_prompts.configuration", mock_config) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + +@pytest.mark.asyncio +async def test_create_saved_prompts_forbidden_without_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """POST /saved-prompts returns 403 when MANAGE_SAVED_PROMPTS is denied.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + mock_role_resolver = mocker.AsyncMock() + mock_role_resolver.resolve_roles.return_value = set() + mock_access_resolver = mocker.Mock() + mock_access_resolver.check_access.return_value = False + mocker.patch( + "authorization.middleware.get_authorization_resolvers", + return_value=(mock_role_resolver, mock_access_resolver), + ) + + with pytest.raises(HTTPException) as exc_info: + await create_saved_prompts_handler( + request=saved_prompts_http_request, + body=SavedPromptCreateRequest(name="Deploy", content="body"), + auth=MOCK_CREATE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_create_saved_prompts_returns_401_when_auth_rejects( + mocker: MockerFixture, + minimal_config: AppConfig, +) -> None: + """POST /v1/saved-prompts returns 401 when auth dependency rejects.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_authorization_resolvers(mocker) + + async def _reject(_self: object, _request: Request) -> None: + """Simulate auth rejection.""" + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "response": "Missing or invalid credentials provided by client", + "cause": "No Authorization header found", + }, + ) + + mocker.patch( + "authentication.noop.NoopAuthDependency.__call__", + _reject, + ) + + app = FastAPI() + app.include_router(router, prefix="/v1") + client = TestClient(app) + response = client.post( + "/v1/saved-prompts", + json={"name": "Deploy", "content": "body"}, + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_happy_path( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE /saved-prompts/{prompt_id} returns 204 and scopes DAL to auth user.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_delete = mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + return_value=None, + ) + + response = await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + mock_delete.assert_called_once_with(VALID_PROMPT_ID, "user-1") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.body == b"" + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_uses_authenticated_user_id( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE passes the authenticated user_id into DAL, not another user's.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_delete = mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + return_value=None, + ) + + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH_OTHER, + ) + + mock_delete.assert_called_once_with(VALID_PROMPT_ID, "user-2") + assert mock_delete.call_args.args[1] != "user-1" + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_invalid_id_format( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 400 for invalid prompt_id and does not call DAL.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_delete = mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user" + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=INVALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Invalid saved prompt ID format" # type: ignore[index] + mock_delete.assert_not_called() + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_not_found( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 404 when the prompt does not exist.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + side_effect=SavedPromptNotFoundError("Saved prompt not found"), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved Prompt not found" # type: ignore[index] + assert VALID_PROMPT_ID in detail["cause"] # type: ignore[index] + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_access_denied_as_not_found( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE maps non-owner access denied to 404 (same as missing).""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + side_effect=SavedPromptAccessDeniedError("Saved prompt access denied"), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Saved Prompt not found" # type: ignore[index] + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_uses_manage_saved_prompts_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE /saved-prompts/{prompt_id} authorizes with MANAGE_SAVED_PROMPTS.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + return_value=None, + ) + perform_check = mocker.patch( + "authorization.middleware._perform_authorization_check", + return_value=None, + ) + + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + perform_check.assert_awaited_once() + await_args = perform_check.await_args + assert await_args is not None + assert await_args.args[0] == Action.MANAGE_SAVED_PROMPTS + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_database_error( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 500 when the database delete fails.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mocker.patch( + "app.endpoints.saved_prompts.delete_saved_prompt_by_id_and_user", + side_effect=SQLAlchemyError("db down"), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["response"] == "Database query failed" # type: ignore[index] + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_configuration_not_loaded( + mocker: MockerFixture, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 500 when configuration is not loaded.""" + mock_authorization_resolvers(mocker) + mock_config = AppConfig() + mock_config._configuration = None # pylint: disable=protected-access + mocker.patch("app.endpoints.saved_prompts.configuration", mock_config) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + +@pytest.mark.asyncio +async def test_delete_saved_prompts_forbidden_without_action( + mocker: MockerFixture, + minimal_config: AppConfig, + saved_prompts_http_request: Request, +) -> None: + """DELETE returns 403 when MANAGE_SAVED_PROMPTS is denied.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + + mock_role_resolver = mocker.AsyncMock() + mock_role_resolver.resolve_roles.return_value = set() + mock_access_resolver = mocker.Mock() + mock_access_resolver.check_access.return_value = False + mocker.patch( + "authorization.middleware.get_authorization_resolvers", + return_value=(mock_role_resolver, mock_access_resolver), + ) + + with pytest.raises(HTTPException) as exc_info: + await delete_saved_prompts_handler( + request=saved_prompts_http_request, + prompt_id=VALID_PROMPT_ID, + auth=MOCK_DELETE_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_delete_saved_prompts_returns_401_when_auth_rejects( + mocker: MockerFixture, + minimal_config: AppConfig, +) -> None: + """DELETE /v1/saved-prompts/{prompt_id} returns 401 when auth rejects.""" + mocker.patch("app.endpoints.saved_prompts.configuration", minimal_config) + mock_authorization_resolvers(mocker) + + async def _reject(_self: object, _request: Request) -> None: + """Simulate auth rejection.""" + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "response": "Missing or invalid credentials provided by client", + "cause": "No Authorization header found", + }, + ) + + mocker.patch( + "authentication.noop.NoopAuthDependency.__call__", + _reject, + ) + + app = FastAPI() + app.include_router(router, prefix="/v1") + client = TestClient(app) + response = client.delete(f"/v1/saved-prompts/{VALID_PROMPT_ID}") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/unit/models/README.md b/tests/unit/models/README.md index 69f63da65..ce6a4101f 100644 --- a/tests/unit/models/README.md +++ b/tests/unit/models/README.md @@ -9,3 +9,7 @@ Unit tests for the ConversationSummary model. ## [test_saved_prompts_config.py](test_saved_prompts_config.py) Unit tests for SavedPromptsConfiguration. +## [test_saved_prompts_list_response.py](test_saved_prompts_list_response.py) + +Unit tests for saved prompts list response models. + diff --git a/tests/unit/models/responses/test_error_responses.py b/tests/unit/models/responses/test_error_responses.py index 5564bc2f4..70b137d8f 100644 --- a/tests/unit/models/responses/test_error_responses.py +++ b/tests/unit/models/responses/test_error_responses.py @@ -83,11 +83,12 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 2 + assert expected_count == 3 # Verify example structure assert "conversation_id" in examples assert "prompt_id" in examples + assert "saved_prompt_id" in examples conversation_example = examples["conversation_id"] assert "value" in conversation_example assert "detail" in conversation_example["value"] @@ -98,6 +99,10 @@ def test_openapi_response(self) -> None: assert ( prompt_example["value"]["detail"]["response"] == "Invalid prompt ID format" ) + saved_prompt_example = examples["saved_prompt_id"] + assert saved_prompt_example["value"]["detail"]["response"] == ( + "Invalid saved prompt ID format" + ) def test_openapi_response_with_explicit_examples(self) -> None: """Test BadRequestResponse.openapi_response() with explicit examples. @@ -376,12 +381,14 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 3 + assert expected_count == 5 # Verify all labeled examples are present assert "invalid format" in examples assert "missing attributes" in examples assert "invalid value" in examples + assert "saved prompt invalid" in examples + assert "saved prompt limit" in examples # Verify example structure for one example invalid_format_example = examples["invalid format"] @@ -518,7 +525,7 @@ def test_openapi_response(self) -> None: # Verify example count matches schema examples count assert len(examples) == expected_count - assert expected_count == 9 + assert expected_count == 10 # Verify all labeled examples are present assert "conversation" in examples @@ -530,6 +537,7 @@ def test_openapi_response(self) -> None: assert "file" in examples assert "prompt" in examples assert "mcp server" in examples + assert "saved prompt" in examples # Verify example structure for one example conversation_example = examples["conversation"] diff --git a/tests/unit/models/test_saved_prompts_list_response.py b/tests/unit/models/test_saved_prompts_list_response.py new file mode 100644 index 000000000..9c57d7c3c --- /dev/null +++ b/tests/unit/models/test_saved_prompts_list_response.py @@ -0,0 +1,74 @@ +"""Unit tests for saved prompts list response models.""" + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from models.api.responses.successful.saved_prompts import ( + SavedPromptResponse, + SavedPromptsListResponse, +) + + +def test_saved_prompt_response_serializes_expected_fields() -> None: + """SavedPromptResponse exposes id/name/content/timestamps and omits user_id.""" + created = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + updated = datetime(2026, 7, 22, 16, 5, 0, tzinfo=UTC) + item = SavedPromptResponse( + id="prompt-1", + name="Deploy to staging", + content="Help me write a deployment checklist", + created_at=created, + updated_at=updated, + ) + + payload = item.model_dump(mode="json") + + assert set(payload.keys()) == { + "id", + "name", + "content", + "created_at", + "updated_at", + } + assert payload["id"] == "prompt-1" + assert payload["name"] == "Deploy to staging" + assert payload["content"] == "Help me write a deployment checklist" + assert datetime.fromisoformat(payload["created_at"]) == created + assert datetime.fromisoformat(payload["updated_at"]) == updated + assert "user_id" not in payload + + +def test_saved_prompt_response_forbids_extra_fields() -> None: + """SavedPromptResponse rejects unknown fields such as user_id.""" + created = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + with pytest.raises(ValidationError): + SavedPromptResponse( + id="prompt-1", + name="Deploy", + content="body", + created_at=created, + updated_at=created, + user_id="user-1", # type: ignore[call-arg] + ) + + +def test_saved_prompts_list_response_empty_and_populated() -> None: + """SavedPromptsListResponse wraps prompts list including empty.""" + assert SavedPromptsListResponse(prompts=[]).model_dump() == {"prompts": []} + + created = datetime(2026, 7, 22, 16, 0, 0, tzinfo=UTC) + response = SavedPromptsListResponse( + prompts=[ + SavedPromptResponse( + id="prompt-1", + name="one", + content="c1", + created_at=created, + updated_at=created, + ) + ] + ) + assert len(response.prompts) == 1 + assert response.prompts[0].name == "one" diff --git a/tests/unit/utils/test_models_dumper.py b/tests/unit/utils/test_models_dumper.py index 2902fa25f..7230f8196 100644 --- a/tests/unit/utils/test_models_dumper.py +++ b/tests/unit/utils/test_models_dumper.py @@ -453,6 +453,13 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Invalid prompt ID format" }, "label": "prompt_id" + }, + { + "detail": { + "cause": "The saved prompt ID 123e4567-e89b-12d3-a456-426614174000 has invalid format.", + "response": "Invalid saved prompt ID format" + }, + "label": "saved_prompt_id" } ], "properties": { @@ -958,6 +965,13 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Tool conflict" }, "label": "file search conflict" + }, + { + "detail": { + "cause": "Saved Prompt with name 'Deploy to staging' is already registered", + "response": "Saved Prompt already exists" + }, + "label": "saved prompt" } ], "properties": { @@ -3248,6 +3262,13 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Prompt not found" }, "label": "prompt" + }, + { + "detail": { + "cause": "Saved Prompt with ID 123e4567-e89b-12d3-a456-426614174000 does not exist", + "response": "Saved Prompt not found" + }, + "label": "saved prompt" } ], "properties": { @@ -8457,6 +8478,20 @@ def test_dump_models(tmpdir: Path) -> None: "response": "Invalid attribute value" }, "label": "invalid value" + }, + { + "detail": { + "cause": "Saved prompt name must not be empty", + "response": "Invalid attribute value" + }, + "label": "saved prompt invalid" + }, + { + "detail": { + "cause": "Saved prompt limit exceeded: 50 existing prompts, maximum is 50", + "response": "Saved prompt limit exceeded" + }, + "label": "saved prompt limit" } ], "properties": { diff --git a/tests/unit/utils/test_saved_prompts.py b/tests/unit/utils/test_saved_prompts.py index 400fd03e4..9c5b54d9c 100644 --- a/tests/unit/utils/test_saved_prompts.py +++ b/tests/unit/utils/test_saved_prompts.py @@ -317,7 +317,7 @@ def test_list_returns_only_that_users_prompts_ordered_by_created_at_desc( """Test list is user-scoped and ordered by created_at descending.""" older = create_saved_prompt("user-1", "first", "c1", max_prompts_per_user=50) newer = create_saved_prompt("user-1", "second", "c2", max_prompts_per_user=50) - create_saved_prompt("user-2", "other", "c3", max_prompts_per_user=50) + create_saved_prompt("user-11", "other", "c3", max_prompts_per_user=50) # Concurrent inserts can share the same created_at; force a clear order. session_factory = sessionmaker( @@ -336,6 +336,25 @@ def test_list_returns_only_that_users_prompts_ordered_by_created_at_desc( assert [p.id for p in results] == [newer.id, older.id] assert all(p.user_id == "user-1" for p in results) + def test_list_excludes_near_collision_user_ids(self) -> None: + """Test list does not match nearby user_id strings (prefix/substring).""" + owned = create_saved_prompt( + "user-1", "deploy", "owned-body", max_prompts_per_user=50 + ) + create_saved_prompt( + "user-11", "deploy-1", "other-11-body", max_prompts_per_user=50 + ) + create_saved_prompt( + "user-1a", "deploy", "other-1a-body", max_prompts_per_user=50 + ) + + results = list_saved_prompts_by_user("user-1") + + assert len(results) == 1 + assert results[0].id == owned.id + assert results[0].user_id == "user-1" + assert results[0].content == "owned-body" + def test_list_caps_at_configured_upper_bound( self, mocker: MockerFixture, sqlite_engine: Engine ) -> None: