This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Transport boundary tests."""
|
||||
@@ -0,0 +1,264 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
|
||||
PASSWORD = "initial-password-fixture"
|
||||
|
||||
|
||||
def test_password_accounts_owned_agent_keys_and_session_lifecycle(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
operator = store.provision(
|
||||
credential_id="operator", principal_id="operator", roles=("admin",)
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=store,
|
||||
account_identity=store,
|
||||
credential_administration=store,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
transport = httpx2.ASGITransport(app=app)
|
||||
operator_headers = {"Authorization": f"Bearer {operator}"}
|
||||
async with httpx2.AsyncClient(transport=transport, base_url="https://gewu.test") as admin:
|
||||
body = {
|
||||
"username": "alice",
|
||||
"password": PASSWORD,
|
||||
"role": "editor",
|
||||
"project_ids": ["pilot"],
|
||||
}
|
||||
assert (await admin.post("/v1/admin/users", json=body)).status_code == 401
|
||||
created = await admin.post("/v1/admin/users", json=body, headers=operator_headers)
|
||||
assert created.status_code == 201 and PASSWORD not in created.text
|
||||
assert (
|
||||
await admin.post("/v1/admin/users", json=body, headers=operator_headers)
|
||||
).status_code == 409
|
||||
for username, role in (("bob", "editor"), ("viewer", "viewer"), ("admin", "admin")):
|
||||
assert (
|
||||
await admin.post(
|
||||
"/v1/admin/users",
|
||||
headers=operator_headers,
|
||||
json={**body, "username": username, "role": role},
|
||||
)
|
||||
).status_code == 201
|
||||
short_secret = "shortsecret"
|
||||
invalid = await admin.post(
|
||||
"/v1/admin/users",
|
||||
headers=operator_headers,
|
||||
json={**body, "username": "invalid", "password": short_secret},
|
||||
)
|
||||
assert invalid.status_code == 422 and short_secret not in invalid.text
|
||||
listing = await admin.get("/v1/admin/users", headers=operator_headers)
|
||||
assert "password_hash" not in listing.text and PASSWORD not in listing.text
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as alice:
|
||||
login = await alice.post(
|
||||
"/v1/users/session",
|
||||
json={"username": "alice", "password": PASSWORD},
|
||||
headers={"Origin": "https://gewu.test"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
assert "secure" in login.headers["set-cookie"].lower()
|
||||
csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]}
|
||||
assert (await alice.get("/v1/me")).json()["principal_id"] == "user:alice"
|
||||
assert (await alice.get("/v1/admin/users")).status_code == 403
|
||||
assert (
|
||||
await alice.post("/v1/admin/users", headers=csrf, json=body)
|
||||
).status_code == 403
|
||||
agent_body = {"name": "codex-laptop", "project_ids": ["pilot"]}
|
||||
assert (
|
||||
await alice.post("/v1/me/agent-credentials", json=agent_body)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers={**csrf, "Origin": "https://attacker"},
|
||||
json=agent_body,
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers=csrf,
|
||||
json={**agent_body, "project_ids": ["other"]},
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers=csrf,
|
||||
json={**agent_body, "role": "admin"},
|
||||
)
|
||||
).status_code == 422
|
||||
agent = await alice.post("/v1/me/agent-credentials", headers=csrf, json=agent_body)
|
||||
assert agent.status_code == 201
|
||||
key_id, token = agent.json()["credential_id"], agent.json()["token"]
|
||||
agent_principal = store.authenticate(token)
|
||||
assert agent_principal and agent_principal.can_submit_project("pilot")
|
||||
assert not agent_principal.can_submit_project("other")
|
||||
assert (await alice.get("/v1/me/agent-credentials")).json()[0][
|
||||
"credential_id"
|
||||
] == key_id
|
||||
assert token not in (await alice.get("/v1/me/agent-credentials")).text
|
||||
assert (
|
||||
await alice.get("/v1/me", headers={"Authorization": f"Bearer {token}"})
|
||||
).status_code == 403
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as bob:
|
||||
bob_login = await bob.post(
|
||||
"/v1/users/session", json={"username": "bob", "password": PASSWORD}
|
||||
)
|
||||
bob_csrf = {"X-GEWU-CSRF": bob_login.json()["csrf_token"]}
|
||||
assert (await bob.get("/v1/me/agent-credentials")).json() == []
|
||||
assert (
|
||||
await bob.delete(f"/v1/me/agent-credentials/{key_id}", headers=bob_csrf)
|
||||
).status_code == 404
|
||||
assert store.authenticate(token) is not None
|
||||
bob_agent = await bob.post(
|
||||
"/v1/me/agent-credentials", headers=bob_csrf, json=agent_body
|
||||
)
|
||||
rotated = store.rotate(bob_agent.json()["credential_id"], "rotated-bob-key")
|
||||
assert (await bob.get("/v1/me/agent-credentials")).json()[-1][
|
||||
"owner_principal_id"
|
||||
] == "user:bob"
|
||||
assert (
|
||||
await admin.delete(
|
||||
"/v1/admin/credentials/account:bob", headers=operator_headers
|
||||
)
|
||||
).status_code == 200
|
||||
assert store.authenticate(rotated) is None
|
||||
assert (await bob.get("/v1/me")).status_code == 401
|
||||
|
||||
password_body = {
|
||||
"current_password": PASSWORD,
|
||||
"new_password": "changed-password-fixture",
|
||||
}
|
||||
assert (await alice.post("/v1/me/password", json=password_body)).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/password",
|
||||
headers=csrf,
|
||||
json={**password_body, "current_password": "wrong"},
|
||||
)
|
||||
).status_code == 403
|
||||
second_session = store.login_account("alice", PASSWORD)
|
||||
assert second_session
|
||||
changed = await alice.post("/v1/me/password", headers=csrf, json=password_body)
|
||||
assert changed.status_code == 200
|
||||
assert store.authenticate_browser_session(second_session.session_token) is None
|
||||
assert (await alice.get("/v1/me")).status_code == 401
|
||||
assert store.login_account("alice", PASSWORD) is None
|
||||
assert store.authenticate(token) is not None
|
||||
relogin = await alice.post(
|
||||
"/v1/users/session",
|
||||
json={"username": "alice", "password": password_body["new_password"]},
|
||||
)
|
||||
csrf = {"X-GEWU-CSRF": relogin.json()["csrf_token"]}
|
||||
assert (
|
||||
await alice.delete(f"/v1/me/agent-credentials/{key_id}", headers=csrf)
|
||||
).status_code == 200
|
||||
assert store.authenticate(token) is None
|
||||
another = await alice.post(
|
||||
"/v1/me/agent-credentials", headers=csrf, json=agent_body
|
||||
)
|
||||
assert (
|
||||
await admin.delete("/v1/admin/users/alice", headers=operator_headers)
|
||||
).status_code == 200
|
||||
assert store.authenticate(another.json()["token"]) is None
|
||||
assert (await alice.get("/v1/me")).status_code == 401
|
||||
assert store.login_account("alice", password_body["new_password"]) is None
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as viewer:
|
||||
login = await viewer.post(
|
||||
"/v1/users/session", json={"username": "viewer", "password": PASSWORD}
|
||||
)
|
||||
csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]}
|
||||
assert (
|
||||
await viewer.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers=csrf,
|
||||
json={"name": "agent", "project_ids": ["pilot"]},
|
||||
)
|
||||
).status_code == 403
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as human_admin:
|
||||
login = await human_admin.post(
|
||||
"/v1/users/session", json={"username": "admin", "password": PASSWORD}
|
||||
)
|
||||
csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]}
|
||||
assert (
|
||||
await human_admin.delete("/v1/admin/users/admin", headers=csrf)
|
||||
).status_code == 403
|
||||
with sqlite3.connect(store.database) as db:
|
||||
assert (
|
||||
db.execute("SELECT password_hash FROM user_accounts WHERE username='bob'")
|
||||
.fetchone()[0]
|
||||
.startswith("$argon2id$")
|
||||
)
|
||||
assert (
|
||||
PASSWORD not in (await admin.get("/v1/admin/events", headers=operator_headers)).text
|
||||
)
|
||||
assert len(SQLiteIdentityStore(store.database).list_accounts(limit=50, offset=0)) == 4
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_password_login_origin_tls_size_and_throttle(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
store.create_account(
|
||||
username="alice",
|
||||
password=PASSWORD,
|
||||
role="editor",
|
||||
project_ids=("pilot",),
|
||||
actor="operator",
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=store,
|
||||
account_identity=store,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://gewu.test"
|
||||
) as http:
|
||||
body = {"username": "alice", "password": PASSWORD}
|
||||
assert (await http.post("/v1/users/session", json=body)).status_code == 403
|
||||
assert (
|
||||
await http.post(
|
||||
"https://gewu.test/v1/users/session",
|
||||
json=body,
|
||||
headers={"Origin": "https://attacker.invalid"},
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.post("https://gewu.test/v1/users/session", content=b" " * 4097)
|
||||
).status_code == 413
|
||||
for _ in range(5):
|
||||
assert (
|
||||
await http.post(
|
||||
"https://gewu.test/v1/users/session", json={**body, "password": "wrong"}
|
||||
)
|
||||
).status_code == 401
|
||||
assert (
|
||||
await http.post("https://gewu.test/v1/users/session", json=body)
|
||||
).status_code == 401
|
||||
with store._connect() as db:
|
||||
db.execute("UPDATE account_login_failures SET failed_at=0")
|
||||
assert (
|
||||
await http.post("https://gewu.test/v1/users/session", json=body)
|
||||
).status_code == 200
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.export_agent_contract import PACK, build_contract
|
||||
|
||||
|
||||
def test_pack_mcp_contract_matches_the_live_server_and_generated_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
snapshot_path = PACK / "contracts" / "service-v1.json"
|
||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
actual = build_contract(tmp_path)
|
||||
assert actual == snapshot
|
||||
assert (PACK / "contracts" / "catalog-v1.json").read_bytes() == (
|
||||
Path(__file__).resolve().parents[2] / "schema" / "v1" / "catalog.json"
|
||||
).read_bytes()
|
||||
|
||||
packaged_schemas = PACK / "contracts"
|
||||
for schema in snapshot["record_schemas"]:
|
||||
schema_path = packaged_schemas / schema["path"]
|
||||
assert schema_path.is_file()
|
||||
assert (
|
||||
schema_path.read_bytes()
|
||||
== (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "schema"
|
||||
/ "v1"
|
||||
/ "records"
|
||||
/ Path(schema["path"]).name
|
||||
).read_bytes()
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.text_extractor import TextMarkdownExtractor
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.uploads import LocalArtifactStore
|
||||
from gewu.bootstrap.app import create_app
|
||||
from tests.domain.proposal_fixtures import NODE, SOURCE_REVISION
|
||||
|
||||
|
||||
def test_authenticated_scoped_canonical_node_read(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
|
||||
node_path = f"projects/{project_key}/nodes/{node_key}.yaml"
|
||||
source_path = (
|
||||
f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()}.yaml"
|
||||
)
|
||||
artifact_store = LocalArtifactStore(tmp_path / "artifacts")
|
||||
source_bytes = b"# Archived source\nA precise citation.\n"
|
||||
source_digest = hashlib.sha256(source_bytes).hexdigest()
|
||||
upload_id = artifact_store.begin(
|
||||
filename="source.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(source_bytes),
|
||||
expected_sha256=source_digest,
|
||||
owner_id="agent-pilot",
|
||||
project_id=NODE.project_id,
|
||||
).upload_id
|
||||
artifact_store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=source_bytes,
|
||||
chunk_sha256=source_digest,
|
||||
owner_id="agent-pilot",
|
||||
)
|
||||
artifact_store.finalize(upload_id, owner_id="agent-pilot")
|
||||
extraction = TextMarkdownExtractor().extract(
|
||||
SOURCE_REVISION.revision_id, source_bytes, "text/markdown"
|
||||
)
|
||||
artifact_store.save_extraction(
|
||||
extraction, owner_id="agent-pilot", project_id=NODE.project_id
|
||||
)
|
||||
archived_source = SOURCE_REVISION.model_copy(
|
||||
update={"artifact_sha256": source_digest, "byte_length": len(source_bytes)}
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={node_path: yaml.safe_dump(NODE.model_dump(mode="json")).encode()},
|
||||
message="Publish test node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
updated_node = NODE.model_copy(update={"summary": "A revised accepted summary."})
|
||||
restricted_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "video:private",
|
||||
"revision_id": "video:private:r1",
|
||||
"ingested_for": ("interactive-video",),
|
||||
}
|
||||
)
|
||||
restricted_source_path = (
|
||||
f"sources/{hashlib.sha256(restricted_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(restricted_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
)
|
||||
reference_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "dome:reference-only",
|
||||
"revision_id": "dome:reference-only:r1",
|
||||
"artifact_sha256": None,
|
||||
"media_type": "application/octet-stream",
|
||||
"byte_length": 0,
|
||||
"original_url": "https://research.example/unavailable-paper",
|
||||
"ingested_for": (NODE.project_id,),
|
||||
"archive_status": "reference-only",
|
||||
}
|
||||
)
|
||||
reference_source_path = (
|
||||
f"sources/{hashlib.sha256(reference_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(reference_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
node_path: yaml.safe_dump(updated_node.model_dump(mode="json")).encode(),
|
||||
source_path: yaml.safe_dump(archived_source.model_dump(mode="json")).encode(),
|
||||
restricted_source_path: yaml.safe_dump(
|
||||
restricted_source.model_dump(mode="json")
|
||||
).encode(),
|
||||
reference_source_path: yaml.safe_dump(
|
||||
reference_source.model_dump(mode="json")
|
||||
).encode(),
|
||||
},
|
||||
message="Revise node and register source",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={"metadata/unrelated.yaml": b"accepted but unrelated\n"},
|
||||
message="Advance repository without changing the node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="agent-key",
|
||||
principal_id="agent-pilot",
|
||||
roles=("agent",),
|
||||
project_ids=(NODE.project_id,),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://gewu.test"
|
||||
) as client:
|
||||
path = f"/v1/projects/{NODE.project_id}/nodes/{NODE.node_id}"
|
||||
assert (await client.get(path)).status_code == 401
|
||||
malformed_id = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/nodes/not%20a%20record-id",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert malformed_id.status_code == 422
|
||||
denied = await client.get(
|
||||
f"/v1/projects/interactive-video/nodes/{NODE.node_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
accepted = await client.get(path, headers={"Authorization": f"Bearer {token}"})
|
||||
assert accepted.status_code == 200
|
||||
envelope = accepted.json()
|
||||
assert envelope["retrieval_mode"] == "canonical"
|
||||
assert envelope["canonical_revision"] == repository.head()
|
||||
assert envelope["data"][0]["node_id"] == NODE.node_id
|
||||
assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
history = await client.get(
|
||||
f"{path}/history", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert history.status_code == 200
|
||||
history_data = history.json()["data"]
|
||||
assert len(history_data) == 2
|
||||
assert history_data[0]["node"]["summary"] == updated_node.summary
|
||||
latest_node_revision = history_data[0]["revision"]
|
||||
assert latest_node_revision != repository.head()
|
||||
latest_file_version = await client.get(
|
||||
f"{path}?revision={latest_node_revision}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert latest_file_version.status_code == 200
|
||||
assert latest_file_version.json()["canonical_revision"] == latest_node_revision
|
||||
assert latest_file_version.json()["data"][0]["summary"] == updated_node.summary
|
||||
historical_revision = history_data[-1]["revision"]
|
||||
historical = await client.get(
|
||||
f"{path}?revision={historical_revision}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert historical.status_code == 200, historical.text
|
||||
assert historical.json()["canonical_revision"] == historical_revision
|
||||
assert historical.json()["data"][0]["summary"] == NODE.summary
|
||||
assert historical.json()["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
malformed_revision = await client.get(
|
||||
f"{path}?revision=not-a-commit",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert malformed_revision.status_code == 422
|
||||
unknown_revision = await client.get(
|
||||
f"{path}?revision={'a' * 40}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert unknown_revision.status_code == 404
|
||||
source = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert source.status_code == 200
|
||||
assert source.json()["data"][0]["revision_id"] == SOURCE_REVISION.revision_id
|
||||
reference_path = (
|
||||
f"/v1/projects/{NODE.project_id}/sources/{reference_source.revision_id}"
|
||||
)
|
||||
reference = await client.get(
|
||||
reference_path, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert reference.status_code == 200
|
||||
assert reference.json()["data"][0]["artifact_sha256"] is None
|
||||
diagnostics_path = "/v1/diagnostics?projects=dome-performance"
|
||||
assert (await client.get("/v1/diagnostics")).status_code == 401
|
||||
diagnostic_response = await client.get(
|
||||
diagnostics_path, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert diagnostic_response.status_code == 200
|
||||
diagnostic_body = diagnostic_response.json()
|
||||
catalog_path = Path(__file__).resolve().parents[2] / "schema/v1/catalog.json"
|
||||
assert (
|
||||
diagnostic_body["catalog_sha256"]
|
||||
== hashlib.sha256(catalog_path.read_bytes()).hexdigest()
|
||||
)
|
||||
assert diagnostic_body["selected_scope"]["projects"] == [NODE.project_id]
|
||||
assert diagnostic_body["index_state"] == "not_configured"
|
||||
assert diagnostic_body["max_upload_bytes"] == 50 * 1024 * 1024
|
||||
assert "get_source_artifact" in diagnostic_body["authorized_operations"]
|
||||
assert token not in diagnostic_response.text
|
||||
assert "principal_id" not in diagnostic_body
|
||||
forbidden_diagnostic_scope = await client.get(
|
||||
"/v1/diagnostics?projects=interactive-video",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert forbidden_diagnostic_scope.status_code == 403
|
||||
forbidden_shared_scope = await client.get(
|
||||
diagnostics_path + "&include_shared=true",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert forbidden_shared_scope.status_code == 403
|
||||
artifact_path = (
|
||||
f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}/artifact"
|
||||
)
|
||||
assert (await client.get(artifact_path)).status_code == 401
|
||||
archived = await client.get(artifact_path, headers={"Authorization": f"Bearer {token}"})
|
||||
assert archived.status_code == 200
|
||||
assert archived.content == source_bytes
|
||||
assert archived.headers["etag"] == f'"{source_digest}"'
|
||||
assert archived.headers["x-content-type-options"] == "nosniff"
|
||||
assert archived.headers["cache-control"] == "private, no-store"
|
||||
assert archived.headers["content-security-policy"] == "default-src 'none'; sandbox"
|
||||
missing_artifact = await client.get(
|
||||
f"{reference_path}/artifact", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert missing_artifact.status_code == 404
|
||||
block = extraction.blocks[0]
|
||||
offset = block.normalized_text.index("precise")
|
||||
location_path = (
|
||||
f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}"
|
||||
f"/extractions/{extraction.extraction_id}/blocks/{block.block_id}"
|
||||
)
|
||||
location = await client.get(
|
||||
location_path,
|
||||
params={"offset": offset, "length": len("precise")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert location.status_code == 200
|
||||
assert location.json()["normalized_text"] == "precise"
|
||||
assert location.json()["original_text"] == "precise"
|
||||
denied_location = await client.get(
|
||||
location_path,
|
||||
params={"offset": offset, "length": len("precise")},
|
||||
)
|
||||
assert denied_location.status_code == 401
|
||||
restricted = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/sources/{restricted_source.revision_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert restricted.status_code == 404
|
||||
restricted_artifact = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/sources/{restricted_source.revision_id}/artifact",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert restricted_artifact.status_code == 404
|
||||
assert identities.revoke("agent-key")
|
||||
assert (
|
||||
await client.get(path, headers={"Authorization": f"Bearer {token}"})
|
||||
).status_code == 401
|
||||
assert (
|
||||
await client.get(artifact_path, headers={"Authorization": f"Bearer {token}"})
|
||||
).status_code == 401
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,189 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.search import CanonicalSearchService
|
||||
from gewu.bootstrap.app import create_app
|
||||
from tests.domain.proposal_fixtures import PROJECTS
|
||||
|
||||
|
||||
def test_browser_session_is_http_only_human_only_and_csrf_protected(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
viewer_key = identities.provision(
|
||||
credential_id="browser-viewer-key",
|
||||
principal_id="browser-viewer",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
agent_key = identities.provision(
|
||||
credential_id="browser-agent-key",
|
||||
principal_id="browser-agent",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
search = CanonicalSearchService(
|
||||
index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository),
|
||||
project_ids_provider=lambda: PROJECTS.keys(),
|
||||
canonical_head=repository.head,
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
search_service=search,
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://gewu.test",
|
||||
) as client:
|
||||
agent_session = await client.post(
|
||||
"/v1/session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {agent_key}",
|
||||
"Origin": "http://gewu.test",
|
||||
},
|
||||
)
|
||||
assert agent_session.status_code == 403
|
||||
|
||||
cross_origin = await client.post(
|
||||
"/v1/session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {viewer_key}",
|
||||
"Origin": "https://attacker.invalid",
|
||||
},
|
||||
)
|
||||
assert cross_origin.status_code == 403
|
||||
|
||||
created = await client.post(
|
||||
"/v1/session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {viewer_key}",
|
||||
"Origin": "http://gewu.test",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
session = created.json()
|
||||
cookie = created.cookies.get("gewu_session")
|
||||
assert cookie is not None
|
||||
assert "httponly" in created.headers["set-cookie"].lower()
|
||||
assert "samesite=strict" in created.headers["set-cookie"].lower()
|
||||
assert "max-age" not in created.headers["set-cookie"].lower()
|
||||
|
||||
projects = await client.get("/v1/projects")
|
||||
assert projects.status_code == 200
|
||||
assert projects.json() == ["dome-performance"]
|
||||
denied_csrf = await client.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert denied_csrf.status_code == 403
|
||||
|
||||
searched = await client.post(
|
||||
"/v1/search",
|
||||
headers={"X-GEWU-CSRF": session["csrf_token"]},
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert searched.status_code == 200, searched.text
|
||||
|
||||
signed_out = await client.delete(
|
||||
"/v1/session", headers={"X-GEWU-CSRF": session["csrf_token"]}
|
||||
)
|
||||
assert signed_out.status_code == 200
|
||||
assert (await client.get("/v1/projects")).status_code == 401
|
||||
assert identities.authenticate_browser_session(cookie) is None
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_session_restoration_preserves_other_tabs_and_original_expiry(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="admin", principal_id="operator", roles=("admin",)
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="https://gewu.test"
|
||||
) as http:
|
||||
headers = {"X-GEWU-Session": "restore", "Origin": "https://gewu.test"}
|
||||
assert (await http.get("/v1/session", headers=headers)).status_code == 401
|
||||
login = await http.post("/v1/session", headers={"Authorization": f"Bearer {token}"})
|
||||
original_csrf = login.json()["csrf_token"]
|
||||
cookie = login.cookies.get("gewu_session")
|
||||
assert cookie
|
||||
assert (await http.get("/v1/session")).status_code == 403
|
||||
assert (
|
||||
await http.get(
|
||||
"/v1/session", headers={**headers, "Origin": "https://attacker.invalid"}
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.get("/v1/session", headers={**headers, "Sec-Fetch-Site": "cross-site"})
|
||||
).status_code == 403
|
||||
restored = await http.get("/v1/session", headers=headers)
|
||||
assert restored.status_code == 200
|
||||
assert restored.json()["expires_at"] == login.json()["expires_at"]
|
||||
assert restored.json()["password_account"] is False
|
||||
assert restored.headers["cache-control"] == "private, no-store"
|
||||
assert "set-cookie" not in restored.headers
|
||||
assert cookie not in restored.text and token not in restored.text
|
||||
restored_csrf = restored.json()["csrf_token"]
|
||||
assert identities.browser_csrf_valid(cookie, original_csrf)
|
||||
assert identities.browser_csrf_valid(cookie, restored_csrf)
|
||||
second = await http.get("/v1/session", headers=headers)
|
||||
assert second.json()["csrf_token"] == restored_csrf
|
||||
assert identities.browser_csrf_valid(cookie, original_csrf)
|
||||
assert not identities.browser_csrf_valid(cookie, "gwc_invalid")
|
||||
signed_out = await http.delete("/v1/session", headers={"X-GEWU-CSRF": restored_csrf})
|
||||
assert signed_out.status_code == 200
|
||||
http.cookies.set("gewu_session", cookie)
|
||||
assert (await http.get("/v1/session", headers=headers)).status_code == 401
|
||||
|
||||
next_login = identities.create_browser_session(token)
|
||||
http.cookies.clear()
|
||||
http.cookies.set("gewu_session", next_login.session_token)
|
||||
identities.revoke("admin")
|
||||
assert (await http.get("/v1/session", headers=headers)).status_code == 401
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_password_session_restore_and_pre_upgrade_session_migration(tmp_path: Path) -> None:
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
identities.create_account(
|
||||
username="admin",
|
||||
password="fixture-password",
|
||||
role="admin",
|
||||
project_ids=(),
|
||||
actor="operator",
|
||||
)
|
||||
session = identities.login_account("admin", "fixture-password")
|
||||
assert session
|
||||
# Reconstruct the pre-upgrade schema with an issued session, then run the migration.
|
||||
with identities._connect() as db:
|
||||
db.execute("ALTER TABLE browser_sessions DROP COLUMN restored_csrf_digest")
|
||||
reopened = SQLiteIdentityStore(identities.database)
|
||||
restored = reopened.restore_browser_session(session.session_token)
|
||||
assert restored and restored.expires_at == session.expires_at
|
||||
assert reopened.browser_csrf_valid(session.session_token, session.csrf_token)
|
||||
assert reopened.browser_csrf_valid(session.session_token, restored.csrf_token)
|
||||
with reopened._connect() as db:
|
||||
db.execute("UPDATE browser_sessions SET expires_at=0")
|
||||
assert reopened.restore_browser_session(session.session_token) is None
|
||||
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
|
||||
|
||||
def test_admin_credentials_sessions_and_secret_free_audit(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
admin = store.provision(credential_id="admin", principal_id="operator", roles=("admin",))
|
||||
viewer = store.provision(credential_id="viewer", principal_id="reader", roles=("viewer",))
|
||||
mixed = store.provision(
|
||||
credential_id="mixed", principal_id="agent", roles=("agent", "admin")
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=store,
|
||||
credential_administration=store,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://test"
|
||||
) as http:
|
||||
assert (await http.get("/v1/admin/credentials")).status_code == 401
|
||||
for token in (viewer, mixed):
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
assert (await http.get("/v1/admin/credentials", headers=headers)).status_code == 403
|
||||
assert (await http.get("/v1/admin/events", headers=headers)).status_code == 403
|
||||
assert (
|
||||
await http.post(
|
||||
"/v1/admin/credentials",
|
||||
headers=headers,
|
||||
json={
|
||||
"credential_id": "denied",
|
||||
"principal_id": "denied",
|
||||
"role": "admin",
|
||||
},
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.delete("/v1/admin/credentials/viewer", headers=headers)
|
||||
).status_code == 403
|
||||
session = await http.post("/v1/session", headers={"Authorization": f"Bearer {admin}"})
|
||||
csrf = {"X-GEWU-CSRF": session.json()["csrf_token"]}
|
||||
body = {
|
||||
"credential_id": "new",
|
||||
"principal_id": "researcher",
|
||||
"role": "agent",
|
||||
"project_ids": ["pilot"],
|
||||
}
|
||||
assert (await http.post("/v1/admin/credentials", json=body)).status_code == 403
|
||||
assert (
|
||||
await http.post(
|
||||
"/v1/admin/credentials", json=body, headers={**csrf, "Origin": "https://other"}
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.post("/v1/admin/credentials", headers=csrf, content=b" " * 4097)
|
||||
).status_code == 413
|
||||
assert (
|
||||
await http.post(
|
||||
"/v1/admin/credentials", headers=csrf, json={**body, "project_ids": []}
|
||||
)
|
||||
).status_code == 422
|
||||
created = await http.post("/v1/admin/credentials", headers=csrf, json=body)
|
||||
assert created.status_code == 201
|
||||
token = created.json()["token"]
|
||||
principal = store.authenticate(token)
|
||||
assert principal and principal.project_ids == frozenset({"pilot"})
|
||||
assert (
|
||||
await http.post("/v1/admin/credentials", headers=csrf, json=body)
|
||||
).status_code == 409
|
||||
listing = await http.get("/v1/admin/credentials")
|
||||
assert listing.headers["cache-control"] == "private, no-store"
|
||||
assert token not in listing.text and "token_digest" not in listing.text
|
||||
assert (
|
||||
await http.delete("/v1/admin/credentials/admin", headers=csrf)
|
||||
).status_code == 409
|
||||
assert (await http.delete("/v1/admin/credentials/new")).status_code == 403
|
||||
assert (await http.delete("/v1/admin/credentials/new", headers=csrf)).status_code == 200
|
||||
assert store.authenticate(token) is None
|
||||
assert (await http.delete("/v1/admin/credentials/new", headers=csrf)).status_code == 404
|
||||
# Revocation invalidates already-issued browser sessions as well as bearer keys.
|
||||
viewer_session = store.create_browser_session(viewer)
|
||||
assert (
|
||||
await http.delete("/v1/admin/credentials/viewer", headers=csrf)
|
||||
).status_code == 200
|
||||
assert store.authenticate_browser_session(viewer_session.session_token) is None
|
||||
events = await http.get("/v1/admin/events")
|
||||
assert [e["action"] for e in events.json()] == [
|
||||
"credential.revoked",
|
||||
"credential.revoked",
|
||||
"credential.created",
|
||||
]
|
||||
assert token not in events.text and admin not in events.text
|
||||
reopened = SQLiteIdentityStore(store.database)
|
||||
assert len(reopened.list_admin_events(limit=50)) == 3
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from gewu.bootstrap.app import create_app, create_local_app
|
||||
|
||||
|
||||
def test_health_is_available_without_exposing_mutation_routes() -> None:
|
||||
async def request_health() -> httpx.Response:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=create_app()),
|
||||
base_url="http://gewu.test",
|
||||
) as client:
|
||||
return await client.get("/health")
|
||||
|
||||
response = asyncio.run(request_health())
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "service": "gewu"}
|
||||
routes = {
|
||||
f"{method} {route.path}"
|
||||
for route in create_app().routes
|
||||
if isinstance(route, APIRoute)
|
||||
for method in route.methods or ()
|
||||
}
|
||||
assert routes == {"GET /health"}
|
||||
|
||||
|
||||
def test_local_factory_composes_private_persistent_read_service(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
data_root = tmp_path / "private-gewu"
|
||||
monkeypatch.setenv("GEWU_DATA_ROOT", str(data_root))
|
||||
app = create_local_app()
|
||||
route_paths = {route.path for route in app.routes if isinstance(route, APIRoute)}
|
||||
assert route_paths == {
|
||||
"/health",
|
||||
"/v1/diagnostics",
|
||||
"/v1/admin/projects",
|
||||
"/v1/admin/credentials",
|
||||
"/v1/admin/credentials/{credential_id}",
|
||||
"/v1/admin/events",
|
||||
"/v1/admin/users",
|
||||
"/v1/admin/users/{username}",
|
||||
"/v1/users/session",
|
||||
"/v1/me",
|
||||
"/v1/me/password",
|
||||
"/v1/me/agent-credentials",
|
||||
"/v1/me/agent-credentials/{credential_id}",
|
||||
"/v1/admin/projects/{project_id}/archive",
|
||||
"/v1/projects/{project_id}/nodes/{node_id}",
|
||||
"/v1/projects/{project_id}/nodes/{node_id}/history",
|
||||
"/v1/projects/{project_id}/sources/{revision_id}",
|
||||
"/v1/projects/{project_id}/sources/{revision_id}/artifact",
|
||||
"/v1/projects/{project_id}/sources/{revision_id}/extractions/{extraction_id}/blocks/{block_id}",
|
||||
"/v1/neighbors",
|
||||
"/v1/projects",
|
||||
"/v1/search",
|
||||
"/v1/sources",
|
||||
"/v1/session",
|
||||
"/auth/owner-session",
|
||||
}
|
||||
assert (data_root / "knowledge.git" / "HEAD").is_file()
|
||||
assert (data_root / "identity.sqlite3").is_file()
|
||||
assert (data_root / "search.sqlite3").is_file()
|
||||
assert (data_root / "search.sqlite3").stat().st_mode & 0o077 == 0
|
||||
assert (data_root.stat().st_mode & 0o777) == 0o700
|
||||
|
||||
|
||||
def test_local_factory_rejects_symlinked_data_root(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
target = tmp_path / "real-root"
|
||||
target.mkdir()
|
||||
link = tmp_path / "linked-root"
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
monkeypatch.setenv("GEWU_DATA_ROOT", str(link))
|
||||
with pytest.raises(ValueError, match="symbolic links"):
|
||||
create_local_app()
|
||||
@@ -0,0 +1,951 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import socket
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.source_extractor import LocalSourceExtractor
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.url_fetch import PublicUrlFetcher
|
||||
from gewu.application.acceptance import AcceptanceService
|
||||
from gewu.application.context import ValidationContext
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.uploads import LocalArtifactStore
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import ProposalOperation, ProposalRevision, Scope, SourceRevisionRecord
|
||||
from gewu.domain.validation import evidence_digest, proposal_payload_digest
|
||||
from gewu.transports.stdio_connector import SelectedFile, create_connector_server
|
||||
from tests.domain.proposal_fixtures import (
|
||||
BLOCK_TEXT,
|
||||
NODE,
|
||||
POLICIES,
|
||||
PROJECTS,
|
||||
SOURCE_REVISION,
|
||||
proposal,
|
||||
)
|
||||
|
||||
|
||||
class _FixtureResponse:
|
||||
def __init__(self, media_type: str, content: bytes, status: int = 200) -> None:
|
||||
self.media_type = media_type
|
||||
self.content = content
|
||||
self.status = status
|
||||
self.offset = 0
|
||||
|
||||
def getheader(self, name: str) -> str | None:
|
||||
return {
|
||||
"Content-Type": f"{self.media_type}; charset=utf-8",
|
||||
"X-Source-Title": "Allowlisted fixture",
|
||||
}.get(name)
|
||||
|
||||
def read(self, amount: int) -> bytes:
|
||||
result = self.content[self.offset : self.offset + amount]
|
||||
self.offset += len(result)
|
||||
return result
|
||||
|
||||
|
||||
class _FixtureConnection:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.path = ""
|
||||
|
||||
def request(self, _method: str, path: str, headers: dict[str, str]) -> None:
|
||||
self.path = path
|
||||
assert headers["Accept-Encoding"] == "identity"
|
||||
return None
|
||||
|
||||
def getresponse(self) -> _FixtureResponse:
|
||||
if self.path.startswith("/missing"):
|
||||
return _FixtureResponse("text/plain", b"unavailable", status=404)
|
||||
if self.path.startswith("/source.md?rev=2"):
|
||||
return _FixtureResponse("text/markdown", b"# Captured fixture\nRevised evidence.\n")
|
||||
if self.path.startswith("/page.html"):
|
||||
return _FixtureResponse(
|
||||
"text/html",
|
||||
b"<article><h1>Web evidence</h1><p>Exact HTML statement.</p>"
|
||||
b"<script>ignoreInjection()</script></article>",
|
||||
)
|
||||
if self.path.startswith("/paper.pdf"):
|
||||
return _FixtureResponse("application/pdf", _pdf_fixture())
|
||||
if self.path.startswith("/malformed.pdf"):
|
||||
return _FixtureResponse("application/pdf", b"%PDF-1.4\nnot a valid PDF")
|
||||
return _FixtureResponse(
|
||||
"text/markdown", b"# Captured fixture\nAn exact source statement.\n"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _fixture_fetcher() -> PublicUrlFetcher:
|
||||
def resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port))]
|
||||
|
||||
return PublicUrlFetcher(
|
||||
{"research.example"},
|
||||
resolver=resolver,
|
||||
connection_factory=lambda *_args: _FixtureConnection(),
|
||||
)
|
||||
|
||||
|
||||
def _text_payload(result: Any) -> str:
|
||||
assert result.content and isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
|
||||
def _pdf_fixture() -> bytes:
|
||||
stream = b"BT /F1 12 Tf 72 720 Td (Archived PDF evidence.) Tj ET"
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
||||
b"/Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>",
|
||||
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream",
|
||||
]
|
||||
result = bytearray(b"%PDF-1.4\n")
|
||||
offsets = [0]
|
||||
for number, item in enumerate(objects, start=1):
|
||||
offsets.append(len(result))
|
||||
result.extend(f"{number} 0 obj\n".encode() + item + b"\nendobj\n")
|
||||
xref = len(result)
|
||||
result.extend(f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode())
|
||||
for offset in offsets[1:]:
|
||||
result.extend(f"{offset:010d} 00000 n \n".encode())
|
||||
result.extend(
|
||||
f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
|
||||
)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
artifacts = LocalArtifactStore(tmp_path)
|
||||
extractor = LocalSourceExtractor()
|
||||
context = ValidationContext(
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={},
|
||||
existing_nodes={},
|
||||
)
|
||||
identities = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3",
|
||||
authority_lock_path=tmp_path / "workflow/locks/canonical-writer.lock",
|
||||
)
|
||||
token = identities.provision(
|
||||
credential_id="pilot-agent-key",
|
||||
principal_id="agent-pilot",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
viewer_token = identities.provision(
|
||||
credential_id="pilot-viewer-key",
|
||||
principal_id="pilot-viewer",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
acceptance = AcceptanceService(
|
||||
repository=repository,
|
||||
artifact_store=artifacts,
|
||||
workflow_root=tmp_path / "workflow",
|
||||
context_provider=lambda: context,
|
||||
actor_is_current=lambda actor: identities.is_current(
|
||||
actor.principal_id,
|
||||
actor.credential_id,
|
||||
roles=actor.roles,
|
||||
project_ids=actor.project_ids,
|
||||
),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
artifact_store=artifacts,
|
||||
extractor=extractor,
|
||||
acceptance_service=acceptance,
|
||||
context_provider=lambda: context,
|
||||
url_fetcher=_fixture_fetcher(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
tools = await mcp.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"diagnostics",
|
||||
"get_node",
|
||||
"node_history",
|
||||
"get_project_context",
|
||||
"get_source",
|
||||
"source_upload_begin",
|
||||
"source_capture_text",
|
||||
"source_capture_url",
|
||||
"source_upload_status",
|
||||
"source_upload_chunk",
|
||||
"source_upload_finalize",
|
||||
"source_extract",
|
||||
"source_extraction_block",
|
||||
"proposal_evaluate",
|
||||
"proposal_submit",
|
||||
"proposal_status",
|
||||
"proposal_withdraw",
|
||||
"run_create",
|
||||
"run_checkpoint",
|
||||
"run_status",
|
||||
}
|
||||
project_context = await mcp.call_tool(
|
||||
"get_project_context", {"project_id": "dome-performance"}
|
||||
)
|
||||
assert not project_context.is_error
|
||||
lens_result = json.loads(_text_payload(project_context))
|
||||
assert (
|
||||
lens_result["lens"]["north_star"] == PROJECTS["dome-performance"].north_star
|
||||
)
|
||||
assert lens_result["lens"]["key_questions"] == list(
|
||||
PROJECTS["dome-performance"].key_questions
|
||||
)
|
||||
assert "autonomy_policy_id" not in lens_result["lens"]
|
||||
denied_lens = await mcp.call_tool(
|
||||
"get_project_context", {"project_id": "interactive-video"}
|
||||
)
|
||||
assert denied_lens.is_error
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
) as viewer_http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=viewer_http)
|
||||
) as viewer_mcp:
|
||||
viewer_proposal = await viewer_mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": proposal().model_dump_json()},
|
||||
)
|
||||
assert viewer_proposal.is_error
|
||||
viewer_upload = await viewer_mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"filename": "viewer.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": 4,
|
||||
"expected_sha256": hashlib.sha256(b"data").hexdigest(),
|
||||
},
|
||||
)
|
||||
assert viewer_upload.is_error
|
||||
viewer_paste = await viewer_mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "viewer-capture-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "viewer-paste",
|
||||
"text": "Viewer supplied text",
|
||||
},
|
||||
)
|
||||
assert viewer_paste.is_error
|
||||
diagnostics_result = await mcp.call_tool(
|
||||
"diagnostics",
|
||||
{"projects": ["dome-performance"], "include_shared": True},
|
||||
)
|
||||
assert not diagnostics_result.is_error
|
||||
diagnostics = json.loads(_text_payload(diagnostics_result))
|
||||
assert diagnostics["workflow_status"] == "not_verified"
|
||||
assert diagnostics["selected_scope"]["include_shared"] is True
|
||||
assert "proposal_submit" in diagnostics["authorized_operations"]
|
||||
assert "run_checkpoint" in diagnostics["authorized_operations"]
|
||||
assert token not in _text_payload(diagnostics_result)
|
||||
run_request = {
|
||||
"run_id": "mcp-run-pilot",
|
||||
"objective": "Compare audience response",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
},
|
||||
"lens": "Audience behavior",
|
||||
"pack_id": "gewu",
|
||||
"pack_version": "0.3.0",
|
||||
"pack_snapshot_digest": "a" * 64,
|
||||
"module_pins": [
|
||||
{
|
||||
"module_id": "profile",
|
||||
"implementation_version": "1.0.0",
|
||||
"sha256": "b" * 64,
|
||||
}
|
||||
],
|
||||
}
|
||||
created_run = await mcp.call_tool(
|
||||
"run_create", {"request_json": json.dumps(run_request)}
|
||||
)
|
||||
assert not created_run.is_error
|
||||
run_record = json.loads(_text_payload(created_run))
|
||||
assert run_record["revision"] == 0
|
||||
checkpoint = {
|
||||
"run_id": "mcp-run-pilot",
|
||||
"expected_revision": 0,
|
||||
"stage": "source-review",
|
||||
}
|
||||
saved_run = await mcp.call_tool(
|
||||
"run_checkpoint", {"request_json": json.dumps(checkpoint)}
|
||||
)
|
||||
assert not saved_run.is_error
|
||||
assert json.loads(_text_payload(saved_run))["revision"] == 1
|
||||
read_run = await mcp.call_tool("run_status", {"run_id": "mcp-run-pilot"})
|
||||
assert not read_run.is_error
|
||||
assert json.loads(_text_payload(read_run))["revision"] == 1
|
||||
denied = await mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "interactive-video",
|
||||
"filename": "private.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": 4,
|
||||
"expected_sha256": "0" * 64,
|
||||
},
|
||||
)
|
||||
assert denied.is_error
|
||||
forbidden_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "interactive-video",
|
||||
"url": "https://research.example/source.md",
|
||||
},
|
||||
)
|
||||
assert forbidden_capture.is_error
|
||||
forbidden_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "out-of-scope-paste-1",
|
||||
"project_id": "interactive-video",
|
||||
"source_key": "out-of-scope-paste",
|
||||
"text": "Must not be uploaded",
|
||||
},
|
||||
)
|
||||
assert forbidden_paste.is_error
|
||||
unallowlisted_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://internal.example/private",
|
||||
},
|
||||
)
|
||||
assert unallowlisted_capture.is_error
|
||||
captured = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/source.md?token=do-not-echo",
|
||||
},
|
||||
)
|
||||
assert not captured.is_error
|
||||
capture_result = json.loads(_text_payload(captured))
|
||||
assert capture_result["title"] == "Allowlisted fixture"
|
||||
assert capture_result["media_type"] == "text/markdown"
|
||||
assert "do-not-echo" not in _text_payload(captured)
|
||||
assert capture_result["original_url"] == ("https://research.example/source.md")
|
||||
captured_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(capture_result["source_candidate"]), strict=True
|
||||
)
|
||||
assert captured_source.archive_status == "archived"
|
||||
assert captured_source.final_url == "https://research.example/source.md"
|
||||
assert captured_source.artifact_sha256 == capture_result["sha256"]
|
||||
assert captured_source.capture_method == "url-fetch"
|
||||
assert f":r:{captured_source.artifact_sha256}:" in captured_source.revision_id
|
||||
pasted_text = "## Meeting note\nPasted evidence: 400 visitors.\n"
|
||||
pasted_arguments = {
|
||||
"capture_id": "interview-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "research-interview-2026-10-14",
|
||||
"text": pasted_text,
|
||||
"title": "Research interview notes",
|
||||
"original_url": "https://research.example/interview?token=secret",
|
||||
"media_type": "text/markdown",
|
||||
"source_type": "internal",
|
||||
}
|
||||
pasted = await mcp.call_tool("source_capture_text", pasted_arguments)
|
||||
assert not pasted.is_error
|
||||
pasted_retry = await mcp.call_tool("source_capture_text", pasted_arguments)
|
||||
assert not pasted_retry.is_error
|
||||
assert json.loads(_text_payload(pasted_retry)) == json.loads(
|
||||
_text_payload(pasted)
|
||||
)
|
||||
changed_paste_retry = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{**pasted_arguments, "text": pasted_text + "changed"},
|
||||
)
|
||||
assert changed_paste_retry.is_error
|
||||
invalid_capture_id = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{**pasted_arguments, "capture_id": "invalid capture id"},
|
||||
)
|
||||
assert invalid_capture_id.is_error
|
||||
pasted_result = json.loads(_text_payload(pasted))
|
||||
pasted_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(pasted_result["source_candidate"]), strict=True
|
||||
)
|
||||
assert pasted_source.capture_method == "pasted-text"
|
||||
assert pasted_source.original_url == "https://research.example/interview"
|
||||
assert "secret" not in _text_payload(pasted)
|
||||
capture_journal = tmp_path / "staging" / "capture-receipts"
|
||||
assert all(
|
||||
pasted_text.encode() not in path.read_bytes()
|
||||
for path in capture_journal.glob("*.json")
|
||||
)
|
||||
assert (
|
||||
pasted_source.artifact_sha256
|
||||
== hashlib.sha256(pasted_text.encode()).hexdigest()
|
||||
)
|
||||
assert pasted_result["byte_length"] == len(pasted_text.encode())
|
||||
assert pasted_result["extraction_id"]
|
||||
pasted_extraction = artifacts.get_extraction(
|
||||
pasted_result["extraction_id"],
|
||||
owner_id="agent-pilot",
|
||||
project_id="dome-performance",
|
||||
)
|
||||
assert pasted_extraction is not None
|
||||
assert pasted_extraction.source_revision_id == pasted_source.revision_id
|
||||
assert (
|
||||
"Pasted evidence: 400 visitors."
|
||||
in pasted_extraction.blocks[0].original_text
|
||||
)
|
||||
oversized_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "oversized-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "oversized-paste",
|
||||
"text": "x" * 1_000_001,
|
||||
},
|
||||
)
|
||||
assert oversized_paste.is_error
|
||||
control_character_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "invalid-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "invalid-paste",
|
||||
"text": "unsafe\x00content",
|
||||
},
|
||||
)
|
||||
assert control_character_paste.is_error
|
||||
oversized_author_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "invalid-author-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "invalid-author-paste",
|
||||
"text": "valid body",
|
||||
"authors": ["a" * 301],
|
||||
},
|
||||
)
|
||||
assert oversized_author_paste.is_error
|
||||
changed_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/source.md?rev=2",
|
||||
},
|
||||
)
|
||||
assert not changed_capture.is_error
|
||||
changed_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(json.loads(_text_payload(changed_capture))["source_candidate"]),
|
||||
strict=True,
|
||||
)
|
||||
assert changed_source.source_id == captured_source.source_id
|
||||
assert changed_source.revision_id != captured_source.revision_id
|
||||
with artifacts.open_artifact(
|
||||
capture_result["sha256"], project_id="dome-performance"
|
||||
) as artifact:
|
||||
assert artifact.read() == (
|
||||
b"# Captured fixture\nAn exact source statement.\n"
|
||||
)
|
||||
failed_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/missing?access-token=must-not-leak",
|
||||
"title": "Temporarily unavailable paper",
|
||||
"source_type": "paper",
|
||||
},
|
||||
)
|
||||
assert not failed_capture.is_error
|
||||
failed_result = json.loads(_text_payload(failed_capture))
|
||||
failed_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(failed_result["source_candidate"]), strict=True
|
||||
)
|
||||
assert failed_result["status"] == "capture_failed"
|
||||
assert failed_source.archive_status == "capture-failed"
|
||||
assert failed_source.artifact_sha256 is None
|
||||
assert "access-token" not in _text_payload(failed_capture)
|
||||
failed_proposal = ProposalRevision(
|
||||
proposal_id="proposal-failed-url-capture",
|
||||
revision=1,
|
||||
initiating_project_id="dome-performance",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=True),
|
||||
base_commit=repository.head(),
|
||||
payload_digest="0" * 64,
|
||||
proposer_id="agent-pilot",
|
||||
run_id="run-failed-url-capture",
|
||||
operations=(
|
||||
ProposalOperation(operation="register_source", source=failed_source),
|
||||
),
|
||||
)
|
||||
failed_proposal = failed_proposal.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(failed_proposal)}
|
||||
)
|
||||
registered_failure = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": failed_proposal.model_dump_json()},
|
||||
)
|
||||
assert not registered_failure.is_error
|
||||
assert (
|
||||
json.loads(_text_payload(registered_failure))["receipt"]["state"]
|
||||
== "accepted"
|
||||
)
|
||||
captured_html = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/page.html",
|
||||
},
|
||||
)
|
||||
assert not captured_html.is_error
|
||||
html_result = json.loads(_text_payload(captured_html))
|
||||
assert html_result["media_type"] == "text/html"
|
||||
html_extraction_result = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": html_result["sha256"],
|
||||
"source_revision_id": "shared:web-page:r1",
|
||||
},
|
||||
)
|
||||
assert not html_extraction_result.is_error
|
||||
html_extraction = json.loads(_text_payload(html_extraction_result))
|
||||
html_block_result = await mcp.call_tool(
|
||||
"source_extraction_block",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"extraction_id": html_extraction["extraction_id"],
|
||||
"block_id": html_extraction["blocks"][0]["block_id"],
|
||||
"offset": 0,
|
||||
"length": 500,
|
||||
},
|
||||
)
|
||||
assert not html_block_result.is_error
|
||||
html_block = _text_payload(html_block_result)
|
||||
assert "Exact HTML statement." in html_block
|
||||
assert "ignoreInjection" not in html_block
|
||||
captured_pdf = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/paper.pdf",
|
||||
},
|
||||
)
|
||||
assert not captured_pdf.is_error
|
||||
pdf_result = json.loads(_text_payload(captured_pdf))
|
||||
assert pdf_result["media_type"] == "application/pdf"
|
||||
with artifacts.open_artifact(
|
||||
pdf_result["sha256"], project_id="dome-performance"
|
||||
) as artifact:
|
||||
assert artifact.read() == _pdf_fixture()
|
||||
pdf_extracted = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": pdf_result["sha256"],
|
||||
"source_revision_id": "shared:pdf:r1",
|
||||
},
|
||||
)
|
||||
assert not pdf_extracted.is_error
|
||||
pdf_record = json.loads(_text_payload(pdf_extracted))
|
||||
assert pdf_record["blocks"][0]["page"] == 1
|
||||
pdf_block_result = await mcp.call_tool(
|
||||
"source_extraction_block",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"extraction_id": pdf_record["extraction_id"],
|
||||
"block_id": pdf_record["blocks"][0]["block_id"],
|
||||
"offset": 0,
|
||||
"length": 500,
|
||||
},
|
||||
)
|
||||
assert not pdf_block_result.is_error
|
||||
pdf_block = json.loads(_text_payload(pdf_block_result))
|
||||
assert pdf_block["page"] == 1
|
||||
assert "Archived PDF evidence." in pdf_block["normalized_text"]
|
||||
malformed_pdf = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/malformed.pdf",
|
||||
},
|
||||
)
|
||||
assert not malformed_pdf.is_error
|
||||
malformed_result = json.loads(_text_payload(malformed_pdf))
|
||||
malformed_extraction = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": malformed_result["sha256"],
|
||||
"source_revision_id": malformed_result["source_candidate"][
|
||||
"revision_id"
|
||||
],
|
||||
},
|
||||
)
|
||||
assert not malformed_extraction.is_error
|
||||
unavailable = json.loads(_text_payload(malformed_extraction))
|
||||
assert unavailable["status"] == "unavailable"
|
||||
assert unavailable["error_code"] == "PDF_UNSUPPORTED"
|
||||
content = BLOCK_TEXT.encode("utf-8")
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
interrupted_begin = await mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"filename": "interrupted.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": len(content),
|
||||
"expected_sha256": digest,
|
||||
},
|
||||
)
|
||||
assert not interrupted_begin.is_error
|
||||
interrupted_upload = json.loads(_text_payload(interrupted_begin))
|
||||
interrupted_finalize = await mcp.call_tool(
|
||||
"source_upload_finalize",
|
||||
{"upload_id": interrupted_upload["upload_id"]},
|
||||
)
|
||||
assert interrupted_finalize.is_error
|
||||
uncitable = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": digest,
|
||||
"source_revision_id": SOURCE_REVISION.revision_id,
|
||||
},
|
||||
)
|
||||
assert uncitable.is_error
|
||||
|
||||
begun = await mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"filename": "pilot.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": len(content),
|
||||
"expected_sha256": digest,
|
||||
},
|
||||
)
|
||||
assert not begun.is_error
|
||||
upload = json.loads(_text_payload(begun))
|
||||
wrong_digest_chunk = await mcp.call_tool(
|
||||
"source_upload_chunk",
|
||||
{
|
||||
"upload_id": upload["upload_id"],
|
||||
"offset": 0,
|
||||
"content_base64": base64.b64encode(content).decode(),
|
||||
"chunk_sha256": "0" * 64,
|
||||
},
|
||||
)
|
||||
assert wrong_digest_chunk.is_error
|
||||
chunked = await mcp.call_tool(
|
||||
"source_upload_chunk",
|
||||
{
|
||||
"upload_id": upload["upload_id"],
|
||||
"offset": 0,
|
||||
"content_base64": base64.b64encode(content).decode(),
|
||||
"chunk_sha256": digest,
|
||||
},
|
||||
)
|
||||
assert not chunked.is_error
|
||||
finalized = await mcp.call_tool(
|
||||
"source_upload_finalize", {"upload_id": upload["upload_id"]}
|
||||
)
|
||||
assert not finalized.is_error
|
||||
artifact = json.loads(_text_payload(finalized))
|
||||
extracted = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": artifact["sha256"],
|
||||
"source_revision_id": SOURCE_REVISION.revision_id,
|
||||
},
|
||||
)
|
||||
assert not extracted.is_error
|
||||
extraction_info = json.loads(_text_payload(extracted))
|
||||
record = extractor.extract(
|
||||
SOURCE_REVISION.revision_id, content, "text/markdown"
|
||||
)
|
||||
assert extraction_info["extraction_id"] == record.extraction_id
|
||||
|
||||
evidence = (
|
||||
NODE.claims[0]
|
||||
.evidence[0]
|
||||
.model_copy(
|
||||
update={
|
||||
"extraction_id": record.extraction_id,
|
||||
"locator": NODE.claims[0]
|
||||
.evidence[0]
|
||||
.locator.model_copy(update={"block_id": record.blocks[0].block_id}),
|
||||
}
|
||||
)
|
||||
)
|
||||
claim = NODE.claims[0].model_copy(
|
||||
update={
|
||||
"evidence": (evidence,),
|
||||
"assessment": NODE.claims[0].assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(evidence),)}
|
||||
),
|
||||
}
|
||||
)
|
||||
node = NODE.model_copy(update={"claims": (claim,)})
|
||||
source = SOURCE_REVISION.model_copy(
|
||||
update={"artifact_sha256": artifact["sha256"], "byte_length": len(content)}
|
||||
)
|
||||
candidate = proposal(node=node, source=source, base_commit=repository.head())
|
||||
candidate = candidate.model_copy(update={"payload_digest": "0" * 64})
|
||||
candidate = candidate.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(candidate)}
|
||||
)
|
||||
|
||||
wrong_location_evidence = evidence.model_copy(
|
||||
update={
|
||||
"quote": "400 visitors",
|
||||
"locator": evidence.locator.model_copy(update={"start": 0, "end": 13}),
|
||||
}
|
||||
)
|
||||
wrong_location_claim = claim.model_copy(
|
||||
update={
|
||||
"evidence": (wrong_location_evidence,),
|
||||
"assessment": claim.assessment.model_copy(
|
||||
update={
|
||||
"evidence_digests": (evidence_digest(wrong_location_evidence),)
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
wrong_location_node = node.model_copy(
|
||||
update={"claims": (wrong_location_claim,)}
|
||||
)
|
||||
wrong_location = proposal(
|
||||
node=wrong_location_node,
|
||||
source=source,
|
||||
base_commit=repository.head(),
|
||||
)
|
||||
wrong_location = wrong_location.model_copy(
|
||||
update={
|
||||
"proposal_id": "proposal-wrong-location",
|
||||
"payload_digest": "0" * 64,
|
||||
}
|
||||
)
|
||||
wrong_location = wrong_location.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(wrong_location)}
|
||||
)
|
||||
head_before_evaluate = repository.head()
|
||||
invalid_evaluation = await mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": wrong_location.model_dump_json()},
|
||||
)
|
||||
assert not invalid_evaluation.is_error, invalid_evaluation.content
|
||||
invalid_evaluation_payload = json.loads(_text_payload(invalid_evaluation))
|
||||
assert invalid_evaluation_payload["status"] == "invalid"
|
||||
assert "EVIDENCE_MISMATCH" in {
|
||||
finding["code"] for finding in invalid_evaluation_payload["findings"]
|
||||
}
|
||||
assert repository.head() == head_before_evaluate
|
||||
rejected = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": wrong_location.model_dump_json()},
|
||||
)
|
||||
assert not rejected.is_error, rejected.content
|
||||
rejected_outcome = json.loads(_text_payload(rejected))
|
||||
assert rejected_outcome["status"] == "invalid"
|
||||
assert "EVIDENCE_MISMATCH" in {
|
||||
finding["code"] for finding in rejected_outcome["findings"]
|
||||
}
|
||||
rejected_status = await mcp.call_tool(
|
||||
"proposal_status",
|
||||
{"proposal_id": "proposal-wrong-location", "revision": 1},
|
||||
)
|
||||
assert not rejected_status.is_error, rejected_status.content
|
||||
rejected_status_payload = json.loads(_text_payload(rejected_status))
|
||||
assert rejected_status_payload["lifecycle"] == "evaluated"
|
||||
assert rejected_status_payload["validation_status"] == "invalid"
|
||||
assert rejected_status_payload["receipt"] is None
|
||||
assert "quote" not in _text_payload(rejected_status)
|
||||
withdrawn = await mcp.call_tool(
|
||||
"proposal_withdraw",
|
||||
{"proposal_id": "proposal-wrong-location", "revision": 1},
|
||||
)
|
||||
assert not withdrawn.is_error, withdrawn.content
|
||||
assert json.loads(_text_payload(withdrawn))["lifecycle"] == "withdrawn"
|
||||
resubmitted = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": wrong_location.model_dump_json()},
|
||||
)
|
||||
assert resubmitted.is_error
|
||||
|
||||
candidate_head = repository.head()
|
||||
valid_evaluation = await mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert not valid_evaluation.is_error, valid_evaluation.content
|
||||
valid_evaluation_payload = json.loads(_text_payload(valid_evaluation))
|
||||
assert valid_evaluation_payload["status"] == "eligible"
|
||||
assert valid_evaluation_payload["eligible_for_auto_accept"]
|
||||
assert repository.head() == candidate_head
|
||||
spoofed_assessment = claim.assessment.model_copy(
|
||||
update={"assessor_id": "agent-other"}
|
||||
)
|
||||
spoofed_claim = claim.model_copy(update={"assessment": spoofed_assessment})
|
||||
spoofed_node = node.model_copy(update={"claims": (spoofed_claim,)})
|
||||
spoofed = proposal(
|
||||
node=spoofed_node,
|
||||
source=source,
|
||||
base_commit=repository.head(),
|
||||
)
|
||||
spoofed = spoofed.model_copy(update={"payload_digest": "0" * 64})
|
||||
spoofed = spoofed.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(spoofed)}
|
||||
)
|
||||
spoofed_result = await mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": spoofed.model_dump_json()},
|
||||
)
|
||||
assert not spoofed_result.is_error, spoofed_result.content
|
||||
assert "ASSESSOR_ATTRIBUTION_MISMATCH" in {
|
||||
finding["code"]
|
||||
for finding in json.loads(_text_payload(spoofed_result))["findings"]
|
||||
}
|
||||
unstored = await mcp.call_tool(
|
||||
"proposal_status",
|
||||
{"proposal_id": candidate.proposal_id, "revision": candidate.revision},
|
||||
)
|
||||
assert unstored.is_error
|
||||
|
||||
submitted = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert not submitted.is_error, submitted.content
|
||||
outcome = json.loads(_text_payload(submitted))
|
||||
assert outcome["status"] == "accepted"
|
||||
assert outcome["validation_status"] == "eligible"
|
||||
assert outcome["receipt"] is not None
|
||||
accepted_head = repository.head()
|
||||
retried = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert not retried.is_error, retried.content
|
||||
retry_outcome = json.loads(_text_payload(retried))
|
||||
assert retry_outcome["receipt"] == outcome["receipt"]
|
||||
assert repository.head() == accepted_head
|
||||
accepted_status = await mcp.call_tool(
|
||||
"proposal_status",
|
||||
{"proposal_id": candidate.proposal_id, "revision": candidate.revision},
|
||||
)
|
||||
assert not accepted_status.is_error, accepted_status.content
|
||||
accepted_status_payload = json.loads(_text_payload(accepted_status))
|
||||
assert accepted_status_payload["lifecycle"] == "accepted"
|
||||
assert accepted_status_payload["receipt"] == outcome["receipt"]
|
||||
other_token = identities.provision(
|
||||
credential_id="other-agent-key",
|
||||
principal_id="agent-other",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {other_token}"},
|
||||
) as other_http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=other_http)
|
||||
) as other_mcp:
|
||||
private_status = await other_mcp.call_tool(
|
||||
"proposal_status",
|
||||
{
|
||||
"proposal_id": candidate.proposal_id,
|
||||
"revision": candidate.revision,
|
||||
},
|
||||
)
|
||||
assert private_status.is_error
|
||||
impersonated_evaluation = await other_mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert impersonated_evaluation.is_error
|
||||
unauthorized_withdrawal = await other_mcp.call_tool(
|
||||
"proposal_withdraw",
|
||||
{
|
||||
"proposal_id": candidate.proposal_id,
|
||||
"revision": candidate.revision,
|
||||
},
|
||||
)
|
||||
assert unauthorized_withdrawal.is_error
|
||||
read = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": node.project_id, "node_id": node.node_id},
|
||||
)
|
||||
assert not read.is_error
|
||||
envelope = json.loads(_text_payload(read))
|
||||
assert envelope["canonical_revision"] == outcome["receipt"]["commit_id"]
|
||||
assert envelope["citations"][0]["quote"] == BLOCK_TEXT
|
||||
|
||||
selected_path = tmp_path / "operator-selected.md"
|
||||
selected_bytes = b"A separately selected source for the stdio bridge.\n"
|
||||
selected_path.write_bytes(selected_bytes)
|
||||
|
||||
@asynccontextmanager
|
||||
async def remote_client() -> AsyncIterator[Client]:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as remote:
|
||||
yield remote
|
||||
|
||||
connector = create_connector_server(
|
||||
(SelectedFile("source-1", selected_path),), remote_client
|
||||
)
|
||||
async with Client(connector) as local:
|
||||
connector_tools = await local.list_tools()
|
||||
assert {tool.name for tool in connector_tools.tools} == {
|
||||
"list_selected_files",
|
||||
"upload_selected_file",
|
||||
}
|
||||
connector_result = await local.call_tool(
|
||||
"upload_selected_file",
|
||||
{
|
||||
"file_id": "source-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_revision_id": "dome:connector-probe:r1",
|
||||
},
|
||||
)
|
||||
assert not connector_result.is_error
|
||||
connector_text = _text_payload(connector_result)
|
||||
connector_receipt = json.loads(connector_text)
|
||||
assert (
|
||||
connector_receipt["sha256"]
|
||||
== hashlib.sha256(selected_bytes).hexdigest()
|
||||
)
|
||||
assert selected_bytes.decode() not in connector_text
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,139 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import yaml
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
from tests.domain.proposal_fixtures import NODE, SOURCE_REVISION
|
||||
|
||||
|
||||
def test_mcp_exposes_only_authenticated_canonical_read(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
|
||||
source_key = hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()
|
||||
source_revision_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump(
|
||||
NODE.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"sources/{source_key}/{source_revision_key}.yaml": yaml.safe_dump(
|
||||
SOURCE_REVISION.model_dump(mode="json")
|
||||
).encode(),
|
||||
},
|
||||
message="Publish test node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="reader-key",
|
||||
principal_id="research-agent",
|
||||
roles=("agent",),
|
||||
project_ids=(NODE.project_id,),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
tools = await mcp.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"diagnostics",
|
||||
"get_node",
|
||||
"node_history",
|
||||
"get_source",
|
||||
}
|
||||
diagnostic_result = await mcp.call_tool(
|
||||
"diagnostics", {"projects": [NODE.project_id]}
|
||||
)
|
||||
assert not diagnostic_result.is_error
|
||||
diagnostic_text = diagnostic_result.content[0]
|
||||
assert isinstance(diagnostic_text, TextContent)
|
||||
diagnostics = json.loads(diagnostic_text.text)
|
||||
assert diagnostics["transport"] == "mcp"
|
||||
assert diagnostics["selected_scope"]["projects"] == [NODE.project_id]
|
||||
assert diagnostics["index_state"] == "not_configured"
|
||||
assert "get_node" in diagnostics["authorized_operations"]
|
||||
assert "proposal_submit" not in diagnostics["authorized_operations"]
|
||||
assert token not in diagnostic_text.text
|
||||
denied_diagnostics = await mcp.call_tool(
|
||||
"diagnostics", {"projects": ["interactive-video"]}
|
||||
)
|
||||
assert denied_diagnostics.is_error
|
||||
result = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert not result.is_error
|
||||
content = result.content[0]
|
||||
assert content.type == "text"
|
||||
envelope = json.loads(content.text)
|
||||
assert envelope["canonical_revision"] == repository.head()
|
||||
assert envelope["data"][0]["node_id"] == NODE.node_id
|
||||
assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
history = await mcp.call_tool(
|
||||
"node_history",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert not history.is_error
|
||||
assert isinstance(history.content[0], TextContent)
|
||||
assert len(json.loads(history.content[0].text)["data"]) == 1
|
||||
source = await mcp.call_tool(
|
||||
"get_source",
|
||||
{
|
||||
"project_id": NODE.project_id,
|
||||
"revision_id": SOURCE_REVISION.revision_id,
|
||||
},
|
||||
)
|
||||
assert not source.is_error
|
||||
assert isinstance(source.content[0], TextContent)
|
||||
assert json.loads(source.content[0].text)["data"][0]["revision_id"] == (
|
||||
SOURCE_REVISION.revision_id
|
||||
)
|
||||
assert identities.revoke("reader-key")
|
||||
revoked = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert revoked.is_error
|
||||
revoked_message = revoked.content[0]
|
||||
assert isinstance(revoked_message, TextContent)
|
||||
assert NODE.node_id not in revoked_message.text
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
) as http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
unauthenticated = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert unauthenticated.is_error
|
||||
error_message = unauthenticated.content[0]
|
||||
assert isinstance(error_message, TextContent)
|
||||
assert NODE.node_id not in error_message.text
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gewu.bootstrap.app import create_local_app
|
||||
from gewu.domain.operations import OPERATIONS, Transport, operations_for
|
||||
|
||||
|
||||
def test_http_openapi_routes_match_the_explicit_operation_allowlist(
|
||||
tmp_path: Path, monkeypatch: Any
|
||||
) -> None:
|
||||
monkeypatch.setenv("GEWU_DATA_ROOT", str(tmp_path / "gewu-data"))
|
||||
app = create_local_app()
|
||||
schema = app.openapi()
|
||||
exposed = {
|
||||
operation["operationId"]: operation
|
||||
for methods in schema["paths"].values()
|
||||
for method, operation in methods.items()
|
||||
if method in {"get", "post", "put", "delete", "patch"}
|
||||
}
|
||||
http_allowlist = {item.name for item in operations_for(Transport.HTTP)}
|
||||
operation_index = {item.name: item for item in OPERATIONS}
|
||||
|
||||
assert set(exposed) == http_allowlist
|
||||
assert "owner_decide" not in exposed
|
||||
assert "proposal_submit" not in exposed
|
||||
assert all(
|
||||
not operation_index[name].mutates_knowledge
|
||||
for name, route in exposed.items()
|
||||
if "knowledge" in route.get("tags", [])
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx2
|
||||
import pyotp
|
||||
from cryptography.fernet import Fernet
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
|
||||
|
||||
def test_owner_login_returns_a_short_lived_non_agent_token(tmp_path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
identities.provision_owner(
|
||||
principal_id="owner-one",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
now = int(time.time())
|
||||
identities.issue_owner_challenge(
|
||||
challenge_id="challenge-one",
|
||||
owner_principal_id="owner-one",
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="a" * 64,
|
||||
diff_digest="b" * 64,
|
||||
domains=("dome-performance",),
|
||||
expires_at=now + 300,
|
||||
now=now,
|
||||
)
|
||||
agent_token = identities.provision(
|
||||
credential_id="agent-key",
|
||||
principal_id="agent-one",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
owner_identity_provider=identities,
|
||||
owner_service_audience="gewu-local-test",
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": pyotp.TOTP(secret).now(),
|
||||
"challenge_id": "challenge-one",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
token = response.json()["session_token"]
|
||||
assert token.startswith("gwo_")
|
||||
assert identities.authenticate(token) is None
|
||||
assert identities.authenticate(agent_token) is not None
|
||||
assert (
|
||||
identities.authenticate_owner_session(
|
||||
token,
|
||||
challenge_id="challenge-one",
|
||||
audience="gewu-local-test",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
wrong_origin = await client.post(
|
||||
"/auth/owner-session",
|
||||
headers={"Origin": "https://attacker.invalid"},
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": pyotp.TOTP(secret).now(),
|
||||
"challenge_id": "challenge-two",
|
||||
},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
|
||||
oversized = await client.post(
|
||||
"/auth/owner-session",
|
||||
content='{"padding":"' + ("x" * 5000) + '"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert oversized.status_code == 413
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://gewu.example"
|
||||
) as remote_http:
|
||||
insecure = await remote_http.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": "000000",
|
||||
"challenge_id": "challenge-three",
|
||||
},
|
||||
)
|
||||
assert insecure.status_code == 403
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_owner_login_is_fail_closed_without_encryption_key_or_audience(tmp_path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
owner_identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": "000000",
|
||||
"challenge_id": "challenge-one",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 503
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_dedicated_owner_mcp_reads_diff_and_records_decision(tmp_path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
identities.provision_owner(
|
||||
principal_id="owner-one",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
diff_json = '{"files":[{"path":"projects/dome/nodes/a.yaml"}]}'
|
||||
diff_digest = hashlib.sha256(diff_json.encode()).hexdigest()
|
||||
identities.issue_owner_challenge(
|
||||
challenge_id="challenge-review",
|
||||
owner_principal_id="owner-one",
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="a" * 64,
|
||||
diff_digest=diff_digest,
|
||||
domains=("dome-performance",),
|
||||
expires_at=int(time.time()) + 300,
|
||||
diff_json=diff_json,
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
owner_identity_provider=identities,
|
||||
owner_service_audience="gewu-owner-test",
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1"
|
||||
) as login_http:
|
||||
login = await login_http.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": pyotp.TOTP(secret).now(),
|
||||
"challenge_id": "challenge-review",
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
token = login.json()["session_token"]
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as owner_http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/owner-mcp", http_client=owner_http)
|
||||
) as owner:
|
||||
names = {tool.name for tool in (await owner.list_tools()).tools}
|
||||
assert names == {
|
||||
"owner_challenge_get",
|
||||
"owner_diff_get",
|
||||
"owner_decide",
|
||||
}
|
||||
challenge = await owner.call_tool(
|
||||
"owner_challenge_get", {"challenge_id": "challenge-review"}
|
||||
)
|
||||
assert not challenge.is_error
|
||||
diff = await owner.call_tool(
|
||||
"owner_diff_get", {"challenge_id": "challenge-review"}
|
||||
)
|
||||
assert not diff.is_error
|
||||
decision = await owner.call_tool(
|
||||
"owner_decide",
|
||||
{
|
||||
"challenge_id": "challenge-review",
|
||||
"expected_proposal_revision": 1,
|
||||
"expected_payload_digest": "a" * 64,
|
||||
"expected_diff_digest": diff_digest,
|
||||
"decision": "authorize",
|
||||
"rationale": "Reviewed this exact change and approve it.",
|
||||
},
|
||||
)
|
||||
assert not decision.is_error
|
||||
assert json.loads(decision.content[0].text)["status"] == "authorized"
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.registry import LocalProjectRegistry
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord, ProjectStatus
|
||||
|
||||
|
||||
def test_project_registry_lifecycle_is_admin_only_and_durable(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
admin_token = identities.provision(
|
||||
credential_id="project-admin",
|
||||
principal_id="owner-operator",
|
||||
roles=("admin",),
|
||||
)
|
||||
viewer_token = identities.provision(
|
||||
credential_id="project-viewer",
|
||||
principal_id="research-reader",
|
||||
roles=("viewer",),
|
||||
)
|
||||
mixed_agent_admin_token = identities.provision(
|
||||
credential_id="mixed-agent-admin",
|
||||
principal_id="agent-with-admin-role",
|
||||
roles=("admin", "agent"),
|
||||
)
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
registry = LocalProjectRegistry(
|
||||
tmp_path / "control" / "registry.json",
|
||||
repository=repository,
|
||||
index_directory=tmp_path / "publishing",
|
||||
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
project_registry=registry,
|
||||
)
|
||||
project = ProjectRecord(
|
||||
project_id="new-research",
|
||||
title="New research",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
description="An administrator-owned research lens.",
|
||||
owners=("owner-operator",),
|
||||
north_star="Investigate a new research area.",
|
||||
key_questions=("What is known?",),
|
||||
autonomy_policy_id="new-policy",
|
||||
)
|
||||
policy = AutoAcceptPolicy(
|
||||
policy_id="new-policy",
|
||||
policy_version=1,
|
||||
project_id="new-research",
|
||||
require_supported_assessment=True,
|
||||
)
|
||||
body = {
|
||||
"project": project.model_dump(mode="json"),
|
||||
"policy": policy.model_dump(mode="json"),
|
||||
}
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://test"
|
||||
) as http:
|
||||
assert (await http.get("/v1/admin/projects")).status_code == 401
|
||||
denied = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
mixed_agent_denied = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {mixed_agent_admin_token}"},
|
||||
)
|
||||
assert mixed_agent_denied.status_code == 403
|
||||
oversized = await http.post(
|
||||
"/v1/admin/projects",
|
||||
content=b" " * (64 * 1024 + 1),
|
||||
headers={
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert oversized.status_code == 413
|
||||
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
diagnostics = await http.get("/v1/diagnostics", headers=headers)
|
||||
assert diagnostics.status_code == 200
|
||||
assert {
|
||||
"admin_list_projects",
|
||||
"project_create",
|
||||
"project_archive",
|
||||
}.issubset(diagnostics.json()["authorized_operations"])
|
||||
created = await http.post("/v1/admin/projects", json=body, headers=headers)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["project_id"] == "new-research"
|
||||
listing = await http.get("/v1/admin/projects", headers=headers)
|
||||
assert [item["project_id"] for item in listing.json()] == ["new-research"]
|
||||
duplicate = await http.post("/v1/admin/projects", json=body, headers=headers)
|
||||
assert duplicate.status_code == 409
|
||||
archived = await http.post("/v1/admin/projects/new-research/archive", headers=headers)
|
||||
assert archived.status_code == 200
|
||||
assert archived.json()["status"] == "archived"
|
||||
assert registry.records()[0].status == ProjectStatus.ARCHIVED
|
||||
|
||||
session = identities.create_browser_session(admin_token)
|
||||
browser_project = project.model_copy(
|
||||
update={
|
||||
"project_id": "browser-created",
|
||||
"title": "Browser created",
|
||||
"autonomy_policy_id": "browser-policy",
|
||||
}
|
||||
)
|
||||
browser_policy = policy.model_copy(
|
||||
update={"policy_id": "browser-policy", "project_id": "browser-created"}
|
||||
)
|
||||
browser_body = {
|
||||
"project": browser_project.model_dump(mode="json"),
|
||||
"policy": browser_policy.model_dump(mode="json"),
|
||||
}
|
||||
cookie = {"Cookie": f"gewu_session={session.session_token}"}
|
||||
without_csrf = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=browser_body,
|
||||
headers={**cookie, "Origin": "http://test"},
|
||||
)
|
||||
assert without_csrf.status_code == 403
|
||||
wrong_origin = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=browser_body,
|
||||
headers={
|
||||
**cookie,
|
||||
"Origin": "https://attacker.example",
|
||||
"X-GEWU-CSRF": session.csrf_token,
|
||||
},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
browser_created = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=browser_body,
|
||||
headers={
|
||||
**cookie,
|
||||
"Origin": "http://test",
|
||||
"X-GEWU-CSRF": session.csrf_token,
|
||||
},
|
||||
)
|
||||
assert browser_created.status_code == 201, browser_created.text
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,439 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import yaml
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.search import CanonicalSearchService
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import EdgeEndpoint, Scope, SearchRequest, TypedEdge
|
||||
from gewu.domain.validation import evidence_digest
|
||||
from tests.domain.proposal_fixtures import CLAIM, EVIDENCE, NODE, SOURCE_REVISION
|
||||
|
||||
|
||||
def _canonical_path(prefix: str, stable_id: str) -> str:
|
||||
key = hashlib.sha256(stable_id.encode()).hexdigest()
|
||||
return f"{prefix}/{key}.yaml"
|
||||
|
||||
|
||||
def test_project_include_resolution_across_http_mcp_and_direct_reads(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
dome_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:scope-default-node",
|
||||
"summary": "scopeprobe dome project record",
|
||||
}
|
||||
)
|
||||
video_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "video:scope-source",
|
||||
"revision_id": "video:scope-source:r1",
|
||||
"title": "Video-only scope source",
|
||||
"ingested_for": ("interactive-video",),
|
||||
"artifact_sha256": None,
|
||||
"media_type": "application/octet-stream",
|
||||
"byte_length": 0,
|
||||
"original_url": "https://video.example/scope-source",
|
||||
"archive_status": "reference-only",
|
||||
}
|
||||
)
|
||||
video_evidence = EVIDENCE.model_copy(
|
||||
update={"source_revision_id": video_source.revision_id}
|
||||
)
|
||||
video_assessment = CLAIM.assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(video_evidence),)}
|
||||
)
|
||||
video_claim = CLAIM.model_copy(
|
||||
update={"evidence": (video_evidence,), "assessment": video_assessment}
|
||||
)
|
||||
video_node = NODE.model_copy(
|
||||
update={
|
||||
"project_id": "interactive-video",
|
||||
"node_id": "video:scope-default-node",
|
||||
"title": "Video scope fixture",
|
||||
"summary": "scopeprobe interactive video project record",
|
||||
"claims": (video_claim,),
|
||||
}
|
||||
)
|
||||
edge = TypedEdge(
|
||||
edge_id="scope:cross-project-edge",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id=dome_node.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=video_node.node_id),
|
||||
)
|
||||
files = {
|
||||
f"projects/{hashlib.sha256(dome_node.project_id.encode()).hexdigest()}/nodes/"
|
||||
f"{hashlib.sha256(dome_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
dome_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{hashlib.sha256(video_node.project_id.encode()).hexdigest()}/nodes/"
|
||||
f"{hashlib.sha256(video_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
video_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
(
|
||||
f"sources/{hashlib.sha256(video_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(video_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
): yaml.safe_dump(video_source.model_dump(mode="json")).encode(),
|
||||
_canonical_path("edges", edge.edge_id): yaml.safe_dump(
|
||||
edge.model_dump(mode="json")
|
||||
).encode(),
|
||||
}
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files=files,
|
||||
message="Add project-scope fixtures",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
all_scope_token = identities.provision(
|
||||
credential_id="all-scope-reader",
|
||||
principal_id="all-scope-reader",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance", "interactive-video", "shared"),
|
||||
)
|
||||
dome_only_token = identities.provision(
|
||||
credential_id="dome-only-reader",
|
||||
principal_id="dome-only-reader",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
search_index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository)
|
||||
indexed_nodes = search_index.canonical_nodes()[1]
|
||||
assert {node.node_id for node in indexed_nodes} == {dome_node.node_id, video_node.node_id}
|
||||
lexical_scope_results = search_index.search(
|
||||
query="scopeprobe",
|
||||
project_ids=("dome-performance", "interactive-video"),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
assert len(lexical_scope_results.nodes) == 2, [
|
||||
item.node.summary for item in lexical_scope_results.nodes
|
||||
]
|
||||
search = CanonicalSearchService(
|
||||
index=search_index,
|
||||
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
|
||||
canonical_head=repository.head,
|
||||
default_includes_provider=lambda project_id: (
|
||||
("interactive-video", "shared") if project_id == "dome-performance" else ()
|
||||
),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
search_service=search,
|
||||
)
|
||||
direct_service_result = search.search(
|
||||
SearchRequest(
|
||||
query="scopeprobe",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
limit=10,
|
||||
budget_tokens=50000,
|
||||
),
|
||||
principal=identities.authenticate(all_scope_token),
|
||||
)
|
||||
assert len(direct_service_result.data) == 2
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://localhost"
|
||||
) as http:
|
||||
default = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert default.status_code == 200, default.text
|
||||
assert {item["node"]["project_id"] for item in default.json()["data"]} == {
|
||||
"dome-performance",
|
||||
"interactive-video",
|
||||
}, default.json()
|
||||
assert video_source.revision_id in {
|
||||
item["source_revision_id"] for item in default.json()["citations"]
|
||||
}
|
||||
|
||||
explicit_empty = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert explicit_empty.status_code == 200, explicit_empty.text
|
||||
assert {item["node"]["project_id"] for item in explicit_empty.json()["data"]} == {
|
||||
"dome-performance"
|
||||
}
|
||||
assert video_source.revision_id not in {
|
||||
item["source_revision_id"] for item in explicit_empty.json()["citations"]
|
||||
}
|
||||
|
||||
explicit_include = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": ["interactive-video"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert explicit_include.status_code == 200, explicit_include.text
|
||||
assert len(explicit_include.json()["data"]) == 2
|
||||
|
||||
denied_default = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert denied_default.status_code == 403
|
||||
denied_explicit_include = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": ["interactive-video"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert denied_explicit_include.status_code == 403
|
||||
allowed_empty = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert allowed_empty.status_code == 200
|
||||
|
||||
default_neighbors = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": dome_node.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert default_neighbors.status_code == 200, default_neighbors.text
|
||||
assert default_neighbors.json()["data"][0]["neighbor"]["id"] == video_node.node_id
|
||||
restricted_neighbors = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": dome_node.node_id,
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert restricted_neighbors.status_code == 200
|
||||
assert restricted_neighbors.json()["data"] == []
|
||||
|
||||
default_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert default_sources.status_code == 200, default_sources.text
|
||||
assert video_source.revision_id in {
|
||||
item["source"]["revision_id"] for item in default_sources.json()["data"]
|
||||
}
|
||||
explicit_empty_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert explicit_empty_sources.status_code == 200
|
||||
assert video_source.revision_id not in {
|
||||
item["source"]["revision_id"] for item in explicit_empty_sources.json()["data"]
|
||||
}
|
||||
|
||||
direct_node_denied = await http.get(
|
||||
f"/v1/projects/interactive-video/nodes/{video_node.node_id}",
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert direct_node_denied.status_code == 403
|
||||
direct_source_denied = await http.get(
|
||||
f"/v1/projects/interactive-video/sources/{video_source.revision_id}",
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert direct_source_denied.status_code == 403
|
||||
direct_node_allowed = await http.get(
|
||||
f"/v1/projects/interactive-video/nodes/{video_node.node_id}",
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert direct_node_allowed.status_code == 200
|
||||
direct_source_allowed = await http.get(
|
||||
f"/v1/projects/interactive-video/sources/{video_source.revision_id}",
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert direct_source_allowed.status_code == 200
|
||||
|
||||
http.headers["Authorization"] = f"Bearer {all_scope_token}"
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
mcp_default = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "scopeprobe",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_default.is_error
|
||||
assert isinstance(mcp_default.content[0], TextContent)
|
||||
default_payload = json.loads(mcp_default.content[0].text)
|
||||
assert len(default_payload["data"]) == 2
|
||||
|
||||
mcp_default_sources = await mcp.call_tool(
|
||||
"list_sources",
|
||||
{
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_default_sources.is_error
|
||||
assert isinstance(mcp_default_sources.content[0], TextContent)
|
||||
assert video_source.revision_id in {
|
||||
row["source"]["revision_id"]
|
||||
for row in json.loads(mcp_default_sources.content[0].text)["data"]
|
||||
}
|
||||
|
||||
mcp_default_neighbors = await mcp.call_tool(
|
||||
"neighbors",
|
||||
{
|
||||
"node_id": dome_node.node_id,
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"edge_types": [],
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_default_neighbors.is_error
|
||||
assert isinstance(mcp_default_neighbors.content[0], TextContent)
|
||||
assert (
|
||||
json.loads(mcp_default_neighbors.content[0].text)["data"][0]["neighbor"][
|
||||
"id"
|
||||
]
|
||||
== video_node.node_id
|
||||
)
|
||||
|
||||
mcp_explicit_empty = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "scopeprobe",
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_explicit_empty.is_error
|
||||
assert isinstance(mcp_explicit_empty.content[0], TextContent)
|
||||
assert len(json.loads(mcp_explicit_empty.content[0].text)["data"]) == 1
|
||||
|
||||
mcp_empty_sources = await mcp.call_tool(
|
||||
"list_sources",
|
||||
{
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_empty_sources.is_error
|
||||
assert isinstance(mcp_empty_sources.content[0], TextContent)
|
||||
assert video_source.revision_id not in {
|
||||
row["source"]["revision_id"]
|
||||
for row in json.loads(mcp_empty_sources.content[0].text)["data"]
|
||||
}
|
||||
|
||||
mcp_empty_neighbors = await mcp.call_tool(
|
||||
"neighbors",
|
||||
{
|
||||
"node_id": dome_node.node_id,
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"edge_types": [],
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_empty_neighbors.is_error
|
||||
assert isinstance(mcp_empty_neighbors.content[0], TextContent)
|
||||
assert json.loads(mcp_empty_neighbors.content[0].text)["data"] == []
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,616 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import yaml
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.search import CanonicalSearchService
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import EdgeEndpoint, NodeType, TypedEdge
|
||||
from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION
|
||||
|
||||
|
||||
def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
|
||||
source_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
|
||||
stale_node = NODE.model_copy(
|
||||
update={
|
||||
"as_of": datetime(2024, 1, 1, tzinfo=UTC),
|
||||
"stale_after": datetime(2025, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
)
|
||||
problem_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:problem-browse-fixture",
|
||||
"node_type": NodeType.PROBLEM,
|
||||
"title": "Problem browse fixture",
|
||||
"value": None,
|
||||
"unit": None,
|
||||
}
|
||||
)
|
||||
problem_key = hashlib.sha256(problem_node.node_id.encode()).hexdigest()
|
||||
older_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "dome:older-pilot-report",
|
||||
"revision_id": "dome:older-pilot-report:r1",
|
||||
"title": "Older pilot installation report",
|
||||
"captured_at": datetime(2025, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
)
|
||||
related = NODE.model_copy(update={"node_id": "z:dome-related"})
|
||||
related_key = hashlib.sha256(related.node_id.encode()).hexdigest()
|
||||
related_two = NODE.model_copy(update={"node_id": "zz:dome-related-two"})
|
||||
related_two_key = hashlib.sha256(related_two.node_id.encode()).hexdigest()
|
||||
edge = TypedEdge(
|
||||
edge_id="dome:related-edge",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=related.node_id),
|
||||
)
|
||||
edge_two = TypedEdge(
|
||||
edge_id="dome:related-edge-two",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=related_two.node_id),
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump(
|
||||
stale_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{project_key}/nodes/{problem_key}.yaml": yaml.safe_dump(
|
||||
problem_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{project_key}/nodes/{related_key}.yaml": yaml.safe_dump(
|
||||
related.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{project_key}/nodes/{related_two_key}.yaml": yaml.safe_dump(
|
||||
related_two.model_dump(mode="json")
|
||||
).encode(),
|
||||
(
|
||||
f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/"
|
||||
f"{source_key}.yaml"
|
||||
): yaml.safe_dump(SOURCE_REVISION.model_dump(mode="json")).encode(),
|
||||
(
|
||||
f"sources/{hashlib.sha256(older_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(older_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
): yaml.safe_dump(older_source.model_dump(mode="json")).encode(),
|
||||
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
edge.model_dump(mode="json")
|
||||
).encode(),
|
||||
(
|
||||
f"edges/{hashlib.sha256(edge_two.edge_id.encode()).hexdigest()}.yaml"
|
||||
): yaml.safe_dump(edge_two.model_dump(mode="json")).encode(),
|
||||
},
|
||||
message="Add searchable node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="search-key",
|
||||
principal_id="search-reader",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
search = CanonicalSearchService(
|
||||
index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository),
|
||||
project_ids_provider=lambda: PROJECTS.keys(),
|
||||
canonical_head=repository.head,
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
search_service=search,
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as http:
|
||||
diagnostic_unbuilt = await http.get("/v1/diagnostics?projects=dome-performance")
|
||||
assert diagnostic_unbuilt.status_code == 200
|
||||
unbuilt_state = diagnostic_unbuilt.json()
|
||||
assert unbuilt_state["index_state"] == "not_built"
|
||||
assert unbuilt_state["index_revision"] is None
|
||||
assert unbuilt_state["index_schema_version"] == 4
|
||||
result = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert result.status_code == 200, result.text
|
||||
envelope = result.json()
|
||||
assert envelope["retrieval_mode"] == "keyword"
|
||||
assert envelope["index_revision"] == repository.head()
|
||||
assert envelope["data"][0]["node"]["node_id"] == NODE.node_id
|
||||
assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
listed_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert listed_sources.status_code == 200, listed_sources.text
|
||||
assert listed_sources.json()["data"][0]["source"]["revision_id"] == (
|
||||
SOURCE_REVISION.revision_id
|
||||
)
|
||||
source_page_one = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
source_cursor = source_page_one.json()["next_cursor"]
|
||||
assert source_cursor
|
||||
source_page_two = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": source_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert source_page_two.status_code == 200
|
||||
assert source_page_two.json()["data"][0]["source"]["revision_id"] == (
|
||||
older_source.revision_id
|
||||
)
|
||||
denied_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["interactive-video"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert denied_sources.status_code == 403
|
||||
problem_filter = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "unmatched phrase",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"node_types": ["problem"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert problem_filter.status_code == 200, problem_filter.text
|
||||
assert problem_filter.json()["data"] == []
|
||||
problem_browse = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"node_types": ["problem"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert problem_browse.status_code == 200, problem_browse.text
|
||||
assert [row["node"]["node_id"] for row in problem_browse.json()["data"]] == [
|
||||
problem_node.node_id
|
||||
]
|
||||
unbounded_browse = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert unbounded_browse.status_code == 422
|
||||
stale_filter = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"stale_only": True,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert stale_filter.status_code == 200, stale_filter.text
|
||||
assert stale_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
|
||||
facet_filter = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"tags": ["attendance"],
|
||||
"audience": ["product"],
|
||||
"use_for": ["comparison"],
|
||||
"domain": ["production"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert facet_filter.status_code == 200, facet_filter.text
|
||||
assert facet_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
|
||||
unmatched_facet = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"domain": ["unrelated-domain"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert unmatched_facet.status_code == 200, unmatched_facet.text
|
||||
assert unmatched_facet.json()["data"] == []
|
||||
diagnostic_current = await http.get("/v1/diagnostics?projects=dome-performance")
|
||||
assert diagnostic_current.status_code == 200
|
||||
current_state = diagnostic_current.json()
|
||||
assert current_state["index_state"] == "current"
|
||||
assert current_state["index_revision"] == repository.head()
|
||||
assert "proposal_submit" not in current_state["authorized_operations"]
|
||||
projects = await http.get("/v1/projects")
|
||||
assert projects.status_code == 200
|
||||
assert projects.json() == ["dome-performance"]
|
||||
page_one = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert page_one.status_code == 200
|
||||
cursor = page_one.json()["next_cursor"]
|
||||
assert cursor
|
||||
page_two = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert page_two.status_code == 200
|
||||
assert (
|
||||
page_two.json()["data"][0]["node"]["node_id"]
|
||||
!= page_one.json()["data"][0]["node"]["node_id"]
|
||||
)
|
||||
reused_cursor = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert reused_cursor.status_code == 422
|
||||
changed_facet_cursor = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"node_types": ["problem"],
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert changed_facet_cursor.status_code == 422
|
||||
changed_tag_cursor = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"tags": ["attendance"],
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert changed_tag_cursor.status_code == 422
|
||||
adjacent = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": NODE.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert adjacent.status_code == 200, adjacent.text
|
||||
assert adjacent.json()["data"][0]["neighbor"]["id"] == related.node_id
|
||||
neighbor_cursor = adjacent.json()["next_cursor"]
|
||||
assert neighbor_cursor
|
||||
adjacent_two = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": NODE.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": neighbor_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert adjacent_two.status_code == 200
|
||||
assert adjacent_two.json()["data"][0]["neighbor"]["id"] == related_two.node_id
|
||||
assert adjacent_two.json()["next_cursor"] is None
|
||||
assert {citation["quote"] for citation in adjacent.json()["citations"]} == {
|
||||
NODE.claims[0].evidence[0].quote
|
||||
}
|
||||
newer = NODE.model_copy(update={"node_id": "new:unrelated-revision-node"})
|
||||
newer_key = hashlib.sha256(newer.node_id.encode()).hexdigest()
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"projects/{project_key}/nodes/{newer_key}.yaml": yaml.safe_dump(
|
||||
newer.model_dump(mode="json")
|
||||
).encode()
|
||||
},
|
||||
message="Advance canonical index for cursor-expiry check",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
diagnostic_lag = await http.get("/v1/diagnostics?projects=dome-performance")
|
||||
assert diagnostic_lag.status_code == 200
|
||||
lag_state = diagnostic_lag.json()
|
||||
assert lag_state["index_state"] == "lagging"
|
||||
assert lag_state["index_revision"] != repository.head()
|
||||
assert lag_state["canonical_revision"] == repository.head()
|
||||
expired_search = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert expired_search.status_code == 409
|
||||
expired_neighbors = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": NODE.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": neighbor_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert expired_neighbors.status_code == 409
|
||||
too_small = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"budget_tokens": 256,
|
||||
},
|
||||
)
|
||||
assert too_small.status_code == 413
|
||||
dated_confidence_search = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"confidences": ["medium"],
|
||||
"as_of_from": "2024-01-01",
|
||||
"as_of_to": "2024-12-31",
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert dated_confidence_search.status_code == 200, dated_confidence_search.text
|
||||
assert [
|
||||
row["node"]["node_id"] for row in dated_confidence_search.json()["data"]
|
||||
] == [NODE.node_id]
|
||||
filtered_page = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"confidences": ["medium"],
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert filtered_page.status_code == 200
|
||||
filter_cursor = filtered_page.json()["next_cursor"]
|
||||
assert filter_cursor
|
||||
changed_filter_page = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"confidences": ["low"],
|
||||
"limit": 1,
|
||||
"cursor": filter_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert changed_filter_page.status_code == 422
|
||||
forbidden = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["interactive-video"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
tools = await mcp.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"diagnostics",
|
||||
"get_node",
|
||||
"node_history",
|
||||
"get_source",
|
||||
"list_projects",
|
||||
"search",
|
||||
"neighbors",
|
||||
"list_sources",
|
||||
}
|
||||
listed = await mcp.call_tool("list_projects", {})
|
||||
assert not listed.is_error
|
||||
assert isinstance(listed.content[0], TextContent)
|
||||
assert json.loads(listed.content[0].text) == ["dome-performance"]
|
||||
searched = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "400 visitors",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert not searched.is_error
|
||||
assert isinstance(searched.content[0], TextContent)
|
||||
assert (
|
||||
json.loads(searched.content[0].text)["data"][0]["node"]["node_id"]
|
||||
== NODE.node_id
|
||||
)
|
||||
dated_confidence_tool = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "visitors",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"confidences": ["medium"],
|
||||
"as_of_from": "2024-01-01",
|
||||
"as_of_to": "2024-12-31",
|
||||
},
|
||||
)
|
||||
assert not dated_confidence_tool.is_error
|
||||
assert [
|
||||
row["node"]["node_id"]
|
||||
for row in json.loads(dated_confidence_tool.content[0].text)["data"]
|
||||
] == [NODE.node_id]
|
||||
filtered_search = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "unmatched phrase",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"node_types": ["problem"],
|
||||
},
|
||||
)
|
||||
assert not filtered_search.is_error
|
||||
assert json.loads(filtered_search.content[0].text)["data"] == []
|
||||
browsed_problems = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"node_types": ["problem"],
|
||||
},
|
||||
)
|
||||
assert not browsed_problems.is_error
|
||||
assert [
|
||||
row["node"]["node_id"]
|
||||
for row in json.loads(browsed_problems.content[0].text)["data"]
|
||||
] == [problem_node.node_id]
|
||||
facet_search = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "400 visitors",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"tags": ["attendance"],
|
||||
"audience": ["product"],
|
||||
"use_for": ["comparison"],
|
||||
"domain": ["production"],
|
||||
},
|
||||
)
|
||||
assert not facet_search.is_error
|
||||
assert (
|
||||
json.loads(facet_search.content[0].text)["data"][0]["node"]["node_id"]
|
||||
== NODE.node_id
|
||||
)
|
||||
listed_sources_tool = await mcp.call_tool(
|
||||
"list_sources",
|
||||
{
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert not listed_sources_tool.is_error
|
||||
assert (
|
||||
json.loads(listed_sources_tool.content[0].text)["data"][0]["source"][
|
||||
"revision_id"
|
||||
]
|
||||
== SOURCE_REVISION.revision_id
|
||||
)
|
||||
adjacent_tool = await mcp.call_tool(
|
||||
"neighbors",
|
||||
{
|
||||
"node_id": NODE.node_id,
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"edge_types": [],
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert not adjacent_tool.is_error
|
||||
assert isinstance(adjacent_tool.content[0], TextContent)
|
||||
assert (
|
||||
json.loads(adjacent_tool.content[0].text)["data"][0]["neighbor"]["id"]
|
||||
== related.node_id
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,374 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp import Client
|
||||
from mcp.server import MCPServer
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
from gewu.domain.contracts import SourceRevisionRecord
|
||||
from gewu.transports.stdio_connector import (
|
||||
SelectedFile,
|
||||
UploadStateStore,
|
||||
_local_source_id,
|
||||
_local_source_revision_id,
|
||||
_read_selected_file,
|
||||
_remote_factory,
|
||||
_selected_media_type,
|
||||
create_connector_server,
|
||||
upload_selected_file,
|
||||
)
|
||||
|
||||
|
||||
def test_connector_uploads_allowlisted_bytes_without_exposing_them_to_local_tools(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
async def exercise() -> None:
|
||||
source = tmp_path / "selected.md"
|
||||
payload = b"# Private source\n" + b"Exact source bytes stay in the connector.\n" * 20_000
|
||||
source.write_bytes(payload)
|
||||
backend = MCPServer("test GEWU backend")
|
||||
chunks: list[bytes] = []
|
||||
interrupted = False
|
||||
extraction_interrupted = False
|
||||
expected_digest = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@backend.tool(name="source_upload_begin", structured_output=False)
|
||||
def begin(
|
||||
project_id: str,
|
||||
filename: str,
|
||||
media_type: str,
|
||||
byte_length: int,
|
||||
expected_sha256: str,
|
||||
) -> str:
|
||||
assert (project_id, filename, media_type, byte_length, expected_sha256) == (
|
||||
"dome-performance",
|
||||
"selected.md",
|
||||
"text/markdown",
|
||||
len(payload),
|
||||
expected_digest,
|
||||
)
|
||||
return json.dumps({"upload_id": "opaque-upload-id"})
|
||||
|
||||
@backend.tool(name="source_upload_chunk", structured_output=False)
|
||||
def chunk(upload_id: str, offset: int, content_base64: str, chunk_sha256: str) -> str:
|
||||
nonlocal interrupted
|
||||
decoded = base64.b64decode(content_base64, validate=True)
|
||||
assert upload_id == "opaque-upload-id"
|
||||
assert hashlib.sha256(decoded).hexdigest() == chunk_sha256
|
||||
assert offset == sum(map(len, chunks))
|
||||
if offset > 0 and not interrupted:
|
||||
interrupted = True
|
||||
raise ValueError("simulated interrupted transfer")
|
||||
chunks.append(decoded)
|
||||
return json.dumps({"upload_id": upload_id})
|
||||
|
||||
@backend.tool(name="source_upload_status", structured_output=False)
|
||||
def status(upload_id: str) -> str:
|
||||
assert upload_id == "opaque-upload-id"
|
||||
return json.dumps({"verified_offsets": [0] if chunks else []})
|
||||
|
||||
@backend.tool(name="source_upload_finalize", structured_output=False)
|
||||
def finalize(upload_id: str) -> str:
|
||||
assert upload_id == "opaque-upload-id"
|
||||
return json.dumps(
|
||||
{"sha256": hashlib.sha256(b"".join(chunks)).hexdigest(), "status": "staged"}
|
||||
)
|
||||
|
||||
@backend.tool(name="source_extract", structured_output=False)
|
||||
def extract(project_id: str, artifact_sha256: str, source_revision_id: str) -> str:
|
||||
nonlocal extraction_interrupted
|
||||
assert project_id == "dome-performance"
|
||||
assert artifact_sha256 == expected_digest
|
||||
assert source_revision_id == "source-r1"
|
||||
if not extraction_interrupted:
|
||||
extraction_interrupted = True
|
||||
raise ValueError("simulated extraction interruption")
|
||||
return json.dumps({"extraction_id": "e" * 64, "blocks": []})
|
||||
|
||||
@asynccontextmanager
|
||||
async def remote_factory() -> AsyncIterator[Client]:
|
||||
async with Client(backend) as remote:
|
||||
yield remote
|
||||
|
||||
state_file = tmp_path / "connector-state.json"
|
||||
selected_files = (SelectedFile("research-note", source),)
|
||||
|
||||
async def connector_call() -> CallToolResult:
|
||||
connector_after_restart = create_connector_server(
|
||||
selected_files, remote_factory, UploadStateStore(state_file)
|
||||
)
|
||||
async with Client(connector_after_restart) as local_after_restart:
|
||||
return await local_after_restart.call_tool(
|
||||
"upload_selected_file",
|
||||
{
|
||||
"file_id": "research-note",
|
||||
"project_id": "dome-performance",
|
||||
"source_revision_id": "source-r1",
|
||||
},
|
||||
)
|
||||
|
||||
connector = create_connector_server(
|
||||
selected_files, remote_factory, UploadStateStore(state_file)
|
||||
)
|
||||
async with Client(connector) as local:
|
||||
tools = await local.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"list_selected_files",
|
||||
"upload_selected_file",
|
||||
}
|
||||
listed = await local.call_tool("list_selected_files", {})
|
||||
assert not listed.is_error
|
||||
assert isinstance(listed.content[0], TextContent)
|
||||
assert "research-note" in listed.content[0].text
|
||||
assert str(source) not in listed.content[0].text
|
||||
denied = await local.call_tool(
|
||||
"upload_selected_file",
|
||||
{
|
||||
"file_id": "arbitrary-path",
|
||||
"project_id": "dome-performance",
|
||||
"source_revision_id": "source-r1",
|
||||
},
|
||||
)
|
||||
assert denied.is_error
|
||||
interrupted_result = await connector_call()
|
||||
assert interrupted_result.is_error
|
||||
assert state_file.exists()
|
||||
finalize_interrupted_result = await connector_call()
|
||||
assert finalize_interrupted_result.is_error
|
||||
assert json.loads(state_file.read_text())
|
||||
restarted_result = await connector_call()
|
||||
assert not restarted_result.is_error
|
||||
assert isinstance(restarted_result.content[0], TextContent)
|
||||
receipt = json.loads(restarted_result.content[0].text)
|
||||
assert receipt["sha256"] == expected_digest
|
||||
assert receipt["byte_length"] == len(payload)
|
||||
assert payload[:80].decode() not in restarted_result.content[0].text
|
||||
source_candidate = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(receipt["source_candidate"]), strict=True
|
||||
)
|
||||
assert source_candidate.archive_status == "archived"
|
||||
assert source_candidate.capture_method == "local-file"
|
||||
assert source_candidate.source_id.startswith("local:")
|
||||
assert source_candidate.revision_id == "source-r1"
|
||||
assert source_candidate.artifact_sha256 == expected_digest
|
||||
assert str(source) not in restarted_result.content[0].text
|
||||
assert b"".join(chunks) == payload
|
||||
state = json.loads(state_file.read_text())
|
||||
assert len(state) == 1
|
||||
assert "upload_id" not in next(iter(state.values()))
|
||||
duplicate_result = await connector_call()
|
||||
assert not duplicate_result.is_error
|
||||
assert json.loads(duplicate_result.content[0].text) == receipt
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_connector_rejects_symlinked_selected_paths(tmp_path: Path) -> None:
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("private")
|
||||
link = tmp_path / "selected.md"
|
||||
link.symlink_to(outside)
|
||||
with pytest.raises(ValueError, match="symbolic links"):
|
||||
_read_selected_file(SelectedFile("selected", link))
|
||||
|
||||
|
||||
def test_local_file_identity_is_path_free_and_revisions_follow_content() -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
captured_at = datetime(2026, 10, 13, tzinfo=UTC)
|
||||
source_id = _local_source_id("research-note")
|
||||
first = _local_source_revision_id(source_id, hashlib.sha256(b"first").hexdigest(), captured_at)
|
||||
second = _local_source_revision_id(
|
||||
source_id, hashlib.sha256(b"second").hexdigest(), captured_at
|
||||
)
|
||||
assert source_id.startswith("local:")
|
||||
assert "/" not in source_id
|
||||
assert first != second
|
||||
assert first.startswith(f"{source_id}:r:")
|
||||
assert len(first) <= 128
|
||||
|
||||
|
||||
def test_selected_file_media_types_are_explicit() -> None:
|
||||
assert _selected_media_type(Path("source.pdf")) == "application/pdf"
|
||||
assert _selected_media_type(Path("source.md")) == "text/markdown"
|
||||
assert _selected_media_type(Path("source.html")) == "text/html"
|
||||
assert _selected_media_type(Path("source.txt")) == "text/plain"
|
||||
assert _selected_media_type(Path("source.vtt")) == "text/vtt"
|
||||
assert _selected_media_type(Path("source.srt")) == "application/x-subrip"
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
_selected_media_type(Path("source.exe"))
|
||||
|
||||
|
||||
def test_vtt_connector_candidate_is_marked_as_transcript(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
source = tmp_path / "interview.vtt"
|
||||
content = b"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nExact words.\n"
|
||||
source.write_bytes(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
|
||||
class Remote:
|
||||
async def call_tool(self, name: str, arguments: dict[str, object]) -> object:
|
||||
response: dict[str, object]
|
||||
if name == "source_upload_begin":
|
||||
response = {"upload_id": "transcript-upload"}
|
||||
elif name == "source_upload_chunk":
|
||||
response = {"upload_id": "transcript-upload"}
|
||||
elif name == "source_upload_finalize":
|
||||
response = {"sha256": digest}
|
||||
elif name == "source_extract":
|
||||
response = {"status": "complete", "extraction_id": "e" * 64, "blocks": []}
|
||||
else:
|
||||
raise AssertionError(f"Unexpected remote tool: {name}")
|
||||
return type(
|
||||
"ToolResult",
|
||||
(),
|
||||
{
|
||||
"is_error": False,
|
||||
"content": [TextContent(type="text", text=json.dumps(response))],
|
||||
},
|
||||
)()
|
||||
|
||||
receipt = await upload_selected_file(
|
||||
Remote(), SelectedFile("interview", source), project_id="dome-performance"
|
||||
)
|
||||
candidate = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(receipt["source_candidate"]), strict=True
|
||||
)
|
||||
assert candidate.capture_method == "transcript"
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_local_connector_returns_archived_candidate_when_pdf_extraction_is_unavailable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
async def exercise() -> None:
|
||||
source = tmp_path / "scanned.pdf"
|
||||
content = b"%PDF-1.4 fixture"
|
||||
source.write_bytes(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
calls: list[str] = []
|
||||
|
||||
class Remote:
|
||||
async def call_tool(self, name: str, arguments: dict[str, object]) -> object:
|
||||
calls.append(name)
|
||||
response: dict[str, object]
|
||||
if name == "source_upload_begin":
|
||||
response = {"upload_id": "active-upload"}
|
||||
elif name == "source_upload_chunk":
|
||||
assert arguments["chunk_sha256"] == digest
|
||||
response = {"upload_id": "active-upload"}
|
||||
elif name == "source_upload_finalize":
|
||||
response = {"sha256": digest}
|
||||
elif name == "source_extract":
|
||||
response = {
|
||||
"status": "unavailable",
|
||||
"error_code": "PDF_NO_TEXT",
|
||||
"blocks": [],
|
||||
}
|
||||
else:
|
||||
raise AssertionError(f"Unexpected remote tool: {name}")
|
||||
return type(
|
||||
"ToolResult",
|
||||
(),
|
||||
{
|
||||
"is_error": False,
|
||||
"content": [TextContent(type="text", text=json.dumps(response))],
|
||||
},
|
||||
)()
|
||||
|
||||
state = UploadStateStore(tmp_path / "state.json")
|
||||
first = await upload_selected_file(
|
||||
Remote(),
|
||||
SelectedFile("scanned-paper", source),
|
||||
project_id="dome-performance",
|
||||
state_store=state,
|
||||
)
|
||||
second = await upload_selected_file(
|
||||
Remote(),
|
||||
SelectedFile("scanned-paper", source),
|
||||
project_id="dome-performance",
|
||||
state_store=state,
|
||||
)
|
||||
|
||||
candidate = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(first["source_candidate"]), strict=True
|
||||
)
|
||||
assert candidate.archive_status == "archived"
|
||||
assert candidate.artifact_sha256 == digest
|
||||
assert len(candidate.revision_id) <= 128
|
||||
assert first["extraction_status"] == "unavailable"
|
||||
assert first["extraction_error_code"] == "PDF_NO_TEXT"
|
||||
assert second["source_candidate"] == first["source_candidate"]
|
||||
assert calls.count("source_upload_begin") == 1
|
||||
assert calls.count("source_extract") == 2
|
||||
state_record = next(iter(json.loads((tmp_path / "state.json").read_text()).values()))
|
||||
assert "completed_receipt" not in state_record
|
||||
assert str(source) not in json.dumps(first)
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_connector_rejects_cleartext_remote_service_urls() -> None:
|
||||
with pytest.raises(ValueError, match="HTTPS"):
|
||||
_remote_factory("http://gewu.example/mcp", "gwu_test")
|
||||
_remote_factory("https://gewu.example/mcp", "gwu_test")
|
||||
_remote_factory("http://localhost:8000/mcp", "gwu_test")
|
||||
|
||||
|
||||
def test_remote_factory_verifies_service_certificate_before_authentication(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import gewu.transports.stdio_connector as connector_module
|
||||
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
class HttpClient:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
observed.update(kwargs)
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
class RemoteClient:
|
||||
def __init__(self, transport: object) -> None:
|
||||
self.transport = transport
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
await self.transport.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
await self.transport.__aexit__(*args)
|
||||
return None
|
||||
|
||||
@asynccontextmanager
|
||||
async def transport_factory(url: str, *, http_client: object):
|
||||
observed["url"] = url
|
||||
observed["http_client"] = http_client
|
||||
yield object()
|
||||
|
||||
monkeypatch.setattr(connector_module.httpx2, "AsyncClient", HttpClient)
|
||||
monkeypatch.setattr(connector_module, "Client", RemoteClient)
|
||||
monkeypatch.setattr(connector_module, "streamable_http_client", transport_factory)
|
||||
|
||||
async def exercise() -> None:
|
||||
async with _remote_factory("https://gewu.example/mcp", "secret-token")() as remote:
|
||||
assert isinstance(remote, RemoteClient)
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert observed["verify"] is True
|
||||
assert observed["headers"] == {"Authorization": "Bearer secret-token"}
|
||||
assert observed["url"] == "https://gewu.example/mcp"
|
||||
assert "secret-token" not in str(observed["url"])
|
||||
Reference in new issue
Block a user