chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
"""Tests for application/api/user/sources/chunks.py."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.chunks.db_readonly", _yield
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _seed_source(pg_conn, user="u", name="src"):
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
return SourcesRepository(pg_conn).create(name, user_id=user)
|
||||
|
||||
|
||||
class TestResolveSource:
|
||||
def test_returns_none_for_missing(self, pg_conn):
|
||||
from application.api.user.sources.chunks import _resolve_source
|
||||
with _patch_db(pg_conn):
|
||||
assert (
|
||||
_resolve_source(
|
||||
"00000000-0000-0000-0000-000000000000", "u"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_source_when_found(self, pg_conn):
|
||||
from application.api.user.sources.chunks import _resolve_source
|
||||
|
||||
src = _seed_source(pg_conn, user="u-resolve")
|
||||
with _patch_db(pg_conn):
|
||||
got = _resolve_source(str(src["id"]), "u-resolve")
|
||||
assert got is not None
|
||||
assert str(got["id"]) == str(src["id"])
|
||||
|
||||
|
||||
class TestGetChunks:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
with app.test_request_context("/api/get_chunks?id=abc"):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_400_missing_id(self, app):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
with app.test_request_context("/api/get_chunks"):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_400_on_resolve_error(self, app):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
@contextmanager
|
||||
def _broken():
|
||||
raise RuntimeError("boom")
|
||||
yield
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.chunks.db_readonly", _broken
|
||||
), app.test_request_context("/api/get_chunks?id=abc"):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_404_when_source_missing(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/get_chunks?id=00000000-0000-0000-0000-000000000000"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_returns_paginated_chunks(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
user = "u-chunks"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
fake_store = MagicMock()
|
||||
fake_store.get_chunks.return_value = [
|
||||
{"text": f"chunk {i}", "metadata": {"title": f"T{i}"}}
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
f"/api/get_chunks?id={src['id']}&per_page=2&page=1"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert data["total"] == 5
|
||||
assert len(data["chunks"]) == 2
|
||||
|
||||
def test_filters_by_path(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
user = "u-path"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
fake_store = MagicMock()
|
||||
fake_store.get_chunks.return_value = [
|
||||
{"text": "a", "metadata": {"source": "/a/b/file.txt"}},
|
||||
{"text": "b", "metadata": {"source": "/other.txt"}},
|
||||
]
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
f"/api/get_chunks?id={src['id']}&path=b/file.txt"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 200
|
||||
assert response.json["total"] == 1
|
||||
|
||||
def test_filters_by_search(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
user = "u-srch"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
fake_store = MagicMock()
|
||||
fake_store.get_chunks.return_value = [
|
||||
{"text": "the cat", "metadata": {"title": ""}},
|
||||
{"text": "a dog", "metadata": {"title": ""}},
|
||||
]
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
f"/api/get_chunks?id={src['id']}&search=cat"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 200
|
||||
assert response.json["total"] == 1
|
||||
|
||||
def test_returns_500_on_vector_store_error(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import GetChunks
|
||||
|
||||
user = "u-err"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
side_effect=RuntimeError("boom"),
|
||||
), app.test_request_context(f"/api/get_chunks?id={src['id']}"):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = GetChunks().get()
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
class TestAddChunk:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.chunks import AddChunk
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/add_chunk", method="POST",
|
||||
json={"id": "x", "text": "hello"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = AddChunk().post()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_400_missing_fields(self, app):
|
||||
from application.api.user.sources.chunks import AddChunk
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/add_chunk", method="POST", json={"id": "x"}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
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".
|
||||
from application.api.user.sources.chunks import AddChunk
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/add_chunk", method="POST",
|
||||
json={
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"text": "content",
|
||||
},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = AddChunk().post()
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_adds_chunk(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import AddChunk
|
||||
|
||||
user = "u-add"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
fake_store = MagicMock()
|
||||
fake_store.add_chunk.return_value = "chunk-id-1"
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
"/api/add_chunk", method="POST",
|
||||
json={
|
||||
"id": str(src["id"]),
|
||||
"text": "the text of the chunk",
|
||||
"metadata": {"title": "My Chunk"},
|
||||
},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = AddChunk().post()
|
||||
assert response.status_code == 201
|
||||
assert response.json["chunk_id"] == "chunk-id-1"
|
||||
|
||||
def test_returns_500_on_vector_error(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import AddChunk
|
||||
|
||||
user = "u-adderr"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
side_effect=RuntimeError("bad"),
|
||||
), app.test_request_context(
|
||||
"/api/add_chunk", method="POST",
|
||||
json={"id": str(src["id"]), "text": "x"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = AddChunk().post()
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
class TestDeleteChunk:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.chunks import DeleteChunk
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/delete_chunk?id=x&chunk_id=y", method="DELETE"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
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".
|
||||
from application.api.user.sources.chunks import DeleteChunk
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/delete_chunk?id=00000000-0000-0000-0000-000000000000&chunk_id=c",
|
||||
method="DELETE",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = DeleteChunk().delete()
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_deletes_chunk(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import DeleteChunk
|
||||
|
||||
user = "u-del"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
fake_store = MagicMock()
|
||||
fake_store.delete_chunk.return_value = True
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
f"/api/delete_chunk?id={src['id']}&chunk_id=c", method="DELETE"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = DeleteChunk().delete()
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_returns_404_chunk_not_found(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import DeleteChunk
|
||||
|
||||
user = "u-missing-chunk"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
fake_store = MagicMock()
|
||||
fake_store.delete_chunk.return_value = False
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
f"/api/delete_chunk?id={src['id']}&chunk_id=c", method="DELETE"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = DeleteChunk().delete()
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestUpdateChunk:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/update_chunk", method="PUT",
|
||||
json={"id": "x", "chunk_id": "c"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = UpdateChunk().put()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_400_missing_fields(self, app):
|
||||
from application.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/update_chunk", method="PUT", json={"id": "x"}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
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".
|
||||
from application.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/update_chunk", method="PUT",
|
||||
json={
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"chunk_id": "c",
|
||||
},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UpdateChunk().put()
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_returns_404_chunk_not_found(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
user = "u-upd-missing"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
fake_store = MagicMock()
|
||||
fake_store.get_chunks.return_value = []
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
"/api/update_chunk", method="PUT",
|
||||
json={"id": str(src["id"]), "chunk_id": "missing"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = UpdateChunk().put()
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_updates_chunk(self, app, pg_conn):
|
||||
from application.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
user = "u-upd"
|
||||
src = _seed_source(pg_conn, user=user)
|
||||
|
||||
fake_store = MagicMock()
|
||||
fake_store.get_chunks.return_value = [
|
||||
{
|
||||
"doc_id": "chunk-123",
|
||||
"text": "old",
|
||||
"metadata": {"title": "T"},
|
||||
}
|
||||
]
|
||||
fake_store.add_chunk.return_value = "new-chunk-id"
|
||||
fake_store.delete_chunk.return_value = True
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.chunks.get_vector_store",
|
||||
return_value=fake_store,
|
||||
), app.test_request_context(
|
||||
"/api/update_chunk", method="PUT",
|
||||
json={
|
||||
"id": str(src["id"]),
|
||||
"chunk_id": "chunk-123",
|
||||
"text": "new text",
|
||||
},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = UpdateChunk().put()
|
||||
assert response.status_code == 200
|
||||
assert response.json["chunk_id"] == "new-chunk-id"
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for the GraphRAG graph-view routes in
|
||||
application/api/user/sources/routes.py.
|
||||
|
||||
The endpoints are read-access gated (owner or team grant). The ``GraphStore`` is
|
||||
mocked so no live vector store, embeddings, or LLM calls run; the ``sources`` row
|
||||
is real so the authz lookup resolves. A separate suite exercises
|
||||
``GraphStore.get_graph_overview`` against a live pgvector store (skipped when
|
||||
unreachable) and asserts the SQL is parameterized via a mock cursor.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.routes.db_session", _yield
|
||||
), patch(
|
||||
"application.api.user.sources.routes.db_readonly", _yield
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _grant_team_access(pg_conn, owner, member, source_id, access_level):
|
||||
from application.storage.db.repositories.team_members import (
|
||||
TeamMembersRepository,
|
||||
)
|
||||
from application.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from application.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
team = TeamsRepository(pg_conn).create(
|
||||
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
|
||||
)
|
||||
TeamMembersRepository(pg_conn).add_member(
|
||||
team["id"], member, role="team_member"
|
||||
)
|
||||
TeamResourceGrantsRepository(pg_conn).grant(
|
||||
team["id"], "source", source_id, owner_id=owner, granted_by=owner,
|
||||
access_level=access_level,
|
||||
)
|
||||
|
||||
|
||||
def _graphrag_source(pg_conn, user):
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"graph-src", user_id=user, type="file",
|
||||
config={
|
||||
"kind": "graphrag",
|
||||
"retrieval": {"retriever": "graphrag"},
|
||||
},
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
return str(src["id"])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSourceGraph:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.routes import SourceGraph
|
||||
|
||||
with app.test_request_context("/api/sources/x/graph"):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = SourceGraph().get("x")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_owner_gets_bounded_overview(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceGraph
|
||||
|
||||
user = "u-graph-view-owner"
|
||||
sid = _graphrag_source(pg_conn, user)
|
||||
|
||||
store = MagicMock()
|
||||
store.count_nodes.return_value = 3
|
||||
store.get_graph_overview.return_value = {
|
||||
"nodes": [
|
||||
{"id": "n1", "name": "A", "type": "person",
|
||||
"description": "d", "degree": 2},
|
||||
{"id": "n2", "name": "B", "type": "org",
|
||||
"description": "e", "degree": 1},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "n1", "target": "n2", "type": "rel", "weight": 1.0},
|
||||
],
|
||||
}
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.graphrag.store.GraphStore", return_value=store
|
||||
), app.test_request_context(
|
||||
f"/api/sources/{sid}/graph?limit=9999"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = SourceGraph().get(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["success"] is True
|
||||
assert {n["id"] for n in response.json["nodes"]} == {"n1", "n2"}
|
||||
assert response.json["edges"][0]["source"] == "n1"
|
||||
# The store receives the source's resolved id and the clamped limit.
|
||||
args = store.get_graph_overview.call_args.args
|
||||
assert args[0] == sid
|
||||
# The route forwards the raw limit; clamping is the store's job (tested
|
||||
# below) but a sane request limit must reach it.
|
||||
assert args[1] == 9999
|
||||
|
||||
def test_empty_graph_returns_empty_lists(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceGraph
|
||||
|
||||
user = "u-graph-view-empty"
|
||||
sid = _graphrag_source(pg_conn, user)
|
||||
|
||||
store = MagicMock()
|
||||
store.count_nodes.return_value = 0
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.graphrag.store.GraphStore", return_value=store
|
||||
), app.test_request_context(f"/api/sources/{sid}/graph"):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = SourceGraph().get(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["nodes"] == []
|
||||
assert response.json["edges"] == []
|
||||
# No graph rows → never query the overview.
|
||||
store.get_graph_overview.assert_not_called()
|
||||
|
||||
def test_non_owner_without_grant_404(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceGraph
|
||||
|
||||
owner = "u-graph-view-owner2"
|
||||
stranger = "u-graph-view-stranger"
|
||||
sid = _graphrag_source(pg_conn, owner)
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.graphrag.store.GraphStore"
|
||||
) as mock_store, app.test_request_context(
|
||||
f"/api/sources/{sid}/graph"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": stranger}
|
||||
response = SourceGraph().get(sid)
|
||||
|
||||
assert response.status_code == 404
|
||||
mock_store.assert_not_called()
|
||||
|
||||
def test_team_viewer_can_read(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceGraph
|
||||
|
||||
owner = "alice-graph-view"
|
||||
viewer = "bob-graph-view-viewer"
|
||||
sid = _graphrag_source(pg_conn, owner)
|
||||
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
|
||||
|
||||
store = MagicMock()
|
||||
store.count_nodes.return_value = 1
|
||||
store.get_graph_overview.return_value = {
|
||||
"nodes": [
|
||||
{"id": "n1", "name": "A", "type": None,
|
||||
"description": None, "degree": 0},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.graphrag.store.GraphStore", return_value=store
|
||||
), app.test_request_context(f"/api/sources/{sid}/graph"):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": viewer}
|
||||
response = SourceGraph().get(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert {n["id"] for n in response.json["nodes"]} == {"n1"}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSourceGraphNode:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.routes import SourceGraphNode
|
||||
|
||||
with app.test_request_context("/api/sources/x/graph/node/n"):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = SourceGraphNode().get("x", "n")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_owner_gets_node_detail_with_chunks(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceGraphNode
|
||||
|
||||
user = "u-graph-node-owner"
|
||||
sid = _graphrag_source(pg_conn, user)
|
||||
|
||||
store = MagicMock()
|
||||
store.get_node_detail.return_value = {
|
||||
"id": "n1",
|
||||
"name": "Ada",
|
||||
"type": "person",
|
||||
"description": "A mathematician.",
|
||||
"degree": 3,
|
||||
"doc_freq": 2,
|
||||
"chunks": [{"chunk_id": "5", "text": "body", "metadata": {}}],
|
||||
}
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.graphrag.store.GraphStore", return_value=store
|
||||
), app.test_request_context(f"/api/sources/{sid}/graph/node/n1"):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = SourceGraphNode().get(sid, "n1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["node"]["name"] == "Ada"
|
||||
assert response.json["node"]["chunks"][0]["text"] == "body"
|
||||
store.get_node_detail.assert_called_once_with(sid, "n1")
|
||||
|
||||
def test_unknown_node_404(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceGraphNode
|
||||
|
||||
user = "u-graph-node-missing"
|
||||
sid = _graphrag_source(pg_conn, user)
|
||||
|
||||
store = MagicMock()
|
||||
store.get_node_detail.return_value = None
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.graphrag.store.GraphStore", return_value=store
|
||||
), app.test_request_context(f"/api/sources/{sid}/graph/node/nope"):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = SourceGraphNode().get(sid, "nope")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_non_owner_without_grant_404(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceGraphNode
|
||||
|
||||
owner = "u-graph-node-owner2"
|
||||
stranger = "u-graph-node-stranger"
|
||||
sid = _graphrag_source(pg_conn, owner)
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.graphrag.store.GraphStore"
|
||||
) as mock_store, app.test_request_context(
|
||||
f"/api/sources/{sid}/graph/node/n1"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": stranger}
|
||||
response = SourceGraphNode().get(sid, "n1")
|
||||
|
||||
assert response.status_code == 404
|
||||
mock_store.assert_not_called()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Tests for the GraphRAG enable route in application/api/user/sources/routes.py.
|
||||
|
||||
``graphrag_available`` and ``extract_graph.delay`` are mocked so no live
|
||||
vector store, LLM, or model calls run; the ``sources`` row is real so the
|
||||
authz lookup and config write read back.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.source_config import SourceConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.routes.db_session", _yield
|
||||
), patch(
|
||||
"application.api.user.sources.routes.db_readonly", _yield
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _grant_team_access(pg_conn, owner, member, source_id, access_level):
|
||||
from application.storage.db.repositories.team_members import (
|
||||
TeamMembersRepository,
|
||||
)
|
||||
from application.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from application.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
team = TeamsRepository(pg_conn).create(
|
||||
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
|
||||
)
|
||||
TeamMembersRepository(pg_conn).add_member(
|
||||
team["id"], member, role="team_member"
|
||||
)
|
||||
TeamResourceGrantsRepository(pg_conn).grant(
|
||||
team["id"], "source", source_id, owner_id=owner, granted_by=owner,
|
||||
access_level=access_level,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEnableSourceGraphRAG:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.routes import EnableSourceGraphRAG
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/sources/x/graphrag/enable", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = EnableSourceGraphRAG().post("x")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_unavailable_returns_400(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import EnableSourceGraphRAG
|
||||
|
||||
user = "u-graph-unavail"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"files", user_id=user, type="file",
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.graphrag_available",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"application.api.user.sources.routes.extract_graph.delay"
|
||||
) as mock_extract, app.test_request_context(
|
||||
f"/api/sources/{sid}/graphrag/enable", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = EnableSourceGraphRAG().post(sid)
|
||||
|
||||
assert response.status_code == 400
|
||||
mock_extract.assert_not_called()
|
||||
got = SourcesRepository(pg_conn).get_any(sid, user)
|
||||
assert SourceConfig.parse(got.get("config")).kind == "classic"
|
||||
|
||||
def test_owner_sets_config_and_enqueues(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import EnableSourceGraphRAG
|
||||
|
||||
user = "u-graph-owner"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"files", user_id=user, type="file",
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
fake_task = type("T", (), {"id": "task-g"})()
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.graphrag_available",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"application.worker._reset_graph_for_source",
|
||||
) as mock_reset, patch(
|
||||
"application.api.user.sources.routes.extract_graph.delay",
|
||||
return_value=fake_task,
|
||||
) as mock_extract, app.test_request_context(
|
||||
f"/api/sources/{sid}/graphrag/enable", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = EnableSourceGraphRAG().post(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["success"] is True
|
||||
assert response.json["task_id"] == "task-g"
|
||||
|
||||
got = SourcesRepository(pg_conn).get_any(sid, user)
|
||||
cfg = SourceConfig.parse(got.get("config"))
|
||||
assert cfg.kind == "graphrag"
|
||||
assert cfg.retrieval.retriever == "graphrag"
|
||||
|
||||
# Each enable wipes any prior graph so it rebuilds from scratch.
|
||||
mock_reset.assert_called_once_with(sid)
|
||||
mock_extract.assert_called_once()
|
||||
assert mock_extract.call_args.args[0] == sid
|
||||
assert mock_extract.call_args.args[1] == user
|
||||
# The key varies with the fresh ``updated_at`` the config write bumped,
|
||||
# so each enable produces a new key that re-runs the worker.
|
||||
key = mock_extract.call_args.kwargs["idempotency_key"]
|
||||
assert key.startswith(f"extract-graph:{sid}:")
|
||||
assert key != f"extract-graph:{sid}:"
|
||||
|
||||
def test_viewer_rejected_403(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import EnableSourceGraphRAG
|
||||
|
||||
owner = "alice-graph"
|
||||
viewer = "bob-graph-viewer"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"files", user_id=owner, type="file",
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
sid = str(src["id"])
|
||||
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.graphrag_available",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"application.api.user.sources.routes.extract_graph.delay"
|
||||
) as mock_extract, app.test_request_context(
|
||||
f"/api/sources/{sid}/graphrag/enable", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": viewer}
|
||||
response = EnableSourceGraphRAG().post(sid)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_extract.assert_not_called()
|
||||
got = SourcesRepository(pg_conn).get_any(sid, owner)
|
||||
assert SourceConfig.parse(got.get("config")).kind == "classic"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestConfigPatchCannotSetGraphrag:
|
||||
"""The config PATCH endpoint must not flip kind to graphrag (D28)."""
|
||||
|
||||
def test_patch_kind_graphrag_rejected_400(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import SourceConfigResource
|
||||
|
||||
user = "u-patch-graph"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"files", user_id=user, type="file",
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/config",
|
||||
method="PATCH",
|
||||
json={"kind": "graphrag"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = SourceConfigResource().patch(sid)
|
||||
|
||||
assert response.status_code == 400
|
||||
got = SourcesRepository(pg_conn).get_any(sid, user)
|
||||
assert SourceConfig.parse(got.get("config")).kind == "classic"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""GET /api/sources/paginated surfaces team-shared sources (with ownership/access).
|
||||
|
||||
Regression test: the paginated settings list must include sources shared with the
|
||||
caller's teams — matching the /api/sources dropdown — not just owned sources.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from application.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app.test_client()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cm(value):
|
||||
yield value
|
||||
|
||||
|
||||
def _source_row(sid, *, user_id):
|
||||
return {
|
||||
"id": sid,
|
||||
"name": f"src-{sid[:4]}",
|
||||
"user_id": user_id,
|
||||
"date": "",
|
||||
"tokens": "",
|
||||
"retriever": "classic",
|
||||
"sync_frequency": "",
|
||||
"remote_data": None,
|
||||
"directory_structure": None,
|
||||
"type": "file",
|
||||
"ingest_status": None,
|
||||
}
|
||||
|
||||
|
||||
def _run(sub, repo, team_shared, client):
|
||||
patches = [
|
||||
patch("application.app.handle_auth", return_value={"sub": sub}),
|
||||
patch("application.app.resolve_roles", return_value=["user"]),
|
||||
patch("application.api.user.sources.routes.db_readonly", lambda: _cm(Mock())),
|
||||
patch("application.api.user.sources.routes.SourcesRepository", return_value=repo),
|
||||
patch(
|
||||
"application.api.user.sources.routes.visible_with_access",
|
||||
return_value=team_shared,
|
||||
),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
return client.get("/api/sources/paginated")
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPaginatedSourcesTeamSharing:
|
||||
def test_team_shared_source_appears_with_access(self, client):
|
||||
owned, shared = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
repo = Mock()
|
||||
repo.count_for_user.return_value = 2
|
||||
repo.list_for_user.return_value = [
|
||||
_source_row(owned, user_id="bob"),
|
||||
_source_row(shared, user_id="alice"), # not the caller → team-shared
|
||||
]
|
||||
resp = _run("bob", repo, {shared: "editor"}, client)
|
||||
assert resp.status_code == 200
|
||||
by_id = {d["id"]: d for d in json.loads(resp.data)["paginated"]}
|
||||
assert by_id[owned]["ownership"] == "user"
|
||||
assert by_id[owned]["team_access"] is None
|
||||
assert by_id[shared]["ownership"] == "team"
|
||||
assert by_id[shared]["team_access"] == "editor"
|
||||
# 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]
|
||||
|
||||
def test_no_shares_returns_only_owned(self, client):
|
||||
owned = str(uuid.uuid4())
|
||||
repo = Mock()
|
||||
repo.count_for_user.return_value = 1
|
||||
repo.list_for_user.return_value = [_source_row(owned, user_id="bob")]
|
||||
resp = _run("bob", repo, {}, client)
|
||||
assert resp.status_code == 200
|
||||
docs = json.loads(resp.data)["paginated"]
|
||||
assert len(docs) == 1
|
||||
assert docs[0]["ownership"] == "user"
|
||||
assert repo.list_for_user.call_args.kwargs["extra_ids"] == []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,790 @@
|
||||
"""Tests for application/api/user/sources/upload.py."""
|
||||
|
||||
import io
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.db_session", _yield
|
||||
), patch(
|
||||
"application.api.user.sources.upload.db_readonly", _yield
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _seed_source(pg_conn, user="u", name="src", **kw):
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
return SourcesRepository(pg_conn).create(name, user_id=user, **kw)
|
||||
|
||||
|
||||
class TestEnforceAudioPathSizeLimit:
|
||||
def test_noop_for_non_audio(self, tmp_path):
|
||||
from application.api.user.sources.upload import (
|
||||
_enforce_audio_path_size_limit,
|
||||
)
|
||||
p = tmp_path / "doc.txt"
|
||||
p.write_bytes(b"x" * 1024)
|
||||
_enforce_audio_path_size_limit(str(p), "doc.txt")
|
||||
|
||||
def test_raises_for_large_audio(self, tmp_path):
|
||||
from application.api.user.sources.upload import (
|
||||
_enforce_audio_path_size_limit,
|
||||
)
|
||||
from application.stt.upload_limits import AudioFileTooLargeError
|
||||
|
||||
p = tmp_path / "audio.mp3"
|
||||
p.write_bytes(b"x" * 100)
|
||||
with patch(
|
||||
"application.api.user.sources.upload.enforce_audio_file_size_limit",
|
||||
side_effect=AudioFileTooLargeError("too large"),
|
||||
):
|
||||
with pytest.raises(AudioFileTooLargeError):
|
||||
_enforce_audio_path_size_limit(str(p), "audio.mp3")
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
|
||||
with app.test_request_context("/api/upload", method="POST"):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_400_missing_fields(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/upload", method="POST",
|
||||
data={"user": "u"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_400_empty_filenames(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/upload", method="POST",
|
||||
data={
|
||||
"user": "u", "name": "job",
|
||||
"file": (io.BytesIO(b""), ""), # empty filename
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_uploads_single_file_successfully(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_task = MagicMock(id="task-1")
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), patch(
|
||||
"application.api.user.sources.upload.ingest.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/upload", method="POST",
|
||||
data={
|
||||
"user": "alice", "name": "my_job",
|
||||
"file": (io.BytesIO(b"content"), "doc.txt"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "alice"}
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 200
|
||||
assert response.json["success"] is True
|
||||
assert response.json["task_id"] == "task-1"
|
||||
|
||||
def test_storage_error_returns_400(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_storage.save_file.side_effect = RuntimeError("boom")
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), app.test_request_context(
|
||||
"/api/upload", method="POST",
|
||||
data={
|
||||
"user": "alice", "name": "j",
|
||||
"file": (io.BytesIO(b"content"), "doc.txt"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "alice"}
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_uploads_zip_extracts_files(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
import zipfile
|
||||
|
||||
# Build an in-memory zip containing 2 files
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w") as zf:
|
||||
zf.writestr("a.txt", "content a")
|
||||
zf.writestr("sub/b.txt", "content b")
|
||||
zip_buffer.seek(0)
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_task = MagicMock(id="task-zip")
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), patch(
|
||||
"application.api.user.sources.upload.ingest.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/upload", method="POST",
|
||||
data={
|
||||
"user": "alice", "name": "job",
|
||||
"file": (zip_buffer, "docs.zip"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "alice"}
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 200
|
||||
# save_file called at least twice (for 2 files in zip)
|
||||
assert fake_storage.save_file.call_count >= 2
|
||||
|
||||
def test_office_format_zip_saved_as_is(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
import zipfile
|
||||
|
||||
# .docx is technically a zip but should be saved as-is
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("word/document.xml", "<x/>")
|
||||
buf.seek(0)
|
||||
|
||||
fake_storage = MagicMock()
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), patch(
|
||||
"application.api.user.sources.upload.ingest.apply_async",
|
||||
return_value=MagicMock(id="t"),
|
||||
), app.test_request_context(
|
||||
"/api/upload", method="POST",
|
||||
data={
|
||||
"user": "alice", "name": "job",
|
||||
"file": (buf, "letter.docx"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "alice"}
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 200
|
||||
# Saved once (as the .docx directly, not extracted)
|
||||
assert fake_storage.save_file.call_count == 1
|
||||
|
||||
def test_audio_too_large_returns_413(self, app):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
from application.stt.upload_limits import AudioFileTooLargeError
|
||||
|
||||
fake_storage = MagicMock()
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), patch(
|
||||
"application.api.user.sources.upload._enforce_audio_path_size_limit",
|
||||
side_effect=AudioFileTooLargeError("too large"),
|
||||
), app.test_request_context(
|
||||
"/api/upload", method="POST",
|
||||
data={
|
||||
"user": "alice", "name": "j",
|
||||
"file": (io.BytesIO(b"audio-content"), "song.mp3"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "alice"}
|
||||
response = UploadFile().post()
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
class TestUploadRemote:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.upload import UploadRemote
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/remote", method="POST",
|
||||
data={"user": "u", "source": "github"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = UploadRemote().post()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_missing_fields(self, app):
|
||||
from application.api.user.sources.upload import UploadRemote
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/remote", method="POST",
|
||||
data={"user": "u"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UploadRemote().post()
|
||||
# check_required_fields returns a response; status is 400
|
||||
# The response is returned directly by missing_fields branch
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_uploads_github_remote_success(self, app):
|
||||
from application.api.user.sources.upload import UploadRemote
|
||||
|
||||
fake_task = MagicMock(id="remote-task-1")
|
||||
with patch(
|
||||
"application.api.user.sources.upload.ingest_remote.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/remote", method="POST",
|
||||
data={
|
||||
"user": "u", "source": "github", "name": "gh",
|
||||
"data": json.dumps({"repo_url": "https://github.com/x/y"}),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UploadRemote().post()
|
||||
assert response.status_code == 200
|
||||
assert response.json["task_id"] == "remote-task-1"
|
||||
|
||||
def test_uploads_url_source(self, app):
|
||||
from application.api.user.sources.upload import UploadRemote
|
||||
|
||||
fake_task = MagicMock(id="url-task")
|
||||
with patch(
|
||||
"application.api.user.sources.upload.ingest_remote.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/remote", method="POST",
|
||||
data={
|
||||
"user": "u", "source": "crawler", "name": "crawl",
|
||||
"data": json.dumps({"url": "https://example.com"}),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UploadRemote().post()
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_uploads_reddit_source(self, app):
|
||||
from application.api.user.sources.upload import UploadRemote
|
||||
|
||||
fake_task = MagicMock(id="reddit-task")
|
||||
with patch(
|
||||
"application.api.user.sources.upload.ingest_remote.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/remote", method="POST",
|
||||
data={
|
||||
"user": "u", "source": "reddit", "name": "r",
|
||||
"data": json.dumps({"subreddit": "python"}),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UploadRemote().post()
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_upload_exception_returns_400(self, app):
|
||||
from application.api.user.sources.upload import UploadRemote
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.ingest_remote.apply_async",
|
||||
side_effect=RuntimeError("boom"),
|
||||
), app.test_request_context(
|
||||
"/api/remote", method="POST",
|
||||
data={
|
||||
"user": "u", "source": "github", "name": "x",
|
||||
"data": json.dumps({"repo_url": "https://github.com/x/y"}),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UploadRemote().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestManageSourceFiles:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={"source_id": "x", "operation": "add"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_400_missing_required(self, app):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={"source_id": "x"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_400_invalid_operation(self, app):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={"source_id": "x", "operation": "weird"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_404_source_not_found(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": "00000000-0000-0000-0000-000000000000",
|
||||
"operation": "add",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_rejects_bad_parent_dir(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-bad-parent"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=MagicMock(),
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "add",
|
||||
"parent_dir": "/abs-path",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_add_no_files_returns_400(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-add-nofile"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=MagicMock(),
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={"source_id": str(src["id"]), "operation": "add"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_add_files_success(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-add-ok"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data/src")
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_task = MagicMock(id="reingest-1")
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), patch(
|
||||
"application.api.user.tasks.reingest_source_task.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "add",
|
||||
"file": (io.BytesIO(b"content"), "new.txt"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 200
|
||||
assert response.json["success"] is True
|
||||
assert "new.txt" in response.json["added_files"]
|
||||
|
||||
def test_remove_missing_file_paths_returns_400(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rm-nolist"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=MagicMock(),
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={"source_id": str(src["id"]), "operation": "remove"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_remove_invalid_json_file_paths(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rm-bad"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=MagicMock(),
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove",
|
||||
"file_paths": "not-json",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_remove_rejects_path_traversal(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rm-trav"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=MagicMock(),
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove",
|
||||
"file_paths": json.dumps(["../escape.txt"]),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_remove_files_success(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rm-ok"
|
||||
src = _seed_source(
|
||||
pg_conn, user=user,
|
||||
file_path="/data/src",
|
||||
file_name_map={"a.txt": "Original A.txt"},
|
||||
)
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_storage.file_exists.return_value = True
|
||||
fake_task = MagicMock(id="reingest-rm")
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), patch(
|
||||
"application.api.user.tasks.reingest_source_task.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove",
|
||||
"file_paths": json.dumps(["a.txt"]),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 200
|
||||
assert "a.txt" in response.json["removed_files"]
|
||||
|
||||
def test_remove_directory_missing_path(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rmdir-missing"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=MagicMock(),
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove_directory",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_remove_directory_rejects_bad_path(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rmdir-bad"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=MagicMock(),
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove_directory",
|
||||
"directory_path": "../escape",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_remove_directory_404_when_not_directory(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rmdir-notdir"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_storage.is_directory.return_value = False
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove_directory",
|
||||
"directory_path": "subdir",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_remove_directory_success(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rmdir-ok"
|
||||
src = _seed_source(
|
||||
pg_conn, user=user, file_path="/data",
|
||||
file_name_map={"sub/a.txt": "A"},
|
||||
)
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_storage.is_directory.return_value = True
|
||||
fake_storage.remove_directory.return_value = True
|
||||
fake_task = MagicMock(id="reingest-dir")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), patch(
|
||||
"application.api.user.tasks.reingest_source_task.apply_async",
|
||||
return_value=fake_task,
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove_directory",
|
||||
"directory_path": "sub",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 200
|
||||
assert response.json["removed_directory"] == "sub"
|
||||
|
||||
def test_remove_directory_storage_failure_returns_500(self, app, pg_conn):
|
||||
from application.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
user = "u-rmdir-fail"
|
||||
src = _seed_source(pg_conn, user=user, file_path="/data")
|
||||
|
||||
fake_storage = MagicMock()
|
||||
fake_storage.is_directory.return_value = True
|
||||
fake_storage.remove_directory.return_value = False
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=fake_storage,
|
||||
), app.test_request_context(
|
||||
"/api/manage_source_files", method="POST",
|
||||
data={
|
||||
"source_id": str(src["id"]),
|
||||
"operation": "remove_directory",
|
||||
"directory_path": "sub",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ManageSourceFiles().post()
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
class TestTaskStatus:
|
||||
def test_returns_400_missing_task_id(self, app):
|
||||
from application.api.user.sources.upload import TaskStatus
|
||||
|
||||
with app.test_request_context("/api/task_status"):
|
||||
response = TaskStatus().get()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_task_status(self, app):
|
||||
from application.api.user.sources.upload import TaskStatus
|
||||
|
||||
fake_task = MagicMock()
|
||||
fake_task.status = "SUCCESS"
|
||||
fake_task.info = {"result": "ok"}
|
||||
|
||||
fake_celery = MagicMock()
|
||||
fake_celery.AsyncResult.return_value = fake_task
|
||||
|
||||
with patch(
|
||||
"application.celery_init.celery", fake_celery
|
||||
), app.test_request_context("/api/task_status?task_id=t-123"):
|
||||
response = TaskStatus().get()
|
||||
assert response.status_code == 200
|
||||
assert response.json["status"] == "SUCCESS"
|
||||
|
||||
def test_pending_without_workers_returns_503(self, app):
|
||||
from application.api.user.sources.upload import TaskStatus
|
||||
|
||||
fake_task = MagicMock()
|
||||
fake_task.status = "PENDING"
|
||||
fake_task.info = None
|
||||
|
||||
fake_inspect = MagicMock()
|
||||
fake_inspect.ping.return_value = None # no workers
|
||||
|
||||
fake_celery = MagicMock()
|
||||
fake_celery.AsyncResult.return_value = fake_task
|
||||
fake_celery.control.inspect.return_value = fake_inspect
|
||||
|
||||
with patch(
|
||||
"application.celery_init.celery", fake_celery
|
||||
), app.test_request_context("/api/task_status?task_id=t-999"):
|
||||
response = TaskStatus().get()
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_exception_returns_400(self, app):
|
||||
from application.api.user.sources.upload import TaskStatus
|
||||
|
||||
fake_celery = MagicMock()
|
||||
fake_celery.AsyncResult.side_effect = RuntimeError("boom")
|
||||
|
||||
with patch(
|
||||
"application.celery_init.celery", fake_celery
|
||||
), app.test_request_context("/api/task_status?task_id=t-err"):
|
||||
response = TaskStatus().get()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_non_serializable_info_gets_stringified(self, app):
|
||||
from application.api.user.sources.upload import TaskStatus
|
||||
|
||||
class WeirdObj:
|
||||
def __str__(self):
|
||||
return "weird-str"
|
||||
|
||||
fake_task = MagicMock()
|
||||
fake_task.status = "SUCCESS"
|
||||
fake_task.info = WeirdObj()
|
||||
|
||||
fake_celery = MagicMock()
|
||||
fake_celery.AsyncResult.return_value = fake_task
|
||||
|
||||
with patch(
|
||||
"application.celery_init.celery", fake_celery
|
||||
), app.test_request_context("/api/task_status?task_id=t-weird"):
|
||||
response = TaskStatus().get()
|
||||
assert response.status_code == 200
|
||||
assert response.json["result"] == "weird-str"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,602 @@
|
||||
"""Tests for the wiki source routes in application/api/user/sources/routes.py."""
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.routes.db_session", _yield
|
||||
), patch(
|
||||
"application.api.user.sources.routes.db_readonly", _yield
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _grant_team_access(pg_conn, owner, member, source_id, access_level):
|
||||
from application.storage.db.repositories.team_members import (
|
||||
TeamMembersRepository,
|
||||
)
|
||||
from application.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from application.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
team = TeamsRepository(pg_conn).create(
|
||||
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
|
||||
)
|
||||
TeamMembersRepository(pg_conn).add_member(
|
||||
team["id"], member, role="team_member"
|
||||
)
|
||||
TeamResourceGrantsRepository(pg_conn).grant(
|
||||
team["id"], "source", source_id, owner_id=owner, granted_by=owner,
|
||||
access_level=access_level,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateWikiSource:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.routes import CreateWikiSource
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/sources/wiki", method="POST", json={"name": "w"}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = CreateWikiSource().post()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_400_missing_name(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import CreateWikiSource
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/sources/wiki", method="POST", json={}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u-wiki"}
|
||||
response = CreateWikiSource().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_creates_row_without_ingest(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import CreateWikiSource
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-wiki-create"
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.reembed_wiki_page.delay"
|
||||
) as mock_reembed, patch(
|
||||
"application.api.user.tasks.ingest.delay"
|
||||
) as mock_ingest, patch(
|
||||
"application.api.user.tasks.reingest_source_task.delay"
|
||||
) as mock_reingest, app.test_request_context(
|
||||
"/api/sources/wiki", method="POST", json={"name": "My Wiki"}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = CreateWikiSource().post()
|
||||
|
||||
assert response.status_code == 200
|
||||
source_id = response.json["source_id"]
|
||||
row = SourcesRepository(pg_conn).get_any(source_id, user)
|
||||
assert row["type"] == "wiki"
|
||||
assert row["config"]["kind"] == "wiki"
|
||||
assert int(row["tokens"]) == 0
|
||||
# No seed content → no re-embed, and never any ingest/reingest task.
|
||||
mock_reembed.assert_not_called()
|
||||
mock_ingest.assert_not_called()
|
||||
mock_reingest.assert_not_called()
|
||||
|
||||
def test_seed_page_roundtrips_and_only_seed_reembeds(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import (
|
||||
CreateWikiSource,
|
||||
WikiPage,
|
||||
WIKI_INDEX_PATH,
|
||||
)
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-wiki-seed"
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.reembed_wiki_page.delay"
|
||||
) as mock_reembed, patch(
|
||||
"application.api.user.tasks.ingest.delay"
|
||||
) as mock_ingest, patch(
|
||||
"application.api.user.tasks.reingest_source_task.delay"
|
||||
) as mock_reingest, app.test_request_context(
|
||||
"/api/sources/wiki",
|
||||
method="POST",
|
||||
json={"name": "Seeded", "initial_content": "# Hello\nworld"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = CreateWikiSource().post()
|
||||
|
||||
assert response.status_code == 200
|
||||
source_id = response.json["source_id"]
|
||||
row = SourcesRepository(pg_conn).get_any(source_id, user)
|
||||
assert int(row["tokens"]) > 0
|
||||
# Re-embed fires once, only for the seed page; no ingest/reingest ever.
|
||||
mock_reembed.assert_called_once()
|
||||
assert mock_reembed.call_args.args[0] == source_id
|
||||
assert mock_reembed.call_args.args[1] == WIKI_INDEX_PATH
|
||||
assert mock_reembed.call_args.kwargs["user"] == user
|
||||
mock_ingest.assert_not_called()
|
||||
mock_reingest.assert_not_called()
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{source_id}/wiki/page?path={WIKI_INDEX_PATH}"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
page_response = WikiPage().get(source_id)
|
||||
assert page_response.status_code == 200
|
||||
assert page_response.json["page"]["content"] == "# Hello\nworld"
|
||||
|
||||
|
||||
class TestWikiPages:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.routes import WikiPages
|
||||
|
||||
with app.test_request_context("/api/sources/x/wiki/pages"):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = WikiPages().get("x")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_owner_lists_pages(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPages
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
|
||||
|
||||
user = "u-wiki-list"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki-list", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
WikiPagesRepository(pg_conn).upsert(
|
||||
sid, "/index.md", "root", updated_by=user, updated_via="agent"
|
||||
)
|
||||
WikiPagesRepository(pg_conn).upsert(sid, "/docs/a.md", "a", updated_by=user)
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/pages"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPages().get(sid)
|
||||
assert response.status_code == 200
|
||||
paths = {p["path"] for p in response.json["pages"]}
|
||||
assert paths == {"/index.md", "/docs/a.md"}
|
||||
via = {p["path"]: p["updated_via"] for p in response.json["pages"]}
|
||||
assert via["/index.md"] == "agent"
|
||||
|
||||
def test_non_owner_without_grant_404(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPages
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
owner = "u-wiki-owner"
|
||||
stranger = "u-wiki-stranger"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki-private", user_id=owner, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/pages"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": stranger}
|
||||
response = WikiPages().get(sid)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_team_viewer_can_read(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPages
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
|
||||
|
||||
owner = "alice-wiki"
|
||||
viewer = "bob-wiki-viewer"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki-shared", user_id=owner, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
WikiPagesRepository(pg_conn).upsert(sid, "/index.md", "x", updated_by=owner)
|
||||
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/pages"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": viewer}
|
||||
response = WikiPages().get(sid)
|
||||
assert response.status_code == 200
|
||||
assert {p["path"] for p in response.json["pages"]} == {"/index.md"}
|
||||
|
||||
|
||||
class TestWikiPage:
|
||||
def test_returns_400_missing_path(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-wiki-page-nopath"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPage().get(sid)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_400_traversal_path(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-wiki-page-traversal"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page?path=/../secret.md"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPage().get(sid)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_404_unknown_page(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-wiki-page-missing"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page?path=/nope.md"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPage().get(sid)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_returns_provenance_and_version(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
|
||||
|
||||
user = "u-wiki-page-provenance"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
WikiPagesRepository(pg_conn).upsert(
|
||||
sid, "/index.md", "x", updated_by=user, updated_via="agent"
|
||||
)
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page?path=/index.md"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPage().get(sid)
|
||||
assert response.status_code == 200
|
||||
page = response.json["page"]
|
||||
assert page["updated_via"] == "agent"
|
||||
assert page["updated_by"] == user
|
||||
assert page["version"] == 1
|
||||
assert page["updated_at"] is not None
|
||||
|
||||
def test_non_owner_without_grant_404(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
|
||||
|
||||
owner = "u-wiki-pg-owner"
|
||||
stranger = "u-wiki-pg-stranger"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
WikiPagesRepository(pg_conn).upsert(sid, "/index.md", "secret", updated_by=owner)
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page?path=/index.md"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": stranger}
|
||||
response = WikiPage().get(sid)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestConvertSourceToWiki:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.routes import ConvertSourceToWiki
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/sources/x/wiki/convert", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = ConvertSourceToWiki().post("x")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_blank_source_enabled_inline_no_task(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import ConvertSourceToWiki
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.source_config import SourceConfig
|
||||
|
||||
user = "u-convert-blank"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"blank", user_id=user, type="file", directory_structure={}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.convert_source_to_wiki.delay"
|
||||
) as mock_convert, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/convert", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ConvertSourceToWiki().post(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["converted"] is False
|
||||
assert response.json["enabled"] is True
|
||||
mock_convert.assert_not_called()
|
||||
got = SourcesRepository(pg_conn).get_any(sid, user)
|
||||
cfg = SourceConfig.parse(got.get("config"))
|
||||
assert cfg.kind == "wiki"
|
||||
assert cfg.retrieval.exposure == "agentic_tool"
|
||||
|
||||
def test_fileful_source_enqueues_task(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import ConvertSourceToWiki
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-convert-files"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"files", user_id=user, type="file",
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
fake_task = type("T", (), {"id": "task-xyz"})()
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.convert_source_to_wiki.delay",
|
||||
return_value=fake_task,
|
||||
) as mock_convert, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/convert", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ConvertSourceToWiki().post(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["task_id"] == "task-xyz"
|
||||
mock_convert.assert_called_once()
|
||||
assert mock_convert.call_args.kwargs["source_id"] == sid
|
||||
assert mock_convert.call_args.kwargs["user"] == user
|
||||
assert mock_convert.call_args.kwargs["idempotency_key"] == (
|
||||
f"convert-wiki:{sid}"
|
||||
)
|
||||
|
||||
def test_in_progress_ingest_rejected_409(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import ConvertSourceToWiki
|
||||
from application.storage.db.repositories.ingest_chunk_progress import (
|
||||
IngestChunkProgressRepository,
|
||||
)
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-convert-ingesting"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"ingesting", user_id=user, type="file",
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
sid = str(src["id"])
|
||||
# An active embed in flight (embedded 0 of 5) → "processing".
|
||||
IngestChunkProgressRepository(pg_conn).init_progress(sid, 5)
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.convert_source_to_wiki.delay"
|
||||
) as mock_convert, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/convert", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = ConvertSourceToWiki().post(sid)
|
||||
|
||||
assert response.status_code == 409
|
||||
mock_convert.assert_not_called()
|
||||
|
||||
def test_viewer_rejected_403(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import ConvertSourceToWiki
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
owner = "alice-convert"
|
||||
viewer = "bob-convert-viewer"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"files", user_id=owner, type="file",
|
||||
directory_structure={"a.md": {"type": "text/markdown"}},
|
||||
)
|
||||
sid = str(src["id"])
|
||||
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.convert_source_to_wiki.delay"
|
||||
) as mock_convert, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/convert", method="POST"
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": viewer}
|
||||
response = ConvertSourceToWiki().post(sid)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_convert.assert_not_called()
|
||||
|
||||
|
||||
class TestWikiPageEdit:
|
||||
def test_returns_401_unauthenticated(self, app):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/sources/x/wiki/page", method="PUT", json={}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = None
|
||||
response = WikiPage().put("x")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_owner_writes_and_enqueues_reembed(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
|
||||
|
||||
user = "u-edit-owner"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.reembed_wiki_page.delay"
|
||||
) as mock_reembed, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page",
|
||||
method="PUT",
|
||||
json={"path": "/notes.md", "content": "body"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPage().put(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["page"]["path"] == "/notes.md"
|
||||
assert response.json["page"]["version"] is not None
|
||||
assert response.json["page"]["updated_via"] == "human"
|
||||
page = WikiPagesRepository(pg_conn).get_by_path(sid, "/notes.md")
|
||||
assert page["content"] == "body"
|
||||
assert page["updated_via"] == "human"
|
||||
mock_reembed.assert_called_once()
|
||||
assert mock_reembed.call_args.args[1] == "/notes.md"
|
||||
assert mock_reembed.call_args.kwargs["user"] == user
|
||||
|
||||
def test_team_editor_reembeds_as_owner(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
owner = "alice-edit"
|
||||
editor = "bob-edit-editor"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
_grant_team_access(pg_conn, owner, editor, sid, "editor")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.reembed_wiki_page.delay"
|
||||
) as mock_reembed, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page",
|
||||
method="PUT",
|
||||
json={"path": "/e.md", "content": "edited"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": editor}
|
||||
response = WikiPage().put(sid)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Re-embed runs AS the owner so the owner-scoped worker load resolves.
|
||||
assert mock_reembed.call_args.kwargs["user"] == owner
|
||||
|
||||
def test_stale_version_returns_409(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
|
||||
|
||||
user = "u-edit-conflict"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
WikiPagesRepository(pg_conn).upsert(sid, "/c.md", "v1", updated_by=user)
|
||||
# Bump to version 2 so a stale expected_version=1 loses the race.
|
||||
WikiPagesRepository(pg_conn).upsert(sid, "/c.md", "v2", updated_by=user)
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.reembed_wiki_page.delay"
|
||||
) as mock_reembed, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page",
|
||||
method="PUT",
|
||||
json={"path": "/c.md", "content": "v3", "expected_version": 1},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPage().put(sid)
|
||||
|
||||
assert response.status_code == 409
|
||||
mock_reembed.assert_not_called()
|
||||
|
||||
def test_traversal_path_returns_400(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-edit-traversal"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=user, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.reembed_wiki_page.delay"
|
||||
), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page",
|
||||
method="PUT",
|
||||
json={"path": "/../secret.md", "content": "x"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
response = WikiPage().put(sid)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_viewer_rejected_403(self, app, pg_conn):
|
||||
from application.api.user.sources.routes import WikiPage
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
owner = "alice-edit-viewer"
|
||||
viewer = "bob-edit-viewer"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
|
||||
)
|
||||
sid = str(src["id"])
|
||||
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
|
||||
|
||||
with _patch_db(pg_conn), patch(
|
||||
"application.api.user.sources.routes.reembed_wiki_page.delay"
|
||||
) as mock_reembed, app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/page",
|
||||
method="PUT",
|
||||
json={"path": "/v.md", "content": "x"},
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": viewer}
|
||||
response = WikiPage().put(sid)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_reembed.assert_not_called()
|
||||
Reference in New Issue
Block a user