diff --git a/integrations/microsoft_sharepoint/src/haystack_integrations/components/retrievers/microsoft_sharepoint/retriever.py b/integrations/microsoft_sharepoint/src/haystack_integrations/components/retrievers/microsoft_sharepoint/retriever.py index 9d2b62df85..12b37c3915 100644 --- a/integrations/microsoft_sharepoint/src/haystack_integrations/components/retrievers/microsoft_sharepoint/retriever.py +++ b/integrations/microsoft_sharepoint/src/haystack_integrations/components/retrievers/microsoft_sharepoint/retriever.py @@ -44,10 +44,10 @@ class MSSharePointRetriever: download or convert the underlying files. Compose a downstream fetcher/converter (such as `MSSharePointFetcher`) when full content is needed. - The retriever takes a per-user `access_token` as a run input, typically wired - from an upstream `OAuthResolver`. The token must carry delegated Microsoft Graph permissions - (for example `Files.Read.All` and, for site/list scoping, `Sites.Read.All`). The Search API supports - delegated permissions only. + The retriever takes an `access_token` as a run input. For delegated (on-behalf-of) auth, pass a + per-user token wired from an upstream `OAuthResolver`; the token must carry delegated Microsoft Graph + permissions (for example `Files.Read.All` and, for site/list scoping, `Sites.Read.All`). For app-only + (client-credentials) auth, pass an application token and set the `region` parameter at init time. ### Usage example ```python @@ -76,6 +76,7 @@ def __init__( top_k: int = 10, fields: list[str] | None = None, query_template: str | None = None, + region: str | None = None, graph_url: str = DEFAULT_GRAPH_URL, timeout: float = 30.0, max_retries: int = 3, @@ -96,6 +97,10 @@ def __init__( `'{searchTerms} path:"https://contoso.sharepoint.com/sites/Team"'`. The literal `{searchTerms}` placeholder is replaced by the run-time query. The template uses [Keyword Query Language (KQL)](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference). + :param region: The region code for the Microsoft Search index, for example `"NAM"`, `"EUR"`, or `"APC"`. + Required when using application permissions (app-only / client-credentials auth); omit for delegated + (on-behalf-of) tokens. See the `region` property of the + [searchRequest resource](https://learn.microsoft.com/en-us/graph/api/resources/searchrequest). :param graph_url: The Microsoft Graph base URL. Defaults to `https://graph.microsoft.com/v1.0`. Override for sovereign clouds. :param timeout: The HTTP timeout in seconds for each request to Microsoft Graph. @@ -121,6 +126,7 @@ def __init__( self.top_k = top_k self.fields = fields self.query_template = query_template + self.region = region self.graph_url = graph_url.rstrip("/") self.timeout = timeout self.max_retries = max_retries @@ -134,9 +140,9 @@ def run(self, query: str, access_token: str | Secret, top_k: int | None = None) operators directly in the query, for example `filetype:docx`, `author:"Jane Doe"`, or `path:"https://contoso.sharepoint.com/sites/Team"`. See the [KQL syntax reference](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference). - :param access_token: A delegated Microsoft Graph bearer token for the user whose content is searched, - typically wired from an upstream `OAuthResolver` (which emits a plain `str`). A `Secret` is also - accepted and resolved internally. + :param access_token: A Microsoft Graph bearer token. For delegated auth, pass a per-user token wired from + an upstream `OAuthResolver`. For app-only (client-credentials) auth, pass an application token and set + `region` at init time. A `Secret` is also accepted and resolved internally. :param top_k: Overrides the `top_k` configured at initialization for this run. :returns: A dictionary with a `documents` key holding the list of retrieved `Document` objects. :raises SharePointConfigError: If `access_token` is a `Secret` that does not resolve to a string. @@ -171,9 +177,9 @@ async def run_async( operators directly in the query, for example `filetype:docx`, `author:"Jane Doe"`, or `path:"https://contoso.sharepoint.com/sites/Team"`. See the [KQL syntax reference](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference). - :param access_token: A delegated Microsoft Graph bearer token for the user whose content is searched, - typically wired from an upstream `OAuthResolver` (which emits a plain `str`). A `Secret` is also - accepted and resolved internally. + :param access_token: A Microsoft Graph bearer token. For delegated auth, pass a per-user token wired from + an upstream `OAuthResolver`. For app-only (client-credentials) auth, pass an application token and set + `region` at init time. A `Secret` is also accepted and resolved internally. :param top_k: Overrides the `top_k` configured at initialization for this run. :returns: A dictionary with a `documents` key holding the list of retrieved `Document` objects. :raises SharePointConfigError: If `access_token` is a `Secret` that does not resolve to a string. @@ -215,6 +221,8 @@ def _build_request_body(self, query: str, offset: int, size: int) -> dict[str, A } if self.fields: request["fields"] = self.fields + if self.region: + request["region"] = self.region return {"requests": [request]} @@ -328,6 +336,7 @@ def to_dict(self) -> dict[str, Any]: top_k=self.top_k, fields=self.fields, query_template=self.query_template, + region=self.region, graph_url=self.graph_url, timeout=self.timeout, max_retries=self.max_retries, diff --git a/integrations/microsoft_sharepoint/tests/test_retriever.py b/integrations/microsoft_sharepoint/tests/test_retriever.py index bac208d650..1bf7a610f9 100644 --- a/integrations/microsoft_sharepoint/tests/test_retriever.py +++ b/integrations/microsoft_sharepoint/tests/test_retriever.py @@ -7,6 +7,7 @@ import httpx import pytest +import requests from haystack import Document, Pipeline from haystack.utils import Secret @@ -100,6 +101,7 @@ def test_defaults(self): assert retriever.top_k == 10 assert retriever.fields is None assert retriever.query_template is None + assert retriever.region is None assert retriever.graph_url == "https://graph.microsoft.com/v1.0" assert retriever.timeout == 30.0 assert retriever.max_retries == 3 @@ -140,12 +142,18 @@ def test_to_dict(self): "top_k": 5, "fields": ["title"], "query_template": '{searchTerms} path:"https://x"', + "region": None, "graph_url": "https://graph.microsoft.us/v1.0", "timeout": 10.0, "max_retries": 1, }, } + def test_to_dict_with_region(self): + retriever = MSSharePointRetriever(region="NAM") + data = retriever.to_dict() + assert data["init_parameters"]["region"] == "NAM" + def test_from_dict_round_trip(self): retriever = MSSharePointRetriever(entity_types=["site"], top_k=7, query_template="{searchTerms}") restored = MSSharePointRetriever.from_dict(retriever.to_dict()) @@ -241,6 +249,19 @@ def test_no_query_template_omits_key(self): assert "queryTemplate" not in mock_post.call_args.kwargs["json"]["requests"][0]["query"] assert "fields" not in mock_post.call_args.kwargs["json"]["requests"][0] + def test_region_included_in_request_body_when_set(self): + retriever = MSSharePointRetriever(region="NAM") + with patch.object(httpx.Client, "post", return_value=_make_response(json_body=EMPTY_RESPONSE)) as mock_post: + retriever.run(query="q", access_token="tok") + request = mock_post.call_args.kwargs["json"]["requests"][0] + assert request["region"] == "NAM" + + def test_region_omitted_from_request_body_when_none(self): + retriever = MSSharePointRetriever() + with patch.object(httpx.Client, "post", return_value=_make_response(json_body=EMPTY_RESPONSE)) as mock_post: + retriever.run(query="q", access_token="tok") + assert "region" not in mock_post.call_args.kwargs["json"]["requests"][0] + def test_empty_results(self): retriever = MSSharePointRetriever() with patch.object(httpx.Client, "post", return_value=_make_response(json_body=EMPTY_RESPONSE)): @@ -410,3 +431,53 @@ def test_run_against_microsoft_graph(self): for doc in documents: assert isinstance(doc, Document) assert "web_url" in doc.meta + + +@pytest.mark.integration +@pytest.mark.skipif( + not all(os.environ.get(v) for v in ("MS_GRAPH_TENANT_ID", "MS_GRAPH_CLIENT_ID", "MS_GRAPH_CLIENT_SECRET")), + reason="MS_GRAPH_TENANT_ID / MS_GRAPH_CLIENT_ID / MS_GRAPH_CLIENT_SECRET not set", +) +class TestLiveAppOnly: + """ + End-to-end tests using app-only (client credentials) authentication. + + Set these env vars before running: + MS_GRAPH_TENANT_ID — Azure AD tenant ID + MS_GRAPH_CLIENT_ID — App registration client ID + MS_GRAPH_CLIENT_SECRET — App registration client secret + MS_GRAPH_REGION — Search region, e.g. "NAM" (default: "DEU") + + Run with: + hatch run test:integration + """ + + @staticmethod + def _get_app_token() -> str: + tenant_id = os.environ["MS_GRAPH_TENANT_ID"] + client_id = os.environ["MS_GRAPH_CLIENT_ID"] + client_secret = os.environ["MS_GRAPH_CLIENT_SECRET"] + response = requests.post( + f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", + data={ + "client_id": client_id, + "client_secret": client_secret, + "scope": "https://graph.microsoft.com/.default", + "grant_type": "client_credentials", + }, + timeout=30, + ) + response.raise_for_status() + return response.json()["access_token"] + + def test_app_only_search_with_region_succeeds(self): + token = self._get_app_token() + region = os.environ.get("MS_GRAPH_REGION", "DEU") + + # With region, the request should succeed. + retriever = MSSharePointRetriever(top_k=3, region=region, max_retries=0) + documents = retriever.run(query="test", access_token=token)["documents"] + assert isinstance(documents, list) + for doc in documents: + assert isinstance(doc, Document) + assert "web_url" in doc.meta