diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py index d86126474..a5ad6458b 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py @@ -40,6 +40,7 @@ from ..runner import CheckResult, Priority, Role, Status from .. import live_egress_probe from ..agent_scope import resolve_agent_directory +from ._da_connection_refs import workday_shared_connection_parameters from .infrastructure import ( _infra_003_directive, _infra_003_probe_layer_note, @@ -77,6 +78,13 @@ }, } +_WORKDAY_SHARED_ENV_KEYS = ( + "tenantName", + "token:ResourceUri", + "token:WorkdayTokenUri", + "token:WorkdayClientId", +) + # ───────────────────────────────────────────────────────────────────────── # Workday install-flavor fingerprint (WD-PKG-001 / WD-CONN-012) # ───────────────────────────────────────────────────────────────────────── @@ -651,6 +659,18 @@ def run_workday_checks(runner) -> list[CheckResult]: # when the kit-side Workday install isn't deployed yet. results.extend(_check_entra_workday_federation_alignment(runner)) + # WD-ENV-001 — DA Workday tenant/OAuth configuration from the native + # AgentBuilder components payload. Run it before the legacy no-Workday + # early-return only when the native client exists or this exact checkpoint + # was requested; Graph-only no-Dataverse probes must stay non-interactive. + should_run_da_env = ( + getattr(runner, "agentbuilder", None) is not None + or str(getattr(runner, "scope", "")) == "checkpoint:WD-ENV-001" + ) + if should_run_da_env: + results.extend(_check_da_env_config(runner)) + runner._da_env_config_checked = True + # If neither flows nor any Workday connection references are # present, this tenant has no Workday integration. Skip the # downstream Workday-specific checks (preserves the pre-existing @@ -672,7 +692,7 @@ def run_workday_checks(runner) -> list[CheckResult]: print("\n Running Workday deep validation...") - # --- Environment Variables --- + # --- Legacy Environment Variables --- results.extend(_check_env_vars(runner)) # --- ISU username vs Entra UPN format alignment --- @@ -1727,15 +1747,89 @@ def _simplified_install_skip( ) +def _check_da_env_config(runner) -> list[CheckResult]: + """WD-ENV-001 — validate Workday tenant/OAuth config from DA components.""" + try: + values, unavailable_reason = workday_shared_connection_parameters(runner) + except ValueError as exc: + return [CheckResult(roles=[Role.ESS_MAKER.value], + checkpoint_id="WD-ENV-001", category="Workday", + priority=Priority.CRITICAL.value, status=Status.WARNING.value, + description="Workday tenant and OAuth connection configuration", + result=f"Unable to run WD-ENV-001: ValueError: {exc}", + remediation=( + "Re-run FlightCheck; if this persists, report the checkpoint " + "ID (WD-ENV-001) and the error above." + ), + doc_link=f"{DOC_BASE}/workday-simplified-setup", + )] + + if values is None: + return [CheckResult(roles=[Role.ESS_MAKER.value], + checkpoint_id="WD-ENV-001", category="Workday", + priority=Priority.CRITICAL.value, status=Status.SKIPPED.value, + description="Workday tenant and OAuth connection configuration", + result=( + "AgentBuilder client or active-agent botId not available — " + "skipping the Workday tenant configuration check." + ), + remediation=( + "Run FlightCheck with native AgentBuilder access and a " + "configured active-agent botId." + ), + doc_link=f"{DOC_BASE}/workday-simplified-setup", + )] + + missing = [key for key in _WORKDAY_SHARED_ENV_KEYS if not values.get(key)] + if missing: + reason = f" {unavailable_reason}." if unavailable_reason else "" + return [CheckResult(roles=[Role.ESS_MAKER.value], + checkpoint_id="WD-ENV-001", category="Workday", + priority=Priority.CRITICAL.value, status=Status.FAILED.value, + description="Workday tenant and OAuth connection configuration", + result=( + "Workday sharedConnectionParameters.values is missing " + f"required entries: {', '.join(missing)}.{reason}" + ), + remediation=( + "Reconnect the Workday connection from Copilot Studio so " + "tenantName, token:ResourceUri, token:WorkdayTokenUri, and " + "token:WorkdayClientId are captured on the Workday connection " + "reference." + ), + doc_link=f"{DOC_BASE}/workday-simplified-setup", + )] + + return [CheckResult(roles=[Role.ESS_MAKER.value], + checkpoint_id="WD-ENV-001", category="Workday", + priority=Priority.CRITICAL.value, status=Status.PASSED.value, + description="Workday tenant and OAuth connection configuration", + result=( + "Workday tenant and OAuth configuration is present in " + "sharedConnectionParameters.values: tenantName=" + f"{values['tenantName']}, token:ResourceUri=" + f"{values['token:ResourceUri']}." + ), + doc_link=f"{DOC_BASE}/workday-simplified-setup", + )] + + def _check_env_vars(runner) -> list[CheckResult]: - """Validate Workday environment variables in Dataverse. + """Validate legacy Workday environment variables in Dataverse. Gated on `runner._workday_package_flavor`: skipped on - `"simplified"` because the three env vars (ISU account name, - RaaS report name, RaaS report instance) are only consumed by the - full / legacy install's RaaS code path. See + `"simplified"` because the legacy RaaS report-name variables are + only consumed by the full / legacy install's RaaS code path. See `_simplified_install_skip` for the SKIP message contract. """ + include_legacy_wd_env_001 = not getattr( + runner, "_da_env_config_checked", False + ) + legacy_vars = { + var_name: meta + for var_name, meta in ENV_VARS.items() + if include_legacy_wd_env_001 or meta["id"] != "WD-ENV-001" + } flavor = getattr(runner, "_workday_package_flavor", None) if flavor == "simplified": return [ @@ -1747,7 +1841,7 @@ def _check_env_vars(runner) -> list[CheckResult]: else Priority.HIGH.value ), ) - for meta in ENV_VARS.values() + for meta in legacy_vars.values() ] results = [] @@ -1755,13 +1849,22 @@ def _check_env_vars(runner) -> list[CheckResult]: dv_token = runner.dv_token if not env_url or not dv_token: - results.append(CheckResult(roles=[Role.POWER_PLATFORM_ADMIN.value], - checkpoint_id="WD-ENV-001", category="Workday", - priority=Priority.CRITICAL.value, status=Status.SKIPPED.value, - description="Workday environment variables", - result="Dataverse token not available — skipping env var checks", - )) - return results + return [ + CheckResult(roles=[Role.POWER_PLATFORM_ADMIN.value], + checkpoint_id=meta["id"], category="Workday", + priority=( + Priority.CRITICAL.value if meta["critical"] + else Priority.HIGH.value + ), + status=Status.SKIPPED.value, + description=meta["description"], + result=( + "Dataverse token not available — skipping legacy Workday " + "environment variable checks" + ), + ) + for meta in legacy_vars.values() + ] try: # Import Dataverse query helper from auth.py @@ -1790,7 +1893,7 @@ def _check_env_vars(runner) -> list[CheckResult]: schema = def_map[def_id].get("schemaname", "") val_map[schema] = v.get("value", "") - for var_name, meta in ENV_VARS.items(): + for var_name, meta in legacy_vars.items(): actual_value = None # Find by partial match on schema name for k, v in val_map.items(): @@ -1826,8 +1929,8 @@ def _check_env_vars(runner) -> list[CheckResult]: )) except Exception as e: results.append(CheckResult(roles=[Role.POWER_PLATFORM_ADMIN.value], - checkpoint_id="WD-ENV-001", category="Workday", - priority=Priority.CRITICAL.value, status=Status.WARNING.value, + checkpoint_id="WD-ENV-002", category="Workday", + priority=Priority.HIGH.value, status=Status.WARNING.value, description="Workday environment variables", result=f"Unable to check: {e}", )) diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py index 12746ce56..c639e1303 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -20,13 +20,16 @@ (never assert a verdict from an unconfirmed API response shape) — this checkpoint echoes the observed ``connectionParametersSet.name`` for the operator to confirm, rather than PASS/FAIL on a guessed value. - * ``DV-CONN-001`` (S5.4) — the Dataverse connection reference the extension - pack ships (``…_92b66``, connector ``shared_commondataserviceforapps``) is - bound to an **active** connection, and its owner is echoed so the operator - can confirm it is their **own** account. Programmatic PASS/FAIL on a - documented-tier Dataverse ``connectionreferences`` read. - * ``WD-REST-001`` (S5.5) — the captured ``restBaseUrl`` is present and - **trimmed to** ``/api``. Pure-config check, no client. + * ``DV-CONN-001`` (S5.4) — the Workday SOAP connection reference reported by + the Declarative Agent minimalBots components API is bound (``connectionId`` + present), and its owner is echoed so the operator can confirm it is their + **own** account. Programmatic PASS/FAIL on the validated minimalBots + components read (same endpoint + ``connectionReferenceChanges`` shape as the + shipped native ``DA-CONN-001`` check). + * ``WD-REST-001`` (S5.5) — the Workday connection reference's + ``sharedConnectionParameters.values.restBaseUri`` is present and + **trimmed to** ``/api``. Read through the same AgentBuilder components + payload used by ``DV-CONN-001``. * ``WD-REST-002`` (S5.7) — the agent's ``user-context-setup.mcs.yml`` topic contains a ``BeginDialog`` redirect to the Workday user-context system topic (``WorkdaySystemGetUserContextV2`` on the simplified pack). Pure local-file @@ -43,7 +46,7 @@ whole run. * **One CheckResult per checkpoint** (principle 7). * **No guessed API shapes** — the two API-backed checks read documented fields - only (Dataverse ``connectionid`` / ``statuscode``; BAP + only (minimalBots ``connectionReferenceChanges`` connector/connection ids; BAP ``connectionParametersSet.name`` / ``createdBy``), and degrade gracefully when a client is unavailable. * **Every** ``CheckResult`` declares ``roles=`` (enforced by @@ -52,18 +55,16 @@ from __future__ import annotations -import os import re -import sys from pathlib import Path from ..runner import CheckResult, Priority, Role, Status from ..agent_scope import resolve_agent_directory, validate_agent_slug - -# scripts/auth.py is on sys.path via cli.py at runtime (tests add it too); this -# mirrors checks/environment.py's top-level import so query_all is patchable as -# flightcheck.checks.workday_extension.query_all. -from auth import query_all # noqa: E402 +from ._da_connection_refs import ( + WORKDAY_SOAP_CONNECTOR_SUFFIX as _WORKDAY_CONNECTOR_SUFFIX, + read_active_agent_connection_references, + workday_shared_connection_parameters, +) DOC_BASE = ( "https://learn.microsoft.com/en-us/copilot/microsoft-365/" @@ -92,12 +93,6 @@ _WORKDAY_RUNTIME_REF_LOGICAL_NAME = ( "msdyn_sharedworkdaysoap_workdayruntime" ) -# The Dataverse connection reference the simplified pack ships. -_DATAVERSE_CONNECTOR_SUFFIX = "/apis/shared_commondataserviceforapps" -_DATAVERSE_REF_SUFFIX = "92b66" -_DATAVERSE_RUNTIME_REF_LOGICAL_NAME = ( - "msdyn_sharedcommondataserviceforapps_workdayruntime" -) _REF_SUFFIX_RE = re.compile(r"_([0-9a-f]{5})$") # ---- Local user-context topic (WD-REST-002) ---- @@ -110,9 +105,9 @@ "Workday connection authentication type is Microsoft Entra ID Integrated" ) _DV_CONN_DESC = ( - "Dataverse connection reference bound to an active connection you own" + "Workday SOAP connection reference bound to a connection you own" ) -_REST_URL_DESC = "Workday REST base URL present and trimmed to '/api'" +_REST_URL_DESC = "Workday REST base URI present and trimmed to '/api'" _REDIRECT_DESC = ( "User-context topic redirects to the Workday user-context system topic" ) @@ -194,14 +189,6 @@ def _is_workday_auth_ref(logical_name) -> bool: ) -def _is_dataverse_runtime_ref(logical_name) -> bool: - normalized = str(logical_name or "").casefold() - return ( - _ref_suffix(logical_name) == _DATAVERSE_REF_SUFFIX - or normalized == _DATAVERSE_RUNTIME_REF_LOGICAL_NAME.casefold() - ) - - def _host_of(url: str) -> str: """Return the host portion of an ``https://host/…`` URL for display.""" match = re.match(r"https?://([^/]+)", str(url).strip()) @@ -231,26 +218,23 @@ def _resolve_owner(props: dict) -> str: def _query_connection_references(runner): - """Return all Dataverse ``connectionreferences`` rows, or ``None`` when the - Dataverse token/endpoint is not available. - - Documented-tier read (Dataverse Web API v9.2) — no cassette required; tests - stub ``query_all``. + """Return the agent's connection references from the Declarative Agent + minimalBots components API, normalized to the row shape + ``_check_dv_connection`` consumes, or ``None`` when the AgentBuilder client + or the active-agent ``botId`` is unavailable. + + Validated-tier read (minimalBots ``POST …/components``). The same endpoint + and ``connectionReferenceChanges`` shape already back the shipped native + ``DA-CONN-001`` check (``checks/native_agent.py``); see + ``tests/fixtures/cassettes/INDEX.md`` and ``tests/mocks/ + agentbuilder_connectivity.py``. Fails loudly (lets the dispatcher degrade + this checkpoint to a WARNING) rather than overclaiming: an + ``AgentBuilderHTTPError`` propagates, and a 200 payload whose + ``connectionReferenceChanges`` is present but not a list raises + ``ValueError`` (mirrors ``native_agent._connection_references``). A missing + changeset is treated as "no references" (genuine absence), not an error. """ - env_url = getattr(runner, "env_url", None) - dv_token = getattr(runner, "dv_token", None) - if not env_url or not dv_token: - return None - # Belt-and-suspenders: keep scripts/ importable even if the module was - # imported before cli.py put it on the path. - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) - return query_all( - env_url, - dv_token, - "connectionreferences", - "connectionreferenceid,connectionreferencelogicalname," - "connectionreferencedisplayname,connectorid,connectionid,statuscode", - ) + return read_active_agent_connection_references(runner) def _get_connections(runner): @@ -405,7 +389,7 @@ def _check_connection_auth(runner) -> list[CheckResult]: # ───────────────────────────────────────────────────────────────────── -# DV-CONN-001 — Dataverse connection reference binding (S5.4, PASS/FAIL). +# DV-CONN-001 — Workday SOAP connection reference binding (S5.4, PASS/FAIL). # ───────────────────────────────────────────────────────────────────── @@ -417,67 +401,44 @@ def _check_dv_connection(runner) -> list[CheckResult]: priority=Priority.HIGH.value, status=Status.SKIPPED.value, description=_DV_CONN_DESC, result=( - "Dataverse token not available — skipping the Dataverse " - "connection-reference check." + "AgentBuilder client or active-agent botId not available — " + "skipping the Workday connection-reference check." ), )] - dv_refs = [ - r - for r in refs - if str(r.get("connectorid") or "").lower().endswith( - _DATAVERSE_CONNECTOR_SUFFIX - ) - and _is_dataverse_runtime_ref( - r.get("connectionreferencelogicalname") - ) - ] - if len(dv_refs) > 1: - names = ", ".join( - sorted( - str(ref.get("connectionreferencelogicalname") or "(unnamed)") - for ref in dv_refs - ) - ) - return [CheckResult(roles=_MAKER_ROLES, - checkpoint_id="DV-CONN-001", category=_CATEGORY, - priority=Priority.HIGH.value, status=Status.WARNING.value, - description=_DV_CONN_DESC, - result=( - "Multiple ESS Dataverse connection references match the " - f"runtime and legacy package fingerprints: {names}. " - "FlightCheck cannot determine which reference is active." - ), - remediation=( - "Remove obsolete Workday package references, then rerun " - "FlightCheck against the remaining Dataverse binding." - ), - doc_link=_DOC_SIMPLIFIED, - )] - dv_ref = dv_refs[0] if dv_refs else None + wd_ref = next( + ( + r + for r in refs + if str(r.get("connectorid") or "") + .lower() + .endswith(_WORKDAY_CONNECTOR_SUFFIX) + ), + None, + ) - if dv_ref is None: + if wd_ref is None: return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="DV-CONN-001", category=_CATEGORY, - priority=Priority.HIGH.value, status=Status.NOT_CONFIGURED.value, + priority=Priority.HIGH.value, status=Status.FAILED.value, description=_DV_CONN_DESC, result=( - "The ESS Dataverse connection reference " - f"(\u2026_{_DATAVERSE_REF_SUFFIX}, connector " - "shared_commondataserviceforapps) was not found in this " - "environment." + "The ESS Workday SOAP connection reference (connector " + "shared_workdaysoap) was not found in the Declarative Agent " + "components payload." ), remediation=( - "Install/repair the Workday extension pack so its Dataverse " - "connection reference is created, then bind it to a Dataverse " - "connection you own." + "Install or repair the Workday extension pack so its Workday " + "SOAP connection reference is created, then bind it to a " + "Workday connection you own." ), doc_link=_DOC_SIMPLIFIED, )] - dv_ref_name = str(dv_ref.get("connectionreferencelogicalname")) - connection_id = dv_ref.get("connectionid") - statuscode = dv_ref.get("statuscode") + wd_ref_name = str( + wd_ref.get("connectionreferencelogicalname") or "(unnamed)" + ) + connection_id = wd_ref.get("connectionid") if not connection_id: return [CheckResult(roles=_MAKER_ROLES, @@ -485,31 +446,13 @@ def _check_dv_connection(runner) -> list[CheckResult]: priority=Priority.HIGH.value, status=Status.FAILED.value, description=_DV_CONN_DESC, result=( - "The ESS Dataverse connection reference " - f"({dv_ref_name}) is unbound " - "(connectionid=null)." - ), - remediation=( - "In Power Platform / Copilot Studio, bind the Dataverse " - "connection reference to an active Dataverse connection owned " - "by your own account." - ), - doc_link=_DOC_SIMPLIFIED, - )] - - if statuscode != 1: - return [CheckResult(roles=_MAKER_ROLES, - checkpoint_id="DV-CONN-001", category=_CATEGORY, - priority=Priority.HIGH.value, status=Status.FAILED.value, - description=_DV_CONN_DESC, - result=( - "The ESS Dataverse connection reference " - f"({dv_ref_name}) is bound but inactive " - f"(statuscode={statuscode})." + "The ESS Workday SOAP connection reference " + f"({wd_ref_name}) is unbound (connectionId=null)." ), remediation=( - "Re-authenticate or re-bind the Dataverse connection so its " - "status is active, using an account you own." + "In Power Platform / Copilot Studio, bind the Workday SOAP " + "connection reference to an active Workday connection owned by " + "your own account." ), doc_link=_DOC_SIMPLIFIED, )] @@ -529,9 +472,8 @@ def _check_dv_connection(runner) -> list[CheckResult]: priority=Priority.HIGH.value, status=Status.PASSED.value, description=_DV_CONN_DESC, result=( - "The ESS Dataverse connection reference " - f"({dv_ref_name}) is bound to an active " - "connection." + owner_note + "The ESS Workday SOAP connection reference " + f"({wd_ref_name}) is bound to a connection." + owner_note ), doc_link=_DOC_SIMPLIFIED, )] @@ -543,21 +485,38 @@ def _check_dv_connection(runner) -> list[CheckResult]: def _check_rest_base_url(runner) -> list[CheckResult]: - config = getattr(runner, "config", None) or {} - rest = config.get("restBaseUrl") + values, unavailable_reason = workday_shared_connection_parameters(runner) + if values is None: + return [CheckResult(roles=_MAKER_ROLES, + checkpoint_id="WD-REST-001", category=_CATEGORY, + priority=Priority.HIGH.value, status=Status.SKIPPED.value, + description=_REST_URL_DESC, + result=( + "AgentBuilder client or active-agent botId not available — " + "skipping the Workday REST base URI check." + ), + remediation=( + "Run FlightCheck with native AgentBuilder access and a " + "configured active-agent botId." + ), + doc_link=_DOC_SIMPLIFIED, + )] + + rest = values.get("restBaseUri") if not rest: return [CheckResult(roles=_MAKER_ROLES, checkpoint_id="WD-REST-001", category=_CATEGORY, - priority=Priority.HIGH.value, status=Status.NOT_CONFIGURED.value, + priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REST_URL_DESC, result=( - "No Workday REST base URL has been captured yet (restBaseUrl " - "is empty)." + "Workday sharedConnectionParameters.values.restBaseUri is " + f"missing or empty. {unavailable_reason}".strip() ), remediation=( - "Capture the Workday REST base URL and trim it to end at " - "'/api' (e.g. https:///ccx/api)." + "Reconnect the Workday connection from Copilot Studio so " + "restBaseUri is captured and trimmed to end at '/api' " + "(e.g. https:///ccx/api)." ), doc_link=_DOC_SIMPLIFIED, )] @@ -568,7 +527,7 @@ def _check_rest_base_url(runner) -> list[CheckResult]: checkpoint_id="WD-REST-001", category=_CATEGORY, priority=Priority.HIGH.value, status=Status.PASSED.value, description=_REST_URL_DESC, - result=f"REST base URL is present and trimmed to '/api': {rest}", + result=f"REST base URI is present and trimmed to '/api': {rest}", doc_link=_DOC_SIMPLIFIED, )] @@ -577,13 +536,13 @@ def _check_rest_base_url(runner) -> list[CheckResult]: priority=Priority.HIGH.value, status=Status.FAILED.value, description=_REST_URL_DESC, result=( - f"REST base URL is present but not trimmed to '/api': {rest}. It " + f"REST base URI is present but not trimmed to '/api': {rest}. It " "must end at '/api' with no trailing path or version segment." ), remediation=( - "Edit the captured restBaseUrl so it ends at '/api' (e.g. " - "https:///ccx/api) — remove any trailing path, version, or " - "resource segment." + "Edit the Workday connection's restBaseUri so it ends at '/api' " + "(e.g. https:///ccx/api) — remove any trailing path, " + "version, or resource segment." ), doc_link=_DOC_SIMPLIFIED, )] diff --git a/solutions/ess-maker-skills/scripts/flightcheck/registry.py b/solutions/ess-maker-skills/scripts/flightcheck/registry.py index dc759a9a1..66ca4cd0b 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/registry.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/registry.py @@ -412,8 +412,20 @@ class ResolvedPlan: roles=(Role.WORKDAY_ADMIN.value,), is_family=True, ), + # WD-ENV-001 — Workday tenant/OAuth configuration read from the + # Declarative Agent components payload (AGENTBUILDER), no Dataverse. + CheckpointSpec( + key="WD-ENV-001", + category_fn=run_workday_checks, + category_label="Workday", + clients=frozenset({AGENTBUILDER}), + requires_config=True, + requires_dataverse_endpoint=False, + priority=Priority.CRITICAL.value, + roles=(Role.ESS_MAKER.value,), + ), # WD-ENV-* — legacy Workday environment-variable checks (banned on the - # simplified flavor; registered so the family resolves). + # simplified flavor; registered so the remaining family resolves). CheckpointSpec( key="WD-ENV", category_fn=run_workday_checks, @@ -540,24 +552,26 @@ class ResolvedPlan: priority=Priority.HIGH.value, roles=(Role.ESS_MAKER.value,), ), - # DV-CONN-001 — self-contained Dataverse read (its own connectionreferences - # query) plus a best-effort BAP owner echo. + # DV-CONN-001 — reads the Workday SOAP connection reference from the + # Declarative Agent minimalBots components API (AGENTBUILDER), plus a + # best-effort BAP owner echo (PP_ADMIN). CheckpointSpec( key="DV-CONN-001", category_fn=run_workday_extension_checks, category_label="Workday Extension", - clients=frozenset({DATAVERSE, PP_ADMIN}), + clients=frozenset({AGENTBUILDER, PP_ADMIN}), requires_config=True, - requires_dataverse_endpoint=True, + requires_dataverse_endpoint=False, priority=Priority.HIGH.value, roles=(Role.ESS_MAKER.value,), ), - # WD-REST-001 — pure config check (restBaseUrl trimmed to /api), no client. + # WD-REST-001 — Workday REST base URI read from DA components + # sharedConnectionParameters (AGENTBUILDER), no Dataverse. CheckpointSpec( key="WD-REST-001", category_fn=run_workday_extension_checks, category_label="Workday Extension", - clients=frozenset(), + clients=frozenset({AGENTBUILDER}), requires_config=True, requires_dataverse_endpoint=False, priority=Priority.HIGH.value, diff --git a/tests/fixtures/cassettes/INDEX.md b/tests/fixtures/cassettes/INDEX.md index 9a52dd987..78c908bd8 100644 --- a/tests/fixtures/cassettes/INDEX.md +++ b/tests/fixtures/cassettes/INDEX.md @@ -51,7 +51,7 @@ the PR. | **Power Automate Admin API** (`/Microsoft.ProcessSimple/.../v2/flows`) | `validated` | Hosted on `api.flow.microsoft.com` (NOT `api.powerapps.com`) and requires a `service.flow.microsoft.com//.default` audience token. Captured at `flightcheck_flow_licensing.yaml` (correct host). | Admin flow listing + per-flow detail — Power Automate audience required. | | **Power Automate runtime runs** (`/Microsoft.ProcessSimple/environments/{env}/flows/{flow}/runs`) | `validated` | Hosted on `api.flow.microsoft.com`, `service.flow.microsoft.com//.default` token. **Runtime/maker scope (NOT `/scopes/admin`)** — run history is not exposed on the admin surface; requires owner/maker access to the flow. Cassette `flightcheck_workday_runs.yaml` (recorder `tests/captures/record_flightcheck_workday_runs.py`). | Backs WD-RUN-001 and SN-RUN-001 (same method+path+shape; ServiceNow reuses this cassette for the API contract but has its own response-action names and a multi-flow topology — confirmed live: orchestrator success `Respond_to_Copilot`, orchestrator failure `Respond_to_Copilot_-_Failure` (`status=Failed`), child/utility flows respond to their parent and are non-scoring). Captured Workday shapes: success (`response.name=Respond_to_Copilot_with_Success`), caught fault (`status=Succeeded`, `response.name=Respond_to_Copilot_with_failure_errorMessage`), template error (`status=Failed` + run-level `error`). | | **PVA Island Gateway** (`/api/botmanagement/v1/...`) | `validated` | Cassette at `island_gateway_botcomponents.yaml`. | Internal Copilot Studio API; not publicly documented. | -| **AgentBuilder Minimal Bot API** (`/copilotstudio/minimalBots/...`) | `validated` | Cassette at `agentbuilder_readiness.yaml` (recorder `tests/captures/record_agentbuilder_readiness.py`). | Internal Copilot Studio API. FlightCheck uses only direct agent lookup, explicit Dev-realm configuration, and the read-only component-fetch operation. | +| **AgentBuilder Minimal Bot API** (`/copilotstudio/minimalBots/...`) | `validated` | Cassette at `agentbuilder_readiness.yaml` (recorder `tests/captures/record_agentbuilder_readiness.py`). | Internal Copilot Studio API. FlightCheck uses only direct agent lookup, explicit Dev-realm configuration, and the read-only component-fetch operation. The cassette validates the enclosing `connectionReferenceChanges[].connectionReference` shape, but it does **not** contain `sharedConnectionParameters`. `tests/mocks/agentbuilder_connectivity.shared_connection_parameters()` is therefore `documented` tier: its nested `values..value` wrapper is grounded in `tools/ess-ca-to-da/reference/hr/agent.yml` (ServiceNow connection) and its Workday-specific keys are grounded in the public Workday connector definition, not in a live components response. | | **Power Platform Connectivity API** (`api.{ring}.powerplatform.com/connectivity/...`) | `validated` | Cassettes at `agentbuilder_readiness.yaml` and `connectivity_connections.yaml`; recorder `tests/captures/record_agentbuilder_readiness.py`. | Environment-scoped physical connection inventory used by native DA readiness. The populated connection shape was captured from a live Preprod connection-settings read and sanitized to the fields consumed by FlightCheck. | | **Workday SOAP** (Human_Resources, Identity_Management, Compensation, Absence_Management, etc.) | `validated` | Cassettes at `flightcheck_workday.yaml`, `workday_config.yaml`. | Vendor docs require Workday Community login; tenant-specific WSDL varies. | | **Workday WQL / REST** (`/ccx/api/wql/v1/...`, `/ccx/api/v1/...`) | `validated` | Cassette at `workday_wql_admin.yaml`. **Known auth blocker** — see "Workday WQL config-validation pattern" section below before authoring any runtime check on this cassette. | Per-tenant API client registration creates the chicken-and-egg blocker. | diff --git a/tests/flightcheck/checks/test_workday_env_vars.py b/tests/flightcheck/checks/test_workday_env_vars.py index ac02fcbaf..83ee4fb02 100644 --- a/tests/flightcheck/checks/test_workday_env_vars.py +++ b/tests/flightcheck/checks/test_workday_env_vars.py @@ -37,8 +37,10 @@ import responses from tests.conftest import require_validated_mock +from tests.mocks import agentbuilder_connectivity as ab from tests.mocks import dataverse as dv +require_validated_mock(ab) require_validated_mock(dv) @@ -57,6 +59,14 @@ class _MinimalRunner: dv_token: str +class _FakeAgentBuilder: + def __init__(self, components: dict[str, Any]): + self._components = components + + def fetch_components(self, _agent_id: str): + return self._components + + @pytest.fixture def runner(fake_dataverse_url: str, fake_token: str) -> _MinimalRunner: return _MinimalRunner(env_url=fake_dataverse_url, dv_token=fake_token) @@ -135,11 +145,181 @@ def _result_by_id(results: list, checkpoint_id: str): return matches[0] +def _runner_with_da_components( + payload: dict[str, Any], + *, + include_bot_id: bool = True, +) -> _MinimalRunner: + runner = _MinimalRunner(env_url="", dv_token="") + runner.agentbuilder = _FakeAgentBuilder(payload) + runner.config = ( + {"agent": {"botId": ab.MOCK_AGENT_ID}} + if include_bot_id + else {} + ) + return runner + + # ─────────────────────────────────────────────────────────────────────── # Tests # ─────────────────────────────────────────────────────────────────────── +class TestDeclarativeAgentWorkdayEnvConfig: + """WD-ENV-001 now reads Workday sharedConnectionParameters from DA + components instead of Dataverse environment variables.""" + + def test_required_shared_parameters_present_passes(self) -> None: + from flightcheck.checks.workday import _check_da_env_config + + runner = _runner_with_da_components( + ab.components_with_references( + references=[ + ab.workday_connection_reference( + shared_connection_parameters=( + ab.shared_connection_parameters() + ) + ) + ] + ) + ) + + result = _check_da_env_config(runner)[0] + + assert result.checkpoint_id == "WD-ENV-001" + assert result.status == "Passed" + assert "sharedConnectionParameters.values" in result.result + assert "tenantName=mocktenant" in result.result + assert "token:ResourceUri" in result.result + + def test_shared_parameters_json_string_shape_passes(self) -> None: + # Live AgentBuilder returns sharedConnectionParameters as a JSON + # string; WD-ENV-001 must parse it, not just accept a nested object. + from flightcheck.checks.workday import _check_da_env_config + + runner = _runner_with_da_components( + ab.components_with_references( + references=[ + ab.workday_connection_reference( + shared_connection_parameters=( + ab.shared_connection_parameters_json_string() + ) + ) + ] + ) + ) + + result = _check_da_env_config(runner)[0] + + assert result.checkpoint_id == "WD-ENV-001" + assert result.status == "Passed" + assert "tenantName=mocktenant" in result.result + assert "token:ResourceUri" in result.result + + def test_required_shared_parameters_can_be_on_later_workday_ref(self) -> None: + from flightcheck.checks.workday import _check_da_env_config + + runner = _runner_with_da_components( + ab.components_with_references( + references=[ + ab.workday_connection_reference( + connection_id="mock-obo-connection", + logical_name=( + "gptagent_mockemployeeselfservice." + "msdyn_sharedworkdaysoap_ff0df" + ), + ), + ab.workday_connection_reference( + connection_id="mock-isu-connection", + logical_name=( + "gptagent_mockemployeeselfservice." + "msdyn_sharedworkdaysoap_0786a" + ), + shared_connection_parameters=( + ab.shared_connection_parameters() + ), + ), + ab.workday_connection_reference( + connection_id="mock-context-isu-connection", + logical_name=( + "gptagent_mockemployeeselfservice." + "msdyn_sharedworkdaysoap_d6081" + ), + ), + ] + ) + ) + + result = _check_da_env_config(runner)[0] + + assert result.status == "Passed" + assert "tenantName=mocktenant" in result.result + assert "token:ResourceUri" in result.result + + def test_missing_required_token_key_fails(self) -> None: + from flightcheck.checks.workday import _check_da_env_config + + runner = _runner_with_da_components( + ab.components_with_references( + references=[ + ab.workday_connection_reference( + shared_connection_parameters=( + ab.shared_connection_parameters(token_uri=None) + ) + ) + ] + ) + ) + + result = _check_da_env_config(runner)[0] + + assert result.status == "Failed" + assert "token:WorkdayTokenUri" in result.result + assert "missing required entries" in result.result + assert "Reconnect the Workday connection" in result.remediation + assert "token:WorkdayClientId" in result.remediation + + def test_no_agentbuilder_client_skips(self) -> None: + from flightcheck.checks.workday import _check_da_env_config + + runner = _MinimalRunner(env_url="", dv_token="") + runner.config = {"agent": {"botId": ab.MOCK_AGENT_ID}} + + result = _check_da_env_config(runner)[0] + + assert result.status == "Skipped" + assert "not available" in result.result + assert "native AgentBuilder access" in result.remediation + + def test_no_active_agent_botid_skips(self) -> None: + from flightcheck.checks.workday import _check_da_env_config + + runner = _runner_with_da_components( + ab.components_with_references(), + include_bot_id=False, + ) + + result = _check_da_env_config(runner)[0] + + assert result.status == "Skipped" + assert "active-agent botId" in result.result + assert "configured active-agent botId" in result.remediation + + def test_malformed_components_shape_returns_warning(self) -> None: + from flightcheck.checks.workday import _check_da_env_config + + runner = _runner_with_da_components( + {"connectionReferenceChanges": {"unexpected": "dict"}} + ) + + result = _check_da_env_config(runner)[0] + + assert result.status == "Warning" + assert "Unable to run WD-ENV-001" in result.result + assert "invalid connectionReferenceChanges" in result.result + assert "report the checkpoint ID" in result.remediation + + class TestGoodConfig: """All three env vars set — every checkpoint should PASS.""" @@ -292,17 +472,21 @@ def test_definitions_table_empty_treats_critical_as_failed( assert _result_by_id(results, "WD-ENV-003").status == "Passed" def test_skips_when_no_dataverse_token(self) -> None: - """No token (e.g. user opted out of auth) — check returns a single - SKIPPED result, doesn't crash, doesn't try to make an HTTP call.""" + """No token (e.g. user opted out of auth) — legacy env checks return + SKIPPED results and do not try to make an HTTP call.""" from flightcheck.checks.workday import _check_env_vars runner_no_token = _MinimalRunner(env_url="", dv_token="") results = _check_env_vars(runner_no_token) - assert len(results) == 1 - assert results[0].checkpoint_id == "WD-ENV-001" - assert results[0].status == "Skipped" - assert "token not available" in results[0].result.lower() + assert {r.checkpoint_id for r in results} == { + "WD-ENV-001", + "WD-ENV-002", + "WD-ENV-003", + } + for result in results: + assert result.status == "Skipped" + assert "token not available" in result.result.lower() @responses.activate def test_partial_match_on_schema_name_works( diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index dbb97f857..f7f60605e 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -10,10 +10,11 @@ connection, degrades gracefully when it does not. Cached-ref read + a best-effort Power Platform admin owner echo — no cassette required (the admin connections listing is the ``validated`` pp_admin mock). - * DV-CONN-001 — PASS/FAIL/NOT_CONFIGURED/SKIPPED over a documented-tier - Dataverse ``connectionreferences`` read (stubbed with ``responses``); owner - echo via the ``validated`` pp_admin mock. - * WD-REST-001 — pure-config check (restBaseUrl trimmed to '/api'). + * DV-CONN-001 — PASS/FAIL/SKIPPED over the validated minimalBots components + read (Workday SOAP connection reference; faked ``runner.agentbuilder``); + owner echo via the ``validated`` pp_admin mock. + * WD-REST-001 — AgentBuilder components check + (sharedConnectionParameters.values.restBaseUri trimmed to '/api'). * WD-REST-002 — pure local-file check (user-context redirect topic); SKIPPED on the legacy install path. * WD-NET-001 — always-MANUAL InfoSec/IT attestation (never PASSED). @@ -28,22 +29,18 @@ from dataclasses import dataclass, field from typing import Any -import responses - from tests.conftest import require_validated_mock +from tests.mocks import agentbuilder_connectivity as ab from tests.mocks import dataverse as dv from tests.mocks import pp_admin as pp +require_validated_mock(ab) require_validated_mock(dv) require_validated_mock(pp) from flightcheck.checks import workday_extension as wx # noqa: E402 from flightcheck.runner import Priority, Role, Status # noqa: E402 -_DV_CONNECTOR_ID = ( - "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps" -) - # ───────────────────────────────────────────────────────────────────── # Minimal runner. The emitters read only these attributes; anything the @@ -62,6 +59,30 @@ def get_connections(self, _env_id: str): return self._connections +class _FakeAgentBuilder: + """Stand-in for FlightCheckRunner.agentbuilder. Only ``fetch_components`` + is consumed (DV-CONN-001's connection-reference read).""" + + def __init__(self, components: dict[str, Any]): + self._components = components + + def fetch_components(self, _agent_id: str): + return self._components + + +class _PerBotAgentBuilder: + """Stand-in for FlightCheckRunner.agentbuilder that routes + ``fetch_components`` by agent id, so multi-agent selection can be + exercised. Component shapes come from the validated + ``agentbuilder_connectivity`` builders.""" + + def __init__(self, components_by_bot: dict[str, Any]): + self._components_by_bot = components_by_bot + + def fetch_components(self, agent_id: str): + return self._components_by_bot[agent_id] + + @dataclass class _Runner: config: Any = field(default_factory=dict) @@ -69,6 +90,7 @@ class _Runner: env_url: str | None = None dv_token: str | None = None pp_admin: Any = None + agentbuilder: Any = None env_id: str | None = None agent_slug: str = "" _workday_connection_refs: list[dict[str, Any]] = field(default_factory=list) @@ -90,28 +112,6 @@ def _by_id(results): return {r.checkpoint_id: r for r in results} -def _dv_ref(*, connection_id, statuscode=1): - """A Dataverse connection reference matching the extension pack's shipped - ref (connector shared_commondataserviceforapps, logical-name suffix - 92b66).""" - return dv.connection_ref( - logical_name="msdyn_sharedcommondataserviceforapps_92b66", - display_name="Microsoft Dataverse", - connector_id=_DV_CONNECTOR_ID, - connection_id=connection_id, - statuscode=statuscode, - ) - - -def _register_refs(base_url: str, refs: list[dict[str, Any]]) -> None: - responses.add( - method="GET", - url=f"{base_url}/api/data/v9.2/connectionreferences", - json=dv.collection(refs), - status=200, - ) - - # ───────────────────────────────────────────────────────────────────── # WD-CONN-AUTH-001 — always MANUAL echo (S5.3). # ───────────────────────────────────────────────────────────────────── @@ -255,148 +255,148 @@ def test_never_passes_regardless_of_state(self): # ───────────────────────────────────────────────────────────────────── -# DV-CONN-001 — Dataverse connection binding (S5.4, PASS/FAIL). +# DV-CONN-001 — Workday SOAP connection binding (S5.4, PASS/FAIL). # ───────────────────────────────────────────────────────────────────── +def _runner_with_refs(references, *, pp_admin=None, env_id=None): + """A runner whose faked ``agentbuilder.fetch_components`` returns the given + connection references and whose config names an active agent (botId).""" + components = ab.components_with_references(references=references) + return _Runner( + config={"agent": {"botId": ab.MOCK_AGENT_ID}}, + agentbuilder=_FakeAgentBuilder(components), + pp_admin=pp_admin, + env_id=env_id, + ) + + class TestDataverseConnection: - @responses.activate - def test_bound_active_with_owner_echo_passes( - self, fake_dataverse_url, fake_token - ): - _register_refs( - fake_dataverse_url, - [_dv_ref(connection_id="dv-conn-active", statuscode=1)], - ) + def test_bound_with_owner_echo_passes(self): owner_conn = pp.connection( - name="dv-conn-active", - api_name="shared_commondataserviceforapps", + name="wd-conn-active", + api_name="shared_workdaysoap", extra_properties={"accountName": "maker@contoso.com"}, ) - runner = _Runner( - env_url=fake_dataverse_url, - dv_token=fake_token, + runner = _runner_with_refs( + [ab.workday_connection_reference(connection_id="wd-conn-active")], pp_admin=_FakePPAdmin([owner_conn]), env_id="env-1", ) r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] assert r.status == Status.PASSED.value - assert "bound to an active" in r.result + assert "bound to a connection" in r.result assert "maker@contoso.com" in r.result assert "your own account" in r.result - @responses.activate - def test_passes_without_pp_admin_notes_owner_unreadable( - self, fake_dataverse_url, fake_token - ): - _register_refs( - fake_dataverse_url, - [_dv_ref(connection_id="dv-conn-active", statuscode=1)], + def test_passes_without_pp_admin_notes_owner_unreadable(self): + runner = _runner_with_refs( + [ab.workday_connection_reference(connection_id="wd-conn-active")], ) - runner = _Runner(env_url=fake_dataverse_url, dv_token=fake_token) r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] assert r.status == Status.PASSED.value assert "owner could not be read" in r.result assert "your own account" in r.result - @responses.activate - def test_runtime_dataverse_reference_passes( - self, fake_dataverse_url, fake_token - ): - runtime_ref = dv.workday_connection_refs_runtime()[1] - _register_refs(fake_dataverse_url, [runtime_ref]) - runner = _Runner( - env_url=fake_dataverse_url, - dv_token=fake_token, + def test_unbound_fails(self): + runner = _runner_with_refs( + [ab.workday_connection_reference(connection_id=None)], ) + r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] - r = _by_id( - wx.run_workday_extension_checks(runner) - )["DV-CONN-001"] + assert r.status == Status.FAILED.value + assert "unbound" in r.result + assert "connectionId=null" in r.result + assert "bind the Workday SOAP connection reference" in r.remediation - assert r.status == Status.PASSED.value - assert ( - "msdyn_sharedcommondataserviceforapps_workdayruntime" - in r.result + def test_workday_ref_absent_fails(self): + # Only a ServiceNow ref is present - no Workday SOAP ref. + runner = _runner_with_refs( + [ + ab.connection_reference_change( + connector="shared_service-now", + connection_id="sn-1", + ) + ] ) + r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] - @responses.activate - def test_mixed_runtime_and_legacy_dataverse_refs_warn( - self, fake_dataverse_url, fake_token - ): - runtime_ref = dv.workday_connection_refs_runtime()[1] - _register_refs( - fake_dataverse_url, - [_dv_ref(connection_id="legacy-dv"), runtime_ref], - ) - runner = _Runner( - env_url=fake_dataverse_url, - dv_token=fake_token, - ) + assert r.status == Status.FAILED.value + assert "was not found" in r.result + assert "shared_workdaysoap" in r.result + assert "Install or repair the Workday extension pack" in r.remediation + def test_no_agentbuilder_client_skips(self): + runner = _Runner(config={"agent": {"botId": ab.MOCK_AGENT_ID}}) r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] - assert r.status == Status.WARNING.value - assert "Multiple ESS Dataverse connection references" in r.result - assert "Remove obsolete Workday package references" in r.remediation + assert r.status == Status.SKIPPED.value + assert "not available" in r.result - @responses.activate - def test_unbound_fails(self, fake_dataverse_url, fake_token): - _register_refs( - fake_dataverse_url, [_dv_ref(connection_id=None, statuscode=1)] + def test_no_active_agent_botid_skips(self): + runner = _Runner( + config={}, + agentbuilder=_FakeAgentBuilder(ab.components_with_references()), ) - runner = _Runner(env_url=fake_dataverse_url, dv_token=fake_token) r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] - assert r.status == Status.FAILED.value - assert "unbound" in r.result - assert "connectionid=null" in r.result - assert "bind the Dataverse connection reference" in r.remediation + assert r.status == Status.SKIPPED.value + assert "not available" in r.result - @responses.activate - def test_inactive_statuscode_fails(self, fake_dataverse_url, fake_token): - _register_refs( - fake_dataverse_url, - [_dv_ref(connection_id="dv-conn-inactive", statuscode=2)], + def test_malformed_changeset_degrades_to_warning(self): + # A 200 payload whose connectionReferenceChanges is present but not a + # list is a shape we do not understand: fail loudly (dispatcher WARNING) + # rather than reporting a confident "reference not found" FAILED. + runner = _Runner( + config={"agent": {"botId": ab.MOCK_AGENT_ID}}, + agentbuilder=_FakeAgentBuilder( + {"connectionReferenceChanges": {"unexpected": "dict"}} + ), ) - runner = _Runner(env_url=fake_dataverse_url, dv_token=fake_token) r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] - assert r.status == Status.FAILED.value - assert "inactive" in r.result - assert "statuscode=2" in r.result - assert "Re-authenticate or re-bind" in r.remediation - - @responses.activate - def test_missing_ref_not_configured(self, fake_dataverse_url, fake_token): - # Only a Workday ref present — no Dataverse (92b66) ref. - _register_refs( - fake_dataverse_url, - [ - dv.connection_ref( - logical_name="new_sharedworkdaysoap_ff0df", - display_name="OAuthUser", - connector_id=dv.WORKDAY_SOAP_CONNECTOR_ID, - connection_id="wd-conn-1", - ) - ], + assert r.status == Status.WARNING.value + assert "Unable to run DV-CONN-001" in r.result + assert "DV-CONN-001" in r.remediation + + def test_reads_only_active_agent_not_other_configured_agents(self): + # Regression (DV-CONN-001 single-active scoping): the check must + # validate the Workday SOAP reference on the *active* agent only. Here + # the active agent has no Workday reference while a second configured + # agent does. A correct single-active read FAILs "not found"; the + # earlier all-agents read PASSed on the other agent's reference — a + # false green on a HIGH-priority binding check. + other_bot_id = "00000000-0000-0000-0000-0000000033aa" + runner = _Runner( + config={ + "agent": {"botId": ab.MOCK_AGENT_ID}, + "agents": [ + {"botId": ab.MOCK_AGENT_ID}, + {"botId": other_bot_id}, + ], + }, + agentbuilder=_PerBotAgentBuilder( + { + ab.MOCK_AGENT_ID: ab.components_with_references( + references=None + ), + other_bot_id: ab.components_with_references( + references=[ + ab.workday_connection_reference( + connection_id="wd-conn-other-agent" + ) + ] + ), + } + ), ) - runner = _Runner(env_url=fake_dataverse_url, dv_token=fake_token) - r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] - - assert r.status == Status.NOT_CONFIGURED.value - assert "was not found in this environment" in r.result - assert "Install/repair the Workday extension pack" in r.remediation - - def test_no_dv_token_skips(self): - runner = _Runner(env_url="https://x.crm.dynamics.com", dv_token="") r = _by_id(wx.run_workday_extension_checks(runner))["DV-CONN-001"] - assert r.status == Status.SKIPPED.value - assert "Dataverse token not available" in r.result - + assert r.status == Status.FAILED.value + assert "was not found" in r.result + assert "Install or repair the Workday extension pack" in r.remediation # ───────────────────────────────────────────────────────────────────── # WD-REST-001 — REST base URL trimmed to /api (S5.5). @@ -404,38 +404,121 @@ def test_no_dv_token_skips(self): class TestRestBaseUrl: + def _runner(self, *, rest_base_uri: str | None): + return _runner_with_refs( + [ + ab.workday_connection_reference( + shared_connection_parameters=( + ab.shared_connection_parameters( + rest_base_uri=rest_base_uri + ) + ) + ) + ] + ) + def test_trimmed_url_passes(self): - runner = _Runner(config={"restBaseUrl": "https://wd.example.com/ccx/api"}) + runner = self._runner(rest_base_uri="https://wd.example.com/ccx/api") r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] assert r.status == Status.PASSED.value assert "trimmed to '/api'" in r.result assert "https://wd.example.com/ccx/api" in r.result + def test_trimmed_url_can_be_on_later_workday_ref(self): + runner = _runner_with_refs( + [ + ab.workday_connection_reference( + connection_id="mock-obo-connection", + logical_name=( + "gptagent_mockemployeeselfservice." + "msdyn_sharedworkdaysoap_ff0df" + ), + ), + ab.workday_connection_reference( + connection_id="mock-isu-connection", + logical_name=( + "gptagent_mockemployeeselfservice." + "msdyn_sharedworkdaysoap_0786a" + ), + shared_connection_parameters=( + ab.shared_connection_parameters( + rest_base_uri="https://wd.example.com/ccx/api" + ) + ), + ), + ab.workday_connection_reference( + connection_id="mock-context-isu-connection", + logical_name=( + "gptagent_mockemployeeselfservice." + "msdyn_sharedworkdaysoap_d6081" + ), + ), + ] + ) + + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] + + assert r.status == Status.PASSED.value + assert "https://wd.example.com/ccx/api" in r.result + def test_trailing_slash_still_passes(self): - runner = _Runner(config={"restBaseUrl": "https://wd.example.com/ccx/api/"}) + runner = self._runner(rest_base_uri="https://wd.example.com/ccx/api/") r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] assert r.status == Status.PASSED.value def test_untrimmed_url_fails(self): - runner = _Runner( - config={"restBaseUrl": "https://wd.example.com/ccx/api/staffing/v1"} + runner = self._runner( + rest_base_uri="https://wd.example.com/ccx/api/staffing/v1" ) r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] assert r.status == Status.FAILED.value assert "not trimmed to '/api'" in r.result assert "https://wd.example.com/ccx/api/staffing/v1" in r.result + assert "restBaseUri" in r.remediation assert "remove any trailing path" in r.remediation - def test_absent_url_not_configured(self): - runner = _Runner(config={}) + def test_absent_url_fails(self): + runner = self._runner(rest_base_uri=None) r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] - assert r.status == Status.NOT_CONFIGURED.value - assert "restBaseUrl is empty" in r.result - assert "trim it to end at '/api'" in r.remediation + assert r.status == Status.FAILED.value + assert "restBaseUri is missing or empty" in r.result + assert "restBaseUri is captured" in r.remediation + + def test_no_agentbuilder_client_skips(self): + runner = _Runner(config={"agent": {"botId": ab.MOCK_AGENT_ID}}) + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] + + assert r.status == Status.SKIPPED.value + assert "not available" in r.result + assert "native AgentBuilder access" in r.remediation + + def test_no_active_agent_botid_skips(self): + runner = _Runner( + config={}, + agentbuilder=_FakeAgentBuilder(ab.components_with_references()), + ) + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] + + assert r.status == Status.SKIPPED.value + assert "active-agent botId" in r.result + assert "configured active-agent botId" in r.remediation + + def test_malformed_components_shape_degrades_to_warning(self): + runner = _Runner( + config={"agent": {"botId": ab.MOCK_AGENT_ID}}, + agentbuilder=_FakeAgentBuilder( + {"connectionReferenceChanges": {"unexpected": "dict"}} + ), + ) + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-001"] + + assert r.status == Status.WARNING.value + assert "Unable to run WD-REST-001" in r.result + assert "WD-REST-001" in r.remediation # ───────────────────────────────────────────────────────────────────── @@ -698,7 +781,7 @@ def _boom(_runner): assert by_id["WD-NET-001"].status == Status.MANUAL.value def test_config_reading_emitters_warn_on_boom_config(self): - # A config whose .get raises breaks the three config-reading emitters; + # A config whose .get raises breaks the two config-reading emitters; # each degrades to WARNING and the run still returns all five rows. results = wx.run_workday_extension_checks(_Runner(config=_BoomConfig())) by_id = _by_id(results) diff --git a/tests/flightcheck/test_registry.py b/tests/flightcheck/test_registry.py index e8882af57..e1aa771ca 100644 --- a/tests/flightcheck/test_registry.py +++ b/tests/flightcheck/test_registry.py @@ -83,7 +83,8 @@ def test_dynamic_id_resolves_to_family(self): assert registry.resolve("WD-CONN-003").key == "WD-CONN" assert registry.resolve("WD-FLOW-002").key == "WD-FLOW" assert registry.resolve("WD-WF-007").key == "WD-WF" - assert registry.resolve("WD-ENV-001").key == "WD-ENV" + assert registry.resolve("WD-ENV-001").key == "WD-ENV-001" + assert registry.resolve("WD-ENV-002").key == "WD-ENV" def test_wildcard_family_request_resolves(self): assert registry.resolve("WD-FLOW-*").key == "WD-FLOW" @@ -294,7 +295,7 @@ class TestWorkdayExtensionCheckpoints: """skill-5 mints five checkpoints, all sharing checks/workday_extension.run_workday_extension_checks, category "Workday Extension". Two are always-MANUAL echoes/attestations, three are - programmatic (one Dataverse read + two pure-local).""" + programmatic (one minimalBots components read + two pure-local).""" _ALL = ( "WD-CONN-AUTH-001", @@ -329,21 +330,37 @@ def test_conn_auth_exact_beats_wd_conn_family(self): assert registry.resolve("WD-CONN-AUTH-001").key == "WD-CONN-AUTH-001" assert registry.resolve("WD-CONN-AUTH-001").is_family is False - def test_dv_conn_spec_declares_dataverse_and_pp_admin(self): + def test_dv_conn_spec_declares_agentbuilder_and_pp_admin(self): spec = registry.resolve("DV-CONN-001") - assert spec.clients == frozenset({registry.DATAVERSE, registry.PP_ADMIN}) - assert spec.requires_dataverse_endpoint is True + assert spec.clients == frozenset( + {registry.AGENTBUILDER, registry.PP_ADMIN} + ) + assert spec.requires_dataverse_endpoint is False assert spec.prereqs == () assert Role.ESS_MAKER.value in spec.roles def test_rest_and_local_checks_are_clientless(self): - for cp in ("WD-REST-001", "WD-REST-002"): + spec = registry.resolve("WD-REST-001") + assert spec.clients == frozenset({registry.AGENTBUILDER}) + assert spec.requires_dataverse_endpoint is False + assert spec.prereqs == () + assert Role.ESS_MAKER.value in spec.roles + + for cp in ("WD-REST-002",): spec = registry.resolve(cp) assert spec.clients == frozenset() assert spec.requires_dataverse_endpoint is False assert spec.prereqs == () assert Role.ESS_MAKER.value in spec.roles + def test_wd_env_001_declares_agentbuilder_without_dataverse(self): + spec = registry.resolve("WD-ENV-001") + assert spec.key == "WD-ENV-001" + assert spec.clients == frozenset({registry.AGENTBUILDER}) + assert spec.requires_dataverse_endpoint is False + assert spec.prereqs == () + assert Role.ESS_MAKER.value in spec.roles + def test_net_check_is_clientless_and_ppadmin_gated(self): spec = registry.resolve("WD-NET-001") assert spec.clients == frozenset() @@ -354,7 +371,7 @@ def test_net_check_is_clientless_and_ppadmin_gated(self): def test_dv_conn_plan_unions_clients(self): plan = registry.transitive_requirements("DV-CONN-001") - assert registry.DATAVERSE in plan.clients + assert registry.AGENTBUILDER in plan.clients assert registry.PP_ADMIN in plan.clients def test_all_five_are_listable(self):