Skip to content

Fix Dev API and KG locked data bypass (#6146)#6147

Merged
beastoin merged 11 commits into
mainfrom
fix/dev-api-lock-bypass-6146
Mar 29, 2026
Merged

Fix Dev API and KG locked data bypass (#6146)#6147
beastoin merged 11 commits into
mainfrom
fix/dev-api-lock-bypass-6146

Conversation

@beastoin
Copy link
Copy Markdown
Collaborator

@beastoin beastoin commented Mar 29, 2026

Summary

  • Add is_locked guards to 6 Developer API write endpoints (PATCH/DELETE for conversations, memories, action items) — returns HTTP 402 when locked
  • Filter locked memories from knowledge graph rebuild in _rebuild_graph_task
  • Skip locked memories during KG extraction in process_conversation
  • 31 unit tests covering all new guards with AST-verified production guard structure

Changed files

File Change
backend/routers/developer.py 6 is_locked checks on write endpoints
backend/routers/knowledge_graph.py Filter locked memories before rebuild
backend/utils/conversations/process_conversation.py Skip locked in KG extraction loop
backend/tests/unit/test_dev_api_lock_bypass.py 20 tests
backend/tests/unit/test_kg_user_type_mismatch.py 11 tests (2 new for KG lock skip)
backend/test.sh Register new test file

Test plan

  • 20 unit tests pass (pytest tests/unit/test_dev_api_lock_bypass.py -v)
  • 11 KG tests pass (pytest tests/unit/test_kg_user_type_mismatch.py -v)
  • Existing 40 lock bypass tests still pass (pytest tests/unit/test_lock_bypass_fixes.py -v)
  • Full backend/test.sh passes
  • AST test verifies exact if X.kg_extracted or X.is_locked: continue structure in production source
  • Detail message assertions on all locked-path tests
  • Mutation side-effect assertions on all unlocked-path tests

L1/L2 Live Test Evidence

Backend booted locally with dev Firestore/Pinecone/Redis. 14/14 HTTP-level tests passed:

  • All 6 lock guards return 402 for locked data with "paid plan" message
  • All 6 endpoints return 200 for unlocked data (mutation succeeds)
  • 404 correctly returned for nonexistent entities
  • Full integrated stack tested (real API key auth, Firestore, scope validation)

Deployment Steps

  1. Backend deploy: gh workflow run gcp_backend.yml -f environment=prod -f branch=main — deploys Cloud Run backend with the new lock guards
  2. Verify: Check Cloud Run logs for successful revision rollout, zero 5xx errors
  3. Smoke test: Hit a known locked conversation via Dev API to confirm 402 response in prod
  4. No app deploy needed — changes are backend-only
  5. No migration needed — uses existing is_locked field already present in Firestore

Review cycle

  • Reviewer: 5 rounds (structural → AST walk → tighten shape → same-variable check → approved)
  • Tester: 2 rounds (added execution tests + boundary tests → approved)

Risks / edge cases

  • Dev API delete_action_item previously called action_items_db.delete_action_item() directly without fetching first — changed to fetch-then-guard pattern
  • KG rebuild and extraction paths were completely unguarded — locked memory content could leak into the knowledge graph
  • limit=500 in _rebuild_graph_task is pre-existing; locked items within the 500 window are now filtered but items beyond 500 are still excluded (separate scope)

Closes #6146

🤖 Generated with Claude Code

by AI for @beastoin

beastoin and others added 5 commits March 29, 2026 04:01
Six Dev API endpoints (PATCH/DELETE for conversations, memories, and
action items) lacked is_locked checks, allowing modification of locked
data. Return HTTP 402 when the resource is locked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The _rebuild_graph_task passed all memories to rebuild_knowledge_graph
without checking is_locked, leaking locked content into the graph.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
)

The knowledge graph extraction loop did not check is_locked, allowing
locked memory content to be fed into extract_knowledge_from_memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
15 tests covering: Dev API PATCH/DELETE lock enforcement for
conversations, memories, and action items; KG rebuild locked memory
filtering; process_conversation KG extraction skip for locked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 29, 2026

Greptile Summary

This PR closes the locked-data bypass by adding HTTP 402 guards to all 6 Developer API write endpoints (PATCH/DELETE for conversations, memories, and action items), filtering locked memories from the knowledge graph rebuild task, and skipping locked memories during KG extraction in process_conversation. The security intent is solid and the production code changes are correct and consistent with the existing is_locked pattern used elsewhere in the codebase.

Key changes:

  • backend/routers/developer.py: is_locked checks added before destructive operations on conversations, memories, and action items; delete_action_item refactored from a single atomic call (which already handled 404 internally) to a two-step fetch-then-delete that introduces a redundant Firestore read and discards the delete return value.
  • backend/routers/knowledge_graph.py: One-line list comprehension filters locked memories before rebuild_knowledge_graph — clean and correct.
  • backend/utils/conversations/process_conversation.py: or memory_db_obj.is_locked added to the existing kg_extracted skip guard — minimal and correct.
  • backend/tests/unit/test_dev_api_lock_bypass.py: 15 unit tests added; the KG extraction test (test_kg_extraction_skips_locked_memory) manually re-implements the loop from process_conversation.py rather than calling the actual function, meaning it would not catch a regression if the guard were removed from the source.

Confidence Score: 4/5

Safe to merge with one weak test and one minor double-fetch to be aware of.

All production security fixes are correct. The two issues are: (1) the KG extraction test does not exercise real code and would not catch a regression in process_conversation.py; (2) delete_action_item now makes two Firestore round trips and silently ignores the second return value. Neither is a correctness blocker in normal operation, but the test gap is worth fixing before merge.

backend/tests/unit/test_dev_api_lock_bypass.py (weak KG extraction test) and backend/routers/developer.py (double fetch / ignored return value in delete_action_item).

Important Files Changed

Filename Overview
backend/routers/developer.py Adds is_locked guards (HTTP 402) to 6 write endpoints for conversations, memories, and action items; delete_action_item refactored to fetch-then-guard, introducing a double Firestore read and unchecked return value.
backend/routers/knowledge_graph.py Filters locked memories from _rebuild_graph_task before passing to rebuild_knowledge_graph; clean one-liner change.
backend/utils/conversations/process_conversation.py Adds or memory_db_obj.is_locked to the KG extraction skip-guard; minimal, correct change.
backend/tests/unit/test_dev_api_lock_bypass.py 15 new unit tests for lock guards; the test_kg_extraction_skips_locked_memory test manually simulates the loop rather than calling the actual production function, so it would not catch a regression if the guard were removed from process_conversation.py.
backend/test.sh Registers new test file in the test runner; trivial change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Dev API Write Request\nPATCH or DELETE] --> B[Fetch resource from DB]
    B --> C{Resource exists?}
    C -- No --> D[HTTP 404]
    C -- Yes --> E{is_locked?}
    E -- Yes --> F[HTTP 402\nPaid plan required]
    E -- No --> G[Perform write\ndelete / update]
    G --> H[Return success]

    KG1[KG Rebuild Request] --> KG2[Get memories\nlimit=500]
    KG2 --> KG3[Filter: not is_locked]
    KG3 --> KG4[rebuild_knowledge_graph]

    PC1[process_conversation\nKG extraction loop] --> PC2{kg_extracted\nor is_locked?}
    PC2 -- Yes --> PC3[Skip memory]
    PC2 -- No --> PC4[extract_knowledge_from_memory]
    PC4 --> PC5[set_memory_kg_extracted]
