6 Commits

Author SHA1 Message Date
929a3c8c3c fix(library): expose Delete control for Daedalus-managed libraries in UI
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m37s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m9s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m56s
The backend already allows admin delete of a workspace-scoped Library via
the shared delete_library_cascade (commit 142e967), and the confirm-delete
page carries the Daedalus caution. But the detail template was never updated
to match: the Delete button stayed hardcoded `disabled` for any library with
a workspace_id, and the banner said "Do not delete it manually." So the
working route was unreachable from the UI — admins still could not clear an
orphaned Library blocking workspace re-registration.

Make Delete an unconditional enabled link to library:library-delete (the
confirm page already warns), and rewrite the workspace banner to describe the
escape hatch (deleting here removes embedded content; Daedalus recreates and
re-embeds on the next sync) instead of forbidding it.

Template-only change; no view/model/migration changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:03:47 -04:00
2af72d6e82 ci: build only on push to main, not on pull_request
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m30s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m33s
Drop the pull_request:[main] trigger so the CVE scan + Docker build runs
only when changes land on main, not when a PR is opened against it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 06:14:52 -04:00
70b1fc510b Merge pull request 'fix(tests): repair stale mock.patch targets after service refactors' (#2) from fix/stale-test-patch-targets into main
Some checks failed
CVE Scan & Docker Build / security-scan (push) Has been cancelled
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Successful in 1m11s
Reviewed-on: #2
2026-06-18 02:01:25 +00:00
46ca2a934d Merge pull request 'feat/workspace-name-conflict-409' (#1) from feat/workspace-name-conflict-409 into main
Some checks failed
CVE Scan & Docker Build / security-scan (push) Has been cancelled
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Has been cancelled
Reviewed-on: #1
2026-06-18 02:00:55 +00:00
dd06f923cd feat(workspaces): return 409 name_conflict instead of 500 on Library name clash
Some checks failed
CVE Scan & Docker Build / security-scan (pull_request) Successful in 3m49s
CVE Scan & Docker Build / build-and-push (pull_request) Has been cancelled
A recreate of a workspace whose Mnemosyne Library was orphaned (left behind
by a failed Daedalus delete-propagate) collides on the global Library.name
unique constraint. neomodel raised UniqueProperty unguarded, so workspace_create
500'd and ingest then 404'd forever — the queue froze silently.

Guard lib.save() and return a structured 409 with a machine code so Daedalus
can classify the failure without string-matching:
- name_conflict   — the new name-collision case
- owner_conflict, library_type_immutable — codes added to the two existing 409s

Cypher-touching paths stay covered by the manual end-to-end plan, per the
test module's stated convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:26:43 -04:00
539d9b6c34 fix(tests): repair stale mock.patch targets after service refactors
All checks were successful
CVE Scan & Docker Build / security-scan (pull_request) Successful in 5m24s
CVE Scan & Docker Build / build-and-push (pull_request) Successful in 2m58s
Several library tests patched symbols at import paths that no longer
expose them, so they errored (AttributeError) instead of testing anything
— giving false confidence. The underlying code is correct; only the test
patch targets were stale after earlier refactors moved imports
function-local.

- test_pipeline: patch source modules (library.models.Item,
  llm_manager.models.LLMModel, library.services.parsers.DocumentParser,
  .chunker.ContentTypeChunker, .embedding_client.EmbeddingClient,
  .vision.VisionAnalyzer, .concepts.ConceptExtractor) since pipeline.py
  imports them inside methods. default_storage stays (still module-level).
- test_search_api: patch library.services.search.SearchService (the view
  imports it function-local).
- test_tasks: patch library.services.pipeline.EmbeddingPipeline (tasks.py
  imports it function-local).
- test_search_views_admin_scope: patch library.utils.neo4j_available; the
  guard moved to utils when views._all_library_uids became a thin alias.
- test_concepts: remove SampleIndexSelectionTests — _select_sample_indices
  was deleted in the document-level concept-extraction refactor (dead test).

Not addressed here: SearchAPIAuthTest / SearchAPIValidationTest return 302
instead of 401/400. Static analysis ruled out routing, middleware, and DRF
config; reproducing needs a running server (DB-backed). Flagged for sandbox
diagnosis — not a stale-patch issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:12:46 -04:00
8 changed files with 67 additions and 73 deletions

View File

@@ -3,8 +3,6 @@ name: CVE Scan & Docker Build
on: on:
push: push:
branches: [main] branches: [main]
pull_request:
branches: [main]
env: env:
REGISTRY: git.helu.ca REGISTRY: git.helu.ca

View File

@@ -17,6 +17,7 @@ across users.
import logging import logging
from neomodel import db from neomodel import db
from neomodel.exceptions import UniqueProperty
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -85,7 +86,10 @@ def workspace_create(request):
data["workspace_id"], request.user.username, data["workspace_id"], request.user.username,
) )
return Response( return Response(
{"detail": "Workspace id is already in use."}, {
"detail": "Workspace id is already in use.",
"code": "owner_conflict",
},
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
if existing.library_type != data["library_type"]: if existing.library_type != data["library_type"]:
@@ -95,7 +99,8 @@ def workspace_create(request):
"library_type is immutable for an existing workspace " "library_type is immutable for an existing workspace "
f"(have '{existing.library_type}', " f"(have '{existing.library_type}', "
f"got '{data['library_type']}')." f"got '{data['library_type']}')."
) ),
"code": "library_type_immutable",
}, },
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
@@ -120,7 +125,29 @@ def workspace_create(request):
reranker_instruction=defaults["reranker_instruction"], reranker_instruction=defaults["reranker_instruction"],
llm_context_prompt=defaults["llm_context_prompt"], llm_context_prompt=defaults["llm_context_prompt"],
) )
try:
lib.save() lib.save()
except UniqueProperty:
# Library.name is globally unique. A name collision here almost always
# means an orphaned Library survived a failed Daedalus workspace delete
# (the old node kept the name), and the recreate under a new
# workspace_id now clashes. Surface a clean 409 instead of a 500 so
# Daedalus can record + report it; the operator clears the orphan
# (admin delete) or renames the workspace.
logger.warning(
"workspace_create name_conflict workspace_id=%s name=%s",
data["workspace_id"], data["name"],
)
return Response(
{
"detail": (
f"A library named '{data['name']}' already exists in "
"Mnemosyne."
),
"code": "name_conflict",
},
status=status.HTTP_409_CONFLICT,
)
logger.info( logger.info(
"Workspace created workspace_id=%s library_uid=%s library_type=%s", "Workspace created workspace_id=%s library_uid=%s library_type=%s",
data["workspace_id"], lib.uid, lib.library_type, data["workspace_id"], lib.uid, lib.library_type,

View File

@@ -25,14 +25,7 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{% url 'library:library-edit' uid=library.uid %}" class="btn btn-sm btn-outline">Edit</a> <a href="{% url 'library:library-edit' uid=library.uid %}" class="btn btn-sm btn-outline">Edit</a>
{% if library.workspace_id %}
<button type="button" class="btn btn-sm btn-error btn-outline" disabled
title="This library is managed by Daedalus. Delete it from the Daedalus workspace, not here.">
Delete
</button>
{% else %}
<a href="{% url 'library:library-delete' uid=library.uid %}" class="btn btn-sm btn-error btn-outline">Delete</a> <a href="{% url 'library:library-delete' uid=library.uid %}" class="btn btn-sm btn-error btn-outline">Delete</a>
{% endif %}
</div> </div>
</div> </div>
@@ -43,8 +36,11 @@
<div class="text-sm opacity-80"> <div class="text-sm opacity-80">
This library was created for Daedalus workspace This library was created for Daedalus workspace
<code class="font-mono">{{ library.workspace_id }}</code>. <code class="font-mono">{{ library.workspace_id }}</code>.
Items here are owned by the workspace; deleting the workspace in Normally you manage it from Daedalus. Deleting it here removes its
Daedalus will remove this library. Do not delete it manually. embedded content from Mnemosyne, but the source files still live in
Daedalus — it will be recreated and re-embedded on the next sync.
Use Delete to clear an orphaned library that is blocking workspace
re-registration.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -48,30 +48,3 @@ class ConceptExtractionParsingTests(TestCase):
result = self.extractor._parse_concept_response(response) result = self.extractor._parse_concept_response(response)
self.assertEqual(len(result), 1) self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "valid") self.assertEqual(result[0]["name"], "valid")
class SampleIndexSelectionTests(TestCase):
"""Tests for sample index selection."""
def setUp(self):
self.extractor = ConceptExtractor(MagicMock())
def test_small_total_returns_all(self):
indices = self.extractor._select_sample_indices(5, max_samples=10)
self.assertEqual(indices, [0, 1, 2, 3, 4])
def test_equal_total_returns_all(self):
indices = self.extractor._select_sample_indices(10, max_samples=10)
self.assertEqual(indices, list(range(10)))
def test_large_total_returns_max_samples(self):
indices = self.extractor._select_sample_indices(100, max_samples=10)
self.assertEqual(len(indices), 10)
# Should be evenly spaced
self.assertEqual(indices[0], 0)
self.assertEqual(indices[-1], 90)
def test_returns_integers(self):
indices = self.extractor._select_sample_indices(50, max_samples=7)
for idx in indices:
self.assertIsInstance(idx, int)

View File

@@ -48,7 +48,7 @@ class EmbeddingPipelineInitTests(TestCase):
class PipelineItemNotFoundTests(TestCase): class PipelineItemNotFoundTests(TestCase):
"""Tests for handling missing items.""" """Tests for handling missing items."""
@patch("library.services.pipeline.Item") @patch("library.models.Item")
def test_process_nonexistent_item_raises(self, mock_item_cls): def test_process_nonexistent_item_raises(self, mock_item_cls):
mock_item_cls.nodes.get.side_effect = Exception("Not found") mock_item_cls.nodes.get.side_effect = Exception("Not found")
@@ -57,7 +57,7 @@ class PipelineItemNotFoundTests(TestCase):
pipeline.process_item("nonexistent-uid") pipeline.process_item("nonexistent-uid")
self.assertIn("Item not found", str(ctx.exception)) self.assertIn("Item not found", str(ctx.exception))
@patch("library.services.pipeline.Item") @patch("library.models.Item")
def test_reprocess_nonexistent_item_raises(self, mock_item_cls): def test_reprocess_nonexistent_item_raises(self, mock_item_cls):
mock_item_cls.nodes.get.side_effect = Exception("Not found") mock_item_cls.nodes.get.side_effect = Exception("Not found")
@@ -69,9 +69,9 @@ class PipelineItemNotFoundTests(TestCase):
class PipelineNoEmbeddingModelTests(TestCase): class PipelineNoEmbeddingModelTests(TestCase):
"""Tests for handling missing system embedding model.""" """Tests for handling missing system embedding model."""
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage") @patch("library.services.pipeline.default_storage")
@patch("library.services.pipeline.DocumentParser") @patch("library.services.parsers.DocumentParser")
def test_no_embedding_model_raises(self, mock_parser, mock_storage, mock_llm): def test_no_embedding_model_raises(self, mock_parser, mock_storage, mock_llm):
"""Pipeline raises ValueError if no system embedding model is configured.""" """Pipeline raises ValueError if no system embedding model is configured."""
mock_llm.get_system_embedding_model.return_value = None mock_llm.get_system_embedding_model.return_value = None
@@ -86,7 +86,7 @@ class PipelineNoEmbeddingModelTests(TestCase):
mock_item.chunks.all.return_value = [] mock_item.chunks.all.return_value = []
mock_item.images.all.return_value = [] mock_item.images.all.return_value = []
with patch("library.services.pipeline.Item") as mock_item_cls: with patch("library.models.Item") as mock_item_cls:
mock_item_cls.nodes.get.return_value = mock_item mock_item_cls.nodes.get.return_value = mock_item
# Mock S3 read # Mock S3 read
@@ -166,11 +166,11 @@ class PipelineVisionStageTests(TestCase):
item.images.all.return_value = [] item.images.all.return_value = []
return item return item
@patch("library.services.pipeline.ConceptExtractor") @patch("library.services.concepts.ConceptExtractor")
@patch("library.services.pipeline.EmbeddingClient") @patch("library.services.embedding_client.EmbeddingClient")
@patch("library.services.pipeline.ContentTypeChunker") @patch("library.services.chunker.ContentTypeChunker")
@patch("library.services.pipeline.DocumentParser") @patch("library.services.parsers.DocumentParser")
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage") @patch("library.services.pipeline.default_storage")
def test_no_vision_model_marks_images_skipped( def test_no_vision_model_marks_images_skipped(
self, mock_storage, mock_llm, mock_parser_cls, self, mock_storage, mock_llm, mock_parser_cls,
@@ -227,12 +227,12 @@ class PipelineVisionStageTests(TestCase):
img_node.save.assert_called() img_node.save.assert_called()
self.assertEqual(result["images_analyzed"], 0) self.assertEqual(result["images_analyzed"], 0)
@patch("library.services.pipeline.VisionAnalyzer") @patch("library.services.vision.VisionAnalyzer")
@patch("library.services.pipeline.ConceptExtractor") @patch("library.services.concepts.ConceptExtractor")
@patch("library.services.pipeline.EmbeddingClient") @patch("library.services.embedding_client.EmbeddingClient")
@patch("library.services.pipeline.ContentTypeChunker") @patch("library.services.chunker.ContentTypeChunker")
@patch("library.services.pipeline.DocumentParser") @patch("library.services.parsers.DocumentParser")
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage") @patch("library.services.pipeline.default_storage")
def test_vision_model_triggers_analysis( def test_vision_model_triggers_analysis(
self, mock_storage, mock_llm, mock_parser_cls, self, mock_storage, mock_llm, mock_parser_cls,
@@ -287,7 +287,7 @@ class PipelineVisionStageTests(TestCase):
mock_vision_cls.assert_called_once_with(mock_vision_model, user=None) mock_vision_cls.assert_called_once_with(mock_vision_model, user=None)
mock_analyzer.analyze_images.assert_called_once() mock_analyzer.analyze_images.assert_called_once()
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
def test_no_images_skips_vision_entirely(self, mock_llm): def test_no_images_skips_vision_entirely(self, mock_llm):
"""When there are no images, vision stage is a no-op regardless of model.""" """When there are no images, vision stage is a no-op regardless of model."""
mock_vision_model = MagicMock() mock_vision_model = MagicMock()
@@ -309,10 +309,10 @@ class PipelineVisionStageTests(TestCase):
patch.object(pipeline, "_store_chunks", return_value=[]), \ patch.object(pipeline, "_store_chunks", return_value=[]), \
patch.object(pipeline, "_store_images", return_value=[]), \ patch.object(pipeline, "_store_images", return_value=[]), \
patch.object(pipeline, "_associate_images_with_chunks"), \ patch.object(pipeline, "_associate_images_with_chunks"), \
patch("library.services.pipeline.DocumentParser") as mock_parser_cls, \ patch("library.services.parsers.DocumentParser") as mock_parser_cls, \
patch("library.services.pipeline.ContentTypeChunker") as mock_chunker_cls, \ patch("library.services.chunker.ContentTypeChunker") as mock_chunker_cls, \
patch("library.services.pipeline.EmbeddingClient"), \ patch("library.services.embedding_client.EmbeddingClient"), \
patch("library.services.pipeline.VisionAnalyzer") as mock_vision_cls: patch("library.services.vision.VisionAnalyzer") as mock_vision_cls:
mock_parser = MagicMock() mock_parser = MagicMock()
mock_parser.parse_bytes.return_value = MagicMock(images=[], text_blocks=[]) mock_parser.parse_bytes.return_value = MagicMock(images=[], text_blocks=[])

View File

@@ -100,7 +100,7 @@ class SearchAPIResponseTest(TestCase):
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_successful_search_response_format(self, MockService): def test_successful_search_response_format(self, MockService):
"""Successful search returns expected JSON structure.""" """Successful search returns expected JSON structure."""
mock_response = SearchResponse( mock_response = SearchResponse(
@@ -159,7 +159,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(image["image_uid"], "img1") self.assertEqual(image["image_uid"], "img1")
self.assertEqual(image["image_type"], "diagram") self.assertEqual(image["image_type"], "diagram")
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_vector_only_endpoint(self, MockService): def test_vector_only_endpoint(self, MockService):
"""Vector-only endpoint sets correct search types.""" """Vector-only endpoint sets correct search types."""
mock_response = SearchResponse( mock_response = SearchResponse(
@@ -184,7 +184,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(call_args.search_types, ["vector"]) self.assertEqual(call_args.search_types, ["vector"])
self.assertFalse(call_args.rerank) self.assertFalse(call_args.rerank)
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_fulltext_only_endpoint(self, MockService): def test_fulltext_only_endpoint(self, MockService):
"""Fulltext-only endpoint sets correct search types.""" """Fulltext-only endpoint sets correct search types."""
mock_response = SearchResponse( mock_response = SearchResponse(
@@ -208,7 +208,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(call_args.search_types, ["fulltext"]) self.assertEqual(call_args.search_types, ["fulltext"])
self.assertFalse(call_args.rerank) self.assertFalse(call_args.rerank)
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_reranker_skip_reason_surfaced_in_json(self, MockService): def test_reranker_skip_reason_surfaced_in_json(self, MockService):
"""``reranker_skip_reason`` propagates through the JSON API.""" """``reranker_skip_reason`` propagates through the JSON API."""
mock_response = SearchResponse( mock_response = SearchResponse(

View File

@@ -48,7 +48,7 @@ class AllLibraryUidsHelperTests(TestCase):
def test_returns_empty_when_neo4j_unavailable(self): def test_returns_empty_when_neo4j_unavailable(self):
"""Helper must not touch ``Library.nodes`` if Neo4j is down.""" """Helper must not touch ``Library.nodes`` if Neo4j is down."""
with patch("library.views.neo4j_available", return_value=False): with patch("library.utils.neo4j_available", return_value=False):
self.assertEqual(views._all_library_uids(), []) self.assertEqual(views._all_library_uids(), [])
def test_returns_every_library_uid(self): def test_returns_every_library_uid(self):
@@ -62,7 +62,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.return_value = fake_libs fake_nodes.all.return_value = fake_libs
fake_library_cls = SimpleNamespace(nodes=fake_nodes) fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \ with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}): patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
result = views._all_library_uids() result = views._all_library_uids()
@@ -83,7 +83,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.return_value = fake_libs fake_nodes.all.return_value = fake_libs
fake_library_cls = SimpleNamespace(nodes=fake_nodes) fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \ with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}): patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
result = views._all_library_uids() result = views._all_library_uids()
@@ -95,7 +95,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.side_effect = RuntimeError("neo4j blew up") fake_nodes.all.side_effect = RuntimeError("neo4j blew up")
fake_library_cls = SimpleNamespace(nodes=fake_nodes) fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \ with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}): patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
self.assertEqual(views._all_library_uids(), []) self.assertEqual(views._all_library_uids(), [])

View File

@@ -13,7 +13,7 @@ from django.test import TestCase, override_settings
class EmbedItemTaskTests(TestCase): class EmbedItemTaskTests(TestCase):
"""Tests for the embed_item task.""" """Tests for the embed_item task."""
@patch("library.tasks.EmbeddingPipeline") @patch("library.services.pipeline.EmbeddingPipeline")
def test_embed_item_success(self, mock_pipeline_cls): def test_embed_item_success(self, mock_pipeline_cls):
from library.tasks import embed_item from library.tasks import embed_item
@@ -31,7 +31,7 @@ class EmbedItemTaskTests(TestCase):
self.assertEqual(result["item_uid"], "test-uid-123") self.assertEqual(result["item_uid"], "test-uid-123")
mock_pipeline.process_item.assert_called_once() mock_pipeline.process_item.assert_called_once()
@patch("library.tasks.EmbeddingPipeline") @patch("library.services.pipeline.EmbeddingPipeline")
def test_embed_item_failure(self, mock_pipeline_cls): def test_embed_item_failure(self, mock_pipeline_cls):
from library.tasks import embed_item from library.tasks import embed_item
@@ -49,7 +49,7 @@ class EmbedItemTaskTests(TestCase):
class ReembedItemTaskTests(TestCase): class ReembedItemTaskTests(TestCase):
"""Tests for the reembed_item task.""" """Tests for the reembed_item task."""
@patch("library.tasks.EmbeddingPipeline") @patch("library.services.pipeline.EmbeddingPipeline")
def test_reembed_item_success(self, mock_pipeline_cls): def test_reembed_item_success(self, mock_pipeline_cls):
from library.tasks import reembed_item from library.tasks import reembed_item