diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index c452dd4..1206951 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -223,8 +223,8 @@ def _get_setup_description(solution_tag: str, solutions_dir: str | None = None) """Return the SETUP_DESCRIPTION from a solution's setup.py, or a generic fallback.""" module = _load_setup_module(solution_tag, solutions_dir) if module: - return getattr(module, "SETUP_DESCRIPTION", "This solution includes a post-install setup script.") - return "This solution includes a post-install setup script." + return getattr(module, "SETUP_DESCRIPTION", "This solution includes a setup script.") + return "This solution includes a setup script." def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None, ctx=None, no_prompt: bool = False) -> None: @@ -700,7 +700,7 @@ def _do_import() -> None: if failed_m: typer.echo(failed_m.group(0)) typer.echo(f"\n {total_imported} imported, {total_failed} failed") - else: + elif total_imported > 0: typer.echo(f" {total_imported} entities imported") else: typer.echo(output) diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index a74cef3..fcb5788 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -139,6 +139,11 @@ def mark_done(self, key: str) -> None: self._state[key] = True self._save_file() + def mark_undone(self, key: str) -> None: + """Clear a step's completion state (e.g., after the underlying resource is deleted).""" + self._state.pop(key, None) + self._save_file() + @abstractmethod def collect_prompts(self) -> None: """Collect all user inputs upfront before executing steps.""" diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index d6f601a..1dc0863 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -5,7 +5,7 @@ """ SETUP_DESCRIPTION = ( - "This solution includes a post-install setup script that will create a GitHub " + "This solution includes a setup script that will create a GitHub " "repository, seed it with a GitHub workflow that will add a deploy to a Cortex entity, and configure the required secrets." ) import base64 @@ -172,8 +172,6 @@ def collect_prompts(self) -> None: if self._session_base_url: self._answers["cortex_base_url"] = self._session_base_url - else: - self.prompt("cortex_base_url", "Cortex base URL", default=self._session_base_url) else: self.prompt( "cortex_base_url", diff --git a/cortexapps_cli/solutions/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py index f2d3f20..f23a8f1 100644 --- a/cortexapps_cli/solutions/harness-deploy/setup.py +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -6,7 +6,7 @@ """ SETUP_DESCRIPTION = ( - "This solution includes a post-install setup script that will configure your Harness " + "This solution includes a setup script that will configure your Harness " "integration in Cortex, create the deploy pipeline and cortex_api_key secret in Harness, " "import the Cortex trigger workflow, and optionally fire a test deploy." ) diff --git a/cortexapps_cli/solutions/workday/README.md b/cortexapps_cli/solutions/workday/README.md new file mode 100644 index 0000000..519a4b6 --- /dev/null +++ b/cortexapps_cli/solutions/workday/README.md @@ -0,0 +1,137 @@ +--- +name: Workday Integration +description: Configure the Cortex Workday integration with a sample org hierarchy from the fictional company Pied Piper (from the TV show Silicon Valley) to sync employees and teams into your service catalog. +--- + +# Workday Integration + +Get the Cortex Workday integration running in minutes using a sample org hierarchy from the fictional company Pied Piper (from the TV show *Silicon Valley*). After install, trigger a sync to see employees and teams appear in your catalog — including the full team hierarchy. + +## Data Model + +``` + ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ + │ Workday Report │────▶│ Cortex Integration │────▶│ Team Catalog │ + │ (JSON / RaaS) │ │ (field mapping) │ │ + Members │ + └──────────────────────┘ └──────────────────────┘ └──────────────────────┘ + + Sample: Pied Piper supervisory org (Silicon Valley) + + PP: Pied Piper (Erlich Bachman) + ├── PP: Engineering (Richard Hendricks) + │ ├── PP: Platform (Bertram Gilfoyle) + │ │ └── PP: Infrastructure (Nelson Bighetti) + │ └── PP: Frontend (Dinesh Chugtai) + └── PP: Operations (Jared Dunn) + └── PP: People Ops (Monica Hall) + + Report Format + ───────────── + Each entry represents one employee. Workteam_Group lists the employee's teams. + Hierarchy is encoded as parentTeamId → teamName. Root teams use "NONE". + Managers have Team_Managed set to their team's teamName. + + { + "Report_Entry": [ + { + "Email": "erlich.bachman@piedpiper.com", + "Employee_ID": "PP-100000", + "First_Name": "Erlich", + "Last_Name": "Bachman", + "Managers_Email": "erlich.bachman@piedpiper.com", + "employeeRole": "CEO", + "Workteam_Group": [ + { + "teamName": "PP: Pied Piper", + "teamDisplayName": "PP: Pied Piper", + "parentTeamId": "NONE", + "Team_Managed": "PP: Pied Piper" + } + ] + }, + ... + ] + } + + Field Mapping (data/configuration.json) + ──────────────────────────────────────── + Maps report columns to employee fields, team identity, and hierarchy. + Workday reports can be customized — the configuration defines the field + attributes and the hierarchy, so it works with any supervisory org report. + + { + "ownershipReportUrl": "", + "reportMappingV2": { + "email": { "columnName": "Email" }, + "employeeId": { "columnName": "Employee_ID" }, + "firstName": { "columnName": "First_Name" }, + "lastName": { "columnName": "Last_Name" }, + "employeeRole": { "columnName": "employeeRole" }, + "teamListFields": { + "teamListKey": { "columnName": "Workteam_Group" }, + "teamId": { "columnName": "teamName" }, + "teamName": { "columnName": "teamDisplayName" }, + "hierarchy": { + "fieldOnParentNode": { "columnName": "teamName" }, + "fieldOnChildNode": { "columnName": "parentTeamId" } + }, + "teamEmployeeManages": { "columnName": "Team_Managed" } + }, + "type": "ONE_EMPLOYEE_MULTIPLE_TEAMS" + } + } +``` + +## After Installing + +Trigger the sync in Cortex to import the Pied Piper org hierarchy: + +**Catalog → All Entities → Import Entities → Workday → Sync Entities → Next Step** + +You should see 7 teams appear in your catalog with the full hierarchy: + +``` +PP: Pied Piper (Erlich Bachman) +├── PP: Engineering (Richard Hendricks) +│ ├── PP: Platform (Bertram Gilfoyle) +│ │ └── PP: Infrastructure (Nelson Bighetti) +│ └── PP: Frontend (Dinesh Chugtai) +└── PP: Operations (Jared Dunn) + └── PP: People Ops (Monica Hall) +``` + +Workday reports can be customized to match your org structure. The configuration defines which report columns map to employee fields, team identity, and the parent-child hierarchy — so the integration works with any supervisory org report that follows the same shape. + +## What's Included + +- **Pied Piper org data:** 7 employees across 4 levels of hierarchy (Erlich → Richard → Gilfoyle/Dinesh, Jared → Monica, Gilfoyle → Big Head) +- **Integration config:** field mapping and report URL pre-configured, pointing at the hosted data +- **Setup script:** one-command configuration of the Cortex Workday integration via API + +## Quick Start + +1. Install the solution: + + ``` + cortex solutions install -s workday + ``` + +2. Follow the post-install setup prompts, or run later: + + ``` + cortex solutions post-install -s workday + ``` + +3. Trigger the import in Cortex: + + **Catalog → All Entities → Import Entities → Workday → Sync Entities → Next Step** + +4. Check your team hierarchy to see the Pied Piper org chart. + +## How It Works + +The setup script calls the Cortex Workday integration API to configure a report URL pointing at the bundled Pied Piper supervisory org data. Cortex fetches the report and syncs employees and teams into your catalog on the next import run. + +## Adapting to Real Workday Data + +To connect your own Workday report, go to **Settings → Integrations → Workday** in the Cortex UI and update the Report URL, username, and password. The field mapping in `data/configuration.json` works unchanged for any Workday supervisory org report that uses the same column names. diff --git a/cortexapps_cli/solutions/workday/data/configuration.json b/cortexapps_cli/solutions/workday/data/configuration.json new file mode 100644 index 0000000..291ab2e --- /dev/null +++ b/cortexapps_cli/solutions/workday/data/configuration.json @@ -0,0 +1,31 @@ +{ + "username": "ISU_Cortex", + "ownershipReportUrl": "https://jeff-test-org.github.io/workday-mocks/pied-piper-supervisory-org", + "reportMappingV2": { + "email": { "columnName": "Email" }, + "employeeId": { "columnName": "Employee_ID" }, + "firstName": { "columnName": "First_Name" }, + "lastName": { "columnName": "Last_Name" }, + "managerEmail": null, + "employeeRole": { "columnName": "employeeRole" }, + "rootTeams": [], + "teamListFields": { + "teamListKey": { "columnName": "Workteam_Group" }, + "teamId": { "columnName": "teamName" }, + "teamName": { "columnName": "teamDisplayName" }, + "hierarchy": { + "fieldOnChildNode": { "columnName": "parentTeamId" }, + "fieldOnParentNode": { "columnName": "teamName", "isList": false } + }, + "teamEmployeeManages": { "columnName": "Team_Managed" } + }, + "fallbackFields": { + "teamId": null, + "teamName": null, + "fieldOnParentNode": null, + "fieldOnChildNode": null + }, + "type": "ONE_EMPLOYEE_MULTIPLE_TEAMS" + }, + "password": "asdf" +} diff --git a/cortexapps_cli/solutions/workday/data/pied-piper-supervisory-org.json b/cortexapps_cli/solutions/workday/data/pied-piper-supervisory-org.json new file mode 100644 index 0000000..9ae5bfd --- /dev/null +++ b/cortexapps_cli/solutions/workday/data/pied-piper-supervisory-org.json @@ -0,0 +1,113 @@ +{ + "Report_Entry": [ + { + "Email": "erlich.bachman@piedpiper.com", + "Employee_ID": "PP-100000", + "First_Name": "Erlich", + "Last_Name": "Bachman", + "Managers_Email": "erlich.bachman@piedpiper.com", + "employeeRole": "CEO", + "Workteam_Group": [ + { + "teamName": "PP: Pied Piper", + "teamDisplayName": "PP: Pied Piper", + "parentTeamId": "NONE", + "Team_Managed": "PP: Pied Piper" + } + ] + }, + { + "Email": "richard.hendricks@piedpiper.com", + "Employee_ID": "PP-100001", + "First_Name": "Richard", + "Last_Name": "Hendricks", + "Managers_Email": "erlich.bachman@piedpiper.com", + "employeeRole": "CTO", + "Workteam_Group": [ + { + "teamName": "PP: Engineering", + "teamDisplayName": "PP: Engineering", + "parentTeamId": "PP: Pied Piper", + "Team_Managed": "PP: Engineering" + } + ] + }, + { + "Email": "bertram.gilfoyle@piedpiper.com", + "Employee_ID": "PP-100002", + "First_Name": "Bertram", + "Last_Name": "Gilfoyle", + "Managers_Email": "richard.hendricks@piedpiper.com", + "employeeRole": "Staff Engineer", + "Workteam_Group": [ + { + "teamName": "PP: Platform", + "teamDisplayName": "PP: Platform", + "parentTeamId": "PP: Engineering", + "Team_Managed": "PP: Platform" + } + ] + }, + { + "Email": "dinesh.chugtai@piedpiper.com", + "Employee_ID": "PP-100003", + "First_Name": "Dinesh", + "Last_Name": "Chugtai", + "Managers_Email": "richard.hendricks@piedpiper.com", + "employeeRole": "Senior Engineer", + "Workteam_Group": [ + { + "teamName": "PP: Frontend", + "teamDisplayName": "PP: Frontend", + "parentTeamId": "PP: Engineering" + } + ] + }, + { + "Email": "jared.dunn@piedpiper.com", + "Employee_ID": "PP-100004", + "First_Name": "Jared", + "Last_Name": "Dunn", + "Managers_Email": "erlich.bachman@piedpiper.com", + "employeeRole": "COO", + "Workteam_Group": [ + { + "teamName": "PP: Operations", + "teamDisplayName": "PP: Operations", + "parentTeamId": "PP: Pied Piper", + "Team_Managed": "PP: Operations" + } + ] + }, + { + "Email": "monica.hall@piedpiper.com", + "Employee_ID": "PP-100005", + "First_Name": "Monica", + "Last_Name": "Hall", + "Managers_Email": "jared.dunn@piedpiper.com", + "employeeRole": "VP People", + "Workteam_Group": [ + { + "teamName": "PP: People Ops", + "teamDisplayName": "PP: People Ops", + "parentTeamId": "PP: Operations" + } + ] + }, + { + "Email": "nelson.bighetti@piedpiper.com", + "Employee_ID": "PP-100006", + "First_Name": "Nelson", + "Last_Name": "Bighetti", + "Managers_Email": "bertram.gilfoyle@piedpiper.com", + "employeeRole": "Engineer", + "Workteam_Group": [ + { + "teamName": "PP: Infrastructure", + "teamDisplayName": "PP: Infrastructure", + "parentTeamId": "PP: Platform" + } + ] + } + ] +} diff --git a/cortexapps_cli/solutions/workday/setup.py b/cortexapps_cli/solutions/workday/setup.py new file mode 100644 index 0000000..3262d6f --- /dev/null +++ b/cortexapps_cli/solutions/workday/setup.py @@ -0,0 +1,145 @@ +""" +Post-install setup script for the workday solution. +Configures the Cortex Workday integration to sync the Pied Piper org hierarchy. +Run via: cortex solutions post-install -s workday +""" + +SETUP_DESCRIPTION = ( + "This solution includes a setup script that will configure " + "the Cortex Workday integration to sync the Pied Piper org hierarchy." +) + +import json +import sys +from pathlib import Path +import requests + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +DATA_DIR = Path(__file__).parent / "data" +CONFIG_FILE = DATA_DIR / "configuration.json" + + +class WorkdayIntegrationSetup(SolutionSetup): + solution_tag = "workday" + + def __init__( + self, + cortex_api_key: str = None, + cortex_base_url: str = None, + no_prompt: bool = False, + **kwargs, + ): + super().__init__(no_prompt=no_prompt, **kwargs) + self._api_key = cortex_api_key or "" + self._base_url = (cortex_base_url or "https://api.getcortexapp.com").rstrip("/") + + def _cortex_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + def collect_prompts(self) -> None: + pass # credentials come from CLI session + + def _check_and_replace_existing(self) -> None: + """Check for an existing Workday config; backup and delete it if user confirms.""" + r = requests.get( + f"{self._base_url}/api/v1/workday/default-configuration", + headers=self._cortex_headers(), + ) + if r.status_code == 404: + self.mark_undone("configure") # no integration on server — reconfigure regardless of cached state + return + r.raise_for_status() + + if not self.confirm("Existing Workday integration found. Replace it?", default=False): + print("Keeping existing Workday integration. Exiting.") + raise SystemExit(0) + + # Back up the existing config + backup_dir = Path.home() / ".cortex" / "solutions" / "workday" + backup_dir.mkdir(parents=True, exist_ok=True) + backup_file = backup_dir / "backup-config.json" + backup_file.write_text(json.dumps(r.json(), indent=2)) + print(f" Backed up existing config to {backup_file}") + + # Delete the existing config + del_r = requests.delete( + f"{self._base_url}/api/v1/workday/configurations", + headers=self._cortex_headers(), + ) + del_r.raise_for_status() + self.mark_undone("configure") + + def _configure_integration(self) -> None: + """POST the bundled Workday integration configuration.""" + if self.already_done("configure"): + return "Already configured (skipped)" + config = json.loads(CONFIG_FILE.read_text()) + print(f" (cortex integrations workday add -f {CONFIG_FILE})") + r = requests.post( + f"{self._base_url}/api/v1/workday/configuration", + headers=self._cortex_headers(), + json=config, + ) + if not r.ok: + raise RuntimeError( + f"Failed to configure Workday integration: {r.status_code} {r.text}" + ) + self.mark_done("configure") + + def _validate_integration(self) -> None: + """Validate the Workday integration configuration.""" + print(f" (cortex integrations workday validate)") + r = requests.post( + f"{self._base_url}/api/v1/workday/configuration/validate", + headers=self._cortex_headers(), + ) + r.raise_for_status() + configs = r.json().get("configurations", []) + if not configs: + raise RuntimeError("Validation returned no results — is the integration configured?") + result = configs[0] + if not result.get("isValid", False): + raise RuntimeError(f"Validation failed: {result.get('message', 'unknown error')}") + print(f" ✓ Configuration validated successfully") + + def _enable_auto_import(self) -> None: + """Prompt user to enable Auto Import Workday Teams in Cortex settings.""" + print(" In Cortex: Settings → Entities → Teams → Enable 'Auto import Workday teams'") + if not self.confirm(" Is 'Auto import Workday teams' enabled?", default=False): + print("Please enable it before continuing.") + raise SystemExit(0) + + def steps(self) -> list: + return [ + ("Enable Auto Import Workday teams", self._enable_auto_import), + ("Check for existing Workday integration", self._check_and_replace_existing), + ("Configure Workday integration", self._configure_integration), + ("Validate Workday integration", self._validate_integration), + ] + + def post_steps(self) -> None: + print("\n✓ Workday integration configured with the Pied Piper org hierarchy.\n") + print("Next: trigger a sync in Cortex:") + print(" Catalog → All Entities → Import Entities → Workday") + print(" Click 'Sync Entities', then 'Next Step' to import.\n") + print("Then check your team hierarchy to see the Pied Piper org chart.") + + +def main(cortex_api_key=None, cortex_base_url=None, no_prompt=False, **kwargs): + WorkdayIntegrationSetup( + cortex_api_key=cortex_api_key, + cortex_base_url=cortex_base_url, + no_prompt=no_prompt, + ).run() + + +if __name__ == "__main__": + main() diff --git a/docs/superpowers/plans/2026-08-14-workday-integration-solution.md b/docs/superpowers/plans/2026-08-14-workday-integration-solution.md new file mode 100644 index 0000000..96eb8ba --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-workday-integration-solution.md @@ -0,0 +1,648 @@ +# Workday Integration Solution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `workday-integration` Cortex CLI solution that bundles a Pied Piper org hierarchy JSON report and a setup script that configures the Cortex Workday integration via API to point at it. + +**Architecture:** Static data files (`pied-piper-hierarchy.json`, `configuration.json`) live in the solution bundle and are served publicly via raw.githubusercontent.com. A `setup.py` wizard (extending `SolutionSetup`) checks for an existing Workday config, optionally backs it up, then POSTs the bundled configuration to the Cortex Workday API. No scorecards, no entity types, no external services. + +**Tech Stack:** Python 3.11+, `requests`, `SolutionSetup` base class (same pattern as `github-actions-deploy`) + +## Global Constraints + +- Solution tag: `workday-integration` +- Solution directory: `cortexapps_cli/solutions/workday-integration/` +- Report format: `ONE_EMPLOYEE_ONE_TEAM` hierarchy +- Report URL (baked into configuration.json): `https://raw.githubusercontent.com/cortexapps/cli/main/cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json` +- Cortex API endpoints (from `cortexapps_cli/commands/integrations_commands/workday.py`): + - GET existing config: `GET {base_url}/api/v1/workday/default-configuration` + - Create config: `POST {base_url}/api/v1/workday/configuration` + - Delete config: `DELETE {base_url}/api/v1/workday/configurations` (note: plural) +- Backup file on replace: `~/.cortex/solutions/workday-integration/backup-config.json` +- `SolutionSetup` must be imported dynamically (same pattern as `github-actions-deploy/setup.py`) +- State file: `~/.cortex/solutions/workday-integration.json` (handled by base class) +- Credentials come from CLI session (`cortex_api_key`, `cortex_base_url` kwargs to `main()`) +- No credential prompts in `collect_prompts()` — only the replace-existing prompt +- `feat:` commit prefix for setup.py (affects CLI deliverable); `chore:` or `docs:` for data/README + +--- + +## File Map + +| File | Action | +|------|--------| +| `cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json` | Create | +| `cortexapps_cli/solutions/workday-integration/data/configuration.json` | Create | +| `cortexapps_cli/solutions/workday-integration/setup.py` | Create | +| `cortexapps_cli/solutions/workday-integration/README.md` | Create | +| `tests/test_workday_setup.py` | Create | + +--- + +## Task 1: Data files + +**Files:** +- Create: `cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json` +- Create: `cortexapps_cli/solutions/workday-integration/data/configuration.json` +- Test: `tests/test_workday_setup.py` (data validation tests only) + +**Interfaces:** +- Produces: `DATA_DIR = Path(__file__).parent / "data"` — used by Task 2's `setup.py` +- Produces: `CONFIG_FILE = DATA_DIR / "configuration.json"` — loaded and POSTed in Task 2 + +- [ ] **Step 1: Create the directory** + +```bash +mkdir -p cortexapps_cli/solutions/workday-integration/data +``` + +- [ ] **Step 2: Write `pied-piper-hierarchy.json`** + +Create `cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json` with this exact content (7 Pied Piper employees, `ONE_EMPLOYEE_ONE_TEAM` format): + +```json +{ + "Report_Entry": [ + { + "email": "erlich.bachman@piedpiper.com", + "employeeId": "100000", + "firstName": "Erlich", + "lastName": "Bachman", + "managersEmail": "erlich.bachman@piedpiper.com", + "teamId": "WORKTEAM-1-000", + "teamName": "PP: Pied Piper", + "parentTeamId": "NONE" + }, + { + "email": "richard.hendricks@piedpiper.com", + "employeeId": "100001", + "firstName": "Richard", + "lastName": "Hendricks", + "managersEmail": "erlich.bachman@piedpiper.com", + "teamId": "WORKTEAM-1-001", + "teamName": "PP: Engineering", + "parentTeamId": "WORKTEAM-1-000" + }, + { + "email": "bertram.gilfoyle@piedpiper.com", + "employeeId": "100002", + "firstName": "Bertram", + "lastName": "Gilfoyle", + "managersEmail": "richard.hendricks@piedpiper.com", + "teamId": "WORKTEAM-1-002", + "teamName": "PP: Platform", + "parentTeamId": "WORKTEAM-1-001" + }, + { + "email": "dinesh.chugtai@piedpiper.com", + "employeeId": "100003", + "firstName": "Dinesh", + "lastName": "Chugtai", + "managersEmail": "richard.hendricks@piedpiper.com", + "teamId": "WORKTEAM-1-003", + "teamName": "PP: Frontend", + "parentTeamId": "WORKTEAM-1-001" + }, + { + "email": "jared.dunn@piedpiper.com", + "employeeId": "100004", + "firstName": "Jared", + "lastName": "Dunn", + "managersEmail": "erlich.bachman@piedpiper.com", + "teamId": "WORKTEAM-1-004", + "teamName": "PP: Operations", + "parentTeamId": "WORKTEAM-1-000" + }, + { + "email": "monica.hall@piedpiper.com", + "employeeId": "100005", + "firstName": "Monica", + "lastName": "Hall", + "managersEmail": "jared.dunn@piedpiper.com", + "teamId": "WORKTEAM-1-005", + "teamName": "PP: People Ops", + "parentTeamId": "WORKTEAM-1-004" + }, + { + "email": "nelson.bighetti@piedpiper.com", + "employeeId": "100006", + "firstName": "Nelson", + "lastName": "Bighetti", + "managersEmail": "bertram.gilfoyle@piedpiper.com", + "teamId": "WORKTEAM-1-006", + "teamName": "PP: Infrastructure", + "parentTeamId": "WORKTEAM-1-002" + } + ] +} +``` + +- [ ] **Step 3: Write `configuration.json`** + +Create `cortexapps_cli/solutions/workday-integration/data/configuration.json` with this exact content: + +```json +{ + "username": "ISU_Cortex", + "ownershipReportUrl": "https://raw.githubusercontent.com/cortexapps/cli/main/cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json", + "reportMappingV2": { + "email": { "columnName": "email" }, + "employeeId": { "columnName": "employeeId" }, + "firstName": { "columnName": "firstName" }, + "lastName": { "columnName": "lastName" }, + "managerEmail": { "columnName": "managersEmail" }, + "employeeRole": null, + "rootTeams": [], + "teamListFields": null, + "fallbackFields": { + "teamId": { "columnName": "teamId" }, + "teamName": { "columnName": "teamName" }, + "fieldOnParentNode": { "columnName": "teamId" }, + "fieldOnChildNode": { "columnName": "parentTeamId" } + }, + "type": "ONE_EMPLOYEE_ONE_TEAM" + }, + "password": "asdf" +} +``` + +- [ ] **Step 4: Write failing data validation tests** + +Create `tests/test_workday_setup.py` with these data-validation tests only (setup.py tests come in Task 2): + +```python +import json +from pathlib import Path + +DATA_DIR = Path("cortexapps_cli/solutions/workday-integration/data") +REPORT_URL = ( + "https://raw.githubusercontent.com/cortexapps/cli/main" + "/cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json" +) + + +def test_hierarchy_json_is_valid(): + data = json.loads((DATA_DIR / "pied-piper-hierarchy.json").read_text()) + assert "Report_Entry" in data + assert len(data["Report_Entry"]) == 7 + + +def test_hierarchy_has_root_employee(): + data = json.loads((DATA_DIR / "pied-piper-hierarchy.json").read_text()) + roots = [e for e in data["Report_Entry"] if e["managersEmail"] == e["email"]] + assert len(roots) == 1 + assert roots[0]["email"] == "erlich.bachman@piedpiper.com" + + +def test_hierarchy_has_root_team(): + data = json.loads((DATA_DIR / "pied-piper-hierarchy.json").read_text()) + roots = [e for e in data["Report_Entry"] if e["parentTeamId"] == "NONE"] + assert len(roots) == 1 + assert roots[0]["teamId"] == "WORKTEAM-1-000" + + +def test_hierarchy_required_fields(): + data = json.loads((DATA_DIR / "pied-piper-hierarchy.json").read_text()) + required = {"email", "employeeId", "firstName", "lastName", "managersEmail", + "teamId", "teamName", "parentTeamId"} + for entry in data["Report_Entry"]: + assert required <= entry.keys(), f"Missing fields in entry: {entry}" + + +def test_configuration_json_is_valid(): + config = json.loads((DATA_DIR / "configuration.json").read_text()) + assert config["ownershipReportUrl"] == REPORT_URL + assert config["reportMappingV2"]["type"] == "ONE_EMPLOYEE_ONE_TEAM" + assert "password" in config + assert "username" in config + + +def test_configuration_mapping_fields(): + config = json.loads((DATA_DIR / "configuration.json").read_text()) + mapping = config["reportMappingV2"] + assert mapping["email"]["columnName"] == "email" + assert mapping["managerEmail"]["columnName"] == "managersEmail" + ff = mapping["fallbackFields"] + assert ff["fieldOnParentNode"]["columnName"] == "teamId" + assert ff["fieldOnChildNode"]["columnName"] == "parentTeamId" +``` + +- [ ] **Step 5: Run tests to verify they fail (data files don't exist yet)** + +```bash +poetry run pytest tests/test_workday_setup.py -v +``` + +Expected: FAIL (FileNotFoundError or similar — data files not created yet). + +If files are already created from Steps 2–3, the tests should PASS. That is also fine — proceed. + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +poetry run pytest tests/test_workday_setup.py::test_hierarchy_json_is_valid \ + tests/test_workday_setup.py::test_configuration_json_is_valid -v +``` + +Expected: all 6 data tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add cortexapps_cli/solutions/workday-integration/data/ tests/test_workday_setup.py +git commit -m "chore: add Pied Piper hierarchy data and configuration for workday-integration solution" +``` + +--- + +## Task 2: setup.py + +**Files:** +- Create: `cortexapps_cli/solutions/workday-integration/setup.py` +- Modify: `tests/test_workday_setup.py` (add setup script tests) + +**Interfaces:** +- Consumes: `cortexapps_cli/solutions/workday-integration/data/configuration.json` (Task 1) +- Consumes: `SolutionSetup` base class from `cortexapps_cli/solutions/_lib/setup_base.py` +- Produces: `main(cortex_api_key=None, cortex_base_url=None, no_prompt=False, **kwargs)` — called by `_run_post_install_script` in `cortexapps_cli/commands/solutions.py` +- Produces: `SETUP_DESCRIPTION` module-level string — displayed by `cortex solutions install` + +**Key facts about the `_run_post_install_script` caller (do not modify this file):** +```python +# From cortexapps_cli/commands/solutions.py: +kwargs["cortex_api_key"] = client.api_key +kwargs["cortex_base_url"] = client.base_url +kwargs["no_prompt"] = no_prompt +module.main(**kwargs) +``` +So `main()` receives `cortex_api_key`, `cortex_base_url`, and `no_prompt` as keyword args. + +**Cortex API endpoints to call:** +- Check existing: `GET {base_url}/api/v1/workday/default-configuration` +- Delete existing: `DELETE {base_url}/api/v1/workday/configurations` (plural `configurations`) +- Create new: `POST {base_url}/api/v1/workday/configuration` (singular `configuration`) + +- [ ] **Step 1: Write failing tests for the setup script** + +Append to `tests/test_workday_setup.py`: + +```python +import importlib.util +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "workday_setup", + "cortexapps_cli/solutions/workday-integration/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def mod(): + return load_setup_module() + + +@pytest.fixture +def setup(mod, tmp_path): + return mod.WorkdayIntegrationSetup( + cortex_api_key="crt_test", + cortex_base_url="https://api.getcortexapp.com", + state_dir=tmp_path, + ) + + +def test_solution_tag(mod): + assert mod.WorkdayIntegrationSetup.solution_tag == "workday-integration" + + +def test_setup_description(mod): + assert "Workday" in mod.SETUP_DESCRIPTION + + +def test_collect_prompts_is_noop(setup): + # collect_prompts() must not raise and must not call input() + with patch("builtins.input", side_effect=AssertionError("should not prompt")): + setup.collect_prompts() # no exception = pass + + +def test_check_existing_no_config_proceeds(setup): + resp_404 = MagicMock(status_code=404) + resp_404.raise_for_status = MagicMock() + with patch("requests.get", return_value=resp_404): + # Should return without prompting or raising + setup._check_and_replace_existing() + + +def test_check_existing_user_declines_exits(setup): + resp_200 = MagicMock(status_code=200) + resp_200.raise_for_status = MagicMock() + resp_200.json.return_value = {"username": "ISU_Cortex"} + with patch("requests.get", return_value=resp_200), \ + patch("builtins.input", return_value="n"): + with pytest.raises(SystemExit) as exc_info: + setup._check_and_replace_existing() + assert exc_info.value.code == 0 + + +def test_check_existing_user_accepts_backs_up_and_deletes(setup, tmp_path): + existing = {"username": "ISU_Cortex", "ownershipReportUrl": "https://old.example.com"} + resp_200 = MagicMock(status_code=200) + resp_200.raise_for_status = MagicMock() + resp_200.json.return_value = existing + resp_del = MagicMock(status_code=204) + resp_del.raise_for_status = MagicMock() + + backup_dir = tmp_path / "workday-integration" + + with patch("requests.get", return_value=resp_200), \ + patch("requests.delete", return_value=resp_del) as mock_delete, \ + patch("builtins.input", return_value="y"), \ + patch("pathlib.Path.home", return_value=tmp_path): + setup._check_and_replace_existing() + + mock_delete.assert_called_once() + delete_url = mock_delete.call_args.args[0] + assert "configurations" in delete_url # plural endpoint + + backup_file = tmp_path / ".cortex" / "solutions" / "workday-integration" / "backup-config.json" + assert backup_file.exists() + assert json.loads(backup_file.read_text()) == existing + + +def test_configure_integration_posts_correct_payload(setup): + resp = MagicMock(ok=True, status_code=200) + with patch("requests.post", return_value=resp) as mock_post: + setup._configure_integration() + + mock_post.assert_called_once() + url = mock_post.call_args.args[0] + assert url.endswith("/api/v1/workday/configuration") + + payload = mock_post.call_args.kwargs["json"] + assert payload["reportMappingV2"]["type"] == "ONE_EMPLOYEE_ONE_TEAM" + assert "pied-piper-hierarchy.json" in payload["ownershipReportUrl"] + + +def test_configure_integration_raises_on_failure(setup): + resp = MagicMock(ok=False, status_code=400, text="Bad Request") + with patch("requests.post", return_value=resp): + with pytest.raises(RuntimeError, match="Failed to configure"): + setup._configure_integration() + + +def test_configure_integration_idempotent(setup, tmp_path): + setup.mark_done("configure") + with patch("requests.post") as mock_post: + setup._configure_integration() + mock_post.assert_not_called() + + +def test_main_callable(mod): + assert callable(mod.main) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +poetry run pytest tests/test_workday_setup.py -k "not test_hierarchy and not test_configuration" -v +``` + +Expected: FAIL with `ModuleNotFoundError` or `FileNotFoundError` (setup.py doesn't exist yet). + +- [ ] **Step 3: Create `setup.py`** + +Create `cortexapps_cli/solutions/workday-integration/setup.py` with this exact content: + +```python +""" +Post-install setup script for the workday-integration solution. +Configures the Cortex Workday integration to sync the Pied Piper org hierarchy. +Run via: cortex solutions post-install -s workday-integration +""" + +SETUP_DESCRIPTION = ( + "This solution includes a post-install setup script that will configure " + "the Cortex Workday integration to sync the Pied Piper org hierarchy." +) + +import json +import sys +from pathlib import Path +import requests + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +DATA_DIR = Path(__file__).parent / "data" +CONFIG_FILE = DATA_DIR / "configuration.json" + + +class WorkdayIntegrationSetup(SolutionSetup): + solution_tag = "workday-integration" + + def __init__( + self, + cortex_api_key: str = None, + cortex_base_url: str = None, + no_prompt: bool = False, + **kwargs, + ): + super().__init__(no_prompt=no_prompt, **kwargs) + self._api_key = cortex_api_key or "" + self._base_url = (cortex_base_url or "https://api.getcortexapp.com").rstrip("/") + + def _cortex_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + def collect_prompts(self) -> None: + pass # credentials come from CLI session + + def _check_and_replace_existing(self) -> None: + """Check for an existing Workday config; backup and delete it if user confirms.""" + r = requests.get( + f"{self._base_url}/api/v1/workday/default-configuration", + headers=self._cortex_headers(), + ) + if r.status_code == 404: + return # no existing config — proceed + r.raise_for_status() + + if not self.confirm("Existing Workday integration found. Replace it?", default=False): + print("Keeping existing Workday integration. Exiting.") + raise SystemExit(0) + + # Back up the existing config + backup_dir = Path.home() / ".cortex" / "solutions" / "workday-integration" + backup_dir.mkdir(parents=True, exist_ok=True) + backup_file = backup_dir / "backup-config.json" + backup_file.write_text(json.dumps(r.json(), indent=2)) + print(f" Backed up existing config to {backup_file}") + + # Delete the existing config + del_r = requests.delete( + f"{self._base_url}/api/v1/workday/configurations", + headers=self._cortex_headers(), + ) + del_r.raise_for_status() + + def _configure_integration(self) -> None: + """POST the bundled Workday integration configuration.""" + if self.already_done("configure"): + return "Already configured (skipped)" + config = json.loads(CONFIG_FILE.read_text()) + r = requests.post( + f"{self._base_url}/api/v1/workday/configuration", + headers=self._cortex_headers(), + json=config, + ) + if not r.ok: + raise RuntimeError( + f"Failed to configure Workday integration: {r.status_code} {r.text}" + ) + self.mark_done("configure") + + def steps(self) -> list: + return [ + ("Check for existing Workday integration", self._check_and_replace_existing), + ("Configure Workday integration", self._configure_integration), + ] + + def post_steps(self) -> None: + print("\n✓ Workday integration configured with the Pied Piper org hierarchy.\n") + print("Next: trigger the import in Cortex:") + print(" Catalog → All Entities → Import Entities\n") + print("Then check your team hierarchy to see the Pied Piper org chart.") + + +def main(cortex_api_key=None, cortex_base_url=None, no_prompt=False, **kwargs): + WorkdayIntegrationSetup( + cortex_api_key=cortex_api_key, + cortex_base_url=cortex_base_url, + no_prompt=no_prompt, + ).run() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run all setup script tests** + +```bash +poetry run pytest tests/test_workday_setup.py -v +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cortexapps_cli/solutions/workday-integration/setup.py tests/test_workday_setup.py +git commit -m "feat: add workday-integration solution setup script" +``` + +--- + +## Task 3: README + +**Files:** +- Create: `cortexapps_cli/solutions/workday-integration/README.md` + +**Interfaces:** +- Consumes: nothing from prior tasks (pure documentation) + +- [ ] **Step 1: Create `README.md`** + +Create `cortexapps_cli/solutions/workday-integration/README.md` with this content: + +```markdown +--- +name: Workday Integration +description: Configure the Cortex Workday integration with a sample Pied Piper org hierarchy to sync employees and teams into your service catalog. +--- + +# Workday Integration + +Get the Cortex Workday integration running in minutes using a pre-built Pied Piper org hierarchy. After install, trigger a sync to see employees and teams appear in your catalog — including the full team hierarchy. + +## What's Included + +- **Pied Piper org data:** 7 employees across 4 levels of hierarchy (Erlich → Richard → Gilfoyle/Dinesh, Jared → Monica, Bachman → Big Head) +- **Integration config:** field mapping and report URL pre-configured, pointing at the hosted data +- **Setup script:** one-command configuration of the Cortex Workday integration via API + +## Quick Start + +1. Install the solution: + + ``` + cortex solutions install -s workday-integration + ``` + +2. Follow the post-install setup prompts, or run later: + + ``` + cortex solutions post-install -s workday-integration + ``` + +3. Trigger the import in Cortex: + + **Catalog → All Entities → Import Entities** + +4. Check your team hierarchy to see the Pied Piper org chart. + +## Org Hierarchy + +``` +PP: Pied Piper (Erlich Bachman) +├── PP: Engineering (Richard Hendricks) +│ ├── PP: Platform (Bertram Gilfoyle) +│ │ └── PP: Infrastructure (Nelson Bighetti) +│ └── PP: Frontend (Dinesh Chugtai) +└── PP: Operations (Jared Dunn) + └── PP: People Ops (Monica Hall) +``` + +## How It Works + +The setup script calls the Cortex Workday integration API to configure a report URL pointing at `pied-piper-hierarchy.json` hosted in this repository. Cortex fetches the report and syncs employees and teams into your catalog on the next import run. + +## Adapting to Real Workday Data + +To point the integration at a real Workday RaaS report: + +1. Go to **Settings → Integrations → Workday** in the Cortex UI +2. Update the **Report URL** to your Workday RaaS endpoint +3. Set your real **username** and **password** +4. Trigger a new import + +The field mapping (`reportMappingV2`) in `data/configuration.json` matches the standard Cortex Workday report format and works unchanged for real Workday data that uses the same column names. +``` + +- [ ] **Step 2: Run the full test suite to confirm nothing is broken** + +```bash +poetry run pytest tests/test_workday_setup.py tests/test_solutions_postinstall.py tests/test_setup_base.py -v +``` + +Expected: all tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add cortexapps_cli/solutions/workday-integration/README.md +git commit -m "docs: add README for workday-integration solution" +``` diff --git a/docs/superpowers/specs/2026-08-14-workday-integration-solution-design.md b/docs/superpowers/specs/2026-08-14-workday-integration-solution-design.md new file mode 100644 index 0000000..51247da --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-workday-integration-solution-design.md @@ -0,0 +1,140 @@ +# Workday Integration Solution Design + +> **Status:** Approved + +## Goal + +Add a `workday-integration` Cortex CLI solution that configures the Cortex Workday integration to sync a Pied Piper org hierarchy from a public JSON report bundled in the CLI repo. + +## Architecture + +A minimal solution: bundled static data + a `configuration.json` field mapping + a lightweight setup wizard that makes one Cortex API call. No external services to create, no scorecards, no entity types to install. The Workday integration in Cortex handles entity creation when the user triggers the sync. + +## Tech Stack + +- Python 3.11+, `requests`, `SolutionSetup` base class (same pattern as `github-actions-deploy`) +- Data served via `raw.githubusercontent.com` (no GitHub Pages setup required) + +## Global Constraints + +- Solution tag: `workday-integration` +- Data file: `ONE_EMPLOYEE_ONE_TEAM` hierarchy format +- Report URL: `https://raw.githubusercontent.com/cortexapps/cli/main/cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json` +- No scorecards, no catalog entity imports, no entity types +- `SolutionSetup` base class must be imported dynamically (same pattern as `github-actions-deploy/setup.py`) +- State file lives at `~/.cortex/solutions/workday-integration.json` + +--- + +## File Structure + +``` +cortexapps_cli/solutions/workday-integration/ +├── README.md +├── setup.py +└── data/ + ├── pied-piper-hierarchy.json # Pied Piper org report (copied from workday-mocks) + └── configuration.json # Cortex Workday integration config (static, bundled) +``` + +--- + +## Data + +### `data/pied-piper-hierarchy.json` + +Copied verbatim from `~/git/jeff-test-org/workday-mocks/pied-piper-hierarchy/index.json`. + +`ONE_EMPLOYEE_ONE_TEAM` format. Each entry has: `email`, `employeeId`, `firstName`, `lastName`, `managersEmail`, `teamId`, `teamName`, `parentTeamId`. Root employee self-references in `managersEmail`. Root teams have `parentTeamId: "NONE"`. + +### `data/configuration.json` + +Static — the URL is fixed at the raw.githubusercontent.com path above. + +```json +{ + "username": "ISU_Cortex", + "ownershipReportUrl": "https://raw.githubusercontent.com/cortexapps/cli/main/cortexapps_cli/solutions/workday-integration/data/pied-piper-hierarchy.json", + "reportMappingV2": { + "email": { "columnName": "email" }, + "employeeId": { "columnName": "employeeId" }, + "firstName": { "columnName": "firstName" }, + "lastName": { "columnName": "lastName" }, + "managerEmail": { "columnName": "managersEmail" }, + "employeeRole": null, + "rootTeams": [], + "teamListFields": null, + "fallbackFields": { + "teamId": { "columnName": "teamId" }, + "teamName": { "columnName": "teamName" }, + "fieldOnParentNode": { "columnName": "teamId" }, + "fieldOnChildNode": { "columnName": "parentTeamId" } + }, + "type": "ONE_EMPLOYEE_ONE_TEAM" + }, + "password": "asdf" +} +``` + +--- + +## Setup Script (`setup.py`) + +Extends `SolutionSetup`. No prompts for credentials — the CLI already has them via `ctx`. Zero prompts in `collect_prompts()`. + +Credentials are read from `ctx.obj["client"]` (the `CortexClient` already configured for the session): `client.api_key` and `client.base_url`. + +### `collect_prompts()` + +Empty — no user prompts needed. + +### `steps()` + +**Step 1: Check for existing Workday integration** +- `GET {base_url}/api/v1/integrations/workday` +- If 404: no existing config, proceed to Step 2 +- If 200: existing integration found — prompt: `"Existing Workday integration found. Replace it? [y/N]"` + - If N: abort with message `"Keeping existing Workday integration. Exiting."` + - If Y: + - Write existing config response body to `~/.cortex/solutions/workday-integration/backup-config.json` + - `DELETE {base_url}/api/v1/integrations/workday` + +**Step 2: Configure Workday integration** +- Reads `data/configuration.json` from the solution directory +- `POST {base_url}/api/v1/integrations/workday` with config as JSON body +- Auth header: `Authorization: Bearer {api_key}` +- On success: prints confirmation +- On error: raises with response body for diagnosis + +Uses `already_done` / `mark_done` for idempotency (re-running post-install skips Step 2 if already done). + +### `post_steps()` + +Prints: + +``` +✓ Workday integration configured with the Pied Piper org hierarchy. + +Next: trigger the import in Cortex: + Catalog → All Entities → Import Entities + +Then check your team hierarchy to see the Pied Piper org chart. +``` + +--- + +## README.md + +Covers: +- What the solution installs (integration config + Pied Piper data) +- Quick start (`cortex solutions install -s workday-integration`) +- How to trigger the sync (Catalog → All Entities → Import Entities) +- What to expect after sync (employees + team hierarchy in Cortex) +- How to adapt to real Workday data (swap `ownershipReportUrl`, set real `username`/`password`) + +--- + +## Testing + +- Unit tests for `setup.py`: mock the `requests.post` call, verify correct URL + payload +- Integration test: `cortex solutions post-install -s workday-integration --no-prompt` against a sandbox tenant diff --git a/tests/test_github_actions_setup.py b/tests/test_github_actions_setup.py index fa83ef3..8ea4a80 100644 --- a/tests/test_github_actions_setup.py +++ b/tests/test_github_actions_setup.py @@ -123,7 +123,7 @@ def test_set_secret(setup): def test_trigger_workflow(setup): resp = MagicMock(status_code=204) with patch("requests.post", return_value=resp) as mock_post: - setup._trigger_workflow() + setup._trigger_direct() url = mock_post.call_args.args[0] assert "dispatches" in url assert mock_post.call_args.kwargs["json"] == {"ref": "main"} diff --git a/tests/test_setup_base.py b/tests/test_setup_base.py index e88d8d3..2fa72fb 100644 --- a/tests/test_setup_base.py +++ b/tests/test_setup_base.py @@ -19,7 +19,8 @@ def steps(self): def test_prompt_uses_env_var(tmp_path, monkeypatch): monkeypatch.setenv("MY_VAR", "from-env") setup = ConcreteSetup(state_dir=tmp_path) - result = setup.prompt("key", "Enter value", env_var="MY_VAR") + with patch("builtins.input", return_value=""): + result = setup.prompt("key", "Enter value", env_var="MY_VAR") assert result == "from-env" @@ -75,9 +76,25 @@ def test_mark_done_persists_across_instances(tmp_path): assert ConcreteSetup(state_dir=tmp_path).already_done("step1") is True +def test_mark_undone_clears_state(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + setup.mark_done("step1") + assert setup.already_done("step1") is True + setup.mark_undone("step1") + assert setup.already_done("step1") is False + # persists across instances + assert ConcreteSetup(state_dir=tmp_path).already_done("step1") is False + + +def test_mark_undone_noop_when_not_set(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + setup.mark_undone("nonexistent") # must not raise + assert setup.already_done("nonexistent") is False + + def test_state_file_path(tmp_path): setup = ConcreteSetup(state_dir=tmp_path) - assert setup._state_file == tmp_path / "setup-test-solution.json" + assert setup._state_file == tmp_path / "test-solution.json" def test_post_steps_called_after_steps(tmp_path): diff --git a/tests/test_solutions_postinstall.py b/tests/test_solutions_postinstall.py index 5c71552..8c9bc11 100644 --- a/tests/test_solutions_postinstall.py +++ b/tests/test_solutions_postinstall.py @@ -1,5 +1,5 @@ import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, ANY from typer.testing import CliRunner from cortexapps_cli.cli import app @@ -21,7 +21,7 @@ def test_post_install_unknown_solution(): def test_post_install_calls_run_for_github_actions(): with patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run: runner.invoke(app, ["solutions", "post-install", "-s", "github-actions-deploy"]) - mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None, ctx=ANY, no_prompt=False) def test_install_skip_post_install_setup_flag_skips_script(): @@ -51,5 +51,5 @@ def test_install_prompts_and_runs_post_install_on_yes(): ["-k", "fake", "solutions", "install", "-s", "github-actions-deploy"], input="y\n", ) - assert "This solution includes a post-install setup script." in result.output - mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) + assert "This solution includes a setup script" in result.output + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None, ctx=ANY) diff --git a/tests/test_workday_setup.py b/tests/test_workday_setup.py new file mode 100644 index 0000000..b25868c --- /dev/null +++ b/tests/test_workday_setup.py @@ -0,0 +1,231 @@ +import json +from pathlib import Path + +DATA_DIR = Path("cortexapps_cli/solutions/workday/data") +REPORT_URL = "https://jeff-test-org.github.io/workday-mocks/pied-piper-supervisory-org" + + +def test_hierarchy_json_is_valid(): + data = json.loads((DATA_DIR / "pied-piper-supervisory-org.json").read_text()) + assert "Report_Entry" in data + assert len(data["Report_Entry"]) == 7 + + +def test_hierarchy_has_root_team(): + data = json.loads((DATA_DIR / "pied-piper-supervisory-org.json").read_text()) + all_teams = [team for entry in data["Report_Entry"] for team in entry["Workteam_Group"]] + root_teams = {team["teamName"] for team in all_teams if team["parentTeamId"] == "NONE"} + assert len(root_teams) == 1 + assert "PP: Pied Piper" in root_teams + + +def test_hierarchy_parent_child_links(): + data = json.loads((DATA_DIR / "pied-piper-supervisory-org.json").read_text()) + all_teams = [team for entry in data["Report_Entry"] for team in entry["Workteam_Group"]] + team_names = {team["teamName"] for team in all_teams} + for team in all_teams: + if team["parentTeamId"] != "NONE": + assert team["parentTeamId"] in team_names, ( + f"{team['teamName']}.parentTeamId={team['parentTeamId']!r} not found in any teamName" + ) + + +def test_hierarchy_required_fields(): + data = json.loads((DATA_DIR / "pied-piper-supervisory-org.json").read_text()) + required_top = {"Email", "Employee_ID", "First_Name", "Last_Name", "Workteam_Group"} + required_team = {"teamName", "teamDisplayName", "parentTeamId"} + for entry in data["Report_Entry"]: + assert required_top <= entry.keys(), f"Missing top-level fields in entry: {entry}" + assert isinstance(entry["Workteam_Group"], list), f"Workteam_Group must be a list in: {entry}" + for team in entry["Workteam_Group"]: + assert required_team <= team.keys(), f"Missing team fields in: {team}" + + +def test_configuration_json_is_valid(): + config = json.loads((DATA_DIR / "configuration.json").read_text()) + assert config["ownershipReportUrl"] == REPORT_URL + assert config["reportMappingV2"]["type"] == "ONE_EMPLOYEE_MULTIPLE_TEAMS" + assert "password" in config + assert "username" in config + + +def test_configuration_mapping_fields(): + config = json.loads((DATA_DIR / "configuration.json").read_text()) + mapping = config["reportMappingV2"] + assert mapping["email"]["columnName"] == "Email" + assert mapping["employeeId"]["columnName"] == "Employee_ID" + tlf = mapping["teamListFields"] + assert tlf["teamListKey"]["columnName"] == "Workteam_Group" + assert tlf["teamId"]["columnName"] == "teamName" + assert tlf["hierarchy"]["fieldOnParentNode"]["columnName"] == "teamName" + assert tlf["hierarchy"]["fieldOnChildNode"]["columnName"] == "parentTeamId" + + +import importlib.util +import pytest +from unittest.mock import patch, MagicMock + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "workday_setup", + "cortexapps_cli/solutions/workday/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def mod(): + return load_setup_module() + + +@pytest.fixture +def setup(mod, tmp_path): + return mod.WorkdayIntegrationSetup( + cortex_api_key="crt_test", + cortex_base_url="https://api.getcortexapp.com", + state_dir=tmp_path, + ) + + +def test_solution_tag(mod): + assert mod.WorkdayIntegrationSetup.solution_tag == "workday" + + +def test_setup_description(mod): + assert "Workday" in mod.SETUP_DESCRIPTION + + +def test_collect_prompts_is_noop(setup): + with patch("builtins.input", side_effect=AssertionError("should not prompt")): + setup.collect_prompts() + + +def test_check_existing_no_config_proceeds(setup): + resp_404 = MagicMock(status_code=404) + resp_404.raise_for_status = MagicMock() + setup.mark_done("configure") # simulate stale cached state + with patch("requests.get", return_value=resp_404): + setup._check_and_replace_existing() + assert not setup.already_done("configure") + + +def test_check_existing_user_declines_exits(setup): + resp_200 = MagicMock(status_code=200) + resp_200.raise_for_status = MagicMock() + resp_200.json.return_value = {"username": "ISU_Cortex"} + with patch("requests.get", return_value=resp_200), \ + patch("builtins.input", return_value="n"): + with pytest.raises(SystemExit) as exc_info: + setup._check_and_replace_existing() + assert exc_info.value.code == 0 + + +def test_check_existing_user_accepts_backs_up_and_deletes(setup, tmp_path): + existing = {"username": "ISU_Cortex", "ownershipReportUrl": "https://old.example.com"} + resp_200 = MagicMock(status_code=200) + resp_200.raise_for_status = MagicMock() + resp_200.json.return_value = existing + resp_del = MagicMock(status_code=204) + resp_del.raise_for_status = MagicMock() + + setup.mark_done("configure") + assert setup.already_done("configure") + + with patch("requests.get", return_value=resp_200), \ + patch("requests.delete", return_value=resp_del) as mock_delete, \ + patch("builtins.input", return_value="y"), \ + patch("pathlib.Path.home", return_value=tmp_path): + setup._check_and_replace_existing() + + mock_delete.assert_called_once() + assert "configurations" in mock_delete.call_args.args[0] + assert not setup.already_done("configure") + + backup_file = tmp_path / ".cortex" / "solutions" / "workday" / "backup-config.json" + assert backup_file.exists() + assert json.loads(backup_file.read_text()) == existing + + +def test_configure_integration_posts_correct_payload(setup): + resp = MagicMock(ok=True, status_code=200) + with patch("requests.post", return_value=resp) as mock_post: + setup._configure_integration() + + mock_post.assert_called_once() + url = mock_post.call_args.args[0] + assert url.endswith("/api/v1/workday/configuration") + + payload = mock_post.call_args.kwargs["json"] + assert payload["reportMappingV2"]["type"] == "ONE_EMPLOYEE_MULTIPLE_TEAMS" + assert "pied-piper-supervisory-org" in payload["ownershipReportUrl"] + + +def test_configure_integration_raises_on_failure(setup): + resp = MagicMock(ok=False, status_code=400, text="Bad Request") + with patch("requests.post", return_value=resp): + with pytest.raises(RuntimeError, match="Failed to configure"): + setup._configure_integration() + + +def test_configure_integration_idempotent(setup, tmp_path): + setup.mark_done("configure") + with patch("requests.post") as mock_post: + setup._configure_integration() + mock_post.assert_not_called() + + +def test_validate_integration_success(setup, capsys): + resp = MagicMock(ok=True, status_code=200) + resp.raise_for_status = MagicMock() + resp.json.return_value = {"configurations": [{"isValid": True, "alias": "default"}]} + with patch("requests.post", return_value=resp): + setup._validate_integration() + assert "validated successfully" in capsys.readouterr().out + + +def test_validate_integration_raises_on_invalid(setup): + resp = MagicMock(ok=True, status_code=200) + resp.raise_for_status = MagicMock() + resp.json.return_value = {"configurations": [{"isValid": False, "message": "404 from URL", "alias": "default"}]} + with patch("requests.post", return_value=resp): + with pytest.raises(RuntimeError, match="Validation failed: 404 from URL"): + setup._validate_integration() + + +def test_validate_integration_raises_on_empty_result(setup): + resp = MagicMock(ok=True, status_code=200) + resp.raise_for_status = MagicMock() + resp.json.return_value = {"configurations": []} + with patch("requests.post", return_value=resp): + with pytest.raises(RuntimeError, match="no results"): + setup._validate_integration() + + +def test_steps_includes_auto_import(setup): + labels = [s[0] for s in setup.steps()] + assert "Enable Auto Import Workday teams" in labels + assert labels.index("Enable Auto Import Workday teams") == 0 + + +def test_auto_import_exits_when_user_declines(setup): + with patch("builtins.input", return_value="n"): + with pytest.raises(SystemExit) as exc_info: + setup._enable_auto_import() + assert exc_info.value.code == 0 + + +def test_auto_import_proceeds_when_user_confirms(setup): + with patch("builtins.input", return_value="y"): + setup._enable_auto_import() # should not raise + + +def test_steps_includes_validate(setup): + labels = [s[0] for s in setup.steps()] + assert "Validate Workday integration" in labels + + +def test_main_callable(mod): + assert callable(mod.main)