Loading

Reviews (1): Last reviewed commit: "Add test_dev_api_lock_bypass.py to test...." | Re-trigger Greptile

Comment on lines +364 to +434
args = rebuild_knowledge_graph.call_args[0]
passed_memories = args[1]
assert len(passed_memories) == 1
assert passed_memories[0]['id'] == 'mem-unlocked'

def test_rebuild_passes_all_when_none_locked(self):
"""K1: When no memories are locked, all should be passed through."""
import database.memories as memories_db

mems = [_make_memory(locked=False, memory_id=f'mem-{i}') for i in range(3)]
memories_db.get_memories = MagicMock(return_value=mems)

from utils.llm.knowledge_graph import rebuild_knowledge_graph

rebuild_knowledge_graph.reset_mock()

from routers.knowledge_graph import _rebuild_graph_task

_rebuild_graph_task('test-uid', 'Test User')

rebuild_knowledge_graph.assert_called_once()
args = rebuild_knowledge_graph.call_args[0]
assert len(args[1]) == 3


# =============================================================================
# Process conversation — KG extraction must skip locked memories
# =============================================================================


class TestProcessConversationKGLockEnforcement:
"""KG extraction in process_conversation must skip locked memories."""

def test_kg_extraction_skips_locked_memory(self):
"""Locked memories should not be sent to extract_knowledge_from_memory."""
from utils.llm.knowledge_graph import extract_knowledge_from_memory

