Skip to content

feat: Add PUT endpoint for projects upsert#11391

Open
ogabrielluiz wants to merge 2 commits into
mainfrom
feat/add-put-users-projects
Open

feat: Add PUT endpoint for projects upsert#11391
ogabrielluiz wants to merge 2 commits into
mainfrom
feat/add-put-users-projects

Conversation

@ogabrielluiz
Copy link
Copy Markdown
Contributor

@ogabrielluiz ogabrielluiz commented Jan 21, 2026

Summary

  • Adds PUT /api/v1/projects/{project_id} endpoint with upsert semantics (create or update)
  • Returns 201 Created for new projects, 200 OK for updates
  • Returns 404 for other user's projects (avoids leaking resource existence)
  • Returns 409 Conflict on name uniqueness violations
  • Adds parent_id field to FolderCreate schema for consistency with the model

Changes

API endpoint (src/backend/base/langflow/api/v1/projects.py):

  • _check_name_uniqueness() - reusable helper for name validation
  • _update_existing_project() - handles UPDATE path
  • _create_new_project() - handles CREATE path with specified ID
  • upsert_project() - main PUT endpoint

Model (src/backend/base/langflow/services/database/models/folder/model.py):

  • Added parent_id to FolderCreate schema

Tests (src/backend/tests/unit/api/v1/test_projects.py):

  • 8 new tests covering create, update, ownership, and conflict scenarios

Summary by CodeRabbit

  • New Features
    • Added unified project creation and update operation: create new projects or update existing ones through a single endpoint with appropriate status responses (201 for creation, 200 for updates).
    • Projects can now be organized hierarchically with parent-child relationships for better structure.
    • Enhanced error handling provides specific HTTP status codes for naming conflicts and authorization scenarios.

✏️ Tip: You can customize this high-level summary in your review settings.

@github-actions github-actions Bot added the community Pull Request from an external contributor label Jan 21, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 21, 2026

Walkthrough

Added a PUT endpoint (upsert_project) to projects API that creates a new project with a specified ID or updates an existing project if already owned by the user, along with helper functions for name uniqueness validation and project creation/update logic. A parent_id field was added to the FolderCreate model. Comprehensive test coverage validates the endpoint behavior including error handling for name conflicts and ownership checks.

Changes

Cohort / File(s) Summary
API Endpoint & Helpers
src/backend/base/langflow/api/v1/projects.py
Added upsert_project PUT endpoint with conditional create/update logic. Introduced internal helpers: _check_name_uniqueness (validates folder name uniqueness per user with optional exclusion), _update_existing_project (updates project with name conflict handling), and _create_new_project (creates project with specified ID and associates flows/components). Includes JSONResponse import and comprehensive error handling (404 for misowned projects, 409 for name conflicts, 500 for other errors).
Model Enhancement
src/backend/base/langflow/services/database/models/folder/model.py
Extended FolderCreate with optional parent_id: UUID | None = None field to support hierarchical project relationships.
Test Coverage
src/backend/tests/unit/api/v1/test_projects.py
Added 192 lines of test cases covering: project creation via PUT with specified ID, updating existing projects (name/description changes), 404 on foreign project upsert, 409 conflict handling for duplicate names, flow association/movement during upsert, parent project linkage, component preservation, and download/ZIP functionality edge cases.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)
Check name Status Explanation Resolution
Test Coverage For New Implementations ❌ Error Tests lack coverage for AUTH/MCP defaults parity and flow ownership validation, two critical security concerns identified in review comments. Add tests verifying auth_settings defaults match POST endpoint and that flow reassignment validates ownership; extract shared auth/MCP initialization logic into reusable helper.
Test Quality And Coverage ⚠️ Warning PUT endpoint tests lack verification of auth defaults inheritance and ownership constraints for flows/components, representing critical security boundary gaps. Add tests for AUTO_LOGIN auth defaults on PUT-created projects and for rejection of unowned flow reassignment attempts.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Add PUT endpoint for projects upsert' directly and accurately summarizes the main change: introducing a new PUT endpoint for project upsert functionality.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Test File Naming And Structure ✅ Passed Test file follows best practices with descriptive async test functions, proper organization using pytest classes, comprehensive coverage of positive/negative/edge cases, and thorough fixture-based setup/teardown.
Excessive Mock Usage Warning ✅ Passed The test file demonstrates appropriate mock usage without excessive mocking patterns. Tests use real fixtures and make genuine HTTP requests through AsyncClient, asserting on real response data and side effects rather than mocking internal dependencies.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/add-put-users-projects

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the enhancement New feature or request label Jan 21, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Jan 21, 2026

