Roles audit and revamp

This commit is contained in:
Pavel committed 2026-09-29 10:42:39 +04:00
1 parent 208d34abc7
commit 98afa5d93c
134 files changed
+13725 -1702

No files matched your search

+25 -24
View File
@@ -30,15 +30,12 @@ def _seed_source(pg_conn, user="u", name="src"):
class TestResolveSource:
def test_returns_none_for_missing(self, pg_conn):
def test_missing_raises_404(self, pg_conn):
from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.chunks import _resolve_source
with _patch_db(pg_conn):
assert (
_resolve_source(
"00000000-0000-0000-0000-000000000000", "u"
)
is None
)
with _patch_db(pg_conn), pytest.raises(AccessDenied) as exc:
_resolve_source("00000000-0000-0000-0000-000000000000", "u")
assert exc.value.status == 404
def test_returns_source_when_found(self, pg_conn):
from docsgpt.api.user.sources.chunks import _resolve_source
@@ -49,7 +46,8 @@ class TestResolveSource:
assert got is not None
assert str(got["id"]) == str(src["id"])
def test_team_viewer_can_read(self, pg_conn):
def test_team_viewer_can_read_not_edit(self, pg_conn):
from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.chunks import _resolve_source
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import TeamResourceGrantsRepository
@@ -65,10 +63,13 @@ class TestResolveSource:
)
with _patch_db(pg_conn):
got = _resolve_source(str(src["id"]), viewer)
stranger = _resolve_source(str(src["id"]), "u-resolve-stranger")
assert got is not None
with pytest.raises(AccessDenied) as edit:
_resolve_source(str(src["id"]), viewer, "edit")
with pytest.raises(AccessDenied) as stranger:
_resolve_source(str(src["id"]), "u-resolve-stranger")
assert str(got["id"]) == str(src["id"])
assert stranger is None
assert edit.value.status == 403
assert stranger.value.status == 404
class TestChunkMatchesPath:
@@ -398,9 +399,9 @@ class TestAddChunk:
response = AddChunk().post()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import AddChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -413,7 +414,7 @@ class TestAddChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = AddChunk().post()
assert response.status_code == 403
assert response.status_code == 404
def test_adds_chunk(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import AddChunk
@@ -472,9 +473,9 @@ class TestDeleteChunk:
response = DeleteChunk().delete()
assert response.status_code == 401
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import DeleteChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -484,7 +485,7 @@ class TestDeleteChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteChunk().delete()
assert response.status_code == 403
assert response.status_code == 404
def test_deletes_chunk(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import DeleteChunk
@@ -551,9 +552,9 @@ class TestUpdateChunk:
response = UpdateChunk().put()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import UpdateChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -566,7 +567,7 @@ class TestUpdateChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateChunk().put()
assert response.status_code == 403
assert response.status_code == 404
def test_returns_404_chunk_not_found(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import UpdateChunk
@@ -53,6 +53,8 @@ def _run(sub, repo, team_shared, client):
"docsgpt.api.user.sources.routes.visible_with_access",
return_value=team_shared,
),
# Owner switches all at their defaults.
patch("docsgpt.api.user.sources.routes.settings_many", return_value={}),
]
for p in patches:
p.start()
@@ -80,6 +82,9 @@ class TestPaginatedSourcesTeamSharing:
assert by_id[owned]["team_access"] is None
assert by_id[shared]["ownership"] == "team"
assert by_id[shared]["team_access"] == "editor"
assert by_id[owned]["access"] == "owner"
assert by_id[shared]["access"] == "editor"
assert "edit" in by_id[shared]["allowed_actions"]
# The shared ids are unioned into the owner-scoped queries by id.
assert repo.list_for_user.call_args.kwargs["extra_ids"] == [shared]
assert repo.count_for_user.call_args.kwargs["extra_ids"] == [shared]
+8 -8
View File
@@ -526,9 +526,9 @@ class TestSyncSource:
response = SyncSource().post()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.routes import SyncSource
with _patch_db(pg_conn), app.test_request_context(
@@ -539,7 +539,7 @@ class TestSyncSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = SyncSource().post()
assert response.status_code == 403
assert response.status_code == 404
def test_returns_400_for_connector_type(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SyncSource
@@ -675,9 +675,9 @@ class TestReingestSource:
response = ReingestSource().post()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.routes import ReingestSource
with _patch_db(pg_conn), app.test_request_context(
@@ -688,7 +688,7 @@ class TestReingestSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = ReingestSource().post()
assert response.status_code == 403
assert response.status_code == 404
def test_triggers_reingest_task(self, app, pg_conn):
from docsgpt.api.user.sources.routes import ReingestSource
+509
View File
@@ -0,0 +1,509 @@
"""Owner / editor / viewer / stranger matrix for the source endpoints.
Every source route resolves the caller's role through
``docsgpt.api.user.resource_access``: 404 when the source isn't visible, 403
when it is but the role can't perform the action, and writes land as the owner.
"""
from __future__ import annotations
import uuid
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
OWNER = "alice-roles"
EDITOR = "bob-roles-editor"
VIEWER = "carol-roles-viewer"
STRANGER = "dave-roles-stranger"
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn, *modules):
@contextmanager
def _yield():
yield conn
patches = []
for mod in modules:
for name in ("db_session", "db_readonly"):
target = f"{mod}.{name}"
try:
p = patch(target, _yield)
p.start()
patches.append(p)
except AttributeError:
pass
try:
yield
finally:
for p in reversed(patches):
p.stop()
ROUTES = "docsgpt.api.user.sources.routes"
CHUNKS = "docsgpt.api.user.sources.chunks"
UPLOAD = "docsgpt.api.user.sources.upload"
CONNECTOR = "docsgpt.api.connector.routes"
def _shared_source(pg_conn, **kwargs):
"""Seed a source owned by OWNER, shared to EDITOR (editor) and VIEWER (viewer)."""
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from docsgpt.storage.db.repositories.teams import TeamsRepository
src = SourcesRepository(pg_conn).create(
kwargs.pop("name", "shared-src"), user_id=OWNER, **kwargs
)
sid = str(src["id"])
for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
team = TeamsRepository(pg_conn).create(
f"T-{level}", f"t-{level}-{uuid.uuid4().hex[:8]}", OWNER
)
TeamMembersRepository(pg_conn).add_member(team["id"], member, role="team_member")
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", sid, owner_id=OWNER, granted_by=OWNER,
access_level=level,
)
return sid
def _set(pg_conn, sid, **switches):
from docsgpt.api.user.resource_access import set_settings
set_settings(pg_conn, "source", sid, switches, OWNER)
def _call(app, user, path, fn, method="GET", **ctx):
with app.test_request_context(path, method=method, **ctx):
from flask import request
request.decoded_token = {"sub": user}
return fn()
# ---------------------------------------------------------------------------
# Listing
# ---------------------------------------------------------------------------
class TestListPayload:
def test_sources_rows_carry_access(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
owner = _call(app, OWNER, "/api/sources", CombinedJson().get)
editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
o = next(r for r in owner.json if r["id"] == sid)
e = next(r for r in editor.json if r["id"] == sid)
v = next(r for r in viewer.json if r["id"] == sid)
assert o["access"] == "owner"
assert "delete" in o["allowed_actions"]
assert o["ownership"] == "user"
assert e["access"] == "editor"
assert "edit" in e["allowed_actions"] and "delete" not in e["allowed_actions"]
assert e["allowed_actions"] == sorted(e["allowed_actions"])
assert e["team_access"] == "editor"
assert v["access"] == "viewer"
assert v["allowed_actions"] == ["use", "view_config"]
# viewers_can_see_config defaults on.
assert "config" in v
def test_viewer_config_hidden_when_switch_off(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson, PaginatedSources
sid = _shared_source(pg_conn)
_set(pg_conn, sid, viewers_can_see_config=False)
with _patch_db(pg_conn, ROUTES):
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
vpage = _call(app, VIEWER, "/api/sources/paginated", PaginatedSources().get)
v = next(r for r in viewer.json if r["id"] == sid)
e = next(r for r in editor.json if r["id"] == sid)
vp = next(r for r in vpage.json["paginated"] if r["id"] == sid)
assert "config" not in v
assert v["allowed_actions"] == ["use"]
assert "config" not in vp
assert "config" in e
def test_paginated_rows_carry_access(self, app, pg_conn):
from docsgpt.api.user.sources.routes import PaginatedSources
sid = _shared_source(pg_conn)
_set(pg_conn, sid, editors_can_delete=True)
with _patch_db(pg_conn, ROUTES):
owner = _call(app, OWNER, "/api/sources/paginated", PaginatedSources().get)
editor = _call(app, EDITOR, "/api/sources/paginated", PaginatedSources().get)
o = next(r for r in owner.json["paginated"] if r["id"] == sid)
e = next(r for r in editor.json["paginated"] if r["id"] == sid)
assert o["access"] == "owner"
assert e["access"] == "editor"
assert "delete" in e["allowed_actions"]
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def _delete(app, pg_conn, user, sid):
from docsgpt.api.user.sources.routes import DeleteOldIndexes
storage = MagicMock()
storage.is_directory.return_value = False
with _patch_db(pg_conn, ROUTES), patch(
f"{ROUTES}.settings.VECTOR_STORE", "milvus"
), patch(f"{ROUTES}.StorageCreator.get_storage", return_value=storage), patch(
f"{ROUTES}.VectorCreator.create_vectorstore", return_value=MagicMock()
):
return _call(app, user, f"/api/delete_old?source_id={sid}", DeleteOldIndexes().get)
class TestDelete:
@pytest.mark.parametrize("user, status", [(EDITOR, 403), (VIEWER, 403), (STRANGER, 404)])
def test_non_owner_denied_by_default(self, app, pg_conn, user, status):
from docsgpt.storage.db.repositories.sources import SourcesRepository
sid = _shared_source(pg_conn)
assert _delete(app, pg_conn, user, sid).status_code == status
assert SourcesRepository(pg_conn).get_by_id(sid) is not None
def test_editor_deletes_when_switch_on(self, app, pg_conn):
from sqlalchemy import text
from docsgpt.storage.db.repositories.sources import SourcesRepository
sid = _shared_source(pg_conn)
_set(pg_conn, sid, editors_can_delete=True)
assert _delete(app, pg_conn, EDITOR, sid).status_code == 200
assert SourcesRepository(pg_conn).get_by_id(sid) is None
grants = pg_conn.execute(
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:i AS uuid)"),
{"i": sid},
).scalar()
settings_rows = pg_conn.execute(
text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:i AS uuid)"),
{"i": sid},
).scalar()
assert grants == 0
assert settings_rows == 0
def test_viewer_still_denied_when_switch_on(self, app, pg_conn):
sid = _shared_source(pg_conn)
_set(pg_conn, sid, editors_can_delete=True)
assert _delete(app, pg_conn, VIEWER, sid).status_code == 403
# ---------------------------------------------------------------------------
# Sync frequency / sync / reingest / config
# ---------------------------------------------------------------------------
class TestManageSync:
@pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import ManageSync
from docsgpt.storage.db.repositories.sources import SourcesRepository
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(
app, user, "/api/manage_sync", ManageSync().post, method="POST",
json={"source_id": sid, "sync_frequency": "weekly"},
)
assert resp.status_code == status
got = SourcesRepository(pg_conn).get_by_id(sid)["sync_frequency"]
assert (got == "weekly") == (status == 200)
class TestSyncSource:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix_runs_as_owner(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import SyncSource
sid = _shared_source(
pg_conn, type="url", remote_data={"url": "https://example.com"}
)
delay = MagicMock(return_value=MagicMock(id="t-1"))
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.sync_source.delay", delay):
resp = _call(
app, user, "/api/sync_source", SyncSource().post, method="POST",
json={"source_id": sid},
)
assert resp.status_code == status
if status == 200:
assert delay.call_args.kwargs["user"] == OWNER
else:
delay.assert_not_called()
class TestReingest:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import ReingestSource
sid = _shared_source(pg_conn)
delay = MagicMock(return_value=MagicMock(id="t-2"))
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reingest_source_task.delay", delay):
resp = _call(
app, user, "/api/sources/reingest", ReingestSource().post,
method="POST", json={"source_id": sid},
)
assert resp.status_code == status
if status == 200:
assert delay.call_args.kwargs["user"] == OWNER
class TestConfig:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import SourceConfigResource
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(
app, user, f"/api/sources/{sid}/config",
lambda: SourceConfigResource().patch(sid), method="PATCH",
json={"retrieval": {"chunks": 7}},
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Reads
# ---------------------------------------------------------------------------
class TestReads:
@pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 200), (STRANGER, 404)])
def test_directory_structure(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import DirectoryStructure
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(app, user, f"/api/directory_structure?id={sid}", DirectoryStructure().get)
assert resp.status_code == status
@pytest.mark.parametrize("user, status", [(VIEWER, 200), (STRANGER, 404)])
def test_get_chunks(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import GetChunks
sid = _shared_source(pg_conn)
store = MagicMock()
store.get_chunks.return_value = []
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(app, user, f"/api/get_chunks?id={sid}", GetChunks().get)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Wiki writes
# ---------------------------------------------------------------------------
class TestWikiWrites:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_put_page(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import WikiPage
sid = _shared_source(pg_conn, type="wiki", config={"kind": "wiki"})
delay = MagicMock()
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reembed_wiki_page.delay", delay):
resp = _call(
app, user, f"/api/sources/{sid}/wiki/page",
lambda: WikiPage().put(sid), method="PUT",
json={"path": "/a.md", "content": "hi"},
)
assert resp.status_code == status
if status == 200:
assert delay.call_args.kwargs["user"] == OWNER
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_convert(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import ConvertSourceToWiki
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(
app, user, f"/api/sources/{sid}/wiki/convert",
lambda: ConvertSourceToWiki().post(sid), method="POST",
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Chunks
# ---------------------------------------------------------------------------
class TestChunkWrites:
@pytest.mark.parametrize("user, status", [(EDITOR, 201), (VIEWER, 403), (STRANGER, 404)])
def test_add(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import AddChunk
sid = _shared_source(pg_conn)
store = MagicMock()
store.add_chunk.return_value = "c1"
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(
app, user, "/api/add_chunk", AddChunk().post, method="POST",
json={"id": sid, "text": "hello"},
)
assert resp.status_code == status
assert store.add_chunk.called == (status == 201)
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_delete(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import DeleteChunk
sid = _shared_source(pg_conn)
store = MagicMock()
store.delete_chunk.return_value = True
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(
app, user, f"/api/delete_chunk?id={sid}&chunk_id=c1",
DeleteChunk().delete, method="DELETE",
)
assert resp.status_code == status
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_update(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import UpdateChunk
sid = _shared_source(pg_conn)
store = MagicMock()
store.get_chunks.return_value = [{"doc_id": "c1", "text": "a", "metadata": {}}]
store.update_chunk.return_value = "c1"
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(
app, user, "/api/update_chunk", UpdateChunk().put, method="PUT",
json={"id": sid, "chunk_id": "c1", "text": "b"},
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Files
# ---------------------------------------------------------------------------
class TestManageSourceFiles:
@pytest.mark.parametrize("user, status", [(EDITOR, 400), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
# An editor passes the role check and reaches the operation's own
# validation (no files → 400); viewer and stranger stop at the check.
from docsgpt.api.user.sources.upload import ManageSourceFiles
sid = _shared_source(pg_conn, file_path="/data/src")
with _patch_db(pg_conn, UPLOAD), patch(
f"{UPLOAD}.StorageCreator.get_storage", return_value=MagicMock()
):
resp = _call(
app, user, "/api/manage_source_files", ManageSourceFiles().post,
method="POST", data={"source_id": sid, "operation": "add"},
content_type="multipart/form-data",
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Connector sync
# ---------------------------------------------------------------------------
def _connector_source(pg_conn):
return _shared_source(
pg_conn, name="drive-src", type="connector:file",
remote_data={"provider": "google_drive", "file_ids": ["f"], "folder_ids": []},
)
def _owner_session(pg_conn, token="st-owner", token_info=None):
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
repo = ConnectorSessionsRepository(pg_conn)
row = repo.upsert(OWNER, "google_drive", status="authorized")
repo.update(
str(row["id"]),
{
"session_token": token,
"token_info": token_info or {"access_token": "a", "refresh_token": "r"},
},
)
def _connector_sync(app, pg_conn, user, body):
from docsgpt.api.connector.routes import ConnectorSync
delay = MagicMock(return_value=MagicMock(id="t-sync"))
with _patch_db(pg_conn, CONNECTOR), patch(
f"{CONNECTOR}.ingest_connector_task.delay", delay
):
resp = _call(app, user, "/api/connectors/sync", ConnectorSync().post, method="POST", json=body)
return resp, delay
class TestConnectorSync:
def test_editor_syncs_with_owner_session(self, app, pg_conn):
sid = _connector_source(pg_conn)
_owner_session(pg_conn)
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
assert resp.status_code == 200
kwargs = delay.call_args.kwargs
assert kwargs["user"] == OWNER
assert kwargs["session_token"] == "st-owner"
def test_editor_cannot_substitute_own_session(self, app, pg_conn):
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
sid = _connector_source(pg_conn)
_owner_session(pg_conn)
repo = ConnectorSessionsRepository(pg_conn)
row = repo.upsert(EDITOR, "google_drive", status="authorized")
repo.update(str(row["id"]), {"session_token": "st-editor", "token_info": {"access_token": "x"}})
resp, delay = _connector_sync(
app, pg_conn, EDITOR, {"source_id": sid, "session_token": "st-editor"}
)
assert resp.status_code == 200
assert delay.call_args.kwargs["session_token"] == "st-owner"
def test_owner_session_missing_returns_409(self, app, pg_conn):
sid = _connector_source(pg_conn)
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
assert resp.status_code == 409
assert "owner" in resp.json["error"].lower()
delay.assert_not_called()
@pytest.mark.parametrize("user, status", [(VIEWER, 403), (STRANGER, 404)])
def test_viewer_and_stranger_denied(self, app, pg_conn, user, status):
sid = _connector_source(pg_conn)
_owner_session(pg_conn)
resp, delay = _connector_sync(app, pg_conn, user, {"source_id": sid})
assert resp.status_code == status
delay.assert_not_called()
def test_owner_still_uses_own_token(self, app, pg_conn):
sid = _connector_source(pg_conn)
_owner_session(pg_conn)
resp, delay = _connector_sync(
app, pg_conn, OWNER, {"source_id": sid, "session_token": "st-owner"}
)
assert resp.status_code == 200
assert delay.call_args.kwargs["user"] == OWNER