"""Tests for Permissions plugin — permissions, share links, public access, 403 enforcement.""" from __future__ import annotations import uuid from datetime import datetime, timedelta, timezone import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession from app.core.db import reset_engine_for_testing, close_engine from app.core.service_container import get_container from app.main import create_app from app.plugins.registry import reset_registry_for_testing from app.plugins.builtins.permissions import PermissionsPlugin from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER @pytest_asyncio.fixture async def plugin_app(engine: AsyncEngine, redis_client): """FastAPI app with permissions plugin registered.""" reset_engine_for_testing(engine) app = create_app() registry = reset_registry_for_testing() registry.initialize(engine, app) container = get_container() await container.initialize() registry.register_plugin(PermissionsPlugin()) reset_plugin_service_for_testing(registry) yield app await close_engine() @pytest_asyncio.fixture async def plugin_client(plugin_app) -> AsyncClient: transport = ASGITransport(app=plugin_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest_asyncio.fixture async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient: """Authenticated admin client with seeded data.""" seed = await seed_tenant_and_users(db_session) await login_client(plugin_client, "admin@tenanta.com") resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER) assert resp.status_code == 200 resp = await plugin_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER) assert resp.status_code == 200 return plugin_client, seed @pytest.mark.asyncio async def test_list_permissions_empty(authed_client: AsyncClient): """AC1: GET /api/v1/dms/files/{id}/permissions → 200 + permission list.""" client, seed = authed_client file_id = str(uuid.uuid4()) resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER) assert resp.status_code == 200 assert resp.json() == [] @pytest.mark.asyncio async def test_grant_permission(authed_client: AsyncClient): """POST /api/v1/dms/files/{id}/permissions → 201, permission granted.""" client, seed = authed_client file_id = str(uuid.uuid4()) user_id = str(seed["admin_a"].id) resp = await client.post( f"/api/v1/dms/files/{file_id}/permissions", json={"user_id": user_id, "access_level": "read"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert data["user_id"] == user_id assert data["access_level"] == "read" # List permissions should show it resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER) assert resp.status_code == 200 assert len(resp.json()) == 1 @pytest.mark.asyncio async def test_revoke_permission(authed_client: AsyncClient): """DELETE /api/v1/dms/files/{id}/permissions/{user_id} → 204.""" client, seed = authed_client file_id = str(uuid.uuid4()) user_id = str(seed["admin_a"].id) # Grant first resp = await client.post( f"/api/v1/dms/files/{file_id}/permissions", json={"user_id": user_id, "access_level": "write"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 # Revoke resp = await client.delete( f"/api/v1/dms/files/{file_id}/permissions/{user_id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 204 # List should be empty resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER) assert resp.status_code == 200 assert resp.json() == [] @pytest.mark.asyncio async def test_create_share_link(authed_client: AsyncClient): """AC4: POST /api/v1/dms/files/{id}/share-link → 200 + public token URL.""" client, seed = authed_client file_id = str(uuid.uuid4()) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"access_level": "download"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert "token" in data assert "public_url" in data assert data["has_password"] is False assert data["file_id"] == file_id @pytest.mark.asyncio async def test_share_link_with_password(authed_client: AsyncClient): """Share link with password — POST verify with correct password succeeds.""" client, seed = authed_client file_id = str(uuid.uuid4()) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"password": "Secret123", "access_level": "download"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["has_password"] is True token = data["token"] # GET without password → 401 resp = await client.get(f"/api/public/share/{token}") assert resp.status_code == 401 # POST with wrong password → 403 resp = await client.post( f"/api/public/share/{token}", json={"password": "WrongPass"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # POST with correct password → 200 resp = await client.post( f"/api/public/share/{token}", json={"password": "Secret123"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["file_id"] == file_id @pytest.mark.asyncio async def test_expired_share_link(authed_client: AsyncClient): """AC5: GET /api/public/share/{token} with expired link → 410.""" client, seed = authed_client file_id = str(uuid.uuid4()) # Create link with expiry in the past past = datetime.now(timezone.utc) - timedelta(hours=1) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"expires_at": past.isoformat(), "access_level": "download"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 token = resp.json()["token"] # GET → 410 Gone resp = await client.get(f"/api/public/share/{token}") assert resp.status_code == 410 @pytest.mark.asyncio async def test_public_share_no_password(authed_client: AsyncClient): """Public share link without password — GET returns file info.""" client, seed = authed_client file_id = str(uuid.uuid4()) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"access_level": "preview"}, headers=ORIGIN_HEADER, ) token = resp.json()["token"] # Public GET — no auth, no Origin header needed resp = await client.get(f"/api/public/share/{token}") assert resp.status_code == 200 data = resp.json() assert data["file_id"] == file_id assert data["access_level"] == "preview" @pytest.mark.asyncio async def test_revoke_share_link(authed_client: AsyncClient): """DELETE /api/v1/dms/share-links/{id} → 204, link revoked.""" client, seed = authed_client file_id = str(uuid.uuid4()) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"access_level": "download"}, headers=ORIGIN_HEADER, ) link_id = resp.json()["id"] resp = await client.delete(f"/api/v1/dms/share-links/{link_id}", headers=ORIGIN_HEADER) assert resp.status_code == 204 @pytest.mark.asyncio async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient, db_session: AsyncSession): """AC14: Folder permissions enforced: user without read → 403. The permissions plugin does not intercept file access (no DMS file system yet). We test the permission check helper directly: a user with no permission record gets denied (False), which translates to 403 at the route layer. """ from app.plugins.builtins.permissions.routes import check_user_file_permission from app.core.db import get_session_factory seed = await seed_tenant_and_users(db_session) await login_client(plugin_client, "admin@tenanta.com") # Install + activate permissions plugin registry = reset_registry_for_testing() # We need to set up the registry properly # Since plugin_client fixture wasn't used, manually install resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER) assert resp.status_code == 200 resp = await plugin_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER) assert resp.status_code == 200 file_id = uuid.uuid4() tenant_id = seed["tenant_a"].id viewer_id = seed["viewer_a"].id admin_id = seed["admin_a"].id # No permission record for viewer → check returns False factory = get_session_factory() async with factory() as session: has_perm = await check_user_file_permission(session, tenant_id, file_id, viewer_id, "read") assert has_perm is False # Grant read to admin from app.plugins.builtins.permissions.models import Permission perm = Permission( tenant_id=tenant_id, file_id=file_id, user_id=admin_id, access_level="read", ) session.add(perm) await session.flush() has_perm = await check_user_file_permission(session, tenant_id, file_id, admin_id, "read") assert has_perm is True # Viewer still has no permission has_perm = await check_user_file_permission(session, tenant_id, file_id, viewer_id, "read") assert has_perm is False await session.rollback() @pytest.mark.asyncio async def test_list_permissions_invalid_file_id(authed_client: AsyncClient): """GET /api/v1/dms/files/{invalid}/permissions → 400.""" client, seed = authed_client resp = await client.get("/api/v1/dms/files/bad-uuid/permissions", headers=ORIGIN_HEADER) assert resp.status_code == 400 @pytest.mark.asyncio async def test_grant_permission_duplicate(authed_client: AsyncClient): """POST /api/v1/dms/files/{id}/permissions twice → 409.""" client, seed = authed_client file_id = str(uuid.uuid4()) user_id = str(seed["admin_a"].id) resp = await client.post( f"/api/v1/dms/files/{file_id}/permissions", json={"user_id": user_id, "access_level": "read"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 resp = await client.post( f"/api/v1/dms/files/{file_id}/permissions", json={"user_id": user_id, "access_level": "read"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 409 @pytest.mark.asyncio async def test_grant_permission_invalid_ids(authed_client: AsyncClient): """POST /api/v1/dms/files/{invalid}/permissions → 400.""" client, seed = authed_client resp = await client.post( "/api/v1/dms/files/bad-uuid/permissions", json={"user_id": str(uuid.uuid4()), "access_level": "read"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 400 @pytest.mark.asyncio async def test_grant_permission_with_group(authed_client: AsyncClient): """POST /api/v1/dms/files/{id}/permissions with group_id → 201.""" client, seed = authed_client file_id = str(uuid.uuid4()) user_id = str(seed["admin_a"].id) group_id = str(uuid.uuid4()) resp = await client.post( f"/api/v1/dms/files/{file_id}/permissions", json={"user_id": user_id, "group_id": group_id, "access_level": "read"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["group_id"] == group_id @pytest.mark.asyncio async def test_revoke_permission_not_found(authed_client: AsyncClient): """DELETE /api/v1/dms/files/{id}/permissions/{user_id} with no perms → 404.""" client, seed = authed_client file_id = str(uuid.uuid4()) user_id = str(seed["admin_a"].id) resp = await client.delete( f"/api/v1/dms/files/{file_id}/permissions/{user_id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_revoke_permission_invalid_ids(authed_client: AsyncClient): """DELETE /api/v1/dms/files/{invalid}/permissions/{user_id} → 400.""" client, seed = authed_client user_id = str(seed["admin_a"].id) resp = await client.delete( f"/api/v1/dms/files/bad-uuid/permissions/{user_id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 400 @pytest.mark.asyncio async def test_create_share_link_invalid_file_id(authed_client: AsyncClient): """POST /api/v1/dms/files/{invalid}/share-link → 400.""" client, seed = authed_client resp = await client.post( "/api/v1/dms/files/bad-uuid/share-link", json={"access_level": "download"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 400 @pytest.mark.asyncio async def test_revoke_share_link_not_found(authed_client: AsyncClient): """DELETE /api/v1/dms/share-links/{nonexistent} → 404.""" client, seed = authed_client resp = await client.delete(f"/api/v1/dms/share-links/{uuid.uuid4()}", headers=ORIGIN_HEADER) assert resp.status_code == 404 @pytest.mark.asyncio async def test_revoke_share_link_invalid_id(authed_client: AsyncClient): """DELETE /api/v1/dms/share-links/{invalid} → 400.""" client, seed = authed_client resp = await client.delete("/api/v1/dms/share-links/bad-uuid", headers=ORIGIN_HEADER) assert resp.status_code == 400 @pytest.mark.asyncio async def test_public_access_not_found(authed_client: AsyncClient): """GET /api/public/share/{nonexistent} → 404.""" client, seed = authed_client resp = await client.get("/api/public/share/nonexistent-token-xyz") assert resp.status_code == 404 @pytest.mark.asyncio async def test_public_access_post_not_found(authed_client: AsyncClient): """POST /api/public/share/{nonexistent} → 404.""" client, seed = authed_client resp = await client.post( "/api/public/share/nonexistent-token-xyz", json={"password": "test"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_public_access_post_expired(authed_client: AsyncClient): """POST /api/public/share/{token} with expired link → 410.""" client, seed = authed_client file_id = str(uuid.uuid4()) past = datetime.now(timezone.utc) - timedelta(hours=1) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"expires_at": past.isoformat(), "access_level": "download"}, headers=ORIGIN_HEADER, ) token = resp.json()["token"] resp = await client.post( f"/api/public/share/{token}", json={"password": "test"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 410 @pytest.mark.asyncio async def test_public_access_post_no_password_required(authed_client: AsyncClient): """POST /api/public/share/{token} for link without password → 200.""" client, seed = authed_client file_id = str(uuid.uuid4()) resp = await client.post( f"/api/v1/dms/files/{file_id}/share-link", json={"access_level": "preview"}, headers=ORIGIN_HEADER, ) token = resp.json()["token"] resp = await client.post( f"/api/public/share/{token}", json={}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["has_password"] is False