diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/solution.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/solution.py index 0b721ea9c..abdb13fb3 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/solution.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/solution.py @@ -4,34 +4,40 @@ """ ESS FlightCheck — ESS Solution Installation Validation (ESS-SOLN-xxx) -Verifies that the base ESS agent solution has been installed into the target -Power Platform environment (skill-2 ``install-ess``). The install itself is a -manual AppSource / admin-center action; this module supplies the *programmatic -verification* that the solution landed, runnable in isolation via -``--checkpoint ESS-SOLN-001``. +Verifies that the base ESS declarative agent package is present in the target +Power Platform environment (skill-2 ``install-ess``). The check reads the +Copilot Studio minimalBots ALM configure surface so it works after the +Declarative Agent re-point away from Dataverse solution-table state. """ -from ..runner import CheckResult, Priority, Role, Status -from auth import query_all, AuthExpiredError # scripts/auth.py, on path via cli.py +from __future__ import annotations + +from typing import Any +from agentbuilder import ( + DEV_REALM, + PROD_REALM, + REALM_NAMES, + TEST_REALM, + AgentBuilderHTTPError, +) -# The AppSource "Employee Self Service" offer installs a managed solution whose -# unique name starts with this prefix. Three variants ship today — the base -# agent plus IT and HR editions — and skill-6 references the same namespace: -# msdyn_copilotforemployeeselfservice (base) -# msdyn_copilotforemployeeselfserviceit (IT) -# msdyn_copilotforemployeeselfservicehr (HR) -# A ``startswith`` match accepts whichever edition the tenant deployed (and any -# future variant) in a single round-trip. -_ESS_SOLUTION_PREFIX = "msdyn_copilotforemployeeselfservice" -_ESS_SOLN_FILTER = f"startswith(uniquename,'{_ESS_SOLUTION_PREFIX}')" -_ESS_SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" +from ..runner import CheckResult, Priority, Role, Status _ESS_SOLN_DOC_LINK = ( "https://learn.microsoft.com/en-us/microsoft-365/copilot/" "employee-self-service/install" ) -_ESS_SOLN_DESCRIPTION = "ESS base agent solution installed in the environment" +_ESS_SOLN_DESCRIPTION = "ESS base agent package present in the environment" +_DEFAULT_ALM_REALM = DEV_REALM +_ALM_NOT_OPTED_IN_ERROR_CODE = "4003" +_ALM_REALMS = { + "dev": DEV_REALM, + "development": DEV_REALM, + "test": TEST_REALM, + "prod": PROD_REALM, + "production": PROD_REALM, +} def run_solution_checks(runner) -> list[CheckResult]: @@ -45,100 +51,218 @@ def run_solution_checks(runner) -> list[CheckResult]: def _check_ess_solution_installed(runner) -> list[CheckResult]: - """ESS-SOLN-001: the base ESS solution is installed in the target env. + """ESS-SOLN-001: the base ESS agent package is installed in the target env.""" + agentbuilder = getattr(runner, "agentbuilder", None) + bot_id = _active_agent_bot_id(runner) + realm = _alm_realm(runner) - Always emits exactly one CheckResult (principle 7 — bucket multi-resource - findings). Never raises — all errors are caught and turned into WARNING - results so a transient Dataverse failure does not abort the whole - flightcheck run. - """ - env_url = getattr(runner, "env_url", None) - token = getattr(runner, "dv_token", None) - - if not env_url or not token: - return [CheckResult(roles=[Role.ESS_MAKER.value], - checkpoint_id="ESS-SOLN-001", category="Solution", - priority=Priority.CRITICAL.value, status=Status.SKIPPED.value, - description=_ESS_SOLN_DESCRIPTION, - result="Dataverse URL or access token not available in this run.", - doc_link=_ESS_SOLN_DOC_LINK, - )] + if agentbuilder is None: + return [ + _result( + Status.SKIPPED.value, + "AgentBuilder client not available in this run.", + ( + "Run FlightCheck with native AgentBuilder authentication " + "configured, then retry this check." + ), + ) + ] + if not bot_id: + return [ + _result( + Status.SKIPPED.value, + "No agent botId is recorded in .local/config.json.", + "Run setup so the agent botId is recorded, then retry this check.", + ) + ] + if realm is None: + return [ + _result( + Status.FAILED.value, + "The configured ALM realm is not Dev, Test, or Prod.", + ( + "Set almRealm, grsRealm, or minimalBotsAlmRealm to Dev, " + "Test, or Prod, then retry this check." + ), + ) + ] try: - solutions = query_all( - env_url, token, - "solutions", - _ESS_SOLN_SELECT, - _ESS_SOLN_FILTER, - ) + config = agentbuilder.get_realm_configuration(bot_id, realm) + except AgentBuilderHTTPError as exc: + if _is_not_opted_into_alm_error(exc): + return [_not_opted_into_alm_result(bot_id)] + return [ + _result( + Status.WARNING.value, + ( + "Unable to verify ESS package state from AgentBuilder ALM " + f"configure: {exc}" + ), + ( + "Retry the read-only ALM configure check after verifying " + "Copilot Studio AgentBuilder access and service availability." + ), + ) + ] + except Exception as exc: + return [ + _result( + Status.WARNING.value, + ( + "Unable to verify ESS package state from AgentBuilder ALM " + f"configure: {type(exc).__name__}: {exc}" + ), + ( + "Retry the read-only ALM configure check after verifying " + "Copilot Studio AgentBuilder access and service availability." + ), + ) + ] - if not solutions: - return [CheckResult(roles=[Role.ESS_MAKER.value], - checkpoint_id="ESS-SOLN-001", category="Solution", - priority=Priority.CRITICAL.value, status=Status.FAILED.value, - description=_ESS_SOLN_DESCRIPTION, - result=( - "No solution whose unique name starts with " - f"'{_ESS_SOLUTION_PREFIX}' is installed in this " - "environment. The base ESS agent is not present." + grs_repository_id = str(config.get("grsRepositoryId") or "").strip() + commit_sha = str(config.get("commitSha") or "").strip() + if not grs_repository_id or not commit_sha: + return [ + _result( + Status.FAILED.value, + ( + "AgentBuilder ALM configure did not return both " + "grsRepositoryId and commitSha. The ESS base package is " + "not present in the agent's GRS package state." ), - remediation=( - "Install the Employee Self Service agent from AppSource " - "into this environment (Microsoft 365 admin center / " - "AppSource -> get 'Employee Self Service' -> deploy to the " - "target environment), wait for the solution import to " - "finish, then re-run this check." + ( + "Install or import the Employee Self Service base package " + "for this agent, confirm the ALM configure response has " + "a repository and commit, then retry this check." ), - doc_link=_ESS_SOLN_DOC_LINK, - )] + ) + ] - installed = ", ".join( - _describe_solution(s) - for s in sorted(solutions, key=lambda s: s.get("uniquename", "")) - ) - return [CheckResult(roles=[Role.ESS_MAKER.value], - checkpoint_id="ESS-SOLN-001", category="Solution", - priority=Priority.CRITICAL.value, status=Status.PASSED.value, - description=_ESS_SOLN_DESCRIPTION, - result=f"ESS base agent solution installed: {installed}.", - doc_link=_ESS_SOLN_DOC_LINK, - )] - - except AuthExpiredError as e: - return [CheckResult(roles=[Role.ESS_MAKER.value], - checkpoint_id="ESS-SOLN-001", category="Solution", - priority=Priority.CRITICAL.value, status=Status.WARNING.value, - description=_ESS_SOLN_DESCRIPTION, - result=str(e), - remediation="Re-run FlightCheck to refresh the access token.", - doc_link=_ESS_SOLN_DOC_LINK, - )] - except Exception as e: - # Per principle 3 (fail loudly): surface unexpected Dataverse failures - # as WARNING rather than silently passing. Surface the HTTP status - # code when available so a 403 (insufficient privileges) is - # distinguishable from a 5xx (transient) at a glance. - status_code = getattr(getattr(e, "response", None), "status_code", None) - status_hint = f" [HTTP {status_code}]" if status_code is not None else "" - return [CheckResult(roles=[Role.ESS_MAKER.value], - checkpoint_id="ESS-SOLN-001", category="Solution", - priority=Priority.CRITICAL.value, status=Status.WARNING.value, - description=_ESS_SOLN_DESCRIPTION, - result=( - f"Unable to verify the ESS solution: " - f"{type(e).__name__}{status_hint}: {e}" - ), - remediation=( - "Inspect the error above; common causes are insufficient " - "Dataverse privileges on the solutions table (typically " - "surfaces as HTTP 403) or a transient platform error (HTTP 5xx)." + return [ + _result( + Status.PASSED.value, + ( + "Agent has a committed GRS package " + f"(repository {grs_repository_id}, commit {commit_sha}); " + "ESS base-package identity match pending US 7792604." ), - doc_link=_ESS_SOLN_DOC_LINK, - )] + ) + ] + + +def _result( + status: str, + result: str, + remediation: str = "", +) -> CheckResult: + return CheckResult( + checkpoint_id="ESS-SOLN-001", + category="Solution", + priority=Priority.CRITICAL.value, + status=status, + description=_ESS_SOLN_DESCRIPTION, + result=result, + remediation=remediation, + doc_link=_ESS_SOLN_DOC_LINK, + roles=[Role.ESS_MAKER.value], + ) + + +def _active_agent_bot_id(runner) -> str | None: + config = getattr(runner, "config", None) or {} + agents = config.get("agents") or [] + active_slug = config.get("activeAgent") or (config.get("agent") or {}).get( + "slug" + ) + if active_slug: + for agent in agents: + if isinstance(agent, dict) and agent.get("slug") == active_slug: + bot_id = str(agent.get("botId") or "").strip() + if bot_id: + return bot_id + single = config.get("agent") or {} + bot_id = str(single.get("botId") or "").strip() + if bot_id: + return bot_id + for agent in agents: + if isinstance(agent, dict): + bot_id = str(agent.get("botId") or "").strip() + if bot_id: + return bot_id + return None + + +def _alm_realm(runner) -> int | None: + config = getattr(runner, "config", None) or {} + single = config.get("agent") or {} + raw = ( + config.get("minimalBotsAlmRealm") + or config.get("grsRealm") + or config.get("almRealm") + or single.get("minimalBotsAlmRealm") + or single.get("grsRealm") + or single.get("almRealm") + or single.get("realm") + or _DEFAULT_ALM_REALM + ) + if type(raw) is int and raw in REALM_NAMES: + return raw + if isinstance(raw, str): + text = raw.strip() + if text.isdigit(): + realm = int(text) + return realm if realm in REALM_NAMES else None + return _ALM_REALMS.get(text.casefold()) + return None + + +def _is_not_opted_into_alm_error(error: AgentBuilderHTTPError) -> bool: + if _error_code_matches(getattr(error, "error_code", None)): + return True + response = getattr(error, "response", None) + if response is None: + return False + try: + body = response.json() + except ValueError: + return False + return _payload_is_not_opted_into_alm(body) + + +def _payload_is_not_opted_into_alm(payload: Any) -> bool: + if not isinstance(payload, dict): + return False + candidates = [ + payload.get("ErrorCode"), + payload.get("errorCode"), + payload.get("code"), + ] + for key in ("error", "Error"): + nested = payload.get(key) + if isinstance(nested, dict): + candidates.extend( + [nested.get("code"), nested.get("Code"), nested.get("ErrorCode")] + ) + else: + candidates.append(nested) + return any(_error_code_matches(candidate) for candidate in candidates) + + +def _error_code_matches(value: Any) -> bool: + return str(value).strip() == _ALM_NOT_OPTED_IN_ERROR_CODE -def _describe_solution(sol: dict) -> str: - """Render one solution row as ``uniquename (vX.Y.Z)`` for the result text.""" - name = sol.get("uniquename", "") - version = sol.get("version") - return f"{name} (v{version})" if version else name +def _not_opted_into_alm_result(bot_id: str) -> CheckResult: + return _result( + Status.NOT_CONFIGURED.value, + ( + f"Agent {bot_id} is not opted into ALM. AgentBuilder ALM configure " + "returned error code 4003, so FlightCheck cannot read its GRS " + "package state." + ), + ( + "Opt the agent into Application Lifecycle Management (ALM) in " + "Copilot Studio, then retry this check." + ), + ) 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..e5bcc81d6 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -20,11 +20,12 @@ (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. + * ``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 captured ``restBaseUrl`` is present and **trimmed to** ``/api``. Pure-config check, no client. * ``WD-REST-002`` (S5.7) — the agent's ``user-context-setup.mcs.yml`` topic @@ -43,7 +44,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,19 +53,12 @@ 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 - DOC_BASE = ( "https://learn.microsoft.com/en-us/copilot/microsoft-365/" "employee-self-service" @@ -92,12 +86,9 @@ _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" -) +# The Workday SOAP connection reference the Declarative Agent reports via the +# minimalBots components API (connector ``shared_workdaysoap``). +_WORKDAY_CONNECTOR_SUFFIX = "/apis/shared_workdaysoap" _REF_SUFFIX_RE = re.compile(r"_([0-9a-f]{5})$") # ---- Local user-context topic (WD-REST-002) ---- @@ -110,7 +101,7 @@ "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'" _REDIRECT_DESC = ( @@ -194,14 +185,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 +214,48 @@ 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: + client = getattr(runner, "agentbuilder", None) + config = getattr(runner, "config", None) or {} + agent_id = (config.get("agent") or {}).get("botId") + if client is None or not agent_id: 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", - ) + changeset = client.fetch_components(agent_id) or {} + changes = changeset.get("connectionReferenceChanges") + if changes is None: + return [] + if not isinstance(changes, list): + raise ValueError( + "Component fetch returned invalid connectionReferenceChanges." + ) + refs = [] + for change in changes: + ref = (change or {}).get("connectionReference") or {} + refs.append( + { + "connectionreferencelogicalname": ref.get( + "connectionReferenceLogicalName" + ), + "connectorid": ref.get("connectorId"), + "connectionid": ref.get("connectionId"), + } + ) + return refs def _get_connections(runner): @@ -405,7 +410,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 +422,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 +467,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 +493,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, )] diff --git a/solutions/ess-maker-skills/scripts/flightcheck/registry.py b/solutions/ess-maker-skills/scripts/flightcheck/registry.py index dc759a9a1..3c638fff2 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/registry.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/registry.py @@ -249,22 +249,16 @@ class ResolvedPlan: roles=(Role.POWER_PLATFORM_ADMIN.value,), ), # ---- Solution: ESS-SOLN-001 (skill-2 install-ess) ---- - # ESS-SOLN-001: the base ESS agent solution (msdyn_copilotforemployeeselfservice*) - # is installed in the target env. Queries the Dataverse `solutions` table - # (DATAVERSE client, already wired in cli.py's single-checkpoint path — no - # new client init). Prereq ENV-002 (Dataverse provisioned) transitively - # pulls ENV-001 (environment exists). Environment Maker owns the fix; the - # AppSource install itself is a manual portal action, but this check - # definitively verifies the outcome, so the S2.1 checklist row auto-completes - # (`prog` gate) on a PASSED result. + # ESS-SOLN-001: the base ESS DA package is present in the agent's GRS/ALM + # state. Reads the AgentBuilder minimalBots ALM configure API instead of + # Dataverse solution-table state. CheckpointSpec( key="ESS-SOLN-001", category_fn=run_solution_checks, category_label="Solution", - clients=frozenset({DATAVERSE}), + clients=frozenset({AGENTBUILDER}), requires_config=True, - requires_dataverse_endpoint=True, - prereqs=("ENV-002",), + requires_dataverse_endpoint=False, priority=Priority.CRITICAL.value, roles=(Role.ESS_MAKER.value,), ), @@ -540,15 +534,16 @@ 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,), ), diff --git a/tests/flightcheck/checks/test_solution.py b/tests/flightcheck/checks/test_solution.py index 4e28de6b3..5547fc054 100644 --- a/tests/flightcheck/checks/test_solution.py +++ b/tests/flightcheck/checks/test_solution.py @@ -1,229 +1,210 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""End-to-end tests for ESS-SOLN-001 (ESS base agent solution installed) in -``solutions/ess-maker-skills/scripts/flightcheck/checks/solution.py``. - -Mocks the single Dataverse Web API endpoint the check calls (the ``solutions`` -table query) with the ``responses`` library, then invokes the real production -helper ``_check_ess_solution_installed`` and asserts on the resulting -``CheckResult``. - -Mock backing: Dataverse Web API v9.2 is the ``documented`` tier per -``tests/fixtures/cassettes/INDEX.md`` — no cassette required. The ``solutions`` -response shape comes from the MS Learn entity reference: -https://learn.microsoft.com/power-apps/developer/data-platform/reference/entities/solution +"""Tests for ESS-SOLN-001's Declarative Agent ALM configure read. + +The check consumes the validated AgentBuilder Minimal Bot API +``GET /copilotstudio/minimalBots/alm/{agent_id}/configure`` shape captured in +``tests/fixtures/cassettes/agentbuilder_readiness.yaml``. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import pytest -import responses - -from tests.conftest import FAKE_DATAVERSE_URL, require_validated_mock -from tests.mocks import dataverse as dv - -require_validated_mock(dv) +from agentbuilder import AgentBuilderHTTPError, DEV_REALM, PROD_REALM, TEST_REALM +from flightcheck.checks.solution import _alm_realm, _check_ess_solution_installed +from flightcheck.runner import Status +from tests.conftest import require_validated_mock +from tests.mocks import agentbuilder_connectivity as ab -# Production module — flightcheck is importable because pyproject.toml puts -# solutions/ess-maker-skills/scripts on pythonpath. -from flightcheck.checks.solution import _check_ess_solution_installed # noqa: E402 +require_validated_mock(ab) -BASE_URL = FAKE_DATAVERSE_URL +class _FakeResponse: + def __init__(self, payload: dict[str, Any]): + self.status_code = 400 + self._payload = payload -# Verbatim from the production check; if these drift, mock-builder URLs will -# stop matching and tests fail loudly with an unregistered-URL error. -ESS_SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" -ESS_SOLN_FILTER = "startswith(uniquename,'msdyn_copilotforemployeeselfservice')" + def json(self) -> dict[str, Any]: + return self._payload -SOLUTION_ID = "11111111-1111-1111-1111-111111111111" +class _FakeAgentBuilder: + def __init__( + self, + payload: dict[str, Any] | None = None, + *, + error: Exception | None = None, + ) -> None: + self._payload = payload or ab.configuration() + self._error = error + self.calls: list[tuple[str, int]] = [] -# ─────────────────────────────────────────────────────────────────────── -# Minimal runner for solution-check tests. -# ─────────────────────────────────────────────────────────────────────── + def get_realm_configuration(self, agent_id: str, realm: int) -> dict[str, Any]: + self.calls.append((agent_id, realm)) + if self._error is not None: + raise self._error + return self._payload @dataclass -class _MinimalRunner: - env_url: str | None - dv_token: str | None +class _Runner: + agentbuilder: Any = None + config: dict[str, Any] = field(default_factory=dict) -@pytest.fixture -def runner(fake_dataverse_url: str, fake_token: str) -> _MinimalRunner: - return _MinimalRunner(env_url=fake_dataverse_url, dv_token=fake_token) +def _config(*, bot_id: str | None = ab.MOCK_AGENT_ID) -> dict[str, Any]: + agent: dict[str, Any] = {"slug": "ess-dev"} + if bot_id is not None: + agent["botId"] = bot_id + return { + "activeAgent": "ess-dev", + "agent": agent.copy(), + "agents": [agent], + } -# ─────────────────────────────────────────────────────────────────────── -# Mock payload builders + registration helpers -# ─────────────────────────────────────────────────────────────────────── +def test_passes_when_configure_returns_grs_repository_and_commit() -> None: + agentbuilder = _FakeAgentBuilder() + runner = _Runner(agentbuilder=agentbuilder, config=_config()) + + result = _check_ess_solution_installed(runner)[0] + + assert result.status == Status.PASSED.value + assert "Agent has a committed GRS package" in result.result + assert "ESS base-package identity match pending US 7792604" in result.result + assert ab.MOCK_ENV_ID in result.result + assert ab.MOCK_COMMIT_SHA in result.result + assert result.remediation == "" + assert agentbuilder.calls == [(ab.MOCK_AGENT_ID, DEV_REALM)] + + +@pytest.mark.parametrize( + ("field", "result_phrase"), + [ + ("grsRepositoryId", "grsRepositoryId"), + ("commitSha", "commitSha"), + ], +) +def test_fails_when_configure_omits_required_package_state( + field: str, + result_phrase: str, +) -> None: + payload = ab.configuration() + payload[field] = "" + runner = _Runner(agentbuilder=_FakeAgentBuilder(payload), config=_config()) + + result = _check_ess_solution_installed(runner)[0] + + assert result.status == Status.FAILED.value + assert result_phrase in result.result + assert "not present in the agent's GRS package state" in result.result + assert "Install or import the Employee Self Service base package" in ( + result.remediation + ) + assert "confirm the ALM configure response has a repository and commit" in ( + result.remediation + ) -def _solution_record( - uniquename: str, - *, - version: str = "1.0.0.0", - ismanaged: bool = True, -) -> dict[str, Any]: - """One ``solutions`` row matching the production $select. +def test_not_configured_when_agent_is_not_opted_into_alm() -> None: + error = AgentBuilderHTTPError( + "Dev realm configuration", + 400, + response=_FakeResponse({"ErrorCode": 4003}), + ) + runner = _Runner( + agentbuilder=_FakeAgentBuilder(error=error), + config=_config(), + ) - Field naming per - https://learn.microsoft.com/power-apps/developer/data-platform/reference/entities/solution - """ - return { - "@odata.etag": 'W/"1"', - "solutionid": SOLUTION_ID, - "uniquename": uniquename, - "friendlyname": uniquename, - "ismanaged": ismanaged, - "version": version, - } + result = _check_ess_solution_installed(runner)[0] + assert result.status == Status.NOT_CONFIGURED.value + assert "not opted into ALM" in result.result + assert "error code 4003" in result.result + assert "Opt the agent into Application Lifecycle Management" in ( + result.remediation + ) + assert "Copilot Studio" in result.remediation -def _register_solutions(solutions: list[dict[str, Any]]) -> None: - responses.add(**dv.query( - base_url=BASE_URL, - entity_set="solutions", - records=solutions, - select=ESS_SOLN_SELECT, - filter_expr=ESS_SOLN_FILTER, - )) +def test_skips_when_agentbuilder_client_is_missing() -> None: + result = _check_ess_solution_installed(_Runner(config=_config()))[0] -# ─────────────────────────────────────────────────────────────────────── -# Tests — one per verdict path. -# ─────────────────────────────────────────────────────────────────────── + assert result.status == Status.SKIPPED.value + assert "AgentBuilder client not available" in result.result + assert "native AgentBuilder authentication" in result.remediation -def test_skipped_when_env_url_missing() -> None: - results = _check_ess_solution_installed( - _MinimalRunner(env_url=None, dv_token="tok") +def test_skips_when_bot_id_is_missing() -> None: + runner = _Runner( + agentbuilder=_FakeAgentBuilder(), + config=_config(bot_id=None), ) - assert len(results) == 1 - r = results[0] - assert r.checkpoint_id == "ESS-SOLN-001" - assert r.category == "Solution" - assert r.status == "Skipped" - assert "Dataverse URL or access token not available" in r.result + result = _check_ess_solution_installed(runner)[0] + + assert result.status == Status.SKIPPED.value + assert "No agent botId" in result.result + assert "Run setup" in result.remediation -def test_skipped_when_token_missing() -> None: - results = _check_ess_solution_installed( - _MinimalRunner(env_url=BASE_URL, dv_token=None) + +def test_fails_when_configured_alm_realm_is_not_supported() -> None: + config = _config() + config["almRealm"] = "sandbox" + agentbuilder = _FakeAgentBuilder() + runner = _Runner(agentbuilder=agentbuilder, config=config) + + result = _check_ess_solution_installed(runner)[0] + + assert result.status == Status.FAILED.value + assert "configured ALM realm is not Dev, Test, or Prod" in result.result + assert "Set almRealm, grsRealm, or minimalBotsAlmRealm" in ( + result.remediation ) - assert results[0].status == "Skipped" - - -@responses.activate -def test_failed_when_no_ess_solution(runner: _MinimalRunner) -> None: - _register_solutions(solutions=[]) - - results = _check_ess_solution_installed(runner) - assert len(results) == 1 - r = results[0] - assert r.checkpoint_id == "ESS-SOLN-001" - assert r.status == "Failed" - assert "not present" in r.result - assert "AppSource" in r.remediation - - -@responses.activate -def test_passed_when_base_solution_present(runner: _MinimalRunner) -> None: - _register_solutions(solutions=[ - _solution_record("msdyn_copilotforemployeeselfservice", version="1.2.3.4"), - ]) - - results = _check_ess_solution_installed(runner) - assert len(results) == 1 - r = results[0] - assert r.checkpoint_id == "ESS-SOLN-001" - assert r.status == "Passed" - assert "msdyn_copilotforemployeeselfservice" in r.result - assert "1.2.3.4" in r.result - # Principle 8: PASSED carries no remediation. - assert r.remediation == "" - - -@responses.activate -def test_passed_when_it_variant_present(runner: _MinimalRunner) -> None: - _register_solutions(solutions=[ - _solution_record("msdyn_copilotforemployeeselfserviceit"), - ]) - - r = _check_ess_solution_installed(runner)[0] - assert r.status == "Passed" - assert "msdyn_copilotforemployeeselfserviceit" in r.result - - -@responses.activate -def test_passed_when_hr_variant_present(runner: _MinimalRunner) -> None: - _register_solutions(solutions=[ - _solution_record("msdyn_copilotforemployeeselfservicehr"), - ]) - - r = _check_ess_solution_installed(runner)[0] - assert r.status == "Passed" - assert "msdyn_copilotforemployeeselfservicehr" in r.result - - -@responses.activate -def test_passed_lists_multiple_editions(runner: _MinimalRunner) -> None: - _register_solutions(solutions=[ - _solution_record("msdyn_copilotforemployeeselfservice"), - _solution_record("msdyn_copilotforemployeeselfserviceit"), - ]) - - r = _check_ess_solution_installed(runner)[0] - assert r.status == "Passed" - assert "msdyn_copilotforemployeeselfservice " in r.result - assert "msdyn_copilotforemployeeselfserviceit" in r.result - - -@responses.activate -def test_warning_when_dataverse_returns_500(runner: _MinimalRunner) -> None: - """A transient platform error must surface as WARNING, not silently PASS.""" - responses.add( - "GET", - dv.build_query_url( - BASE_URL, - "solutions", - select=ESS_SOLN_SELECT, - filter_expr=ESS_SOLN_FILTER, - ), - json={"error": {"code": "0x80040220", "message": "boom"}}, - status=500, + assert "Dev, Test, or Prod" in result.remediation + assert agentbuilder.calls == [] + + +@pytest.mark.parametrize( + ("raw_realm", "expected"), + [ + (DEV_REALM, DEV_REALM), + ("dev", DEV_REALM), + ("DEV", DEV_REALM), + ("test", TEST_REALM), + ("TeSt", TEST_REALM), + ("prod", PROD_REALM), + ("PROD", PROD_REALM), + (str(TEST_REALM), TEST_REALM), + ("sandbox", None), + ], +) +def test_alm_realm_maps_supported_config_values( + raw_realm: Any, + expected: int | None, +) -> None: + runner = _Runner(config={"almRealm": raw_realm}) + + assert _alm_realm(runner) == expected + + +def test_warning_when_configure_read_errors() -> None: + error = AgentBuilderHTTPError("Dev realm configuration", 500) + runner = _Runner( + agentbuilder=_FakeAgentBuilder(error=error), + config=_config(), ) - r = _check_ess_solution_installed(runner)[0] - assert r.status == "Warning" - assert "Unable to verify the ESS solution" in r.result - - -@responses.activate -def test_warning_when_dataverse_returns_401(runner: _MinimalRunner) -> None: - """A 401 must surface as WARNING with an auth-expired hint. - - Exercises the AuthExpiredError catch block in _check_ess_solution_installed. - """ - responses.add( - "GET", - dv.build_query_url( - BASE_URL, - "solutions", - select=ESS_SOLN_SELECT, - filter_expr=ESS_SOLN_FILTER, - ), - json={"error": {"code": "0x80048306", "message": "token expired"}}, - status=401, - ) + result = _check_ess_solution_installed(runner)[0] - r = _check_ess_solution_installed(runner)[0] - assert r.status == "Warning" - assert "401" in r.result - assert "Re-run FlightCheck" in r.remediation + assert result.status == Status.WARNING.value + assert "Unable to verify ESS package state" in result.result + assert "Dev realm configuration failed with HTTP 500" in result.result + assert "Retry the read-only ALM configure check" in result.remediation + assert "AgentBuilder access" in result.remediation diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index dbb97f857..8983cc9ec 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -10,9 +10,9 @@ 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. + * 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 — pure-config check (restBaseUrl trimmed to '/api'). * WD-REST-002 — pure local-file check (user-context redirect topic); SKIPPED on the legacy install path. @@ -28,22 +28,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 +58,17 @@ 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 + + @dataclass class _Runner: config: Any = field(default_factory=dict) @@ -69,6 +76,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 +98,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,147 +241,111 @@ 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"] - - assert r.status == Status.PASSED.value - assert ( - "msdyn_sharedcommondataserviceforapps_workdayruntime" - in r.result - ) - - @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, - ) - 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.FAILED.value + assert "unbound" in r.result + assert "connectionId=null" in r.result + assert "bind the Workday SOAP connection reference" in r.remediation - @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_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", + ) + ] ) - 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 "was not found" in r.result + assert "shared_workdaysoap" in r.result + assert "Install or repair the Workday extension pack" in r.remediation - @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)], - ) - runner = _Runner(env_url=fake_dataverse_url, dv_token=fake_token) + 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.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.SKIPPED.value + assert "not available" in r.result + + 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.NOT_CONFIGURED.value - assert "was not found in this environment" in r.result - assert "Install/repair the Workday extension pack" in r.remediation + assert r.status == Status.SKIPPED.value + assert "not available" in r.result - def test_no_dv_token_skips(self): - runner = _Runner(env_url="https://x.crm.dynamics.com", dv_token="") + 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"}} + ), + ) 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.WARNING.value + assert "Unable to run DV-CONN-001" in r.result + assert "DV-CONN-001" in r.remediation # ───────────────────────────────────────────────────────────────────── diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index 93df536ee..5c84a445b 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -270,24 +270,6 @@ def test_missing_config_exits_1( cli._run_single_checkpoint(_args("ESS-SOLN-001", tmp_path)) assert exc.value.code == 1 - def test_missing_dataverse_endpoint_exits_1( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - # Config present (so the config gate passes) but no dataverseEndpoint, - # and ESS-SOLN-001 requires one -> the endpoint gate fires, still - # before any auth. - plan = registry.transitive_requirements("ESS-SOLN-001") - assert plan.requires_dataverse_endpoint, ( - "test assumes ESS-SOLN-001 requires a Dataverse endpoint" - ) - local = tmp_path / ".local" - local.mkdir() - (local / "config.json").write_text("{}", encoding="utf-8") - monkeypatch.chdir(tmp_path) - with pytest.raises(SystemExit) as exc: - cli._run_single_checkpoint(_args("ESS-SOLN-001", tmp_path)) - assert exc.value.code == 1 - def test_capacity_uses_explicit_environment_id_without_dataverse( self, tmp_path: Path, diff --git a/tests/flightcheck/test_registry.py b/tests/flightcheck/test_registry.py index e8882af57..4a9a07460 100644 --- a/tests/flightcheck/test_registry.py +++ b/tests/flightcheck/test_registry.py @@ -207,6 +207,17 @@ def test_native_agent_checkpoints_use_only_native_read_clients(self): "Native Agent" ] + def test_ess_soln_uses_agentbuilder_without_dataverse(self): + spec = registry.resolve("ESS-SOLN-001") + assert spec is not None and spec.key == "ESS-SOLN-001" + assert spec.clients == frozenset({registry.AGENTBUILDER}) + assert spec.requires_dataverse_endpoint is False + + plan = registry.transitive_requirements("ESS-SOLN-001") + assert plan.clients == frozenset({registry.AGENTBUILDER}) + assert plan.requires_config is True + assert plan.requires_dataverse_endpoint is False + def test_env009_is_individually_targetable_with_dataverse_only(self): spec = registry.resolve("ENV-009") assert spec is not None and spec.key == "ENV-009" @@ -216,24 +227,19 @@ def test_env009_is_individually_targetable_with_dataverse_only(self): assert plan.requires_dataverse_endpoint is True assert len(plan.ordered_fns) == 1 - def test_ess_soln_001_resolves_and_pulls_env_prereqs(self): + def test_ess_soln_001_resolves_to_agentbuilder_configure_read(self): spec = registry.resolve("ESS-SOLN-001") assert spec is not None and spec.key == "ESS-SOLN-001" assert spec.category_label == "Solution" assert spec.category_fn is run_solution_checks - # Solution presence is a pure Dataverse read. - assert spec.clients == frozenset({registry.DATAVERSE}) - assert spec.prereqs == ("ENV-002",) + assert spec.clients == frozenset({registry.AGENTBUILDER}) + assert spec.prereqs == () plan = registry.transitive_requirements("ESS-SOLN-001") - assert registry.DATAVERSE in plan.clients + assert plan.clients == frozenset({registry.AGENTBUILDER}) assert plan.requires_config is True - assert plan.requires_dataverse_endpoint is True - # Own fn (run_solution_checks) plus the shared run_environment_checks - # that ENV-001+ENV-002 pull in -> exactly two, environment first. + assert plan.requires_dataverse_endpoint is False fns = [fn for _label, fn in plan.ordered_fns] - assert run_solution_checks in fns - assert len(fns) == 2 - assert fns.index(run_solution_checks) == len(fns) - 1 + assert fns == [run_solution_checks] class TestListCheckpoints: @@ -294,7 +300,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,10 +335,12 @@ 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 @@ -354,7 +362,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): diff --git a/tests/mocks/agentbuilder_connectivity.py b/tests/mocks/agentbuilder_connectivity.py index f666f928c..01cc429da 100644 --- a/tests/mocks/agentbuilder_connectivity.py +++ b/tests/mocks/agentbuilder_connectivity.py @@ -22,6 +22,7 @@ MOCK_FAMILY_ID = "00000000-0000-0000-0000-000000003333" MOCK_CONNECTION_ID = "mock-servicenow-connection" MOCK_WORKDAY_CONNECTION_ID = "mock-workday-connection" +MOCK_COMMIT_SHA = "4bc80d2768da5de930fd56a1f5ee815b8f9d1d3b" MOCK_AGENTBUILDER_BASE = ( "https://00000000000000000000000000000000." "0.environment.api.test.powerplatform.com" @@ -42,7 +43,8 @@ def configuration() -> dict[str, Any]: "realm": "Dev", "cdsBotId": MOCK_AGENT_ID, "schemaName": "gptagent_mockemployeeselfservice", - "grsRepositoryId": MOCK_FAMILY_ID, + "grsRepositoryId": MOCK_ENV_ID, + "commitSha": MOCK_COMMIT_SHA, }