Codecov Report

❌ Patch coverage is 46.00000% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 34.56%. Comparing base (6f30ed2) to head (dbbd2ed).

Files with missing lines Patch % Lines
src/backend/base/langflow/api/v1/projects.py 44.89% 27 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main   #11391      +/-   ##
==========================================
+ Coverage   34.54%   34.56%   +0.02%     
==========================================
  Files        1415     1415              
  Lines       67401    67450      +49     
  Branches     9937     9937              
==========================================
+ Hits        23282    23313      +31     
- Misses      42889    42908      +19     
+ Partials     1230     1229       -1     
Flag Coverage Δ
backend 53.47% <46.00%> (+0.02%) ⬆️
lfx 41.59% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
.../langflow/services/database/models/folder/model.py 100.00% <100.00%> (ø)
src/backend/base/langflow/api/v1/projects.py 32.88% <44.89%> (+1.63%) ⬆️

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jan 21, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/backend/base/langflow/api/v1/projects.py`:
- Around line 531-549: The PUT-create path in _create_new_project currently only
constructs and persists the Folder model, so projects created via PUT skip the
POST path's auth defaults and MCP registration; update _create_new_project to
apply the same initialization logic used in create_project: after validating
uniqueness and before session.add, set auth defaults (e.g., enable API-key auth
when AUTO_LOGIN is false) and invoke the same MCP auto-registration routine used
by create_project (or call a new shared helper that encapsulates both the
API-key enabling logic and MCP registration), ensuring the code references the
same Folder model handling (Folder.model_validate) and reuses/create a helper to
avoid duplication.
- Around line 551-556: The code currently updates flows by IDs from
project.flows_list and project.components_list without checking ownership;
change the logic to first fetch/count Flow rows matching Flow.id.in_(flow_ids)
AND Flow.user_id == current_user.id (or your request user variable), compare the
count/IDs returned to the requested flow_ids and if any requested ID is missing
raise HTTPException(status_code=404), and only then perform the update
constrained to those same ownership-filtered rows (e.g.,
update(Flow).where(and_(Flow.id.in_(flow_ids), Flow.user_id ==
current_user.id)).values(folder_id=new_project.id)); ensure you reference
project.flows_list, project.components_list, Flow, new_project.id and the
authenticated user variable in the fix.
🧹 Nitpick comments (1)
src/backend/tests/unit/api/v1/test_projects.py (1)

221-265: Make other‑user cleanup resilient to test failures.

If an assertion fails before the cleanup block, the fixed username can remain in the DB and cause collisions in later runs. Wrap setup/teardown in try/finally and use a unique username.

♻️ Suggested tweak
 async def test_upsert_project_returns_404_for_other_users_project(client: AsyncClient, logged_in_headers):
     """Test that PUT returns 404 when trying to upsert another user's project (avoids leaking existence)."""
     from langflow.services.auth.utils import get_password_hash
     from langflow.services.database.models.user.model import User
 
     # Create another user
     other_user_id = uuid.uuid4()
-    async with session_scope() as session:
-        other_user = User(
-            id=other_user_id,
-            username="other_user_for_project_upsert_test",
-            password=get_password_hash("testpassword"),
-            is_active=True,
-            is_superuser=False,
-        )
-        session.add(other_user)
-        await session.commit()
-
-    # Login as other user and create a project
-    login_data = {
-        "username": "other_user_for_project_upsert_test",
-        "password": "testpassword",  # pragma: allowlist secret
-    }
-    login_response = await client.post("api/v1/login", data=login_data)
-    assert login_response.status_code == status.HTTP_200_OK, f"Login failed: {login_response.text}"
-    other_user_headers = {"Authorization": f"Bearer {login_response.json()['access_token']}"}
-
-    project_data = {"name": "other_user_project", "description": ""}
-    create_response = await client.post("api/v1/projects/", json=project_data, headers=other_user_headers)
-    other_user_project_id = create_response.json()["id"]
-
-    # Try to upsert other user's project with original user's credentials
-    update_data = {"name": "trying_to_steal", "description": ""}
-    response = await client.put(f"api/v1/projects/{other_user_project_id}", json=update_data, headers=logged_in_headers)
-
-    assert response.status_code == status.HTTP_404_NOT_FOUND
-    assert "not found" in response.json()["detail"].lower()
-
-    # Cleanup
-    async with session_scope() as session:
-        user = await session.get(User, other_user_id)
-        if user:
-            await session.delete(user)
-            await session.commit()
+    other_username = f"other_user_for_project_upsert_test_{other_user_id}"
+    try:
+        async with session_scope() as session:
+            other_user = User(
+                id=other_user_id,
+                username=other_username,
+                password=get_password_hash("testpassword"),
+                is_active=True,
+                is_superuser=False,
+            )
+            session.add(other_user)
+            await session.commit()
+
+        # Login as other user and create a project
+        login_data = {
+            "username": other_username,
+            "password": "testpassword",  # pragma: allowlist secret
+        }
+        login_response = await client.post("api/v1/login", data=login_data)
+        assert login_response.status_code == status.HTTP_200_OK, f"Login failed: {login_response.text}"
+        other_user_headers = {"Authorization": f"Bearer {login_response.json()['access_token']}"}
+
+        project_data = {"name": "other_user_project", "description": ""}
+        create_response = await client.post("api/v1/projects/", json=project_data, headers=other_user_headers)
+        other_user_project_id = create_response.json()["id"]
+
+        # Try to upsert other user's project with original user's credentials
+        update_data = {"name": "trying_to_steal", "description": ""}
+        response = await client.put(
+            f"api/v1/projects/{other_user_project_id}", json=update_data, headers=logged_in_headers
+        )
+
+        assert response.status_code == status.HTTP_404_NOT_FOUND
+        assert "not found" in response.json()["detail"].lower()
+    finally:
+        async with session_scope() as session:
+            user = await session.get(User, other_user_id)
+            if user:
+                await session.delete(user)
+                await session.commit()

As per coding guidelines, ensure async test resources are cleaned up with try/finally blocks.

Comment on lines +531 to +549
async def _create_new_project(
session: DbSession,
project: FolderCreate,
project_id: UUID,
user_id: UUID,
) -> FolderRead:
"""Create a new project with specified ID (PUT create path).

Unlike POST, this fails on name conflict instead of auto-renaming.
"""
await _check_name_uniqueness(session, project.name, user_id)

new_project = Folder.model_validate(project, from_attributes=True)
new_project.id = project_id
new_project.user_id = user_id

session.add(new_project)
await session.flush()
await session.refresh(new_project)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

PUT‑create bypasses POST defaults (auth + MCP) and can drift behavior.

create_project auto‑enables API‑key auth when AUTO_LOGIN is false and performs MCP auto‑registration; _create_new_project doesn’t, so PUT‑created projects can end up with different auth/MCP behavior. This is user‑visible and may weaken the intended auth posture. Please mirror the POST initialization here (or extract shared helpers).

🔒 Proposed fix (mirror POST auth defaults)
     new_project = Folder.model_validate(project, from_attributes=True)
     new_project.id = project_id
     new_project.user_id = user_id
+
+    # Mirror POST defaults for auth_settings when AUTO_LOGIN is disabled
+    settings_service = get_settings_service()
+    if not settings_service.auth_settings.AUTO_LOGIN and not new_project.auth_settings:
+        new_project.auth_settings = encrypt_auth_settings({"auth_type": "apikey"})
+        await logger.adebug(
+            f"Auto-enabled API key authentication for project {new_project.name} ({new_project.id}) "
+            "due to AUTO_LOGIN=false"
+        )

Also consider extracting the MCP auto‑registration block from create_project and reusing it here for consistency.

🤖 Prompt for AI Agents
In `@src/backend/base/langflow/api/v1/projects.py` around lines 531 - 549, The
PUT-create path in _create_new_project currently only constructs and persists
the Folder model, so projects created via PUT skip the POST path's auth defaults
and MCP registration; update _create_new_project to apply the same
initialization logic used in create_project: after validating uniqueness and
before session.add, set auth defaults (e.g., enable API-key auth when AUTO_LOGIN
is false) and invoke the same MCP auto-registration routine used by
create_project (or call a new shared helper that encapsulates both the API-key
enabling logic and MCP registration), ensuring the code references the same
Folder model handling (Folder.model_validate) and reuses/create a helper to
avoid duplication.

Comment on lines +551 to +556
# Associate flows with the new project
flow_ids = (project.flows_list or []) + (project.components_list or [])
if flow_ids:
await session.exec(
update(Flow).where(Flow.id.in_(flow_ids)).values(folder_id=new_project.id) # type: ignore[attr-defined]
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate flow/component ownership before reassignment.

The PUT‑create path updates flows by ID without a user_id check, so a caller could reassign flows they don’t own. Please constrain updates to the current user and return 404 on any non‑owned IDs to avoid leakage.

🔐 Proposed fix
     flow_ids = (project.flows_list or []) + (project.components_list or [])
     if flow_ids:
-        await session.exec(
-            update(Flow).where(Flow.id.in_(flow_ids)).values(folder_id=new_project.id)  # type: ignore[attr-defined]
-        )
+        owned_ids = (
+            await session.exec(select(Flow.id).where(Flow.id.in_(flow_ids), Flow.user_id == user_id))
+        ).all()
+        owned_ids = set(owned_ids)
+        if set(flow_ids) - owned_ids:
+            raise HTTPException(status_code=404, detail="Flow not found")
+        await session.exec(
+            update(Flow).where(Flow.id.in_(owned_ids)).values(folder_id=new_project.id)  # type: ignore[attr-defined]
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Associate flows with the new project
flow_ids = (project.flows_list or []) + (project.components_list or [])
if flow_ids:
await session.exec(
update(Flow).where(Flow.id.in_(flow_ids)).values(folder_id=new_project.id) # type: ignore[attr-defined]
)
# Associate flows with the new project
flow_ids = (project.flows_list or []) + (project.components_list or [])
if flow_ids:
owned_ids = (
await session.exec(select(Flow.id).where(Flow.id.in_(flow_ids), Flow.user_id == user_id))
).all()
owned_ids = set(owned_ids)
if set(flow_ids) - owned_ids:
raise HTTPException(status_code=404, detail="Flow not found")
await session.exec(
update(Flow).where(Flow.id.in_(owned_ids)).values(folder_id=new_project.id) # type: ignore[attr-defined]
)
🤖 Prompt for AI Agents
In `@src/backend/base/langflow/api/v1/projects.py` around lines 551 - 556, The
code currently updates flows by IDs from project.flows_list and
project.components_list without checking ownership; change the logic to first
fetch/count Flow rows matching Flow.id.in_(flow_ids) AND Flow.user_id ==
current_user.id (or your request user variable), compare the count/IDs returned
to the requested flow_ids and if any requested ID is missing raise
HTTPException(status_code=404), and only then perform the update constrained to
those same ownership-filtered rows (e.g.,
update(Flow).where(and_(Flow.id.in_(flow_ids), Flow.user_id ==
current_user.id)).values(folder_id=new_project.id)); ensure you reference
project.flows_list, project.components_list, Flow, new_project.id and the
authenticated user variable in the fix.

@ogabrielluiz ogabrielluiz removed the community Pull Request from an external contributor label Mar 3, 2026
@Cristhianzl
Copy link
Copy Markdown
Member

CRITICAL: Security & PII

Item Status Notes
PII in logs PASS No logs added
Secrets/credentials in code PASS No secrets exposed
Internal details exposed in errors FAIL str(e) exposed in HTTP 500 response (see finding below)
SQL injection PASS Uses parameterized queries via SQLModel

CRITICAL: DRY

Item Status Notes
Duplicate logic FAIL Update path duplicates update_project (PATCH) partially and incompletely
Duplicate types PASS Reuses existing FolderCreate, FolderRead
Duplicate validation WARN _check_name_uniqueness is new but similar validation exists implicitly via DB constraint

CRITICAL: File Structure

Item Status Notes
~500 line limit FAIL projects.py already has 691 lines; PR adds ~105 more → ~796 lines
Function limit (different responsibilities) FAIL File now has check*, update*, create*, download*, upload* prefixes (5+ different)
Mixed responsibilities FAIL Validation, persistence, and HTTP handlers mixed in same file
Class limit PASS No classes in the file

IMPORTANT: Architecture & Structure

Item Status Notes
SRP FAIL New helpers mix validation + persistence + HTTP handling in one file
Layer separation WARN Business logic (name uniqueness check) lives in the route handler file
Error handling FAIL Generic except Exception with str(e) in response; DB-specific string matching

IMPORTANT: Code Quality

Item Status Notes
Strong typing PASS Proper use of UUID, type hints
Clean code WARN if project.name: is falsy for empty string (inconsistent with is not None checks)
Naming PASS Function names are clear and descriptive

TESTING

Item Status Notes
Happy path tests PASS 8 tests cover basic create, update, ownership, conflicts
Adversarial tests FAIL Missing edge cases, boundary values, security scenarios
Coverage validated FAIL No evidence of coverage execution in the PR

Detailed Findings


🔴 CRITICAL #1 — File Structure Violation (Blocker)

File: src/backend/base/langflow/api/v1/projects.py

The file already has 691 lines. This PR adds ~105 more lines, bringing it to ~796 lines.

  • REVIEWER_RULE.md sets a limit of ~500 lines (up to ~530 OK; 600+ is a red flag)
  • DEVELOPMENT_RULE.md sets a limit of 300 lines
  • The file was already a violation BEFORE this PR; this PR significantly worsens it

Required action: The new functions (_check_name_uniqueness, _update_existing_project, _create_new_project, upsert_project) should be extracted, or the entire file needs refactoring with responsibility separation.


🔴 CRITICAL #2 — Mixed Responsibilities in Same File

The file now contains functions with different responsibility prefixes:

Function Responsibility
_check_name_uniqueness() Validation (check* prefix)
_update_existing_project() Data persistence (update* prefix)
_create_new_project() Data persistence (create* prefix)
upsert_project() HTTP handler (endpoint)
create_project() HTTP handler (endpoint)
update_project() HTTP handler (endpoint)
delete_project() HTTP handler (endpoint)
download_file() Serialization (download* prefix)
upload_file() Parsing (upload* prefix)

Per the rules, functions with different prefixes (check*, update*, create*, download*, upload*) MUST NOT coexist in the same file.

Required action: Separate validation helpers, persistence logic, and handlers into distinct files.


🔴 CRITICAL #3 — Internal Error Details Exposed to Users (Security)

# projects.py, upsert_project endpoint
except Exception as e:
    if "UNIQUE constraint failed" in str(e):
        raise HTTPException(status_code=409, detail="Name must be unique") from e
    raise HTTPException(status_code=500, detail=str(e)) from e  # ❌ DANGER

Problems:

  1. detail=str(e) exposes internal error messages (stack traces, table names, DB structure) directly to end users
  2. Generic except Exception catch violates the "no generic exceptions" rule
  3. String matching "UNIQUE constraint failed" is brittle — works with SQLite but fails with PostgreSQL (which uses "duplicate key value violates unique constraint")

Break risk: In production with PostgreSQL, a name conflict would return 500 with internal DB details instead of 409.

Suggested fix:

except IntegrityError as e:
    raise HTTPException(status_code=409, detail="Name must be unique") from e
except Exception:
    raise HTTPException(status_code=500, detail="Internal server error")

🟠 IMPORTANT #1 — Incomplete Functionality: MCP Server Not Handled

The existing create_project endpoint (lines 55-213) does:

  • Auto-registers MCP server
  • Auto-enables API key auth when AUTO_LOGIN=false

The existing update_project endpoint (lines 304-489) does:

  • Renames MCP server when the project name changes
  • Starts/stops MCP Composer based on auth_settings changes
  • Moves excluded flows to DEFAULT_FOLDER

The new upsert_project does NONE of these.

Break risk:

  • Projects created via PUT will not have MCP server registered
  • Projects updated via PUT will not have MCP server renamed
  • Inconsistency between projects created via POST vs PUT
  • If the frontend or any client switches to using PUT, projects will lack MCP configuration

🟠 IMPORTANT #2 — auth_settings Ignored in Update Path

_update_existing_project does not handle auth_settings:

async def _update_existing_project(...) -> FolderRead:
    if project.name:
        existing_project.name = project.name
    if project.description is not None:
        existing_project.description = project.description
    if project.parent_id is not None:
        existing_project.parent_id = project.parent_id
    # ❌ auth_settings is not handled

But FolderBase (parent model of FolderCreate) has auth_settings: dict | None.

Risk: Updates via PUT silently lose authentication configuration.


🟠 IMPORTANT #3 — Inconsistency: flows_list/components_list Ignored in Update Path

In the create path (PUT create), flows_list and components_list are processed:

# _create_new_project
flow_ids = (project.flows_list or []) + (project.components_list or [])
if flow_ids:
    await session.exec(update(Flow).where(Flow.id.in_(flow_ids)).values(folder_id=new_project.id))

In the update path (PUT update), they are completely ignored. This is inconsistent with the existing PATCH update_project, which handles flows and components.

Risk: Clients using PUT to update projects with flow reassociation will get unexpected behavior — flows silently remain unchanged.


🟠 IMPORTANT #4 — Falsy Check Fails with Empty String

async def _update_existing_project(...):
    if project.name:        # ❌ Empty string "" is falsy — silently skipped
        ...
    if project.description is not None:  # ✅ Correct
        ...
    if project.parent_id is not None:    # ✅ Correct
        ...

FolderCreate inherits name: str from FolderBase as a required field. But:

  • if project.name: treats "" as falsy, silently preserving the old name
  • Inconsistent with the other fields that use is not None

Risk: Sending name: "" silently preserves the old name with no error.


🟠 IMPORTANT #5 — DRY Violation: Partial Duplication with Existing update_project

The update logic in _update_existing_project partially duplicates what update_project (PATCH) already does, but incompletely:

Feature PATCH update_project PUT upsert (update path)
Updates name
Updates description
Updates parent_id
Updates auth_settings
Handles MCP server rename
Handles MCP Composer
Reassociates flows/components
Moves excluded flows to default
Validates name uniqueness Implicit (DB constraint) ✅ Explicit

This creates two update paths with different behaviors, which is a source of future bugs.


🟠 IMPORTANT #6 — Race Condition Between Check and Create

existing_project = (await session.exec(select(Folder).where(Folder.id == project_id))).first()

if existing_project is not None:
    # update path
else:
    # create path — but another request could have created between the check and the create

The protection via except Exception + string matching is not robust (see PostgreSQL issue above).


🟡 RECOMMENDED #1 — No Logging at Key Decision Points

The endpoint has no structured logging for:

  • Which path was taken (create vs update)
  • Ownership check failure (404 return)
  • Name conflict (409 return)

Per observability rules, decision points should have structured logging.


🟡 RECOMMENDED #2 — DB Error String Matching is Fragile

if "UNIQUE constraint failed" in str(e):
  • SQLite: "UNIQUE constraint failed: folder.user_id, folder.name"
  • PostgreSQL: "duplicate key value violates unique constraint"
  • MySQL: "Duplicate entry ... for key"

This code works only with SQLite. In production with another database, the 409 will never be returned for DB-level name conflicts.


🟡 RECOMMENDED #3 — parent_id Without Existence Validation

if project.parent_id is not None:
    existing_project.parent_id = project.parent_id

There is no check whether parent_id points to an existing project owned by the same user. This can create invalid hierarchies or references to other users' projects.


🟢 TESTING — Missing Adversarial Tests

The 8 tests cover happy path scenarios and some conflicts, but are missing:

Missing Test Type
PUT with name: "" (empty string) Edge case
PUT with very long name (thousands of characters) Boundary
PUT with special characters / SQL injection in name Security
PUT with parent_id that doesn't exist Edge case
PUT with parent_id pointing to another user's project Security
PUT with invalid project_id (not a UUID) Invalid input
PUT update without sending name (required field in FolderCreate) Validation
PUT create with flows_list containing non-existent flow_id Edge case
PUT create with flows_list containing another user's flow Security
Concurrent upsert with same name Race condition

Per the rule: "Happy path tests are the foundation — but they are NOT enough by themselves."


🟢 TESTING — Coverage Not Validated

The rules require:

  • Coverage to be executed and shown (≥75% minimum, target 80%)
  • For ALL created tests

The PR shows no evidence of coverage execution.


Break Risk Summary

Risk Severity Scenario
500 error with internal details exposed to user 🔴 High Any non-SQLite exception in upsert
Name conflict returns 500 instead of 409 on PostgreSQL 🔴 High Production with PostgreSQL
Projects created via PUT without MCP server 🟠 Medium Frontend uses PUT instead of POST
Auth settings lost on update via PUT 🟠 Medium Client updates project via PUT
Flows not reassociated in update path 🟠 Medium Client sends flows_list in PUT update
Reference to non-existent parent_id 🟡 Low API used directly with invalid parent_id

Recommendations

  1. Block merge until CRITICAL items are resolved (file structure, security, DB-specific string matching)
  2. Reuse logic from existing update_project instead of creating a partial duplicate update path
  3. Use IntegrityError from SQLAlchemy instead of string matching for DB conflict handling
  4. Never expose str(e) in HTTP responses — use a generic message
  5. Add adversarial tests to cover edge cases and security scenarios
  6. Add MCP handling or explicitly document why it is not needed
  7. Run coverage and include the output in the PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants