Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

184 lines
6.9 KiB
Python

"""Integration tests for the SCIM 2.0 endpoints against a live Postgres.
These tests drive the real Flask app through its test client and verify
row state in the database configured by ``POSTGRES_URI``. Redis is not
required — the denylist functions are stubbed. They are skipped by the
default ``pytest`` run (``--ignore=tests/integration`` in ``pytest.ini``)
and marked ``@pytest.mark.integration``. Run them locally with::
.venv/bin/python -m pytest tests/integration/test_scim.py -q --no-cov \\
-p no:cacheprovider --override-ini "addopts="
"""
from __future__ import annotations
import uuid
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from sqlalchemy import text
from application.core.settings import settings
from application.storage.db.repositories.auth_events import AuthEventsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
SCIM_TOKEN = "scim-test-token"
AUTH = {"Authorization": f"Bearer {SCIM_TOKEN}"}
ERROR_URN = "urn:ietf:params:scim:api:messages:2.0:Error"
PATCH_OP_URN = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not settings.POSTGRES_URI,
reason="POSTGRES_URI not set — skipping SCIM integration tests",
),
]
@pytest.fixture(scope="module")
def app():
"""Real Flask app; /scim/ paths bypass JWT auth so no handle_auth patching is needed."""
from application.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def scim_env(monkeypatch):
"""Enable SCIM on the settings singleton and stub the Redis denylist."""
monkeypatch.setattr(settings, "SCIM_ENABLED", True)
monkeypatch.setattr(settings, "SCIM_TOKEN", SCIM_TOKEN)
with patch("application.api.scim.routes.deny_user") as deny_user_mock:
yield SimpleNamespace(deny_user=deny_user_mock)
@pytest.fixture
def scim_user_name():
"""Unique per-run userName; deletes the user + audit rows afterwards."""
name = f"scim-it-{uuid.uuid4().hex[:10]}@example.com"
yield name
with db_session() as conn:
conn.execute(text("DELETE FROM auth_events WHERE user_id = :user_id"), {"user_id": name})
conn.execute(text("DELETE FROM users WHERE user_id = :user_id"), {"user_id": name})
def _fetch_user(user_name: str):
with db_readonly() as conn:
return UsersRepository(conn).get(user_name)
def _fetch_events(user_name: str):
with db_readonly() as conn:
return AuthEventsRepository(conn).list_recent(user_name)
@pytest.mark.integration
class TestScimLifecycle:
def test_full_user_lifecycle(self, client, scim_env, scim_user_name):
# Create
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": scim_user_name})
assert response.status_code == 201, response.get_data(as_text=True)
created = response.get_json()
pk = created["id"]
assert created["userName"] == scim_user_name
assert created["active"] is True
assert created["emails"] == [{"value": scim_user_name, "primary": True}]
assert response.headers["Location"].endswith(f"/scim/v2/Users/{pk}")
row = _fetch_user(scim_user_name)
assert row is not None
assert row["active"] is True
assert [event["event"] for event in _fetch_events(scim_user_name)] == ["scim_created"]
# GET by id
response = client.get(f"/scim/v2/Users/{pk}", headers=AUTH)
assert response.status_code == 200
assert response.get_json()["id"] == pk
# List with exact userName filter
response = client.get(
"/scim/v2/Users",
headers=AUTH,
query_string={"filter": f'userName eq "{scim_user_name}"'},
)
assert response.status_code == 200
body = response.get_json()
assert body["totalResults"] == 1
assert body["itemsPerPage"] == 1
assert body["Resources"][0]["id"] == pk
# Deactivate via PATCH (Okta no-path form with string value)
response = client.patch(
f"/scim/v2/Users/{pk}",
headers=AUTH,
json={
"schemas": [PATCH_OP_URN],
"Operations": [{"op": "replace", "value": {"active": "False"}}],
},
)
assert response.status_code == 200
assert response.get_json()["active"] is False
assert _fetch_user(scim_user_name)["active"] is False
deactivations = [e for e in _fetch_events(scim_user_name) if e["event"] == "scim_deactivated"]
assert len(deactivations) == 1
assert deactivations[0]["metadata"] == {"via": "scim"}
scim_env.deny_user.assert_called_once_with(scim_user_name)
# Reactivate via PUT (full replace; only active is honored)
response = client.put(
f"/scim/v2/Users/{pk}",
headers=AUTH,
json={"userName": scim_user_name, "active": True},
)
assert response.status_code == 200
assert response.get_json()["active"] is True
assert _fetch_user(scim_user_name)["active"] is True
# Reactivation no longer clears the denylist (watermark model).
scim_env.deny_user.assert_called_once_with(scim_user_name)
assert any(e["event"] == "scim_reactivated" for e in _fetch_events(scim_user_name))
# Soft delete deactivates again
response = client.delete(f"/scim/v2/Users/{pk}", headers=AUTH)
assert response.status_code == 204
assert response.data == b""
assert _fetch_user(scim_user_name)["active"] is False
assert scim_env.deny_user.call_count == 2
# Duplicate create conflicts (row still exists after soft delete)
response = client.post("/scim/v2/Users", headers=AUTH, json={"userName": scim_user_name})
assert response.status_code == 409
body = response.get_json()
assert body["schemas"] == [ERROR_URN]
assert body["scimType"] == "uniqueness"
@pytest.mark.integration
class TestScimAccess:
def test_wrong_bearer_token_rejected(self, client, scim_env):
response = client.get("/scim/v2/Users", headers={"Authorization": "Bearer wrong"})
assert response.status_code == 401
assert response.get_json()["schemas"] == [ERROR_URN]
def test_get_user_with_malformed_uuid_returns_404(self, client, scim_env):
response = client.get("/scim/v2/Users/not-a-uuid", headers=AUTH)
assert response.status_code == 404
assert response.get_json()["status"] == "404"
def test_service_provider_config_served(self, client, scim_env):
response = client.get("/scim/v2/ServiceProviderConfig", headers=AUTH)
assert response.status_code == 200
assert response.content_type.startswith("application/scim+json")
assert response.get_json()["patch"] == {"supported": True}