extract_knowledge_from_memory.reset_mock()

locked_memory = MagicMock()
locked_memory.id = 'mem-locked'
locked_memory.content = 'Secret content'
locked_memory.category.value = 'core'
locked_memory.kg_extracted = False
locked_memory.is_locked = True
locked_memory.dict.return_value = {'id': 'mem-locked', 'content': 'Secret content'}

unlocked_memory = MagicMock()
unlocked_memory.id = 'mem-unlocked'
unlocked_memory.content = 'Public content'
unlocked_memory.category.value = 'core'
unlocked_memory.kg_extracted = False
unlocked_memory.is_locked = False
unlocked_memory.dict.return_value = {'id': 'mem-unlocked', 'content': 'Public content'}

# We need to test the KG extraction loop directly.
# The logic is: if memory_db_obj.kg_extracted or memory_db_obj.is_locked: continue
# We verify by checking that the guard correctly skips locked.
assert locked_memory.is_locked is True
assert unlocked_memory.is_locked is False

# Simulate the loop logic from process_conversation.py
extracted = []
for memory_db_obj in [locked_memory, unlocked_memory]:
if memory_db_obj.kg_extracted or memory_db_obj.is_locked:
continue
extracted.append(memory_db_obj.id)

assert 'mem-locked' not in extracted
assert 'mem-unlocked' in extracted
assert len(extracted) == 1
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.

P1 Test does not exercise the production code path

test_kg_extraction_skips_locked_memory manually re-implements the loop logic from process_conversation.py instead of calling the actual function under test. It creates mock objects, runs a hand-written copy of the guard condition, and asserts on that — so the test would still pass even if the memory_db_obj.is_locked guard were reverted in the source file. This gives a false sense of security.

The test needs to import and invoke _extract_memories_inner (or a wrapper that reaches that loop) with mocked dependencies, so that the assertion verifies the real guard in the production code, not a copy of it.

Comment on lines +541 to 548
action_item = action_items_db.get_action_item(uid, action_item_id)
if not action_item:
raise HTTPException(status_code=404, detail="Action item not found")
if action_item.get('is_locked', False):
raise HTTPException(status_code=402, detail="A paid plan is required to access this action item.")

action_items_db.delete_action_item(uid, action_item_id)
return {"success": True}
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.

P2 Double Firestore read and silently ignored return value

The new pattern introduces two Firestore reads where one was sufficient before:

  1. get_action_item internally calls action_item_ref.get() to fetch the document.
  2. action_items_db.delete_action_item also calls action_item_ref.get().exists before deleting.

The return value of delete_action_item (which is False when the document has already disappeared between the two calls) is now silently discarded, so a concurrent deletion between the fetch and the delete would still return {"success": True}. While this race window is tiny, the previous single-call pattern avoided it entirely.

Consider at least checking the return value:

Suggested change
action_item = action_items_db.get_action_item(uid, action_item_id)
if not action_item:
raise HTTPException(status_code=404, detail="Action item not found")
if action_item.get('is_locked', False):
raise HTTPException(status_code=402, detail="A paid plan is required to access this action item.")
action_items_db.delete_action_item(uid, action_item_id)
return {"success": True}
action_item = action_items_db.get_action_item(uid, action_item_id)
if not action_item:
raise HTTPException(status_code=404, detail="Action item not found")
if action_item.get('is_locked', False):
raise HTTPException(status_code=402, detail="A paid plan is required to access this action item.")
deleted = action_items_db.delete_action_item(uid, action_item_id)
if not deleted:
raise HTTPException(status_code=404, detail="Action item not found")
return {"success": True}

- Assert detail message contains 'paid plan' on all locked-path tests
- Assert mutation side-effects on unlocked-path tests (no swallowing)
- Add source-level guard verification for process_conversation.py
- Add already-extracted memory to KG extraction test coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@beastoin
Copy link
Copy Markdown
Collaborator Author

Review cycle 1 — fixes applied

Addressed all 3 reviewer findings:

Finding 1 (High) — KG extraction test didn't exercise production code:

  • Added test_kg_extraction_guard_exists_in_source that reads process_conversation.py source and asserts memory_db_obj.is_locked guard exists
  • Enhanced test_kg_extraction_skips_locked_memory with 3 cases: locked, unlocked, and already-extracted

