Skip to content

Commit 69d4efb

Browse files
committed
fix(workflow-tags): handle duplicate links and harden tests
1 parent b345a51 commit 69d4efb

File tree

4 files changed

+97
-10
lines changed

4 files changed

+97
-10
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""HTTP-level tests for workflow tags API endpoints."""
2+
3+
import uuid
4+
from unittest.mock import AsyncMock, patch
5+
6+
import pytest
7+
from fastapi import status
8+
from fastapi.testclient import TestClient
9+
from sqlalchemy.exc import NoResultFound
10+
11+
from tracecat.auth.types import Role
12+
from tracecat.workflow.tags import router as workflow_tags_router
13+
14+
15+
@pytest.mark.anyio
16+
async def test_add_tag_conflict_on_duplicate_assignment(
17+
client: TestClient,
18+
test_admin_role: Role,
19+
) -> None:
20+
"""POST /workflows/{workflow_id}/tags returns 409 on duplicate assignment."""
21+
workflow_id = uuid.UUID("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa")
22+
tag_id = uuid.UUID("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb")
23+
24+
with patch.object(workflow_tags_router, "WorkflowTagsService") as mock_service_cls:
25+
mock_service = AsyncMock()
26+
mock_service.add_workflow_tag.side_effect = ValueError(
27+
"Tag already assigned to workflow"
28+
)
29+
mock_service_cls.return_value = mock_service
30+
31+
response = client.post(
32+
f"/workflows/{workflow_id}/tags",
33+
params={"workspace_id": str(test_admin_role.workspace_id)},
34+
json={"tag_id": str(tag_id)},
35+
)
36+
37+
assert response.status_code == status.HTTP_409_CONFLICT
38+
assert response.json()["detail"] == "Tag already assigned to workflow"
39+
40+
41+
@pytest.mark.anyio
42+
async def test_add_tag_not_found_when_workflow_or_tag_missing(
43+
client: TestClient,
44+
test_admin_role: Role,
45+
) -> None:
46+
"""POST /workflows/{workflow_id}/tags returns 404 when resources are missing."""
47+
workflow_id = uuid.UUID("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa")
48+
tag_id = uuid.UUID("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb")
49+
50+
with patch.object(workflow_tags_router, "WorkflowTagsService") as mock_service_cls:
51+
mock_service = AsyncMock()
52+
mock_service.add_workflow_tag.side_effect = NoResultFound(
53+
"Workflow or tag not found"
54+
)
55+
mock_service_cls.return_value = mock_service
56+
57+
response = client.post(
58+
f"/workflows/{workflow_id}/tags",
59+
params={"workspace_id": str(test_admin_role.workspace_id)},
60+
json={"tag_id": str(tag_id)},
61+
)
62+
63+
assert response.status_code == status.HTTP_404_NOT_FOUND
64+
assert response.json()["detail"] == "Workflow or tag not found"

tests/unit/test_tags_service.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@ async def other_workspace(session: AsyncSession, svc_workspace: Workspace) -> Wo
4343

4444

4545
@pytest.fixture
46-
async def other_role(other_workspace: Workspace) -> Role:
47-
"""Role scoped to the secondary workspace."""
48-
return Role(
49-
type="user",
50-
workspace_id=other_workspace.id,
51-
organization_id=other_workspace.organization_id,
52-
user_id=uuid4(),
53-
service_id="tracecat-api",
46+
async def other_role(svc_role: Role, other_workspace: Workspace) -> Role:
47+
"""Clone base service role for the secondary workspace."""
48+
return svc_role.model_copy(
49+
update={
50+
"workspace_id": other_workspace.id,
51+
"organization_id": other_workspace.organization_id,
52+
"user_id": uuid4(),
53+
}
5454
)
5555

5656

@@ -372,6 +372,20 @@ async def test_remove_workflow_tag(
372372
workflow_tags = await workflow_tags_service.list_tags_for_workflow(workflow_id)
373373
assert len(workflow_tags) == 0
374374

375+
async def test_add_workflow_tag_duplicate_raises_value_error(
376+
self,
377+
workflow_tags_service: WorkflowTagsService,
378+
tags_service: TagsService,
379+
tag_create_params: TagCreate,
380+
workflow_id: WorkflowID,
381+
) -> None:
382+
"""Adding the same tag twice to a workflow should raise a conflict error."""
383+
tag = await tags_service.create_tag(tag_create_params)
384+
await workflow_tags_service.add_workflow_tag(workflow_id, tag.id)
385+
386+
with pytest.raises(ValueError, match="already assigned"):
387+
await workflow_tags_service.add_workflow_tag(workflow_id, tag.id)
388+
375389
async def test_list_tags_for_workflow_enforces_workspace_scope(
376390
self,
377391
workflow_tags_service: WorkflowTagsService,

tracecat/workflow/tags/router.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ async def add_tag(
3939
service = WorkflowTagsService(session, role=role)
4040
try:
4141
await service.add_workflow_tag(workflow_id, params.tag_id)
42+
except ValueError as e:
43+
raise HTTPException(
44+
status_code=status.HTTP_409_CONFLICT,
45+
detail=str(e),
46+
) from e
4247
except NoResultFound as e:
4348
raise HTTPException(
4449
status_code=status.HTTP_404_NOT_FOUND,

tracecat/workflow/tags/service.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from collections.abc import Sequence
22

33
from sqlalchemy import exists, select
4-
from sqlalchemy.exc import NoResultFound
4+
from sqlalchemy.exc import IntegrityError, NoResultFound
55

66
from tracecat.authz.controls import require_scope
77
from tracecat.db.models import Workflow, WorkflowTag, WorkflowTagLink
@@ -72,7 +72,11 @@ async def add_workflow_tag(
7272
await self._require_workflow_and_tag_in_workspace(wf_id, tag_id)
7373
wf_tag = WorkflowTagLink(workflow_id=wf_id, tag_id=tag_id)
7474
self.session.add(wf_tag)
75-
await self.session.commit()
75+
try:
76+
await self.session.commit()
77+
except IntegrityError as e:
78+
await self.session.rollback()
79+
raise ValueError("Tag already assigned to workflow") from e
7680
return wf_tag
7781

7882
@require_scope("workflow:update")

0 commit comments

Comments
 (0)