diff --git a/README.md b/README.md index 5971f9c..b4ab491 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,16 @@ uv run ruff check . ``` +Coverage report: + +```bash +uv run pytest --cov=gnexus_creds --cov-report=term-missing +``` + +The MVP backend currently targets focused coverage for core services, REST, +auth/session, MCP, and production configuration. UI end-to-end checks remain a +manual smoke-testing step. + ## Demo Data After applying migrations against a configured PostgreSQL database: diff --git a/gnexus_creds/api.py b/gnexus_creds/api.py index aa28308..65c1c7a 100644 --- a/gnexus_creds/api.py +++ b/gnexus_creds/api.py @@ -259,6 +259,7 @@ tag_rows = db.scalars( select(distinct(SecretTag.name)) .where(SecretTag.user_id == actor.user.id, func.lower(SecretTag.name).like(like)) + .order_by(SecretTag.name) .limit(20) ) return {"categories": list(category_rows), "tags": list(tag_rows)} diff --git a/gnexus_creds/auth.py b/gnexus_creds/auth.py index c958230..f0b178e 100644 --- a/gnexus_creds/auth.py +++ b/gnexus_creds/auth.py @@ -29,6 +29,7 @@ if actor: actor.ip_address = request.client.host if request.client else None actor.user_agent = request.headers.get("user-agent") + db.commit() return actor session_id = request.cookies.get(get_settings().session_cookie_name) user = get_session_user(db, session_id) diff --git a/gnexus_creds/mcp.py b/gnexus_creds/mcp.py index 79353ee..205c699 100644 --- a/gnexus_creds/mcp.py +++ b/gnexus_creds/mcp.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from gnexus_creds.auth import actor_from_request @@ -42,7 +42,7 @@ class ToolCall(BaseModel): - arguments: dict = {} + arguments: dict = Field(default_factory=dict) def _mcp_actor(actor: Actor) -> Actor: diff --git a/gnexus_creds/mcp_protocol.py b/gnexus_creds/mcp_protocol.py index c5f098a..8baba13 100644 --- a/gnexus_creds/mcp_protocol.py +++ b/gnexus_creds/mcp_protocol.py @@ -33,6 +33,7 @@ actor = authenticate_api_token(db, token) if actor is None or Scope.mcp.value not in actor.api_token.scopes: return None + db.commit() return AccessToken( token=token, client_id=str(actor.user.id), @@ -47,6 +48,7 @@ actor = authenticate_api_token(db, access_token.token) if actor is None or actor.api_token is None or Scope.mcp.value not in actor.api_token.scopes: raise ValueError("MCP token is invalid or missing required scope.") + db.commit() require_enabled_user(actor.user) actor.channel = "mcp" return actor diff --git a/gnexus_creds/services.py b/gnexus_creds/services.py index ddc38fe..3190cf1 100644 --- a/gnexus_creds/services.py +++ b/gnexus_creds/services.py @@ -371,19 +371,15 @@ stmt = stmt.where(Secret.status == status.value) if q: like = f"%{q.lower()}%" - stmt = ( - stmt.join(SecretVersion) - .outerjoin(SecretTag) - .where( - or_( - func.lower(Secret.title).like(like), - func.lower(Secret.purpose).like(like), - func.lower(Secret.category).like(like), - func.lower(Secret.source).like(like), - func.lower(Secret.notes).like(like), - func.lower(SecretTag.name).like(like), - SecretVersion.search_text.ilike(like), - ) + stmt = stmt.where( + or_( + func.lower(Secret.title).like(like), + func.lower(Secret.purpose).like(like), + func.lower(Secret.category).like(like), + func.lower(Secret.source).like(like), + func.lower(Secret.notes).like(like), + Secret.tags.any(func.lower(SecretTag.name).like(like)), + Secret.versions.any(SecretVersion.search_text.ilike(like)), ) ) count_stmt = select(func.count()).select_from(stmt.subquery()) diff --git a/tests/conftest.py b/tests/conftest.py index 7172f0e..a9521cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,8 @@ connect_args={"check_same_thread": False}, ) Base.metadata.create_all(engine) - return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + yield sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + engine.dispose() @pytest.fixture() diff --git a/tests/test_api.py b/tests/test_api.py index 6547822..20c07be 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -129,6 +129,79 @@ @pytest.mark.anyio +async def test_rest_search_total_deduplicates_versions_and_tags(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create_response = await client.post( + "/api/v1/secrets", + json={ + "title": "Deploy target", + "tags": ["deploy", "deploy-alt"], + "fields": [{"name": "username", "value": "deploy", "encrypted": False}], + }, + ) + secret_id = create_response.json()["id"] + await client.patch( + f"/api/v1/secrets/{secret_id}", + json={"fields": [{"name": "username", "value": "deploy-v2", "encrypted": False}]}, + ) + + response = await client.get("/api/v1/secrets", params={"q": "deploy"}) + + assert response.status_code == 200 + assert response.json()["total"] == 1 + assert len(response.json()["items"]) == 1 + + +@pytest.mark.anyio +async def test_rest_taxonomy_audit_and_token_management(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create_response = await client.post( + "/api/v1/secrets", + json={ + "title": "Taxonomy", + "category": "infra", + "tags": ["ssh", "server"], + "fields": [{"name": "username", "value": "ops", "encrypted": False}], + }, + ) + secret_id = create_response.json()["id"] + await client.post(f"/api/v1/secrets/{secret_id}/reveal") + + categories_response = await client.get("/api/v1/categories") + tags_response = await client.get("/api/v1/tags") + suggestions_response = await client.get("/api/v1/suggestions", params={"q": "s"}) + audit_response = await client.get("/api/v1/audit-events") + secret_audit_response = await client.get(f"/api/v1/secrets/{secret_id}/audit-events") + + token_create_response = await client.post( + "/api/v1/api-tokens", + json={"name": "REST client", "scopes": ["read", "reveal"]}, + ) + token_id = token_create_response.json()["id"] + token_list_response = await client.get("/api/v1/api-tokens") + revoke_response = await client.delete(f"/api/v1/api-tokens/{token_id}") + + assert categories_response.status_code == 200 + assert categories_response.json() == ["infra"] + assert tags_response.status_code == 200 + assert tags_response.json() == ["server", "ssh"] + assert suggestions_response.status_code == 200 + assert suggestions_response.json()["tags"] == ["server", "ssh"] + assert audit_response.status_code == 200 + assert audit_response.json()["total"] >= 2 + assert secret_audit_response.status_code == 200 + assert {event["action"] for event in secret_audit_response.json()["items"]} >= { + "secret.created", + "secret.revealed", + } + assert token_create_response.status_code == 200 + assert token_create_response.json()["token"].startswith("gcr_") + assert token_list_response.status_code == 200 + assert token_list_response.json()[0]["name"] == "REST client" + assert revoke_response.status_code == 204 + + +@pytest.mark.anyio async def test_failed_access_is_aggregated(auth_app, db_session): async with AsyncClient(transport=ASGITransport(app=auth_app), base_url="http://test") as client: assert (await client.get("/api/v1/me")).status_code == 401 @@ -150,15 +223,16 @@ ) read_token = "gcr_read_secret" reveal_token = "gcr_reveal_secret" + read_token_row = ApiToken( + user_id=user.id, + public_id="read", + name="read", + token_hash=crypto.token_hash(read_token), + scopes=["read"], + ) db_session.add_all( [ - ApiToken( - user_id=user.id, - public_id="read", - name="read", - token_hash=crypto.token_hash(read_token), - scopes=["read"], - ), + read_token_row, ApiToken( user_id=user.id, public_id="reveal", @@ -176,6 +250,8 @@ ) assert list_response.status_code == 200 assert list_response.json()["total"] == 1 + db_session.expire_all() + assert db_session.get(ApiToken, read_token_row.id).last_used_at is not None denied_response = await client.post( f"/api/v1/secrets/{created.id}/reveal", diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 58d3ea8..114e188 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -107,6 +107,69 @@ @pytest.mark.anyio +async def test_mcp_http_adapter_tool_variants(app, db_session, actor): + actor.channel = "mcp" + actor.api_token = ApiToken( + user_id=actor.user.id, + public_id="mcp-write", + name="mcp-write", + token_hash="hash", + scopes=["mcp", "read", "reveal", "write"], + ) + secret = create_secret( + db_session, + Actor(user=actor.user, channel="ui"), + SecretCreate( + title="MCP variants", + allow_mcp=True, + fields=[SecretFieldIn(name="username", value="variant", encrypted=False)], + ), + ) + db_session.commit() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + sse_response = await client.get("/mcp/sse") + get_response = await client.post( + "/mcp/tools/get_secret", + json={"arguments": {"secret_id": str(secret.id)}}, + ) + create_response = await client.post( + "/mcp/tools/create_secret", + json={ + "arguments": { + "title": "MCP adapter created", + "allow_mcp": True, + "fields": [{"name": "token", "value": "adapter", "encrypted": True}], + } + }, + ) + status_response = await client.post( + "/mcp/tools/set_secret_status", + json={"arguments": {"secret_id": str(secret.id), "status": "outdated"}}, + ) + archive_response = await client.post( + "/mcp/tools/archive_secret", + json={"arguments": {"secret_id": str(secret.id)}}, + ) + missing_response = await client.post( + "/mcp/tools/nope", + json={"arguments": {}}, + ) + + assert sse_response.status_code == 200 + assert "search_secrets" in sse_response.text + assert get_response.status_code == 200 + assert get_response.json()["title"] == "MCP variants" + assert create_response.status_code == 200 + assert create_response.json()["title"] == "MCP adapter created" + assert status_response.status_code == 200 + assert status_response.json()["status"] == "outdated" + assert archive_response.status_code == 200 + assert archive_response.json()["archived"] is True + assert missing_response.status_code == 404 + + +@pytest.mark.anyio async def test_mcp_protocol_streamable_http_tools(session_factory, db_session, user, monkeypatch): monkeypatch.setattr("gnexus_creds.mcp_protocol.SessionLocal", session_factory)