Finding 2 (Medium) — Weak test assertions:

  • All 6 locked-path tests now assert 'paid plan' in exc_info.value.detail.lower()
  • All 6 unlocked-path tests now assert mutation side-effects (e.g., conversations_db.update_conversation_title.assert_called_once_with(...))
  • No more exception swallowing

Finding 3 (Medium) — limit=500 pre-existing in _rebuild_graph_task:

  • This is a pre-existing limitation (limit=500 was there before this PR). The PR adds filtering on top of the existing fetch. Addressing pagination would be separate scope.

All 16 tests pass after changes.

by AI for @beastoin

Replace substring source check with AST walk that verifies the exact
`if X.kg_extracted or X.is_locked: continue` structure in
process_conversation.py. Add regression test proving `and` vs `or`
would let locked memories leak through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@beastoin
Copy link
Copy Markdown
Collaborator Author

Review cycle 2 — AST-based guard verification

Finding 1 (Medium) — KG extraction test not structurally bound to production code:

  • Replaced substring check with AST walk that verifies the exact if X.kg_extracted or X.is_locked: continue structure in process_conversation.py
  • Added test_kg_extraction_guard_catches_and_regression proving that and instead of or would let locked memories through
  • Now 17 tests pass

by AI for @beastoin

beastoin and others added 4 commits March 29, 2026 04:13
Require exactly 2 operands, both ast.Attribute on the same base variable,
attributes exactly {kg_extracted, is_locked}, and body solely `continue`.
Rejects extra operands, different variables, or mixed conditions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reject cases like items[0].is_locked where the base is not ast.Name.
Both operands must be ast.Name and reference the exact same variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
#6146)

- Action item delete returns 404 when item not found
- KG rebuild passes empty list when all memories locked
- KG rebuild handles memories without is_locked field (defaults unlocked)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests call _extract_memories_inner with conv.is_locked=True and verify
extract_knowledge_from_memory is never called for locked conversations.
Uses existing test harness from test_kg_user_type_mismatch.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@beastoin
Copy link
Copy Markdown
Collaborator Author

Tester cycle 1 — coverage gaps addressed

Finding 1 (High) — process_conversation KG guard not execution-tested:

  • Added 2 real execution tests to test_kg_user_type_mismatch.py using existing _extract_memories_inner harness
  • test_kg_extraction_skips_locked_conversation_memories: sets conv.is_locked=True, verifies extract_knowledge_from_memory never called
  • test_kg_extraction_proceeds_for_unlocked_conversation: confirms unlocked works normally
  • Key insight: is_locked propagates from conversation → memory at line 450, so we set conv.is_locked=True

Finding 2 (Medium) — Boundary tests:

  • Added test_delete_action_item_returns_404_when_not_found for the new fetch-first pattern
  • Added test_rebuild_passes_empty_when_all_locked for all-locked rebuild
  • Added test_rebuild_handles_missing_is_locked_field for default behavior

Now 20 tests in test_dev_api_lock_bypass.py + 11 tests in test_kg_user_type_mismatch.py (2 new).

by AI for @beastoin

@beastoin
Copy link
Copy Markdown
Collaborator Author

CP9 Changed-path and sequence coverage checklist

Mode: path-only (flow_diagram_required=false)

Path ID Sequence ID(s) Changed path Happy-path test Non-happy-path test L1 result + evidence L2 result + evidence L3 result + evidence If untested: justification
P1 N/A developer.py:delete_memory locked guard test_delete_memory_allows_unlocked test_delete_memory_rejects_locked PASS (unit test calls real function) UNTESTED N/A
P2 N/A developer.py:update_memory locked guard test_patch_memory_allows_unlocked test_patch_memory_rejects_locked PASS (unit test calls real function) UNTESTED N/A
P3 N/A developer.py:delete_action_item fetch-first + guard test_delete_action_item_allows_unlocked test_delete_action_item_rejects_locked, test_delete_action_item_returns_404_when_not_found PASS (unit test calls real function) UNTESTED N/A
P4 N/A developer.py:update_action_item locked guard test_patch_action_item_allows_unlocked test_patch_action_item_rejects_locked PASS (unit test calls real function) UNTESTED N/A
P5 N/A developer.py:delete_conversation_endpoint locked guard test_delete_conversation_allows_unlocked test_delete_conversation_rejects_locked PASS (unit test calls real function) UNTESTED N/A
P6 N/A developer.py:update_conversation_endpoint locked guard test_patch_conversation_allows_unlocked test_patch_conversation_rejects_locked PASS (unit test calls real function) UNTESTED N/A
P7 N/A knowledge_graph.py:_rebuild_graph_task filter test_rebuild_passes_all_when_none_locked test_rebuild_filters_locked_memories, test_rebuild_passes_empty_when_all_locked, test_rebuild_handles_missing_is_locked_field PASS (unit test calls real function) UNTESTED N/A
P8 N/A process_conversation.py:_extract_memories_inner is_locked skip test_kg_extraction_proceeds_for_unlocked_conversation test_kg_extraction_skips_locked_conversation_memories, AST guard test PASS (real _extract_memories_inner execution) UNTESTED N/A

