feat(12-06): connection-ID native lifecycle — always INSERT, PUT credentials, connectionsFor multi-account
- Replace _upsert_cloud_connection with _insert_cloud_connection (always inserts new UUID row)
- Add PUT /connections/{id}/credentials endpoint (owner-scoped, SSRF + health-check, password-preserve)
- SettingsCloudTab: connectionsFor() renders all same-provider connections with Add account row
- CloudCredentialModal.submit: calls updateWebDavCredentials on edit, connectWebDav on create
- utils.js: FastAPI validation arrays normalised to concise field messages (Rule 2)
- Backend tests: same-provider independence + IDOR negative test for credential update
This commit is contained in:
@@ -328,6 +328,64 @@ async def test_nextcloud_connect_persists(async_client, db_session):
|
||||
assert "credentials_enc" not in data
|
||||
|
||||
|
||||
async def test_same_provider_connections_are_independent(async_client, db_session):
|
||||
"""Two POSTs for nextcloud create two distinct UUIDs with independent credentials.
|
||||
|
||||
Regression for Phase 12 UAT gap: _upsert_cloud_connection previously merged
|
||||
same-provider connections into one row, preventing multi-account setups.
|
||||
"""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
auth = await _create_user_and_token(db_session, role="user")
|
||||
|
||||
with patch("api.cloud.connections.validate_cloud_url", return_value=None), \
|
||||
patch("storage.webdav_backend.validate_cloud_url", return_value=None), \
|
||||
patch("storage.nextcloud_backend.validate_cloud_url", return_value=None), \
|
||||
patch("asyncio.to_thread", new_callable=AsyncMock, return_value=True):
|
||||
|
||||
resp1 = await async_client.post(
|
||||
"/api/cloud/connections/webdav",
|
||||
json={"server_url": "https://nc1.example.com/remote.php/dav", "username": "alice", "password": "pass1", "provider": "nextcloud"},
|
||||
headers=auth["headers"],
|
||||
)
|
||||
resp2 = await async_client.post(
|
||||
"/api/cloud/connections/webdav",
|
||||
json={"server_url": "https://nc2.example.com/remote.php/dav", "username": "bob", "password": "pass2", "provider": "nextcloud"},
|
||||
headers=auth["headers"],
|
||||
)
|
||||
|
||||
assert resp1.status_code == 201, resp1.text
|
||||
assert resp2.status_code == 201, resp2.text
|
||||
id1 = resp1.json()["id"]
|
||||
id2 = resp2.json()["id"]
|
||||
assert id1 != id2, "Two same-provider connections must produce distinct UUIDs"
|
||||
|
||||
# Both are returned in the list
|
||||
list_resp = await async_client.get("/api/cloud/connections", headers=auth["headers"])
|
||||
assert list_resp.status_code == 200
|
||||
ids = [c["id"] for c in list_resp.json()["items"]]
|
||||
assert id1 in ids and id2 in ids
|
||||
|
||||
|
||||
async def test_credential_update_wrong_owner_returns_404(async_client, db_session, cloud_connection_factory):
|
||||
"""PUT /connections/{id}/credentials with another user's connection returns 404.
|
||||
|
||||
T-12-06-01: IDOR protection — owner check via _get_owned_connection.
|
||||
"""
|
||||
owner = await _create_user_and_token(db_session, role="user")
|
||||
attacker = await _create_user_and_token(db_session, role="user")
|
||||
|
||||
conn = await cloud_connection_factory(db_session, owner["user"].id, provider="nextcloud")
|
||||
|
||||
resp = await async_client.put(
|
||||
f"/api/cloud/connections/{conn.id}/credentials",
|
||||
json={"server_url": "https://evil.example.com/dav", "username": "hacker", "password": "x"},
|
||||
headers=attacker["headers"],
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "credentials_enc" not in resp.text
|
||||
|
||||
|
||||
async def test_webdav_connect_validates(async_client, db_session, monkeypatch):
|
||||
"""POST /api/cloud/connections/webdav with localhost URL returns 422 (SSRF blocked)."""
|
||||
auth = await _create_user_and_token(db_session, role="user")
|
||||
|
||||
Reference in New Issue
Block a user