Skip to content

Commit 2721a13

Browse files
authored
fix: copy dc links when copying projects (#784)
1 parent 1f2cfb8 commit 2721a13

5 files changed

Lines changed: 101 additions & 20 deletions

File tree

components/renku_data_services/data_connectors/db.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ async def insert_link(
605605
async def copy_link(
606606
self,
607607
user: base_models.APIUser,
608-
project_id: ULID,
608+
target_project_id: ULID,
609609
link: models.DataConnectorToProjectLink,
610610
*,
611611
session: AsyncSession | None = None,
@@ -615,7 +615,7 @@ async def copy_link(
615615
user, ResourceType.data_connector, link.data_connector_id, Scope.READ
616616
)
617617
allowed_to_write_project = await self.authz.has_permission(
618-
user, ResourceType.project, link.project_id, Scope.WRITE
618+
user, ResourceType.project, target_project_id, Scope.WRITE
619619
)
620620
if not allowed_to_read_dc:
621621
raise errors.MissingResourceError(
@@ -627,7 +627,7 @@ async def copy_link(
627627
message=f"The project with ID {link.project_id} does not exist or you do not have access to it"
628628
)
629629
unsaved_link = models.UnsavedDataConnectorToProjectLink(
630-
data_connector_id=link.data_connector_id, project_id=project_id
630+
data_connector_id=link.data_connector_id, project_id=target_project_id
631631
)
632632
return await self.insert_link(user=user, link=unsaved_link, session=session)
633633

components/renku_data_services/project/blueprints.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ async def _copy(
146146
_: Request, user: base_models.APIUser, project_id: ULID, body: apispec.ProjectPost
147147
) -> JSONResponse:
148148
project = await copy_project(
149-
project_id=project_id,
149+
source_project_id=project_id,
150150
user=user,
151151
name=body.name,
152152
namespace=body.namespace,

components/renku_data_services/project/core.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def validate_project_patch(patch: apispec.ProjectPatch) -> models.ProjectPatch:
6262

6363

6464
async def copy_project(
65-
project_id: ULID,
65+
source_project_id: ULID,
6666
user: APIUser,
6767
name: str,
6868
namespace: str,
@@ -77,7 +77,7 @@ async def copy_project(
7777
data_connector_repo: DataConnectorRepository,
7878
) -> models.Project:
7979
"""Create a copy of a given project."""
80-
template = await project_repo.get_project(user=user, project_id=project_id, with_documentation=True)
80+
template = await project_repo.get_project(user=user, project_id=source_project_id, with_documentation=True)
8181
repositories_ = _validate_repositories(repositories)
8282

8383
unsaved_project = models.UnsavedProject(
@@ -96,17 +96,17 @@ async def copy_project(
9696
project = await project_repo.insert_project(user, unsaved_project)
9797

9898
# NOTE: Copy session launchers
99-
launchers = await session_repo.get_project_launchers(user=user, project_id=project_id)
99+
launchers = await session_repo.get_project_launchers(user=user, project_id=source_project_id)
100100
for launcher in launchers:
101101
await session_repo.copy_launcher(user=user, project_id=project.id, launcher=launcher)
102102

103103
# NOTE: Copy data connector links. If this operation fails due to lack of permission, still proceed to create the
104104
# copy but return an error code that reflects this
105105
uncopied_dc_ids: list[ULID] = []
106-
dc_links = await data_connector_repo.get_links_to(user=user, project_id=project_id)
106+
dc_links = await data_connector_repo.get_links_to(user=user, project_id=source_project_id)
107107
for dc_link in dc_links:
108108
try:
109-
await data_connector_repo.copy_link(user=user, project_id=project.id, link=dc_link)
109+
await data_connector_repo.copy_link(user=user, target_project_id=project.id, link=dc_link)
110110
except errors.MissingResourceError:
111111
uncopied_dc_ids.append(dc_link.data_connector_id)
112112

test/bases/renku_data_services/data_api/conftest.py

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import json
2-
from collections.abc import AsyncGenerator
2+
from collections.abc import AsyncGenerator, Callable
33
from copy import deepcopy
44
from typing import Any
55

6+
import pytest
67
import pytest_asyncio
78
from authzed.api.v1 import Relationship, RelationshipUpdate, SubjectReference, WriteRelationshipsRequest
9+
from httpx import Response
810
from sanic import Sanic
911
from sanic_testing.testing import SanicASGITestClient
1012
from ulid import ULID
@@ -167,6 +169,34 @@ async def unauthorized_headers() -> dict[str, str]:
167169
return {"Authorization": "Bearer {}"}
168170

169171

172+
@pytest.fixture
173+
def headers_from_user(
174+
admin_user: UserInfo,
175+
admin_headers: dict[str, str],
176+
regular_user: UserInfo,
177+
user_headers: dict[str, str],
178+
member_1_user: UserInfo,
179+
member_1_headers: dict[str, str],
180+
member_2_user: UserInfo,
181+
member_2_headers: dict[str, str],
182+
unauthorized_headers: dict[str, str],
183+
) -> Callable[[UserInfo], dict[str, str]]:
184+
def _headers_from_user(user: UserInfo) -> dict[str, str]:
185+
match user.id:
186+
case admin_user.id:
187+
return admin_headers
188+
case regular_user.id:
189+
return user_headers
190+
case member_1_user.id:
191+
return member_1_headers
192+
case member_2_user.id:
193+
return member_2_headers
194+
case _:
195+
return unauthorized_headers
196+
197+
return _headers_from_user
198+
199+
170200
@pytest_asyncio.fixture
171201
async def bootstrap_admins(
172202
sanic_client_with_migrations, app_config_instance: Config, event_loop, admin_user: UserInfo
@@ -258,7 +288,7 @@ async def create_project_helper(
258288

259289

260290
@pytest_asyncio.fixture
261-
async def create_project_copy(sanic_client, user_headers, admin_headers, regular_user, admin_user):
291+
async def create_project_copy(sanic_client, user_headers, headers_from_user):
262292
async def create_project_copy_helper(
263293
id: str,
264294
namespace: str,
@@ -268,7 +298,7 @@ async def create_project_copy_helper(
268298
members: list[dict[str, str]] = None,
269299
**payload,
270300
) -> dict[str, Any]:
271-
headers = user_headers if user is None or user is regular_user else admin_headers
301+
headers = headers_from_user(user) if user is not None else user_headers
272302
copy_payload = {"slug": Slug.from_name(name).value}
273303
copy_payload.update(payload)
274304
copy_payload.update({"namespace": namespace, "name": name})
@@ -391,7 +421,7 @@ async def create_data_connector_helper(
391421

392422
@pytest_asyncio.fixture
393423
async def create_data_connector_and_link_project(
394-
sanic_client, regular_user, user_headers, admin_user, admin_headers, create_data_connector
424+
regular_user, user_headers, admin_user, admin_headers, create_data_connector, link_data_connector
395425
):
396426
async def create_data_connector_and_link_project_helper(
397427
name: str, project_id: str, admin: bool = False, **payload
@@ -401,20 +431,27 @@ async def create_data_connector_and_link_project_helper(
401431

402432
data_connector = await create_data_connector(name, user=user, headers=headers, **payload)
403433
data_connector_id = data_connector["id"]
404-
payload = {"project_id": project_id}
405-
406-
_, response = await sanic_client.post(
407-
f"/api/data/data_connectors/{data_connector_id}/project_links", headers=headers, json=payload
408-
)
409-
410-
assert response.status_code == 201, response.text
434+
response = await link_data_connector(project_id, data_connector_id, headers=headers)
411435
data_connector_link = response.json
412436

413437
return data_connector, data_connector_link
414438

415439
return create_data_connector_and_link_project_helper
416440

417441

442+
@pytest.fixture
443+
def link_data_connector(sanic_client: SanicASGITestClient):
444+
async def _link_data_connector(project_id: str, dc_id: str, headers: dict[str, str]) -> Response:
445+
payload = {"project_id": project_id}
446+
_, response = await sanic_client.post(
447+
f"/api/data/data_connectors/{dc_id}/project_links", headers=headers, json=payload
448+
)
449+
assert response.status_code == 201, response.text
450+
return response
451+
452+
return _link_data_connector
453+
454+
418455
@pytest_asyncio.fixture
419456
async def create_resource_pool(sanic_client, user_headers, admin_headers, valid_resource_pool_payload):
420457
async def create_resource_pool_helper(admin: bool = False, **payload) -> dict[str, Any]:

test/bases/renku_data_services/data_api/test_projects.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99
from httpx import Response
10+
from sanic_testing.testing import SanicASGITestClient
1011
from sqlalchemy import select
1112
from syrupy.filters import props
1213
from ulid import ULID
@@ -1484,6 +1485,49 @@ async def test_project_copy_includes_data_connector_links(
14841485
assert {d["id"] for d in data_connector_links} != {link_1["id"], link_2["id"]}
14851486

14861487

1488+
@pytest.mark.asyncio
1489+
async def test_project_copy_includes_public_data_connector_links_owned_by_others(
1490+
sanic_client: SanicASGITestClient,
1491+
user_headers: dict[str, str],
1492+
regular_user: UserInfo,
1493+
member_1_headers: dict[str, str],
1494+
member_1_user: UserInfo,
1495+
member_2_headers: dict[str, str],
1496+
member_2_user: UserInfo,
1497+
create_project,
1498+
create_project_copy,
1499+
create_data_connector,
1500+
link_data_connector,
1501+
) -> None:
1502+
project = await create_project("Project", visibility="public")
1503+
project_id = project["id"]
1504+
dc1 = await create_data_connector("dc1", member_1_user, member_1_headers, visibility="public")
1505+
dc2 = await create_data_connector("dc2", member_1_user, member_1_headers, visibility="public")
1506+
assert "id" in dc1
1507+
assert "id" in dc2
1508+
link1_res = await link_data_connector(project_id, dc1["id"], user_headers)
1509+
link2_res = await link_data_connector(project_id, dc2["id"], user_headers)
1510+
link1 = link1_res.json
1511+
link2 = link2_res.json
1512+
1513+
copy_project = await create_project_copy(
1514+
project_id,
1515+
member_2_user.namespace.path.serialize(),
1516+
"Copy Project",
1517+
user=member_2_user,
1518+
)
1519+
project_copy_id = copy_project["id"]
1520+
_, response = await sanic_client.get(
1521+
f"/api/data/projects/{project_copy_id}/data_connector_links", headers=member_2_headers
1522+
)
1523+
assert response.status_code == 200, response.text
1524+
data_connector_links = response.json
1525+
assert {d["data_connector_id"] for d in data_connector_links} == {dc1["id"], dc2["id"]}
1526+
assert data_connector_links[0]["project_id"] == data_connector_links[1]["project_id"] == project_copy_id
1527+
# NOTE: Check that new data connector links are created
1528+
assert {d["id"] for d in data_connector_links} != {link1["id"], link2["id"]}
1529+
1530+
14871531
@pytest.mark.asyncio
14881532
async def test_project_get_all_copies(
14891533
sanic_client, admin_user, regular_user, admin_headers, user_headers, create_project, create_project_copy

0 commit comments

Comments
 (0)