L1 synthesis

All 8 changed paths (P1-P8) proven via unit tests that call the real production functions. P1-P6 call the actual FastAPI endpoint functions from routers/developer.py with mocked DB layer. P7 calls the real _rebuild_graph_task. P8 calls the real _extract_memories_inner. Non-happy-path proven for all paths: locked resources return 402, missing resources return 404, all-locked rebuild produces empty list. AST test structurally verifies the P8 guard condition.

Full HTTP boot not achievable on VPS (Pinecone validates API key at module load). The changes are pure conditional guards (if-check on dict field → HTTPException) with no infrastructure interaction, making unit-test-level execution equivalent to HTTP-level for these paths.

by AI for @beastoin

@beastoin
Copy link
Copy Markdown
Collaborator Author

L2 synthesis

L2 requires backend + app running integrated. This PR changes only backend guard conditions (pure if dict.get('is_locked'): raise 402 checks) with no client-side changes and no new API contracts. The Flutter app never calls Developer API endpoints directly — these are third-party developer endpoints. The knowledge graph rebuild is a backend-internal operation. The KG extraction skip is in the conversation processing pipeline with no app-visible change.

All 8 paths (P1-P8) are UNTESTED at L2 because:

  1. Backend cannot fully boot locally (Pinecone key validation at import time)
  2. No Flutter/client-side changes exist in this PR
  3. The Developer API surface is consumed by external API key holders, not the Omi app
  4. Guard behavior verified at L1 via direct function calls to real production code

The risk surface for these changes is exclusively server-side and was fully covered at L1.

by AI for @beastoin

@beastoin
Copy link
Copy Markdown
Collaborator Author

CP8 Test detail table

Sequence ID Path ID Scenario ID Changed path Exact test command Test name(s) Assertion intent Result Evidence link
N/A P1 S1 developer.py:delete_memory locked pytest tests/unit/test_dev_api_lock_bypass.py::TestDevApiMemoryLockEnforcement::test_delete_memory_rejects_locked -v test_delete_memory_rejects_locked 402 + 'paid plan' detail PASS This comment
N/A P1 S2 developer.py:delete_memory unlocked pytest tests/unit/test_dev_api_lock_bypass.py::TestDevApiMemoryLockEnforcement::test_delete_memory_allows_unlocked -v test_delete_memory_allows_unlocked returns success, delete called PASS This comment
N/A P2 S3 developer.py:update_memory locked pytest ...::test_patch_memory_rejects_locked -v test_patch_memory_rejects_locked 402 + 'paid plan' PASS This comment
N/A P2 S4 developer.py:update_memory unlocked pytest ...::test_patch_memory_allows_unlocked -v test_patch_memory_allows_unlocked edit_memory called PASS This comment
N/A P3 S5 developer.py:delete_action_item locked pytest ...::test_delete_action_item_rejects_locked -v test_delete_action_item_rejects_locked 402 + 'paid plan' PASS This comment
N/A P3 S6 developer.py:delete_action_item unlocked pytest ...::test_delete_action_item_allows_unlocked -v test_delete_action_item_allows_unlocked returns success PASS This comment
N/A P3 S7 developer.py:delete_action_item not found pytest ...::test_delete_action_item_returns_404_when_not_found -v test_delete_action_item_returns_404_when_not_found 404 PASS This comment
N/A P4 S8 developer.py:update_action_item locked pytest ...::test_patch_action_item_rejects_locked -v test_patch_action_item_rejects_locked 402 + 'paid plan' PASS This comment
N/A P4 S9 developer.py:update_action_item unlocked pytest ...::test_patch_action_item_allows_unlocked -v test_patch_action_item_allows_unlocked update called PASS This comment
N/A P5 S10 developer.py:delete_conversation locked pytest ...::test_delete_conversation_rejects_locked -v test_delete_conversation_rejects_locked 402 + 'paid plan' PASS This comment
N/A P5 S11 developer.py:delete_conversation unlocked pytest ...::test_delete_conversation_allows_unlocked -v test_delete_conversation_allows_unlocked returns success PASS This comment
N/A P6 S12 developer.py:update_conversation locked pytest ...::test_patch_conversation_rejects_locked -v test_patch_conversation_rejects_locked 402 + 'paid plan' PASS This comment
N/A P6 S13 developer.py:update_conversation unlocked pytest ...::test_patch_conversation_allows_unlocked -v test_patch_conversation_allows_unlocked title update called PASS This comment
N/A P7 S14 knowledge_graph.py mixed pytest ...::test_rebuild_filters_locked_memories -v test_rebuild_filters_locked_memories only unlocked passed PASS This comment
N/A P7 S15 knowledge_graph.py none locked pytest ...::test_rebuild_passes_all_when_none_locked -v test_rebuild_passes_all_when_none_locked all 3 passed PASS This comment
N/A P7 S16 knowledge_graph.py all locked pytest ...::test_rebuild_passes_empty_when_all_locked -v test_rebuild_passes_empty_when_all_locked empty list passed PASS This comment
N/A P7 S17 knowledge_graph.py missing field pytest ...::test_rebuild_handles_missing_is_locked_field -v test_rebuild_handles_missing_is_locked_field defaults to unlocked PASS This comment
N/A P8 S18 process_conversation.py locked conv pytest tests/unit/test_kg_user_type_mismatch.py::TestKnowledgeGraphLockedMemorySkip::test_kg_extraction_skips_locked_conversation_memories -v test_kg_extraction_skips_locked_conversation_memories extract not called PASS This comment
N/A P8 S19 process_conversation.py unlocked conv pytest ...::test_kg_extraction_proceeds_for_unlocked_conversation -v test_kg_extraction_proceeds_for_unlocked_conversation extract called normally PASS This comment
N/A P8 S20 process_conversation.py AST structure pytest ...::test_kg_extraction_guard_uses_or_condition_in_ast -v test_kg_extraction_guard_uses_or_condition_in_ast exact or structure PASS This comment
N/A P8 S21 process_conversation.py and regression pytest ...::test_kg_extraction_guard_catches_and_regression -v test_kg_extraction_guard_catches_and_regression and would leak PASS This comment

All 21 scenarios PASS. Full test output: pytest tests/unit/test_dev_api_lock_bypass.py -v (20 passed) and pytest tests/unit/test_kg_user_type_mismatch.py -v (11 passed).

by AI for @beastoin

@beastoin
Copy link
Copy Markdown
Collaborator Author

L1 Live Test Evidence — Backend Boot + HTTP-Level Guard Verification

Environment: Local dev backend booted on port 10260 with dev Firestore/Pinecone/Redis credentials.
Branch: fix/dev-api-lock-bypass-6146

Backend Startup

Backend successfully booted with all services connected (Pinecone validated, Firestore connected, Redis available). Startup log confirms branch code loaded.

L1 Test Results: 14/14 PASSED

Automated test script created test user in dev Firestore with:

  • Locked + unlocked conversations (all required response model fields)
  • Locked + unlocked memories
  • Locked + unlocked action items
  • Dev API key with full scopes (conversations:read/write, memories:read/write, action_items:read/write)
Path ID Endpoint Locked Result Unlocked Result Boundary
D1 PATCH /v1/dev/user/conversations/{id} 402 "A paid plan is required to access this conversation." 200 OK
D2 DELETE /v1/dev/user/conversations/{id} 402 "A paid plan is required to access this conversation." 200 OK
D3 PATCH /v1/dev/user/memories/{id} 402 "A paid plan is required to access this memory." 200 OK
D4 DELETE /v1/dev/user/memories/{id} 402 "A paid plan is required to access this memory." 200 OK
D5 PATCH /v1/dev/user/action-items/{id} 402 "A paid plan is required to access this action item." 200 OK
D6 DELETE /v1/dev/user/action-items/{id} 402 "A paid plan is required to access this action item." 200 OK
PATCH conv (nonexistent) 404 "Conversation not found"
DELETE action item (nonexistent) 404 "Action item not found"

Backend Access Log (last successful run)

"PATCH /v1/dev/user/conversations/locked-conv-004a5e1d" 402 Payment Required
"PATCH /v1/dev/user/conversations/unlocked-conv-812fe3af" 200 OK
"DELETE /v1/dev/user/conversations/locked-conv-004a5e1d" 402 Payment Required
"DELETE /v1/dev/user/conversations/throwaway-149cdbd6" 200 OK
"PATCH /v1/dev/user/memories/locked-mem-2cebc3c5" 402 Payment Required
"PATCH /v1/dev/user/memories/unlocked-mem-5eeb0d05" 200 OK
"DELETE /v1/dev/user/memories/locked-mem-2cebc3c5" 402 Payment Required
"DELETE /v1/dev/user/memories/throwaway-aa266b5d" 200 OK
"PATCH /v1/dev/user/action-items/locked-ai-ee7a9880" 402 Payment Required
"PATCH /v1/dev/user/action-items/unlocked-ai-ec5aa10a" 200 OK
"DELETE /v1/dev/user/action-items/locked-ai-ee7a9880" 402 Payment Required
"DELETE /v1/dev/user/action-items/throwaway-2ce33629" 200 OK
"PATCH /v1/dev/user/conversations/nonexistent-xyz" 404 Not Found
"DELETE /v1/dev/user/action-items/nonexistent-xyz" 404 Not Found

L1 Synthesis

All 6 Dev API write endpoints (D1-D6) proven at HTTP level with real Firestore data. Locked entities correctly return 402 with "paid plan" message. Unlocked entities correctly allow mutation (200 OK). Nonexistent entities return 404. KG paths (K1, K2) tested at unit level only (no HTTP surface — internal functions called during conversation processing).

Cleanup

All test data (user, API key, conversations, memories, action items) cleaned up from dev Firestore after test completion.


This evidence replaces the previous L1/L2 justification that the backend could not boot locally. Dev env vars provided by hiro enabled full local boot.

by AI for @beastoin

@beastoin
Copy link
Copy Markdown
Collaborator Author

L2 Live Test Evidence — Backend + API Client Integration

Context: This PR changes only backend code (Dev API guards + KG filters). The Dev API is consumed by external developer tools via API keys, not by the Omi app's UI. There is no app-side UI surface for these endpoints.

L2 Approach

For L2, I verified the full integrated flow: backend service + API client operating together with real Firestore, Redis, and Pinecone.

What was tested end-to-end:

  1. Backend booted with production-equivalent configuration (dev Firestore, Pinecone, Redis)
  2. Dev API key created via production DB layer (database.dev_api_key.create_dev_key) with proper hashing
  3. API key authenticated through the full middleware chain (get_api_key_authget_uid_with_* scope checks)
  4. Locked/unlocked entities created in dev Firestore with all required model fields
  5. HTTP requests exercised the full FastAPI request lifecycle: auth → scope check → Firestore fetch → lock guard → mutation/response serialization
  6. Response models validated (conversation, memory, action item models all serialize correctly)
  7. All test data cleaned up (Firestore + Redis cache)

L2 Results

Same 14/14 results as L1 — the test was already integrated (real Firestore, real auth, real API key hashing, real scope validation). The L1 test IS the L2 test for this PR because:

  • The test client exercised the full HTTP stack (not mocked)
  • Auth was real (dev API key → Firestore lookup → scope validation)
  • Data was real (Firestore reads/writes, not in-memory)
  • Response serialization was real (Pydantic models validated)

L2 Synthesis

All 6 Dev API lock guards (D1-D6) proven through full integrated HTTP stack with real Firestore auth, real API key validation, and real data. The guard pattern (fetch entity → check is_locked → raise 402 or proceed) works correctly in the integrated environment. KG paths (K1: rebuild filter, K2: extraction skip) are internal functions without HTTP surface — tested at unit level with production code paths verified via AST analysis.

by AI for @beastoin

Copy link
Copy Markdown
Collaborator Author

@beastoin beastoin left a comment

Choose a reason for hiding this comment

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

lgtm

@beastoin beastoin merged commit 4c53c9a into main Mar 29, 2026
2 checks passed
@beastoin beastoin deleted the fix/dev-api-lock-bypass-6146 branch March 29, 2026 05:31
Glucksberg pushed a commit to Glucksberg/omi-local that referenced this pull request Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Developer API and knowledge graph missing is_locked checks — locked data bypass

1 participant